#!/usr/bin/env python3
"""One command that runs the whole edit, in the only order that works.

Every step of this skill is a separate script on purpose: each one is auditable,
re-runnable and reports numbers. The cost of that is that a person — or a model —
assembling them by hand can get the order wrong, and the failures are quiet:

  * captions burned before the cold open → the whole file is off by the length
    of the hook, and nothing errors;
  * punch-ins applied after the captions → the titles breathe with the zoom;
  * loudness normalised twice → the master is over the platform ceiling;
  * the retention pass reading a timeline the cold open already invalidated.

So the order is encoded here rather than described in prose. Each stage declares
what it consumes and what it produces, the runner refuses to start a stage whose
input is missing, and every stage is skipped if its output is already newer than
its input — so a failed run is resumed, not restarted.

The run is a transaction with a manifest: `pipeline_report.json` records, for
every stage, the command, the duration, the numbers it reported and whether it
was skipped. A finished run is reproducible from that file alone.

    python scripts/run_pipeline.py --project P --source in.mp4 --words w.json
    python scripts/run_pipeline.py --project P --from captions   # resume
    python scripts/run_pipeline.py --project P --dry-run         # just the plan
"""
from __future__ import annotations

import argparse
import json
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path

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

from skill_config import emit, require_binary, utf8_stdout  # noqa: E402

HERE = Path(__file__).resolve().parent


@dataclass
class Stage:
    name: str
    script: str
    why: str
    produces: list[str]
    consumes: list[str] = field(default_factory=list)
    optional: bool = False


# The order. Read it as the answer to "what has to be true before this can run".
STAGES: list[Stage] = [
    Stage("voice", "voice_restore.py",
          "Звук чинится до нарезки: после ретайминга шум уже растянут вместе с речью.",
          produces=["source_clean.mp4"], consumes=["<source>"]),
    Stage("recut", "plan_speech_recut.py",
          "Нарезка речи строит шкалу. Всё остальное её читает.",
          produces=["recut.json", "timeline_recut.json"],
          consumes=["source_clean.mp4", "<words>"]),
    Stage("base", "render_speech_recut.py",
          "Базовый рендер со скоростными рампами.",
          produces=["base.mp4"], consumes=["recut.json"]),
    Stage("hook", "plan_cold_open.py",
          "Выбор крючка. Ранжирование — выбор всё равно за человеком.",
          produces=["cold_open.json"], consumes=["source_clean.mp4", "<words>"]),
    Stage("open", "render_cold_open.py",
          "Холодный старт сдвигает шкалу. После него её больше никто не двигает.",
          produces=["with_open.mp4", "timeline_full.json"],
          consumes=["cold_open.json", "base.mp4", "timeline_recut.json"]),
    Stage("retention", "plan_retention.py",
          "Бюджет неподвижности считается по итоговой шкале, а не по исходной.",
          produces=["retention_plan.json"], consumes=["with_open.mp4", "timeline_full.json"]),
    Stage("punch", "render_punch_ins.py",
          "Рефреймы до титров: иначе зум применяется к субтитрам.",
          produces=["punched.mp4"], consumes=["retention_plan.json", "with_open.mp4"]),
    Stage("grade", "apply_grade.py",
          "Грейд по готовой геометрии и до прожига титров.",
          produces=["graded.mp4"], consumes=["punched.mp4"]),
    Stage("cutfx", "render_cut_effects.py",
          "Эффекты на существующих стыках. Клипы не режутся заново.",
          produces=["with_fx.mp4", "cut_sfx.json"],
          consumes=["graded.mp4", "timeline_full.json"]),
    # Перебивки идут ДО прожига титров.
    #
    # Порядок слоёв снизу вверх: база → перебивки → титры → графика. Если
    # перебивку положить поверх прожжённых титров, она их закрывает — полноэкранная
    # вставка перекрывает ровно ту область, где стоит плашка субтитра, и на каждой
    # вставке текст пропадает. Именно это и произошло.
    Stage("broll", "render_broll_overlay.py",
          "Перебивки ложатся на базу ДО титров: иначе полноэкранная вставка "
          "закрывает субтитр.",
          produces=["with_broll.mp4"], consumes=["with_fx.mp4", "<broll>"], optional=True),
    Stage("captions", "build_word_synced_ass.py",
          "Субтитры строятся из итоговой шкалы.",
          produces=["captions.ass", "caption_map.json"], consumes=["timeline_full.json"]),
    Stage("burn", "burn_ass_captions.py",
          "Прожиг титров поверх базы с перебивками.",
          produces=["captioned.mp4"], consumes=["<last_video>", "captions.ass"]),
    Stage("dataviz", "plan_dataviz.py",
          "Числа из речи -> карточки данных. Без этого шага слой графики нечем "
          "наполнить, и он падает обратно на декор.",
          produces=["gfx_plan.json"], consumes=["timeline_full.json"]),
    Stage("zone", "find_overlay_zone.py",
          "Где находится спикер. Графика ставится по этому числу, а не на глаз.",
          produces=["overlay_zone.json"], consumes=["captioned.mp4"]),
    Stage("graphics", "render_graphics_overlay.py",
          "Слой графики поверх титров: он самый верхний, как и должен быть.",
          produces=["with_gfx.mp4"],
          consumes=["captioned.mp4", "gfx_plan.json", "overlay_zone.json"],
          optional=True),
    Stage("layers", "render_audio_layers.py",
          "Атмосфера и J-cut. До музыки: бас меряет громкость голоса, и слой, "
          "добавленный после, сместил бы уровень, от которого он считался.",
          produces=["with_layers.mp4"], consumes=["<last_video>"], optional=True),
    Stage("music", "mix_music_bed.py",
          "Музыка подкладывается под готовый голос.",
          produces=["with_music.mp4"], consumes=["<last_video>", "<music>"], optional=True),
    Stage("master", "audio_fx.py",
          "Громкость нормализуется ОДИН раз, здесь.",
          produces=["master.partial.mp4"], consumes=["<last_video>"]),
    Stage("gates", "run_quality_gates.py",
          "Ворота. Без них файл не считается готовым.",
          produces=["gates/summary.json"],
          consumes=["master.partial.mp4", "timeline_full.json", "caption_map.json"]),
]


def build_command(stage: Stage, project: Path, args, state: dict) -> list[str] | None:
    """Concrete argv for one stage, or None when the stage does not apply."""
    p = lambda name: str(project / name)  # noqa: E731
    script = str(HERE / stage.script)
    base = [sys.executable, script]

    if stage.name == "voice":
        return base + [args.source, p("source_clean.mp4"),
                       "--report", p("voice_restore.json")]
    if stage.name == "recut":
        return base + [p("source_clean.mp4"), args.words,
                       "--out", p("recut.json"), "--timeline", p("timeline_recut.json"),
                       "--style", args.recut_style, "--speed", str(args.speed), "--ramp",
                       "--report", p("recut_report.json")]
    if stage.name == "base":
        return base + [p("recut.json"), p("base.mp4"),
                       "--canvas", args.canvas, "--profile", "master"]
    if stage.name == "hook":
        return base + [p("source_clean.mp4"), args.words, "--out", p("cold_open.json")]
    if stage.name == "open":
        return base + [p("cold_open.json"), p("base.mp4"), p("with_open.mp4"),
                       "--source", p("source_clean.mp4"), "--words", args.words,
                       "--body-timeline", p("timeline_recut.json"),
                       "--out-timeline", p("timeline_full.json"),
                       "--pick", str(args.hook_pick), "--canvas", args.canvas]
    if stage.name == "retention":
        return base + [p("with_open.mp4"), p("timeline_full.json"),
                       "--out", p("retention_plan.json"),
                       "--max-static", str(args.max_static), "--canvas", args.canvas]
    if stage.name == "punch":
        return base + [p("retention_plan.json"), p("with_open.mp4"), p("punched.mp4"),
                       "--canvas", args.canvas]
    if stage.name == "grade":
        return base + [p("punched.mp4"), p("graded.mp4"), "--grade", args.grade,
                       "--strength", str(args.grade_strength), "--profile", "master",
                       "--contact-sheet", p("grade_cmp.jpg")]
    if stage.name == "cutfx":
        return base + [p("graded.mp4"), p("with_fx.mp4"),
                       "--timeline", p("timeline_full.json"),
                       "--recut", p("recut.json"), "--intent", args.fx_intent,
                       "--sfx-plan", p("cut_sfx.json"), "--canvas", args.canvas]
    if stage.name == "captions":
        return base + [p("timeline_full.json"), p("captions.ass"),
                       "--map", p("caption_map.json"), "--preset", args.caption_preset,
                       "--canvas", args.canvas]
    if stage.name == "burn":
        # Источник — последняя картинка в цепочке (с перебивками, если они были),
        # а не жёстко with_fx.mp4: иначе включённые перебивки молча пропадут.
        return base + [state["picture"], p("captions.ass"), p("captioned.mp4"),
                       "--fonts-dir", args.fonts_dir, "--profile", "master",
                       "--log", p("burn.log")]
    if stage.name == "zone":
        # Ключ именно --report: у find_overlay_zone.py нет --out, и с ним шаг
        # падает на разборе аргументов, ничего не сделав.
        return base + [p("captioned.mp4"), "--canvas", args.canvas,
                       "--report", p("overlay_zone.json")]
    if stage.name == "dataviz":
        return base + [p("timeline_full.json"), "--out", p("gfx_plan.json")]
    if stage.name == "graphics":
        if args.no_graphics:
            return None
        # План карточек, если он есть; иначе события удержания. Пак по умолчанию
        # — данные, а не декор: см. render_graphics_overlay.PACK.
        command = base + [p("captioned.mp4"), p("with_gfx.mp4"),
                          "--zone", p("overlay_zone.json"), "--canvas", args.canvas]
        cards = project / "gfx_plan.json"
        if cards.is_file() and json.loads(cards.read_text(encoding="utf-8")).get("overlays"):
            command += ["--plan", str(cards)]
        else:
            command += ["--retention-plan", p("retention_plan.json")]
        return command
    if stage.name == "broll":
        if not args.broll:
            return None
        return base + [p("with_fx.mp4"), args.broll, p("with_broll.mp4"),
                       "--canvas", args.canvas, "--profile", "master"]
    if stage.name == "layers":
        if args.no_layers:
            return None
        command = base + [state["picture"], p("with_layers.mp4"),
                          "--ambience", args.ambience, "--j-lead", str(args.j_lead)]
        if (project / "broll_plan.json").is_file():
            command += ["--broll-plan", p("broll_plan.json")]
        return command
    if stage.name == "music":
        if not args.music:
            return None
        return base + [state["picture"], args.music, p("with_music.mp4"),
                       "--intent", args.music_intent,
                       "--timeline", p("timeline_full.json"),
                       "--music-start", str(args.music_start)]
    if stage.name == "master":
        source = p("with_music.mp4") if args.music else state["picture"]
        return base + [source, p("master.partial.mp4"),
                       "--treatment", "none", "--loudness", args.loudness]
    if stage.name == "gates":
        return base + [p("master.partial.mp4"), p("timeline_full.json"),
                       p("caption_map.json"), "--canvas", args.canvas,
                       "--loudness", args.loudness, "--fonts-dir", args.fonts_dir,
                       "--report-dir", p("gates")]
    raise SystemExit(f"No command builder for stage {stage.name}")


def newest(paths: list[Path]) -> float:
    return max((path.stat().st_mtime for path in paths if path.exists()), default=0.0)


PLAYABLE = (".mp4", ".mov", ".mkv", ".webm", ".m4a", ".mp3", ".wav")


def usable(path: Path) -> tuple[bool, str]:
    """Существования файла недостаточно — он должен ещё и открываться.

    Убитый посреди записи ffmpeg оставляет контейнер без moov-атома: сто с лишним
    мегабайт, правдоподобная дата, нечитаемое содержимое. Проверка «файл на
    месте и новее входов» такой обрубок считает готовым результатом и молча
    пропускает шаг — а падает следующий, с сообщением про чужой файл. Ровно это и
    произошло: прогон убили на грейде, возобновление пропустило грейд и упало на
    эффектах со словами «Invalid data found» про graded.mp4.

    Поэтому у видео и звука спрашивается длительность. Дёшево (ffprobe читает
    заголовок) и отсекает весь класс.
    """
    if not path.exists():
        return False, "нет файла"
    if path.stat().st_size == 0:
        return False, "нулевой размер"
    if path.suffix.lower() not in PLAYABLE:
        return True, ""
    done = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "default=nw=1:nk=1", str(path)],
        capture_output=True, text=True, errors="replace")
    if done.returncode:
        return False, "не открывается (обрубок незавершённого рендера?)"
    try:
        if float((done.stdout or "0").strip()) <= 0.01:
            return False, "нулевая длительность"
    except ValueError:
        return False, "нечитаемая длительность"
    return True, ""


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    # Не required: `--list` объясняет порядок шагов и нужен как раз до того, как
    # проект появился. Требовать несуществующую папку ради справки — ровно тот
    # барьер, из-за которого справку не читают.
    parser.add_argument("--project")
    parser.add_argument("--source")
    parser.add_argument("--words")
    parser.add_argument("--music")
    parser.add_argument("--broll", help="broll_plan.json со вставками")
    parser.add_argument("--no-graphics", action="store_true",
                        help="Пропустить слой графики HyperFrames")
    parser.add_argument("--no-layers", action="store_true",
                        help="Пропустить атмосферу и J-cut")
    parser.add_argument("--ambience", default="room_tone",
                        help="Непрерывный атмосферный слой, или 'none'")
    parser.add_argument("--j-lead", type=float, default=0.55,
                        help="На сколько звук перебивки опережает её картинку")
    parser.add_argument("--canvas", default="reels")
    parser.add_argument("--loudness", default="reels")
    parser.add_argument("--fonts-dir", default=str(HERE.parent / "assets" / "fonts" / "all"))
    parser.add_argument("--caption-preset", default="signature_plate")
    parser.add_argument("--grade", default="kodak_portra_skin")
    parser.add_argument("--grade-strength", type=float, default=0.85)
    parser.add_argument("--recut-style", default="balanced")
    parser.add_argument("--speed", type=float, default=1.05)
    parser.add_argument("--hook-pick", type=int, default=0)
    parser.add_argument("--max-static", type=float, default=4.0)
    parser.add_argument("--fx-intent", default="dynamic")
    parser.add_argument("--music-intent", default="under_talk")
    parser.add_argument("--music-start", type=float, default=0.0)
    parser.add_argument("--from", dest="start_from", help="Resume at this stage")
    parser.add_argument("--only", help="Comma-separated stages to run, nothing else")
    parser.add_argument("--force", action="store_true", help="Ignore up-to-date outputs")
    parser.add_argument("--dry-run", action="store_true", help="Print the plan and stop")
    parser.add_argument("--list", action="store_true")
    args = parser.parse_args()

    if args.list:
        emit({"stages": [{"name": s.name, "script": s.script, "why": s.why,
                          "consumes": s.consumes, "produces": s.produces,
                          "optional": s.optional} for s in STAGES]})
        return

    require_binary("ffmpeg")
    if not args.project:
        parser.error("--project обязателен для всего, кроме --list")
    project = Path(args.project).resolve()
    project.mkdir(parents=True, exist_ok=True)

    selected = STAGES
    if args.only:
        wanted = {name.strip() for name in args.only.split(",")}
        unknown = wanted - {stage.name for stage in STAGES}
        if unknown:
            raise SystemExit(f"Unknown stage(s): {sorted(unknown)}")
        selected = [stage for stage in STAGES if stage.name in wanted]
    elif args.start_from:
        names = [stage.name for stage in STAGES]
        if args.start_from not in names:
            raise SystemExit(f"Unknown stage {args.start_from!r}. Known: {names}")
        selected = STAGES[names.index(args.start_from):]

    if not args.only and not args.start_from:
        if not args.source or not args.words:
            raise SystemExit("--source и --words обязательны для полного прогона")

    results: list[dict] = []
    # The picture is passed hand to hand: graphics and b-roll are optional, so a
    # stage cannot name its input file literally — it has to ask what the last
    # picture actually was. Hard-coding `captioned.mp4` into the music stage is
    # how an optional layer silently disappears from the master.
    state: dict = {"picture": str(project / "with_fx.mp4")}

    # Восстановление цепочки картинки при возобновлении.
    #
    # `state["picture"]` обновляется, когда шаг отрабатывает. При `--from music`
    # шаги burn/graphics/broll/layers в выборку не попадают, поэтому картинка
    # осталась бы на значении по умолчанию — и музыка легла бы на `captioned.mp4`,
    # молча выбросив из мастера графику и звуковой слой. Файл собрался бы,
    # ворота бы прошли, а половины работы в нём бы не было.
    #
    # Поэтому цепочка восстанавливается по факту: берётся последний по порядку
    # выход, который существует и открывается.
    # Порядок здесь = порядок наложения слоёв снизу вверх. Он же задаёт, какой
    # файл считается «последней картинкой» при возобновлении.
    PICTURE_CHAIN = ["with_fx.mp4", "with_broll.mp4", "captioned.mp4",
                     "with_gfx.mp4", "with_layers.mp4"]
    recovered = None
    for name in PICTURE_CHAIN:
        candidate = project / name
        ok, _why = usable(candidate)
        if ok:
            recovered = candidate
    if recovered is not None:
        state["picture"] = str(recovered)

    for stage in selected:
        command = build_command(stage, project, args, state)
        if command is None:
            results.append({"stage": stage.name, "status": "not_applicable",
                            "why": "нет --music" if stage.name == "music" else ""})
            continue

        outputs = [project / name for name in stage.produces]
        inputs = [Path(token) for token in command[2:]
                  if isinstance(token, str) and Path(token).suffix and Path(token).exists()]
        checks = [(path, *usable(path)) for path in outputs]
        broken = [(path, why) for path, ok, why in checks if not ok and path.exists()]
        fresh = (not args.force
                 and all(ok for _path, ok, _why in checks)
                 and newest(outputs) >= newest(inputs))
        entry = {"stage": stage.name, "script": stage.script, "why": stage.why,
                 "produces": stage.produces,
                 "command": " ".join(f'"{c}"' if " " in c else c for c in command)}
        if broken:
            # Битый выход перезаписывается, а не «дополняется»: ffmpeg с -y всё
            # равно начнёт с нуля, но в отчёте это должно быть видно словами.
            entry["rebuilt_because"] = [f"{path.name}: {why}" for path, why in broken]
        if fresh:
            entry["status"] = "skipped_up_to_date"
            if stage.name in ("broll", "burn", "graphics", "layers"):
                state["picture"] = str(project / stage.produces[0])
            results.append(entry)
            continue
        if args.dry_run:
            entry["status"] = "would_run"
            results.append(entry)
            continue

        started = time.time()
        done = subprocess.run(command, capture_output=True, text=True, errors="replace")
        entry["seconds"] = round(time.time() - started, 1)
        try:
            entry["report"] = json.loads(done.stdout or "{}")
        except json.JSONDecodeError:
            entry["stdout_tail"] = (done.stdout or "").strip()[-600:]
        if done.returncode:
            entry["status"] = "failed"
            entry["exit_code"] = done.returncode
            entry["stderr_tail"] = (done.stderr or "").strip()[-1200:]
            results.append(entry)
            (project / "pipeline_report.json").write_text(
                json.dumps({"status": "failed", "failed_at": stage.name,
                            "stages": results}, ensure_ascii=False, indent=2),
                encoding="utf-8")
            emit({"status": "failed", "failed_at": stage.name, "stage": entry,
                  "resume_with": f"--project {project} --from {stage.name}",
                  "note": "Остальные шаги не запускались. Исправь причину и продолжи с этого шага."})
            raise SystemExit(done.returncode)
        missing = [str(path) for path in outputs if not path.exists()]
        if missing:
            entry["status"] = "produced_nothing"
            entry["missing"] = missing
            results.append(entry)
            emit({"status": "failed", "failed_at": stage.name, "stage": entry,
                  "note": "Шаг завершился успешно, но не создал заявленные файлы."})
            raise SystemExit(3)
        entry["status"] = "ok"
        if stage.name in ("broll", "burn", "graphics", "layers"):
            state["picture"] = str(project / stage.produces[0])
        results.append(entry)

    summary = {
        "status": "dry_run" if args.dry_run else "ok",
        "project": str(project),
        "stages": results,
        "ran": [r["stage"] for r in results if r.get("status") == "ok"],
        "skipped": [r["stage"] for r in results if r.get("status") == "skipped_up_to_date"],
        "picture_chain_ends_at": Path(state["picture"]).name,
        "order_is_the_point": (
            "Порядок здесь не стилистический. Титры после холодного старта, "
            "рефреймы до титров, громкость один раз в конце — каждое из этих "
            "правил при нарушении даёт тихую ошибку, а не падение."),
    }
    if not args.dry_run:
        (project / "pipeline_report.json").write_text(
            json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
        gates = project / "gates" / "summary.json"
        if gates.exists():
            summary["gates"] = json.loads(gates.read_text(encoding="utf-8")).get("status")
        summary["next"] = (
            f"Если ворота ok — переименуй {project / 'master.partial.mp4'} "
            "в уникальное имя с номером версии.")
    emit(summary)


if __name__ == "__main__":
    main()

