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

import re
from decimal import Decimal, ROUND_HALF_UP, localcontext

_PRICE_PATTERN = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)", re.ASCII)


def _is_valid_int(value):
    return isinstance(value, int) and not isinstance(value, bool)


def _normalize_channel(value):
    return value.strip().casefold()


def _price_to_cents(price):
    """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 _PRICE_PATTERN.fullmatch(text):
        raise ValueError("price must be a finite decimal string")
    amount = Decimal(text)
    if amount < 0:
        raise ValueError("price must be nonnegative")
    with localcontext() as ctx:
        ctx.prec = len(text) + 5
        cents = (amount * 100).quantize(Decimal(1), rounding=ROUND_HALF_UP)
    return int(cents)


def _row_cost_cents(row):
    """Return the 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_valid_int(cost) or cost < 0:
        raise ValueError("cost_cents must be a nonnegative integer")
    return cost


def plan(rows, budget_cents, excluded_channels=()):
    """Select proposals by descending priority while they fit the budget.

    Each row is a mapping with a string "id" and a cost. A row's cost is
    its "price" (a finite nonnegative decimal string in dollars, whitespace
    allowed, converted to integer cents with ROUND_HALF_UP per row) when a
    "price" key is present; otherwise it is the legacy nonnegative integer
    "cost_cents", which must be present.

    Only the first occurrence of each id is considered; later rows with an
    already-seen id are ignored, even if the first occurrence was archived,
    excluded by channel or too expensive. After deduplication, rows are
    skipped when:
      - "archived" is true (missing "archived" means false), or
      - the row's "channel" matches an entry in excluded_channels after
        normalizing both with strip().casefold() (a missing channel is
        treated as an empty string).
    Costs are validated only for the remaining eligible rows, before any
    budget selection; ValueError is raised for invalid costs.

    The eligible rows are considered in descending integer "priority"
    (missing priority means zero); equal priorities 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.

    excluded_channels is a list or tuple of strings; an empty one excludes
    nothing.

    Returns a dict with selected_ids (in selection order), spent_cents and
    remaining_cents. Neither rows nor excluded_channels are mutated.
    """
    if not _is_valid_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(ch) for ch 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

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

        cost = _row_cost_cents(row)

        priority = row.get("priority", 0)
        if not _is_valid_int(priority):
            raise ValueError("priority must be an integer")

        candidates.append((priority, row_id, cost))

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

    remaining = budget_cents
    selected_ids = []

    for _priority, row_id, cost in candidates:
        if cost <= remaining:
            selected_ids.append(row_id)
            remaining -= cost

    return {
        "selected_ids": selected_ids,
        "spent_cents": budget_cents - remaining,
        "remaining_cents": remaining,
    }
```