"""ACT DAG validation for Phase 2 execution records.

Validates the directed acyclic graph formed by pred (predecessor) references
in Phase 2 ACTs, ensuring uniqueness, predecessor existence, temporal ordering,
acyclicity, and capability consistency.

Reference: ACT §7 (DAG Structure and Causal Ordering).
"""

from __future__ import annotations

from typing import Protocol

from .errors import ACTCapabilityError, ACTDAGError
from .token import ACTRecord

# Maximum ancestor traversal limit for cycle detection — ACT §7.1 step 4.
MAX_TRAVERSAL_LIMIT: int = 10_000

# Clock skew tolerance for temporal ordering — ACT §7.1 step 3.
DAG_CLOCK_SKEW_TOLERANCE: int = 30


class ACTStore(Protocol):
    """Protocol for an ACT store used in DAG validation.

    Any object implementing get() and has() can serve as the store.
    The ACTLedger in ledger.py implements this protocol.
    """

    def get(self, jti: str) -> ACTRecord | None:
        """Retrieve a Phase 2 ACT record by jti."""
        ...


def validate_dag(
    record: ACTRecord,
    store: ACTStore,
    *,
    clock_skew_tolerance: int = DAG_CLOCK_SKEW_TOLERANCE,
) -> None:
    """Validate the DAG constraints for a Phase 2 execution record.

    Performs all five DAG validation checks defined in ACT §7.1:
    1. jti uniqueness within wid scope (or globally)
    2. Predecessor existence in store
    3. Temporal ordering with clock skew tolerance
    4. Acyclicity (max traversal limit)
    5. Capability consistency (exec_act matches cap[].action)

    Reference: ACT §7.1 (DAG Validation).

    Args:
        record: The Phase 2 ACTRecord to validate.
        store: An ACT store providing get() for predecessor lookup.
        clock_skew_tolerance: Seconds of allowed clock skew (default 30).

    Raises:
        ACTDAGError: If any DAG constraint is violated.
        ACTCapabilityError: If exec_act does not match cap actions.
    """
    # Step 1: jti uniqueness — ACT §7.1 step 1
    existing = store.get(record.jti)
    if existing is not None:
        raise ACTDAGError(
            f"Duplicate jti {record.jti!r} already exists in store"
        )

    # Step 5: Capability consistency — ACT §7.1 step 5
    cap_actions = {c.action for c in record.cap}
    if record.exec_act not in cap_actions:
        raise ACTCapabilityError(
            f"exec_act {record.exec_act!r} does not match any "
            f"cap[].action: {sorted(cap_actions)}"
        )

    # Step 2 & 3: Predecessor existence and temporal ordering
    for pred_jti in record.pred:
        parent = store.get(pred_jti)
        if parent is None:
            raise ACTDAGError(
                f"Predecessor jti {pred_jti!r} not found in store"
            )

        # Temporal ordering: predecessor.exec_ts < child.exec_ts + tolerance
        if parent.exec_ts >= record.exec_ts + clock_skew_tolerance:
            raise ACTDAGError(
                f"Temporal ordering violation: predecessor {pred_jti!r} "
                f"exec_ts={parent.exec_ts} >= child exec_ts="
                f"{record.exec_ts} + tolerance={clock_skew_tolerance}"
            )

    # Step 4: Acyclicity — ACT §7.1 step 4
    _check_acyclicity(record.jti, record.pred, store)


def _check_acyclicity(
    current_jti: str,
    pred_jtis: list[str],
    store: ACTStore,
) -> None:
    """Check that following pred references does not lead back to current_jti.

    Uses breadth-first traversal with a maximum node limit.

    Reference: ACT §7.1 step 4.

    Raises:
        ACTDAGError: If a cycle is detected or traversal limit exceeded.
    """
    visited: set[str] = set()
    queue: list[str] = list(pred_jtis)
    nodes_visited = 0

    while queue:
        if nodes_visited >= MAX_TRAVERSAL_LIMIT:
            raise ACTDAGError(
                f"DAG traversal limit ({MAX_TRAVERSAL_LIMIT}) exceeded; "
                f"possible cycle or excessively deep DAG"
            )

        jti = queue.pop(0)
        if jti == current_jti:
            raise ACTDAGError(
                f"DAG cycle detected: jti {current_jti!r} appears in "
                f"its own ancestor chain"
            )

        if jti in visited:
            continue
        visited.add(jti)
        nodes_visited += 1

        parent = store.get(jti)
        if parent is not None:
            queue.extend(parent.pred)
