#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""G4.4 canary — STRICT double-description detection (Task 4.1).

Detects co-occurrence of a mapped character ID (`C##` / `C##O##`) and a
generic person noun (`an Asian man` / `a young woman` / `a figure` etc. —
imported from `_CONTINUITY_GENERIC_PERSON_NOUNS`) within a ±50 chars
window. Generic noun list is imported from
`render_prompt_card._CONTINUITY_GENERIC_PERSON_NOUNS` (Trap #8 — module-level
constant single source).

Scope: id-mapping shots only (R2-B4 carry — `fixed_elements[i].character_name`
maps to a `C##` / `C##O##` listed in `id_policy.allowed_outlook_pairs`).

Exit criteria (analyzer, OUT-OF-BAND):
  candidate.metrics.double_description_count == 0  (STRICT, Override O-1)

Override O-7 — paired-string substring carry: any double-description with the
same character ID treated as cross-card / cross-shot violation; canary
intentionally does NOT inspect cross_shot_id_substitution_rule wording.

Usage (config mode):
    python scripts/canary/g4_4_double_description.py \\
        --config canary_config.json \\
        --cp-root data/projects/<pid>/checkpoints/scene_detail \\
        --prompt-version 19.<timestamp> \\
        --role candidate \\
        --output results/double_description_candidate.json
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path

# Make scripts/canary importable AND backend/ on sys.path so the production
# render_prompt_card module resolves.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _g4_4_common import (  # noqa: E402
    _load_scenes_or_fail,
    _validate_scene_preflight,
    build_pinning_scene_set,
    emit_output,
    load_cp_manifest,
    load_pinning_from_args,
    render_prompt_card_imports,
    resolve_cp_root,
    validate_pinning,
)

_CONSTS = render_prompt_card_imports()
_CONTINUITY_GENERIC_PERSON_NOUNS = _CONSTS["_CONTINUITY_GENERIC_PERSON_NOUNS"]

# bare C## OR composite C##O##
_ID_REGEX = re.compile(r"\bC\d{2}(?:O\d{2})?\b")
_WINDOW_CHARS = 50


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


def main() -> int:
    parser = argparse.ArgumentParser(
        description=(
            "G4.4 canary: double-description detection "
            "(id-mapping shots only — Trap #8 module-level constants)."
        )
    )
    parser.add_argument("--config", type=str, default=None)
    parser.add_argument("--pid", 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(
        "--continuity-card-snapshot-hash", type=str, default=None
    )
    parser.add_argument("--output", type=str, required=True)
    args = parser.parse_args()

    pinning = load_pinning_from_args(args)
    validate_pinning(pinning)
    cp_root = resolve_cp_root(args)
    cp = load_cp_manifest(cp_root)

    measurement_failures: list[str] = []
    scenes = _load_scenes_or_fail(cp, measurement_failures)
    if scenes is None:
        out = _empty_output(args, pinning, measurement_failures)
        emit_output(out, args)
        return 1

    pinning_set = build_pinning_scene_set(pinning)
    seen_in_cp: set[tuple[int, int]] = set()

    total_shots_with_id_mapping = 0
    double_description_count = 0
    double_description_per_shot: dict[str, int] = {}
    scene_set_out: list[dict] = []

    for scene in scenes:
        if not isinstance(scene, dict):
            measurement_failures.append("scene entry is not dict")
            continue
        si = scene.get("scene_index")
        shi = scene.get("_shot_index")
        if si is None or shi is None:
            continue
        si_int = int(si)
        shi_int = int(shi)
        if (si_int, shi_int) not in pinning_set:
            continue
        seen_in_cp.add((si_int, shi_int))

        variations = _validate_scene_preflight(
            scene, si_int, shi_int, measurement_failures
        )
        if variations is None:
            continue

        rpc = scene["render_prompt_card"]
        ce = rpc["continuity_elements_used"]
        # Trap #1 — explicit key existence + type validation; NO `.get(default)`
        if "fixed_elements" not in ce:
            measurement_failures.append(
                f"s{si_int}_sh{shi_int}: continuity_elements_used."
                f"fixed_elements key missing"
            )
            continue
        fixed_elements = ce["fixed_elements"]
        if not isinstance(fixed_elements, list):
            measurement_failures.append(
                f"s{si_int}_sh{shi_int}: continuity_elements_used."
                f"fixed_elements not list"
            )
            continue
        if "id_policy" not in rpc:
            measurement_failures.append(
                f"s{si_int}_sh{shi_int}: render_prompt_card.id_policy "
                f"key missing"
            )
            continue
        id_policy = rpc["id_policy"]
        if not isinstance(id_policy, dict):
            measurement_failures.append(
                f"s{si_int}_sh{shi_int}: render_prompt_card.id_policy "
                f"not dict"
            )
            continue
        if "allowed_outlook_pairs" not in id_policy:
            measurement_failures.append(
                f"s{si_int}_sh{shi_int}: id_policy.allowed_outlook_pairs "
                f"key missing"
            )
            continue
        allowed_pairs = id_policy["allowed_outlook_pairs"]
        if not isinstance(allowed_pairs, list):
            measurement_failures.append(
                f"s{si_int}_sh{shi_int}: id_policy.allowed_outlook_pairs "
                f"not list"
            )
            continue

        # id-mapping presence — fixed_elements[i].character_name matches a
        # character_id (or composite C##O## form) in id_policy
        # .allowed_outlook_pairs.
        has_id_mapping = False
        for fe in fixed_elements:
            if not isinstance(fe, dict):
                continue
            character_name = fe.get("character_name")
            if not isinstance(character_name, str) or not character_name:
                continue
            for op in allowed_pairs:
                if not isinstance(op, dict):
                    continue
                cid = op.get("character_id")
                oid = op.get("outlook_id")
                if (
                    isinstance(cid, str) and character_name == cid
                ) or (
                    isinstance(cid, str)
                    and isinstance(oid, str)
                    and character_name == f"{cid}{oid}"
                ):
                    has_id_mapping = True
                    break
            if has_id_mapping:
                break

        scene_set_out.append({
            "scene_index": si_int,
            "shot_index": shi_int,
            "has_fixed_element_id_mapping": has_id_mapping,
        })

        if not has_id_mapping:
            continue  # scope 외 — id_mapping 부재 shot 제외 (R2-B4 carry)

        total_shots_with_id_mapping += 1
        shot_count = 0
        for vi, var in enumerate(variations):
            if not isinstance(var, dict):
                measurement_failures.append(
                    f"s{si_int}_sh{shi_int}_v{vi}: variation not a dict"
                )
                continue
            prompt = var.get("t2i_prompt")
            if not isinstance(prompt, str) or not prompt.strip():
                measurement_failures.append(
                    f"s{si_int}_sh{shi_int}_v{vi}: t2i_prompt missing, "
                    f"empty, or not str"
                )
                continue

            # ID positions — full-text scan (no truncation, CLAUDE.md carry).
            prompt_lower = prompt.lower()
            for match in _ID_REGEX.finditer(prompt):
                id_start = match.start()
                id_end = match.end()
                # ±50 chars window — generic noun substring presence
                win_start = max(0, id_start - _WINDOW_CHARS)
                win_end = min(len(prompt), id_end + _WINDOW_CHARS)
                window = prompt_lower[win_start:win_end]
                for noun in _CONTINUITY_GENERIC_PERSON_NOUNS:
                    if noun.lower() in window:
                        shot_count += 1
                        break  # one violation per ID per window

        double_description_count += shot_count
        double_description_per_shot[f"{si_int}_{shi_int}"] = shot_count

    # Trap #4 — pinned-tuple post-loop validation.
    for (si, shi) in pinning_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_with_id_mapping": total_shots_with_id_mapping,
            "double_description_count": double_description_count,
            "double_description_per_shot": double_description_per_shot,
            "generic_person_nouns_used": list(
                _CONTINUITY_GENERIC_PERSON_NOUNS
            ),
        },
        "measurement_failures": measurement_failures,
        "exit_criteria_threshold": 0,
        "exit_criteria_pass": (
            not measurement_failures and double_description_count == 0
        ),
        "measurement_scripts": {
            "double_description": (
                "scripts/canary/g4_4_double_description.py "
                "(id-mapping shots only — Override O-7 / Trap #8)"
            )
        },
    }

    emit_output(out, args)

    print(
        f"[g4_4_double_description] role={args.role} "
        f"prompt_version={args.prompt_version}\n"
        f"  total_shots_with_id_mapping={total_shots_with_id_mapping}\n"
        f"  double_description_count={double_description_count}\n"
        f"  measurement_failures: {len(measurement_failures)}\n"
        f"Exit criteria (STRICT): "
        f"candidate.metrics.double_description_count == 0",
        file=sys.stderr,
    )

    if measurement_failures:
        return 1
    if args.role == "candidate" and double_description_count > 0:
        return 1
    return 0


def _empty_output(
    args: argparse.Namespace,
    pinning: dict,
    measurement_failures: list,
) -> dict:
    return {
        "timestamp": iso8601_now(),
        "prompt_version": args.prompt_version,
        "role": args.role,
        "pinning": pinning,
        "scene_set": [],
        "metrics": {
            "total_shots_with_id_mapping": 0,
            "double_description_count": 0,
            "double_description_per_shot": {},
            "generic_person_nouns_used": list(
                _CONTINUITY_GENERIC_PERSON_NOUNS
            ),
        },
        "measurement_failures": measurement_failures,
        "exit_criteria_threshold": 0,
        "exit_criteria_pass": False,
        "measurement_scripts": {
            "double_description": (
                "scripts/canary/g4_4_double_description.py "
                "(id-mapping shots only — Override O-7 / Trap #8)"
            )
        },
    }


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