"""Cutting a race out of the day's recording — planned from the anchor log, and proved with ffmpeg.

The claim that matters is not "a file appeared". It is **that the clip shows the moment it was taken
for**, and nothing in the broadcast picture is machine-readable enough to check that after the fact —
which is the whole reason `recording_anchor.py` exists. So the fixture recording is built out of
solid-colour spans, and the test reads the colour back out of the installed clip: a cut that lands in
the wrong place comes back the wrong colour.
"""
from __future__ import annotations

import shutil
import subprocess
import sys
from pathlib import Path

import pytest

_LIVE = Path(__file__).resolve().parents[2] / "live"          # v3/broadcast/live
if str(_LIVE) not in sys.path:
    sys.path.insert(0, str(_LIVE))

import day_cutter  # noqa: E402
from day_cutter import DayCutterError, Piece, plan_cut, segments  # noqa: E402

FFMPEG = shutil.which("ffmpeg")
FFPROBE = shutil.which("ffprobe")
needs_ffmpeg = pytest.mark.skipif(not (FFMPEG and FFPROBE), reason="ffmpeg/ffprobe not on PATH")

T0 = 1_755_600_000_000          # frame zero of the first file, in UTC ms
MINUTE = 60_000

#: Eight seconds of each, in order. The clip's colour is what says where the cut landed.
#: `lime`, not `green` — ffmpeg follows the X11/CSS names, where `green` is #008000 and the fully
#: saturated one is `lime`. A test that expected (0,255,0) from `green` would fail on a correct cut.
SPANS = [("red", (255, 0, 0)), ("lime", (0, 255, 0)), ("blue", (0, 0, 255))]
SPAN_S = 8


def anchor_rows(path: str, anchor_utc_ms: int, minutes: int, *, start_min: int = 0) -> list[dict]:
    """What `recording_anchor.py` would have appended for one file, one sample a minute."""
    return [{"tUtcMs": anchor_utc_ms + (start_min + i) * MINUTE,
             "durationMs": (start_min + i) * MINUTE,
             "anchorUtcMs": anchor_utc_ms, "path": path}
            for i in range(minutes)]


# --- planning: what gets cut, and what is refused instead of guessed ------------------------------

def test_a_window_inside_one_file_is_one_piece_at_the_right_offset():
    rows = anchor_rows("D:/rec/a.mp4", T0, 10)
    pieces = plan_cut(rows, T0 + 300_000, T0 + 340_000, now_utc_ms=T0 + 9 * MINUTE)
    assert pieces == [Piece(path="D:/rec/a.mp4", offset_s=300.0, duration_s=40.0)]


def test_a_cut_that_straddles_a_sixty_minute_split_is_made_from_both_files():
    """OBS splits the recording on a timer and every split starts a NEW zero, so a race that crosses
    one is two cuts joined — not one cut with an hour of error in it."""
    first = anchor_rows("D:/rec/a.mp4", T0, 60)
    second_anchor = T0 + 60 * MINUTE
    second = anchor_rows("D:/rec/b.mp4", second_anchor, 10)
    pieces = plan_cut(first + second, second_anchor - 10_000, second_anchor + 30_000,
                      now_utc_ms=second_anchor + 9 * MINUTE)
    assert [p.path for p in pieces] == ["D:/rec/a.mp4", "D:/rec/b.mp4"]
    assert pieces[0].offset_s == pytest.approx(60 * 60 - 10)     # ten seconds before the split
    assert pieces[0].duration_s == pytest.approx(10.0)
    assert pieces[1].offset_s == pytest.approx(0.0)              # ...and from the top of the next file
    assert pieces[1].duration_s == pytest.approx(30.0)


def test_a_moment_not_yet_written_to_disk_is_refused_rather_than_returned_short():
    """The sampler ticks once a minute, so the recording always reaches further than the last sample
    says — but only so far. A clip that silently ends before the moment it was taken for is worse than
    one that did not happen: the operator sees a picture and believes it."""
    rows = anchor_rows("D:/rec/a.mp4", T0, 5)                    # proven to 4 minutes in
    last = T0 + 4 * MINUTE
    ok = plan_cut(rows, last + 10_000, last + 40_000, now_utc_ms=last + 70_000)
    assert ok[0].duration_s == pytest.approx(30.0)               # inside the open tail: fine

    with pytest.raises(DayCutterError, match="short of the requested end"):
        plan_cut(rows, last, last + 5 * MINUTE, now_utc_ms=last + 5 * MINUTE)


def test_a_gap_between_recordings_is_named_rather_than_jumped_over():
    """A stop and a restart is not a split. Concatenating across it would produce a clip that jumps
    with nothing on screen to explain the jump."""
    first = anchor_rows("D:/rec/a.mp4", T0, 5)
    resumed = T0 + 30 * MINUTE                                   # 25 minutes of nothing
    rows = first + anchor_rows("D:/rec/b.mp4", resumed, 5)
    with pytest.raises(DayCutterError, match="gap"):
        plan_cut(rows, T0 + 4 * MINUTE + 30_000, resumed + 30_000, now_utc_ms=resumed + 4 * MINUTE)


def test_an_empty_anchor_log_says_which_process_was_not_running():
    with pytest.raises(DayCutterError, match="recording_anchor"):
        plan_cut([], T0, T0 + 40_000, now_utc_ms=T0 + MINUTE)


def test_a_cut_too_short_to_be_a_replay_is_refused():
    rows = anchor_rows("D:/rec/a.mp4", T0, 5)
    with pytest.raises(DayCutterError, match="not a replay"):
        plan_cut(rows, T0 + 10_000, T0 + 10_500, now_utc_ms=T0 + 4 * MINUTE)


def test_the_anchor_of_a_file_is_the_median_of_its_samples():
    """Each sample carries the jitter of one round trip to OBS. The median throws away the outliers
    without a threshold anybody has to justify — the same reason `anchor_for` uses one."""
    rows = [{"tUtcMs": T0 + i * MINUTE, "anchorUtcMs": a, "path": "D:/rec/a.mp4"}
            for i, a in enumerate([T0 - 400, T0, T0 + 20, T0 + 10, T0 + 9_000])]
    assert segments(rows, now_utc_ms=T0 + 10 * MINUTE)[0].anchor_utc_ms == T0 + 10


# --- and the picture: does the clip show the moment it was taken for? -----------------------------

def _make_recording(path: Path, *, spans=SPANS) -> None:
    """A stand-in for the day's recording: solid-colour spans, at the canvas `verify_media` demands."""
    parts = []
    for name, _rgb in spans:
        parts += ["-f", "lavfi", "-t", str(SPAN_S), "-i", f"color=c={name}:s=1920x1080:r=30"]
    n = len(spans)
    filt = "".join(f"[{i}:v]" for i in range(n)) + f"concat=n={n}:v=1:a=0[v]"
    subprocess.run(
        [FFMPEG, "-y", "-hide_banner", "-loglevel", "error", *parts,
         "-f", "lavfi", "-t", str(SPAN_S * n), "-i", "sine=frequency=440:sample_rate=48000",
         "-filter_complex", filt, "-map", "[v]", "-map", f"{n}:a",
         # 1 s keyframe interval: a stream copy cuts on keyframes, and this is what bounds how early
         # a clip can open. The production recording is NVENC at a similar GOP.
         "-c:v", "libx264", "-preset", "ultrafast", "-g", "30", "-pix_fmt", "yuv420p",
         "-c:a", "aac", "-ar", "48000", "-r", "30", str(path)],
        check=True, capture_output=True)


def _first_pixel(path: Path, *, at_s: float = 0.5) -> tuple[int, int, int]:
    out = subprocess.run(
        [FFMPEG, "-hide_banner", "-loglevel", "error", "-ss", f"{at_s}", "-i", str(path),
         "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:"],
        check=True, capture_output=True).stdout
    return out[0], out[1], out[2]


def _near(got, want, tol=40) -> bool:
    return all(abs(a - b) <= tol for a, b in zip(got, want, strict=True))


@needs_ffmpeg
def test_the_clip_shows_the_moment_it_was_taken_for(tmp_path):
    rec = tmp_path / "day.mp4"
    _make_recording(rec)
    rows = anchor_rows(str(rec), T0, 2)                          # frame zero = T0, proven a minute in
    out = tmp_path / "replay" / "replay.mp4"

    # The lime span runs 8–16 s into the recording. Ask for the middle of it by WALL CLOCK.
    details = day_cutter.cut(rows, T0 + 10_000, T0 + 14_000, output=out,
                             ffmpeg=FFMPEG, ffprobe=FFPROBE, now_utc_ms=T0 + 60_000)
    assert out.is_file()
    assert details["durationS"] == pytest.approx(4.0, abs=0.6)
    assert _near(_first_pixel(out, at_s=1.0), (0, 255, 0)), "the cut landed outside the lime span"


@needs_ffmpeg
def test_a_cut_across_two_recording_files_is_one_continuous_clip(tmp_path):
    """The 60-minute split, at the scale a test can build. Both halves must be in the result, in
    order: a concat that dropped one would still produce a playable file."""
    a, b = tmp_path / "a.mp4", tmp_path / "b.mp4"
    _make_recording(a, spans=[("red", (255, 0, 0))] * 3)         # 24 s of red
    _make_recording(b, spans=[("blue", (0, 0, 255))] * 3)        # then 24 s of blue
    split = T0 + 24_000
    rows = anchor_rows(str(a), T0, 1) + anchor_rows(str(b), split, 1)
    out = tmp_path / "replay.mp4"

    details = day_cutter.cut(rows, split - 4_000, split + 4_000, output=out,
                             ffmpeg=FFMPEG, ffprobe=FFPROBE, now_utc_ms=split + 60_000)
    assert len(details["pieces"]) == 2
    assert _near(_first_pixel(out, at_s=1.0), (255, 0, 0)), "the clip must open in the first file"
    assert _near(_first_pixel(out, at_s=6.5), (0, 0, 255)), "and carry on into the second"


@needs_ffmpeg
def test_a_failed_cut_leaves_the_previous_clip_in_place(tmp_path):
    """An operator takes replays one after another. A bad take must not blank the scene: OBS holds
    this exact path open, so the only safe install is candidate then verify then replace."""
    rec = tmp_path / "day.mp4"
    _make_recording(rec)
    rows = anchor_rows(str(rec), T0, 2)
    out = tmp_path / "replay.mp4"
    day_cutter.cut(rows, T0 + 2_000, T0 + 6_000, output=out, ffmpeg=FFMPEG, ffprobe=FFPROBE,
                   now_utc_ms=T0 + 60_000)
    good = out.read_bytes()

    with pytest.raises(DayCutterError):                          # past the end of the recording
        day_cutter.cut(rows, T0 + 600_000, T0 + 640_000, output=out, ffmpeg=FFMPEG, ffprobe=FFPROBE,
                       now_utc_ms=T0 + 700_000)
    assert out.read_bytes() == good
    assert not list(out.parent.glob(".*candidate*")), "no half-written file may be left behind"
