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

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 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" and a nonnegative integer "cost_cents".
    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, 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.

    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.

    Neither rows nor excluded_channels are mutated.

    Returns a dict with selected_ids, spent_cents and remaining_cents.
    Raises ValueError for negative or non-integer (including bool) budgets.
    """
    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)

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

    remaining = budget_cents
    spent = 0
    selected_ids = []

    for row in candidates:
        cost = row["cost_cents"]
        if cost <= remaining:
            selected_ids.append(row["id"])
            remaining -= cost
            spent += cost

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