```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 in input order 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 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.

    A row is selected if its cost is at most the remaining budget; otherwise
    it is skipped and later rows are still considered.

    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}

    remaining = budget_cents
    spent = 0
    selected_ids = []
    seen_ids = set()

    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

        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,
    }
```