"""O que já está escrito ou desenhado na tela, para a legenda não repetir nem cobrir.

Lê o vídeo de amostra em amostra (2 por segundo por padrão) e responde três coisas:

  1. onde há texto/desenho na tela, por faixa de altura (para escolher onde a legenda cabe);
  2. em que trechos já existe legenda gravada no vídeo (para não legendar duas vezes);
  3. em que trechos a tela inteira é um gráfico claro (papel), onde a legenda branca sumiria.

Dois motores de leitura:

  vision       macOS com as ferramentas de linha de comando da Apple (`swiftc`): lê as palavras de
               verdade (Vision). É o único que sabe dizer "isso aqui já é a legenda", porque compara o
               que está escrito com o que está sendo falado.
  heuristico   qualquer sistema, só com OpenCV: acha as manchas de texto sem ler. Serve para desviar,
               e marca "já tem legenda" quando a mancha fica no lugar de legenda e troca junto com a
               fala (uma placa de motion fica parada; legenda muda a cada segundo).

Usage:
    uv run python helpers/texto_na_tela.py --video cut.mp4 [--edit-dir DIR] [--transcript W.json]
        [--fps 2] [--backend auto|vision|heuristico] [--force] [--json]
"""

from __future__ import annotations

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

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

VERSION = 1
# Faixas candidatas, em fração da altura (centro da legenda no meio da faixa).
BANDAS: list[tuple[str, float, float]] = [
    ("legenda", 0.655, 0.760),   # padrão: altura do peito, abaixo do rosto
    ("alta", 0.525, 0.630),
    ("baixa", 0.780, 0.885),
    ("rodape", 0.865, 0.960),
]
PAPEL_MIN = 0.72        # fração de pixel claro sem cor para a tela contar como gráfico claro
TINTA_LIVRE = 0.02      # tinta máxima numa faixa de papel para caber legenda escura
CONF_MIN = 0.45
JANELA_FALA = 1.5       # segundos de folga ao comparar o escrito com o falado


def utf8_console() -> None:
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
        except (AttributeError, ValueError, OSError):
            pass


def _norm(texto: str) -> str:
    return unicodedata.normalize("NFD", texto.lower()).encode("ascii", "ignore").decode()


def _bin_dir() -> Path:
    d = Path.home() / ".editor-video" / "bin"
    d.mkdir(parents=True, exist_ok=True)
    return d


def vision_disponivel() -> bool:
    return sys.platform == "darwin" and shutil.which("swiftc") is not None


def compilar_vision(fonte: Path | None = None) -> Path | None:
    """Compila (uma vez) o leitor da Apple e devolve o caminho do binário."""
    if not vision_disponivel():
        return None
    fonte = fonte or Path(__file__).resolve().parent / "assets_ocr" / "ocr_vision.swift"
    if not fonte.exists():
        return None
    alvo = _bin_dir() / "ocr_vision"
    if alvo.exists() and alvo.stat().st_mtime >= fonte.stat().st_mtime:
        return alvo
    try:
        run(["swiftc", "-O", str(fonte), "-o", str(alvo)])
    except (subprocess.CalledProcessError, OSError):
        return None
    return alvo if alvo.exists() else None


def extrair_quadros(video: Path, destino: Path, fps: float, largura: int = 608) -> list[float]:
    require_binary("ffmpeg")
    run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(video),
         "-vf", f"fps={fps},scale={largura}:-2", "-q:v", "3", str(destino / "f_%05d.jpg")])
    nomes = sorted(destino.glob("f_*.jpg"))
    return [round(i / fps, 3) for i in range(len(nomes))]


def _ler_vision(binario: Path, pasta: Path) -> dict[str, list[dict]]:
    saida = pasta / "ocr.json"
    run([str(binario), str(pasta), str(saida)])
    return json.loads(saida.read_text())


def _ler_heuristico(pasta: Path) -> dict[str, list[dict]]:
    import cv2  # noqa: PLC0415 — só quando este motor é usado
    import numpy as np  # noqa: PLC0415

    out: dict[str, list[dict]] = {}
    for arq in sorted(pasta.glob("f_*.jpg")):
        img = cv2.imread(str(arq), cv2.IMREAD_GRAYSCALE)
        if img is None:
            continue
        h, w = img.shape
        grad = cv2.morphologyEx(img, cv2.MORPH_GRADIENT, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)))
        _, bw = cv2.threshold(grad, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)
        linha = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_RECT, (21, 3)))
        cont, _ = cv2.findContours(linha, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        itens: list[dict] = []
        for c in cont:
            x, y, cw, ch = cv2.boundingRect(c)
            if ch < h * 0.012 or ch > h * 0.12 or cw < ch * 1.2 or cw > w * 0.96:
                continue
            densidade = float(np.count_nonzero(bw[y:y + ch, x:x + cw])) / max(1, cw * ch)
            if not 0.08 < densidade < 0.62:
                continue
            itens.append({"t": "", "c": 0.5, "x": x / w, "y": 1 - (y + ch) / h, "w": cw / w, "h": ch / h})
        out[arq.name] = itens
    return out


def _papel_e_tinta(pasta: Path, nomes: list[str]) -> tuple[list[float], list[dict[str, float]]]:
    from PIL import Image  # noqa: PLC0415
    import numpy as np  # noqa: PLC0415

    papel: list[float] = []
    tinta: list[dict[str, float]] = []
    for nome in nomes:
        im = np.asarray(Image.open(pasta / nome).convert("RGB")).astype(int)
        sat = im.max(2) - im.min(2)
        claro = im.min(2)
        papel.append(round(float(((claro > 205) & (sat < 40)).mean()), 3))
        h = im.shape[0]
        faixa: dict[str, float] = {}
        for nome_faixa, a, b in BANDAS:
            bloco = im[int(a * h):int(b * h)]
            s = bloco.max(2) - bloco.min(2)
            c = bloco.min(2)
            faixa[nome_faixa] = round(float(((c < 200) | (s > 45)).mean()), 3)
        tinta.append(faixa)
    return papel, tinta


def _intervalos(marcas: list[tuple[float, bool]], minimo: float = 0.6,
                juntar: float = 2.5, dilatar: int = 1) -> list[list[float]]:
    if dilatar:
        vivos = [i for i, (_, v) in enumerate(marcas) if v]
        marcas = [(t, any(abs(i - j) <= dilatar for j in vivos)) for i, (t, v) in enumerate(marcas)] \
            if vivos else marcas
    faixas: list[list[float]] = []
    aberto: list[float] | None = None
    for t, v in marcas:
        if v and aberto is None:
            aberto = [t, t]
        elif v:
            aberto[1] = t
        elif aberto is not None:
            faixas.append(aberto)
            aberto = None
    if aberto is not None:
        faixas.append(aberto)
    unidas: list[list[float]] = []
    for f in faixas:
        if unidas and f[0] - unidas[-1][1] <= juntar:
            unidas[-1][1] = f[1]
        else:
            unidas.append(f)
    return [f for f in unidas if f[1] - f[0] >= minimo]


def _legenda_por_palavra(amostras: list[dict], palavras: list[dict]) -> list[tuple[float, bool]]:
    """Já tem legenda quando o que está escrito bate com o que está sendo falado."""
    marcas: list[tuple[float, bool]] = []
    for am in amostras:
        t = am["t"]
        ditas = {_norm(re.sub(r"\W", "", w.get("word", w.get("text", ""))))
                 for w in palavras
                 if t - JANELA_FALA <= float(w.get("start", w.get("s", -9))) <= t + JANELA_FALA}
        ditas = {p for p in ditas if len(p) >= 3}
        achou = False
        for it in am["itens"]:
            escritas = {_norm(x) for x in re.findall(r"\w{3,}", it.get("texto", ""))}
            if escritas & ditas:
                achou = True
                break
        marcas.append((t, achou))
    return marcas


def _legenda_por_movimento(amostras: list[dict]) -> list[tuple[float, bool]]:
    """Sem a fala para comparar: mancha no lugar de legenda que troca de tamanho a cada amostra.

    Recebe as amostras já convertidas por `mapear` (centro_y medido do topo, centro_x, largura, altura), nos dois
    motores. Antes lia as chaves cruas do leitor (x, y, w, h) e quebrava com KeyError 'y' (visto em 29/09/2026)."""
    marcas: list[tuple[float, bool]] = []
    anterior: tuple[float, float] | None = None
    for am in amostras:
        alvo = None
        for it in am["itens"]:
            if 0.55 <= it["centro_y"] <= 0.92 and 0.25 <= it["centro_x"] <= 0.75:
                if alvo is None or it["largura"] > alvo[0]:
                    alvo = (it["largura"], it["altura"])
        troca = bool(alvo and anterior and abs(alvo[0] - anterior[0]) > 0.02)
        marcas.append((am["t"], troca))
        anterior = alvo
    return marcas


def mapear(video: Path, *, fps: float = 2.0, backend: str = "auto",
           palavras: list[dict] | None = None) -> dict:
    info = probe_video(video)
    largura, altura = int(info["width"]), int(info["height"])
    with tempfile.TemporaryDirectory(prefix="texto-na-tela-") as tmp:
        pasta = Path(tmp)
        tempos = extrair_quadros(video, pasta, fps)
        nomes = [f"f_{i + 1:05d}.jpg" for i in range(len(tempos))]
        binario = compilar_vision() if backend in ("auto", "vision") else None
        if backend == "vision" and binario is None:
            raise RuntimeError("motor vision indisponível (precisa de macOS com swiftc)")
        usado = "vision" if binario else "heuristico"
        bruto = _ler_vision(binario, pasta) if binario else _ler_heuristico(pasta)
        papel, tinta = _papel_e_tinta(pasta, nomes)
    amostras: list[dict] = []
    for i, (t, nome) in enumerate(zip(tempos, nomes)):
        itens = []
        for it in bruto.get(nome, []):
            if float(it.get("c", 1)) < CONF_MIN:
                continue
            texto = str(it.get("t", "")).strip()
            centro = 1 - (it["y"] + it["h"] / 2)          # 0 no topo, 1 embaixo
            itens.append({"texto": texto, "centro_y": round(centro, 4),
                          "altura": round(it["h"], 4), "centro_x": round(it["x"] + it["w"] / 2, 4),
                          "largura": round(it["w"], 4)})
        amostras.append({"t": t, "itens": itens, "papel": papel[i], "tinta": tinta[i]})
    if palavras:
        marcas = _legenda_por_palavra(amostras, palavras)
    else:
        marcas = _legenda_por_movimento(amostras)
    doc = {
        "version": VERSION, "created_at": now_iso(), "video": video.name,
        "video_sig": file_signature(video), "backend": usado, "fps": fps,
        "width": largura, "height": altura, "duration": round(float(info["duration"]), 3),
        "bandas": [{"nome": n, "a": a, "b": b} for n, a, b in BANDAS],
        "amostras": amostras,
        "legenda_existente": _intervalos(marcas),
        "tela_cheia": _intervalos([(a["t"], a["papel"] >= PAPEL_MIN) for a in amostras], minimo=1.0),
    }
    doc["resumo"] = resumo(doc)
    return doc


def _amostras_entre(doc: dict, a: float, z: float) -> list[dict]:
    return [s for s in doc["amostras"] if a - 0.3 <= s["t"] <= z + 0.3] or \
           [min(doc["amostras"], key=lambda s: abs(s["t"] - (a + z) / 2))] if doc["amostras"] else []


def cobre(faixas: list[list[float]], a: float, z: float, minimo: float = 0.45) -> bool:
    if z <= a:
        return False
    sobra = sum(max(0.0, min(z, f[1]) - max(a, f[0])) for f in faixas)
    return sobra > minimo * (z - a)


def escolher_faixa(doc: dict, a: float, z: float, *, preferida: str | None = None,
                   margem: float = 0.05) -> dict | None:
    """Faixa livre para pôr a legenda entre `a` e `z` (None = não cabe em nenhuma)."""
    ordem = [n for n, _, _ in BANDAS]
    if preferida and preferida in ordem:
        ordem = [ordem[0]] + ([preferida] if preferida != ordem[0] else []) + \
                [n for n in ordem[1:] if n != preferida]
    amostras = _amostras_entre(doc, a, z)
    papel = bool(amostras) and all(s["papel"] >= PAPEL_MIN for s in amostras)
    for nome in ordem:
        faixa = next(f for f in BANDAS if f[0] == nome)
        lo, hi = faixa[1] - margem, faixa[2] + margem
        livre = True
        for s in amostras:
            if papel:
                if s["tinta"].get(nome, 1.0) > TINTA_LIVRE:
                    livre = False
                    break
            else:
                for it in s["itens"]:
                    if it["centro_y"] + it["altura"] / 2 > lo and it["centro_y"] - it["altura"] / 2 < hi:
                        livre = False
                        break
            if not livre:
                break
        if livre:
            return {"faixa": nome, "centro_y": round((faixa[1] + faixa[2]) / 2, 4),
                    "papel": papel, "cor": "escura" if papel else "clara"}
    return None


def resumo(doc: dict) -> str:
    n_leg = len(doc["legenda_existente"])
    n_cheia = len(doc["tela_cheia"])
    seg_leg = sum(b - a for a, b in doc["legenda_existente"])
    seg_cheia = sum(b - a for a, b in doc["tela_cheia"])
    motor = "leitura de texto (Vision)" if doc["backend"] == "vision" else "manchas de texto (OpenCV)"
    partes = [f"{motor}",
              f"{n_leg} trecho(s) já com legenda na tela ({seg_leg:.0f}s)",
              f"{n_cheia} trecho(s) de gráfico em tela cheia ({seg_cheia:.0f}s)"]
    if doc["backend"] != "vision":
        partes.append("sem leitura de palavras: confira os trechos marcados antes de pular a legenda")
    return "; ".join(partes) + "."


def main(argv: list[str] | None = None) -> int:
    utf8_console()
    ap = argparse.ArgumentParser(description="O que já está escrito na tela (para a legenda desviar)")
    ap.add_argument("--video", type=Path, required=True)
    ap.add_argument("--edit-dir", type=Path)
    ap.add_argument("--transcript", type=Path, help="JSON com as palavras faladas (start/word)")
    ap.add_argument("--fps", type=float, default=2.0)
    ap.add_argument("--backend", choices=["auto", "vision", "heuristico"], default="auto")
    ap.add_argument("--force", action="store_true")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args(argv)
    video = args.video.expanduser().absolute()
    if not video.exists():
        print(f"erro: não encontrei {video}", file=sys.stderr)
        return 2
    edit_dir = (args.edit_dir.expanduser().absolute() if args.edit_dir else edit_dir_for(video))
    destino = edit_dir / "texto_na_tela.json"
    palavras: list[dict] | None = None
    if args.transcript and args.transcript.exists():
        dados = load_json(args.transcript)
        palavras = dados.get("words") if isinstance(dados, dict) else dados
    if destino.exists() and not args.force:
        antigo = load_json(destino)
        if (antigo.get("video_sig") == file_signature(video) and antigo.get("version") == VERSION
                and abs(float(antigo.get("fps", 0)) - args.fps) < 1e-6
                and (palavras is None or antigo.get("backend") == "vision")):
            print(antigo.get("resumo", "já estava pronto"))
            if args.json:
                print(json.dumps(antigo, ensure_ascii=False, indent=2))
            return 0
    try:
        doc = mapear(video, fps=args.fps, backend=args.backend, palavras=palavras)
    except RuntimeError as exc:
        print(f"erro: {exc}", file=sys.stderr)
        return 2
    try:
        save_json(destino, doc)
    except OSError:
        pass
    print(doc["resumo"])
    if args.json:
        print(json.dumps(doc, ensure_ascii=False, indent=2))
    return 0


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