import pytest

from player.ring import (
    RingError,
    RingMove,
    Scale,
    ScaleRing,
    Transition,
    TransitionStep,
    advance_ring,
    pick_clip_id,
    resolve_move,
    scale_at,
)


def _pair(a, b):
    """Both directed morphs for an adjacent clip pair (zoom-in + zoom-out)."""
    return (Transition(a, b, f"t/{a}__{b}.mp4"),
            Transition(b, a, f"t/{a}__{b}.rev.mp4"))


def _ring():
    # cosmos (largest, idx 0) -> forest -> abyss (smallest) -> wraps to cosmos.
    # Pool-of-one scales, so each adjacent pair is a single clip pair.
    return ScaleRing(
        scales=(
            Scale(id="cosmos", clip_id="cosmos"),
            Scale(id="forest", clip_id="forest"),
            Scale(id="abyss", clip_id="abyss"),
        ),
        transitions=(
            *_pair("cosmos", "forest"),
            *_pair("forest", "abyss"),
            *_pair("abyss", "cosmos"),
        ),
    )


def test_morph_for_returns_directed_file():
    r = _ring()
    assert r.morph_for("cosmos", "forest") == "t/cosmos__forest.mp4"   # zoom in
    assert r.morph_for("forest", "cosmos") == "t/cosmos__forest.rev.mp4"  # zoom out
    # cosmos<->abyss is adjacent via the wrap edge, so it resolves too:
    assert r.morph_for("cosmos", "abyss") == "t/abyss__cosmos.rev.mp4"
    assert r.morph_for("cosmos", "nope") is None   # no such pair baked


def test_ring_rejects_empty():
    with pytest.raises(RingError):
        ScaleRing(scales=(), transitions=())


def test_ring_allows_any_transition_count():
    # The old "N scales need N transitions" rule is gone — the per-clip-pair model
    # has a variable count, and a ring with no morphs is valid (plain cuts).
    r = ScaleRing(scales=(Scale("a", "a"), Scale("b", "b")), transitions=())
    assert r.morph_for("a", "b") is None


def test_scale_at_wraps_modulo():
    r = _ring()
    assert scale_at(r, 0).id == "cosmos"
    assert scale_at(r, 3).id == "cosmos"   # wraps
    assert scale_at(r, -1).id == "abyss"    # negative wraps
    assert scale_at(r, 4).id == "forest"


def test_advance_one_step_inward_crosses_edge_forward():
    r = _ring()
    move = advance_ring(r, from_index=0, delta=1)
    assert isinstance(move, RingMove)
    assert move.from_index == 0
    assert move.to_index == 1
    assert move.wrapped is False
    assert move.steps == (TransitionStep(edge=0, reversed=False, to_index=1),)


def test_advance_one_step_outward_crosses_edge_reversed():
    r = _ring()
    move = advance_ring(r, from_index=1, delta=-1)
    assert move.to_index == 0
    assert move.wrapped is False
    # going forest(1) -> cosmos(0) is edge 0 crossed in reverse
    assert move.steps == (TransitionStep(edge=0, reversed=True, to_index=0),)


def test_advance_inward_past_smallest_wraps_to_largest():
    r = _ring()
    # at abyss (smallest, idx 2), zoom in -> wraps to cosmos (idx 0) via edge 2
    move = advance_ring(r, from_index=2, delta=1)
    assert move.to_index == 0
    assert move.wrapped is True
    assert move.steps == (TransitionStep(edge=2, reversed=False, to_index=0),)


def test_advance_outward_past_largest_wraps_to_smallest():
    r = _ring()
    # at cosmos (idx 0), zoom out -> wraps to abyss (idx 2) via edge 2 reversed
    move = advance_ring(r, from_index=0, delta=-1)
    assert move.to_index == 2
    assert move.wrapped is True
    assert move.steps == (TransitionStep(edge=2, reversed=True, to_index=2),)


def test_advance_multi_detent_chains_steps():
    r = _ring()
    move = advance_ring(r, from_index=0, delta=2)
    assert move.to_index == 2
    assert move.wrapped is False
    assert move.steps == (
        TransitionStep(edge=0, reversed=False, to_index=1),
        TransitionStep(edge=1, reversed=False, to_index=2),
    )


def test_advance_full_loop_returns_to_start_and_marks_wrap():
    r = _ring()
    move = advance_ring(r, from_index=0, delta=3)
    assert move.to_index == 0
    assert move.wrapped is True
    assert len(move.steps) == 3


def test_advance_zero_is_a_noop():
    r = _ring()
    move = advance_ring(r, from_index=1, delta=0)
    assert move.from_index == 1
    assert move.to_index == 1
    assert move.steps == ()
    assert move.wrapped is False


def test_advance_normalizes_from_index():
    r = _ring()
    move = advance_ring(r, from_index=3, delta=1)
    assert move.from_index == 0
    assert move.to_index == 1


def test_degenerate_single_scale_ring_is_a_noop():
    r = ScaleRing(scales=(Scale("solo", "solo"),), transitions=())
    move = advance_ring(r, from_index=0, delta=5)
    assert move.to_index == 0
    assert move.steps == ()
    assert move.wrapped is False


# --- fast-spin: a faster blended pass past a speed threshold (scales design §3) ---
#
# A fast encoder spin batches many detents into one advance() call, so |delta| is
# the input adapter's proxy for spin speed. Past a configured threshold every step
# is marked `blended` so the renderer plays the WHOLE chain fast (each crossed
# altitude still morphs). The policy is opt-in (default off) because the pure
# function cannot know wall-clock spin speed — the input layer enables it.


def test_fast_spin_disabled_by_default_chains_every_detent():
    r = _ring()
    move = advance_ring(r, from_index=0, delta=5)
    assert move.fast is False
    assert len(move.steps) == 5
    assert all(not s.blended for s in move.steps)


def test_fast_spin_below_threshold_still_chains_unblended():
    r = _ring()
    move = advance_ring(r, from_index=0, delta=2, fast_spin_threshold=3)
    assert move.fast is False
    assert len(move.steps) == 2
    assert move.steps[0].edge == 0 and move.steps[1].edge == 1
    assert all(not s.blended for s in move.steps)


def test_fast_spin_at_or_above_threshold_keeps_all_steps_blended():
    r = _ring()
    # 4 inward detents from cosmos(0): 0->1->2->0->1, lands on forest(1),
    # crosses the abyss->cosmos seam. Every step is kept, marked blended.
    move = advance_ring(r, from_index=0, delta=4, fast_spin_threshold=3)
    assert move.fast is True
    assert move.to_index == 1
    assert move.wrapped is True
    assert len(move.steps) == 4
    assert all(s.blended for s in move.steps)
    # structural path preserved (not collapsed)
    assert [s.edge for s in move.steps] == [0, 1, 2, 0]


def test_fast_spin_outward_keeps_all_steps_blended_and_reversed():
    r = _ring()
    move = advance_ring(r, from_index=0, delta=-4, fast_spin_threshold=3)
    assert move.fast is True
    assert move.to_index == 2
    assert move.wrapped is True
    assert len(move.steps) == 4
    assert all(s.blended and s.reversed for s in move.steps)


def test_fast_spin_threshold_below_two_is_ignored():
    r = _ring()
    move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=1)
    assert move.fast is False
    assert len(move.steps) == 3


def test_fast_spin_noop_on_degenerate_ring():
    r = ScaleRing(scales=(Scale("solo", "solo"),), transitions=())
    move = advance_ring(r, from_index=0, delta=9, fast_spin_threshold=3)
    assert move.fast is False
    assert move.steps == ()


# --- rotating clip pool (content-pipeline design §11.1) ---


def test_scale_without_pool_is_a_pool_of_one():
    s = Scale(id="cosmos", clip_id="cosmos")
    assert s.members == ("cosmos",)
    assert pick_clip_id(s, 0.0) == "cosmos"
    assert pick_clip_id(s, 0.99) == "cosmos"


def test_scale_with_pool_exposes_all_members():
    s = Scale(id="coast", clip_id="coast_a", pool=("coast_a", "coast_b", "coast_c"))
    assert s.members == ("coast_a", "coast_b", "coast_c")


def test_pick_clip_id_selects_member_by_injected_r():
    s = Scale(id="coast", clip_id="a", pool=("a", "b", "c", "d"))
    assert pick_clip_id(s, 0.0) == "a"
    assert pick_clip_id(s, 0.24) == "a"
    assert pick_clip_id(s, 0.25) == "b"
    assert pick_clip_id(s, 0.5) == "c"
    assert pick_clip_id(s, 0.75) == "d"


def test_pick_clip_id_clamps_out_of_range_r():
    s = Scale(id="coast", clip_id="a", pool=("a", "b", "c"))
    assert pick_clip_id(s, -5.0) == "a"
    assert pick_clip_id(s, 1.0) == "c"
    assert pick_clip_id(s, 999.0) == "c"


def test_scale_carries_an_optional_audio_path():
    s = Scale(id="coast", clip_id="coast_birdrock", audio="coast/waves.loop.mp3")
    assert s.audio == "coast/waves.loop.mp3"
    assert Scale(id="x", clip_id="x").audio == ""


# --- resolve_move: pick the destination clip THEN the matching morph, chained ---


def _chain_ring():
    # 3 scales, pools of 2/1/2; descending edges cosmos->coast->abyss + wrap.
    scales = (Scale("cosmos", "c1", ("c1", "c2")),
              Scale("coast", "o1", ("o1",)),
              Scale("abyss", "a1", ("a1", "a2")))
    t = []
    for h in ("c1", "c2"):
        t += list(_pair(h, "o1"))            # cosmos<->coast
    for l in ("a1", "a2"):
        t += list(_pair("o1", l))            # coast<->abyss
    for h in ("a1", "a2"):
        t += list(_pair(h, "c1"))            # abyss<->cosmos wrap (to c1)
        t += list(_pair(h, "c2"))            # abyss<->cosmos wrap (to c2)
    return ScaleRing(scales=scales, transitions=tuple(t))


def test_resolve_single_step_zoom_in():
    r = _chain_ring()
    move = advance_ring(r, from_index=0, delta=1)        # cosmos -> coast
    res = resolve_move(r, move, from_clip_id="c2", picks=(0.0,))
    assert len(res.steps) == 1
    s = res.steps[0]
    assert (s.from_clip, s.to_clip) == ("c2", "o1")
    assert s.file == "t/c2__o1.mp4"
    assert res.target_clip_id == "o1"


def test_resolve_chains_through_intermediate_altitude():
    r = _chain_ring()
    move = advance_ring(r, from_index=0, delta=2)        # cosmos -> coast -> abyss
    # coast member (only o1) then abyss member: 0.6*2 -> index 1 -> a2
    res = resolve_move(r, move, from_clip_id="c1", picks=(0.0, 0.6))
    files = [s.file for s in res.steps]
    assert files == ["t/c1__o1.mp4", "t/o1__a2.mp4"]
    assert res.target_clip_id == "a2"


def test_resolve_zoom_out_uses_reverse_file():
    r = _chain_ring()
    move = advance_ring(r, from_index=2, delta=-1)       # abyss -> coast (ascend)
    res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,))
    assert res.steps[0].file == "t/o1__a1.rev.mp4"
    assert res.target_clip_id == "o1"


def test_resolve_empty_move_keeps_source():
    r = _chain_ring()
    move = advance_ring(r, from_index=0, delta=0)
    res = resolve_move(r, move, from_clip_id="c2", picks=())
    assert res.steps == () and res.target_clip_id == "c2"


def test_resolve_requires_one_pick_per_step():
    r = _chain_ring()
    move = advance_ring(r, from_index=0, delta=2)
    with pytest.raises(ValueError):
        resolve_move(r, move, from_clip_id="c1", picks=(0.0,))


def test_resolve_missing_morph_yields_none_file():
    # A pair with no baked morph resolves to file=None (renderer plain-cuts).
    r = ScaleRing(scales=(Scale("a", "a1", ("a1",)), Scale("b", "b1", ("b1",))),
                  transitions=())
    move = advance_ring(r, from_index=0, delta=1)
    res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,))
    assert res.steps[0].file is None
    assert res.target_clip_id == "b1"
