"""Invariant tests for narration schedule slicing (chunked-render correctness).

The property that must hold: render(A:C) uses the same per-segment source samples
as render(A:B) ++ render(B:C). Since the render seeks each narration segment by its
(sliced) skip, that reduces to: slice([A,C]) covers the same source spans as
slice([A,B]) followed by slice([B,C]).
"""
import sys
from pathlib import Path

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

from gnommo.narration import NarrationSegment, slice_schedule


def seg(sid, skip, dur, offset):
    return NarrationSegment(
        seg_id=sid, source_path=Path(f"{sid}.mov"),
        skip=skip, take=dur, duration=dur, offset=offset,
    )


# Combined timeline:
#   s1: source skip 5,  plays 100  -> combined [0,100],   source [5,105]
#   s2: source skip 10, plays 200  -> combined [100,300], source [10,210]
#   s3: source skip 2,  plays 50   -> combined [300,350], source [2,52]
SCHED = [seg("s1", 5, 100, 0), seg("s2", 10, 200, 100), seg("s3", 2, 50, 300)]


def _spans(segs):
    return [(s.seg_id, round(s.skip, 6), round(s.skip + s.take, 6)) for s in segs]


def _merge(spans):
    """Fuse adjacent spans of the same segment (the boundary segment split across
    two chunks) so a chunked coverage can be compared to a single-window one."""
    out = []
    for sid, a, b in spans:
        if out and out[-1][0] == sid and abs(out[-1][2] - a) < 1e-6:
            out[-1] = (sid, out[-1][1], b)
        else:
            out.append((sid, a, b))
    return out


def check(name, cond):
    print(f"  {'PASS' if cond else 'FAIL'}  {name}")
    assert cond, name


# Full-window slice is a no-op (full renders unaffected).
full = slice_schedule(SCHED, 0, 350)
check("full-window slice is identity", _spans(full) == _spans(SCHED))

# Window drops the out-of-range segment and trims the edges into the files.
w = slice_schedule(SCHED, 150, 320)
check("drops out-of-window segment s1", [s.seg_id for s in w] == ["s2", "s3"])
check("first kept seg seeks into its file (s2 skip 60, take 150, offset 0)",
      (w[0].skip, w[0].take, w[0].offset) == (60, 150, 0))
check("last kept seg trimmed to window (s3 skip 2, take 20, offset 150)",
      (w[1].skip, w[1].take, w[1].offset) == (2, 20, 150))

# Every seek stays within its own file's real bounds.
for orig, sl in ((SCHED[1], w[0]), (SCHED[2], w[1])):
    check(f"{sl.seg_id} seek within file bounds",
          sl.skip >= orig.skip and sl.skip + sl.take <= orig.skip + orig.duration + 1e-6)

# THE invariant: A:C == A:B ++ B:C in source coverage, for boundaries that land
# mid-segment, on a segment edge, and spanning multiple segments.
for A, B, C in [(150, 250, 340), (150, 300, 340), (50, 100, 350), (0, 300, 350)]:
    whole = _spans(slice_schedule(SCHED, A, C))
    joined = _merge(_spans(slice_schedule(SCHED, A, B)) + _spans(slice_schedule(SCHED, B, C)))
    check(f"slice({A}:{C}) == slice({A}:{B})++slice({B}:{C})", joined == whole)

# Offsets are contiguous and cover the window with no gap/overlap.
for A, C in [(150, 320), (0, 350), (120, 340)]:
    sl = slice_schedule(SCHED, A, C)
    ok = abs(sl[0].offset) < 1e-6
    for prev, cur in zip(sl, sl[1:]):
        ok = ok and abs((prev.offset + prev.duration) - cur.offset) < 1e-6
    ok = ok and abs((sl[-1].offset + sl[-1].duration) - (C - A)) < 1e-6
    check(f"contiguous coverage of window [{A},{C}]", ok)

print("\nAll narration-slice invariants passed.")
