using DeepDrftModels.DTOs;
using DeepDrftPublic.Client.Common;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;

namespace DeepDrftPublic.Client.Controls;

/// <summary>
/// Full-page scrolling Mix waveform background. Standalone and reusable: give it a
/// <see cref="ReleaseEntryKey"/> and it fetches its own loudness datum. The rendering itself — a windowed,
/// bottom-to-top, playback-coupled scroll with a glassy theme-aware gradient — lives in the
/// MixVisualizer.ts interop module; this component is the bridge that feeds it datum, playback
/// position, zoom, and theme, and owns the module lifecycle.
///
/// Strictly read-only (spec §D): no seek, no two-way write-back. <see cref="PlaybackPosition"/> is a
/// one-way input. The live playback signal on the Mix detail page comes from the cascaded player
/// service (which also supplies the mix duration needed for the time↔sample mapping); the
/// <see cref="PlaybackPosition"/> parameter is the composability fallback for hosts that have no
/// player cascade (e.g. an embed) and want to drive position themselves.
/// </summary>
public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
{
    [Inject] public required IReleaseDataService ReleaseData { get; set; }
    [Inject] public required IJSRuntime JS { get; set; }
    [Inject] public required MixVisualizerControlState ControlState { get; set; }
    [Inject] public required ILogger<MixWaveformVisualizer> Logger { get; set; }

    // Live playback + the mix duration come from the cascaded streaming player when present. The
    // cascade is IsFixed, so we subscribe to its multicast StateChanged side-channel to learn about
    // position/play-state ticks (same pattern as WaveformSeeker / SpectrumVisualizer).
    [CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }

    // Live dark-mode state. Toggling re-themes the gradient without a reload: the cascade re-renders
    // us, and OnAfterRender pushes fresh palette colours into the module.
    [CascadingParameter] public DarkModeSettings? DarkMode { get; set; }

    /// <summary>The opaque public EntryKey of the Mix release whose waveform datum to fetch and render.</summary>
    [Parameter] public required string ReleaseEntryKey { get; set; }

    /// <summary>
    /// The id of this mix's playable track. Used to gate the cascaded player as the live source: we
    /// only couple to playback when the player is on THIS track, so a different track playing
    /// elsewhere leaves this backdrop at its at-rest slice instead of scrolling to the wrong audio.
    /// Null leaves the visualizer in the at-rest state (no player coupling).
    /// </summary>
    [Parameter] public long? TrackId { get; set; }

    /// <summary>
    /// Normalized playback head in [0, 1]. One-way input only — the component never writes back.
    /// Used as the position source for hosts with no cascaded player (composability fallback);
    /// when a matching player is cascaded, its live position takes precedence.
    /// </summary>
    [Parameter] public double PlaybackPosition { get; set; }

    // Bridge-level diagnostics. Mirrors the JS-side DEBUG flag in MixVisualizer.ts: when true the
    // datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
    // `[MixVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint
    // which upstream link is broken when the ribbon stays blank — set false once confirmed healthy.
    // ON for the Phase 10 reframe Wave R4 controls test (matches the JS-side DEBUG in
    // MixVisualizer.ts). Daniel evaluates the seven-knob bar + pause behavior in-browser; flip back to
    // false at reframe close along with the JS flag.
    private static readonly bool Debug = true;
    private const string Tag = "[MixVisualizer]";

    private static void DebugLog(string message)
    {
        if (Debug) Console.WriteLine($"{Tag} {message}");
    }

    private ElementReference _canvas;
    private IJSObjectReference? _module;
    private IJSObjectReference? _handle;

    private IStreamingPlayerService? _subscribedService;
    private WaveformProfileDto? _profile;
    private string? _loadedReleaseKey;

    // Whether we are subscribed to the shared control state's Changed event. The controls row (a
    // sibling component) mutates ControlState and raises Changed; we push the affected uniforms.
    private bool _subscribedToControls;

    // The profile reference last sent to the module, plus whether it went with a real duration.
    // Tracked so a per-tick playback push never re-decodes the (up to ~1.2 MB) datum in JS — we only
    // push the datum when its identity or duration-availability actually changes.
    private WaveformProfileDto? _pushedProfile;
    private bool _pushedWithDuration;

    // Theme last pushed to the module, so we only re-push on an actual change.
    private bool? _lastIsDark;

    protected override void OnInitialized()
    {
        // Subscribe once to the shared control state. The controls row mutates it and raises Changed;
        // we are the sole owner of the JS module handle, so we do the uniform pushes here. This keeps
        // the handle single-owned (no handle sharing, no service-locator) — the scoped state object is
        // the decoupling seam between the foreground controls and this backdrop bridge.
        if (!_subscribedToControls)
        {
            ControlState.Changed += OnControlStateChanged;
            _subscribedToControls = true;
        }
    }

    protected override async Task OnParametersSetAsync()
    {
        // Subscribe to the player's multicast side-channel once, to re-render on position/play ticks.
        // Log whether the cascade is even present: a null PlayerService here means the visualizer
        // never couples to playback (no StateChanged events ever reach OnPlayerStateChanged).
        if (PlayerService is not null && !ReferenceEquals(PlayerService, _subscribedService))
        {
            if (_subscribedService is not null)
                _subscribedService.StateChanged -= OnPlayerStateChanged;
            PlayerService.StateChanged += OnPlayerStateChanged;
            _subscribedService = PlayerService;
            DebugLog($"subscribed to player StateChanged. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
        }
        else if (PlayerService is null)
        {
            DebugLog($"NO player cascade — playback will never couple. ReleaseEntryKey={ReleaseEntryKey}, TrackId={TrackId?.ToString() ?? "null"}.");
        }

        // ReleaseEntryKey is the only fetch input; fetch once per key. Position/zoom/theme changes
        // re-render but must not refetch, and a release with no datum must not refetch either — so the
        // guard keys on the fetched key, not on whether a profile came back.
        if (_loadedReleaseKey == ReleaseEntryKey) return;
        _loadedReleaseKey = ReleaseEntryKey;

        DebugLog($"fetching mix waveform datum for ReleaseEntryKey={ReleaseEntryKey}…");
        var result = await ReleaseData.GetMixWaveform(ReleaseEntryKey);
        if (result is { Success: true, Value: { } profile } && profile.BucketCount > 0 && profile.Data.Length > 0)
        {
            _profile = profile;
            DebugLog($"datum fetch OK — {profile.BucketCount} buckets, base64 length {profile.Data.Length}.");
        }
        else
        {
            // No datum (not generated yet, or not a Mix) — empty backdrop; the detail page still
            // renders its content over a plain background.
            _profile = null;
            DebugLog(result.Success
                ? $"datum fetch returned EMPTY/absent (no stored datum for ReleaseEntryKey={ReleaseEntryKey}) — backdrop stays blank."
                : $"datum fetch FAILED ({result.GetMessage() ?? "unknown error"}) — backdrop stays blank.");
        }

        // Push the (possibly new) datum to the module if it is already created.
        await PushDatumAsync();
    }

    private void OnPlayerStateChanged() => InvokeAsync(async () =>
    {
        // Position/play-state changed: push it to the module (cheap; no re-fetch, no full re-render
        // needed for the canvas itself, but StateHasChanged keeps the slider/visibility in sync).
        // Log the gating inputs so a "ribbon never couples" failure shows exactly why: whether the
        // player is on THIS track (IsActivePlayer), and what duration/position/play-state it reports.
        var currentTrackId = PlayerService?.CurrentTrack is { } ct ? ct.Id.ToString() : "null";
        DebugLog($"player StateChanged — IsActivePlayer={IsActivePlayer} (player.CurrentTrack.Id={currentTrackId}, TrackId={TrackId?.ToString() ?? "null"}), player.IsPlaying={PlayerService?.IsPlaying}, player.Duration={PlayerService?.Duration?.ToString("F2") ?? "null"}.");
        await PushPlaybackAsync();
        StateHasChanged();
    });

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            try
            {
                _module = await JS.InvokeAsync<IJSObjectReference>(
                    "import", "./js/visualizer/MixVisualizer.js");
                _handle = await _module.InvokeAsync<IJSObjectReference>("create", _canvas);
            }
            catch (JSException ex)
            {
                Logger.LogWarning(ex, "MixWaveformVisualizer: failed to load the visualizer module; rendering a plain backdrop.");
                return;
            }

            // Seed the module with the current state now that it exists. All seven control values
            // come from the shared (session-persisted) state, so a mix opened mid-session seeds the
            // module with the knob positions the listener left them at.
            await PushControlsAsync();
            await PushDatumAsync();
            await PushPlaybackAsync();
            await PushThemeIfChangedAsync();
            return;
        }

        // On every subsequent render (e.g. dark-mode toggle), re-theme if it changed.
        await PushThemeIfChangedAsync();
    }

    // The controls bar mutated a knob on the shared state and raised Changed. Push all seven control
    // values (cheap scalar interop). Each control now drives its own dedicated dial in the JS handle
    // (lava reframe Wave R4) — scroll speed → visible-time-span, plus the six lava/colour dials; see
    // PushControlsAsync. The bridge stays the sole owner of the JS module handle.
    private void OnControlStateChanged() => InvokeAsync(async () =>
    {
        await PushControlsAsync();
    });

    // ── Bridge pushes. Each is a no-op until the module handle exists. ───────────────────────────

    /// <summary>
    /// Push the seven control values to the module from the shared state. Used to seed on first render
    /// and to re-push when the controls bar signals a change (lava reframe Wave R4). Each value is its
    /// own dedicated dial now — no more R2 temp-remapping:
    /// <list type="bullet">
    ///   <item>scroll speed [0,1] is mapped to a visible time-span via <see cref="MixZoomMapping"/> and
    ///         pushed through <c>setScrollSpeed</c> (higher speed → tighter window → faster scroll);</item>
    ///   <item>gradient rotation speed → <c>setGradientRotationSpeed</c> (inert until Wave R3);</item>
    ///   <item>gravity / heat / blob density / collision strength → their dedicated lava-physics dials;</item>
    ///   <item>waveform width → the ribbon-extent uniform.</item>
    /// </list>
    /// </summary>
    private async Task PushControlsAsync()
    {
        if (_handle is null) return;
        // Scroll speed is a normalized [0,1] axis; map it to the visible time-span the renderer scrolls
        // through. The log map keeps the even-to-the-hand feel the old zoom slider had.
        var visibleSeconds = MixZoomMapping.FractionToSeconds(ControlState.ScrollSpeed);
        await _handle.InvokeVoidAsync("setScrollSpeed", visibleSeconds);
        await _handle.InvokeVoidAsync("setGradientRotationSpeed", ControlState.GradientRotationSpeed);
        await _handle.InvokeVoidAsync("setLavaGravity", ControlState.LavaGravity);
        await _handle.InvokeVoidAsync("setLavaHeat", ControlState.LavaHeat);
        await _handle.InvokeVoidAsync("setBlobDensity", ControlState.BlobDensity);
        await _handle.InvokeVoidAsync("setCollisionStrength", ControlState.CollisionStrength);
        await _handle.InvokeVoidAsync("setWaveformWidth", ControlState.WaveformWidth);
    }

    /// <summary>
    /// Push the datum to the module, but only when it actually changed — a different profile, or the
    /// mix duration becoming available for the first time. Idempotent so the per-tick playback path
    /// can call it without re-decoding the (large) base64 datum in JS every frame.
    /// </summary>
    private async Task PushDatumAsync()
    {
        if (_handle is null) return;

        var haveDuration = _profile is not null && PlayerDurationSeconds is > 0;

        // No change since the last push? Nothing to do.
        if (ReferenceEquals(_profile, _pushedProfile) && haveDuration == _pushedWithDuration)
            return;

        if (!haveDuration)
        {
            // The most common stuck state: a datum is loaded but no positive player duration has
            // arrived, so we cannot map samples↔time and push an empty datum. Spell out which half
            // is missing so the broken link is unambiguous in the console.
            DebugLog($"datum push deferred (empty) — profile={(_profile is null ? "null" : "loaded")}, playerDuration={PlayerDurationSeconds?.ToString("F2") ?? "null"} (needs IsActivePlayer + duration>0).");
        }

        if (haveDuration)
        {
            // The mix duration must come from the player (no DTO field carries it); without a
            // positive duration we cannot map samples↔time, so we hold off until it arrives.
            DebugLog($"datum push (REAL) — base64 length {_profile!.Data.Length}, duration {PlayerDurationSeconds!.Value:F2}s.");
            await _handle.InvokeVoidAsync("setDatum", _profile.Data, PlayerDurationSeconds.Value);
        }
        else
        {
            await _handle.InvokeVoidAsync("setDatum", string.Empty, 0d);
        }

        _pushedProfile = _profile;
        _pushedWithDuration = haveDuration;
    }

    private async Task PushPlaybackAsync()
    {
        if (_handle is null)
        {
            DebugLog("PushPlayback skipped — module handle not created yet.");
            return;
        }

        // Duration arrives via the player after the initial (duration-less) datum push; the
        // idempotent PushDatumAsync re-pushes exactly once when it first becomes available.
        await PushDatumAsync();

        DebugLog($"setPlayback → position={CurrentPositionSeconds:F2}s, isPlaying={IsPlaying}.");
        await _handle.InvokeVoidAsync("setPlayback", CurrentPositionSeconds, IsPlaying);
    }

    private async Task PushThemeIfChangedAsync()
    {
        if (_handle is null) return;
        var isDark = DarkMode?.IsDarkMode ?? false;
        if (_lastIsDark == isDark) return;
        _lastIsDark = isDark;

        // The module reads the gradient stops directly from the canvas's computed --mud-palette-*
        // vars (canvas gradients can't resolve var(), so resolution must happen in JS). The bespoke
        // light/dark themes swap those vars on toggle; we just tell the module to re-read.
        await _handle.InvokeVoidAsync("refreshTheme");
    }

    // ── Live signal sources. The matching player wins; PlaybackPosition is the no-player fallback. ─

    /// <summary>True only when the cascaded player is loaded with THIS mix's track.</summary>
    private bool IsActivePlayer =>
        PlayerService is { CurrentTrack: not null }
        && TrackId is { } id
        && PlayerService.CurrentTrack.Id == id;

    private double? PlayerDurationSeconds =>
        IsActivePlayer && PlayerService!.Duration is > 0 ? PlayerService.Duration : null;

    private bool IsPlaying => IsActivePlayer && (PlayerService?.IsPlaying ?? false);

    private double CurrentPositionSeconds
    {
        get
        {
            // Prefer the matching player's absolute time. Otherwise fall back to the one-way
            // PlaybackPosition ([0,1]) scaled by whatever duration we have; with no duration the
            // position is unusable, so show the at-rest slice (0).
            if (IsActivePlayer)
                return PlayerService!.CurrentTime;
            if (PlayerDurationSeconds is { } dur)
                return Math.Clamp(PlaybackPosition, 0, 1) * dur;
            return 0;
        }
    }

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

        if (_subscribedToControls)
        {
            ControlState.Changed -= OnControlStateChanged;
            _subscribedToControls = false;
        }

        if (_handle is not null)
        {
            try { await _handle.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { }
            try { await _handle.DisposeAsync(); } catch (JSDisconnectedException) { }
            _handle = null;
        }

        if (_module is not null)
        {
            try { await _module.DisposeAsync(); } catch (JSDisconnectedException) { }
            _module = null;
        }
    }
}
