#!/usr/bin/env python3
"""Атмосферный слой и J-cut: звук приходит раньше картинки.

Две вещи, которых в этом скилле не было и без которых монтаж читается как
любительский, сколько ни улучшай картинку.

**Первая — атмосфера.** Между фразами в тесно нарезанной речи наступает цифровая
тишина, которой не бывает ни в одной комнате мира. Зритель не осознаёт её как
дефект, но слышит запись как дешёвую. Тонкий слой комнаты на −36 дБ убирает
провал, и тот же материал начинает звучать сведённым. Слой не слышат — слышат
его отсутствие.

**Вторая — J-cut.** У прямой склейки звук и картинка меняются одновременно.
В жизни так не бывает: сначала слышишь голос коллеги, потом поворачиваешься. По
опубликованным разборам монтажа смещение 20–30% склеек уже читается как
кинематографичное, а безопасная зона смещения — от полусекунды до двух.

Здесь J-cut делается на **перебивках**, и только на них. Речь спикера смещать
нельзя — она обязана оставаться в липсинке с губами; это прямо запрещено
нулевой аксиомой скилла. А вот атмосфера самой перебивки — гул железа под кадром
видеокарты, щелчки клавиш под кадром кода — может и должна начинаться **до**
того, как картинка переключится. Тогда переход на вставку звучит как причина, а
не как следствие.

Уровни считаются от измеренной громкости голоса, а не от полной шкалы: то же
правило, что и у шины SFX.
"""
from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path

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

from harvest_broll import CONCEPTS as BROLL_CONCEPTS  # noqa: E402
from media_probe import loudness, probe  # noqa: E402
from skill_config import ASSETS, emit, require_binary, utf8_stdout  # noqa: E402

SFX = ASSETS / "sfx"

# Какая атмосфера подходит какому понятию. Ключ — то, что harvest_broll.py уже
# записал в поле concept, поэтому связка держится на одном словаре, а не на
# совпадении названий файлов.
BY_CONCEPT: dict[str, str] = {
    "железо": "electronics_hum",
    "видеокарта": "electronics_hum",
    "сервер": "server_room",
    "код": "keyboard_typing",
    "монтаж": "keyboard_typing",
    "данные": "server_room",
    "нейросеть": "tension_bed",
    "мозг": "tension_bed",
    "сеть": "server_room",
    "город": "city_distant",
    "люди": "city_distant",
    "лаборатория": "electronics_hum",
    "график": "tension_bed",
}


def ff(value: float) -> str:
    return f"{value:.6f}".rstrip("0").rstrip(".") or "0"


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("input")
    parser.add_argument("output")
    parser.add_argument("--broll-plan", help="broll_plan.json — из него берутся вставки")
    parser.add_argument("--ambience", default="room_tone",
                        help="Непрерывный слой под весь ролик, или 'none'")
    parser.add_argument("--ambience-db", type=float, default=-36.0,
                        help="Уровень слоя относительно измеренного голоса")
    parser.add_argument("--layer-db", type=float, default=-26.0,
                        help="Уровень атмосферы перебивки относительно голоса")
    parser.add_argument("--j-lead", type=float, default=0.55,
                        help="На сколько секунд звук вставки опережает её картинку. "
                             "Безопасная зона 0.5-2.0; выше начинает читаться как ошибка")
    parser.add_argument("--fade", type=float, default=0.35)
    parser.add_argument("--sfx-dir", default=str(SFX))
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    require_binary("ffmpeg")
    source = Path(args.input).resolve()
    if not source.is_file():
        raise SystemExit(f"Not found: {source}")
    sfx_dir = Path(args.sfx_dir).resolve()
    info = probe(source)
    if not info.get("has_audio"):
        raise SystemExit(f"{source} без звуковой дорожки")
    duration = float((info.get("video") or {}).get("duration") or info.get("duration") or 0.0)

    voice = loudness(source)
    voice_lufs = voice.get("integrated_lufs")
    if voice_lufs is None:
        raise SystemExit("ebur128 не измерил громкость голоса")

    layers: list[dict] = []

    if args.ambience != "none":
        bed = sfx_dir / f"{args.ambience}.wav"
        if not bed.is_file():
            raise SystemExit(f"Нет атмосферы {bed}. Запусти scripts/generate_sfx.py")
        layers.append({
            "kind": "ambience", "file": bed, "at": 0.0, "length": duration,
            "gain_db": round(voice_lufs + args.ambience_db - (-23.0), 2),
            "loop": True,
            "why": "непрерывный слой: между фразами не должно быть цифровой тишины",
        })

    if args.broll_plan and Path(args.broll_plan).is_file():
        plan = json.loads(Path(args.broll_plan).read_text(encoding="utf-8"))
        for item in plan.get("broll", []):
            concept = (item.get("concept") or "").strip()
            name = BY_CONCEPT.get(concept)
            if not name:
                # План мог быть написан руками и не содержать concept — так и
                # вышло на первом же реальном проекте. Тогда понятие выводится
                # из текста привязки теми же образцами, что использует
                # harvest_broll.py, и связка не рвётся из-за отсутствия поля.
                haystack = " ".join(str(item.get(key, ""))
                                    for key in ("anchor", "note", "query")).lower()
                for pattern, _query, label in BROLL_CONCEPTS:
                    if pattern.search(haystack) and label in BY_CONCEPT:
                        concept, name = label, BY_CONCEPT[label]
                        break
            if not name:
                continue
            path = sfx_dir / f"{name}.wav"
            if not path.is_file():
                continue
            picture_at = float(item["start"])
            # Вот он, J-cut: звук стартует раньше картинки.
            audio_at = max(0.0, picture_at - args.j_lead)
            length = float(item.get("duration", 2.4)) + (picture_at - audio_at)
            layers.append({
                "kind": "broll_ambience", "file": path,
                "at": round(audio_at, 3), "length": round(length, 3),
                "picture_at": round(picture_at, 3),
                "lead": round(picture_at - audio_at, 3),
                "gain_db": round(voice_lufs + args.layer_db - (-23.0), 2),
                "loop": True, "concept": concept,
                "why": f"«{concept}»: звук вставки приходит за "
                       f"{picture_at - audio_at:.2f} с до картинки",
            })

    if not layers:
        emit({"status": "nothing_to_do",
              "note": "Ни атмосферы, ни подходящих перебивок — слой пуст."})
        return

    command = ["ffmpeg", "-y", "-v", "warning", "-nostdin", "-i", str(source)]
    for layer in layers:
        if layer["loop"]:
            command += ["-stream_loop", "-1"]
        command += ["-i", str(layer["file"])]

    graph: list[str] = ["[0:a]aformat=sample_rates=48000:channel_layouts=stereo[voice]"]
    mix = ["[voice]"]
    for index, layer in enumerate(layers, start=1):
        fade = min(args.fade, layer["length"] / 2.5)
        chain = [
            "aformat=sample_rates=48000:channel_layouts=stereo",
            f"atrim=duration={ff(layer['length'])}",
            "asetpts=PTS-STARTPTS",
            f"volume={layer['gain_db']:.2f}dB",
        ]
        if fade > 0.02:
            chain.append(f"afade=t=in:st=0:d={ff(fade)}:curve=qsin")
            chain.append(
                f"afade=t=out:st={ff(layer['length'] - fade)}:d={ff(fade)}:curve=qsin")
        if layer["at"] > 0.001:
            delay = int(round(layer["at"] * 1000))
            chain.append(f"adelay={delay}|{delay}:all=1")
        graph.append(f"[{index}:a]" + ",".join(chain) + f"[l{index}]")
        mix.append(f"[l{index}]")

    # `apad` перед `atrim`, и никакого `-shortest`.
    #
    # Видео здесь копируется, то есть его длина задана и трогать её нечем. Но с
    # `-shortest` ffmpeg заканчивает файл по самому короткому потоку, и если
    # смикшированный звук вышел на пару кадров короче — а он выходит, потому что
    # AAC квантует по 1024 отсчёта, — то обрезается **скопированное видео**.
    # Ровно так этот шаг съедал 100 мс: каждый проход по отдельности укладывался
    # в свой допуск, а ворота доставки ловили сумму.
    #
    # apad делает звук заведомо не короче, atrim обрезает его точно по картинке.
    graph.append("".join(mix) +
                 f"amix=inputs={len(mix)}:normalize=0:dropout_transition=0,"
                 f"apad,atrim=duration={ff(duration)},asetpts=N/SR/TB[aout]")

    report = {
        "status": "planned",
        "input": str(source),
        "voice_lufs": voice_lufs,
        "layers": [{k: (str(v) if isinstance(v, Path) else v)
                    for k, v in layer.items()} for layer in layers],
        "j_cuts": sum(1 for layer in layers if layer["kind"] == "broll_ambience"),
        "note": ("Речь не смещается никогда — она обязана оставаться в липсинке. "
                 "Смещается только атмосфера перебивки, и это делает переход "
                 "на вставку причиной, а не следствием."),
    }
    if args.dry_run:
        emit(report)
        return

    output = Path(args.output).resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    command += [
        "-filter_complex", ";".join(graph),
        "-map", "0:v?", "-map", "[aout]",
        "-c:v", "copy", "-c:a", "aac", "-b:a", "224k", "-ar", "48000", "-ac", "2",
        "-movflags", "+faststart", str(output),
    ]
    done = subprocess.run(command, capture_output=True, text=True, errors="replace")
    if done.returncode:
        report.update({"status": "failed", "graph": ";".join(graph),
                       "tail": (done.stderr or "").strip()[-1800:]})
        emit(report)
        raise SystemExit(done.returncode)

    after = loudness(output)
    report["status"] = "ok"
    report["output"] = str(output)
    report["loudness_after"] = after
    report["loudness_note"] = ("Слой добавляет доли децибела к интегральной громкости; "
                               "выравнивание всё равно делает audio_fx.py в конце.")
    emit(report)


if __name__ == "__main__":
    main()
