using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;

namespace DeepDrftTests;

/// <summary>
/// Unit tests for the two-level deque play-queue orchestrator (<see cref="QueueService"/>). The queue
/// is pure domain logic over the single-slot player, so it is exercised here against a recording fake
/// (<see cref="FakeStreamingPlayer"/>) — no browser, no JS interop, no DI container. Coverage: PLAY-
/// prepend (single + release), add-to-queue append, dormant-seed-from-player, ordered advance,
/// next/previous bounds, jump, clear, current-index integrity, and auto-advance / last-track-empty on
/// the player's <see cref="IPlayerService.TrackEnded"/> signal.
/// </summary>
[TestFixture]
public class QueueServiceTests
{
    private FakeStreamingPlayer _player = null!;
    private QueueService _queue = null!;

    [SetUp]
    public void SetUp()
    {
        _player = new FakeStreamingPlayer();
        _queue = new QueueService();
        _queue.Attach(_player);
    }

    [TearDown]
    public void TearDown() => _queue.Dispose();

    private static List<TrackDto> Tracks(int count) =>
        Enumerable.Range(1, count)
            .Select(i => new TrackDto { EntryKey = $"track-{i}", TrackName = $"Track {i}", TrackNumber = i })
            .ToList();

    // --- Empty-queue invariants (no regression to single-track play) ---

    [Test]
    public void NewQueue_IsEmptyWithCurrentIndexNegativeOne()
    {
        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Is.Empty);
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(_queue.Current, Is.Null);
            Assert.That(_queue.HasNext, Is.False);
            Assert.That(_queue.HasPrevious, Is.False);
        });
    }

    [Test]
    public async Task NextAndPrevious_OnEmptyQueue_AreNoOpsAndDriveNoPlayback()
    {
        await _queue.Next();
        await _queue.Previous();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(_player.SelectedTracks, Is.Empty);
        });
    }

    // --- PlayRelease: enqueue + ordered start ---

    [Test]
    public async Task PlayRelease_LoadsTracksInOrderAndStreamsFirst()
    {
        var tracks = Tracks(3);

        await _queue.PlayRelease(tracks);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-1"));
            Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"));
        });
    }

    [Test]
    public async Task PlayRelease_WithStartIndex_StartsMidAlbumAndKeepsRemainderQueued()
    {
        var tracks = Tracks(4);

        await _queue.PlayRelease(tracks, startIndex: 2);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
            Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-3"));
            Assert.That(_queue.HasNext, Is.True);
            Assert.That(_queue.HasPrevious, Is.True);
        });
    }

    [Test]
    public async Task PlayRelease_ClampsOutOfRangeStartIndex()
    {
        var tracks = Tracks(3);

        await _queue.PlayRelease(tracks, startIndex: 99);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
            Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-3"));
        });
    }

    [Test]
    public async Task PlayRelease_WithEmptyTracks_IsNoOp()
    {
        await _queue.PlayRelease(Enumerable.Empty<TrackDto>());

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Is.Empty);
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(_player.SelectedTracks, Is.Empty);
        });
    }

    [Test]
    public async Task PlayRelease_PrependsToFront_RemovesPreviousCurrent_KeepsRemainderIntact()
    {
        // Deque PLAY (bug #5): PlayRelease into a non-empty queue prepends the release at the front,
        // removes the previously-current track, and leaves the up-next that sat after it intact behind
        // the prepend. Current was track-1 (index 0) → after prepend, the old current is dropped and
        // its tail (track-2, track-3) stays behind [x-1, x-2].
        await _queue.PlayRelease(Tracks(3));
        var second = new List<TrackDto>
        {
            new() { EntryKey = "x-1", TrackName = "X1" },
            new() { EntryKey = "x-2", TrackName = "X2" },
        };

        await _queue.PlayRelease(second);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "x-1", "x-2", "track-2", "track-3" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("x-1"));
            Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("x-1"));
        });
    }

    [Test]
    public async Task PlayRelease_FromMidQueueCurrent_DropsOnlyTheCurrentTrack_NotItsTail()
    {
        // Current advanced to track-2 (index 1) with track-3, track-4 after it. PLAY of a new release
        // drops only track-2 (the current) and keeps track-3, track-4 behind the prepend. The old
        // back-history (track-1, before the current) is discarded — a fresh PLAY defines a new front.
        await _queue.PlayRelease(Tracks(4));
        await _queue.Next(); // current = track-2 at index 1

        await _queue.PlayRelease(new List<TrackDto> { new() { EntryKey = "p-1", TrackName = "P1" } });

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "p-1", "track-3", "track-4" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("p-1"));
        });
    }

    [Test]
    public async Task PlayRelease_WithStartIndex_PrependsWholeReleaseInOrder_CurrentAtStartIndex()
    {
        // A mid-album row play prepends the whole release in order; the chosen startIndex becomes
        // current. Tracks before it sit behind the pointer (Previous reaches them); tracks after are
        // up-next. The previous current is dropped.
        await _queue.PlayRelease(Tracks(2)); // existing queue: [track-1*, track-2]
        var release = new List<TrackDto>
        {
            new() { EntryKey = "r-1", TrackName = "R1" },
            new() { EntryKey = "r-2", TrackName = "R2" },
            new() { EntryKey = "r-3", TrackName = "R3" },
        };

        await _queue.PlayRelease(release, startIndex: 1);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "r-1", "r-2", "r-3", "track-2" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("r-2"));
            Assert.That(_queue.HasPrevious, Is.True);  // r-1 is behind the pointer
            Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("r-2"));
        });
    }

    [Test]
    public async Task PlayRelease_ViaLiveQueueItems_DoesNotCorruptListUnderPrepend()
    {
        // Aliasing guard retained under the deque model: a caller that passes the live Items reference
        // into PlayRelease must not corrupt the list. PlayRelease materializes tracks.ToList() before
        // the RemoveRange/InsertRange prepend, so the defensive copy survives the mutation.
        await _queue.PlayRelease(Tracks(4));

        // Pass the live Items reference (current = track-1). Prepend drops the current and re-inserts
        // the copy at the front, with the tail (track-2..4) preserved behind it.
        await _queue.PlayRelease(_queue.Items, 2);

        Assert.Multiple(() =>
        {
            // The defensive copy is intact: all four original tracks were re-prepended in order, and the
            // old current's tail follows. CurrentIndex is the chosen start.
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4", "track-2", "track-3", "track-4" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
            Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
        });
    }

    // --- PlayTrack: deque PLAY of a single track (prepend to front) — bug #5 ---

    [Test]
    public async Task PlayTrack_IntoDormantQueue_BecomesSoleHeadAndStreams()
    {
        await _queue.PlayTrack(new TrackDto { EntryKey = "solo", TrackName = "Solo" });

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "solo" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("solo"));
        });
    }

    [Test]
    public async Task PlayTrack_FromNonEmptyQueue_PrependsDropsPreviousCurrent_KeepsRemainder()
    {
        // bug #5: PLAY of a single track from a non-empty queue prepends it as the new head, drops the
        // previously-current track, and leaves the remainder intact behind the new head.
        await _queue.PlayRelease(Tracks(3)); // [track-1*, track-2, track-3]

        await _queue.PlayTrack(new TrackDto { EntryKey = "jump-in", TrackName = "Jump In" });

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "jump-in", "track-2", "track-3" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("jump-in"));
            Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("jump-in"));
        });
    }

    [Test]
    public async Task PlayTrack_DisarmsAnArmedQueue()
    {
        _queue.Arm(Tracks(3));

        await _queue.PlayTrack(new TrackDto { EntryKey = "override", TrackName = "Override" });

        Assert.Multiple(() =>
        {
            Assert.That(_queue.IsArmed, Is.False);
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("override"));
        });
    }

    // --- Arm: prerender-safe load without streaming (release embed) ---

    [Test]
    public void Arm_LoadsTracksAtIndexZero_WithoutStreaming()
    {
        var tracks = Tracks(3);

        _queue.Arm(tracks);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.IsArmed, Is.True);
            // Arming is interop-free state only: nothing must have been streamed yet.
            Assert.That(_player.SelectedTracks, Is.Empty);
        });
    }

    [Test]
    public void Arm_WithSingleTrackRelease_ArmsAOneItemQueueWithoutError()
    {
        _queue.Arm(Tracks(1));

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Has.Count.EqualTo(1));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.IsArmed, Is.True);
            Assert.That(_queue.HasNext, Is.False);
            Assert.That(_player.SelectedTracks, Is.Empty);
        });
    }

    [Test]
    public void Arm_WithEmptyTracks_IsNoOpAndLeavesQueueDisarmed()
    {
        _queue.Arm(Enumerable.Empty<TrackDto>());

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Is.Empty);
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(_queue.IsArmed, Is.False);
        });
    }

    [Test]
    public async Task Start_OnArmedQueue_StreamsTrackZeroAndDisarms()
    {
        // Models the embed's first-gesture path: FramePlayer arms the queue (no stream), then the
        // play click routes through Start().
        _queue.Arm(Tracks(3));

        await _queue.Start();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.IsArmed, Is.False);
            Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"));
        });
    }

    [Test]
    public async Task Start_OnUnarmedQueue_IsNoOp()
    {
        // A non-embed flow (PlayRelease already streaming) must not be disturbed by a stray Start.
        await _queue.PlayRelease(Tracks(3));
        var streamedBefore = _player.SelectedTracks.Count;

        await _queue.Start();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.IsArmed, Is.False);
            Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
                "Start on an unarmed queue must not re-stream");
        });
    }

    [Test]
    public async Task ArmedQueue_StartedThenAdvancesThroughWholeReleaseOnTrackEnded()
    {
        _queue.Arm(Tracks(3));
        await _queue.Start();

        _player.RaiseTrackEnded(); // → track-2
        _player.RaiseTrackEnded(); // → track-3

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
            Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
        });
    }

    [Test]
    public void Arm_RaisesQueueChanged()
    {
        var raised = false;
        _queue.QueueChanged += () => raised = true;

        _queue.Arm(Tracks(2));

        Assert.That(raised, Is.True);
    }

    [Test]
    public async Task Clear_DisarmsAnArmedQueue()
    {
        _queue.Arm(Tracks(2));

        _queue.Clear();

        Assert.That(_queue.IsArmed, Is.False);
        await Task.CompletedTask;
    }

    // --- Next / Previous mechanics and bounds ---

    [Test]
    public async Task Next_AdvancesThroughTracksInOrder()
    {
        await _queue.PlayRelease(Tracks(3));

        await _queue.Next();
        Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));

        await _queue.Next();
        Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));

        Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
            Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
    }

    [Test]
    public async Task Next_AtLastTrack_IsNoOp()
    {
        await _queue.PlayRelease(Tracks(2), startIndex: 1);

        await _queue.Next();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
            Assert.That(_queue.HasNext, Is.False);
            // Only the initial PlayRelease selection — Next at the end drove no further playback.
            Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
        });
    }

    [Test]
    public async Task Previous_StepsBackThroughTracks()
    {
        await _queue.PlayRelease(Tracks(3), startIndex: 2);

        await _queue.Previous();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
            Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2"));
        });
    }

    [Test]
    public async Task Previous_AtFirstTrack_IsNoOp()
    {
        await _queue.PlayRelease(Tracks(3));

        await _queue.Previous();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.HasPrevious, Is.False);
            Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1));
        });
    }

    // --- Enqueue / EnqueueRange ---

    [Test]
    public void Enqueue_IntoDormantQueue_StagesCoherentIndexWithoutStartingPlayback()
    {
        // OQ8 (Phase 17 wave 17.1): the first add into a dormant queue stages a coherent
        // CurrentIndex (0) so the next play/skip is correct, but does NOT begin playback. (This
        // supersedes the pre-OQ8 expectation that a dormant Enqueue left CurrentIndex at -1.)
        _queue.Enqueue(new TrackDto { EntryKey = "a", TrackName = "A" });

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Has.Count.EqualTo(1));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_player.SelectedTracks, Is.Empty);
        });
    }

    [Test]
    public async Task Enqueue_AfterPlayRelease_ExtendsTheQueueAndEnablesHasNext()
    {
        await _queue.PlayRelease(Tracks(1));
        Assert.That(_queue.HasNext, Is.False);

        _queue.Enqueue(new TrackDto { EntryKey = "appended", TrackName = "Appended" });

        Assert.Multiple(() =>
        {
            Assert.That(_queue.HasNext, Is.True);
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
        });
    }

    [Test]
    public void EnqueueRange_AppendsAllTracks()
    {
        _queue.EnqueueRange(Tracks(3));

        Assert.That(_queue.Items, Has.Count.EqualTo(3));
    }

    // --- Move (reorder) — Phase 17 wave 17.1 ---

    [Test]
    public async Task Move_ReordersItems_AndRaisesQueueChangedOnce()
    {
        // T1: Move(2, 0) on a 4-item queue reorders correctly; QueueChanged fired once.
        await _queue.PlayRelease(Tracks(4));
        var changed = 0;
        _queue.QueueChanged += () => changed++;

        _queue.Move(2, 0);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "track-3", "track-1", "track-2", "track-4" }));
            Assert.That(changed, Is.EqualTo(1));
        });
    }

    [Test]
    public async Task Move_MovingOtherItemsAroundCurrent_KeepsSameTrackCurrent()
    {
        // T2 (moving others): with CurrentIndex on track-2, moving an item across it leaves track-2 current.
        await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1

        _queue.Move(3, 0); // move track-4 to the front → [4,1,2,3]; track-2 now at index 2

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
        });
    }

    [Test]
    public async Task Move_MovingCurrentItself_UpdatesCurrentIndexToNewSlot()
    {
        // T2 (moving current): moving the current track updates CurrentIndex to its new slot.
        await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1

        _queue.Move(1, 3); // move track-2 to the end → [1,3,4,2]

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(3));
        });
    }

    [Test]
    public async Task Move_DoesNotStreamAnything()
    {
        // T3: Move drives no playback — the fake player records no further SelectTrackStreaming call.
        await _queue.PlayRelease(Tracks(4));
        var streamedBefore = _player.SelectedTracks.Count;

        _queue.Move(3, 0);
        _queue.Move(0, 2);

        Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
            "Move must not stream — it is a pure list/index mutation");
    }

    [Test]
    public async Task Move_ReorderingCurrentTrack_AutoAdvancePointsAtCorrectNext()
    {
        // T10: reorder the currently-playing item; the next auto-advance follows the new order.
        await _queue.PlayRelease(Tracks(4)); // current = track-1 at index 0

        _queue.Move(0, 2); // move current track-1 to index 2 → [2,3,1,4]; track-1 still current at index 2

        Assert.That(_queue.CurrentIndex, Is.EqualTo(2));

        _player.RaiseTrackEnded(); // organic end of track-1 → advance to index 3 (track-4)

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(3));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-4"));
            Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-4"));
        });
    }

    [Test]
    public async Task Move_OutOfRangeOrEqualIndices_AreNoOpsAndDoNotRaiseQueueChanged()
    {
        // T8 (Move half): out-of-range and equal-index moves do not throw and do not fire QueueChanged.
        await _queue.PlayRelease(Tracks(3));
        var raised = false;
        _queue.QueueChanged += () => raised = true;

        Assert.DoesNotThrow(() =>
        {
            _queue.Move(-1, 0);
            _queue.Move(0, 5);
            _queue.Move(5, 0);
            _queue.Move(1, 1);
        });

        Assert.Multiple(() =>
        {
            Assert.That(raised, Is.False);
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
        });
    }

    // --- RemoveAt — Phase 17 wave 17.1 ---

    [Test]
    public async Task RemoveAt_AfterCurrent_LeavesCurrentIndexUnchanged()
    {
        // T4: removing a track after the current leaves CurrentIndex unchanged, item gone.
        await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1

        _queue.RemoveAt(3); // remove track-4 (after current)

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
        });
    }

    [Test]
    public async Task RemoveAt_BeforeCurrent_DecrementsCurrentIndex_SameTrackStaysCurrent()
    {
        // T5: removing before the current decrements CurrentIndex; the same track stays current.
        await _queue.PlayRelease(Tracks(4), startIndex: 2); // current = track-3 at index 2

        _queue.RemoveAt(0); // remove track-1 (before current)

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
        });
    }

    [Test]
    public async Task RemoveAt_OfCurrent_NotLast_DoesNotStop_AndResolvesToNextOccupant()
    {
        // T6 (not last): removing the current track does not stop the player; CurrentIndex resolves
        // to the new occupant of that slot (the next track).
        await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1
        var streamedBefore = _player.SelectedTracks.Count;

        _queue.RemoveAt(1); // remove current track-2 → [1,3,4]; index 1 now holds track-3

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
            Assert.That(_player.StopCount, Is.EqualTo(0), "RemoveAt must not stop playback (C2)");
            Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
                "RemoveAt must not re-stream");
        });
    }

    [Test]
    public async Task RemoveAt_OfCurrent_WhenLastSlot_ResolvesToDormantMinusOne()
    {
        // T6 (last slot, others remain): removing the current track when it is the last item has no
        // next occupant → CurrentIndex resolves to -1; playback is not stopped.
        await _queue.PlayRelease(Tracks(3), startIndex: 2); // current = track-3 at last index 2

        _queue.RemoveAt(2);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(_queue.Current, Is.Null);
            Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2" }));
            Assert.That(_player.StopCount, Is.EqualTo(0));
        });
    }

    [Test]
    public async Task RemoveAt_OfLastRemainingTrack_EmptiesAndGoesDormant()
    {
        // T7: removing the last remaining item → empty + dormant; QueueChanged fired.
        await _queue.PlayRelease(Tracks(1));
        var raised = false;
        _queue.QueueChanged += () => raised = true;

        _queue.RemoveAt(0);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Is.Empty);
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(_queue.Current, Is.Null);
            Assert.That(raised, Is.True);
        });
    }

    [Test]
    public async Task RemoveAt_OutOfRange_IsNoOpAndDoesNotRaiseQueueChanged()
    {
        // T8 (RemoveAt half): out-of-range removal does not throw and does not fire QueueChanged.
        await _queue.PlayRelease(Tracks(3));
        var raised = false;
        _queue.QueueChanged += () => raised = true;

        Assert.DoesNotThrow(() =>
        {
            _queue.RemoveAt(-1);
            _queue.RemoveAt(3);
        });

        Assert.Multiple(() =>
        {
            Assert.That(raised, Is.False);
            Assert.That(_queue.Items, Has.Count.EqualTo(3));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
        });
    }

    // --- Dormant Enqueue (OQ8: pure append, coherent index, no auto-play) — Phase 17 wave 17.1 ---

    [Test]
    public async Task Enqueue_IntoDormantQueue_LeavesCoherentCurrentIndexWithoutPlaying_ThenPlayStartsCorrectly()
    {
        // T9: Enqueue into a dormant queue stages a coherent CurrentIndex (0) but does not auto-play;
        // a subsequent play (Next from the staged position, or PlayRelease) behaves per OQ8.
        _queue.Enqueue(new TrackDto { EntryKey = "a", TrackName = "A" });

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "first add stages a coherent index");
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("a"));
            Assert.That(_player.SelectedTracks, Is.Empty, "add is not play — nothing streamed");
            Assert.That(_queue.IsArmed, Is.False, "dormant Enqueue does not arm");
        });

        _queue.Enqueue(new TrackDto { EntryKey = "b", TrackName = "B" });

        Assert.Multiple(() =>
        {
            // Second add appends without disturbing the staged current.
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.HasNext, Is.True);
            Assert.That(_player.SelectedTracks, Is.Empty);
        });

        // The coherent index means a skip-forward streams the right track without a prior play.
        await _queue.Next();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
            Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("b"));
        });
    }

    [Test]
    public void EnqueueRange_IntoDormantQueue_StagesCoherentIndexWithoutPlaying()
    {
        // T9 (range variant): EnqueueRange into a dormant queue stages index 0, streams nothing.
        _queue.EnqueueRange(Tracks(3));

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Has.Count.EqualTo(3));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-1"));
            Assert.That(_player.SelectedTracks, Is.Empty);
            Assert.That(_queue.IsArmed, Is.False);
        });
    }

    [Test]
    public void Enqueue_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends()
    {
        // Bug #3: a single track is playing NOT through the queue (the player's CurrentTrack is set, the
        // queue is dormant). The first Add-to-queue must seed the head with that now-playing track and
        // then append the added one → [now-playing, added], even if they are the same track.
        var nowPlaying = new TrackDto { Id = 7, EntryKey = "now-playing", TrackName = "Now Playing" };
        _player.SimulateDirectPlay(nowPlaying);

        _queue.Enqueue(new TrackDto { EntryKey = "added", TrackName = "Added" });

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "now-playing", "added" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "the now-playing track is the head/current");
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("now-playing"));
            Assert.That(_player.SelectedTracks, Is.Empty, "add is not play — nothing streamed");
        });

        // A second add appends a third item — no ghost/duplicate seeding.
        _queue.Enqueue(new TrackDto { EntryKey = "added-2", TrackName = "Added 2" });

        Assert.That(_queue.Items.Select(t => t.EntryKey),
            Is.EqualTo(new[] { "now-playing", "added", "added-2" }));
    }

    [Test]
    public void Enqueue_OfTheSameExternallyPlayingTrack_SeedsHeadThenAppendsTheDuplicate()
    {
        // Bug #3 exact repro: add the very track that is playing externally. Result must be a 2-item
        // queue [now-playing(current), same-track-appended] — not a single ghost entry.
        var nowPlaying = new TrackDto { Id = 7, EntryKey = "the-track", TrackName = "The Track" };
        _player.SimulateDirectPlay(nowPlaying);

        _queue.Enqueue(nowPlaying);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Has.Count.EqualTo(2));
            Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "the-track", "the-track" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
        });
    }

    [Test]
    public void EnqueueRange_IntoDormantQueue_WhileTrackPlaysExternally_SeedsHeadThenAppends()
    {
        var nowPlaying = new TrackDto { Id = 9, EntryKey = "live", TrackName = "Live" };
        _player.SimulateDirectPlay(nowPlaying);

        _queue.EnqueueRange(Tracks(2));

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "live", "track-1", "track-2" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_player.SelectedTracks, Is.Empty);
        });
    }

    [Test]
    public void Enqueue_IntoDormantQueue_WithNothingPlaying_DoesNotSeedAPhantomHead()
    {
        // No external track playing → nothing to seed. The single added track is the head (OQ8 coherent
        // index), and there is no phantom duplicate.
        _queue.Enqueue(new TrackDto { EntryKey = "only", TrackName = "Only" });

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "only" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
        });
    }

    [Test]
    public async Task Enqueue_IntoActiveQueue_DoesNotMoveCurrentIndex()
    {
        // Guard the non-dormant path stays unchanged: appending while playing leaves current put.
        await _queue.PlayRelease(Tracks(2), startIndex: 1); // current = track-2 at index 1

        _queue.Enqueue(new TrackDto { EntryKey = "tail", TrackName = "Tail" });

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(1), "active-queue Enqueue must not disturb current");
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
        });
    }

    // --- JumpTo: row-jump within the deque (move pointer + stream once) ---

    [Test]
    public async Task JumpTo_MovesPointerForwardAndStreamsTheTargetOnce()
    {
        await _queue.PlayRelease(Tracks(4)); // current = track-1
        var streamedBefore = _player.SelectedTracks.Count;

        await _queue.JumpTo(2);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
            // Exactly one new stream — the intervening track-2 must NOT have been streamed.
            Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore + 1));
            Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
        });
    }

    [Test]
    public async Task JumpTo_MovesPointerBackwardAndStreamsTheTarget()
    {
        await _queue.PlayRelease(Tracks(4), startIndex: 3); // current = track-4

        await _queue.JumpTo(1);

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
            Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2"));
        });
    }

    [Test]
    public async Task JumpTo_DoesNotDuplicateTheQueue()
    {
        // Regression guard: JumpTo must NOT prepend (it is not a PLAY) — the deque length is unchanged.
        await _queue.PlayRelease(Tracks(4));

        await _queue.JumpTo(2);

        Assert.That(_queue.Items.Select(t => t.EntryKey),
            Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
    }

    [Test]
    public async Task JumpTo_SameIndexOrOutOfRange_IsNoOp()
    {
        await _queue.PlayRelease(Tracks(3)); // current = track-1
        var streamedBefore = _player.SelectedTracks.Count;

        await _queue.JumpTo(0);  // already current
        await _queue.JumpTo(-1);
        await _queue.JumpTo(3);

        Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
            "no-op jumps must not re-stream");
    }

    // --- Clear ---

    [Test]
    public async Task Clear_EmptiesQueueAndResetsIndexWithoutStoppingPlayer()
    {
        await _queue.PlayRelease(Tracks(3));

        _queue.Clear();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Is.Empty);
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(_queue.Current, Is.Null);
            // Clear is a queue-state reset; it must not tear the player down.
            Assert.That(_player.StopCount, Is.EqualTo(0));
        });
    }

    // --- ClearUpcoming (OQ5: keep the current track, drop the up-next) — Phase 17 wave 17.2 ---

    [Test]
    public async Task ClearUpcoming_KeepsCurrentTrack_DropsTheRest_WithoutStopping()
    {
        // Current = track-2; ClearUpcoming leaves only track-2 at index 0 and does not stop the player.
        await _queue.PlayRelease(Tracks(4), startIndex: 1);
        var streamedBefore = _player.SelectedTracks.Count;

        _queue.ClearUpcoming();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-2" }));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
            Assert.That(_queue.HasNext, Is.False);
            Assert.That(_queue.HasPrevious, Is.False);
            Assert.That(_player.StopCount, Is.EqualTo(0), "ClearUpcoming must not stop playback (C2)");
            Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
                "ClearUpcoming must not re-stream");
        });
    }

    [Test]
    public async Task ClearUpcoming_RaisesQueueChangedOnce()
    {
        await _queue.PlayRelease(Tracks(3));
        var changed = 0;
        _queue.QueueChanged += () => changed++;

        _queue.ClearUpcoming();

        Assert.That(changed, Is.EqualTo(1));
    }

    [Test]
    public async Task ClearUpcoming_WhenOnlyCurrentRemains_IsNoOpAndDoesNotRaiseQueueChanged()
    {
        await _queue.PlayRelease(Tracks(1));
        var raised = false;
        _queue.QueueChanged += () => raised = true;

        _queue.ClearUpcoming();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Has.Count.EqualTo(1));
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
            Assert.That(raised, Is.False);
        });
    }

    [Test]
    public void ClearUpcoming_OnEmptyQueue_IsNoOpAndDoesNotRaiseQueueChanged()
    {
        var raised = false;
        _queue.QueueChanged += () => raised = true;

        _queue.ClearUpcoming();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Is.Empty);
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(raised, Is.False);
        });
    }

    // --- QueueChanged notifications ---

    [Test]
    public async Task MutatingOperations_RaiseQueueChanged()
    {
        var count = 0;
        _queue.QueueChanged += () => count++;

        await _queue.PlayRelease(Tracks(3)); // 1
        await _queue.Next();                 // 2
        await _queue.Previous();             // 3
        _queue.Enqueue(new TrackDto { EntryKey = "z", TrackName = "Z" }); // 4
        _queue.Clear();                      // 5

        Assert.That(count, Is.EqualTo(5));
    }

    [Test]
    public void Clear_OnAlreadyEmptyQueue_DoesNotRaiseQueueChanged()
    {
        var raised = false;
        _queue.QueueChanged += () => raised = true;

        _queue.Clear();

        Assert.That(raised, Is.False);
    }

    // --- Auto-advance on TrackEnded ---

    [Test]
    public async Task TrackEnded_AutoAdvancesToNextTrack()
    {
        await _queue.PlayRelease(Tracks(3));

        _player.RaiseTrackEnded();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
            Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
            Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-2"));
        });
    }

    [Test]
    public async Task TrackEnded_OnLastTrack_EmptiesTheQueueAndGoesDormant()
    {
        // Bug #2: when the current track ends naturally and there is nothing after it, the queue empties
        // (CurrentIndex == -1, dormant) rather than stranding the finished track as current. No replay.
        await _queue.PlayRelease(Tracks(2), startIndex: 1);
        var raised = false;
        _queue.QueueChanged += () => raised = true;

        _player.RaiseTrackEnded();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Is.Empty);
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(_queue.Current, Is.Null);
            Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1), "no replay on end");
            Assert.That(raised, Is.True, "emptying the queue raises QueueChanged");
        });
    }

    [Test]
    public async Task TrackEnded_OnSingleTrackQueue_EmptiesTheQueue()
    {
        // Bug #2, single-track variant: a one-item queue playing to its end empties (dormant).
        await _queue.PlayRelease(Tracks(1));

        _player.RaiseTrackEnded();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Is.Empty);
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
        });
    }

    [Test]
    public void TrackEnded_OnEmptyQueue_IsIgnored()
    {
        _player.RaiseTrackEnded();

        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(_player.SelectedTracks, Is.Empty);
        });
    }

    /// <summary>
    /// Regression guard for the cross-context spurious-advance bug: a direct single-track play
    /// (Session, StreamNowButton, resume) overwrites the player's CurrentTrack without touching the
    /// queue. When that external track reaches its natural end, TrackEnded fires — but the queue's
    /// Current no longer matches the player's CurrentTrack, so the queue must NOT advance.
    /// </summary>
    [Test]
    public async Task TrackEnded_WhenPlayerCurrentTrackIsNotQueueCurrent_DoesNotAdvance()
    {
        // Load a 3-track album into the queue (queue.Current → track-1, player.CurrentTrack → track-1).
        await _queue.PlayRelease(Tracks(3));
        Assert.That(_queue.CurrentIndex, Is.EqualTo(0));

        // Simulate a direct play (e.g. SessionDetail streams an unrelated track by Id = 99).
        // The player's CurrentTrack is now the session track, but the queue is still on track-1.
        var sessionTrack = new TrackDto { Id = 99, EntryKey = "session-track", TrackName = "Session Mix" };
        _player.SimulateDirectPlay(sessionTrack);

        // The session track finishes naturally — player raises TrackEnded.
        _player.RaiseTrackEnded();

        // The queue must not have advanced: index still 0, and no additional SelectTrackStreaming
        // calls beyond the initial PlayRelease selection.
        Assert.Multiple(() =>
        {
            Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "Queue must not advance when a direct-play track ends");
            Assert.That(_player.SelectedTracks, Has.Count.EqualTo(1), "No further streaming must be triggered by the queue");
            Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"), "Only the original PlayRelease selection must have been streamed");
        });
    }

    [Test]
    public async Task TrackEnded_PlaysWholeAlbumThroughToTheEnd_ThenEmptiesOnLastEnd()
    {
        await _queue.PlayRelease(Tracks(3));

        _player.RaiseTrackEnded(); // → track-2
        _player.RaiseTrackEnded(); // → track-3
        _player.RaiseTrackEnded(); // last track ends → queue empties (bug #2)

        Assert.Multiple(() =>
        {
            Assert.That(_queue.Items, Is.Empty);
            Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
            Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
                Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
        });
    }

    // --- Attach lifecycle ---

    [Test]
    public async Task Dispose_UnsubscribesFromTrackEnded_SoNoAutoAdvanceAfterDispose()
    {
        await _queue.PlayRelease(Tracks(3));
        _queue.Dispose();

        _player.RaiseTrackEnded();

        Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
    }

    [Test]
    public async Task Attach_ToNewPlayer_RedirectsPlaybackAndAutoAdvance()
    {
        var second = new FakeStreamingPlayer();
        _queue.Attach(second);

        await _queue.PlayRelease(Tracks(3));
        Assert.That(second.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"));

        // The old player's TrackEnded must no longer drive this queue.
        _player.RaiseTrackEnded();
        Assert.That(_queue.CurrentIndex, Is.EqualTo(0));

        // The newly attached player does.
        second.RaiseTrackEnded();
        Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
    }

    /// <summary>
    /// Records the tracks the queue asks the player to stream and lets a test raise the
    /// player's organic end-of-stream signal. Implements the full <see cref="IStreamingPlayerService"/>
    /// surface but only the members the queue actually drives carry behavior; the rest are inert
    /// — the queue touches nothing else, which is exactly the seam this fake pins down.
    /// </summary>
    private sealed class FakeStreamingPlayer : IStreamingPlayerService
    {
        public List<TrackDto> SelectedTracks { get; } = new();
        public int StopCount { get; private set; }

        public void RaiseTrackEnded() => TrackEnded?.Invoke();

        /// <summary>
        /// Sets <see cref="CurrentTrack"/> to <paramref name="track"/> without recording a
        /// <see cref="SelectedTracks"/> entry. Models a direct single-track play (SessionDetail,
        /// StreamNowButton, resume) that overwrites the player state without going through the queue.
        /// </summary>
        public void SimulateDirectPlay(TrackDto track) => CurrentTrack = track;

        public Task SelectTrackStreaming(TrackDto track)
        {
            SelectedTracks.Add(track);
            CurrentTrack = track;
            return Task.CompletedTask;
        }

        public Task Stop()
        {
            StopCount++;
            return Task.CompletedTask;
        }

        public event Action? TrackEnded;

        // Part of the implemented contract but the queue never subscribes to it, so it is
        // intentionally never raised here.
#pragma warning disable CS0067
        public event Action? StateChanged;
#pragma warning restore CS0067

        // Inert remainder of the contract — the queue never invokes these.
        public bool IsInitialized => false;
        public bool IsLoaded => false;
        public bool IsLoading => false;
        public bool IsPlaying => false;
        public bool IsPaused => false;
        public double CurrentTime => 0;
        public double? Duration => null;
        public double Volume => 1.0;
        public double LoadProgress => 0;
        public string? ErrorMessage => null;
        public TrackDto? CurrentTrack { get; private set; }
        public double[]? WaveformProfile => null;
        public EventCallback? OnStateChanged { get; set; }
        public EventCallback? OnTrackSelected { get; set; }
        public bool IsStreamingMode => false;
        public bool CanStartStreaming => false;
        public bool HeaderParsed => false;
        public int BufferedChunks => 0;

        public Task InitializeAsync() => Task.CompletedTask;
        public Task SelectTrack(TrackDto track) => SelectTrackStreaming(track);
        public Task Unload() => Task.CompletedTask;
        public Task TogglePlayPause() => Task.CompletedTask;
        public Task Seek(double position) => Task.CompletedTask;
        public Task SetVolume(double volume) => Task.CompletedTask;
        public Task ClearError() => Task.CompletedTask;
        public Task WarmAudioContext() => Task.CompletedTask;
        public Task StageTrack(TrackDto track) => Task.CompletedTask;
    }
}
