using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;

namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;

/// <summary>
/// Loudness-waveform seekbar. Renders the current track's profile (off the cascaded player
/// service) as a high-density bar chart and serves as the seek surface. The played/unplayed
/// split is a CSS clip overlay (<c>--played-fraction</c>), so seeking never re-renders bars.
/// Seekability never depends on the profile: with no profile it draws flat floor-height bars
/// that are still fully seekable.
/// </summary>
public partial class WaveformSeeker : ComponentBase, IAsyncDisposable
{
    [Inject] public required IJSRuntime JS { get; set; }

    [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }

    [Parameter] public EventCallback OnSeekStart { get; set; }
    [Parameter] public EventCallback<double> OnSeekChange { get; set; }
    [Parameter] public EventCallback<double> OnSeekEnd { get; set; }
    [Parameter] public string? Class { get; set; }

    /// <summary>Cap on rendered bars; actual count is min(profile length, this, width-derived).</summary>
    [Parameter] public int MaxBars { get; set; } = 200;

    /// <summary>Minimum bar height as a fraction of full height, so silence stays a visible hairline.</summary>
    private const double HeightFloor = 0.02;

    private ElementReference _seekerElement;
    private IStreamingPlayerService? _subscribedService;
    private IJSObjectReference? _jsModule;
    private IJSObjectReference? _resizeObserver;
    private DotNetObjectReference<WaveformSeeker>? _dotNetRef;

    // Bars currently drawn (normalized [0,1] heights). Recomputed only when the source profile
    // identity or the rendered width changes — not on every seek/progress tick.
    private double[] _renderedBars = Array.Empty<double>();
    // _lastProfileRef: sole tracker for profile-change detection in OnPlayerStateChanged.
    // _lastWidth: sole tracker for width-change detection in OnAfterRenderAsync.
    // Kept separate so a width rebuild cannot mask a pending profile change.
    private double[]? _lastProfileRef;
    private double _elementWidth;

    // Seek/hover gesture state.
    private bool _isSeeking;
    private double _seekFraction;
    private bool _showHover;
    private double _hoverFraction;
    private double _hoverTime;
    private string HoverPercent => $"{_hoverFraction * 100.0}%";

    private double? Duration => PlayerService?.Duration;
    private bool CanSeek => (PlayerService?.IsLoaded ?? false) && Duration is > 0;

    /// <summary>Fraction of the track that reads as "played" — the drag position while seeking,
    /// otherwise live playback position. Drives the clip overlay and the playhead.</summary>
    private double PlayedFraction
    {
        get
        {
            if (_isSeeking) return _seekFraction;
            if (PlayerService is null || Duration is not > 0) return 0;
            return Math.Clamp(PlayerService.CurrentTime / Duration.Value, 0, 1);
        }
    }

    protected override void OnInitialized()
    {
        // Seed flat fallback bars so the control reads as a seekbar on the very first paint,
        // before the width is measured (OnAfterRender) or a profile arrives (StateChanged).
        RebuildBars();
    }

    protected override void OnParametersSet()
    {
        // The cascade is IsFixed, so subscribe to the multicast side-channel to re-render when
        // playback position or the fetched profile changes — same pattern as SpectrumVisualizer.
        if (PlayerService != null && !ReferenceEquals(PlayerService, _subscribedService))
        {
            if (_subscribedService != null)
                _subscribedService.StateChanged -= OnPlayerStateChanged;

            PlayerService.StateChanged += OnPlayerStateChanged;
            _subscribedService = PlayerService;
        }
    }

    private void OnPlayerStateChanged() => InvokeAsync(() =>
    {
        // Rebuild bars only if the profile reference changed; position ticks just re-render the
        // clip overlay (a CSS var), which StateHasChanged already covers.
        var profile = PlayerService?.WaveformProfile;
        if (!ReferenceEquals(profile, _lastProfileRef))
        {
            _lastProfileRef = profile;
            RebuildBars();
        }
        StateHasChanged();
    });

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "./js/waveformSeeker.js");
        }

        if (_jsModule is null) return;

        if (firstRender)
        {
            var width = await _jsModule.InvokeAsync<double>("getWidth", _seekerElement);
            if (width > 0 && Math.Abs(width - _elementWidth) > 1)
            {
                _elementWidth = width;
                RebuildBars();
                StateHasChanged();
            }

            _dotNetRef = DotNetObjectReference.Create(this);
            _resizeObserver = await _jsModule.InvokeAsync<IJSObjectReference>("observeResize", _seekerElement, _dotNetRef);
        }
    }

    [JSInvokable]
    public void OnWidthChanged(double width)
    {
        if (width > 0 && Math.Abs(width - _elementWidth) > 1)
        {
            _elementWidth = width;
            RebuildBars();
            InvokeAsync(StateHasChanged);
        }
    }

    /// <summary>
    /// Recomputes the drawn bar set: count derived from available width (capped by MaxBars and the
    /// source length), heights downsampled from the profile by peak. With no profile, emits flat
    /// floor-height bars so the control still reads as — and behaves as — a seekbar.
    /// </summary>
    private void RebuildBars()
    {
        var profile = PlayerService?.WaveformProfile;

        var widthBars = _elementWidth > 0 ? (int)(_elementWidth / BarPitchPx) : MaxBars;
        var barCount = Math.Clamp(widthBars, 1, MaxBars);

        if (profile is null || profile.Length == 0)
        {
            // Flat fallback — floor-height bars, fully seekable.
            var flat = new double[barCount];
            Array.Fill(flat, HeightFloor);
            _renderedBars = flat;
            return;
        }

        barCount = Math.Min(barCount, profile.Length);
        var bars = new double[barCount];
        for (var i = 0; i < barCount; i++)
        {
            // Source range [start, end) for this rendered bar; peak (max) over it for the punchy look.
            var start = (int)((long)i * profile.Length / barCount);
            var end = (int)((long)(i + 1) * profile.Length / barCount);
            if (end <= start) end = start + 1;

            var peak = 0.0;
            for (var j = start; j < end && j < profile.Length; j++)
            {
                if (profile[j] > peak) peak = profile[j];
            }
            bars[i] = Math.Max(HeightFloor, peak);
        }
        _renderedBars = bars;
    }

    /// <summary>Bar + gap pitch in px, matched to SpectrumVisualizer (≈4px min bar + 2px gap).</summary>
    private const double BarPitchPx = 6.0;

    private async Task HandlePointerDown(PointerEventArgs e)
    {
        if (!CanSeek) return;

        _isSeeking = true;
        _seekFraction = FractionFromOffset(e.OffsetX);

        // Fire seek-start notifications BEFORE awaiting capturePointer. In Blazor WASM a JS
        // interop await yields to the browser event loop. A fast click can fire pointerup
        // during that window: HandlePointerUp runs (OnSeekEnd, _isSeeking = false), then
        // HandlePointerDown resumes and calls OnSeekStart (_isSeeking stuck true, display
        // frozen). Notifying first ensures the ordering is always Start → End, never End → Start.
        if (Duration is not > 0) { _isSeeking = false; return; }
        await OnSeekStart.InvokeAsync();
        await OnSeekChange.InvokeAsync(_seekFraction * Duration.Value);

        // Capture AFTER seek-start is notified so a fast pointerup cannot reorder
        // OnSeekEnd before OnSeekStart in AudioPlayerBar.
        if (_jsModule is not null)
        {
            try
            {
                await _jsModule.InvokeVoidAsync("capturePointer", _seekerElement, e.PointerId);
            }
            catch
            {
                // Capture is a UX nicety; gesture still works within element bounds.
            }
        }
    }

    private async Task HandlePointerMove(PointerEventArgs e)
    {
        if (!CanSeek) return;
        if (Duration is not > 0) return;

        var fraction = FractionFromOffset(e.OffsetX);

        if (_isSeeking)
        {
            _seekFraction = fraction;
            await OnSeekChange.InvokeAsync(fraction * Duration.Value);
        }
        else
        {
            _showHover = true;
            _hoverFraction = fraction;
            _hoverTime = fraction * Duration.Value;
            StateHasChanged();
        }
    }

    private async Task HandlePointerUp(PointerEventArgs e)
    {
        if (!_isSeeking) return;
        _isSeeking = false;

        if (Duration is not > 0) return;
        _seekFraction = FractionFromOffset(e.OffsetX);
        await OnSeekEnd.InvokeAsync(_seekFraction * Duration.Value);
    }

    private async Task HandlePointerLeave()
    {
        // Pointer capture keeps a drag alive past the edge, so a leave during seeking means the
        // gesture genuinely ended without a pointerup (rare). Commit at the last known position.
        if (_isSeeking)
        {
            _isSeeking = false;
            if (Duration is > 0)
                await OnSeekEnd.InvokeAsync(_seekFraction * Duration.Value);
        }

        if (_showHover)
        {
            _showHover = false;
            StateHasChanged();
        }
    }

    private double FractionFromOffset(double offsetX)
    {
        if (_elementWidth <= 0) return 0;
        return Math.Clamp(offsetX / _elementWidth, 0, 1);
    }

    private static string FormatHeight(double normalized) =>
        $"{(normalized * 100).ToString("F1", System.Globalization.CultureInfo.InvariantCulture)}%";

    private static string FormatTime(double seconds)
    {
        if (double.IsNaN(seconds) || seconds < 0) seconds = 0;
        var ts = TimeSpan.FromSeconds(seconds);
        return $"{(int)ts.TotalMinutes}:{ts.Seconds:D2}";
    }

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

        if (_resizeObserver is not null)
        {
            try { await (_jsModule?.InvokeVoidAsync("unobserveResize", _resizeObserver) ?? ValueTask.CompletedTask); } catch { }
            try { await _resizeObserver.DisposeAsync(); } catch { }
            _resizeObserver = null;
        }
        _dotNetRef?.Dispose();
        _dotNetRef = null;

        if (_jsModule is not null)
        {
            try
            {
                await _jsModule.DisposeAsync();
            }
            catch (JSDisconnectedException)
            {
                // The circuit/runtime is already gone (navigation/teardown); nothing to release.
            }
            _jsModule = null;
        }
    }
}
