```python
"""planner.py - greedy selection of fictional campaign proposals."""

from decimal import Context, Decimal, ROUND_HALF_UP, InvalidOperation
from decimal import MAX_EMAX, MIN_EMIN
from typing import Any, Iterable, Mapping


def _is_int(value: Any) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def _normalize_channel(value: str) -> str:
    return value.strip().casefold()


def _price_to_cents(price: Any) -> int:
    """Convert a dollar decimal string to integer cents (ROUND_HALF_UP)."""
    if not isinstance(price, str):
        raise ValueError("price must be a string")
    text = price.strip()
    if not text or "_" in text:
        raise ValueError("malformed price")
    try:
        amount = Decimal(text)
    except InvalidOperation:
        raise ValueError("malformed price") from None
    if not amount.is_finite():
        raise ValueError("price must be finite")
    if amount < 0:
        raise ValueError("price must be nonnegative")
    try:
        ctx = Context(
            prec=max(28, len(amount.as_tuple().digits) + 5),
            rounding=ROUND_HALF_UP,
            Emax=MAX_EMAX,
            Emin=MIN_EMIN,
        )
        cents = amount.scaleb(2, context=ctx).to_integral_value(
            rounding=ROUND_HALF_UP
        )
        return int(cents)
    except (InvalidOperation, OverflowError, ValueError):
        raise ValueError("price out of range") from None


def _row_cost_cents(row: Mapping[str, Any]) -> int:
    """Return a row's cost in integer cents, validating it."""
    if "price" in row:
        return _price_to_cents(row["price"])
    if "cost_cents" not in row:
        raise ValueError("row has neither price nor cost_cents")
    cost = row["cost_cents"]
    if not _is_int(cost):
        raise ValueError("cost_cents must be an integer")
    if cost < 0:
        raise ValueError("cost_cents must be nonnegative")
    return cost


def plan(rows: Iterable[Mapping[str, Any]], budget_cents: int, excluded_channels=()):
    """Select rows by descending priority while they fit in the remaining budget.

    Each row has a string "id". Its cost is the "price" key if present (a
    finite nonnegative decimal string in dollars, whitespace allowed,
    converted to integer cents with Decimal and ROUND_HALF_UP, each row
    individually); otherwise it is the legacy integer "cost_cents", which
    is required on legacy rows.

    Only the first occurrence of each id is considered; later rows with the
    same id are ignored, even if the first occurrence is archived, in an
    excluded channel, or too expensive. Deduplication happens before any
    filtering, validation, ordering, or budgeting.

    Rows with a true "archived" value are excluded (missing archived means
    false). Rows whose channel matches an entry in excluded_channels are
    skipped; both sides are normalized with strip().casefold(), and a missing
    channel is treated as an empty string. An empty excluded_channels
    excludes nothing.

    Costs are validated only for eligible rows (after deduplication and
    filtering) and before budget selection; malformed duplicate, archived or
    excluded rows are ignored.

    The remaining rows are considered in descending integer "priority" order
    (missing priority means zero). Rows with equal priority keep their
    original input order. A row is selected if its cost is at most the
    remaining budget; otherwise it is skipped and later rows are still
    considered. selected_ids are returned in selection order, and
    skipped_ids lists eligible rows that were too expensive at their turn,
    in the same traversal order. Duplicates, archived rows and rows in
    excluded channels never appear in skipped_ids.

    Neither rows nor excluded_channels are mutated.

    Returns a dict with exactly four keys: selected_ids, spent_cents,
    remaining_cents and skipped_ids.
    Raises ValueError for negative or non-integer (including bool) budgets,
    and for invalid prices or legacy costs on eligible rows.
    """
    if not _is_int(budget_cents):
        raise ValueError("budget_cents must be an integer")
    if budget_cents < 0:
        raise ValueError("budget_cents must be nonnegative")

    excluded = {_normalize_channel(channel) for channel in excluded_channels}

    seen_ids = set()
    candidates = []

    for row in rows:
        row_id = row["id"]
        if row_id in seen_ids:
            continue
        seen_ids.add(row_id)

        if row.get("archived", False):
            continue

        channel = _normalize_channel(row.get("channel", ""))
        if channel in excluded:
            continue

        candidates.append(row)

    # Validate and convert costs for all eligible rows before selecting.
    priced = [(row, _row_cost_cents(row)) for row in candidates]

    # sorted() is stable, so equal priorities keep their original input order.
    priced = sorted(priced, key=lambda item: -item[0].get("priority", 0))

    remaining = budget_cents
    spent = 0
    selected_ids = []
    skipped_ids = []

    for row, cost in priced:
        if cost <= remaining:
            selected_ids.append(row["id"])
            remaining -= cost
            spent += cost
        else:
            skipped_ids.append(row["id"])

    return {
        "selected_ids": selected_ids,
        "spent_cents": spent,
        "remaining_cents": remaining,
        "skipped_ids": skipped_ids,
    }
```