"""First cut built automatically: every phrase once, the last take of each repeated one, no pauses.

The agent used to write the EDL range by range. For a talking video that follows a script this
helper does the mechanical part and the agent only reviews what it dropped:

1. Each source is split into speech blocks at the pauses measured on the waveform (at least
   --pausa s below the noise floor + 10 dB). Whisper decides the words, never the edges.
2. A block is a false start when a later block, up to JANELA_BLOCOS ahead and within
   JANELA_S seconds, begins with nearly the same words (difflib >= PARECIDO on the first words):
   the speaker said it again, so the last take wins. Chains ("E nesse exato momento..." three
   times) keep only the last one.
3. Blocks without words (breath, cough, noise) or with only hesitation words go out.
4. What is left becomes one range per block, and bordas_precisas.refine_edl() puts every edge in
   a measured pause (0.08 s before, 0.12 s after): the junction silence lands at ~0.2 s.
5. A take abandoned at the end of a block with no pause before its retake is trimmed off, and a
   whole sentence said again later (even inside a longer take, or in the next video) goes out.
6. voice_levels.py sets gain_db on ranges GANHO_DB or more away from the voice reference.

Writes <edit>/edl.json (the old one goes to edl.json.bak) and <edit>/corte_auto.md with every
dropped block, its reason and the take that replaced it. Read corte_auto.md before rendering:
a block dropped as a retake that was NOT one (two different sentences starting the same way) is
the error to look for.

Usage:
    uv run python helpers/montar_corte.py --edit-dir <edit> [--fontes a.mov b.mov ...]
        [--pausa 0.35] [--formato vertical] [--dry-run]
"""

from __future__ import annotations

import argparse
import difflib
import re
import shutil
import subprocess
import sys
import unicodedata
from pathlib import Path

import numpy as np

from bordas_precisas import HOP, OFF_DB, refine_edl, source_levels
from common import load_json, save_json

PAUSA_S = 0.35          # a pause this long splits two blocks
MIN_ILHA_S = 0.03       # speech islands shorter than this are clicks and join the silence
                        # (0.08 swallowed short words: "ser", "o que", "É")
JANELA_BLOCOS = 4       # a retake is looked for this many blocks ahead ...
JANELA_S = 25.0         # ... and within this many seconds
PARECIDO = 0.6          # share of the block's words found, in order, at the start of a near later block
PARECIDO_LONGE = 0.75   # ... of a later block further away or in a later source (retake of a whole part)
LONGE_BLOCOS = 12       # how far ahead, in blocks across sources, a whole-part retake is looked for
MIN_LONGE = 5           # blocks shorter than this are never matched far away
QUASE_IGUAL = 0.9       # a block of MIN_LONGE+ words found this complete anywhere later is a retake
CURTO = 3               # a block this short is a false start when all its words open the next block
VOZ_SEM_PALAVRA_S = 0.25   # a block without transcribed words this long ...
VOZ_PICO_DB = 20.0         # ... and this loud over the floor is a word Whisper misplaced, not a breath
HESITACAO = {"é", "e", "ah", "ahn", "hã", "hum", "hm", "uhm", "eh", "éé", "tá", "né", "então", "aí"}
GANHO_DB = 4.0          # ranges this far from the voice reference get their gain_db (verify uses 4 too)
FORMATOS = {"vertical": (1080, 1920), "horizontal": (1920, 1080), "square": (1080, 1080)}


def norm(word: str) -> str:
    w = unicodedata.normalize("NFD", word.lower())
    w = "".join(c for c in w if unicodedata.category(c) != "Mn")
    return re.sub(r"[^\w]", "", w)


def blocks_of(db: np.ndarray, floor: float, pausa: float) -> list[tuple[float, float]]:
    """Speech blocks (start, end) in seconds, split at quiet runs of at least `pausa`."""
    loud = db >= floor + OFF_DB
    # clicks: loud runs shorter than MIN_ILHA_S become silence
    k, n, short = 0, loud.size, int(round(MIN_ILHA_S / HOP))
    while k < n:
        if loud[k]:
            j = k
            while j < n and loud[j]:
                j += 1
            if j - k < short:
                loud[k:j] = False
            k = j
        else:
            k += 1
    out, k, gap = [], 0, int(round(pausa / HOP))
    while k < n:
        if not loud[k]:
            k += 1
            continue
        a = k
        quiet = 0
        while k < n and quiet < gap:
            quiet = 0 if loud[k] else quiet + 1
            k += 1
        out.append((a * HOP, (k - quiet) * HOP))
    return out


def words_in(words: list[dict], a: float, b: float) -> list[dict]:
    return [w for w in words if a <= (float(w["start"]) + float(w["end"])) / 2 < b]


def coverage(first: list[str], later: list[str]) -> tuple[float, int]:
    """(share of `first` found in order in `later` in runs of 2+ words, where that match starts)."""
    if not first or not later:
        return 0.0, 0
    sm = difflib.SequenceMatcher(None, first, later, autojunk=False)
    runs = [m for m in sm.get_matching_blocks() if m.size >= 2]
    if not runs:
        return 0.0, 0
    return sum(m.size for m in runs) / len(first), runs[0].b


def retake_of(bl: dict, nxt: list[dict], near: bool) -> tuple[float, int]:
    """(how clearly `bl` is an earlier take of what `nxt` says, which of `nxt` the take starts in);
    (0, 0) if it is not a retake."""
    toks = bl["toks"]
    later = [t for b in nxt for t in b["toks"]]
    if near and len(toks) <= CURTO:
        opening = nxt[0]["toks"][:8]
        hit = opening and all(t in opening for t in toks) and len(opening) > len(toks)
        return (1.0, 0) if hit else (0.0, 0)
    cov, at = coverage(toks, later[: len(toks) + 12])
    if at > 3:
        # the later take must begin with it, not quote it in the middle ... unless it repeats a
        # long stretch almost word for word (a whole sentence said again inside a longer take)
        cov, at = coverage(toks, later)
        if not (len(toks) >= MIN_LONGE and cov >= QUASE_IGUAL):
            return 0.0, 0
    which, acc = 0, 0
    for k, b in enumerate(nxt):
        if at < acc + len(b["toks"]):
            which = k
            break
        acc += len(b["toks"])
    if near:
        return (cov, which) if cov >= PARECIDO and cov * len(toks) >= 3 else (0.0, 0)
    return (cov, which) if len(toks) >= MIN_LONGE and cov >= PARECIDO_LONGE else (0.0, 0)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--edit-dir", type=Path, required=True)
    ap.add_argument("--fontes", nargs="*", help="vídeos na ordem do roteiro (padrão: todos os transcritos, por nome)")
    ap.add_argument("--pausa", type=float, default=PAUSA_S)
    ap.add_argument("--formato", default="vertical", choices=sorted(FORMATOS))
    ap.add_argument("--dry-run", action="store_true", help="só mostra, não grava o edl.json")
    args = ap.parse_args()
    edit = args.edit_dir

    trs = {}
    for p in sorted((edit / "transcripts").glob("*.json")):
        if p.stem == "cut":
            continue
        d = load_json(p)
        if isinstance(d, dict) and d.get("words") is not None and d.get("source"):
            trs[p.stem] = d
    if args.fontes:
        order = [Path(f).stem for f in args.fontes]
        missing = [s for s in order if s not in trs]
        if missing:
            print(f"sem transcrição: {', '.join(missing)} (rode o transcribe.py antes)", file=sys.stderr)
            return 2
    else:
        order = list(trs)
    if not order:
        print(f"nenhuma transcrição em {edit / 'transcripts'}", file=sys.stderr)
        return 2

    sources, ranges, dropped, envs = {}, [], [], {}
    allb: list[dict] = []          # every block of every source, in script order
    n = 0
    for stem in order:
        tr = trs[stem]
        src = str(tr["source"])
        lv = source_levels(src, print)
        envs[stem] = lv
        sources[stem] = src
        if lv is None:
            print(f"{stem}: entra inteiro, revise.")
            allb.append({"stem": stem, "a": 0.0, "b": float(tr.get("duration") or 0), "text": "", "toks": ["?"],
                         "whole": True})
            continue
        db, floor = lv
        words = [w for w in tr["words"] if w.get("start") is not None]
        for a, b in blocks_of(db, floor, args.pausa):
            ws = words_in(words, a, b)
            text = " ".join(str(w.get("text", "")).strip() for w in ws)
            ws = [w for w in ws if norm(str(w.get("text", "")))]   # one token per word, aligned
            peak = float(db[int(a / HOP):max(int(a / HOP) + 1, int(b / HOP))].max()) - floor
            allb.append({"stem": stem, "a": a, "b": b, "text": text, "words": ws, "peak": peak,
                         "toks": [norm(str(w.get("text", ""))) for w in ws],
                         "cut_off": text.rstrip().endswith(("...", "…", "-"))})

    for i, bl in enumerate(allb):
        bl["keep"], bl["why"] = True, ""
        if bl.get("whole"):
            continue
        if not bl["toks"]:
            if bl["b"] - bl["a"] >= VOZ_SEM_PALAVRA_S and bl.get("peak", 0.0) >= VOZ_PICO_DB:
                # Whisper put the word somewhere else ("Eu" 1 s early): loud enough to be a word, so it stays
                bl["text"] = "(fala sem transcrição: confira)"
                continue
            bl["keep"], bl["why"] = False, "sem fala (respiro, barulho)"
            continue
        if all(t in HESITACAO for t in bl["toks"]):
            bl["keep"], bl["why"] = False, f"só hesitação (\"{bl['text']}\")"
            continue
        for j in range(i + 1, min(len(allb), i + 1 + LONGE_BLOCOS)):
            later = allb[j]
            if not later["toks"] or later.get("whole"):
                continue
            near = j - i <= JANELA_BLOCOS and (later["stem"] != bl["stem"] or later["a"] - bl["b"] <= JANELA_S)
            if len(bl["toks"]) <= CURTO and j > i + 1 and near:
                continue      # a false start is redone right after it, not three blocks later ("Não!" ... "que não")
            r, k = retake_of(bl, allb[j:j + 3], near)
            if r:
                later = allb[j + k]
                onde = later["a"] if later["stem"] == bl["stem"] else f"{later['stem']} {later['a']:.2f}"
                bl["keep"] = False
                bl["why"] = (f"tomada repetida ({r:.0%} dela no começo de {onde if isinstance(onde, str) else f'{onde:.2f}'}s: "
                             f"\"{later['text'][:60]}\")")
                break

    # a take abandoned at the end of a block with no pause before the retake: "... da Shopee e um
    # mecanismo..." + "E um mecanismo com inteligência artificial". The block ends before it.
    kept = [b for b in allb if b["keep"] and not b.get("whole")]
    for bl, nb in zip(kept, kept[1:]):
        if not bl.get("cut_off") or len(bl["toks"]) < 3 or not nb["toks"]:
            continue
        for k in range(min(6, len(bl["toks"]) - 1), 1, -1):
            tail, head = bl["toks"][-k:], nb["toks"][: k + 2]
            cov, at = coverage(tail, head)
            if cov >= 0.99 and at <= 1:
                t0 = float(bl["words"][-k]["start"])
                if t0 - bl["a"] > 0.3:
                    dropped.append((bl["stem"], {"a": t0, "b": bl["b"], "text": " ".join(
                        str(w.get("text", "")).strip() for w in bl["words"][-k:])},
                        f"fim de tomada abandonada, refeita em {nb['a']:.2f}s"))
                    bl["b"] = t0
                    bl["text"] = " ".join(str(w.get("text", "")).strip() for w in bl["words"][:-k])
                break

    for bl in allb:
        if bl["keep"]:
            n += 1
            ranges.append({"id": f"r{n:02d}", "source": bl["stem"], "start": round(bl["a"], 3),
                           "end": round(bl["b"], 3), "beat": "", "quote": bl["text"][:90],
                           "reason": "fonte sem pausas medíveis" if bl.get("whole")
                           else "montar_corte: última tomada da frase", "gain_db": 0})
        else:
            dropped.append((bl["stem"], bl, bl["why"]))

    edl = {"version": 1, "sources": sources,
           "output": {"format": args.formato, "width": FORMATOS[args.formato][0],
                      "height": FORMATOS[args.formato][1]},
           "grade": "", "jcut": {"lead_frames": 4, "tail_trim_frames": 2}, "ranges": ranges}
    log_lines: list[str] = []
    alerts = refine_edl(edl, envs, log=log_lines.append)

    fala = sum(r["end"] - r["start"] for r in ranges)
    bruto = sum(float(t.get("duration") or 0) for s, t in trs.items() if s in order)
    md = [f"# Corte automático · {len(ranges)} trechos, {fala:.1f} s de {bruto:.1f} s de bruto", "",
          f"## Tirado ({len(dropped)} blocos) · confira se alguma frase boa saiu como repetida", ""]
    for stem, bl, reason in dropped:
        md.append(f"- {stem} [{bl['a']:.2f}-{bl['b']:.2f}] \"{bl['text'][:80]}\" · {reason}")
    if alerts:
        md += ["", "## SEM PAUSA (ouça essas bordas)", ""] + [f"- {a}" for a in alerts]
    md += ["", "## Trechos", ""] + [f"- {r['id']} {r['source']} [{r['start']:.2f}-{r['end']:.2f}] {r['quote']}"
                                    for r in ranges]
    report = "\n".join(md) + "\n"
    print(report)
    if not args.dry_run:
        edl_path = edit / "edl.json"
        if edl_path.exists():
            shutil.copy(edl_path, edit / "edl.json.bak")
        save_json(edl_path, edl)
        # voice level per range now, so the first render does not come back with LOW-LEVEL flags
        vl = subprocess.run([sys.executable, str(Path(__file__).with_name("voice_levels.py")), "--edl", str(edl_path),
                             "--drop-db", str(GANHO_DB)], capture_output=True, text=True)
        levels = load_json(edit / "verify" / "voice_levels.json") if vl.returncode == 0 else None
        ganhos = {r["id"]: float(r["gain_db"]) for r in (levels or {}).get("ranges", [])
                  if (r.get("low") or r.get("high")) and r.get("gain_db") is not None}
        if ganhos:
            for r in edl["ranges"]:
                if r["id"] in ganhos:
                    r["gain_db"] = ganhos[r["id"]]
            save_json(edl_path, edl)
            print("volume ajustado: " + ", ".join(f"{k} {v:+.1f} dB" for k, v in ganhos.items()))
        (edit / "corte_auto.md").write_text(report, encoding="utf-8")
        print(f"gravado: {edl_path} e {edit / 'corte_auto.md'}")
    return 0


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