#!/usr/bin/env python3
"""Build the transition SFX bus and mix it under the voice.

Design decisions that make synthetic SFX sound placed rather than pasted:

  * The bus is a separate stage, ducked against the voice with a sidechain, not
    added on top of it. A whoosh at the same level as a word fights the word;
    ducked, it fills the space the cut already made.

  * Placement is in frames, not seconds, and the *peak* of each sound is aligned to
    the cut — which means sounds with a late peak (a riser) start well before it.
    The library stores that offset per cue; this honours it and reports the final
    absolute times so they can be checked against the render.

  * Overlapping cues are limited. Three whooshes inside 200 ms sum to noise, so
    simultaneous cues past a small budget are dropped with a note rather than
    silently piling up.
"""
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 media_probe import probe  # noqa: E402
from skill_config import LOUDNESS, SFX, emit, require_binary, utf8_stdout  # noqa: E402

MAX_SIMULTANEOUS = 2
SIMULTANEITY_WINDOW = 0.18


def ff(value: float) -> str:
    return f"{value:.6f}".rstrip("0").rstrip(".") or "0"


def sound_path(sound_id: str, sfx_dir: Path) -> Path:
    candidate = sfx_dir / f"{sound_id}.wav"
    if candidate.is_file():
        return candidate
    matches = sorted(sfx_dir.glob(f"{sound_id}*.wav"))
    if matches:
        return matches[0]
    raise SystemExit(
        f"SFX '{sound_id}' not found in {sfx_dir}. Run: python scripts/generate_sfx.py"
    )


def pan_filter(pan: str | None) -> str:
    """Match a cue's stereo direction to the picture.

    The synthesised pack already contains the movement — `whip_pan` is generated
    panning left to right — so nothing needs automating here. The only useful
    operation is mirroring it for a cut that moves the other way, which is an exact
    channel swap rather than a static balance change (`stereotools=balance_out`
    cannot move during a sound, so using it here would be decorative).
    """
    if pan == "r2l":
        return "pan=stereo|c0=c1|c1=c0"
    return ""


LIBRARY_LOUDEST_CUE_DB = -13.0


def measure_voice(video: Path) -> dict:
    """Integrated loudness of the bed, used as the reference for the bus level."""
    from media_probe import loudness
    return loudness(video)


def cue_level_db(path: Path, window: float = 0.2) -> float:
    """Perceived level of one sound: RMS of its loudest `window` seconds.

    Peak level is the wrong reference for placing a sound in a mix. The pack is
    peak-normalised to -1.5 dBFS, but a whoosh with a 16 dB crest factor is
    perceptually 16 dB quieter than an impact with the same peak — levelling by
    peak makes half the library inaudible. Measuring the loudest window gives a
    figure that can be compared directly with the bed's loudness.
    """
    import numpy as np

    rate = 16_000
    completed = subprocess.run(
        ["ffmpeg", "-v", "error", "-nostdin", "-i", str(path),
         "-ac", "1", "-ar", str(rate), "-f", "s16le", "-"],
        capture_output=True,
    )
    if completed.returncode or not completed.stdout:
        return -30.0
    samples = np.frombuffer(completed.stdout, dtype="<i2").astype(np.float32) / 32768.0
    if samples.size == 0:
        return -30.0
    span = min(samples.size, int(window * rate))
    energy = np.convolve(samples ** 2, np.ones(span) / span, mode="valid")
    loudest = float(np.sqrt(np.max(energy))) if energy.size else float(
        np.sqrt(np.mean(samples ** 2))
    )
    import math

    return 20 * math.log10(max(loudest, 1e-9))


def measure_loudnorm(audio: Path) -> dict | None:
    """First pass: what loudnorm measures on the finished mix."""
    completed = subprocess.run(
        ["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(audio),
         "-af", "loudnorm=print_format=json", "-f", "null", "-"],
        capture_output=True, text=True, errors="replace",
    )
    text = completed.stderr or ""
    if "{" not in text:
        return None
    try:
        return json.loads(text[text.rindex("{"):text.rindex("}") + 1])
    except (ValueError, json.JSONDecodeError):
        return None


# Lossy encoding overshoots. loudnorm holds its own output at the requested true
# peak, but the AAC encode that follows reconstructs inter-sample peaks above it —
# measured at -0.4 dBTP against a -1.0 request. Aiming lower by this margin lands
# the delivered file under the platform's ceiling.
LOSSY_TP_HEADROOM_DB = 0.8


def master_audio(mixed: Path, target: dict,
                 headroom_db: float = LOSSY_TP_HEADROOM_DB) -> tuple[str, dict | None]:
    """Second-pass loudnorm arguments, from the first pass's measurement.

    `linear=true` applies one gain across the whole programme instead of riding it
    dynamically: the mix's own dynamics survive, the target is hit within about
    0.1 LU, and loudnorm's internal true-peak limiter keeps the ceiling — which is
    why no separate limiter follows it.
    """
    measured = measure_loudnorm(mixed)
    aim = float(target["tp"]) - abs(headroom_db)
    base = f"loudnorm=I={target['i']}:TP={aim:.2f}:LRA={target['lra']}"
    if not measured:
        return base, None
    try:
        return (
            base
            + f":measured_I={float(measured['input_i'])}"
            + f":measured_TP={float(measured['input_tp'])}"
            + f":measured_LRA={float(measured['input_lra'])}"
            + f":measured_thresh={float(measured['input_thresh'])}"
            + ":linear=true:print_format=summary"
        ), measured
    except (KeyError, TypeError, ValueError):
        return base, measured


def thin_out(events: list[dict]) -> tuple[list[dict], list[str]]:
    """Drop cues that would stack past the simultaneity budget."""
    kept: list[dict] = []
    dropped: list[str] = []
    for event in sorted(events, key=lambda item: item["at"]):
        window = [item for item in kept if abs(item["at"] - event["at"]) < SIMULTANEITY_WINDOW]
        if len(window) >= MAX_SIMULTANEOUS:
            dropped.append(
                f"{event['sound']} на {event['at']:.2f}s — уже {len(window)} звука "
                f"в окне {SIMULTANEITY_WINDOW * 1000:.0f} мс"
            )
            continue
        kept.append(event)
    return kept, dropped


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("video", help="Rendered video whose audio is the voice bed")
    parser.add_argument("events", help="JSON with sfx_events (from render_transitions.py)")
    parser.add_argument("output")
    parser.add_argument("--sfx-dir", default=str(SFX))
    parser.add_argument("--sfx-offset-db", type=float, default=-7.0,
                        help="Where the loudest cue sits relative to the voice's own "
                             "loudness. -7 means clearly present but under the words.")
    parser.add_argument("--bus-gain", type=float, default=0.0,
                        help="Extra dB on top of the computed bus level")
    parser.add_argument("--duck-ratio", type=float, default=2.0,
                        help="Sidechain compression ratio for the bus (not dB)")
    parser.add_argument("--music", help="Optional music bed to mix in as well")
    parser.add_argument("--music-gain", type=float, default=-19.0)
    parser.add_argument("--loudness", default="reels", choices=list(LOUDNESS))
    parser.add_argument("--no-duck", action="store_true",
                        help="Mix the bus flat; use only when the video has no speech")
    parser.add_argument("--tp-headroom", type=float, default=LOSSY_TP_HEADROOM_DB,
                        help="На сколько дБ занижать цель по истинному пику, чтобы "
                             "AAC не выбил её обратно вверх")
    parser.add_argument("--bus-out",
                        help="Also write the SFX bus alone here, so the achieved duck "
                             "depth and cue levels can be measured")
    parser.add_argument("--filter-report")
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    require_binary("ffmpeg")
    video = Path(args.video).resolve()
    if not video.is_file():
        raise SystemExit(f"Video not found: {video}")
    info = probe(video)
    duration = float(info.get("duration") or 0.0)
    if not info.get("has_audio"):
        raise SystemExit(
            f"{video} has no audio track. Mix the voice first, then add the SFX bus."
        )

    payload = json.loads(Path(args.events).read_text(encoding="utf-8"))
    events = payload.get("sfx_events") if isinstance(payload, dict) else payload
    if not events:
        raise SystemExit("No sfx_events found in the plan")
    events = [event for event in events if 0.0 <= float(event["at"]) < duration]
    events, dropped = thin_out(events)

    sfx_dir = Path(args.sfx_dir)
    resolved = []
    for event in events:
        path = sound_path(event["sound"], sfx_dir)
        sound_info = probe(path)
        resolved.append({
            **event,
            "path": path,
            "length": float(sound_info.get("duration") or 0.0),
            "measured_level_db": round(cue_level_db(path), 2),
        })

    target = LOUDNESS[args.loudness]
    # The bus is levelled against the voice, not against full scale. A cue at a
    # fixed -13 dBFS is loud over a quiet recording and inaudible over a loud one;
    # anchoring to the bed's measured loudness makes one setting work everywhere.
    voice = measure_voice(video)
    voice_lufs = voice.get("integrated_lufs")
    if voice_lufs is None:
        voice_lufs = -18.0
        level_note = "Не удалось измерить громкость голоса, взят -18 LUFS по умолчанию."
    else:
        level_note = None
    # Target level for the loudest cue, then each cue is trimmed by how much
    # quieter the library says it should be relative to that one.
    bus_target_db = voice_lufs + args.sfx_offset_db + args.bus_gain
    for item in resolved:
        relative = float(item["gain_db"]) - LIBRARY_LOUDEST_CUE_DB
        item["applied_gain_db"] = round(
            bus_target_db + relative - item["measured_level_db"], 2
        )

    report = {
        "video": str(video),
        "duration": round(duration, 3),
        "cues": len(resolved),
        "dropped_for_crowding": dropped,
        "loudness_target": target,
        "voice_loudness": voice,
        "bus_level": {
            "voice_lufs": voice_lufs,
            "sfx_offset_db": args.sfx_offset_db,
            "loudest_cue_target_db": round(bus_target_db, 2),
            "explanation": (
                "Уровень каждого звука измеряется по самому громкому окну 200 мс и "
                "приводится к громкости голоса. Выравнивание по пику не работает: "
                "свуш с крест-фактором 16 дБ на слух тише удара с тем же пиком."
            ),
            "note": level_note,
        },
        "duck": not args.no_duck,
        "placement": [
            {"sound": item["sound"], "at": round(float(item["at"]), 3),
             "library_gain_db": item["gain_db"],
             "measured_level_db": item["measured_level_db"],
             "applied_gain_db": item["applied_gain_db"],
             "resulting_level_db": round(item["measured_level_db"]
                                         + item["applied_gain_db"], 2),
             "length": round(item["length"], 3),
             "transition": item.get("transition")}
            for item in resolved
        ],
    }
    if args.dry_run:
        emit(report)
        return

    command = ["ffmpeg", "-y", "-v", "warning", "-nostdin", "-i", str(video)]
    for item in resolved:
        command += ["-i", str(item["path"])]
    music_index = None
    if args.music:
        music = Path(args.music).resolve()
        if not music.is_file():
            raise SystemExit(f"Music not found: {music}")
        command += ["-stream_loop", "-1", "-i", str(music)]
        music_index = 1 + len(resolved)

    graph: list[str] = []
    graph.append(
        "[0:a]aformat=sample_rates=48000:channel_layouts=stereo,"
        "asplit=2[voice_main][voice_key]"
    )
    bus_labels = []
    for index, item in enumerate(resolved, start=1):
        gain = float(item["applied_gain_db"])
        delay_ms = int(round(float(item["at"]) * 1000))
        chain = (
            f"[{index}:a]aformat=sample_rates=48000:channel_layouts=stereo,"
            f"volume={gain:.2f}dB"
        )
        movement = pan_filter(item.get("pan"))
        if movement:
            chain += f",{movement}"
        chain += f",adelay={delay_ms}|{delay_ms}:all=1[s{index}]"
        graph.append(chain)
        bus_labels.append(f"[s{index}]")

    if len(bus_labels) == 1:
        graph.append(f"{bus_labels[0]}anull[bus_raw]")
    else:
        graph.append(
            "".join(bus_labels)
            + f"amix=inputs={len(bus_labels)}:normalize=0:dropout_transition=0[bus_raw]"
        )
    # Trim the bus to the video: adelay leaves tails past the end otherwise.
    graph.append(
        f"[bus_raw]atrim=duration={ff(duration)},asetpts=PTS-STARTPTS,"
        f"alimiter=limit=0.95[bus_trimmed]"
    )
    split = "asplit=2[bus][bus_probe]" if args.bus_out else "anull[bus]"
    if args.no_duck:
        graph.append(f"[bus_trimmed]{split}")
    else:
        # Keyed on the voice. `ratio` here is a compression ratio, not an amount of
        # attenuation in dB — how far the bus actually drops depends on how far the
        # key exceeds the threshold, so the achieved depth is measured afterwards
        # and reported instead of being assumed.
        ratio = max(1.2, min(20.0, args.duck_ratio))
        graph.append(
            f"[bus_trimmed][voice_key]sidechaincompress="
            f"threshold=0.03:ratio={ratio:.2f}:attack=6:release=180:makeup=1,"
            f"{split}"
        )

    mix_inputs = ["[voice_main]", "[bus]"]
    if music_index is not None:
        graph.append(
            f"[{music_index}:a]aformat=sample_rates=48000:channel_layouts=stereo,"
            f"atrim=duration={ff(duration)},asetpts=PTS-STARTPTS,"
            f"volume={args.music_gain:.2f}dB,"
            f"afade=t=in:st=0:d=1.2,afade=t=out:st={ff(max(0.0, duration - 2.0))}:d=2[music_raw]"
        )
        graph.append(
            "[music_raw][voice_key]sidechaincompress=threshold=0.025:ratio=9:"
            "attack=12:release=320:makeup=1[music]"
        )
        mix_inputs.append("[music]")

    # No normalisation here. Loudness is a separate, measured pass below: one-pass
    # loudnorm only approximates the target, and a limiter placed after it overrides
    # its true-peak control — together they landed the master at -12.9 LUFS / +0.3
    # dBTP against a -14 / -1.0 target.
    graph.append(
        "".join(mix_inputs)
        + f"amix=inputs={len(mix_inputs)}:normalize=0:dropout_transition=0[aout]"
    )

    filter_graph = ";\n".join(graph)
    report_path = (
        Path(args.filter_report) if args.filter_report
        else Path(args.output).with_suffix(".sfx_filter.txt")
    )
    report_path.parent.mkdir(parents=True, exist_ok=True)
    report_path.write_text(filter_graph + "\n", encoding="utf-8")

    output = Path(args.output).resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    mixed_audio = output.with_suffix(".mixed.wav")
    command += [
        "-filter_complex_script", str(report_path),
        "-map", "[aout]",
        "-c:a", "pcm_s24le", "-ar", "48000", "-ac", "2",
        "-t", ff(duration),
        str(mixed_audio),
    ]
    if args.bus_out:
        bus_out = Path(args.bus_out).resolve()
        bus_out.parent.mkdir(parents=True, exist_ok=True)
        command += ["-map", "[bus_probe]", "-c:a", "pcm_s24le",
                    "-t", ff(duration), str(bus_out)]
    completed = subprocess.run(command, capture_output=True, text=True, errors="replace")
    if completed.returncode:
        report.update({"status": "mix_failed", "filter_report": str(report_path),
                       "tail": (completed.stderr or "").strip()[-1500:]})
        emit(report)
        raise SystemExit(completed.returncode)

    master_filter, first_pass = master_audio(mixed_audio, target, args.tp_headroom)
    muxed = subprocess.run(
        ["ffmpeg", "-y", "-v", "warning", "-nostdin",
         "-i", str(video), "-i", str(mixed_audio),
         "-map", "0:v", "-map", "1:a",
         "-af", master_filter,
         "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2",
         "-movflags", "+faststart", "-t", ff(duration), str(output)],
        capture_output=True, text=True, errors="replace",
    )
    if muxed.returncode:
        report.update({"status": "master_failed",
                       "tail": (muxed.stderr or "").strip()[-1500:]})
        emit(report)
        raise SystemExit(muxed.returncode)
    mixed_audio.unlink(missing_ok=True)

    from media_probe import loudness
    measured = loudness(output)
    report["mastering"] = {
        "mode": "two-pass linear" if first_pass else "single-pass fallback",
        "platform_tp": target["tp"],
        "tp_aim": round(float(target["tp"]) - abs(args.tp_headroom), 2),
        "tp_headroom_db": args.tp_headroom,
        "true_peak_aim_db": round(float(target["tp"]) - abs(args.tp_headroom), 2),
        "true_peak_headroom_db": args.tp_headroom,
        "why_headroom": (
            "AAC восстанавливает интерсэмпловые пики выше того, что удержал "
            "лимитер loudnorm, поэтому цель занижается на этот запас."
        ),
        "first_pass": first_pass,
    }
    report.update({
        "status": "ok",
        "output": str(output),
        "filter_report": str(report_path),
        "measured_loudness": measured,
        "on_target": (
            measured.get("integrated_lufs") is not None
            and abs(measured["integrated_lufs"] - target["i"]) <= 1.0
        ),
        "bus_only": str(Path(args.bus_out).resolve()) if args.bus_out else None,
        "note": (
            "Видео скопировано без перекодирования — качество картинки не тронуто, "
            "изменилась только звуковая дорожка."
        ),
    })
    emit(report)


if __name__ == "__main__":
    main()
