"""Stylesheet parser + cascade tests.

The §10 ¶2 contract — "last-wins applies per declaration, not per rule"
— gets its own dedicated test (`test_last_wins_per_declaration`).
Integration vs. the `workflows/stylesheet-cascade.dot` fixture lives at
the bottom; it pins the documented expected configs per node.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from attractor.workflow import (
    NodeIdentity,
    Selector,
    SelectorKind,
    Stylesheet,
    StylesheetParseError,
    parse,
)

# ─── parser ────────────────────────────────────────────────────────


# [unit->REQ-ROUTING-STYLESHEET]
def test_stylesheet_parses_universal_rule() -> None:
    """`* { ... }` parses as a UNIVERSAL selector."""
    sheet = Stylesheet.parse("* { model: claude-haiku-4-5; }")
    assert len(sheet.rules) == 1
    rule = sheet.rules[0]
    assert rule.selector == Selector(SelectorKind.UNIVERSAL)
    assert len(rule.declarations) == 1
    assert rule.declarations[0].name == "model"
    assert rule.declarations[0].value == "claude-haiku-4-5"


# [unit->REQ-ROUTING-STYLESHEET]
def test_stylesheet_parses_class_rule() -> None:
    """`.name { ... }` parses as a CLASS selector with that name."""
    sheet = Stylesheet.parse(".heavy { model: claude-sonnet-4-6; reasoning_effort: high; }")
    assert len(sheet.rules) == 1
    rule = sheet.rules[0]
    assert rule.selector == Selector(SelectorKind.CLASS, "heavy")
    assert [(d.name, d.value) for d in rule.declarations] == [
        ("model", "claude-sonnet-4-6"),
        ("reasoning_effort", "high"),
    ]


# [unit->REQ-ROUTING-STYLESHEET]
def test_stylesheet_parses_id_rule() -> None:
    """`#name { ... }` parses as an ID selector with that name."""
    sheet = Stylesheet.parse("#critical_node { model: claude-opus-4-7; }")
    assert len(sheet.rules) == 1
    rule = sheet.rules[0]
    assert rule.selector == Selector(SelectorKind.ID, "critical_node")


# [unit->REQ-ROUTING-STYLESHEET]
def test_stylesheet_parser_tolerates_missing_trailing_semicolon() -> None:
    """A rule may end without a trailing `;` before `}`."""
    sheet = Stylesheet.parse("* { model: claude-haiku-4-5 }")
    assert len(sheet.rules) == 1
    assert sheet.rules[0].declarations[0].value == "claude-haiku-4-5"


# [unit->REQ-ROUTING-STYLESHEET]
def test_stylesheet_parser_rejects_missing_brace() -> None:
    """A rule without `{` fails with a clear error."""
    with pytest.raises(StylesheetParseError):
        Stylesheet.parse("* model: claude-haiku-4-5; ")


# [unit->REQ-ROUTING-STYLESHEET]
def test_stylesheet_parser_rejects_empty_value() -> None:
    """`model: ;` is rejected — empty values are configuration errors."""
    with pytest.raises(StylesheetParseError):
        Stylesheet.parse("* { model: ; }")


# [unit->REQ-ROUTING-STYLESHEET]
def test_stylesheet_parser_rejects_unknown_selector_kind() -> None:
    """`@media { ... }` or other CSS extensions are rejected."""
    with pytest.raises(StylesheetParseError):
        Stylesheet.parse("@media { model: x; }")


# [unit->REQ-ROUTING-STYLESHEET]
def test_stylesheet_parses_empty_source() -> None:
    """An empty source parses to an empty rule list — `resolve()` returns
    an empty dict for every node, which the engine treats as "no
    stylesheet-set declarations".
    """
    sheet = Stylesheet.parse("")
    assert sheet.rules == []


# ─── cascade: specificity ──────────────────────────────────────────


# [unit->REQ-ROUTING-STYLESHEET]
def test_resolve_universal_only() -> None:
    """A `*` rule applies to every node, including ones without classes."""
    sheet = Stylesheet.parse("* { model: claude-haiku-4-5; }")
    resolved = sheet.resolve(NodeIdentity(id="anything", class_=None))
    assert resolved == {"model": "claude-haiku-4-5"}


# [unit->REQ-ROUTING-STYLESHEET]
def test_resolve_class_beats_universal() -> None:
    """A class selector that matches overrides `*` on shared declarations."""
    sheet = Stylesheet.parse(
        "* { model: claude-haiku-4-5; } "
        ".heavy { model: claude-sonnet-4-6; }"
    )
    resolved = sheet.resolve(NodeIdentity(id="x", class_="heavy"))
    assert resolved["model"] == "claude-sonnet-4-6"


# [unit->REQ-ROUTING-STYLESHEET]
def test_resolve_id_beats_class_and_universal() -> None:
    """An id selector that matches overrides class and universal on
    shared declarations."""
    sheet = Stylesheet.parse(
        "* { model: claude-haiku-4-5; } "
        ".heavy { model: claude-sonnet-4-6; } "
        "#critical { model: claude-opus-4-7; }"
    )
    resolved = sheet.resolve(NodeIdentity(id="critical", class_="heavy"))
    assert resolved["model"] == "claude-opus-4-7"


# [unit->REQ-ROUTING-STYLESHEET]
def test_resolve_class_rule_does_not_match_other_classes() -> None:
    """A `.review` rule does not affect a `.coding` node."""
    sheet = Stylesheet.parse(
        "* { model: haiku; } "
        ".review { model: gpt-5; }"
    )
    resolved = sheet.resolve(NodeIdentity(id="x", class_="coding"))
    assert resolved == {"model": "haiku"}


# [unit->REQ-ROUTING-STYLESHEET]
def test_resolve_id_rule_does_not_match_other_ids() -> None:
    """`#foo` does not affect node `bar`."""
    sheet = Stylesheet.parse(
        "* { model: haiku; } "
        "#foo { model: opus; }"
    )
    resolved = sheet.resolve(NodeIdentity(id="bar", class_=None))
    assert resolved == {"model": "haiku"}


# ─── cascade: §10 ¶2 last-wins-per-DECLARATION ─────────────────────


# [unit->REQ-ROUTING-STYLESHEET]
# [unit->REQ-EXEC-CONFIG-CASCADE]
def test_last_wins_per_declaration_inside_bucket() -> None:
    """The §10 ¶2 test contract — the load-bearing rule for routing.

    A later `.heavy { model: X }` overrides an earlier
    `.heavy { model: Y; reasoning_effort: high }` only on `model`; the
    earlier rule's `reasoning_effort` survives. Rules contribute
    declarations into the effective config, NOT wholesale replacement.
    """
    sheet = Stylesheet.parse(
        ".heavy { model: claude-sonnet-4-6; reasoning_effort: high; } "
        ".heavy { reasoning_effort: medium; }"
    )
    resolved = sheet.resolve(NodeIdentity(id="x", class_="heavy"))
    assert resolved["model"] == "claude-sonnet-4-6"
    assert resolved["reasoning_effort"] == "medium"


# [unit->REQ-ROUTING-STYLESHEET]
def test_last_wins_per_declaration_at_universal_bucket() -> None:
    """Same rule applies to the universal bucket."""
    sheet = Stylesheet.parse(
        "* { model: haiku; reasoning_effort: low; } "
        "* { model: sonnet; }"
    )
    resolved = sheet.resolve(NodeIdentity(id="x", class_=None))
    assert resolved == {"model": "sonnet", "reasoning_effort": "low"}


# [unit->REQ-ROUTING-STYLESHEET]
def test_higher_specificity_only_overrides_shared_declarations() -> None:
    """An id rule that only sets `model` doesn't clobber a class rule's
    `reasoning_effort`. (Per the stylesheet-cascade.dot fixture: opus
    from #id wins for model, but reasoning_effort comes from .heavy.)
    """
    sheet = Stylesheet.parse(
        ".heavy { model: claude-sonnet-4-6; reasoning_effort: medium; } "
        "#critical_node { model: claude-opus-4-7; }"
    )
    resolved = sheet.resolve(NodeIdentity(id="critical_node", class_="heavy"))
    assert resolved == {
        "model": "claude-opus-4-7",
        "reasoning_effort": "medium",
    }


# ─── stylesheet-cascade.dot integration ────────────────────────────


# [int->REQ-ROUTING-STYLESHEET]
# [int->REQ-EXEC-CONFIG-CASCADE]
def test_stylesheet_cascade_fixture_matches_documented_configs(workflows_dir: Path) -> None:
    """End-to-end: parse `workflows/stylesheet-cascade.dot`, extract the
    `model_stylesheet` attribute, resolve against each node, and assert
    the resolved configs match the documented values in the fixture's
    header comment.

    Per the fixture:

        universal_node       model=haiku-4-5,  reasoning_effort=low
        classed_node         model=sonnet-4-6, reasoning_effort=medium
        critical_node        model=opus-4-7,   reasoning_effort=medium
        override_node        (per-node attrs win, but the stylesheet
                              cascade alone yields sonnet-4-6/medium —
                              the engine applies per-node attrs on top)
    """
    src = (workflows_dir / "stylesheet-cascade.dot").read_text(encoding="utf-8")
    graph = parse(src)
    sheet_src = graph.attributes["model_stylesheet"]
    sheet = Stylesheet.parse(sheet_src)

    by_id = {n.id: n for n in graph.nodes}

    # universal_node: only `*` matches
    resolved = sheet.resolve(
        NodeIdentity(id="universal_node", class_=by_id["universal_node"].class_)
    )
    assert resolved == {
        "model": "anthropic/claude-haiku-4-5",
        "reasoning_effort": "low",
    }

    # classed_node: .heavy beats *; second .heavy rule rewrites
    # reasoning_effort (last-wins-per-declaration).
    resolved = sheet.resolve(
        NodeIdentity(id="classed_node", class_=by_id["classed_node"].class_)
    )
    assert resolved == {
        "model": "anthropic/claude-sonnet-4-6",
        "reasoning_effort": "medium",
    }

    # critical_node: #id beats .class for model (opus); reasoning_effort
    # comes from .heavy (medium, last-wins) since #critical_node doesn't
    # set it.
    resolved = sheet.resolve(
        NodeIdentity(id="critical_node", class_=by_id["critical_node"].class_)
    )
    assert resolved == {
        "model": "anthropic/claude-opus-4-7",
        "reasoning_effort": "medium",
    }

    # override_node: stylesheet alone — class .heavy resolves to
    # sonnet/medium. Per-node attrs (model="openai/gpt-5.2",
    # reasoning_effort=high) are layered on top by the ENGINE, not by
    # `resolve()`.
    resolved = sheet.resolve(
        NodeIdentity(id="override_node", class_=by_id["override_node"].class_)
    )
    assert resolved == {
        "model": "anthropic/claude-sonnet-4-6",
        "reasoning_effort": "medium",
    }
