"""The design-system cascade, as pure functions over already-loaded rows.

Deliberately free of every database and service import — including
`services/access.py`, which needs `ancestry` to answer "can this caller read
this system?" and would otherwise form an import cycle with the service that
needs `access` back. A module that imports nothing can be imported by both.

Being pure is also what makes the cascade rule testable without a database:
these take a plain `{id: parent_id}` map, so a test states the shape of a
hierarchy in one literal instead of building one.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass

# The mode key that applies when no more specific one does. Emitted CSS puts it
# on the base selector and every other key on a mode selector, mirroring how a
# stylesheet is actually written: light on `:root`, dark layered over it.
BASE_MODE = "base"


def ancestry(system_id: int, parents: Mapping[int, int | None]) -> list[int]:
    """The inheritance chain from `system_id` up to its root, nearest first.

    Includes `system_id` itself at index 0, because resolution wants
    deepest-to-shallowest and the system being resolved is the deepest link.

    A system missing from `parents` terminates the chain rather than raising: a
    parent whose row was soft-deleted or filtered out is a truncated chain, not
    a failed request.

    The visited-set is defensive, not the primary guard — writes already refuse
    to create a cycle (`would_cycle`). It is here because a loop introduced by a
    direct DB edit or a future bug must degrade to a truncated chain instead of
    spinning forever. Truncation shows up in the result; a hang shows up as an
    outage.
    """
    chain: list[int] = []
    seen: set[int] = set()
    current: int | None = system_id
    while current is not None and current not in seen:
        seen.add(current)
        chain.append(current)
        current = parents.get(current)
    return chain


def would_cycle(
    system_id: int,
    proposed_parent_id: int | None,
    parents: Mapping[int, int | None],
) -> bool:
    """Would making `proposed_parent_id` the parent of `system_id` close a loop?

    True when the proposed parent IS the system, or already inherits from it.

    The walk goes UP from the proposed parent, which is the cheap direction —
    each system has at most one parent, so the chain is a line. Asking the
    equivalent downward question ("is the proposed parent among my
    descendants?") would mean searching a whole forest for the same answer.

    `proposed_parent_id=None` clears the parent and can never cycle.
    """
    if proposed_parent_id is None:
        return False
    if proposed_parent_id == system_id:
        return True
    return system_id in ancestry(proposed_parent_id, parents)


# ---------------------------------------------------------------------------
# Resolution — flattening a chain into an effective token set
# ---------------------------------------------------------------------------

@dataclass(frozen=True)
class Contribution:
    """One system's offer for one token in one mode."""
    system_id: int
    value: str


@dataclass(frozen=True)
class ResolvedToken:
    """A token after the cascade, carrying the whole argument rather than the verdict.

    `contributions` holds every system that supplied a value, per mode, DEEPEST
    FIRST — so `[0]` is the winner and `[1:]` are what it shadowed. Storing the
    contest rather than a winner plus a separate provenance field means there is
    nothing to keep in sync: "which system supplied this?" and "what did it
    override?" are both reads of the same list, and they cannot disagree.

    Provenance is per MODE, not per token, because overriding is. A system that
    deepens one accent for light backgrounds while leaving dark alone owns the
    base value and inherits the dark one, and a token-level "overridden here"
    flag would have to lie about one of them.
    """
    name: str
    contributions: dict[str, tuple[Contribution, ...]]
    group_name: str | None
    purpose: str | None
    rationale: str | None
    supersedes: tuple[str, ...]
    order_index: int

    @property
    def value_by_mode(self) -> dict[str, str]:
        """The effective value for each mode — the winner of each contest."""
        return {mode: entries[0].value for mode, entries in self.contributions.items()}

    @property
    def origin_by_mode(self) -> dict[str, int]:
        """Which system supplied each mode's effective value."""
        return {mode: entries[0].system_id for mode, entries in self.contributions.items()}

    def value_for(self, mode: str) -> str | None:
        """The value to render in `mode`, falling back to the base mode.

        This is the read rule the storage shape implies: a token that is not
        mode-dependent carries only `base`, and asking it for "dark" must yield
        the base value rather than nothing.
        """
        entries = self.contributions.get(mode) or self.contributions.get(BASE_MODE)
        return entries[0].value if entries else None

    def to_dict(self) -> dict:
        """Payload shape for both surfaces — and it carries the SHADOWED entries.

        Serialising only the winner would throw away the provenance at the last
        step, which is the one thing this type exists to preserve. `contributions`
        is the audit trail; `value_by_mode` / `origin_by_mode` are alongside it so
        a client renders without re-deriving anything, and cannot derive it
        differently.
        """
        return {
            "name": self.name,
            "group_name": self.group_name,
            "purpose": self.purpose,
            "rationale": self.rationale,
            "supersedes": list(self.supersedes),
            "order_index": self.order_index,
            "value_by_mode": self.value_by_mode,
            "origin_by_mode": self.origin_by_mode,
            "contributions": {
                mode: [
                    {"system_id": c.system_id, "value": c.value} for c in entries
                ]
                for mode, entries in self.contributions.items()
            },
        }

    def is_overridden_in(self, system_id: int) -> bool:
        """Does `system_id` win any mode of this token AND shadow something?

        The distinction the UI needs: a token this system introduced is not an
        override, and a token it merely inherits is not either.
        """
        return any(
            len(entries) > 1 and entries[0].system_id == system_id
            for entries in self.contributions.values()
        )


def _sort_key(token: ResolvedToken) -> tuple:
    # Ungrouped tokens sort last rather than first: a design system that has
    # started grouping should read as its groups, with the not-yet-filed
    # remainder at the end.
    return (token.group_name is None, token.group_name or "", token.order_index, token.name)


def resolve_tokens(
    system_id: int,
    parents: Mapping[int, int | None],
    tokens_by_system: Mapping[int, Sequence],
) -> list[ResolvedToken]:
    """Flatten a system's inheritance chain into its effective token set.

    Walks from `system_id` up to the root and applies tokens by name, deepest
    winning. That is the CSS cascade — precedence by name along a parent chain —
    rather than an analogy to it, which is why the storage model and the
    stylesheet model came out the same shape.

    `tokens_by_system` maps a system id to its own token rows. Any object with
    `.name`, `.value_by_mode`, `.group_name`, `.purpose` and `.order_index` will
    do, so a test can state a hierarchy in literals and the service can pass ORM
    rows to the same function.

    The result includes tokens the system never mentions — inheriting one is
    what puts it in the effective set. A system with no tokens of its own
    resolves to its parent's set entire, which is the correct answer for an app
    that has not departed from the family yet.

    Merging is per (name, MODE): a child that supplies only a dark value
    overrides only dark and keeps inheriting base. Metadata (`group_name`,
    `purpose`, `order_index`) cascades separately by the same deepest-wins rule,
    since a child overriding a value routinely leaves the family's description
    of what the token is FOR untouched — and inheriting it beats blanking it.
    """
    chain = ancestry(system_id, parents)

    contributions: dict[str, dict[str, list[Contribution]]] = {}
    metadata: dict[str, dict[str, object]] = {}

    # Deepest first, so the first contribution seen for a (name, mode) wins and
    # every later one is a shadowed ancestor appended behind it.
    for depth_system_id in chain:
        for token in tokens_by_system.get(depth_system_id) or ():
            per_mode = contributions.setdefault(token.name, {})
            for mode, value in (token.value_by_mode or {}).items():
                per_mode.setdefault(mode, []).append(
                    Contribution(system_id=depth_system_id, value=value)
                )

            meta = metadata.setdefault(
                token.name,
                {
                    "group_name": None, "purpose": None, "rationale": None,
                    "supersedes": None, "order_index": None,
                },
            )
            for field in ("group_name", "purpose", "rationale"):
                if meta[field] is None:
                    meta[field] = getattr(token, field, None)
            # order_index alone treats 0 as UNSTATED rather than "first",
            # because 0 is the column default. Reading it as a real value would
            # let any child override drag its token to the top of the group and
            # lose the family's ordering — a visible reshuffle in return for a
            # change that only touched a colour.
            if not meta["order_index"]:
                meta["order_index"] = getattr(token, "order_index", 0) or None
            # `supersedes` cascades on EMPTINESS, not on None: a child that
            # overrides a colour and says nothing about which literals it
            # replaces should keep the family's declaration, and an empty list
            # is what "said nothing" looks like once the column is NOT NULL.
            # A child that states its own list replaces the whole thing.
            if not meta["supersedes"]:
                meta["supersedes"] = tuple(getattr(token, "supersedes", None) or ()) or None

    resolved = [
        ResolvedToken(
            name=name,
            contributions={
                mode: tuple(entries) for mode, entries in per_mode.items()
            },
            group_name=metadata[name]["group_name"],
            purpose=metadata[name]["purpose"],
            rationale=metadata[name]["rationale"],
            supersedes=metadata[name]["supersedes"] or (),
            order_index=metadata[name]["order_index"] or 0,
        )
        for name, per_mode in contributions.items()
    ]
    return sorted(resolved, key=_sort_key)
