using DeepDrftModels.Enums;

namespace DeepDrftPublic.Client.Services;

/// <summary>
/// Per-session play tracker (Phase 16 §2.1). Observes the player-service playback lifecycle — open on
/// playback start, advance the high-water mark on each progress tick, close on organic end / track-switch
/// / stop / page-unload — and emits at most one play event per session, classified into a completion
/// bucket, but only once the engagement floor is crossed (§1d / D2).
///
/// <para>
/// Deliberately free of any player, HTTP, or JS dependency: it takes an <see cref="IPlayEventSink"/> and
/// owns only session state and the floor/classification arithmetic, so its behaviour is unit-testable
/// against a fake sink with no interop (the spec's "testable behind one seam"). The production sink fires
/// the beacon. Instrumented at the player-service level ONLY — never the HTTP/media client — so a
/// seek-beyond-buffer re-fetch is the same play, not a new one (§1d).
/// </para>
///
/// <para>Not thread-safe: the WASM dispatcher is single-threaded, and every call originates there.</para>
/// </summary>
public sealed class PlayTracker
{
    // Engagement floor (§1d / D2): a listen counts only once playback reaches at least 3 seconds OR
    // 5% of duration, whichever is SMALLER — so a sub-60s clip floors on the percentage and anything
    // longer floors on the 3-second wall. Single tunable constant pair; one place to retune.
    private const double FloorSeconds = 3.0;
    private const double FloorFraction = 0.05;

    // Bucket thresholds (§1a / D1): partial [0, 30%), sampled [30%, 80%], complete (80%, 100%].
    private const double SampledThreshold = 0.30;
    private const double CompleteThreshold = 0.80;

    private readonly IPlayEventSink _sink;

    private string? _trackEntryKey;
    private double? _duration;
    private double _highWater;
    private bool _closed;

    public PlayTracker(IPlayEventSink sink)
    {
        _sink = sink;
    }

    /// <summary>True while a session is open (playback started, not yet closed). Drives the unload beacon.</summary>
    public bool HasOpenSession => _trackEntryKey is not null && !_closed;

    /// <summary>
    /// Open a session for the track whose playback just started. Supersedes any still-open session by
    /// closing it first — a track-switch that did not route through <see cref="Close"/> still records the
    /// prior listen. Duration is unknown at open and arrives later via <see cref="SetDuration"/>.
    /// </summary>
    public void OnPlaybackStarted(string trackEntryKey)
    {
        if (HasOpenSession)
            Close();

        _trackEntryKey = trackEntryKey;
        _duration = null;
        _highWater = 0;
        _closed = false;
    }

    /// <summary>
    /// Record the duration once the WAV header has set it. Idempotent — only the first non-positive-guarded
    /// value is taken, matching the player which sets <c>Duration</c> exactly once.
    /// </summary>
    public void SetDuration(double durationSeconds)
    {
        if (!HasOpenSession) return;
        if (_duration is null && durationSeconds > 0)
            _duration = durationSeconds;
    }

    /// <summary>
    /// Advance the high-water mark from a progress tick. Monotonic — seeking backward never lowers it,
    /// so a seek-to-end-then-back still classifies by the furthest point reached (§1d).
    /// </summary>
    public void OnProgress(double currentTime)
    {
        if (!HasOpenSession) return;
        if (currentTime > _highWater)
            _highWater = currentTime;
    }

    /// <summary>
    /// Close the open session and emit a play event if the engagement floor was crossed; below the floor
    /// nothing is sent (it was a preview/skip, §1d). Idempotent and safe to call when no session is open —
    /// organic end, track-switch, stop, dispose, and the unload beacon may all race to close, and only the
    /// first call emits.
    /// </summary>
    public void Close()
    {
        if (!HasOpenSession)
        {
            // Mark closed even if never opened so a stray late callback cannot reopen-then-emit.
            _closed = true;
            return;
        }

        var key = _trackEntryKey!;
        _closed = true;

        // Without a known duration there is no fraction to classify and no floor to test — drop the
        // session. In practice the WAV header sets duration well before any meaningful listen, so this
        // only drops listens that ended before the header parsed (i.e. effectively no listen).
        if (_duration is not { } duration || duration <= 0)
            return;

        var fraction = Math.Clamp(_highWater / duration, 0.0, 1.0);

        if (!CrossesFloor(_highWater, duration))
            return;

        _sink.EmitPlay(key, Classify(fraction));
    }

    // The floor is the SMALLER of the absolute-seconds wall and the percentage of duration (§1d / D2).
    private static bool CrossesFloor(double highWater, double duration)
    {
        var floor = Math.Min(FloorSeconds, FloorFraction * duration);
        return highWater >= floor;
    }

    private static PlayBucket Classify(double fraction)
        => fraction < SampledThreshold ? PlayBucket.Partial
            : fraction <= CompleteThreshold ? PlayBucket.Sampled
            : PlayBucket.Complete;
}
