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

namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;

public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
{
    [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
    [CascadingParameter] public IQueueService? QueueService { get; set; }
    [Parameter] public bool Fixed { get; set; } = false;

    [Parameter] public EventCallback<bool> OnMinimized { get; set; }

    [Inject] private IJSRuntime JsRuntime { get; set; } = default!;

    // Theater Mode (Phase 20). Property-injected (no constructor growth) so the bar can read
    // TheaterMode to mount the "now showing" band and re-render when the flag flips. The toggle lives on
    // the detail pages; the bar only observes — single source, multiple observers (§6).
    [Inject] private WaveformVisualizerControlState VisualizerControlState { get; set; } = default!;

    private bool _isMinimized = true;
    private bool _isSeeking = false;
    private double _seekPosition = 0;
    private bool _queueOpen = false;
    private IStreamingPlayerService? _subscribedService;
    private IQueueService? _subscribedQueue;
    private bool _subscribedToVisualizerState;

    // Spacer-height bridge: the expanded dock is position:fixed, so MainLayout's
    // spacer reserves its space. We mirror this element's live height into a CSS
    // var via a ResizeObserver (see Interop/layout/spacer.ts) rather than a static
    // value, because the player reflows across breakpoints and grows with the
    // error banner.
    //
    // _miniDock is the minimized FAB container. We observe it in minimized state so
    // --player-height stays non-zero (the FAB's actual height) and the WaveformVisualizer
    // clips to the top of the FAB rather than extending to the viewport bottom (fix §1).
    // The player-spacer's .minimized class uses a hardcoded 60px and ignores the var,
    // so publishing the FAB height here does not regress the spacer.
    private ElementReference _playerRoot;
    private ElementReference _miniDock;
    private ElementReference _lastObservedElement;
    private IJSObjectReference? _spacerModule;
    private bool _spacerObserved;

    // Fixed-embed → host resize handshake (OQ1 Option A). When the inline panel collapses/expands we
    // measure the player's live height and post it to the host so the iframe resizes to match. The
    // dirty flag defers the post to OnAfterRenderAsync so the DOM reflects the new panel state first.
    private IJSObjectReference? _embedModule;
    private bool _embedHeightDirty;
    private bool _embedHeightPosted;

    private bool IsLoaded => PlayerService?.IsLoaded ?? false;
    private bool IsLoading => PlayerService?.IsLoading ?? false;

    /// <summary>
    /// A track is staged when it has been selected as the current track but not yet loaded into
    /// the audio context (the embed's pre-gesture state). The first play click loads + plays it.
    /// </summary>
    private bool IsStaged => PlayerService is { IsLoaded: false, IsLoading: false, CurrentTrack: not null };

    /// <summary>Play is available once a track is loaded, or staged and waiting for the first gesture.</summary>
    private bool CanPlay => IsLoaded || IsStaged;
    private bool IsStreaming => PlayerService?.CanStartStreaming ?? false;
    private bool IsStreamingMode => PlayerService?.IsStreamingMode ?? false;
    private double? Duration => PlayerService?.Duration;
    private TrackDto? CurrentTrack => PlayerService?.CurrentTrack;
    private double Volume => PlayerService?.Volume ?? 0;
    private double LoadProgress => PlayerService?.LoadProgress ?? 0;
    private string? ErrorMessage => PlayerService?.ErrorMessage;

    // Skip affordances reflect live queue state. With no queue (null) or an empty queue both are
    // false, so the buttons sit disabled and the bar behaves exactly as it did before the queue.
    private bool HasNext => QueueService?.HasNext ?? false;
    private bool HasPrevious => QueueService?.HasPrevious ?? false;

    // Queue button gating. The button appears in BOTH modes when a queue is loaded, mirroring the
    // skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue self, so a
    // single-track embed (empty queue) shows no button and no panel (UC6). In docked mode it toggles
    // the overlay; in Fixed mode it collapses/expands the inline panel (OQ1 Option A).
    private bool HasQueue => (QueueService?.Items.Count ?? 0) > 0;
    private bool ShowQueueButton => HasQueue;

    // The docked overlay mounts only in docked mode; the Fixed embed renders its inline panel instead.
    private bool ShowDockedOverlay => !Fixed && HasQueue;

    // The Fixed-mode inline panel: always shown (read-only, C3) when a release embed has a queue.
    // Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
    private bool ShowFixedPanel => Fixed && HasQueue;

    // Cached snapshot of the queue list (bug #4 fix). QueueService.Items returns the service's
    // backing list by reference, so passing it straight through means Blazor parameter diffing sees
    // an unchanged reference after an in-place Clear/remove/reorder and the child (QueueList /
    // MudDropContainer) keeps its stale snapshot until reopened. We snapshot on first access and
    // rebuild in OnQueueChanged, so every real mutation hands the child a NEW reference while
    // progress-tick re-renders (the frequent path) reuse the cached one without allocating.
    private IReadOnlyList<TrackDto>? _queueItemsCache;
    private IReadOnlyList<TrackDto> QueueItems =>
        _queueItemsCache ??= QueueService is null ? [] : QueueService.Items.ToList();
    private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;

    // Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
    // up-next out of the box; the Queue button collapses it to let the viewer reclaim iframe space.
    private bool _fixedPanelOpen = true;

    // The Queue button's "open" state differs by mode: docked tracks the overlay, Fixed tracks the
    // inline panel's expanded state. One button, mode-appropriate meaning.
    private bool QueueButtonOpen => Fixed ? _fixedPanelOpen : _queueOpen;

    /// <summary>
    /// Display time - shows seek position while dragging, otherwise current playback time.
    /// </summary>
    private double DisplayTime => _isSeeking ? _seekPosition : (PlayerService?.CurrentTime ?? 0);
    private string PlayerModeClass => Fixed ? "player-fixed" : "player-dock";

    protected override void OnParametersSet()
    {
        if (Fixed)
        {
            _isMinimized = false;
        }
        
        // PlayerService is cascaded by AudioPlayerProvider; once it arrives,
        // wire our track-selection handler. The provider owns OnStateChanged —
        // we intentionally do NOT wrap or replace it. Because the cascade is
        // IsFixed, the provider's re-render does NOT reliably re-render this bar
        // (it has no incoming parameters that change), so we subscribe to the
        // multicast StateChanged side-channel to re-render ourselves.
        if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
        {
            if (_subscribedService != null)
                _subscribedService.StateChanged -= OnPlayerStateChanged;

            PlayerService.OnTrackSelected = new EventCallback(this, Expand);
            PlayerService.StateChanged += OnPlayerStateChanged;
            _subscribedService = PlayerService;
        }

        // The queue cascade is also IsFixed, so re-render the skip affordances off its own
        // change signal — same posture as the player StateChanged subscription above.
        if (QueueService != null && !ReferenceEquals(QueueService, _subscribedQueue))
        {
            if (_subscribedQueue != null)
                _subscribedQueue.QueueChanged -= OnQueueChanged;

            QueueService.QueueChanged += OnQueueChanged;
            _subscribedQueue = QueueService;
        }

        // Theater Mode (Phase 20 §7): re-render the bar when TheaterMode flips so the "now showing" band
        // appears/disappears. VisualizerControlState is injected (one stable scoped instance per session),
        // so the subscribe is once-only — same idempotent subscribe-here / unsubscribe-on-dispose shape.
        if (!_subscribedToVisualizerState)
        {
            VisualizerControlState.Changed += OnVisualizerStateChanged;
            _subscribedToVisualizerState = true;
        }
    }

    private void OnVisualizerStateChanged() => InvokeAsync(StateHasChanged);

    private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);

    private void OnQueueChanged()
    {
        // Invalidate the snapshot so QueueItems rebuilds a fresh list on the next render.
        // This gives Blazor a new reference on every real mutation (bug #4 reactivity preserved)
        // while progress-tick re-renders that don't go through here keep the cached reference.
        _queueItemsCache = null;

        // If a removal emptied the queue while the overlay was open, the button disappears (AC1) — close
        // the overlay so it cannot strand open over an empty queue. The button gate hides the overlay
        // mount too, so this keeps state and view consistent.
        if (_queueOpen && (QueueService?.Items.Count ?? 0) == 0)
            _queueOpen = false;

        InvokeAsync(StateHasChanged);
    }

    private async Task SkipNext()
    {
        if (QueueService == null) return;
        await QueueService.Next();
    }

    private async Task SkipPrevious()
    {
        if (QueueService == null) return;
        await QueueService.Previous();
    }

    // Docked: toggle the overlay. Fixed: collapse/expand the inline panel and flag a height re-post so
    // the host iframe resizes to match the new panel state (OQ1 Option A). The post happens in
    // OnAfterRenderAsync (below) once the DOM reflects the new state, then degrades safely — the host
    // listener may simply not be present (Option B's behaviour).
    private void ToggleQueue()
    {
        if (Fixed)
        {
            _fixedPanelOpen = !_fixedPanelOpen;
            _embedHeightDirty = true;
            return;
        }

        _queueOpen = !_queueOpen;
    }

    private void CloseQueue() => _queueOpen = false;

    // Reorder/remove/clear are interop-free engine mutations (C2/C5): they never re-stream or interrupt
    // the playing track. QueueChanged re-renders the bar and the overlay's list.
    private void OnQueueReorder((int FromIndex, int ToIndex) move) =>
        QueueService?.Move(move.FromIndex, move.ToIndex);

    private void OnQueueRemove(int index) => QueueService?.RemoveAt(index);

    private void ClearUpcoming() => QueueService?.ClearUpcoming();

    // Jump to a row already in the queue. Under the deque model PlayRelease prepends (it is a PLAY,
    // not an in-place seek), so a jump cannot route through it without duplicating the queue. JumpTo
    // moves the pointer to the chosen row and streams it once — preserving deque order. This is the one
    // queue action besides PLAY/skip that touches playback.
    private async Task OnQueueJump(int index)
    {
        if (QueueService == null) return;
        await QueueService.JumpTo(index);
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        // Fixed embed: post the live player height to the host so the iframe sizes to the panel. We
        // post on the first render (so the host snaps to the expanded panel rather than the snippet's
        // initial guess) and whenever the panel is collapsed/expanded (_embedHeightDirty). No spacer/
        // clip here — the embed is in normal flow.
        if (Fixed)
        {
            if (ShowFixedPanel && (!_embedHeightPosted || _embedHeightDirty))
            {
                _embedHeightDirty = false;
                _embedHeightPosted = true;
                await PostEmbedHeight();
            }
            return;
        }

        // For the docked player: we observe in BOTH expanded and minimized states
        // so --player-height always reflects the live height of whichever element
        // is visible. This keeps the WaveformVisualizer clipped to the top of
        // the footer in both states (fix §1).
        //   expanded  → observe _playerRoot (full player bar, reflows across breakpoints)
        //   minimized → observe _miniDock   (floating FAB container, ~56–60px)
        // The player-spacer's .minimized class uses a hardcoded height and ignores
        // the var, so publishing the FAB height here does not regress the spacer.

        var elementToObserve = _isMinimized ? _miniDock : _playerRoot;
        var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id;
        if (alreadyOnThisElement) return;

        var module = await GetSpacerModuleAsync();
        if (module is null) return;

        await module.InvokeVoidAsync("observe", elementToObserve);
        _spacerObserved = true;
        _lastObservedElement = elementToObserve;
    }

    private async Task<IJSObjectReference?> GetSpacerModuleAsync()
    {
        try
        {
            return _spacerModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
                "import", "./js/layout/spacer.js");
        }
        catch (JSException)
        {
            // Module failed to load — the spacer falls back to 0px (no overlap
            // guard, but the player still works). Nothing actionable here.
            return null;
        }
    }

    // Measure the player root's live height and post it to the host page (OQ1 Option A). Best-effort:
    // a missing module or a host that ignores the message just means no outer resize (Option B value).
    private async Task PostEmbedHeight()
    {
        var module = await GetEmbedModuleAsync();
        if (module is null) return;

        try
        {
            await module.InvokeVoidAsync("postHeight", _playerRoot);
        }
        catch (JSException)
        {
            // Runtime gone or element detached mid-teardown — nothing actionable.
        }
    }

    private async Task<IJSObjectReference?> GetEmbedModuleAsync()
    {
        try
        {
            return _embedModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
                "import", "./js/embed/embed-frame.js");
        }
        catch (JSException)
        {
            // Module failed to load — the panel still renders and toggles; only the outer resize is lost.
            return null;
        }
    }

    private async Task Expand() => await SetMinimized(false);

    /// <summary>
    /// The single assignment site for <see cref="_isMinimized"/>. Guards no-op transitions,
    /// fires <see cref="OnMinimized"/> so MainLayout's spacer class stays in sync, and renders
    /// so OnAfterRenderAsync re-evaluates the ResizeObserver on every transition path.
    /// The <c>Fixed</c> branch in OnParametersSet intentionally bypasses this — it is a
    /// prerender/parameter pass, not a user-driven transition, and the embed host has no spacer.
    /// </summary>
    private async Task SetMinimized(bool value)
    {
        if (_isMinimized == value) return;

        _isMinimized = value;
        if (OnMinimized.HasDelegate) await OnMinimized.InvokeAsync(value);
        StateHasChanged();
    }

    private async Task TogglePlayPause()
    {
        if (PlayerService == null) return;

        // Gesture-gated start: a staged-but-unloaded track (the embed autoplay path) is loaded on
        // the first play click — the user gesture the browser requires before audio can start.
        if (IsStaged)
        {
            // Release embed: the queue is armed with the whole release. Route the first gesture through
            // the queue so it takes over (streams track 0 and auto-advances) rather than streaming the
            // staged track in isolation. Single-track embeds leave the queue disarmed and fall through
            // to the direct stream below — unchanged.
            if (QueueService is { IsArmed: true })
            {
                await QueueService.Start();
                return;
            }

            await PlayerService.SelectTrackStreaming(PlayerService.CurrentTrack!);
            return;
        }

        await PlayerService.TogglePlayPause();
    }

    private async Task Stop()
    {
        if (PlayerService == null) return;
        await PlayerService.Stop();
    }

    private void OnSeekStart()
    {
        if (PlayerService == null) return;
        _isSeeking = true;
        _seekPosition = PlayerService.CurrentTime;
    }

    private void OnSeekChange(double position)
    {
        _seekPosition = position;
        StateHasChanged();
    }

    private async Task OnSeekEnd(double position)
    {
        if (PlayerService == null) return;
        _isSeeking = false;
        await PlayerService.Seek(position);
    }

    private async Task OnVolumeChange(double volume)
    {
        if (PlayerService == null) return;
        await PlayerService.SetVolume(volume);
    }

    private void ClearError()
    {
        PlayerService?.ClearError();
    }

    private async Task ToggleMinimized() => await SetMinimized(!_isMinimized);

    private async Task Close()
    {
        if (PlayerService != null && PlayerService.IsLoaded)
        {
            await PlayerService.Unload();
        }

        await SetMinimized(true);
    }
    
    public async ValueTask DisposeAsync()
    {
        if (_subscribedService != null)
        {
            _subscribedService.StateChanged -= OnPlayerStateChanged;
            _subscribedService = null;
        }

        if (_subscribedQueue != null)
        {
            _subscribedQueue.QueueChanged -= OnQueueChanged;
            _subscribedQueue = null;
        }

        if (_subscribedToVisualizerState)
        {
            VisualizerControlState.Changed -= OnVisualizerStateChanged;
            _subscribedToVisualizerState = false;
        }

        if (_spacerModule is not null)
        {
            try
            {
                // Clear the var so a torn-down player can't strand a spacer height.
                await _spacerModule.InvokeVoidAsync("unobserve");
                await _spacerModule.DisposeAsync();
            }
            catch (JSException)
            {
                // Runtime already gone (navigation/teardown) — nothing to clean up.
            }
            _spacerModule = null;
        }

        if (_embedModule is not null)
        {
            try
            {
                await _embedModule.DisposeAsync();
            }
            catch (JSException)
            {
                // Runtime already gone (navigation/teardown) — nothing to clean up.
            }
            _embedModule = null;
        }
    }
}