"""Quality gate for a rendered video: the edit is only delivered when this prints "PASSOU".

Checks the exported file (not the studio):
  1. black frames (0.1 s or more anywhere, the very first 0.1 s excepted);
  2. loudness: integrated -14 LUFS with 1.5 of margin, true peak under -1 dBTP;
  3. breaths: no silence of 0.25 s or more (helpers/respiros.py). The silence after the last spoken word does not
     count when the project has a still end (`segura_fim`) or a closing card (`cartao_final`): it is the end of the
     video, not a breath;
  4. text outside the Reels safe zone (x 140-940, y 230-1530 on 1080x1920) or touching the edge of the frame,
     read with the same reader as conferir_dados.py (Apple Vision on the Mac, Tesseract elsewhere). The stretches
     of `prova`, `contida`, `comparar` and `tela_verde` blocks are not read: the text there is on the person's own
     footage (a screen recording), not text of the edit. Without a reader the check says so and does not block;
  4b. (PrimoCode) text glued to the face: in the full-screen stretches (the person fills the frame) a line of text
     closer than ROSTO_PX to the face box fails (the box from the hair line down to just under the mouth: the chin is
     where the captions sit by design). The face is found in the same frames the reader reads, with the YuNet
     detector of face_layout.py (the face as it is on screen: zoom and camera included). Without the detector the
     check says so and does not block;
  5. a sheet of frames with the safe zone box drawn on it, saved next to the report;
  6. (PrimoCode) the loudnorm of every step (cut, clean voice, soundtrack) stayed LINEAR: the dynamic mode is a
     compressor ffmpeg turns on silently and the voice pumps (helpers/ganho_por_trecho.py prevents it);
  7. (PrimoCode) no sample at the digital ceiling (|x| >= 0.999) in the exported audio.

Usage:
    uv run python helpers/portao.py <final.mp4> --projeto <PROJ> [--fps 3] [--folha <png>] [--ajustar-volume] [--json]

`--ajustar-volume`: when only the loudness is off, rewrites the audio to -14 LUFS (two passes, the picture is not
re-encoded), keeps the original as `<nome>_antes_do_volume.mp4` and measures again. No new render needed.
Exit code 0 when everything passes, 1 when something failed, 2 on error.
"""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import conferir_dados as cd  # noqa: E402
import corte_por_onda as onda  # noqa: E402
import respiros  # noqa: E402
from common import fmt_time, probe_video, require_binary  # noqa: E402

ALVO_LUFS, MARGEM_LUFS, PICO_MAX_DB = -14.0, 1.5, -1.0
PRETO_MIN_S = 0.1
PRETO_INICIO_S = 0.1       # black this short at the very start (the first frame) is the video opening
BLOCOS_DE_MATERIAL = ("prova", "contida", "comparar", "tela_verde")   # footage of the person: its text is not ours
ZONA = (140, 230, 940, 1530)          # x0, y0, x1, y1 on 1080x1920
BORDA_PX = 24                         # text closer than this to the edge of the frame is "touching"
CONF_MIN = 0.5
FOLGA_PX = 6                          # the reader's box is not exact
ROSTO_PX = 40                         # text closer than this to the face box is "glued to the face" (px on 1920 high)
CENAS_COM_ROSTO = ("tela_cheia",)     # stretches where the person fills the frame (moldura/prova/...: the face is small or not ours)


def distancia_ao_rosto(caixa: tuple[float, float, float, float], rosto: tuple[float, float, float, float]) -> float:
    """Gap in px between a text box and the face box, both (x0, y0, x1, y1). Negative when they overlap (the depth of
    the overlap on the shallower axis)."""
    dx = max(rosto[0] - caixa[2], caixa[0] - rosto[2])
    dy = max(rosto[1] - caixa[3], caixa[1] - rosto[3])
    if dx < 0 and dy < 0:
        return max(dx, dy)
    return max(dx, dy) if (dx < 0 or dy < 0) else (dx * dx + dy * dy) ** 0.5


def colado_no_rosto(caixa, rosto, h: int = 1920, px: float = ROSTO_PX) -> bool:
    """True when the text box is closer than `px` (scaled to the video height) to the face box."""
    return rosto is not None and distancia_ao_rosto(caixa, rosto) < px * h / 1920


def rostos_dos_quadros(pasta: Path, n: int, w: int, h: int) -> dict[int, tuple[float, float, float, float] | None] | None:
    """The face box (px) in each extracted frame f_00001.jpg..., with the YuNet detector of face_layout.py. None when the
    detector is not available here (no OpenCV or no model)."""
    try:
        import cv2  # noqa: PLC0415
        import face_layout as fl  # noqa: PLC0415
        fl._cv2()
    except Exception:  # noqa: BLE001
        return None
    out: dict[int, tuple[float, float, float, float] | None] = {}
    for k in range(n):
        img = cv2.imread(str(pasta / f"f_{k + 1:05d}.jpg"))
        f = fl.detect_array(img) if img is not None else None
        out[k] = caixa_do_rosto(f, w, h) if f else None
    return out


QUEIXO = 0.08    # the face box for the rule ends this far (fraction of the face height) under the mouth line


def caixa_do_rosto(f: dict, w: int, h: int) -> tuple[float, float, float, float]:
    """The face box (px) the text has to keep away from: the YuNet box from its top (hair line) down to just under the
    mouth. The chin under the mouth is where the captions of the edit sit by design (face_layout: they miss the eyes and
    the mouth), so it is not part of it."""
    y0, y1 = f["y0"] * h, f["y1"] * h
    boca = f.get("mouth_y")
    if boca is not None:
        y1 = min(y1, boca * h + QUEIXO * (y1 - y0))
    return (f["x0"] * w, y0, f["x1"] * w, y1)


def zona_para(w: int, h: int) -> tuple[int, int, int, int] | None:
    """The safe zone scaled to the video size; only defined for vertical 9:16 (other formats have wider margins)."""
    if abs(w / h - 9 / 16) > 0.02:
        return None
    k = w / 1080
    return tuple(round(v * k) for v in ZONA)  # type: ignore[return-value]


def quadros_pretos(video: Path, dur: float) -> list[tuple[float, float]]:
    r = subprocess.run(["ffmpeg", "-nostdin", "-v", "info", "-i", str(video), "-vf",
                        f"blackdetect=d={PRETO_MIN_S - 0.02:.2f}:pix_th=0.06", "-an", "-f", "null", "-"],
                       capture_output=True, text=True)
    out = []
    for m in re.finditer(r"black_start:([\d.]+)\s+black_end:([\d.]+)", r.stderr):
        a, b = float(m[1]), float(m[2])
        if a < 0.05 and b - a <= PRETO_INICIO_S + 0.05:
            continue
        out.append((a, b))
    return out


def volume(video: Path, completo: bool = False) -> dict:
    r = subprocess.run(["ffmpeg", "-nostdin", "-v", "info", "-i", str(video), "-vn", "-af",
                        "loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json", "-f", "null", "-"],
                       capture_output=True, text=True)
    m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", r.stderr, re.S)
    if not m:
        raise RuntimeError("não consegui medir o volume (o arquivo tem áudio?)")
    d = json.loads(m.group(0))
    out = {"lufs": float(d["input_i"]), "pico_db": float(d["input_tp"])}
    if completo:
        out["_loudnorm"] = d
    return out


def ajustar_volume(video: Path) -> Path:
    """Two-pass loudnorm to -14 LUFS (true peak -1.5), picture copied. The original stays next to it."""
    d = volume(video, completo=True)["_loudnorm"]
    antes = video.with_name(video.stem + "_antes_do_volume" + video.suffix)
    novo = video.with_name(video.stem + "_novo_volume" + video.suffix)
    af = ("loudnorm=I=-14:TP=-1.5:LRA=11:linear=true:"
          f"measured_I={d['input_i']}:measured_TP={d['input_tp']}:measured_LRA={d['input_lra']}:"
          f"measured_thresh={d['input_thresh']}:offset={d['target_offset']}")
    r = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(video), "-map", "0:v", "-map", "0:a",
                        "-c:v", "copy", "-af", af, "-ar", "48000", "-c:a", "aac", "-b:a", "192k",
                        "-movflags", "+faststart", str(novo)], capture_output=True, text=True)
    if r.returncode != 0 or not novo.is_file():
        raise RuntimeError("não consegui ajustar o volume: " + r.stderr[-300:])
    if not antes.exists():
        video.rename(antes)
    else:
        video.unlink()
    novo.rename(video)
    return antes


def texto_fora(video: Path, w: int, h: int, fps: float, ignorar: list[tuple[float, float]] | None = None,
               com_rosto: list[tuple[float, float]] | None = None) -> dict:
    """Lines of text outside the safe zone, touching the edge or glued to the face (in `com_rosto` stretches).
    {'leitor': ..., 'achados': [...], 'rosto': True | False | None}."""
    zona = zona_para(w, h)
    motor = cd.leitor()
    if motor is None:
        return {"leitor": None, "achados": [], "zona": zona, "ignorado": ignorar or [], "rosto": None}
    info = probe_video(video)
    fps_video = float(info.get("fps") or 30.0)
    dur = float(info["duration"])
    n_total = int(dur * fps - 1e-6) + 1
    achados: list[dict] = []
    with tempfile.TemporaryDirectory(prefix="portao-") as tmp:
        pasta = Path(tmp)
        n = cd.extrair(video, pasta, 0, n_total, fps, fps_video, True, "")
        bruto = cd.ler_pasta(motor, pasta) if n else {}
        rostos = rostos_dos_quadros(pasta, n, w, h) if (n and com_rosto) else None
    ignorar = ignorar or []
    com_rosto = com_rosto or []
    for k in range(n):
        t = k / fps
        if any(x - 0.05 <= t <= y + 0.05 for x, y in ignorar):
            continue
        for it in bruto.get(f"f_{k + 1:05d}.jpg", []):
            if float(it.get("c", 1)) < CONF_MIN or len(re.sub(r"\W", "", str(it.get("t", "")))) < 2:
                continue
            x0, y1 = float(it["x"]) * w, (1 - float(it["y"]) - float(it["h"])) * h
            x1, y2 = x0 + float(it["w"]) * w, y1 + float(it["h"]) * h
            motivo = None
            if x0 < BORDA_PX or y1 < BORDA_PX or x1 > w - BORDA_PX or y2 > h - BORDA_PX:
                motivo = "encostado na borda"
            elif zona and (x0 < zona[0] - FOLGA_PX or x1 > zona[2] + FOLGA_PX
                           or y1 < zona[1] - FOLGA_PX or y2 > zona[3] + FOLGA_PX):
                motivo = "fora da zona segura"
            elif rostos and any(x - 0.05 <= t <= y + 0.05 for x, y in com_rosto) and \
                    colado_no_rosto((x0 + FOLGA_PX, y1 + FOLGA_PX, x1 - FOLGA_PX, y2 - FOLGA_PX), rostos.get(k), h):
                motivo = f"colado no rosto (a menos de {round(ROSTO_PX * h / 1920)} px)"
            if motivo:
                achados.append({"t": round(t, 2), "texto": str(it["t"]).strip(), "motivo": motivo,
                                "caixa": [round(x0), round(y1), round(x1 - x0), round(y2 - y1)]})
    # the same line seen in a row of samples is one finding
    juntos: list[dict] = []
    for a in sorted(achados, key=lambda a: (a["texto"], a["t"])):
        if juntos and juntos[-1]["texto"] == a["texto"] and a["t"] - juntos[-1]["ate"] <= 1.5 / fps + 1e-6:
            juntos[-1]["ate"] = a["t"]
        else:
            juntos.append(dict(a, ate=a["t"]))
    return {"leitor": motor[0], "achados": sorted(juntos, key=lambda a: a["t"]), "zona": zona, "ignorado": ignorar,
            "rosto": (rostos is not None) if com_rosto else None}


def faixas_de_material(cfg: dict, projeto: Path | None, dur: float, cenas=BLOCOS_DE_MATERIAL) -> list[tuple[float, float]]:
    """Time ranges (s) of the blocks that show the person's own footage (or of the `cenas` asked). A time written as a
    word is looked up in the words of the cut; without the transcript only the blocks written in seconds are found."""
    import camera_viva as cv  # noqa: PLC0415
    ws: list[dict] = []
    vdur = dur
    if projeto:
        try:
            edit = (projeto / cfg.get("edit", "../edit")).resolve()
            ws = cv.palavras_do_corte(edit)
            vdur = float(json.loads((edit / "cut_timeline.json").read_text(encoding="utf-8"))["duration"])
        except Exception:  # noqa: BLE001
            ws = []
    out = []
    for b in cfg.get("blocos") or []:
        if not isinstance(b, dict) or b.get("cena") not in cenas:
            continue
        de = cv.tempo_do_campo(b.get("de", 0.0), ws)
        ate = cv.tempo_do_campo(b["ate"], ws, de or 0.0) if "ate" in b else vdur
        if de is not None and ate is not None:
            out.append((de, ate))
    return out


def faixas_com_rosto(cfg: dict, projeto: Path | None, dur: float) -> list[tuple[float, float]]:
    """Time ranges (s) where the person fills the frame: everything outside the blocks that are not `tela_cheia`
    (moldura, prova, contida, comparar, tela_verde). There the face box is the one that matters for the text."""
    outros = sorted(faixas_de_material(cfg, projeto, dur, cenas=("moldura", "prova", "contida", "comparar", "tela_verde")))
    return livres_entre(outros, dur)


def livres_entre(ocupados: list[tuple[float, float]], dur: float) -> list[tuple[float, float]]:
    """[0, dur] minus the sorted `ocupados` ranges."""
    out, t = [], 0.0
    for de, ate in sorted(ocupados):
        if de > t + 0.05:
            out.append((t, de))
        t = max(t, ate)
    if t < dur - 0.05:
        out.append((t, dur))
    return out


def fim_da_fala(cfg: dict, dur: float) -> float | None:
    """Where the voice ends in the final video: the duration minus the still end and the closing card. None when
    the project has neither (then the last silence counts like any other)."""
    segura = float(cfg.get("segura_fim") or 0)
    cf = cfg.get("cartao_final") or {}
    cartao = float(cf.get("segundos", 1.8)) if cf.get("ligado") else 0.0
    return dur - segura - cartao if (segura > 0 or cartao > 0) else None


def folha(video: Path, w: int, h: int, tempos: list[float], saida: Path) -> Path:
    from PIL import Image, ImageDraw  # noqa: PLC0415
    import contact_sheet as cs  # noqa: PLC0415
    tw = 270
    th = round(tw * h / w)
    quadros = cs.extract_frames(video, tempos, width=tw, height=th)
    zona = zona_para(w, h)
    for im in quadros:
        d = ImageDraw.Draw(im)
        if zona:
            k = tw / w
            d.rectangle([round(v * k) for v in zona], outline=(255, 60, 60), width=2)
    rotulos = [fmt_time(t) for t in tempos]
    cs.build_sheet(quadros, rotulos, 6, f"{video.name} · caixa vermelha = zona segura").save(saida)
    return saida


def modos_do_volume(video: Path, projeto: Path | None, cfg: dict) -> list[tuple[str, str]]:
    """PrimoCode: o modo de cada loudnorm do caminho (corte, voz limpa, trilha). Dinâmico é um compressor que o
    ffmpeg liga calado quando o ganho linear passaria do pico: a voz bombeia. Lista (etapa, modo)."""
    out: list[tuple[str, str]] = []
    def ler(p: Path) -> dict:
        try:
            return json.loads(p.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            return {}
    edit = None
    if projeto and cfg.get("edit"):
        edit = (projeto / cfg["edit"]).resolve()
    if edit and edit.is_dir():
        m = ler(edit / "cut_timeline.json").get("loudnorm_modo")
        if m:
            out.append(("corte", m))
        m = (ler(edit / "verify" / "voz_limpa.json").get("relatorio") or {}).get("loudnorm_modo")
        if m:
            out.append(("voz limpa", m))
    m = ler(video.with_name(video.name + ".trilha.json")).get("ajuste")
    if m:
        out.append(("trilha", m))
    return out


def amostras_clipadas(video: Path) -> int:
    """PrimoCode: amostras no teto digital (|x| >= 0,999) no áudio exportado. Uma já é estalo."""
    import numpy as np
    raw = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-i", str(video), "-vn", "-f", "f32le", "-acodec",
                          "pcm_f32le", "-ar", "48000", "-"], capture_output=True).stdout
    if not raw:
        return 0
    x = np.frombuffer(raw[: len(raw) // 4 * 4], dtype=np.float32)
    return int(np.count_nonzero(np.abs(x) >= 0.999))


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Portão de qualidade do vídeo exportado: só entrega com PASSOU.")
    ap.add_argument("video", type=Path)
    ap.add_argument("--projeto", type=Path, help="a pasta do projeto (lê o segura_fim e guarda a folha em renders/)")
    ap.add_argument("--fps", type=float, default=3.0, help="quadros lidos por segundo para o texto (3)")
    ap.add_argument("--folha", type=Path, help="PNG da folha de quadros (padrão: <projeto>/renders/portao.png)")
    ap.add_argument("--ajustar-volume", action="store_true",
                    help="se só o volume estiver fora, regrava o áudio em -14 LUFS (sem novo render) e mede de novo")
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args(argv)
    if not a.video.is_file():
        print(f"ERRO: não achei {a.video}", file=sys.stderr)
        return 2
    try:
        require_binary("ffmpeg")
        info = probe_video(a.video)
        W, H = int(info.get("display_width") or info["width"]), int(info.get("display_height") or info["height"])
        dur = float(info["duration"])
        cfg = {}
        if a.projeto and (a.projeto / "projeto.json").is_file():
            cfg = json.loads((a.projeto / "projeto.json").read_text(encoding="utf-8"))
        pretos = quadros_pretos(a.video, dur)
        vol = volume(a.video)
        volume_ajustado = None
        if a.ajustar_volume and (abs(vol["lufs"] - ALVO_LUFS) > MARGEM_LUFS or vol["pico_db"] > PICO_MAX_DB):
            volume_ajustado = ajustar_volume(a.video)
            vol = volume(a.video)
        db = onda.envelope(a.video, None)
        th = onda.threshold(db)
        resp = respiros.find(db, th, respiros.MIN_S)
        fim_voz = fim_da_fala(cfg, dur)
        resp_fim = []
        if fim_voz is not None:                 # silence after the last word is the still end / the card, not a breath
            resp_fim = [r for r in resp if r[0] >= fim_voz - 0.3]
            resp = [r for r in resp if r not in resp_fim]
        txt = texto_fora(a.video, W, H, a.fps, faixas_de_material(cfg, a.projeto, dur), faixas_com_rosto(cfg, a.projeto, dur))
        modos = modos_do_volume(a.video, a.projeto, cfg)
        clip = amostras_clipadas(a.video)
    except (RuntimeError, OSError, ValueError) as e:
        print(f"ERRO: {e}", file=sys.stderr)
        return 2

    falhas: list[str] = []
    if pretos:
        falhas.append("quadro preto: " + ", ".join(f"{fmt_time(x)} a {fmt_time(y)}" for x, y in pretos))
    if abs(vol["lufs"] - ALVO_LUFS) > MARGEM_LUFS:
        falhas.append(f"volume {vol['lufs']:.1f} LUFS (o alvo é {ALVO_LUFS:.0f}, margem {MARGEM_LUFS:g}): "
                      "ajuste o ganho do arquivo e confira de novo")
    if vol["pico_db"] > PICO_MAX_DB:
        falhas.append(f"pico {vol['pico_db']:.1f} dBTP (o limite é {PICO_MAX_DB:g}): baixe o ganho ou use o limitador")
    dinamicos = [e for e, m in modos if str(m).lower() not in ("linear",)]
    if dinamicos:
        falhas.append("volume em modo dinâmico (" + ", ".join(dinamicos) + "): o loudnorm comprimiu a voz; rode o "
                      "helpers/ganho_por_trecho.py no edl_manual.json, refaça o corte e renderize")
    if clip:
        falhas.append(f"clipou: {clip} amostra(s) no teto digital; baixe o ganho do trecho ou o volume dos sons")
    if resp:
        falhas.append("respiro: " + ", ".join(f"{fmt_time(x)} ({y - x:.2f} s)" for x, y in resp))
    for f in txt["achados"]:
        falhas.append(f"texto {f['motivo']}: \"{f['texto'][:40]}\" em {fmt_time(f['t'])}"
                      + (f" a {fmt_time(f['ate'])}" if f["ate"] > f["t"] else ""))
    saida = a.folha or ((a.projeto / "renders" / "portao.png") if a.projeto else a.video.with_name("portao.png"))
    tempos = sorted({round(min(dur - 0.05, x), 2) for x in
                     [0.1] + [f["t"] for f in txt["achados"]][:6] + [i * dur / 11 for i in range(1, 12)]})[:18]
    try:
        saida.parent.mkdir(parents=True, exist_ok=True)
        folha(a.video, W, H, tempos, saida)
    except Exception as e:  # noqa: BLE001  (the sheet is a help to the eye, it never fails the gate)
        saida = None
        print(f"aviso: não montei a folha de quadros ({e})", file=sys.stderr)

    if txt.get("rosto") is False:
        print("aviso: o rosto não foi conferido (sem o detector YuNet aqui); texto colado no rosto não reprova", file=sys.stderr)
    rel = {"passou": not falhas, "falhas": falhas, "duracao": round(dur, 2), "volume": vol, "rosto_conferido": txt.get("rosto"),
           "modos_do_volume": dict(modos), "amostras_clipadas": clip,
           "limiar_respiro_db": th, "leitor_de_texto": txt["leitor"], "folha": str(saida) if saida else None}
    if a.json:
        print(json.dumps(rel, ensure_ascii=False))
        return 0 if not falhas else 1
    print(f"portão · {a.video.name} · {dur:.1f} s · {W}x{H}")
    print(f"  quadro preto: {'nenhum' if not pretos else len(pretos)}")
    print(f"  volume: {vol['lufs']:.1f} LUFS, pico {vol['pico_db']:.1f} dBTP"
          + (f" (ajustado; o original ficou em {volume_ajustado.name})" if volume_ajustado else ""))
    print("  ajuste de volume: " + (", ".join(f"{e} {m}" for e, m in modos) if modos else "sem registro")
          + f" · amostras clipadas: {clip}")
    print(f"  respiros de 0,25 s ou mais: {'nenhum' if not resp else len(resp)}"
          + (f" (o silêncio do fim, {resp_fim[-1][1] - resp_fim[0][0]:.1f} s, é o fim parado ou o cartão e não conta)" if resp_fim else ""))
    if txt["leitor"] is None:
        print("  texto na tela: NÃO conferido (sem leitor de texto neste computador; olhe a folha de quadros)")
    elif txt["zona"] is None:
        print(f"  texto na tela: só a borda foi conferida (formato {W}x{H}; a zona segura vale para 9:16)")
    else:
        print(f"  texto fora da zona segura, na borda ou colado no rosto: {'nenhum' if not txt['achados'] else len(txt['achados'])} "
              f"(leitor {txt['leitor']})"
              + (f"; sem ler as telas gravadas em {', '.join(fmt_time(x) + '-' + fmt_time(y) for x, y in txt['ignorado'])}"
                 if txt.get("ignorado") else ""))
    if saida:
        print(f"  folha de quadros: {saida}")
    if falhas:
        print("REPROVOU:")
        for f in falhas:
            print(f"  - {f}")
        print("Conserte o que está acima, renderize de novo e rode o portão outra vez. Não entregue assim.")
        return 1
    print("PASSOU")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
