#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""G4.2 canary — owned-violation cumulative count (G3.2 sentinel CP read).

Reads pre-computed `t2i_variations[*].owned_validation` sentinel objects from
the scene_detail checkpoint. Each sentinel `violations` list length is summed
per shot (and per scene_set). NO LLM calls. Uses the strict shape validator
from `app.core.steps._owned_helpers.assert_owned_sentinel_shape` (plan-R2-I5).

Exit criteria (analyzer):
  candidate.metrics.owned_violations_total <= baseline.metrics.owned_violations_total
  AND both runs' measurement_failures lists are empty.

Usage (config mode):
    python scripts/canary/g4_2_owned_violations.py \\
        --config canary_config.json --role candidate \\
        --output results/owned_violations_candidate.json
"""
from __future__ import annotations

import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

# Ensure backend module is importable for assert_owned_sentinel_shape.
_REPO_ROOT = Path(__file__).resolve().parents[2]
_BACKEND_PATH = _REPO_ROOT / "backend"
if str(_BACKEND_PATH) not in sys.path:
    sys.path.insert(0, str(_BACKEND_PATH))


def iso8601_now() -> str:
    return datetime.utcnow().isoformat() + "Z"


def _fail(msg: str, code: int = 1) -> None:
    print(f"[g4_2_owned_violations] ERROR: {msg}", file=sys.stderr)
    sys.exit(code)


def load_pinning(args: argparse.Namespace) -> Dict[str, Any]:
    if args.config:
        cfg_path = Path(args.config)
        if not cfg_path.exists():
            _fail(f"--config file not found: {cfg_path}")
        try:
            with cfg_path.open("r", encoding="utf-8") as fp:
                cfg = json.load(fp)
        except Exception as exc:
            _fail(f"--config parse error: {exc}")
        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"),
            "chain_bg_card_snapshot_hash": cfg.get("chain_bg_card_snapshot_hash"),
        }
    try:
        scene_index_list = (
            json.loads(args.scene_index_list) if args.scene_index_list else None
        )
        shot_index_list_per_scene = (
            json.loads(args.shot_index_list_per_scene)
            if args.shot_index_list_per_scene else None
        )
    except Exception as exc:
        _fail(f"failed to parse list/dict JSON args: {exc}")
    return {
        "pid": args.pid,
        "scene_index_list": scene_index_list,
        "shot_index_list_per_scene": shot_index_list_per_scene,
        "model_routing": args.model_routing,
        "prompt_source_mode": args.prompt_source_mode,
        "card_commit_hash": args.card_commit_hash,
        "chain_bg_card_snapshot_hash": args.chain_bg_card_snapshot_hash,
    }


def validate_pinning(pinning: Dict[str, Any]) -> None:
    required = [
        "pid", "scene_index_list", "shot_index_list_per_scene",
        "model_routing", "prompt_source_mode",
        "card_commit_hash", "chain_bg_card_snapshot_hash",
    ]
    missing = [k for k in required if pinning.get(k) in (None, "")]
    if missing:
        _fail(
            f"pinning missing required field(s): {missing}. "
            f"Provide via --config <json> or individual flags. NO silent defaults."
        )
    if not isinstance(pinning["scene_index_list"], list):
        _fail("pinning.scene_index_list must be list[int]")
    if not isinstance(pinning["shot_index_list_per_scene"], dict):
        _fail("pinning.shot_index_list_per_scene must be dict[str, list[int]]")


def build_pinning_scene_set(pinning: Dict[str, Any]) -> List[Tuple[int, int]]:
    """Cartesian (scene_index, shot_index) tuples from pinning block.

    Strict — pinning enumerates EXACT (si, shi) tuples. A scene_index without
    a corresponding shot_index_list entry (or with a non-list / empty list)
    is a contract violation, not a silent default. fail-fast via _fail.
    """
    out: List[Tuple[int, int]] = []
    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:
            _fail(
                f"pinning.scene_index_list contains scene {si_int} but "
                f"shot_index_list_per_scene has no entry for it. NO silent default."
            )
        if not isinstance(raw_shots, list):
            _fail(
                f"pinning.shot_index_list_per_scene[{si_int}] must be list[int], "
                f"got {type(raw_shots).__name__}. NO silent default."
            )
        if not raw_shots:
            _fail(
                f"pinning.shot_index_list_per_scene[{si_int}] is empty — "
                f"a pinned scene must enumerate at least one shot. NO silent default."
            )
        for shi in raw_shots:
            out.append((si_int, int(shi)))
    return out


def load_cp_manifest(cp_root: Path) -> Dict[str, Any]:
    manifest_path = cp_root / "manifest.json"
    if not manifest_path.exists():
        _fail(f"manifest.json not found at {manifest_path}")
    try:
        with manifest_path.open("r", encoding="utf-8") as fp:
            return json.load(fp)
    except Exception as exc:
        _fail(f"manifest.json parse error: {exc}")


def is_close_framing(scene: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
    """Return (cf_flag, error_message).

    Reads from `scene["render_prompt_card"]["background_binding"]` — the actual
    CP shape produced by detail_steps.py (scene["background_binding"] does NOT
    exist as a flat field). When the scene record is missing the
    `render_prompt_card` envelope or its `background_binding` field, returns
    (False, reason) so the caller can append to measurement_failures
    (NO silent absorb — Codex C1 fix).

    error_message is None on success.
    """
    rpc = scene.get("render_prompt_card")
    if not isinstance(rpc, dict):
        return (False, "render_prompt_card missing or not dict")
    bb = rpc.get("background_binding")
    if not isinstance(bb, dict):
        return (False, "render_prompt_card.background_binding missing or not dict")
    if bb.get("mode") == "skipped_close_framing":
        return (True, None)
    if bb.get("close_framing_skips_background_ref") is True:
        return (True, None)
    return (False, None)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="G4.2 canary: owned-violation cumulative count (G3.2 sentinel CP read)."
    )
    parser.add_argument("--config", type=str, default=None)
    parser.add_argument("--pid", type=str, default=None)
    parser.add_argument("--episode", type=str, default=None)
    parser.add_argument("--cp-root", type=str, default=None,
                        help="Directory containing scene_detail manifest.json")
    parser.add_argument("--prompt-version", type=str, required=True)
    parser.add_argument("--role", type=str, default="candidate",
                        choices=["baseline", "candidate"])
    parser.add_argument("--scene-index-list", type=str, default=None)
    parser.add_argument("--shot-index-list-per-scene", type=str, default=None)
    parser.add_argument("--model-routing", type=str, default=None)
    parser.add_argument("--prompt-source-mode", type=str, default=None,
                        choices=[None, "file", "db"])
    parser.add_argument("--card-commit-hash", type=str, default=None)
    parser.add_argument("--chain-bg-card-snapshot-hash", type=str, default=None)
    parser.add_argument("--output", type=str, required=True)
    args = parser.parse_args()

    pinning = load_pinning(args)
    validate_pinning(pinning)

    # Strict shape validator import — plan-R2-I5.
    try:
        from app.core.steps._owned_helpers import assert_owned_sentinel_shape
    except ModuleNotFoundError as exc:
        _fail(
            f"Cannot import app.core.steps._owned_helpers (backend not on sys.path?): {exc}. "
            f"Run from repo root or set PYTHONPATH=<repo>/backend."
        )

    cp_root_str: str | None = None
    if args.config:
        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 = args.cp_root
    if not cp_root_str:
        _fail("--cp-root (or config.cp_root) is required")
    cp_root = Path(cp_root_str)
    if not cp_root.exists():
        _fail(f"--cp-root path does not exist: {cp_root}")

    cp = load_cp_manifest(cp_root)
    # Codex 2 (silent absorption fix): reject malformed CP shape explicitly.
    # `(cp.get("data") or {}).get("scenes") or []` would silently produce
    # empty scenes list → STRICT gate trivially passes. fail-fast instead.
    data = cp.get("data")
    if not isinstance(data, dict):
        _fail("cp['data'] is missing or not dict")
    scenes = data.get("scenes")
    if not isinstance(scenes, list):
        _fail("cp['data']['scenes'] is missing or not list")

    pinning_scene_set = set(build_pinning_scene_set(pinning))
    seen_in_cp: set[Tuple[int, int]] = set()

    owned_violations_total = 0
    owned_violations_per_shot: Dict[str, int] = {}
    measurement_failures: List[str] = []
    scene_set_out: List[Dict[str, Any]] = []
    total_shots = 0

    for scene in scenes:
        if not isinstance(scene, dict):
            continue
        si = scene.get("scene_index")
        shi = scene.get("_shot_index")
        if si is None or shi is None:
            continue
        if (si, shi) not in pinning_scene_set:
            continue
        seen_in_cp.add((int(si), int(shi)))
        total_shots += 1
        cf, cf_err = is_close_framing(scene)
        if cf_err:
            measurement_failures.append(
                f"s{si}_sh{shi}: {cf_err}"
            )
        scene_set_out.append({
            "scene_index": si, "shot_index": shi, "is_close_framing": cf,
        })

        # t2i_variations presence/shape validation — silent absorb 차단.
        # Codex iter2 BLOCKING 2.
        variations = scene.get("t2i_variations")
        if not isinstance(variations, list) or not variations:
            measurement_failures.append(
                f"s{si}_sh{shi}: t2i_variations missing, empty, or not list"
            )
            continue

        shot_count = 0
        for var in variations:
            sentinel = var.get("owned_validation")
            if sentinel is None:
                measurement_failures.append(
                    f"s{si}_sh{shi}: owned_validation sentinel 부재"
                )
                continue
            try:
                assert_owned_sentinel_shape(
                    sentinel, where=f"canary.owned_violations s{si}_sh{shi}"
                )
            except Exception as exc:
                measurement_failures.append(
                    f"s{si}_sh{shi}: sentinel shape 위반 — {exc}"
                )
                continue
            violations = sentinel.get("violations") or []
            shot_count += len(violations)
        owned_violations_total += shot_count
        owned_violations_per_shot[f"{si}_{shi}"] = shot_count

    # Pinned tuple presence check — fail-fast if pinning enumerates a (si, shi)
    # that the CP doesn't contain. silent absorb (data.scenes 가 짧거나 비어 있어도
    # false-pass) 차단. Codex iter2 BLOCKING 1.
    for (si, shi) in pinning_scene_set:
        if (si, shi) not in seen_in_cp:
            measurement_failures.append(
                f"pinning (s{si}_sh{shi}): not present in CP data.scenes"
            )

    out = {
        "timestamp": iso8601_now(),
        "prompt_version": args.prompt_version,
        "role": args.role,
        "pinning": pinning,
        "scene_set": scene_set_out,
        "metrics": {
            "total_shots": total_shots,
            "owned_violations_total": owned_violations_total,
            "owned_violations_per_shot": owned_violations_per_shot,
        },
        "measurement_failures": measurement_failures,
        "measurement_scripts": {
            "owned_violations": "scripts/canary/g4_2_owned_violations.py (G3.2 sentinel CP read — plan-R1-B2)"
        },
    }

    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)

    print(
        f"[g4_2_owned_violations] role={args.role} prompt_version={args.prompt_version}\n"
        f"  total_shots={total_shots}  owned_violations_total={owned_violations_total}\n"
        f"  measurement_failures: {len(measurement_failures)}\n"
        f"Exit criteria:\n"
        f"  candidate.metrics.owned_violations_total <= baseline.metrics.owned_violations_total\n"
        f"  AND both runs' measurement_failures must be empty.",
        file=sys.stderr,
    )
    # Codex 1 fix — non-zero exit when measurement_failures is non-empty
    # (cannot judge cumulative-count gate). The cross-role comparison is
    # OUT-OF-BAND (analyzer compares baseline.json vs candidate.json), so
    # the script's exit code only signals measurement-side failures here.
    if measurement_failures:
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
