using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;

namespace DeepDrftTests;

/// <summary>
/// Unit tests for the Phase 16 play-session tracker (<see cref="PlayTracker"/>): the engagement-floor
/// gate (§1d / D2 — ≥3s OR ≥5% of duration, whichever smaller) and the three-bucket completion
/// classification (§1a / D1 — partial &lt;30%, sampled 30–80%, complete &gt;80%), exercised behind a
/// fake sink so the logic is tested with no player or JS interop — the seam the spec calls out as
/// testable. Also covers the high-water (seek-backward) and idempotent-close invariants.
/// </summary>
[TestFixture]
public class PlayTrackerTests
{
    // Captures emitted plays so assertions read the (key, bucket) the tracker classified.
    private sealed class FakeSink : IPlayEventSink
    {
        public List<(string Key, PlayBucket Bucket)> Emitted { get; } = new();
        public void EmitPlay(string trackEntryKey, PlayBucket bucket) => Emitted.Add((trackEntryKey, bucket));
    }

    private FakeSink _sink = null!;
    private PlayTracker _tracker = null!;

    [SetUp]
    public void SetUp()
    {
        _sink = new FakeSink();
        _tracker = new PlayTracker(_sink);
    }

    // Drive a full session: open, set duration, advance to a high-water position, close.
    private void PlaySession(string key, double duration, double highWater)
    {
        _tracker.OnPlaybackStarted(key);
        _tracker.SetDuration(duration);
        _tracker.OnProgress(highWater);
        _tracker.Close();
    }

    // --- Engagement floor (§1d / D2) ---

    // A long track floors on the 3-second wall (3s < 5% of 200s = 10s): under 3s sends nothing.
    [Test]
    public void Close_LongTrackUnderThreeSeconds_SendsNothing()
    {
        PlaySession("t", duration: 200, highWater: 2.5);
        Assert.That(_sink.Emitted, Is.Empty);
    }

    // The same long track at exactly the 3-second floor crosses it and records a (partial) play.
    [Test]
    public void Close_LongTrackAtThreeSecondFloor_RecordsPlay()
    {
        PlaySession("t", duration: 200, highWater: 3.0);
        Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
        Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
    }

    // A short clip floors on the percentage (5% of 40s = 2s < 3s): 1.5s is below 2s → nothing.
    [Test]
    public void Close_ShortClipUnderFivePercent_SendsNothing()
    {
        PlaySession("t", duration: 40, highWater: 1.5);
        Assert.That(_sink.Emitted, Is.Empty);
    }

    // The same short clip at the 5%-of-duration floor (2s) crosses it and records.
    [Test]
    public void Close_ShortClipAtFivePercentFloor_RecordsPlay()
    {
        PlaySession("t", duration: 40, highWater: 2.0);
        Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
    }

    // --- Bucket classification (§1a / D1) ---

    // [floor, 30%) → partial. 60/200 = 30% is the boundary, so 59.9/200 (just under) is partial.
    [Test]
    public void Close_UnderThirtyPercent_ClassifiesPartial()
    {
        PlaySession("t", duration: 200, highWater: 50);   // 25%
        Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
    }

    // Exactly 30% is the start of the sampled band [30%, 80%].
    [Test]
    public void Close_AtThirtyPercent_ClassifiesSampled()
    {
        PlaySession("t", duration: 200, highWater: 60);   // 30%
        Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
    }

    // Mid-band is sampled.
    [Test]
    public void Close_MidBand_ClassifiesSampled()
    {
        PlaySession("t", duration: 200, highWater: 120);  // 60%
        Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
    }

    // Exactly 80% is the inclusive top of the sampled band — still sampled, not complete.
    [Test]
    public void Close_AtEightyPercent_ClassifiesSampled()
    {
        PlaySession("t", duration: 200, highWater: 160);  // 80%
        Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
    }

    // Past 80% is complete.
    [Test]
    public void Close_OverEightyPercent_ClassifiesComplete()
    {
        PlaySession("t", duration: 200, highWater: 190);  // 95%
        Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
    }

    // A full listen classifies complete, and the high-water is clamped (never over 100%).
    [Test]
    public void Close_FullListen_ClassifiesComplete()
    {
        PlaySession("t", duration: 200, highWater: 200);
        Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
    }

    // --- High-water invariant (§1d: seeks never lower the mark) ---

    // Seeking backward after reaching the end still classifies complete — the max position wins.
    [Test]
    public void Close_SeekBackwardAfterEnd_StaysComplete()
    {
        _tracker.OnPlaybackStarted("t");
        _tracker.SetDuration(200);
        _tracker.OnProgress(190);   // reached 95%
        _tracker.OnProgress(20);    // seek back to 10% — must NOT lower the high-water
        _tracker.Close();
        Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
    }

    // --- Session lifecycle ---

    // Carries the track key the session opened with through to the emitted event.
    [Test]
    public void Close_RecordsTheOpenedTrackKey()
    {
        PlaySession("the-entry-key", duration: 100, highWater: 90);
        Assert.That(_sink.Emitted[0].Key, Is.EqualTo("the-entry-key"));
    }

    // Close is idempotent — a second close (e.g. organic end after the unload beacon already fired) emits nothing further.
    [Test]
    public void Close_CalledTwice_EmitsOnce()
    {
        PlaySession("t", duration: 100, highWater: 90);
        _tracker.Close();
        Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
    }

    // A session with no duration ever set (header never parsed) has no fraction to classify → nothing.
    [Test]
    public void Close_WithoutDuration_SendsNothing()
    {
        _tracker.OnPlaybackStarted("t");
        _tracker.OnProgress(30);
        _tracker.Close();
        Assert.That(_sink.Emitted, Is.Empty);
    }

    // Progress before any session opens is ignored (no open session to advance), so a later open+close
    // starts the high-water from zero.
    [Test]
    public void OnProgress_BeforeOpen_IsIgnored()
    {
        _tracker.OnProgress(150);
        PlaySession("t", duration: 200, highWater: 10);   // 5% — partial
        Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
    }

    // Opening a new session while one is still open closes (and records) the prior one — a track-switch
    // that did not route through Close still records the superseded listen. Two complete plays result.
    [Test]
    public void OnPlaybackStarted_WhileOpen_ClosesPriorSession()
    {
        _tracker.OnPlaybackStarted("first");
        _tracker.SetDuration(100);
        _tracker.OnProgress(95);                // first: complete
        _tracker.OnPlaybackStarted("second");   // supersedes — records first
        _tracker.SetDuration(100);
        _tracker.OnProgress(95);                // second: complete
        _tracker.Close();

        Assert.That(_sink.Emitted.Select(e => e.Key), Is.EqualTo(new[] { "first", "second" }));
        Assert.That(_sink.Emitted.All(e => e.Bucket == PlayBucket.Complete), Is.True);
    }

    // A replay (open the same key again after closing) is a second, independent play (§1d).
    [Test]
    public void Replay_RecordsTwoPlays()
    {
        PlaySession("t", duration: 100, highWater: 95);
        PlaySession("t", duration: 100, highWater: 95);
        Assert.That(_sink.Emitted, Has.Count.EqualTo(2));
    }
}
