"""The parent chain: walking it, and refusing to close it (milestone #254 step 1).

Pure functions over a `{id: parent_id}` literal, so a whole hierarchy is one line
of setup and no database is involved. That is the reason the cascade lives in its
own import-free module — see services/design_cascade.py.
"""
from types import SimpleNamespace

from scribe.services.design_cascade import ancestry, resolve_tokens, would_cycle
from tests.helpers import design_token_stub


# --- ancestry ---------------------------------------------------------------

def test_ancestry_returns_the_chain_deepest_first():
    """Deepest first because that is the order resolution consumes it in: the
    system being resolved wins over its parent, which wins over the root."""
    parents = {1: None, 2: 1, 3: 2}
    assert ancestry(3, parents) == [3, 2, 1]


def test_a_root_is_its_own_whole_chain():
    """A family system has no parent, and that is an ordinary state — not an
    incomplete one. It must resolve to exactly itself."""
    assert ancestry(1, {1: None}) == [1]


def test_a_missing_parent_truncates_rather_than_raising():
    """A parent that was soft-deleted (or filtered out of the caller's scope) is
    a shorter chain, not a failed request. The alternative — raising — would make
    one deleted system break every descendant's rendering."""
    assert ancestry(3, {3: 2}) == [3, 2]


def test_a_system_absent_from_the_map_still_yields_itself():
    assert ancestry(9, {}) == [9]


def test_ancestry_terminates_on_a_cycle_instead_of_hanging():
    """LOAD-BEARING, and the reason a visited-set exists even though writes are
    guarded. A loop introduced by a direct DB edit or a future bug must degrade
    to a truncated chain: truncation shows up in the result, a hang shows up as
    an outage. Every id appears exactly once."""
    parents = {1: 3, 2: 1, 3: 2}
    chain = ancestry(1, parents)
    assert chain == [1, 3, 2]
    assert len(chain) == len(set(chain))


def test_ancestry_terminates_on_a_self_parent():
    assert ancestry(1, {1: 1}) == [1]


# --- would_cycle ------------------------------------------------------------

def test_clearing_the_parent_never_cycles():
    """None means "make this a root", which is always safe."""
    assert would_cycle(2, None, {1: None, 2: 1}) is False


def test_a_system_cannot_be_its_own_parent():
    assert would_cycle(1, 1, {1: None}) is True


def test_an_ordinary_reparent_is_allowed():
    """family <- app is the shape the whole model exists for; it must not trip
    the guard."""
    assert would_cycle(2, 1, {1: None, 2: None}) is False


def test_a_direct_swap_is_refused():
    """A -> B, then B -> A. The two-system case, and the one a UI produces first
    because both systems are on screen together."""
    parents = {1: None, 2: 1}
    assert would_cycle(1, 2, parents) is True


def test_an_indirect_loop_is_refused():
    """Three deep: root <- mid <- leaf, then root's parent set to leaf. Catching
    this is what makes the check a chain walk rather than a parent comparison."""
    parents = {1: None, 2: 1, 3: 2}
    assert would_cycle(1, 3, parents) is True


def test_reparenting_onto_a_sibling_subtree_is_allowed():
    """Two branches off one root. Moving one under the other is legitimate — the
    guard must refuse loops, not reorganisation."""
    parents = {1: None, 2: 1, 3: 1}
    assert would_cycle(3, 2, parents) is False


def test_the_guard_survives_a_hierarchy_that_is_already_corrupt():
    """If a cycle somehow already exists, the guard still has to answer rather
    than spin — the write path is exactly where such a hierarchy gets repaired."""
    parents = {1: 2, 2: 1, 3: None}
    assert would_cycle(3, 1, parents) is False
    assert would_cycle(1, 2, parents) is True


# --- resolution -------------------------------------------------------------
#
# The cascade proper (milestone #254 step 2). Hierarchies are stated as literals
# because resolve_tokens is pure and duck-typed — which is the whole reason it
# lives here rather than inside the service.

# A family (1) and an app inheriting from it (2) — the shape the model exists for.
FAMILY, APP = 1, 2
PARENTS = {FAMILY: None, APP: FAMILY}


def _by_name(tokens):
    return {t.name: t for t in tokens}


def test_a_system_with_no_tokens_of_its_own_inherits_the_whole_family_set():
    """The correct answer for an app that has not departed from the family yet —
    and the state every app system starts in."""
    resolved = resolve_tokens(
        APP, PARENTS,
        {FAMILY: [design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"})], APP: []},
    )
    assert [t.name for t in resolved] == ["--fs-obsidian"]
    assert resolved[0].value_by_mode == {"base": "#14171a"}
    assert resolved[0].origin_by_mode == {"base": FAMILY}


def test_the_deepest_system_wins_and_says_what_it_overrode():
    """Provenance is the point of the whole model: the effective set is just a
    list without it, and "where does this app depart from the family?" becomes a
    diff someone has to compute."""
    resolved = _by_name(resolve_tokens(
        APP, PARENTS,
        {
            FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#6b2118"})],
            APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#5b4a8a"})],
        },
    ))
    accent = resolved["--fs-accent"]
    assert accent.value_by_mode == {"base": "#5b4a8a"}
    assert accent.origin_by_mode == {"base": APP}
    # The shadowed ancestor is still there, behind the winner.
    assert [c.system_id for c in accent.contributions["base"]] == [APP, FAMILY]
    assert accent.contributions["base"][1].value == "#6b2118"


def test_overriding_one_mode_leaves_the_others_inherited():
    """LOAD-BEARING, and the argument that decided the storage shape. An accent
    deepened for light backgrounds while dark is left alone must own `base` and
    still inherit `dark` — which is why merging is per (name, MODE) and why the
    value column is a map rather than a pair of columns."""
    resolved = _by_name(resolve_tokens(
        APP, PARENTS,
        {
            FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#34a877", "dark": "#34a877"})],
            APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#15803d"})],
        },
    ))
    accent = resolved["--fs-accent"]
    assert accent.value_by_mode == {"base": "#15803d", "dark": "#34a877"}
    assert accent.origin_by_mode == {"base": APP, "dark": FAMILY}


def test_a_token_only_the_app_defines_is_not_an_override():
    """Introducing a token and overriding one are different acts, and a UI that
    labelled both "overridden here" would misdescribe the first."""
    resolved = _by_name(resolve_tokens(
        APP, PARENTS,
        {FAMILY: [], APP: [design_token_stub(name="--fs-editor-caret", value_by_mode={"base": "#5b4a8a"})]},
    ))
    caret = resolved["--fs-editor-caret"]
    assert caret.origin_by_mode == {"base": APP}
    assert caret.is_overridden_in(APP) is False


def test_is_overridden_in_is_true_only_for_the_system_that_shadowed():
    resolved = _by_name(resolve_tokens(
        APP, PARENTS,
        {
            FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#6b2118"})],
            APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#5b4a8a"})],
        },
    ))
    accent = resolved["--fs-accent"]
    assert accent.is_overridden_in(APP) is True
    assert accent.is_overridden_in(FAMILY) is False


def test_three_levels_stack_nearest_first():
    """A chain deeper than family->app, to prove the walk isn't a single hop."""
    parents = {1: None, 2: 1, 3: 2}
    resolved = _by_name(resolve_tokens(
        3, parents,
        {
            1: [design_token_stub(name="--fs-bg", value_by_mode={"base": "a"})],
            2: [design_token_stub(name="--fs-bg", value_by_mode={"base": "b"})],
            3: [design_token_stub(name="--fs-bg", value_by_mode={"base": "c"})],
        },
    ))
    bg = resolved["--fs-bg"]
    assert bg.value_by_mode == {"base": "c"}
    assert [c.value for c in bg.contributions["base"]] == ["c", "b", "a"]


def test_resolving_the_family_itself_ignores_its_children():
    """Inheritance runs one way. A family system resolved on its own must not
    pick up an app's overrides — otherwise every app would restyle the house."""
    resolved = _by_name(resolve_tokens(
        FAMILY, PARENTS,
        {
            FAMILY: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#6b2118"})],
            APP: [design_token_stub(name="--fs-accent", value_by_mode={"base": "#5b4a8a"})],
        },
    ))
    assert resolved["--fs-accent"].value_by_mode == {"base": "#6b2118"}


def test_value_for_falls_back_to_the_base_mode():
    """A token that isn't mode-dependent carries only `base`, and asking it for
    "dark" must yield that rather than nothing — the read rule the storage shape
    implies."""
    resolved = _by_name(resolve_tokens(
        FAMILY, PARENTS, {FAMILY: [design_token_stub(name="--fs-radius-md", value_by_mode={"base": "8px"})]},
    ))
    radius = resolved["--fs-radius-md"]
    assert radius.value_for("dark") == "8px"
    assert radius.value_for("base") == "8px"


def test_value_for_prefers_an_explicit_mode_over_the_fallback():
    resolved = _by_name(resolve_tokens(
        FAMILY, PARENTS,
        {FAMILY: [design_token_stub(name="--fs-bg", value_by_mode={"base": "#f7f5ef", "dark": "#14171a"})]},
    ))
    assert resolved["--fs-bg"].value_for("dark") == "#14171a"


def test_metadata_is_inherited_when_the_override_leaves_it_blank():
    """A child overriding a colour routinely says nothing about what the token is
    FOR. Inheriting the family's description beats blanking it — the override was
    about the value, not the meaning."""
    resolved = _by_name(resolve_tokens(
        APP, PARENTS,
        {
            FAMILY: [design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#14171a"}, group_name="surface", purpose="page bg, deepest surface")],
            APP: [design_token_stub(name="--fs-obsidian", value_by_mode={"base": "#101317"})],
        },
    ))
    obsidian = resolved["--fs-obsidian"]
    assert obsidian.group_name == "surface"
    assert obsidian.purpose == "page bg, deepest surface"
    assert obsidian.value_by_mode == {"base": "#101317"}   # the value still won


def test_an_override_that_states_metadata_wins_it_too():
    resolved = _by_name(resolve_tokens(
        APP, PARENTS,
        {
            FAMILY: [design_token_stub(name="--fs-x", value_by_mode={"base": "a"}, purpose="family says")],
            APP: [design_token_stub(name="--fs-x", value_by_mode={"base": "b"}, purpose="app says")],
        },
    ))
    assert resolved["--fs-x"].purpose == "app says"


def test_an_override_at_default_order_keeps_the_familys_position():
    """order_index 0 is the column DEFAULT, so it reads as unstated. Treating it
    as "first" would let a colour-only override drag its token to the top of the
    group — a visible reshuffle nobody asked for."""
    resolved = _by_name(resolve_tokens(
        APP, PARENTS,
        {
            FAMILY: [design_token_stub(name="--fs-x", value_by_mode={"base": "a"}, order_index=7)],
            APP: [design_token_stub(name="--fs-x", value_by_mode={"base": "b"})],
        },
    ))
    assert resolved["--fs-x"].order_index == 7


def test_the_effective_set_is_ordered_by_group_then_position_with_ungrouped_last():
    resolved = resolve_tokens(
        FAMILY, PARENTS,
        {FAMILY: [
            design_token_stub(name="--fs-z", value_by_mode={"base": "1"}),                                  # ungrouped
            design_token_stub(name="--fs-b", value_by_mode={"base": "2"}, group_name="text", order_index=1),
            design_token_stub(name="--fs-a", value_by_mode={"base": "3"}, group_name="surface", order_index=2),
            design_token_stub(name="--fs-c", value_by_mode={"base": "4"}, group_name="surface", order_index=1),
        ]},
    )
    assert [t.name for t in resolved] == ["--fs-c", "--fs-a", "--fs-b", "--fs-z"]


def test_resolution_terminates_on_a_corrupt_hierarchy():
    """Resolution inherits ancestry's visited-set. A loop reaching this function
    must produce a truncated set, not a hung request — the defensive half of the
    cycle guard, exercised end to end."""
    parents = {1: 2, 2: 1}
    resolved = _by_name(resolve_tokens(
        1, parents,
        {1: [design_token_stub(name="--fs-a", value_by_mode={"base": "one"})], 2: [design_token_stub(name="--fs-b", value_by_mode={"base": "two"})]},
    ))
    assert set(resolved) == {"--fs-a", "--fs-b"}
    # Each system contributes exactly once, not endlessly.
    assert len(resolved["--fs-a"].contributions["base"]) == 1


# --- supersedes -------------------------------------------------------------
#
# The declaration that replaces a prohibition. A design system stores what things
# ARE, so "pure white is never text" has no row — but "write this token instead
# of #fff" does, and it is the same fact stated forwards.

def test_supersedes_is_inherited_when_the_override_is_silent_about_it():
    """LOAD-BEARING. A child overriding a colour says nothing about which
    literals it replaces, and blanking the family's declaration there would
    silently disarm the check for every app that customises the token."""
    resolved = _by_name(resolve_tokens(
        APP, PARENTS,
        {
            FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"])],
            APP: [design_token_stub(name="--fs-text", value_by_mode={"base": "#f0ece0"})],
        },
    ))
    text = resolved["--fs-text"]
    assert text.supersedes == ("#fff", "#ffffff")
    assert text.value_by_mode == {"base": "#f0ece0"}   # the value still overrode


def test_an_override_that_states_its_own_supersedes_replaces_the_list():
    """Whole-list replacement, not a merge — an app that means "only #fff" must
    be able to say so without inheriting entries it deliberately dropped."""
    resolved = _by_name(resolve_tokens(
        APP, PARENTS,
        {
            FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "a"}, supersedes=["#fff", "#ffffff"])],
            APP: [design_token_stub(name="--fs-text", value_by_mode={"base": "b"}, supersedes=["#fff"])],
        },
    ))
    assert resolved["--fs-text"].supersedes == ("#fff",)


def test_a_token_that_supersedes_nothing_resolves_to_an_empty_tuple():
    """Most tokens replace nothing. That has to be an empty sequence rather than
    None, so no caller has to test for two kinds of nothing."""
    resolved = _by_name(resolve_tokens(
        FAMILY, PARENTS, {FAMILY: [design_token_stub(name="--fs-radius-md", value_by_mode={"base": "8px"})]},
    ))
    assert resolved["--fs-radius-md"].supersedes == ()


def test_supersedes_survives_serialisation_as_a_list():
    resolved = _by_name(resolve_tokens(
        FAMILY, PARENTS,
        {FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff"])]},
    ))
    assert resolved["--fs-text"].to_dict()["supersedes"] == ["#fff"]


def test_the_superseded_literal_need_not_match_the_tokens_own_value():
    """The whole reason this is DECLARED rather than derived. `#fff` and
    Parchment are different colours, so a value-matching rule could never have
    connected them — which is why the prohibition looked unrepresentable until
    it was turned around."""
    resolved = _by_name(resolve_tokens(
        FAMILY, PARENTS,
        {FAMILY: [design_token_stub(name="--fs-text", value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff"])]},
    ))
    text = resolved["--fs-text"]
    assert text.value_by_mode["base"] not in text.supersedes


def test_rows_without_a_supersedes_attribute_at_all_still_resolve():
    """Duck-typed input: a caller passing rows from before the column existed
    must not crash the cascade."""
    legacy = SimpleNamespace(
        name="--fs-x", value_by_mode={"base": "a"},
        group_name=None, purpose=None, order_index=0,
    )
    resolved = _by_name(resolve_tokens(FAMILY, PARENTS, {FAMILY: [legacy]}))
    assert resolved["--fs-x"].supersedes == ()


# --- rationale --------------------------------------------------------------

def test_rationale_cascades_like_purpose_and_is_a_different_question():
    """`purpose` is what the token is FOR; `rationale` is why it is this value.
    Rules carry the second routinely ("Success = Moss, aligned by design") and a
    token row had nowhere to put it until now."""
    resolved = _by_name(resolve_tokens(
        APP, PARENTS,
        {
            FAMILY: [SimpleNamespace(
                name="--fs-success", value_by_mode={"base": "#4a5d3f"},
                group_name="semantic", purpose="success states",
                rationale="equals Moss, aligned by design",
                order_index=0, supersedes=[],
            )],
            APP: [design_token_stub(name="--fs-success", value_by_mode={"base": "#3f5236"})],
        },
    ))
    token = resolved["--fs-success"]
    assert token.rationale == "equals Moss, aligned by design"
    assert token.purpose == "success states"
    assert token.value_by_mode == {"base": "#3f5236"}   # the value still overrode


def test_a_token_without_a_rationale_resolves_to_none():
    resolved = _by_name(resolve_tokens(
        FAMILY, PARENTS, {FAMILY: [design_token_stub(name="--fs-x", value_by_mode={"base": "1px"})]},
    ))
    assert resolved["--fs-x"].rationale is None
