#!/usr/bin/env python3
"""
gen_scene_map_dense.py — gerador de PRODUÇÃO do mapeamento de cena DENSO por-segundo
(promovido de scripts/experiments/scene_map_dense_validate.py depois de validação medida —
ver scratchpad/backlog/backlog-25-densificar-mapeamento-cena.md, recomendação #1 "(a-denso)").

O QUE É: complementa (NÃO substitui) content_map_dense.json/gen_content_map.py. O gerador
antigo faz "top-50 por query" (SigLIP absoluto) e cobre só ~8,9% dos segundos da timeline —
ver §2 do backlog. Este gerador faz o inverso: pra CADA segundo indexado (embeddings 1fps que
o visual_index.py já produz pra TODA câmera), cosine contra as ~31 categorias de
director_moments.py:QUERIES, e rotula com as categorias cujo score passa um limiar RELATIVO
(z-score da distribuição daquela categoria, DAQUELA câmera — corrige o viés cam2/cam3 medido no
esquema antigo: cam3=599, cam2=500, cam1=88 hits).

VALIDADO em scripts/experiments/scene_map_dense_validate.py contra uma amostra real de 3 clipes
(1111 frame-segundos, 1 por câmera) ANTES de rodar aqui: z=2.0 -> 19,3% cobertura (2,2x o
baseline), z=1.5 -> 38,8% (4,4x), z=1.0 -> 59,1% (6,6x) — cobertura sobe suave com o limiar,
sem quebrar (não satura/explode). Viés de câmera reduzido em todos os limiares (não mais
cam1<<cam2/3 por ordem de grandeza). Default deste gerador: z=1.5 (meio-termo documentado,
NÃO tunado pros marcadores de nenhum casamento específico — é limiar estatístico genérico).

FORMATO DE SAÍDA (scene_map_dense.json):
  {
    "meta": {"z_thresh": float, "top_k": int, "tl_fps": 30.0, "queries": [...],
              "source_workdir": str, "n_frames_analisados": int},
    "scene_map": {
       "<segundo_de_timeline_inteiro>": [
           {"cam": "cam2", "label": "declaracao", "z": 2.9, "score": 0.02, "sub_second": 97.4},
           ...
       ],
       ...
    }
  }
"segundo_de_timeline" = segundo INTEIRO da timeline do PROJETO (não do clipe-fonte) — mapeado via
placements.json (startFrame + t_sec_fonte, / TL_FPS=30). "sub_second" guarda o valor fracionário
real (startFrame/30 + t_sec) antes de arredondar, útil se quem consumir quiser janela fina.
Múltiplas câmeras/labels podem cair no mesmo segundo inteiro — a lista agrega todas.

ESCOPO / REGRAS (skill autocut-wedding-editor):
- NÃO mexe em content_map_dense.json/gen_content_map.py/director_moments.py (produção existente
  intacta) nem em vlib.py/content_at()/unified.py — artefato NOVO, aditivo. Integração real
  (trocar o que o judge consome) é decisão futura separada, fora de escopo aqui.
- READ-ONLY nos vídeos-fonte (visual_index.py só usa ffmpeg -i) e no placements.json (só leitura).
- Reusa o cache de embeddings do workdir se já existir (visual_index._load_index) — não reindexa
  do zero.

Uso:
  ./venv/bin/python3 scripts/gen_scene_map_dense.py --workdir <dir-com-media_paths.json+placements.json> \
      --out <caminho-do-scene_map_dense.json> [--z 1.5] [--topk 3]
"""
import argparse
import json
import os
import sys
from collections import defaultdict

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "experiments"))

import visual_index as VI  # noqa: E402
from scene_map_dense_validate import (  # noqa: E402
    QUERIES,
    build_score_records,
    label_records_relative,
)

TL_FPS = 30.0  # mesmo valor de ~/palmier-pro-study/multicam-pipeline/vlib.py:TL_FPS


def load_clip_offsets(workdir):
    """placements.json (produção real) -> {footage: (cam, startFrame)}. Mesma lógica de casamento
    por sufixo que director_moments.py:src_to_tl / load_cam_map usam (nome do asset pode ter
    prefixo camN_ e sufixo .wav/.mp4 diferentes do footage cru do índice SigLIP)."""
    path = os.path.join(workdir, "placements.json")
    if not os.path.exists(path):
        return {}
    clips = json.load(open(path)).get("clips", [])
    return clips  # devolve a lista crua; casamento é feito no caller (precisa do nome footage)


def match_offset(footage, clips):
    """Índice SigLIP footage → (cam, startFrame). v0.3: usa vlib.match_placement
    (cerim/cerimonia + sufixo camN). Antes: igualdade frouxa → 0 segundos mapeados."""
    try:
        from vlib import match_placement
        cam, sf = match_placement(footage, clips)
        if sf is not None:
            return cam or "?", sf
    except Exception:
        pass
    # fallback mínimo (legado) se import falhar
    try:
        from vlib import footage_key as _fk
    except Exception:
        def _fk(nm):
            if "_" in nm:
                nm = nm.split("_", 1)[1]
            return os.path.splitext(nm)[0]
    for c in clips:
        nm = c.get("name", "")
        base = _fk(nm)
        if base == footage or footage.endswith(base) or nm.rstrip(".wav").rstrip(".mp4").endswith(footage):
            return c.get("cam", "?"), int(c["startFrame"])
    return None, None


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--workdir", required=True, help="dir com media_paths.json + placements.json (+ .search_cache/ se existir)")
    ap.add_argument("--out", required=True, help="caminho de saída do scene_map_dense.json (mapa A/B estruturado)")
    ap.add_argument("--emit-flat", default="", help="opcional: grava content_map_dense.json lista flat p/ content_at/content_gen (E2)")
    ap.add_argument("--z", type=float, default=1.5, help="limiar de z-score relativo (default 1.5, ver docstring)")
    ap.add_argument("--topk", type=int, default=3, help="max de labels por segundo-fonte (default 3)")
    args = ap.parse_args()

    workdir = os.path.abspath(os.path.expanduser(args.workdir))
    print(f"workdir: {workdir}")

    records, reused_cache = build_score_records(workdir, QUERIES)
    print(f"cache reusado: {reused_cache}  |  frames carregados: {len(records)}")
    if not records:
        print("Nenhum frame indexado — nada a gerar.", file=sys.stderr)
        sys.exit(1)

    records = label_records_relative(records, QUERIES, z_thresh=args.z, top_k=args.topk)

    clips = load_clip_offsets(workdir)
    offset_cache = {}

    scene_map = defaultdict(list)
    unmapped = 0
    for r in records:
        if not r["labels"]:
            continue
        footage = r["footage"]
        if footage not in offset_cache:
            offset_cache[footage] = match_offset(footage, clips)
        cam, start_frame = offset_cache[footage]
        if start_frame is None:
            unmapped += 1
            continue
        sub_second = start_frame / TL_FPS + r["t_sec"]
        sec_int = int(round(sub_second))
        for key, z, s in r["labels"]:
            scene_map[str(sec_int)].append({
                "cam": cam, "label": key, "z": round(z, 2), "score": round(s, 4),
                "sub_second": round(sub_second, 2),
            })

    if unmapped:
        print(f"[aviso] {unmapped} frames rotulados sem entrada em placements.json (ignorados na saida)")

    n_seconds_labeled = len(scene_map)
    print(f"segundos de timeline com >=1 label: {n_seconds_labeled}")

    meta = {
        "z_thresh": args.z,
        "top_k": args.topk,
        "tl_fps": TL_FPS,
        "queries": list(QUERIES.keys()),
        "source_workdir": workdir,
        "n_frames_analisados": len(records),
        "n_segundos_timeline_com_label": n_seconds_labeled,
    }
    os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
    json.dump({"meta": meta, "scene_map": scene_map}, open(args.out, "w"), indent=1)
    print(f"scene_map_dense.json salvo em: {args.out}")

    # E2: emitter lista flat compatível com content_at / content_gen / missed_cands
    # Formato: [{"f": frame, "cam": "...", "score": float, "key": label}, ...]
    emit_path = args.emit_flat.strip() or os.path.join(workdir, "content_map_dense.json")
    flat = []
    for sec_s, hits in scene_map.items():
        try:
            sec = int(sec_s)
        except Exception:
            continue
        f = int(round(sec * TL_FPS))
        for h in hits:
            flat.append({
                "f": f,
                "cam": h["cam"],
                "score": float(h.get("z", h.get("score", 0)) or 0),  # z como score relativo
                "key": h["label"],
                "z": h.get("z"),
                "raw_score": h.get("score"),
            })
    flat.sort(key=lambda x: x["f"])
    json.dump(flat, open(emit_path, "w"))
    from collections import Counter
    print(f"content_map_dense FLAT emit: {len(flat)} hits {dict(Counter(h['cam'] for h in flat))} -> {emit_path}")


if __name__ == "__main__":
    main()
