#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""G4.3 canary — STRICT close-framing face-filling forbidden phrasing counter (Task 4.2).

Detects 6 literal forbidden substrings inside `t2i_prompt` of close-framing
shots only. Phrases are imported from
`render_prompt_card._ID_CLOSE_FACE_FORBIDDEN_PHRASES` (Override R4-B3 carry —
all 6 phrases including masculine `"his face fills the frame"`).

Note (R4-B3 carry): the v18 compact section + constraints[3] inline list
contains only 5 phrases for token cost reasons; the 6th masculine entry is
preserved via the structured field `close_framing_face_phrasing.forbidden_phrases`
which is injected to the LLM. Therefore this canary checks all 6 phrases —
the structured field is the instruction source and any leak (regardless of
which inline list dropped it) is a contract violation.

Scope: close-framing shots only (Override R3-I1).

Exit criteria (analyzer, OUT-OF-BAND):
  candidate.metrics.close_framing_face_forbidden_count == 0  (STRICT)
  baseline value is informational only.

Usage:
    python scripts/canary/g4_3_close_framing_face_forbidden.py \\
        --config canary_config.json \\
        --cp-root data/projects/<pid>/checkpoints/scene_detail \\
        --prompt-version 17.<timestamp> \\
        --role candidate \\
        --output results/close_framing_face_forbidden_candidate.json
"""
from __future__ import annotations

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

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _g4_3_common import (  # noqa: E402
    _load_scenes_or_fail,
    _validate_scene_preflight,
    build_pinning_scene_set,
    is_close_framing,
    load_cp_manifest,
    load_pinning_from_args,
    render_prompt_card_imports,
    validate_pinning,
)

_CONSTS = render_prompt_card_imports()
_ID_CLOSE_FACE_FORBIDDEN_PHRASES = _CONSTS["_ID_CLOSE_FACE_FORBIDDEN_PHRASES"]
_ID_REPRODUCTION_SURFACES = _CONSTS["_ID_REPRODUCTION_SURFACES"]

_COMPOSITE_ID_RE = re.compile(r"\bC\d{2}(?:O\d{2})?\b")


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


def find_forbidden_phrases(prompt: str) -> list[str]:
    """Return list of forbidden phrases that appear in `prompt` (case-insensitive
    literal substring match — NO regex needed — `feedback_no_regex_postprocessing.md` carry)."""
    found: list[str] = []
    pl = prompt.lower()
    for phrase in _ID_CLOSE_FACE_FORBIDDEN_PHRASES:
        if phrase.lower() in pl:
            found.append(phrase)
    return found


def main() -> int:
    parser = argparse.ArgumentParser(
        description="G4.3 canary: close-framing face-fill forbidden phrasing counter (6 phrases)."
    )
    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)
    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("--id-policy-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_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:
        print("[g4_3_close_framing_face_forbidden] ERROR: --cp-root required",
              file=sys.stderr)
        return 1
    cp_root = Path(cp_root_str)
    if not cp_root.exists():
        print(f"[g4_3_close_framing_face_forbidden] ERROR: --cp-root not found: {cp_root}",
              file=sys.stderr)
        return 1

    cp = load_cp_manifest(cp_root)
    measurement_failures: list[str] = []

    scenes = _load_scenes_or_fail(cp, measurement_failures)
    if scenes is None:
        out = {
            "timestamp": iso8601_now(),
            "prompt_version": args.prompt_version,
            "role": args.role,
            "pinning": pinning,
            "scene_set": [],
            "metrics": {
                "total_shots": 0,
                "close_framing_shots": 0,
                "non_close_framing_shots": 0,
                "close_framing_face_forbidden_count": 0,
                "close_framing_face_forbidden_per_shot": {},
                "forbidden_phrases_detected": [],
            },
            "measurement_failures": measurement_failures,
            "measurement_scripts": {
                "close_framing_face_forbidden": (
                    "scripts/canary/g4_3_close_framing_face_forbidden.py "
                    "(close-framing only — all 6 phrases R4-B3)"
                )
            },
        }
        _emit(out, args)
        return 1

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

    total_shots = 0
    cf_count = 0
    ncf_count = 0
    forbidden_total = 0
    forbidden_per_shot: dict[str, int] = {}
    forbidden_phrases_union: list[str] = []
    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
        if (int(si), int(shi)) not in pinning_set:
            continue
        seen_in_cp.add((int(si), int(shi)))

        variations = _validate_scene_preflight(scene, si, shi, measurement_failures)
        if variations is None:
            continue

        cf_flag, cf_err = is_close_framing(scene)
        if cf_err is not None:
            measurement_failures.append(f"s{si}_sh{shi}: {cf_err}")

        # Diagnostic flags (advisory only).
        has_composite_id = False
        has_reproduction_surface = False
        for var in variations:
            if not isinstance(var, dict):
                continue
            p = var.get("t2i_prompt")
            if isinstance(p, str):
                if _COMPOSITE_ID_RE.search(p):
                    has_composite_id = True
                pl = p.lower()
                for surf in _ID_REPRODUCTION_SURFACES:
                    if surf.lower() in pl:
                        has_reproduction_surface = True
                        break

        scene_set_out.append({
            "scene_index": si,
            "shot_index": shi,
            "is_close_framing": cf_flag,
            "has_composite_id": has_composite_id,
            "has_reproduction_surface": has_reproduction_surface,
        })

        total_shots += 1
        if not cf_flag:
            ncf_count += 1
            continue  # Close-framing-only scope (R3-I1).
        cf_count += 1

        shot_count = 0
        for vi, var in enumerate(variations):
            if not isinstance(var, dict):
                measurement_failures.append(
                    f"s{si}_sh{shi}_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}_sh{shi}_v{vi}: t2i_prompt missing, empty, or not str"
                )
                continue
            phrases_in_prompt = find_forbidden_phrases(prompt)
            shot_count += len(phrases_in_prompt)
            for ph in phrases_in_prompt:
                if ph not in forbidden_phrases_union:
                    forbidden_phrases_union.append(ph)
        forbidden_total += shot_count
        forbidden_per_shot[f"{si}_{shi}"] = shot_count

    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": total_shots,
            "close_framing_shots": cf_count,
            "non_close_framing_shots": ncf_count,
            "close_framing_face_forbidden_count": forbidden_total,
            "close_framing_face_forbidden_per_shot": forbidden_per_shot,
            "forbidden_phrases_detected": forbidden_phrases_union,
        },
        "measurement_failures": measurement_failures,
        "measurement_scripts": {
            "close_framing_face_forbidden": (
                "scripts/canary/g4_3_close_framing_face_forbidden.py "
                "(close-framing only — all 6 phrases R4-B3)"
            )
        },
    }

    _emit(out, args)

    print(
        f"[g4_3_close_framing_face_forbidden] role={args.role} prompt_version={args.prompt_version}\n"
        f"  total={total_shots}  cf={cf_count}  ncf={ncf_count}\n"
        f"  close_framing_face_forbidden_count={forbidden_total}\n"
        f"  forbidden_phrases_detected={forbidden_phrases_union}\n"
        f"  measurement_failures: {len(measurement_failures)}\n"
        f"Exit criteria (STRICT): candidate.metrics.close_framing_face_forbidden_count == 0",
        file=sys.stderr,
    )

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


def _emit(out: dict, args: argparse.Namespace) -> None:
    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)


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