"""Target resolver for ``rederive --from-script``.

Read-only checks that map a batch selector to the persisted scene cache and,
when given a freshly re-derived plan, decide whether that one target can be
refreshed without a full episode re-cluster.
"""
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal

from recoil.pipeline._lib import derivation_manifest
from recoil.pipeline._lib.grouping import GroupingContext, get_grouping
from recoil.pipeline.core.persistence import (
    active_scene_body_path,
    load_manifest,
    load_scene_active,
    structure_fingerprint,
)
from recoil.pipeline.orchestrator.batch_selector import (
    BatchSelector,
    verify_scene_grouping_metadata,
)
from recoil.pipeline.orchestrator.episode_runner import (
    BoardGateError,
    RerollPreflightError,
)


TargetStatus = Literal["PROCEED", "REFUSE", "ESCALATE"]
TargetReason = Literal[
    "stale",
    "not_stale",
    "legacy_no_backlink",
    "shot_set_change",
    "scene_missing",
    "batch_selector_metadata_mismatch",
    "board_gate_blocked",
]


@dataclass(frozen=True)
class TargetVerdict:
    status: TargetStatus
    reason: TargetReason
    scene_id: str
    message: str
    path: Path | None = None
    error: str | None = None
    freshness: tuple[bool, str | None] | None = None
    stored_spans: dict[str, str | None] = field(default_factory=dict)
    persisted_spans: dict[str, str | None] = field(default_factory=dict)
    live_spans: dict[str, str | None] = field(default_factory=dict)

    @property
    def proceed(self) -> bool:
        return self.status == "PROCEED"

    @property
    def escalate(self) -> bool:
        return self.status == "ESCALATE"

    @property
    def refuse(self) -> bool:
        return self.status == "REFUSE"


def resolve_from_script_target(
    project: str,
    episode: int,
    selector: BatchSelector,
    *,
    live_plan=None,
) -> TargetVerdict:
    """Resolve one ``--from-script`` batch target.

    With ``live_plan=None`` this only rejects legacy manifests and missing
    persisted scenes, and reports the coarse existing scenes-stage freshness.
    With ``live_plan`` it also verifies shot-id-set stability, checks persisted
    grouping metadata, and performs the target-level idempotence check from the
    scene file's stored script spans.
    """
    # REC-231 Phase 4: report + read the ACTIVE version body via the pointer.
    target_path = active_scene_body_path(project, f"ep_{episode:03d}", selector.scene_id)
    manifest = derivation_manifest.load(project, episode)
    scenes_stage = (manifest.get("stages") or {}).get("scenes") or {}
    all_shot_script_spans = scenes_stage.get("shot_script_spans")
    if not isinstance(all_shot_script_spans, dict) or not all_shot_script_spans:
        return TargetVerdict(
            status="REFUSE",
            reason="legacy_no_backlink",
            scene_id=selector.scene_id,
            path=target_path,
            error="legacy_no_backlink",
            message=(
                "scenes stage has no shot_script_spans back-link; run "
                "`rederive --episode` once to stamp the back-link."
            ),
        )

    stored_spans = all_shot_script_spans.get(selector.scene_id)
    if not isinstance(stored_spans, dict) or not stored_spans:
        return TargetVerdict(
            status="REFUSE",
            reason="legacy_no_backlink",
            scene_id=selector.scene_id,
            path=target_path,
            error="legacy_no_backlink",
            message=(
                f"scenes stage has no shot_script_spans for {selector.scene_id}; "
                "run `rederive --episode` once to stamp the back-link."
            ),
        )

    try:
        scene = load_scene_active(project, f"ep_{episode:03d}", selector.scene_id)
    except FileNotFoundError:
        return TargetVerdict(
            status="REFUSE",
            reason="scene_missing",
            scene_id=selector.scene_id,
            path=target_path,
            error="batch_scene_missing",
            stored_spans=dict(stored_spans),
            message=f"No persisted scene for --batch target {selector.scene_id}.",
        )

    freshness = _safe_freshness(project, episode)
    if live_plan is None:
        return TargetVerdict(
            status="PROCEED",
            reason="stale",
            scene_id=selector.scene_id,
            path=target_path,
            freshness=freshness,
            stored_spans=dict(stored_spans),
            message=(
                f"{selector.scene_id} is resolvable for from-script rederive; "
                "target currentness requires a live post-upstream plan."
            ),
        )

    live_group = _live_group_for_selector(project, episode, selector, live_plan)
    if live_group is None:
        return _shot_set_change_verdict(
            selector=selector,
            target_path=target_path,
            stored_spans=stored_spans,
            live_spans={},
        )

    live_spans = {
        shot.shot_id: (shot.raw or {}).get("source_text_hash")
        for shot in live_group.shots
    }
    if set(live_spans) != set(stored_spans):
        return _shot_set_change_verdict(
            selector=selector,
            target_path=target_path,
            stored_spans=stored_spans,
            live_spans=live_spans,
        )

    try:
        verify_scene_grouping_metadata(scene, selector, _scene_selector_beat(scene))
    except BoardGateError as exc:
        return TargetVerdict(
            status="ESCALATE",
            reason="board_gate_blocked",
            scene_id=selector.scene_id,
            path=target_path,
            error="board_gate_blocked",
            stored_spans=dict(stored_spans),
            live_spans=live_spans,
            message=str(exc),
        )
    except RerollPreflightError as exc:
        return TargetVerdict(
            status="ESCALATE",
            reason="batch_selector_metadata_mismatch",
            scene_id=selector.scene_id,
            path=target_path,
            error=exc.error_code,
            stored_spans=dict(stored_spans),
            live_spans=live_spans,
            message=str(exc),
        )

    persisted_spans = _scene_script_spans(scene)
    if persisted_spans == live_spans:
        return TargetVerdict(
            status="REFUSE",
            reason="not_stale",
            scene_id=selector.scene_id,
            path=target_path,
            error="not_stale",
            stored_spans=dict(stored_spans),
            persisted_spans=persisted_spans,
            live_spans=live_spans,
            message="this scene file already reflects the current script",
        )

    return TargetVerdict(
        status="PROCEED",
        reason="stale",
        scene_id=selector.scene_id,
        path=target_path,
        freshness=freshness,
        stored_spans=dict(stored_spans),
        persisted_spans=persisted_spans,
        live_spans=live_spans,
        message=f"{selector.scene_id} is stale and can be refreshed from script.",
    )


def matching_scene_version_for_structure(
    project: str,
    episode: int | str,
    batch_id: str,
    scene,
) -> int | None:
    """Return an existing version with ``scene``'s structure, if one is registered."""
    manifest = load_manifest(project, episode, batch_id)
    if manifest is None:
        return None
    candidate_fp = structure_fingerprint(scene)
    for entry in manifest.get("versions", []):
        if entry.get("structure_fingerprint") == candidate_fp:
            version = entry.get("version")
            if isinstance(version, int):
                return version
    return None


def _safe_freshness(project: str, episode: int) -> tuple[bool, str | None] | None:
    try:
        return derivation_manifest.freshness(project, episode, "scenes")
    except Exception:
        return None


def _live_group_for_selector(project: str, episode: int, selector, live_plan):
    ctx = GroupingContext(
        project=project,
        episode=episode,
        canonical_plan=live_plan,
        selected_coverage_passes=[],
        tier_map={},
        wildcard_override=None,
    )
    groups = get_grouping(selector.strategy).assemble(list(live_plan.shots), ctx)
    return next((group for group in groups if group.scene_id == selector.scene_id), None)


def _scene_selector_beat(scene):
    if scene.beats:
        return scene.beats[0]
    raise RerollPreflightError(
        "batch_selector_metadata_mismatch",
        "--batch selector does not match persisted scene grouping metadata.",
    )


def _scene_script_spans(scene) -> dict[str, str | None]:
    spans: dict[str, str | None] = {}
    for beat in scene.beats:
        metadata = beat.beat_metadata or {}
        shots = metadata.get("batch_shots")
        if not shots:
            shot = metadata.get("shot")
            shots = [shot] if shot else []
        for shot in shots:
            shot_id, source_text_hash = _shot_span(shot)
            if shot_id:
                spans[shot_id] = source_text_hash
    return spans


def _shot_span(shot: Any) -> tuple[str | None, str | None]:
    if isinstance(shot, dict):
        raw = shot.get("raw") or {}
        return shot.get("shot_id"), raw.get("source_text_hash")
    raw = getattr(shot, "raw", None) or {}
    return getattr(shot, "shot_id", None), raw.get("source_text_hash")


def _shot_set_change_verdict(
    *,
    selector: BatchSelector,
    target_path: Path,
    stored_spans: dict,
    live_spans: dict[str, str | None],
) -> TargetVerdict:
    return TargetVerdict(
        status="ESCALATE",
        reason="shot_set_change",
        scene_id=selector.scene_id,
        path=target_path,
        error="shot_set_change",
        stored_spans=dict(stored_spans),
        live_spans=live_spans,
        message=(
            f"shot set changed for {selector.scene_id}; run a full "
            "`rederive --episode`."
        ),
    )


__all__ = [
    "TargetVerdict",
    "matching_scene_version_for_structure",
    "resolve_from_script_target",
]
