"""Shared board-wall assembly helpers for workspace routes and MCP tools."""
from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any, NamedTuple

from recoil.core.exceptions import SidecarCorruptError
from recoil.core.paths import ProjectPaths
from recoil.execution.pass_store import PassStore
from recoil.pipeline.core.persistence import (
    _canonical_episode_token,
    list_scenes,
    load_scene_active,
)
from recoil.workspace.coverage import best_status
from recoil.workspace.helpers import get_store, shot_status_color


class EpisodeIds(NamedTuple):
    scene_token: str
    prep_token: str
    coverage_id: str


_EPISODE_RE = re.compile(r"^(?:ep_?)?(\d+)$", re.IGNORECASE)
_SCENE_RE_TEMPLATE = r"^{}_BATCH_(\d+)\.json$"
# Parses the board version from a pointed artifact path (...BATCH_001_v2.png).
_BOARD_ARTIFACT_VERSION_RE = re.compile(r"_v(\d+)(?=\.png$)", re.IGNORECASE)


def normalize_episode(episode_id: str) -> EpisodeIds:
    raw = str(episode_id or "").strip()
    match = _EPISODE_RE.match(raw)
    if not match:
        raise ValueError(f"invalid episode_id: {episode_id!r}")
    number = int(match.group(1))
    return EpisodeIds(
        # REC-231 Phase 4: the canonical scene-file token is "ep_NNN" (NOT the bare
        # number "1") — `str(number)` built `^1_BATCH_…$`, which never matched the
        # canonical `ep_001_BATCH_*.json` files, so the wall enumerated nothing.
        scene_token=_canonical_episode_token(number),
        prep_token=f"ep_{number:03d}",
        coverage_id=f"EP{number:03d}",
    )


def build_episode_board(project: str, episode_id: str) -> dict[str, Any]:
    ids = normalize_episode(episode_id)
    paths = ProjectPaths.for_project(project)
    shot_coverage = _coverage_by_shot(project, ids.coverage_id)

    batches: list[dict[str, Any]] = []
    for scene_order, scene_file in enumerate(_episode_scene_paths(project, ids), start=1):
        batch_id = scene_file.stem.removeprefix(f"{ids.scene_token}_")
        # REC-231 Phase 4: enumeration finds the flat identity files, but the body
        # AND its pointed board are resolved via the ACTIVE version pointer, so a
        # conform/revert changes what the wall shows.
        scene = load_scene_active(project, ids.prep_token, batch_id)
        beat = scene.beats[0] if scene.beats else None
        metadata = dict(getattr(beat, "beat_metadata", {}) or {})
        batch_shots = list(metadata.get("batch_shots") or [])
        segment_ids = [
            str(shot.get("segment_id"))
            for shot in batch_shots
            if isinstance(shot, dict) and shot.get("segment_id") is not None
        ]
        board = _resolve_board_sidecar(
            paths.project_root, beat.board if beat is not None else None
        )
        coverage_summary = _coverage_summary_for_batch(segment_ids, shot_coverage)
        statuses = [
            shot_coverage.get(segment_id, {}).get("status", "untracked")
            for segment_id in segment_ids
        ]
        best_batch_status = best_status(statuses)
        batches.append(
            {
                "batch_id": batch_id,
                "scene_order": scene_order,
                "board_artifact": board["board_artifact"],
                "version": board["version"],
                "status": board["status"],
                "photoreal_artifact": board["photoreal_artifact"],
                "duration_s": sum(
                    _float_or_zero(shot.get("duration_s"))
                    for shot in batch_shots
                    if isinstance(shot, dict)
                ),
                "shared_location_id": metadata.get("shared_location_id"),
                "shared_characters": list(metadata.get("shared_characters") or []),
                "panels": [
                    {
                        "segment_id": shot.get("segment_id"),
                        "setting": shot.get("setting"),
                        "duration_s": _float_or_zero(shot.get("duration_s")),
                    }
                    for shot in batch_shots
                    if isinstance(shot, dict)
                ],
                "board_status_color": shot_status_color(best_batch_status),
                "coverage_summary": coverage_summary,
            }
        )

    summary = {
        "covered": sum(batch["coverage_summary"]["covered"] for batch in batches),
        "total": sum(batch["coverage_summary"]["total"] for batch in batches),
        "awaiting": sum(batch["coverage_summary"]["awaiting"] for batch in batches),
        "review_count": sum(1 for batch in batches if batch["status"] == "proposed"),
    }
    return {
        "episode_id": ids.coverage_id,
        "batches": batches,
        "summary": summary,
    }


def _episode_scene_paths(project: str, ids: EpisodeIds) -> list[Path]:
    # REC-231 Phase 4: enumerate via the manifest-aware list_scenes (which excludes
    # *.versions.json + *.vNNN.json sidecars) instead of a raw orchestration-dir
    # glob, then sort by BATCH ordinal. Each batch's active body is resolved by the
    # caller through the pointer (load_scene_active).
    scene_re = re.compile(_SCENE_RE_TEMPLATE.format(re.escape(ids.scene_token)))
    matches: list[tuple[int, Path]] = []
    for path in list_scenes(project, ids.prep_token):
        match = scene_re.match(path.name)
        if match:
            matches.append((int(match.group(1)), path))
    return [path for _, path in sorted(matches, key=lambda item: item[0])]


def _resolve_board_sidecar(
    project_root: Path,
    board_pointer: dict[str, Any] | None,
) -> dict[str, Any]:
    """Resolve the board strip from the ACTIVE version body's POINTED board
    (``beat.board.artifact``), NOT the newest sidecar on disk (REC-231 Phase 4).

    The retired newest-by-glob selection could surface a newer UNPOINTED board
    after a revert/conform; dereferencing the pointer keeps the wall on the board
    the active version actually points at.
    """
    artifact = board_pointer.get("artifact") if isinstance(board_pointer, dict) else None
    if not isinstance(artifact, str) or not artifact:
        return {
            "board_artifact": None,
            "version": None,
            "status": "proposed",
            "photoreal_artifact": None,
        }

    sidecar_path = _safe_sidecar_path(project_root, artifact)
    if sidecar_path is None:
        return {
            "board_artifact": None,
            "version": None,
            "status": "proposed",
            "photoreal_artifact": None,
        }
    sidecar = _read_json_dict(sidecar_path)
    png_path = project_root / Path(artifact)
    resolved_artifact = sidecar.get("artifact")
    if not isinstance(resolved_artifact, str) or not resolved_artifact:
        resolved_artifact = artifact
    pointer_status = board_pointer.get("status")
    if isinstance(pointer_status, str) and pointer_status:
        status = pointer_status
    elif isinstance(sidecar.get("status"), str):
        status = sidecar["status"]
    else:
        status = "proposed"
    return {
        "board_artifact": resolved_artifact,
        "version": _board_artifact_version(resolved_artifact),
        "status": status,
        "photoreal_artifact": _resolve_photoreal_artifact(
            project_root,
            png_path,
            sidecar.get("photoreal_artifact"),
        ),
    }


def _safe_sidecar_path(project_root: Path, artifact: str) -> Path | None:
    """``project_root / f"{artifact}.json"`` if ``artifact`` is a safe relative path
    (no absolute / ``..`` traversal), mirroring readmodel._safe_project_sidecar_path."""
    artifact_path = Path(artifact)
    if artifact_path.is_absolute() or ".." in artifact_path.parts:
        return None
    return project_root / f"{artifact_path.as_posix()}.json"


def _board_artifact_version(artifact: str) -> int | None:
    match = _BOARD_ARTIFACT_VERSION_RE.search(artifact)
    return int(match.group(1)) if match else None


def _resolve_photoreal_artifact(
    project_root: Path,
    png_path: Path,
    sidecar_value: Any,
) -> str | None:
    if isinstance(sidecar_value, str) and sidecar_value:
        return sidecar_value
    direct = png_path.with_name(f"{png_path.stem}_photoreal{png_path.suffix}")
    if direct.is_file():
        return _project_relative(project_root, direct)
    parent = png_path.parent
    if parent.is_dir():
        matches = sorted(
            p
            for p in parent.glob(f"{png_path.stem}_photoreal*")
            if p.is_file() and p.suffix.lower() == ".png"
        )
        if matches:
            return _project_relative(project_root, matches[0])
    return None


def _coverage_by_shot(project: str, coverage_id: str) -> dict[str, dict[str, Any]]:
    store_exec = get_store(project)
    try:
        shots = [
            shot
            for shot in store_exec.get_all_shots()
            if str(shot.get("shot_id", "")).startswith(coverage_id + "_")
        ]
    finally:
        store_exec.close()

    shot_map = {
        str(shot["shot_id"]): {
            "covered": False,
            "status": shot.get("status", "untracked"),
        }
        for shot in shots
        if shot.get("shot_id")
    }

    store = PassStore(project)
    try:
        passes = store.list_passes(coverage_id)
    finally:
        store.close()
    for record in passes:
        for shot_id in record.get("segment_shot_ids") or []:
            shot_map.setdefault(
                str(shot_id),
                {"covered": False, "status": "untracked"},
            )["covered"] = True
    return shot_map


def _coverage_summary_for_batch(
    segment_ids: list[str],
    shot_coverage: dict[str, dict[str, Any]],
) -> dict[str, int]:
    total = len(segment_ids)
    covered = sum(
        1 for segment_id in segment_ids if shot_coverage.get(segment_id, {}).get("covered")
    )
    return {
        "covered": covered,
        "total": total,
        "awaiting": max(0, total - covered),
    }


def _read_json_dict(path: Path) -> dict[str, Any]:
    """Read a board sidecar.

    Missing sidecar -> {} (legitimate: the board PNG exists but no sidecar has
    been written yet — the caller degrades to pointer-derived defaults). A
    sidecar that EXISTS but is unreadable / malformed / non-dict raises
    SidecarCorruptError instead of silently collapsing to {} and emitting
    default "proposed" review state from corrupt JSON (REC-239).
    """
    if not path.exists():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as e:
        raise SidecarCorruptError(str(path), message=str(e)) from e
    if not isinstance(data, dict):
        raise SidecarCorruptError(
            str(path), message=f"expected a JSON object, got {type(data).__name__}"
        )
    return data


def _project_relative(project_root: Path, path: Path) -> str:
    return str(path.relative_to(project_root))


def _float_or_zero(value: Any) -> float:
    try:
        return float(value)
    except (TypeError, ValueError):
        return 0.0
