"""v0.4 Sprint 5 PR-T2.B/T2.C — full A/B contradiction routing wire.

Wires the deterministic ``classify_contradiction`` decision into
the production CASCADE / EVENT dispatch. PR-T2.B landed the A-path
(cascade routing); PR-T2.C completes the surface with the B-path
(supersede chain wiring) so ``dispatch_contradiction`` is now the
single entry point for all three labels.

Surface
-------

  ``route_a_invalidate(bad_doc_id, entity_root, *, audit_emit=None)``
      Run the existing ``cascade_remove_doc_from_sources`` for the
      wrong source + emit a ``mutation_type=invalidated`` audit
      row. Returns the cascade counts dict + the audit payload.

  ``route_b_supersede(old_edge, new_fact, supersede_ts,
                      *, audit_emit=None)``
      Call ``supersede_edge`` from PR-T7.A. Old edge marked
      ``status.superseded_by = new_edge.id`` + ``mutation_type =
      "superseded"``. Audit row carries both ids so the T7 replay
      primitive (``reconstruct_view_at``) sees the chain.

  ``dispatch_contradiction(old_edge, new_fact, *, now, entity_root,
                           bad_doc_id_for_a=None, audit_emit=None)``
      End-to-end: calls ``classify_contradiction`` → dispatches.
      A → ``route_a_invalidate`` (requires ``bad_doc_id_for_a``).
      B → ``route_b_supersede`` (uses ``now`` as supersede_ts).
      ignore → returns ``{"action": "ignore"}``.

Design notes
------------

- **A path narrowness.** The routing wire does NOT add new CASCADE
  logic — it calls the existing ``cascade_remove_doc_from_sources``
  unchanged. The PR's surface is two functions + an audit
  side-effect.
- **Bad-source identification stays at the caller.** The router
  takes ``bad_doc_id_for_a`` explicitly rather than guessing which
  source on ``old_edge`` is the wrong one. The caller (the
  ingestion path that detected the contradiction) knows which doc
  was just rejected — passing that doc_id through is one line.
- **mutation_type audit invariant.** The audit row carries
  ``mutation_type="invalidated"`` so the T7 replay primitive
  (``reconstruct_view_at``) filters correctly — CASCADE
  invalidations are gone for replay; the audit row is the only
  trace.
- **Default audit emitter.** When ``audit_emit`` is ``None``, falls
  back to ``core.audit_bridge.mirror_to_audit_db`` (the same
  mirror the reason:* events use). Tests pass an in-memory
  capture function to assert the payload shape without touching
  the SQLite DB.
"""
from __future__ import annotations

from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, Optional

from core.lifecycle.contradiction_arbiter import (
    classify_contradiction,
)
from core.lifecycle.schema import (
    T1_MUTATION_INVALIDATED,
    T1_MUTATION_SUPERSEDED,
)
from core.lifecycle.supersede_chain import supersede_edge


AuditEmit = Callable[[Dict[str, Any]], None]


def _default_audit_emit(payload: Dict[str, Any]) -> None:
    """Mirror to the audit_log SQLite via core.audit_bridge. Never
    raises — audit failure must not block the cascade write path
    (same pattern as ``trace_synth_call``)."""
    try:
        from core.audit_bridge import mirror_to_audit_db
        mirror_to_audit_db(payload)
    except Exception:
        pass


def route_a_invalidate(
    bad_doc_id: str,
    entity_root: Path | str,
    *,
    audit_emit: Optional[AuditEmit] = None,
) -> Dict[str, Any]:
    """A-path: cascade-remove the wrong source + emit audit row.

    Args:
        bad_doc_id: the doc_id of the source identified as wrong
            (by the caller's contradiction-detection logic).
            ``cascade_remove_doc_from_sources`` removes this from
            every edge that references it; manual sources and
            non-matching doc_ids are preserved unchanged (per the
            existing CASCADE contract — see
            ``core/cascade/_delete.py:cascade_remove_doc_from_sources``
            docstring).
        entity_root: wiki entity root (typically ``wiki/entity``
            or the wiki snapshot the cascade should target).
        audit_emit: optional callback for the audit row. Defaults
            to ``core.audit_bridge.mirror_to_audit_db``.

    Returns:
        ``{"action": "invalidate", "bad_doc_id": …, "counts": …,
           "audit_payload": …}``

        ``counts`` is the dict returned by
        ``cascade_remove_doc_from_sources``:
        ``entities_scanned`` / ``entities_touched`` /
        ``relations_recomputed`` / ``relations_dropped``.

        ``audit_payload`` is the dict that was emitted — useful
        for callers that want to chain audit writes (e.g., write a
        compound row for a multi-step ingestion).

    Raises:
        ValueError if ``bad_doc_id`` is empty or
        ``entity_root`` doesn't exist.
    """
    if not bad_doc_id or not isinstance(bad_doc_id, str):
        raise ValueError(f"bad_doc_id must be a non-empty str, got {bad_doc_id!r}")
    root = Path(entity_root)
    if not root.exists():
        raise ValueError(f"entity_root does not exist: {root}")

    # Lazy-import the cascade to avoid pulling Phase-C dependencies
    # at module import time. The router stays import-light.
    from core.cascade import cascade_remove_doc_from_sources

    counts = cascade_remove_doc_from_sources(bad_doc_id, root)

    audit_payload: Dict[str, Any] = {
        "endpoint":      "lifecycle:invalidate",
        "role":          "system",
        "mutation_type": T1_MUTATION_INVALIDATED,
        "bad_doc_id":    bad_doc_id,
        "entities_scanned":     counts.get("entities_scanned", 0),
        "entities_touched":     counts.get("entities_touched", 0),
        "relations_recomputed": counts.get("relations_recomputed", 0),
        "relations_dropped":    counts.get("relations_dropped", 0),
    }
    emitter = audit_emit or _default_audit_emit
    emitter(audit_payload)

    return {
        "action":         "invalidate",
        "bad_doc_id":     bad_doc_id,
        "counts":         counts,
        "audit_payload":  audit_payload,
    }


def route_b_supersede(
    old_edge: dict,
    new_fact: dict,
    supersede_ts: datetime,
    *,
    audit_emit: Optional[AuditEmit] = None,
) -> Dict[str, Any]:
    """B-path: call PR-T7.A ``supersede_edge`` + emit audit row.

    Args:
        old_edge: the existing edge being superseded. **Mutated in
            place** (PR-T7.A contract — the caller's frontmatter
            list reference picks up the change).
        new_fact: the new fact replacing the old edge. Same shape
            as a v0.4 edge (relation dict from frontmatter).
        supersede_ts: UTC-aware ``datetime`` of the supersede event.
            Used for both ``old_edge.status.superseded_at`` and
            ``new_edge.validity.from`` (PR-T7.A contract).
        audit_emit: optional callback for the audit row. Defaults
            to ``core.audit_bridge.mirror_to_audit_db``.

    Returns:
        ``{"action": "supersede", "new_edge": …, "old_edge": …,
           "audit_payload": …}``

        ``new_edge`` and ``old_edge`` are the dicts returned by
        ``supersede_edge``. The caller writes both back to the wiki
        (the router does NOT touch disk — same contract as
        PR-T7.A's pure-function layer).

        ``audit_payload`` carries ``mutation_type="superseded"`` +
        ``superseded_by=<new_edge.id>`` so the T7 replay primitive
        sees the chain when querying audit history. The PR-T7.B
        invariant ``test_supersede_does_not_trigger_cascade``
        verifies this path does not invoke CASCADE.

    What this function does NOT do:
        - **No I/O.** Caller writes ``new_edge`` + ``mutated_old``
          back to the wiki (same as PR-T7.A direct callers).
        - **No cascade_remove.** Supersede preserves the old
          edge's sources; CASCADE is exclusive (T7 EVENT
          invariant).
    """
    new_edge, mutated_old = supersede_edge(old_edge, new_fact, supersede_ts)

    audit_payload: Dict[str, Any] = {
        "endpoint":         "lifecycle:supersede",
        "role":             "system",
        "mutation_type":    T1_MUTATION_SUPERSEDED,
        "old_edge_id":      mutated_old.get("id"),
        "new_edge_id":      new_edge.get("id"),
        "superseded_at":    supersede_ts.isoformat(),
        "superseded_by":    new_edge.get("id"),
    }
    emitter = audit_emit or _default_audit_emit
    emitter(audit_payload)

    return {
        "action":         "supersede",
        "new_edge":       new_edge,
        "old_edge":       mutated_old,
        "audit_payload":  audit_payload,
    }


def dispatch_contradiction(
    old_edge: dict,
    new_fact: dict,
    *,
    now: datetime,
    entity_root: Optional[Path | str] = None,
    bad_doc_id_for_a: Optional[str] = None,
    audit_emit: Optional[AuditEmit] = None,
) -> Dict[str, Any]:
    """Full A/B dispatch in one call. Calls
    ``classify_contradiction`` + routes.

    Args:
        old_edge: existing v0.4-shaped edge.
        new_fact: the contradicting observation (edge or source
            shape — same dual-shape the classifier accepts).
        now: UTC-aware ``datetime``.
        entity_root: required for A-path (cascade target). Optional
            for B / ignore.
        bad_doc_id_for_a: required when ``classify_contradiction``
            returns ``A_invalidate``. Caller's contradiction-detection
            logic supplies this.
        audit_emit: optional audit callback (A-path only — B-path
            audit lands at PR-T2.C).

    Returns:
        For A_invalidate: see ``route_a_invalidate`` return.
        For B_supersede: see ``route_b_supersede`` return. Uses
            ``now`` as ``supersede_ts`` — the caller doesn't have
            to track two timestamps when the contradiction is
            being detected at the same moment it's being applied.
        For ignore: ``{"action": "ignore", "label": "ignore"}``.

    Raises:
        ValueError if A_invalidate is selected but required args
        (``entity_root`` / ``bad_doc_id_for_a``) are missing.
    """
    label = classify_contradiction(old_edge, new_fact, now=now)

    if label == "ignore":
        return {"action": "ignore", "label": "ignore"}

    if label == "A_invalidate":
        if entity_root is None:
            raise ValueError(
                "A_invalidate dispatch requires entity_root (cascade target)"
            )
        if not bad_doc_id_for_a:
            raise ValueError(
                "A_invalidate dispatch requires bad_doc_id_for_a — caller's "
                "contradiction-detection logic identifies which source on "
                "old_edge is the wrong one"
            )
        return route_a_invalidate(
            bad_doc_id_for_a, entity_root, audit_emit=audit_emit,
        )

    if label == "B_supersede":
        return route_b_supersede(
            old_edge, new_fact, now, audit_emit=audit_emit,
        )

    # Defensive — classifier returned a label we don't know about.
    # Reachable only if classify_contradiction is extended without a
    # matching dispatch arm here.
    raise RuntimeError(f"unknown contradiction label: {label!r}")


__all__ = [
    "AuditEmit",
    "route_a_invalidate",
    "route_b_supersede",
    "dispatch_contradiction",
]
