"""Print what the user did in the preview, one flushed line per new event (SPEC Contract 9).

Reads edit/preview_marks.json, edit/preview_trims.json, edit/preview_approval.json and
edit/preview_style.json.
Remembers what it already reported (by `saved_at`) in edit/preview_cache/watch_seen.json,
so running it again never repeats an event. Every line ends with the time the person saved it
("· salvo às 14:30:14"), so a resumed session can compare it with the last render.

When an approval arrives while marks for the same video were saved after its last render, an extra
`[preview] atenção: ...` line follows. The loop mode writes preview_cache/watcher.json every few
seconds, so the preview page knows a live watcher is listening (Codex has none).

Output is UTF-8 and never stops on a character the console cannot show.

Usage:
  uv run python helpers/watch_preview.py --edit-dir DIR [--once] [--interval 1.0]
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
from datetime import datetime
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from common import fmt_time, load_json, load_style_catalog, save_json  # noqa: E402

SOURCES = (
    ("approval", "preview_approval.json"),
    ("marks", "preview_marks.json"),
    ("trims", "preview_trims.json"),
    ("style", "preview_style.json"),
)
PHASE_NAME = {"cut": "corte", "final": "vídeo final"}
PHASE_IN = {"cut": "no corte", "final": "no vídeo final"}
SEEN_LIMIT = 50
HEARTBEAT_S = 3.0
DEFAULT_MEDIA = {"cut": "cut.mp4", "final": "final.mp4"}
MEDIA_KEY = {"cut": "cut_video", "final": "final_video"}
DENOISE_LABEL = {"off": "desligado", "leve": "leve", "forte": "forte"}   # preview_style.json "denoise"
# preview_style.json "music_mood": "off" (Sem trilha), "arquivo" (+ music_file) or a library mood id
# preview_style.json "tela_verde_posicao" and "tela_verde_inserts_fundo" (with "edit_type": "tela_verde")
TV_POSITION_LABEL = {"esquerda": "canto esquerdo", "direita": "canto direito", "centro": "meio"}


def utf8_console() -> None:
    """Windows pipes (Monitor, Codex) default to the ANSI code page: an emoji in a note must not crash."""
    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 saved_clock(saved_at: str) -> str:
    try:
        return datetime.fromisoformat(str(saved_at)).strftime("%H:%M:%S")
    except ValueError:
        return str(saved_at)


def quote(text: str) -> str:
    """One-line, double-quoted note."""
    clean = " ".join(str(text or "").split())
    return '"' + clean.replace('"', "'") + '"'


def format_approval(rec: dict) -> str:
    phase = PHASE_NAME.get(rec.get("phase"), str(rec.get("phase")))
    line = f"[preview] {phase} APROVADO"
    note = (rec.get("note") or "").strip()
    if note:
        line += f" · nota: {quote(note)}"
    return line


def format_marks(rec: dict) -> str:
    marks = rec.get("marks") or []
    where = PHASE_IN.get(rec.get("phase"), f"em {rec.get('phase')}")
    if not marks:
        return f"[preview] marcações {where} apagadas (0 marcações)"
    parts = []
    for m in marks:
        note = (m.get("note") or "").strip()
        if m.get("kind") == "motion":
            piece = f"MOTION AQUI em {fmt_time(float(m.get('in', 0)))}"
            parts.append(f"{piece} {quote(note)}" if note else f"{piece} (o agente escolhe o modelo pela fala)")
            continue
        piece = f"{fmt_time(float(m.get('in', 0)))} a {fmt_time(float(m.get('out', 0)))}"
        parts.append(f"{piece} {quote(note)}" if note else f"{piece} (sem nota)")
    label = "marcação" if len(marks) == 1 else "marcações"
    return f"[preview] {len(marks)} {label} {where}: " + "; ".join(parts)


def format_trims(rec: dict) -> str:
    """Pedidos de esticar/encolher a borda de um trecho, em segundos do EDL."""
    trims = rec.get("trims") or []
    where = PHASE_IN.get(rec.get("phase"), f"em {rec.get('phase')}")
    if not trims:
        return f"[preview] ajustes de trecho {where} apagados (0 ajustes)"
    parts = []
    for tr in trims:
        rid = str(tr.get("id", "?"))
        bits = []
        for key, onde in (("start_delta", "no começo"), ("end_delta", "no fim")):
            delta = float(tr.get(key) or 0.0)
            if not delta:
                continue
            verbo = "estica" if (delta < 0 if key == "start_delta" else delta > 0) else "encolhe"
            bits.append(f"{verbo} {abs(delta):.2f}s {onde}")
        quote_txt = (tr.get("quote") or "").strip()
        peca = f"{rid}: " + " e ".join(bits or ["sem mudança"])
        parts.append(f"{peca} {quote(quote_txt)}" if quote_txt else peca)
    label = "ajuste de trecho" if len(trims) == 1 else "ajustes de trecho"
    return (f"[preview] {len(trims)} {label} {where}: " + "; ".join(parts)
            + " · aplique no edl.json (start_delta soma no start, end_delta soma no end) e renderize de novo")


def format_style(rec: dict, catalog: dict) -> str:
    elements = rec.get("elements") or {}
    order = [e.get("id") for e in (catalog.get("elements") or []) if e.get("id")]
    order += [k for k in elements if k not in order]
    on = [k for k in order if elements.get(k) is True]
    off = [k for k in order if k in elements and elements.get(k) is False]
    edit_type = rec.get("edit_type") or "-"
    if edit_type == "tela_verde":
        pos = rec.get("tela_verde_posicao") or "direita"
        moved = rec.get("tela_verde_inserts_fundo")
        if isinstance(moved, int) and not isinstance(moved, bool) and moved > 0:
            what = ("o insert de imagem em cima ou embaixo vai" if moved == 1 else
                    f"os {moved} inserts de imagem em cima ou embaixo vão")
            what += " para o fundo, sem tela dividida"
        else:
            what = "imagens e vídeos dos inserts vão para o fundo, sem tela dividida"
        edit_type += f" ({TV_POSITION_LABEL.get(pos, pos)}: tela_verde.posicao \"{pos}\"; {what})"
    fields = [
        f"[preview] estilo salvo: {edit_type}",
        f"legenda {rec.get('caption_style') or '-'}",
        f"gancho {rec.get('hook_style') or '-'}",
        f"cor {rec.get('accent') or '-'}",
        "elementos: " + (", ".join(on) if on else "nenhum"),
        "fora: " + (", ".join(off) if off else "nenhum"),
    ]
    if isinstance(elements.get("motion"), bool):
        if elements["motion"] and rec.get("motion_qtd"):
            n = {"poucas": 3, "algumas": 5}.get(rec["motion_qtd"], 3)
            tema = rec.get("motion_tema") or "papelaria"
            fields.append(f"animações: ligadas, {rec['motion_qtd']} (motion.quantidade {n}), visual {tema} "
                          f"(motion.tema \"{tema}\")")
        else:
            fields.append(f"animações: {'ligadas' if elements['motion'] else 'desligadas'}")
    if isinstance(rec.get("denoise"), str) and rec["denoise"] in DENOISE_LABEL:
        fields.append(f"ruído: {DENOISE_LABEL[rec['denoise']]}")
    mood = rec.get("music_mood")
    if isinstance(mood, str) and mood:
        if mood == "off":
            fields.append("trilha: sem")
        elif mood == "arquivo":
            fields.append(f"trilha: arquivo da pasta ({rec.get('music_file') or '?'})")
        else:
            fields.append(f"trilha: {mood}")
    if (rec.get("hook_text") or "").strip():
        fields.append(f"texto do gancho: {quote(rec['hook_text'])}")
    if (rec.get("cta_text") or "").strip():
        fields.append(f"chamada: {quote(rec['cta_text'])}")
    if (rec.get("note") or "").strip():
        fields.append(f"nota: {quote(rec['note'])}")
    return " · ".join(fields)


def read_record(path: Path) -> dict | None:
    for attempt in range(3):
        try:
            data = load_json(path)
            return data if isinstance(data, dict) else None
        except json.JSONDecodeError:
            time.sleep(0.05)
        except OSError:
            return None
    return None


def load_seen(path: Path) -> dict[str, list[str]]:
    try:
        data = load_json(path, default={}) or {}
    except (json.JSONDecodeError, OSError):
        data = {}
    seen: dict[str, list[str]] = {}
    for kind, _ in SOURCES:
        value = data.get(kind)
        if isinstance(value, str):
            value = [value]
        seen[kind] = [v for v in (value or []) if isinstance(v, str)]
    return seen


def collect_new(edit_dir: Path, seen: dict[str, list[str]]) -> list[tuple[str, str, dict]]:
    events = []
    for kind, filename in SOURCES:
        path = edit_dir / filename
        if not path.is_file():
            continue
        rec = read_record(path)
        if not rec:
            continue
        saved_at = rec.get("saved_at")
        if not isinstance(saved_at, str) or not saved_at:
            continue
        if saved_at in seen.get(kind, []):
            continue
        events.append((saved_at, kind, rec))
    events.sort(key=lambda e: e[0])
    return events


def media_mtime(edit_dir: Path, phase: str) -> float | None:
    """mtime of the video the person was reviewing for this phase."""
    rel = DEFAULT_MEDIA.get(phase)
    try:
        state = load_json(edit_dir / "state.json") or {}
        if isinstance(state, dict) and isinstance(state.get(MEDIA_KEY.get(phase, "")), str):
            rel = state[MEDIA_KEY[phase]]
    except (json.JSONDecodeError, OSError):
        pass
    try:
        return (edit_dir / rel).stat().st_mtime if rel else None
    except OSError:
        return None


def iso_epoch(saved_at: str) -> float | None:
    try:
        return datetime.fromisoformat(str(saved_at)).timestamp()
    except ValueError:
        return None


def pending_marks_note(edit_dir: Path, approval: dict) -> str | None:
    """Marks for the same video saved after its render and before the approval: the agent must ask."""
    phase = approval.get("phase")
    marks = read_record(edit_dir / "preview_marks.json")
    if not marks or marks.get("phase") != phase or not marks.get("marks"):
        return None
    marks_t, approval_t = iso_epoch(marks.get("saved_at", "")), iso_epoch(approval.get("saved_at", ""))
    render_t = media_mtime(edit_dir, str(phase))
    if marks_t is None or approval_t is None or marks_t > approval_t + 0.001:
        return None
    if render_t is not None and marks_t < render_t:
        return None  # those marks were for an older version, already handled
    n = len(marks["marks"])
    where = PHASE_IN.get(phase, f"em {phase}")
    label = "marcação" if n == 1 else "marcações"
    return (f"[preview] atenção: a aprovação veio junto com {n} {label} {where} salvas depois do último render. "
            "Pergunte se é para ajustar antes de seguir.")


def stale_approval_note(edit_dir: Path, approval: dict) -> str | None:
    """The approval names the file version the person watched; a newer render needs a new approval."""
    rel, mtime, size = approval.get("file"), approval.get("file_mtime"), approval.get("file_size")
    if not isinstance(rel, str) or mtime is None:
        return None
    try:
        st = (edit_dir / rel).stat()
    except OSError:
        return None
    if abs(st.st_mtime - float(mtime)) <= 0.01 and (size is None or int(size) == st.st_size):
        return None
    name = PHASE_NAME.get(approval.get("phase"), str(approval.get("phase")))
    return (f"[preview] atenção: essa aprovação é de uma versão anterior do {name} ({rel} mudou depois). "
            "Peça para a pessoa assistir e aprovar de novo.")


def emit(line: str) -> None:
    try:
        print(line, flush=True)
    except (UnicodeError, OSError):
        try:
            sys.stdout.buffer.write((line + "\n").encode("utf-8", "replace"))
            sys.stdout.flush()
        except (AttributeError, OSError):
            pass


def report(edit_dir: Path, seen_path: Path, seen: dict[str, list[str]], catalog: dict) -> int:
    events = collect_new(edit_dir, seen)
    if not events:
        return 0
    for saved_at, kind, rec in events:
        # mark it seen first: one line that fails to print must never block every later event
        seen[kind] = (seen.get(kind, []) + [saved_at])[-SEEN_LIMIT:]
        try:
            if kind == "approval":
                line = format_approval(rec)
            elif kind == "marks":
                line = format_marks(rec)
            elif kind == "trims":
                line = format_trims(rec)
            else:
                line = format_style(rec, catalog)
        except (TypeError, ValueError, AttributeError) as exc:
            line = f"[preview] {kind}: não consegui ler o registro ({exc}); abra o arquivo preview_{kind}.json"
        emit(f"{line} · salvo às {saved_clock(saved_at)}")
        if kind == "approval":
            for note in (stale_approval_note(edit_dir, rec), pending_marks_note(edit_dir, rec)):
                if note:
                    emit(note)
    try:
        save_json(seen_path, seen)
    except OSError as exc:
        print(f"Aviso: não consegui gravar {seen_path}: {exc}", file=sys.stderr, flush=True)
    return len(events)


def heartbeat(edit_dir: Path) -> None:
    try:
        save_json(edit_dir / "preview_cache" / "watcher.json", {"mode": "loop", "pid": os.getpid(), "updated_at": time.time()})
    except OSError:
        pass


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Mostra, uma linha por evento, o que a pessoa fez no preview (aprovação, marcações, estilo).",
    )
    parser.add_argument("--edit-dir", required=True, help="pasta edit/ da sessão")
    parser.add_argument("--once", action="store_true", help="verifica uma vez e sai")
    parser.add_argument("--interval", type=float, default=1.0, help="segundos entre verificações (padrão 1.0)")
    args = parser.parse_args()
    utf8_console()

    edit_dir = Path(args.edit_dir).expanduser().resolve()
    if not edit_dir.is_dir():
        print(f"Erro: a pasta de edição não existe: {edit_dir}", file=sys.stderr)
        return 2
    if args.interval <= 0:
        print("Erro: --interval precisa ser maior que zero.", file=sys.stderr)
        return 2
    seen_path = edit_dir / "preview_cache" / "watch_seen.json"
    seen = load_seen(seen_path)
    catalog = load_style_catalog()

    if args.once:
        if report(edit_dir, seen_path, seen, catalog) == 0:
            print("[preview] nada novo", file=sys.stderr, flush=True)
        return 0

    print("Esperando ações no preview. Ctrl+C para parar.", file=sys.stderr, flush=True)
    last_beat = 0.0
    try:
        while True:
            if not catalog:
                catalog = load_style_catalog()
            if time.time() - last_beat >= HEARTBEAT_S:
                heartbeat(edit_dir)
                last_beat = time.time()
            report(edit_dir, seen_path, seen, catalog)
            time.sleep(args.interval)
    except KeyboardInterrupt:
        try:
            (edit_dir / "preview_cache" / "watcher.json").unlink()
        except OSError:
            pass
        return 0


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