#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Shared preflight helpers for G4.4 canary scripts (Wave 3 carry).

All 4 metric scripts (`g4_4_double_description.py`,
`g4_4_zoom_in_detail_no_new_entity.py`, `g4_4_atmosphere_no_layout_import.py`,
`g4_4_view_mixing.py`) import the helpers from this module and apply them
identically. The token-count script (`g4_4_token_count.py`) does NOT use
these helpers — it reads prompt files, not CP, and per R3-M2 must remain
CP-free (no `compute_continuity_snapshot_hash` import).

Trap #1 silent-absorb ban (Wave 3 carry — G4.3 R3R4-B2 carry):
- `or [] / or {}` 패턴 금지.
- `data.scenes` / `t2i_variations` / `t2i_prompt` /
  `render_prompt_card` / `continuity_elements_used` 누락은
  measurement_failures 로 적재 후 caller 가 exit 1.
- `compute_continuity_snapshot_hash()` 호출 실패도 measurement_failures 로
  적재 (silent skip 금지).
"""
from __future__ import annotations

import json
import sys
from pathlib import Path
from typing import Any

# Ensure backend/ is on sys.path so canary scripts can import the production
# render_prompt_card module without altering PYTHONPATH at the shell level.
_BACKEND_PATH = Path(__file__).resolve().parents[2] / "backend"
if str(_BACKEND_PATH) not in sys.path:
    sys.path.insert(0, str(_BACKEND_PATH))


def render_prompt_card_imports() -> dict:
    """Return the G4.4 + G4.3-cross module symbols Wave 1-A exposes.

    Centralised to give every canary script a single import boundary; if
    Wave 1-A renames a constant, only this module needs adjustment.

    Trap #8 carry — module-level constants direct import; NO inline list
    duplication of the 7-entry generic person nouns / 7-entry furniture
    layout tokens / 4-entry body-part triggers / etc.
    """
    from app.core.steps.render_prompt_card import (  # noqa: PLC0415
        _CONTINUITY_FRAMING_SCOPE_OPTIONS,
        _CONTINUITY_FURNITURE_LAYOUT_TOKENS,
        _CONTINUITY_GENERIC_PERSON_NOUNS,
        _CONTINUITY_ID_POLICY_CROSS_REF_LITERAL,
        _CONTINUITY_REF_USAGE_VALUES,
        _ID_BODY_PART_TRIGGERS,
        compute_continuity_snapshot_hash,
    )

    return {
        "_CONTINUITY_FRAMING_SCOPE_OPTIONS": _CONTINUITY_FRAMING_SCOPE_OPTIONS,
        "_CONTINUITY_FURNITURE_LAYOUT_TOKENS": _CONTINUITY_FURNITURE_LAYOUT_TOKENS,
        "_CONTINUITY_GENERIC_PERSON_NOUNS": _CONTINUITY_GENERIC_PERSON_NOUNS,
        "_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL": _CONTINUITY_ID_POLICY_CROSS_REF_LITERAL,
        "_CONTINUITY_REF_USAGE_VALUES": _CONTINUITY_REF_USAGE_VALUES,
        "_ID_BODY_PART_TRIGGERS": _ID_BODY_PART_TRIGGERS,
        "compute_continuity_snapshot_hash": compute_continuity_snapshot_hash,
    }


def _load_scenes_or_fail(cp: dict, measurement_failures: list) -> list | None:
    """Validate CP envelope shape and return scenes list (or None on failure).

    Caller must check None and fail-fast (sys.exit(1)) — measurement_failures
    is appended in-place. Trap #1 — silent-absorb ban.
    """
    if "data" not in cp:
        measurement_failures.append("cp.data key missing")
        return None
    data = cp["data"]
    if not isinstance(data, dict):
        measurement_failures.append("cp.data not dict")
        return None
    if "scenes" not in data:
        measurement_failures.append("cp.data.scenes key missing")
        return None
    scenes = data["scenes"]
    if not isinstance(scenes, list):
        measurement_failures.append("cp.data.scenes not list")
        return None
    return scenes


def _validate_scene_preflight(
    scene: dict,
    si: int | None,
    shi: int | None,
    measurement_failures: list,
) -> list | None:
    """Validate per-scene shape — render_prompt_card / continuity_elements_used /
    variations.

    Returns variations list when valid; None otherwise (caller continues
    loop). Per spec §5.3 + Trap #1 silent-absorb ban + Trap #3 CP shape:

      - render_prompt_card must be dict.
      - render_prompt_card.continuity_elements_used must be dict.
      - compute_continuity_snapshot_hash(rpc) must succeed (no silent skip).
      - t2i_variations must be present, list, AND non-empty (R3-B1 carry —
        empty must fail).
    """
    if "render_prompt_card" not in scene:
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card key missing"
        )
        return None
    rpc = scene["render_prompt_card"]
    if not isinstance(rpc, dict):
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card not dict"
        )
        return None
    if "continuity_elements_used" not in rpc:
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card.continuity_elements_used "
            f"key missing"
        )
        return None
    ce = rpc["continuity_elements_used"]
    if not isinstance(ce, dict):
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card.continuity_elements_used "
            f"not dict"
        )
        return None
    try:
        # Import lazily — see render_prompt_card_imports() rationale.
        from app.core.steps.render_prompt_card import (  # noqa: PLC0415
            compute_continuity_snapshot_hash,
        )

        compute_continuity_snapshot_hash(rpc)
    except Exception as exc:  # noqa: BLE001 — surface ALL failure modes
        measurement_failures.append(
            f"s{si}_sh{shi}: continuity hash failed: {exc}"
        )
        return None
    if "t2i_variations" not in scene:
        measurement_failures.append(
            f"s{si}_sh{shi}: t2i_variations key missing"
        )
        return None
    variations = scene["t2i_variations"]
    if not isinstance(variations, list):
        measurement_failures.append(
            f"s{si}_sh{shi}: t2i_variations not list"
        )
        return None
    if not variations:
        # R3-B1 carry — empty must fail (Trap #1 silent-absorb ban).
        measurement_failures.append(
            f"s{si}_sh{shi}: t2i_variations empty"
        )
        return None
    return variations


def load_pinning_from_args(args: Any) -> dict:
    """Construct pinning dict from --config JSON OR individual CLI flags.

    Returns dict with 7 keys (G4.4 pinning block — Override O-12):
      pid / scene_index_list / shot_index_list_per_scene / model_routing /
      prompt_source_mode / card_commit_hash / continuity_card_snapshot_hash

    Trap #1 — NO silent defaults; caller must run validate_pinning() afterwards.
    """
    if getattr(args, "config", None):
        cfg_path = Path(args.config)
        if not cfg_path.exists():
            print(
                f"[g4_4_common] ERROR: --config file not found: {cfg_path}",
                file=sys.stderr,
            )
            sys.exit(1)
        try:
            with cfg_path.open("r", encoding="utf-8") as fp:
                cfg = json.load(fp)
        except Exception as exc:  # noqa: BLE001
            print(
                f"[g4_4_common] ERROR: --config parse error: {exc}",
                file=sys.stderr,
            )
            sys.exit(1)
        return {
            "pid": cfg.get("pid"),
            "scene_index_list": cfg.get("scene_index_list"),
            "shot_index_list_per_scene": cfg.get("shot_index_list_per_scene"),
            "model_routing": cfg.get("model_routing"),
            "prompt_source_mode": cfg.get("prompt_source_mode"),
            "card_commit_hash": cfg.get("card_commit_hash"),
            "continuity_card_snapshot_hash": cfg.get(
                "continuity_card_snapshot_hash"
            ),
        }
    try:
        scene_index_list = (
            json.loads(args.scene_index_list)
            if getattr(args, "scene_index_list", None)
            else None
        )
        shot_index_list_per_scene = (
            json.loads(args.shot_index_list_per_scene)
            if getattr(args, "shot_index_list_per_scene", None)
            else None
        )
    except Exception as exc:  # noqa: BLE001
        print(
            f"[g4_4_common] ERROR: failed to parse list/dict JSON args: {exc}",
            file=sys.stderr,
        )
        sys.exit(1)
    return {
        "pid": getattr(args, "pid", None),
        "scene_index_list": scene_index_list,
        "shot_index_list_per_scene": shot_index_list_per_scene,
        "model_routing": getattr(args, "model_routing", None),
        "prompt_source_mode": getattr(args, "prompt_source_mode", None),
        "card_commit_hash": getattr(args, "card_commit_hash", None),
        "continuity_card_snapshot_hash": getattr(
            args, "continuity_card_snapshot_hash", None
        ),
    }


def validate_pinning(pinning: dict) -> None:
    """Fail-fast on missing pinning fields. Trap #1 — NO silent defaults."""
    required = (
        "pid",
        "scene_index_list",
        "shot_index_list_per_scene",
        "model_routing",
        "prompt_source_mode",
        "card_commit_hash",
        "continuity_card_snapshot_hash",
    )
    missing = [k for k in required if pinning.get(k) in (None, "")]
    if missing:
        print(
            f"[g4_4_common] ERROR: pinning missing required field(s): "
            f"{missing}. Provide via --config <json> or individual flags. "
            f"NO silent defaults.",
            file=sys.stderr,
        )
        sys.exit(1)
    if not isinstance(pinning["scene_index_list"], list):
        print(
            "[g4_4_common] ERROR: pinning.scene_index_list must be list[int]",
            file=sys.stderr,
        )
        sys.exit(1)
    if not isinstance(pinning["shot_index_list_per_scene"], dict):
        print(
            "[g4_4_common] ERROR: pinning.shot_index_list_per_scene "
            "must be dict",
            file=sys.stderr,
        )
        sys.exit(1)


def build_pinning_scene_set(pinning: dict) -> set[tuple[int, int]]:
    """Cartesian (scene_index, shot_index) tuples from pinning block.

    Trap #4 — pinning enumerates EXACT (si, shi) tuples. A scene_index without
    a corresponding shot_index_list entry (or with non-list / empty list) is a
    contract violation, not a silent default.
    """
    out: set[tuple[int, int]] = set()
    shot_map = pinning["shot_index_list_per_scene"]
    for si in pinning["scene_index_list"]:
        si_int = int(si)
        raw_shots = shot_map.get(str(si_int))
        if raw_shots is None:
            raw_shots = shot_map.get(si_int)
        if raw_shots is None:
            print(
                f"[g4_4_common] ERROR: pinning.scene_index_list contains "
                f"scene {si_int} but shot_index_list_per_scene has no entry. "
                f"NO silent default.",
                file=sys.stderr,
            )
            sys.exit(1)
        if not isinstance(raw_shots, list):
            print(
                f"[g4_4_common] ERROR: pinning.shot_index_list_per_scene"
                f"[{si_int}] must be list[int], got "
                f"{type(raw_shots).__name__}.",
                file=sys.stderr,
            )
            sys.exit(1)
        if not raw_shots:
            print(
                f"[g4_4_common] ERROR: pinning.shot_index_list_per_scene"
                f"[{si_int}] is empty — pinned scene must enumerate "
                f">=1 shot.",
                file=sys.stderr,
            )
            sys.exit(1)
        for shi in raw_shots:
            out.add((si_int, int(shi)))
    return out


def load_cp_manifest(cp_root: Path) -> dict:
    """Load `manifest.json` under cp_root. fail-fast on missing/parse error."""
    manifest_path = cp_root / "manifest.json"
    if not manifest_path.exists():
        print(
            f"[g4_4_common] ERROR: manifest.json not found at "
            f"{manifest_path}",
            file=sys.stderr,
        )
        sys.exit(1)
    try:
        with manifest_path.open("r", encoding="utf-8") as fp:
            return json.load(fp)
    except Exception as exc:  # noqa: BLE001
        print(
            f"[g4_4_common] ERROR: manifest.json parse error: {exc}",
            file=sys.stderr,
        )
        sys.exit(1)


def resolve_cp_root(args: Any) -> Path:
    """Resolve cp_root from --config or --cp-root flag. Fail-fast on missing.

    Trap #1 — NO silent defaults.
    """
    cp_root_str: str | None = None
    if getattr(args, "config", None):
        with Path(args.config).open("r", encoding="utf-8") as fp:
            cfg = json.load(fp)
        cp_root_str = cfg.get("cp_root")
    if not cp_root_str:
        cp_root_str = getattr(args, "cp_root", None)
    if not cp_root_str:
        print(
            "[g4_4_common] ERROR: --cp-root (or config.cp_root) required",
            file=sys.stderr,
        )
        sys.exit(1)
    cp_root = Path(cp_root_str)
    if not cp_root.exists():
        print(
            f"[g4_4_common] ERROR: --cp-root not found: {cp_root}",
            file=sys.stderr,
        )
        sys.exit(1)
    return cp_root


def emit_output(out: dict, args: Any) -> None:
    """Write JSON output to args.output ('-' for stdout)."""
    if args.output == "-":
        json.dump(out, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
        sys.stdout.write("\n")
    else:
        out_path = Path(args.output)
        out_path.parent.mkdir(parents=True, exist_ok=True)
        with out_path.open("w", encoding="utf-8") as fp:
            json.dump(out, fp, ensure_ascii=False, indent=2, sort_keys=True)
