using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Clients;
using System.Buffers;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;

namespace DeepDrftPublic.Client.Services;

public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerService
{
    // Configuration constants  
    private const int DefaultBufferSize = 32 * 1024; // 32KB chunks
    private const int NotificationThrottleMs = 100; // Throttle UI updates to max 10 per second
    
    // Adaptive chunk sizing
    private const int MinBufferSize = 16 * 1024;  // 16KB minimum
    private const int MaxBufferSize = 64 * 1024;  // 64KB maximum
    private int _currentBufferSize = DefaultBufferSize;
    private int _consecutiveSlowReads = 0;
    
    
    // Streaming state properties
    public bool IsStreamingMode { get; private set; } = false;
    public bool CanStartStreaming { get; private set; } = false;
    public bool HeaderParsed { get; private set; } = false;
    public int BufferedChunks { get; private set; } = 0;
    public bool IsSeekingBeyondBuffer { get; private set; } = false;

    private bool _streamingPlaybackStarted = false;
    private CancellationTokenSource? _streamingCancellation;
    private Task? _activeStreamingTask;
    private DateTime _lastNotification = DateTime.MinValue;
    private readonly ILogger<StreamingAudioPlayerService> _logger;
    private string? _currentTrackId;

    // Phase 16 play-session telemetry (§2.1). The tracker observes the playback lifecycle and emits at
    // most one bucketed play event per session, behind the engagement floor. Attached after construction
    // by AudioPlayerProvider (the player is not DI-registered), mirroring how QueueService binds — no
    // constructor growth propagated through DI, no construction cycle. Null when telemetry is not wired
    // (e.g. unit tests that construct the player without it), so every call is null-guarded.
    private PlayTracker? _playTracker;
    private BeaconInterop? _beacon;
    private DotNetObjectReference<StreamingAudioPlayerService>? _unloadRef;
    private string? _unloadKey;

    // One-shot guard so the play session opens exactly once per LoadTrackStreaming — never on the
    // SeekBeyondBuffer re-stream, which reuses _currentTrackId and re-runs the playback-start transition
    // with _streamingPlaybackStarted reset. A seek-beyond-buffer is the SAME play (§1d), so it must not
    // open a new session. Set true when the session opens; reset only by LoadTrackStreaming.
    private bool _sessionOpened;

    public StreamingAudioPlayerService(
        AudioInteropService audioInterop, 
        TrackMediaClient trackMediaClient,
        ILogger<StreamingAudioPlayerService> logger)
        : base(audioInterop, trackMediaClient)
    {
        _logger = logger;
    }

    /// <summary>
    /// Wire the play-session tracker and beacon transport into the player after construction (Phase 16
    /// §2.1). Called once by <c>AudioPlayerProvider</c>. Kept off the constructor deliberately: the player
    /// is built with <c>new</c> by the provider (not DI), so threading the tracker through the constructor
    /// would force the provider to resolve it too — instead the provider injects the tracker's collaborators
    /// and hands a built tracker here, the same post-construction binding QueueService uses. Also registers
    /// the page-unload handler so a mid-play tab-close still records the play via sendBeacon.
    /// </summary>
    public void AttachTracker(PlayTracker tracker, BeaconInterop beacon)
    {
        _playTracker = tracker;
        _beacon = beacon;

        _unloadRef = DotNetObjectReference.Create(this);
        _unloadKey = PlayerId;
        // Fire-and-forget: registration only needs to have happened before the listener leaves; it
        // never gates playback. A failure simply means tab-close mid-play isn't recorded.
        _ = _beacon.RegisterUnloadAsync(_unloadKey, _unloadRef, nameof(OnPageUnload));
    }

    /// <summary>
    /// Close the open play session as the page unloads (pagehide / visibility→hidden). Invoked
    /// synchronously from the beacon's unload handler so the session's beacon is queued before the page
    /// freezes. <see cref="PlayTracker.Close"/> is idempotent, so a later organic close is a no-op.
    /// </summary>
    [JSInvokable]
    public void OnPageUnload() => _playTracker?.Close();

    // Advance the play-session high-water mark on each progress tick (§2.1). Seeking backward never
    // lowers it — the tracker takes the max.
    protected override void OnProgressTick(double currentTime) => _playTracker?.OnProgress(currentTime);

    // Organic end-of-stream closes the session; the bucket reflects the high-water fraction reached.
    protected override void OnPlaybackEnded() => _playTracker?.Close();

    public override async Task SelectTrack(TrackDto track)
    {
        await SelectTrackStreaming(track);
    }

    /// <inheritdoc />
    public async Task WarmAudioContext()
    {
        await EnsureInitializedAsync();
        await _audioInterop.EnsureAudioContextReady(PlayerId);
    }

    public async Task SelectTrackStreaming(TrackDto track)
    {
        await EnsureInitializedAsync();

        // Resume AudioContext immediately on track selection (user interaction) to avoid clicks later
        await _audioInterop.EnsureAudioContextReady(PlayerId);

        await NotifyTrackSelected();

        await LoadTrackStreaming(track);
        await NotifyStateChanged();
    }

    /// <inheritdoc />
    public async Task StageTrack(TrackDto track)
    {
        // Pure state: expose the track as current so the bar shows it ready, but do NOT
        // initialize the player, resume the AudioContext, or start streaming. Those steps
        // require a user gesture and run on the first play click via SelectTrackStreaming.
        CurrentTrack = track;
        ErrorMessage = null;
        await NotifyStateChanged();
    }

    private async Task LoadTrackStreaming(TrackDto track)
    {
        // Always reset to clean state before loading new track. ResetToIdle
        // both cancels and awaits any in-flight streaming loop, so by the time
        // we return from it the previous loop is guaranteed to have exited and
        // there is no risk of interleaved ProcessStreamingChunk calls against
        // the single-instance JS StreamDecoder.
        await ResetToIdle();

        // Save track ID for seek operations
        _currentTrackId = track.EntryKey;
        // A fresh load is a fresh play candidate (§1d: replays = multiple plays). Arm the
        // one-shot session-open guard; the session actually opens at the playback-start transition
        // below (a track that fails to load never reaches it, so it does not count).
        _sessionOpened = false;
        // Expose to UI immediately — Now-Playing surfaces should reflect the selected
        // track while it's still loading, not only after playback starts.
        CurrentTrack = track;

        // Create new cancellation token for this streaming operation. Capture it in a local
        // so the catch/finally can compare identity against _streamingCancellation: a seek
        // replaces _streamingCancellation with its own seekCts before this load's continuation
        // resumes on the single-threaded WASM dispatcher, and we must not clobber the seek's state.
        var loadCts = new CancellationTokenSource();
        _streamingCancellation = loadCts;

        // Fetch the waveform profile alongside the audio. Fire-and-forget against the same
        // streaming token so a track switch abandons it; it only updates display state and must
        // never gate or fail the audio load (a missing profile yields the flat-seekbar fallback).
        _ = LoadWaveformProfileAsync(track.EntryKey, loadCts.Token);

        try
        {
            // Set state to indicate loading has started
            ErrorMessage = null;
            LoadProgress = 0;
            IsLoading = true;
            IsStreamingMode = true;

            // Reset adaptive buffer sizing
            _currentBufferSize = DefaultBufferSize;
            _consecutiveSlowReads = 0;

            await NotifyStateChanged();

            // Pass the streaming token to the HTTP layer so a navigation/track switch
            // aborts the server connection instead of leaving it draining bytes.
            var mediaResult = await _trackMediaClient.GetTrackMedia(
                track.EntryKey,
                byteOffset: 0,
                cancellationToken: loadCts.Token);
            if (!mediaResult.Success)
            {
                var technicalError = mediaResult.GetMessage();
                _logger.LogError("Failed to get track media for {TrackId}: {Error}",
                    track.EntryKey, technicalError);
                ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
                return;
            }

            if (mediaResult.Value == null)
            {
                const string technicalError = "No audio returned from server";
                _logger.LogError("No audio data returned for track {TrackId}", track.EntryKey);
                ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
                return;
            }

            using var audio = mediaResult.Value;

            // Initialize streaming mode with content length and media type (drives
            // JS format-decoder selection).
            var streamingResult = await _audioInterop.InitializeStreaming(PlayerId, audio.ContentLength, audio.ContentType);
            if (!streamingResult.Success)
            {
                var technicalError = $"Failed to initialize streaming: {streamingResult.Error}";
                _logger.LogError("Streaming initialization failed for track {TrackId}: {Error}",
                    track.EntryKey, technicalError);
                ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
                return;
            }

            _activeStreamingTask = StreamAudioWithEarlyPlayback(audio, loadCts.Token);
            await _activeStreamingTask;
        }
        catch (OperationCanceledException) when (loadCts.IsCancellationRequested)
        {
            // Cancellation is expected when this load was superseded (track switch or seek).
            // The when filter ensures HttpClient timeout OCEs — where loadCts was NOT
            // cancelled — fall through to the error handler below instead of being swallowed.
            _logger.LogDebug("Audio streaming cancelled for track {TrackId}", track.EntryKey);

            // Only reset streaming state if this load is still the active operation. A seek
            // in flight has already replaced _streamingCancellation with its own seekCts and
            // owns IsLoaded/IsStreamingMode; clobbering them here corrupts the seek mid-flight.
            if (ReferenceEquals(_streamingCancellation, loadCts))
            {
                IsLoaded = false;
                IsStreamingMode = false;
            }
        }
        catch (Exception ex)
        {
            StreamingErrorHandler.LogError(_logger, ex, "LoadTrackStreaming", track.EntryKey);
            ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
            LoadProgress = 0;
            IsLoaded = false;
            IsStreamingMode = false;
        }
        finally
        {
            IsLoading = false;
            // Only notify if this load is still the active operation. A superseding seek
            // owns state notifications; firing here mid-seek would push a stale snapshot.
            if (ReferenceEquals(_streamingCancellation, loadCts))
            {
                await NotifyStateChanged();
            }
        }
    }

    /// <summary>
    /// Fetches and decodes the track's waveform loudness profile, then notifies state so the
    /// seek zone re-renders with real bars. Best-effort: a 404 (no stored profile) or any other
    /// failure simply leaves <see cref="AudioPlayerService.WaveformProfile"/> null, which the
    /// WaveformSeeker renders as a flat-but-seekable fallback. Never throws into the load path.
    /// </summary>
    private async Task LoadWaveformProfileAsync(string entryKey, CancellationToken cancellationToken)
    {
        WaveformProfile = null;

        try
        {
            var result = await _trackMediaClient.GetWaveformProfileAsync(entryKey, cancellationToken);
            if (cancellationToken.IsCancellationRequested) return;

            if (result.Success && result.Value is { } dto)
            {
                WaveformProfile = DecodeWaveformProfile(dto);
                await NotifyStateChanged();
            }
        }
        catch (OperationCanceledException)
        {
            // Track switched or stopped before the profile arrived — nothing to surface.
        }
        catch (Exception ex)
        {
            // A failed profile fetch must not disturb playback; log and fall back to flat bars.
            _logger.LogDebug(ex, "Failed to load waveform profile for {EntryKey}", entryKey);
        }
    }

    /// <summary>
    /// Decodes a <see cref="WaveformProfileDto"/> (base64 of byte[BucketCount], each 0..255) into
    /// a normalized double[] in [0, 1]. Returns null if the payload is malformed so callers treat
    /// it as "no profile" rather than rendering garbage bars.
    /// </summary>
    private static double[]? DecodeWaveformProfile(WaveformProfileDto dto)
    {
        if (string.IsNullOrEmpty(dto.Data)) return null;

        byte[] bytes;
        try
        {
            bytes = Convert.FromBase64String(dto.Data);
        }
        catch (FormatException)
        {
            return null;
        }

        if (bytes.Length == 0) return null;

        var profile = new double[bytes.Length];
        for (var i = 0; i < bytes.Length; i++)
        {
            profile[i] = bytes[i] / 255.0;
        }
        return profile;
    }

    private async Task StreamAudioWithEarlyPlayback(TrackMediaResponse audio, CancellationToken cancellationToken)
    {
        byte[]? buffer = null;
        try
        {
            long totalBytesRead = 0;
            buffer = ArrayPool<byte>.Shared.Rent(MaxBufferSize); // Rent larger buffer to accommodate adaptive sizing
            int currentBytes;
            var readTimer = System.Diagnostics.Stopwatch.StartNew();
            
            do
            {
                readTimer.Restart();
                currentBytes = await audio.Stream.ReadAsync(buffer, 0, _currentBufferSize, cancellationToken);
                readTimer.Stop();
                
                // Adapt buffer size based on read performance
                AdaptBufferSize(currentBytes, readTimer.ElapsedMilliseconds);
                
                if (currentBytes > 0)
                {
                    totalBytesRead += currentBytes;
                    
                    // Always slice to the exact number of bytes read. The pooled buffer
                    // is rented at MaxBufferSize and may carry stale bytes past
                    // currentBytes from a prior rental — handing the full array to JS
                    // interop would serialise that garbage into the audio stream.
                    var actualBuffer = buffer.AsSpan(0, currentBytes).ToArray();

                    // Process chunk for streaming
                    var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer);
                    if (!chunkResult.Success)
                    {
                        var error = $"Failed to process streaming chunk: {chunkResult.Error}";
                        _logger.LogWarning("Chunk processing failed: {Error}", error);
                        throw new Exception(error);
                    }
                    
                    // Update streaming state
                    CanStartStreaming = chunkResult.CanStartStreaming;
                    HeaderParsed = chunkResult.HeaderParsed;
                    BufferedChunks = chunkResult.BufferCount;

                    // Set duration from WAV header when available (only set once)
                    if (chunkResult.Duration.HasValue && Duration == null)
                    {
                        Duration = chunkResult.Duration.Value;
                        _logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration);
                        // Feed the same once-only duration to the play session so it can compute the
                        // completion fraction at close. Safe before/after session open — SetDuration
                        // is a no-op when no session is open and idempotent otherwise.
                        _playTracker?.SetDuration(chunkResult.Duration.Value);
                    }

                    // Start playback as soon as we can
                    if (!_streamingPlaybackStarted && CanStartStreaming)
                    {
                        var playbackResult = await _audioInterop.StartStreamingPlayback(PlayerId);
                        if (playbackResult.Success)
                        {
                            _streamingPlaybackStarted = true;
                            IsPlaying = true;
                            IsPaused = false;
                            IsLoaded = true; // Track is loaded and ready to play (even if still downloading)
                            ErrorMessage = null;

                            // Open the play session exactly once per load, at the moment playback truly
                            // begins (§2.1). The _sessionOpened guard keeps the SeekBeyondBuffer re-stream
                            // — which re-enters this transition with _streamingPlaybackStarted reset —
                            // from opening a second session for the same play. Duration may already be
                            // known from a prior chunk, so re-feed it after opening.
                            if (!_sessionOpened && _currentTrackId is { } trackKey)
                            {
                                _sessionOpened = true;
                                _playTracker?.OnPlaybackStarted(trackKey);
                                if (Duration is { } d)
                                    _playTracker?.SetDuration(d);
                            }

                            await NotifyStateChanged(); // Immediate notification for critical state change
                        }
                        else
                        {
                            var technicalError = $"Failed to start streaming playback: {playbackResult.Error}";
                            _logger.LogError("Failed to start playback: {Error}", technicalError);
                            ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
                        }
                    }
                    
                    // Update progress
                    if (audio.ContentLength > 0)
                    {
                        LoadProgress = Math.Min(1.0, (double)totalBytesRead / audio.ContentLength);
                    }
                    
                    await ThrottledNotifyStateChanged();
                }
            } while (currentBytes > 0);

            // Notify the JS decoder that the stream is finished. When the server omits
            // Content-Length the StreamDecoder cannot determine completion via byte counting
            // alone; this explicit signal ensures the tail-decoding path (streamComplete=true)
            // fires regardless of whether Content-Length was present.
            await _audioInterop.MarkStreamCompleteAsync(PlayerId);

            // Mark as fully loaded
            LoadProgress = 1.0;
            await NotifyStateChanged();
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // Cancellation is expected during track switch or seek — propagate cleanly.
            throw;
        }
        catch (Exception ex)
        {
            StreamingErrorHandler.LogError(_logger, ex, "StreamAudioWithEarlyPlayback");
            ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
            LoadProgress = 0;
            IsLoaded = false;
            IsStreamingMode = false;
            await NotifyStateChanged();
            throw;
        }
        finally
        {
            if (buffer != null)
            {
                ArrayPool<byte>.Shared.Return(buffer);
            }
        }
    }

    /// <summary>
    /// In streaming mode, Stop fully resets to Idle state since audio data is consumed.
    /// This is equivalent to Unload for streaming playback.
    /// </summary>
    public override async Task Stop()
    {
        // In streaming mode, Stop = Unload (data is consumed, can't replay)
        await ResetToIdle();
    }

    /// <summary>
    /// Fully resets the player to Idle state, ready for a new track.
    /// </summary>
    public override async Task Unload()
    {
        await ResetToIdle();
    }

    /// <summary>
    /// Override Seek to handle seek-beyond-buffer for streaming mode.
    /// </summary>
    public override async Task Seek(double position)
    {
        if (!IsLoaded || !IsStreamingMode) return;

        try
        {
            var result = await _audioInterop.SeekAsync(PlayerId, position);

            if (result.Success)
            {
                if (result.SeekBeyondBuffer && result.ByteOffset >= 0)
                {
                    // Need to load new stream from offset
                    _logger.LogInformation("Seeking beyond buffer to {Position:F2}s, byte offset: {ByteOffset}",
                        position, result.ByteOffset);
                    await SeekBeyondBuffer(position, result.ByteOffset);
                }
                else
                {
                    // Seek within buffer succeeded
                    CurrentTime = position;
                    ErrorMessage = null;
                    await NotifyStateChanged();
                }
            }
            else
            {
                ErrorMessage = $"Seek error: {result.Error}";
                await NotifyStateChanged();
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error seeking to position {Position}", position);
            ErrorMessage = $"Error seeking: {ex.Message}";
            await NotifyStateChanged();
        }
    }

    /// <summary>
    /// Handle seeking beyond the currently buffered content by requesting a new stream from offset.
    /// </summary>
    private async Task SeekBeyondBuffer(double seekPosition, long byteOffset)
    {
        if (string.IsNullOrEmpty(_currentTrackId))
        {
            ErrorMessage = "Cannot seek - no track loaded";
            return;
        }

        IsSeekingBeyondBuffer = true;

        // Cancel the current streaming loop AND wait for it to fully exit before
        // starting a new one. The previous loop's pending ReadAsync will throw
        // OperationCanceledException asynchronously; if we kick off a new loop
        // immediately, both can race against the single-instance JS StreamDecoder
        // and corrupt decode state. Draining here is the load-bearing guarantee.
        //
        // Invariant: any caller that supersedes a load WITHOUT wanting the load's
        // state reset must assign its own CTS to _streamingCancellation *before*
        // its first await. LoadTrackStreaming's OCE continuation fires during the
        // drain await on the shared _activeStreamingTask; it resets IsLoaded/
        // IsStreamingMode only when _streamingCancellation still equals its loadCts.
        // Assigning seekCts synchronously here makes that identity check fail, so
        // the seek's state survives. (ResetToIdle deliberately does NOT do this —
        // it wants the reset, and nulls _streamingCancellation only after the drain.)
        var oldCts = _streamingCancellation;
        var seekCts = new CancellationTokenSource();
        _streamingCancellation = seekCts;
        oldCts?.Cancel();
        await DrainActiveStreamingTaskAsync();
        oldCts?.Dispose();

        try
        {
            // Update UI immediately
            CurrentTime = seekPosition;
            await NotifyStateChanged();

            // Request new stream from offset
            var mediaResult = await _trackMediaClient.GetTrackMedia(
                _currentTrackId,
                byteOffset,
                cancellationToken: seekCts.Token);
            if (!mediaResult.Success || mediaResult.Value == null)
            {
                var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position";
                _logger.LogError("Failed to get track media from offset {Offset}: {Error}", byteOffset, technicalError);
                ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError);
                IsSeekingBeyondBuffer = false;
                return;
            }

            using var audio = mediaResult.Value;

            // Reinitialize JS player for offset streaming
            var reinitResult = await _audioInterop.ReinitializeFromOffset(PlayerId, audio.ContentLength, seekPosition);
            if (!reinitResult.Success)
            {
                _logger.LogError("Failed to reinitialize for offset streaming: {Error}", reinitResult.Error);
                ErrorMessage = "Failed to seek to position";
                IsSeekingBeyondBuffer = false;
                return;
            }

            // Reset streaming state for new stream
            _streamingPlaybackStarted = false;
            CanStartStreaming = false;
            HeaderParsed = false;
            BufferedChunks = 0;

            // Stream audio from offset
            _activeStreamingTask = StreamAudioWithEarlyPlayback(audio, seekCts.Token);
            await _activeStreamingTask;

            IsSeekingBeyondBuffer = false;
        }
        catch (OperationCanceledException) when (seekCts.IsCancellationRequested)
        {
            // Another seek or stop interrupted this one. Only clear the flag if we are
            // still the active seek — if _streamingCancellation has been replaced, a
            // newer seek is in progress and owns the flag.
            _logger.LogDebug("Seek beyond buffer cancelled");
            if (ReferenceEquals(_streamingCancellation, seekCts))
            {
                IsSeekingBeyondBuffer = false;
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error during seek beyond buffer to position {Position}", seekPosition);
            ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message);
            IsSeekingBeyondBuffer = false;
            await NotifyStateChanged();
        }
    }

    /// <summary>
    /// Single method to reset all state - called by both Stop and Unload.
    /// </summary>
    private async Task ResetToIdle()
    {
        // 0. Close any open play session BEFORE tearing down (§2.1). ResetToIdle is the single funnel
        //    for stop / unload / dispose / track-switch (a new LoadTrackStreaming calls it first), so a
        //    superseded listen is recorded here with its high-water bucket. Close is idempotent — if the
        //    session already closed organically or via the unload beacon, this is a no-op.
        _playTracker?.Close();
        _sessionOpened = false;

        // 1. Cancel any ongoing streaming operation and wait for it to exit
        //    before tearing down JS state. Otherwise the loop's pending
        //    ProcessStreamingChunk call can land after StopAsync/UnloadAsync.
        _streamingCancellation?.Cancel();
        await DrainActiveStreamingTaskAsync();
        _streamingCancellation?.Dispose();
        _streamingCancellation = null;

        // 2. Tell JS to stop and unload
        try
        {
            await _audioInterop.StopAsync(PlayerId);
            await _audioInterop.UnloadAsync(PlayerId);
        }
        catch
        {
            // Ignore JS errors during cleanup
        }

        // 3. Reset ALL state to Idle
        IsPlaying = false;
        IsPaused = false;
        IsLoaded = false;
        IsLoading = false;
        CurrentTime = 0;
        Duration = null;
        LoadProgress = 0;
        ErrorMessage = null;
        CurrentTrack = null;
        WaveformProfile = null;

        // 4. Reset streaming-specific state
        IsStreamingMode = false;
        CanStartStreaming = false;
        HeaderParsed = false;
        BufferedChunks = 0;
        _streamingPlaybackStarted = false;
        IsSeekingBeyondBuffer = false;
        _currentTrackId = null;

        await NotifyStateChanged();
    }

    /// <summary>
    /// Wait for the previously-started streaming loop to fully exit. The caller
    /// must have already cancelled <see cref="_streamingCancellation"/>. Swallows
    /// the expected OperationCanceledException; any other exception was already
    /// surfaced through the loop's own catch block, so we ignore it here too.
    /// </summary>
    private async Task DrainActiveStreamingTaskAsync()
    {
        var task = _activeStreamingTask;
        if (task == null) return;

        try
        {
            await task;
        }
        catch (OperationCanceledException)
        {
            // Expected when we cancelled the loop ourselves.
        }
        catch
        {
            // Any other failure was already logged inside the loop.
        }
        finally
        {
            // Only clear if we are still the active task — a concurrent caller
            // may have started a new stream while we were draining the old one.
            if (ReferenceEquals(_activeStreamingTask, task))
            {
                _activeStreamingTask = null;
            }
        }
    }

    private async Task ThrottledNotifyStateChanged()
    {
        var now = DateTime.UtcNow;
        if ((now - _lastNotification).TotalMilliseconds >= NotificationThrottleMs)
        {
            _lastNotification = now;
            await NotifyStateChanged();
        }
    }

    /// <summary>
    /// On component unmount we must cancel the in-flight streaming loop and tear
    /// down JS callbacks before the JS side's setInterval fires again with a
    /// stale DotNetObjectReference. ResetToIdle covers cancellation + JS stop
    /// + state reset; the base then disposes the JS player and its callbacks.
    /// </summary>
    public override async ValueTask DisposeAsync()
    {
        try
        {
            // ResetToIdle closes any open play session, so a dispose mid-play still records the listen.
            await ResetToIdle();
        }
        catch
        {
            // Disposal must not throw; any failure here is best-effort cleanup.
        }

        // Detach the page-unload handler so the torn-down circuit is never invoked, then release the
        // self-reference. Best-effort — the JS side tolerates an absent key.
        if (_unloadKey is not null && _beacon is not null)
        {
            try { await _beacon.UnregisterUnloadAsync(_unloadKey); }
            catch { /* best-effort */ }
        }
        _unloadRef?.Dispose();
        _unloadRef = null;

        await base.DisposeAsync();
    }

    private void AdaptBufferSize(int bytesRead, long readTimeMs)
    {
        // Adaptive buffer sizing based on network performance
        if (readTimeMs > 100) // Slow read (>100ms)
        {
            _consecutiveSlowReads++;
            if (_consecutiveSlowReads >= 3 && _currentBufferSize > MinBufferSize)
            {
                // Reduce buffer size for slow connections
                _currentBufferSize = Math.Max(MinBufferSize, _currentBufferSize / 2);
                _consecutiveSlowReads = 0;
            }
        }
        else if (readTimeMs < 20 && bytesRead == _currentBufferSize) // Fast read, buffer fully utilized
        {
            _consecutiveSlowReads = 0;
            if (_currentBufferSize < MaxBufferSize)
            {
                // Increase buffer size for fast connections
                _currentBufferSize = Math.Min(MaxBufferSize, _currentBufferSize * 2);
            }
        }
        else
        {
            _consecutiveSlowReads = 0;
        }
    }
}