"""anu_v3.track_loop_state — track-level loop state machine (task-2553+17, 9-R.3).

13-state transition table for a single track inside a parallel batch.

Authority: task-2553+17.md §4(9) + §12 9-R.3.

States (13)
-----------
PLANNED, DISPATCHED, RUNNING, NORMAL_COLLECTOR_COMPLETED, CODEX_AUDIT_PENDING,
ANU_ADJUDICATION_PENDING, AUTO_MICRO_FIX, RETRY_WITHIN_SCOPE, ACCEPTED,
LANDING_PENDING, MERGE_READY, MERGED, HOLD_FOR_CHAIR.

Terminal       : ACCEPTED, MERGED, HOLD_FOR_CHAIR.
Retry ceiling  : RETRY_WITHIN_SCOPE may be entered at most ``retry_ceiling``
                 times (per profile/goal); over the ceiling the only legal
                 successor is HOLD_FOR_CHAIR.
Precedence     : HOLD_FOR_CHAIR > RETRY_WITHIN_SCOPE > AUTO_MICRO_FIX.
Callback prec. : if NORMAL_COLLECTOR_COMPLETED has already been reached, a
                 later fallback callback is classified DUPLICATE_CALLBACK_IGNORED
                 (the state machine never regresses out of / past it for a
                 fallback event).

This module is pure (stdlib only) and never mutates any existing tracked file.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Dict, List, Tuple

# ---------------------------------------------------------------------------
# State constants
# ---------------------------------------------------------------------------

PLANNED = "PLANNED"
DISPATCHED = "DISPATCHED"
RUNNING = "RUNNING"
NORMAL_COLLECTOR_COMPLETED = "NORMAL_COLLECTOR_COMPLETED"
CODEX_AUDIT_PENDING = "CODEX_AUDIT_PENDING"
ANU_ADJUDICATION_PENDING = "ANU_ADJUDICATION_PENDING"
AUTO_MICRO_FIX = "AUTO_MICRO_FIX"
RETRY_WITHIN_SCOPE = "RETRY_WITHIN_SCOPE"
ACCEPTED = "ACCEPTED"
LANDING_PENDING = "LANDING_PENDING"
MERGE_READY = "MERGE_READY"
MERGED = "MERGED"
HOLD_FOR_CHAIR = "HOLD_FOR_CHAIR"

ALL_STATES: Tuple[str, ...] = (
    PLANNED,
    DISPATCHED,
    RUNNING,
    NORMAL_COLLECTOR_COMPLETED,
    CODEX_AUDIT_PENDING,
    ANU_ADJUDICATION_PENDING,
    AUTO_MICRO_FIX,
    RETRY_WITHIN_SCOPE,
    ACCEPTED,
    LANDING_PENDING,
    MERGE_READY,
    MERGED,
    HOLD_FOR_CHAIR,
)

assert len(ALL_STATES) == 13, "13-state machine invariant (9-R.3)"

TERMINAL_STATES: Tuple[str, ...] = (ACCEPTED, MERGED, HOLD_FOR_CHAIR)

# Precedence ranking — higher wins when multiple successors are simultaneously
# eligible (9-R.3): HOLD_FOR_CHAIR > RETRY_WITHIN_SCOPE > AUTO_MICRO_FIX.
PRECEDENCE: Dict[str, int] = {
    HOLD_FOR_CHAIR: 3,
    RETRY_WITHIN_SCOPE: 2,
    AUTO_MICRO_FIX: 1,
}

# Allowed transition table. Every non-terminal state may always escalate to
# HOLD_FOR_CHAIR (chair safety valve). Terminal states have no successors.
TRANSITIONS: Dict[str, Tuple[str, ...]] = {
    PLANNED: (DISPATCHED, HOLD_FOR_CHAIR),
    DISPATCHED: (RUNNING, HOLD_FOR_CHAIR),
    RUNNING: (NORMAL_COLLECTOR_COMPLETED, HOLD_FOR_CHAIR),
    NORMAL_COLLECTOR_COMPLETED: (CODEX_AUDIT_PENDING, HOLD_FOR_CHAIR),
    CODEX_AUDIT_PENDING: (ANU_ADJUDICATION_PENDING, HOLD_FOR_CHAIR),
    ANU_ADJUDICATION_PENDING: (
        AUTO_MICRO_FIX,
        RETRY_WITHIN_SCOPE,
        ACCEPTED,
        LANDING_PENDING,
        HOLD_FOR_CHAIR,
    ),
    AUTO_MICRO_FIX: (ANU_ADJUDICATION_PENDING, RETRY_WITHIN_SCOPE, HOLD_FOR_CHAIR),
    RETRY_WITHIN_SCOPE: (DISPATCHED, HOLD_FOR_CHAIR),
    LANDING_PENDING: (MERGE_READY, HOLD_FOR_CHAIR),
    MERGE_READY: (MERGED, HOLD_FOR_CHAIR),
    # terminal
    ACCEPTED: (),
    MERGED: (),
    HOLD_FOR_CHAIR: (),
}


def is_terminal(state: str) -> bool:
    return state in TERMINAL_STATES


def legal_successors(state: str) -> Tuple[str, ...]:
    if state not in TRANSITIONS:
        raise KeyError(f"unknown state: {state!r}")
    return TRANSITIONS[state]


def is_legal_transition(src: str, dst: str) -> bool:
    return dst in TRANSITIONS.get(src, ())


@dataclass
class TrackLoopState:
    """Mutable per-track loop position with audited history.

    ``retry_ceiling`` defaults to 2 and is normally supplied from the
    track goal / policy_profile (Track3). Over the ceiling the machine
    refuses any RETRY_WITHIN_SCOPE entry and forces HOLD_FOR_CHAIR.
    """

    track_id: str
    state: str = PLANNED
    retry_ceiling: int = 2
    retry_count: int = 0
    normal_collector_reached: bool = False
    history: List[str] = field(default_factory=list)
    rejected: List[Tuple[str, str, str]] = field(default_factory=list)

    def __post_init__(self) -> None:
        if self.state not in ALL_STATES:
            raise ValueError(f"invalid initial state {self.state!r}")
        if not self.history:
            self.history.append(self.state)
        if self.state == NORMAL_COLLECTOR_COMPLETED:
            self.normal_collector_reached = True

    # -- core transition -------------------------------------------------
    def transition(self, dst: str) -> None:
        """Apply a transition or raise ValueError if illegal (rejected)."""
        ok, reason = self.can_transition(dst)
        if not ok:
            self.rejected.append((self.state, dst, reason))
            raise ValueError(
                f"illegal transition {self.state!r}->{dst!r}: {reason}"
            )
        self.state = dst
        if dst == NORMAL_COLLECTOR_COMPLETED:
            self.normal_collector_reached = True
        if dst == RETRY_WITHIN_SCOPE:
            self.retry_count += 1
        self.history.append(dst)

    def can_transition(self, dst: str) -> Tuple[bool, str]:
        if dst not in ALL_STATES:
            return False, f"unknown target state {dst!r}"
        if is_terminal(self.state):
            return False, f"{self.state} is terminal — no successors"
        if not is_legal_transition(self.state, dst):
            return False, f"{dst} not in legal successors of {self.state}"
        if dst == RETRY_WITHIN_SCOPE and self.retry_count >= self.retry_ceiling:
            return (
                False,
                f"retry ceiling {self.retry_ceiling} reached "
                f"(count={self.retry_count}) — only HOLD_FOR_CHAIR legal",
            )
        return True, "ok"

    # -- precedence-aware resolution ------------------------------------
    def resolve_next(self, candidates: List[str]) -> str:
        """Pick the highest-precedence legal successor among *candidates*.

        Implements 9-R.3 precedence HOLD_FOR_CHAIR > RETRY_WITHIN_SCOPE >
        AUTO_MICRO_FIX. Candidates outside PRECEDENCE keep insertion order
        and rank below the three precedence states. If a precedence pick is
        RETRY_WITHIN_SCOPE but the ceiling is exhausted it falls back to
        HOLD_FOR_CHAIR (still highest), guaranteeing forward progress.
        """
        legal = [c for c in candidates if is_legal_transition(self.state, c)]
        if not legal:
            raise ValueError(
                f"no legal candidate among {candidates} from {self.state}"
            )
        ranked = sorted(
            legal,
            key=lambda c: PRECEDENCE.get(c, 0),
            reverse=True,
        )
        pick = ranked[0]
        if pick == RETRY_WITHIN_SCOPE and self.retry_count >= self.retry_ceiling:
            return HOLD_FOR_CHAIR
        return pick

    # -- callback precedence (9-R.3) ------------------------------------
    def classify_callback(self, kind: str) -> str:
        """Classify an incoming callback event for this track.

        ``kind`` is "normal" or "fallback". If the normal collector has
        already completed, any later fallback is DUPLICATE_CALLBACK_IGNORED.
        """
        if kind == "fallback" and self.normal_collector_reached:
            return "DUPLICATE_CALLBACK_IGNORED"
        if kind == "normal":
            return "NORMAL_COLLECTOR_ACCEPTED"
        if kind == "fallback":
            return "CALLBACK_PENDING"
        raise ValueError(f"unknown callback kind {kind!r}")

    def to_dict(self) -> Dict[str, object]:
        return {
            "track_id": self.track_id,
            "state": self.state,
            "terminal": is_terminal(self.state),
            "retry_ceiling": self.retry_ceiling,
            "retry_count": self.retry_count,
            "normal_collector_reached": self.normal_collector_reached,
            "history": list(self.history),
            "rejected_transitions": [
                {"from": a, "to": b, "reason": r} for (a, b, r) in self.rejected
            ],
        }


def transition_table_spec() -> Dict[str, object]:
    """Static, serialisable description of the table (regression evidence)."""
    return {
        "states": list(ALL_STATES),
        "terminal": list(TERMINAL_STATES),
        "precedence": dict(PRECEDENCE),
        "transitions": {s: list(t) for s, t in TRANSITIONS.items()},
        "callback_precedence": (
            "NORMAL_COLLECTOR_COMPLETED reached => later fallback = "
            "DUPLICATE_CALLBACK_IGNORED"
        ),
    }
