"""The face inside each take, 10 times a second, smoothed: the path the framing follows (`acompanhar` blocks).

face_layout.json keeps one face per take, so the framing stands still inside a take. When the person leans,
walks or drifts inside a take and the block is zoomed (moldura, a punch-in, tela cheia at 1.2), the face slides
to the border or the forehead goes. This helper measures the face with the same YuNet detector as
face_layout.py, 10 times a second on the cut, and writes the smoothed path that modelo-projeto/build.py follows:

  1. raw measure every 0.1 s (the largest confident face, as face_layout.py);
  2. median of 5 measures in a row (a single wrong detection does not move anything);
  3. lost face (hand in front, turned aside): the last good point is held. After a loss of 0.3 s or more, a new
     detection is only trusted when 2 of the next 3 samples also find the face;
  4. dead zone: the target moves only when the centre walks more than 3% of the video width (or the face size
     changes more than 12%); micro movements and detector jitter never move the frame;
  5. every change of target becomes a sine.inOut move of 0.5 s centred on the change (the path is continuous,
     no jump);
  6. everything restarts at each take of cut_timeline.json (one take's path never leaks into the next).

Output <edit>/rosto_trilha.json (fractions of the cut.mp4 frame; times in seconds of the cut):
    {"versao": 1, "video": "cut.mp4", "video_sig": "size:mtime_ns", "largura", "altura", "fps", "passo_s": 0.1,
     "parametros": {...},
     "tomadas": [{"id", "de", "ate", "amostras", "medidos", "w_med", "h_med",
                  "pontos": [[t, cx, cy, w, h, medido], ...]}]}      # medido: 1 = face found near this sample
A take with no face at all has "pontos": [] (the build keeps the take's framing and says so).

The build runs this by itself when a block asks for `"acompanhar": true` and the file is missing or older than the
cut. By hand:
    uv run --directory '<SKILL>' python helpers/rastrear_rosto.py '<EDIT>' [--forcar]
"""

from __future__ import annotations

import argparse
import math
import subprocess
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import file_signature, load_json, now_iso, probe_video, require_binary, save_json  # noqa: E402

VERSAO = 1
PASSO_S = 0.1              # 10 measures a second
JANELA = 5                 # median of 5 measures in a row
ZONA_MORTA = 0.03          # the target moves when the centre walks more than 3% of the width
ZONA_TAMANHO = 0.12        # ... or the face width changes more than 12%
TRANSICAO_S = 0.5          # each move of the target: sine.inOut of 0.5 s, centred on the change
PERDA_LONGA = 3            # samples without a face that count as "lost" (0.3 s)
CONFIRMA = 2               # after a long loss: 2 of the next 3 samples must find the face
LADO_DETECCAO = 540        # frames are scaled to this short side before detection (same as face_layout.py)


class TrilhaErro(Exception):
    pass


# ---------------------------------------------------------------- smoothing (pure: tested without a video)

def _mediana(vals: list[float]) -> float:
    v = sorted(vals)
    n = len(v)
    return v[n // 2] if n % 2 else (v[n // 2 - 1] + v[n // 2]) / 2.0


def _suave(u: float) -> float:
    """sine.inOut from 0 to 1"""
    if u <= 0:
        return 0.0
    if u >= 1:
        return 1.0
    return 0.5 - 0.5 * math.cos(math.pi * u)


CHAVES = ("cx", "cy", "w", "h")


def suavizar(amostras: list, largura: int, altura: int, passo: float = PASSO_S) -> list | None:
    """The path of one take. `amostras`: [(t, {"cx", "cy", "w", "h"} or None)] in time order, fractions of the
    frame. Returns [[t, cx, cy, w, h, medido]] (same times) or None when the take has no face at all."""
    n = len(amostras)
    if not n:
        return None
    brutos = [a[1] for a in amostras]
    # 3a. after a long loss, a lone detection is not trusted yet
    confiaveis = list(brutos)
    perdidos = PERDA_LONGA          # the start of a take counts as a loss (nothing to hold yet)
    for k in range(n):
        if brutos[k] is None:
            perdidos += 1
            continue
        if perdidos >= PERDA_LONGA:
            achados = sum(1 for j in range(k, min(n, k + 3)) if brutos[j] is not None)
            if achados < min(CONFIRMA, n - k):
                confiaveis[k] = None
                continue
        perdidos = 0
    # 2. median of the trusted measures around each sample
    med = []
    meia = JANELA // 2
    for k in range(n):
        if confiaveis[k] is None:
            med.append(None)
            continue
        viz = [confiaveis[j] for j in range(max(0, k - meia), min(n, k + meia + 1)) if confiaveis[j] is not None]
        med.append({c: _mediana([v[c] for v in viz]) for c in CHAVES})
    bons = [m for m in med if m is not None]
    if not bons:
        return None
    # 3b. hold the last good point (and the first good one before the first detection)
    seguro, ult = [], bons[0]
    for m in med:
        if m is not None:
            ult = m
        seguro.append(ult)
    # 4. dead zone: the target stays until the face really moved
    esc_y = altura / float(largura)          # a vertical fraction in widths
    alvo, anc = [], seguro[0]
    for v in seguro:
        if (abs(v["cx"] - anc["cx"]) > ZONA_MORTA or abs(v["cy"] - anc["cy"]) * esc_y > ZONA_MORTA
                or abs(v["w"] / max(anc["w"], 1e-6) - 1) > ZONA_TAMANHO):
            anc = v
        alvo.append(anc)
    # 5. each change of target is an eased move of TRANSICAO_S centred on it
    ts = [a[0] for a in amostras]
    mudancas = [(ts[k], {c: alvo[k][c] - alvo[k - 1][c] for c in CHAVES}) for k in range(1, n) if alvo[k] is not alvo[k - 1]]
    saida = []
    for k in range(n):
        v = dict(alvo[0])
        for tm, d in mudancas:
            e = _suave((ts[k] - (tm - TRANSICAO_S / 2)) / TRANSICAO_S)
            if e <= 0:
                break
            for c in CHAVES:
                v[c] += d[c] * e
        medido = any(brutos[j] is not None for j in range(max(0, k - 1), min(n, k + 2)))
        saida.append([round(ts[k], 3), round(v["cx"], 4), round(v["cy"], 4), round(v["w"], 4), round(v["h"], 4),
                      1 if medido else 0])
    return saida


def tomadas_do_corte(timeline: dict | None, duracao: float) -> list[dict]:
    segs = []
    for i, s in enumerate((timeline or {}).get("segments", []) or []):
        try:
            a, b = float(s["out_start"]), float(s.get("out_end", 0) or 0)
        except (KeyError, TypeError, ValueError):
            continue
        segs.append({"id": str(s.get("id") or f"t{i + 1}"), "de": a, "ate": b})
    segs.sort(key=lambda s: s["de"])
    for k, s in enumerate(segs):            # a timeline without out_end: the next take (or the end) closes it
        if s["ate"] <= s["de"]:
            s["ate"] = segs[k + 1]["de"] if k + 1 < len(segs) else duracao
    if not segs:
        segs = [{"id": "t1", "de": 0.0, "ate": duracao}]
    return segs


def montar_tomadas(tomadas: list[dict], medidas: dict[float, dict | None], largura: int, altura: int) -> list[dict]:
    """medidas: {t: face or None} for every sample of the cut. One path per take."""
    ts = sorted(medidas)
    saida = []
    for tm in tomadas:
        # 2 ms of slack towards the new take, as the build does (a cut time rounded to 3 digits)
        am = [(t, medidas[t]) for t in ts if tm["de"] - 2e-3 <= t < tm["ate"] - 2e-3]
        pontos = suavizar(am, largura, altura) or []
        achados = [m for _t, m in am if m is not None]
        saida.append({"id": tm["id"], "de": round(tm["de"], 4), "ate": round(tm["ate"], 4), "amostras": len(am),
                      "medidos": len(achados),
                      "w_med": round(_mediana([m["w"] for m in achados]), 4) if achados else None,
                      "h_med": round(_mediana([m["h"] for m in achados]), 4) if achados else None,
                      "pontos": pontos})
    return saida


# ---------------------------------------------------------------- measuring

def medir(video: Path, fps: float, largura: int, altura: int, progresso: bool = True) -> dict[float, dict | None]:
    """One decoding pass (every `step`-th frame, scaled to 540 on the short side), one detection per sample."""
    import numpy as np  # noqa: PLC0415

    import face_layout  # noqa: PLC0415

    passo = max(1, int(round(fps * PASSO_S)))
    esc = min(1.0, LADO_DETECCAO / max(1, min(largura, altura)))
    dw, dh = max(2, int(round(largura * esc / 2)) * 2), max(2, int(round(altura * esc / 2)) * 2)
    cmd = ["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-i", str(video),
           "-vf", f"select='not(mod(n\\,{passo}))',scale={dw}:{dh}:flags=area", "-fps_mode", "passthrough",
           "-an", "-f", "rawvideo", "-pix_fmt", "bgr24", "-"]
    tam = dw * dh * 3
    out: dict[float, dict | None] = {}
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    k, inicio = 0, time.time()
    try:
        while True:
            buf = proc.stdout.read(tam) if proc.stdout else b""
            if len(buf) < tam:
                break
            img = np.frombuffer(buf, np.uint8).reshape(dh, dw, 3)
            f = face_layout.detect_array(img)
            t = round(k * passo / fps, 4)
            out[t] = None if not f else {"cx": (f["x0"] + f["x1"]) / 2, "cy": (f["y0"] + f["y1"]) / 2,
                                         "w": f["x1"] - f["x0"], "h": f["y1"] - f["y0"]}
            k += 1
            if progresso and k % 100 == 0:
                print(f"  {t:6.1f} s medidos ({time.time() - inicio:.0f} s)", flush=True)
    finally:
        if proc.poll() is None:
            proc.kill()
        err = proc.stderr.read().decode("utf-8", "replace") if proc.stderr else ""
        proc.wait()
    if not out:
        raise TrilhaErro(f"não consegui ler os quadros de {video.name}: {' '.join(err.strip().splitlines()[-3:])}")
    return out


def construir(edit: Path, forcar: bool = False) -> dict:
    edit = edit.resolve()
    video = edit / "cut.mp4"
    if not video.exists():
        raise TrilhaErro(f"não achei {video}: termine o corte antes (render_cut.py)")
    saida = edit / "rosto_trilha.json"
    sig = file_signature(video)
    velho = load_json(saida) if saida.exists() and not forcar else None
    if isinstance(velho, dict) and velho.get("versao") == VERSAO and velho.get("video_sig") == sig:
        velho["_de_novo"] = False
        return velho
    require_binary("ffmpeg")
    info = probe_video(video)
    largura = int(info.get("display_width") or info.get("width") or 0)
    altura = int(info.get("display_height") or info.get("height") or 0)
    fps = float(info.get("fps") or 30.0)
    duracao = float(info.get("duration") or 0.0)
    if largura <= 0 or altura <= 0:
        raise TrilhaErro(f"não consegui ler o tamanho de {video.name}")
    tl_p = edit / "cut_timeline.json"
    timeline = load_json(tl_p) if tl_p.exists() else None
    tomadas = tomadas_do_corte(timeline, duracao)
    inicio = time.time()
    medidas = medir(video, fps, largura, altura)
    trilha = {
        "versao": VERSAO, "video": "cut.mp4", "video_sig": sig, "largura": largura, "altura": altura,
        "fps": round(fps, 3), "duracao": round(duracao, 3), "passo_s": PASSO_S,
        "parametros": {"mediana": JANELA, "zona_morta": ZONA_MORTA, "zona_tamanho": ZONA_TAMANHO,
                       "transicao_s": TRANSICAO_S, "perda_longa_s": round(PERDA_LONGA * PASSO_S, 2),
                       "confirma": f"{CONFIRMA} de 3"},
        "tomadas": montar_tomadas(tomadas, medidas, largura, altura),
        "segundos": round(time.time() - inicio, 1),
        "criado_em": now_iso(),
    }
    save_json(saida, trilha)
    trilha["_de_novo"] = True
    return trilha


def resumo(trilha: dict) -> list[str]:
    linhas = []
    W = trilha["largura"]
    for tm in trilha["tomadas"]:
        pts = tm["pontos"]
        if not pts:
            linhas.append(f"  {tm['id']:>5} {tm['de']:6.2f}-{tm['ate']:6.2f}s  sem rosto ({tm['medidos']}/{tm['amostras']}): "
                          f"o enquadramento fica o da tomada")
            continue
        xs, ys = [p[1] for p in pts], [p[2] for p in pts]
        anda = max(max(xs) - min(xs), (max(ys) - min(ys)) * trilha["altura"] / W)
        linhas.append(f"  {tm['id']:>5} {tm['de']:6.2f}-{tm['ate']:6.2f}s  rosto {tm['medidos']}/{tm['amostras']}  "
                      f"anda até {anda * W:.0f} px ({anda:.0%} da largura)")
    return linhas


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Rosto 10 vezes por segundo em cada tomada do corte, suavizado "
                                             "(o caminho que o enquadramento segue nos blocos com acompanhar).")
    ap.add_argument("edit", type=Path, help="pasta edit do corte (cut.mp4 e cut_timeline.json)")
    ap.add_argument("--forcar", action="store_true", help="mede de novo mesmo com o rosto_trilha.json em dia")
    a = ap.parse_args(argv)
    edit = a.edit.expanduser()
    if not edit.is_dir():
        print(f"erro: pasta edit não encontrada: {edit}", file=sys.stderr)
        return 2
    try:
        print("medindo o rosto 10 vezes por segundo no corte (uns segundos por minuto de vídeo)...", flush=True)
        trilha = construir(edit, a.forcar)
    except TrilhaErro as e:
        print(f"erro: {e}", file=sys.stderr)
        return 2
    except Exception as e:  # noqa: BLE001  (OpenCV or the model missing: say it plainly)
        print(f"erro: {e}", file=sys.stderr)
        return 2
    for linha in resumo(trilha):
        print(linha)
    estado = "medido agora" if trilha.get("_de_novo") else "já estava em dia"
    print(f"ok: {edit.resolve() / 'rosto_trilha.json'} ({estado})")
    return 0


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