@namespace Lumeo
@using Blazicons
@implements IAsyncDisposable

@if (_initError is not null)
{
    <div class="@($"flex items-center justify-center text-sm text-destructive border border-destructive/30 rounded-md bg-destructive/5 {Class}".Trim())" style="@StyleStr" @attributes="AdditionalAttributes">
        <span>@_initError</span>
    </div>
}
else
{
    <div class="@RootCssClass" style="@RootStyleStr" @attributes="AdditionalAttributes">
        <div id="@_chartId" class="lumeo-chart-host w-full h-full"
             style="@HostStyle"></div>
        @if (ShouldShowSkeleton && SkeletonStyle == ChartSkeletonStyle.Silhouette)
        {
            @* Silhouette mode (legacy SVG skeleton overlay). Phantom mode renders the real
               ECharts with placeholder data instead — no DOM element needed here. *@
            <div style="position: absolute; inset: 0; pointer-events: none;">
                <ChartSkeleton Kind="@SkeletonKind" Width="100%" Height="100%" Class="!rounded-none" />
            </div>
        }
        @if (ShouldShowSkeleton && ShowLoadingLabel)
        {
            @* Loading label — small top-right pill (spinner + text) overlaid on TOP of
               whichever skeleton style is active. Tells users explicitly "this is loading"
               so the cycling phantom data isn't mistaken for a bug. Doesn't block the
               chart underneath (small, corner-aligned). Disable via ShowLoadingLabel=false
               for bare phantom/silhouette visuals. *@
            <div role="status" aria-live="polite" aria-label="@(LoadingText ?? L["Chart.Loading"])"
                 class="absolute top-2 right-2 flex items-center gap-1.5 rounded-full border border-border/60 bg-background/85 backdrop-blur-sm px-2.5 py-1 shadow-sm pointer-events-none">
                <Blazicon Svg="Lucide.LoaderCircle" class="h-3.5 w-3.5 text-muted-foreground animate-spin" />
                @{
                    var _chartLoadingLabel = LoadingText ?? L["Chart.Loading"];
                }
                @if (!string.IsNullOrEmpty(_chartLoadingLabel))
                {
                    <span class="text-xs font-medium text-muted-foreground">@_chartLoadingLabel</span>
                }
            </div>
        }
    </div>
}

@inject ComponentInteropService Interop
@inject Lumeo.Services.Localization.ILumeoLocalizer L

@code {
    [Parameter] public EChartOption? Option { get; set; }
    [Parameter] public string? OptionJson { get; set; }
    [Parameter] public string? Theme { get; set; }
    [Parameter] public string? EChartsSource { get; set; }
    [Parameter] public string Width { get; set; } = "100%";
    [Parameter] public string Height { get; set; } = "350px";
    [Parameter] public EventCallback<ChartEventArgs> OnClick { get; set; }
    [Parameter] public EventCallback<ChartEventArgs> OnMouseOver { get; set; }
    [Parameter] public bool ShowLoading { get; set; }
    [Parameter] public string? Group { get; set; }
    [Parameter] public string? AspectRatio { get; set; }
    [Parameter] public EventCallback<ChartEventArgs> OnDoubleClick { get; set; }
    [Parameter] public EventCallback<ChartEventArgs> OnBrushSelected { get; set; }
    [Parameter] public EventCallback<ChartEventArgs> OnDataZoom { get; set; }
    [Parameter] public EventCallback<string> OnInitError { get; set; }
    [Parameter] public string? Class { get; set; }
    [Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object>? AdditionalAttributes { get; set; }

    /// <summary>When true, forces the loading state on top of the chart
    /// (consumer is refetching data). Independent of the JS-driven ECharts loading
    /// indicator controlled by <see cref="ShowLoading"/>.</summary>
    [Parameter] public bool IsLoading { get; set; }
    /// <summary>When true (default), renders the loading state while the chart
    /// has not yet rendered for the first time, and whenever <see cref="IsLoading"/>
    /// is true. Set to <c>false</c> to opt out entirely.</summary>
    [Parameter] public bool ShowLoadingSkeleton { get; set; } = true;
    /// <summary>Shape family for the skeleton. Each chart wrapper presets a sensible
    /// default (bars for Bar, pie ring for Pie, etc.) — consumers only override when
    /// they want a different silhouette.</summary>
    [Parameter] public ChartSkeletonKind SkeletonKind { get; set; } = ChartSkeletonKind.Generic;
    /// <summary>Phantom (default) renders the real chart with synthesized placeholder
    /// data in a muted palette; ECharts animates the option swap to real data.
    /// Silhouette falls back to the legacy SVG overlay skeleton.</summary>
    [Parameter] public ChartSkeletonStyle SkeletonStyle { get; set; } = ChartSkeletonStyle.Phantom;
    /// <summary>Phantom-mode refresh interval in milliseconds — each tick regenerates
    /// the placeholder dataset with a new random seed so bars/lines/slices visibly
    /// redistribute while loading. Defaults to 1400ms; clamped to ≥200ms.</summary>
    [Parameter] public int PhantomCycleMs { get; set; } = 1400;
    /// <summary>Explicit override for the phantom layout (category count, series count,
    /// stacking, area fill). When null (default), the shape is inferred from the real
    /// <see cref="Option"/> so the phantom mirrors the actual chart structure.</summary>
    [Parameter] public PhantomShape? PhantomShapeOverride { get; set; }
    /// <summary>When true (default), overlays a small top-right pill containing a
    /// spinner and <see cref="LoadingText"/> on top of the active skeleton — a clear,
    /// always-on visual cue that the chart is loading. The pill renders independently
    /// of <see cref="SkeletonStyle"/> so phantom and silhouette both get the same
    /// hint. Set to <c>false</c> for bare visuals where the skeleton speaks for
    /// itself.</summary>
    [Parameter] public bool ShowLoadingLabel { get; set; } = true;
    /// <summary>Label rendered next to the spinner inside the loading pill. Set to
    /// <c>null</c> or empty to hide the text and show only the spinner. When not set,
    /// uses the active locale's "Chart.Loading" string (default: "Loading…").</summary>
    [Parameter] public string? LoadingText { get; set; }

    private readonly string _chartId = $"lumeo-chart-{Guid.NewGuid():N}";
    private IJSObjectReference? _module;
    private DotNetObjectReference<Chart>? _selfRef;
    private bool _initialized;
    private string? _lastJson;
    private bool _lastShowLoading;
    private string? _lastTheme;
    private string? _lastGroup;
    private string? _initError;

    private string CssClass => $"{Class}".Trim();
    private string StyleStr => AspectRatio is not null
        ? $"width: {Width}; aspect-ratio: {AspectRatio};"
        : $"width: {Width}; height: {Height};";

    private string RootCssClass => $"relative {Class}".Trim();
    private string RootStyleStr => $"position: relative; {StyleStr}";

    /// <summary>True when we should be showing a loading state. Keyed strictly on
    /// <c>IsLoading</c> — we do NOT show phantom/silhouette on initial mount, because
    /// ECharts initializes synchronously and a ready chart should render its real data
    /// on first paint. The loading state is reserved for explicit consumer refetches.</summary>
    private bool ShouldShowSkeleton => ShowLoadingSkeleton && IsLoading;

    // Silhouette mode hides the real chart while the SVG overlay takes its place.
    // Phantom mode keeps the chart host fully visible — the "skeleton" IS the chart
    // with placeholder data. To give phantom a "loading" look without polluting the
    // option.color palette (which would bleed through the merge into the real chart),
    // we apply a CSS grayscale+dim filter while phantom is active. The filter cleanly
    // transitions off when real data arrives — bars keep their real hue throughout.
    private string ChartHostOpacity => (ShouldShowSkeleton && SkeletonStyle == ChartSkeletonStyle.Silhouette) ? "0" : "1";

    private bool IsPhantomStyled =>
        ShouldShowSkeleton && SkeletonStyle == ChartSkeletonStyle.Phantom && ShowLoadingSkeleton;

    private string HostStyle => IsPhantomStyled
        ? $"transition: opacity 350ms ease-out, filter 500ms ease-out; opacity: {ChartHostOpacity}; filter: grayscale(0.85) saturate(0.15) opacity(0.78);"
        : $"transition: opacity 350ms ease-out, filter 500ms ease-out; opacity: {ChartHostOpacity}; filter: none;";

    // Phantom-mode state: cycling random placeholder data while loading, then one
    // clean "notMerge=true" swap to real data so the muted palette doesn't bleed.
    // Driven by a CancellationTokenSource + PeriodicTimer loop (replaces the older
    // System.Threading.Timer) so DisposeAsync can deterministically cancel and
    // await any in-flight callback before returning.
    private CancellationTokenSource? _phantomCts;
    private Task? _phantomLoopTask;
    private int _phantomTick;
    private bool _lastWasPhantom;
    private int _activePhantomCycleMs;
    private volatile bool _disposed;
    private int EffectivePhantomCycleMs => Math.Max(200, PhantomCycleMs);

    // Components whose phantom styling (muted palette, transparent text, skeleton-box
    // labels) must be wiped during the phantom↔real transition — otherwise they'd
    // persist under setOption merge semantics and bleed into the real chart.
    private const string PhantomTransitionReplaceMergeJson =
        "[\"xAxis\",\"yAxis\",\"legend\",\"polar\",\"angleAxis\",\"radiusAxis\",\"radar\",\"parallel\",\"parallelAxis\",\"visualMap\",\"grid\"]";

    private bool IsPhantomActive =>
        ShouldShowSkeleton && SkeletonStyle == ChartSkeletonStyle.Phantom && ShowLoadingSkeleton;

    /// <summary>Returns the JSON that actually goes to ECharts. Phantom mode uses
    /// the current cycling tick so each frame redistributes the random data, and
    /// mirrors the real chart's structure (category count, series count, stacking)
    /// via <see cref="ChartPlaceholderFactory.InferShape"/>.</summary>
    private string GetEffectiveJson()
    {
        if (IsPhantomActive)
        {
            var shape = PhantomShapeOverride ?? ChartPlaceholderFactory.InferShape(Option);
            return ChartPlaceholderFactory.CreateJson(SkeletonKind, _phantomTick, shape);
        }
        return GetOptionsJson();
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _module = await Interop.ImportModuleAsync("./_content/Lumeo.Charts/js/echarts-interop.js");

            var json = GetEffectiveJson();
            _lastJson = json;
            _lastTheme = Theme;
            _lastGroup = Group;

            try
            {
                await _module.InvokeVoidAsync("initChart", _chartId, json, Theme, EChartsSource);
            }
            catch (JSException ex)
            {
                _initError = $"Chart initialization failed: {ex.Message}";
                Console.Error.WriteLine($"[Lumeo Chart] Init failed: {ex.Message}");
                if (OnInitError.HasDelegate)
                    await OnInitError.InvokeAsync(ex.Message);
                StateHasChanged();
                return;
            }
            _initialized = true;

            await RegisterChartEventsAsync();

            if (ShowLoading)
            {
                _lastShowLoading = true;
                await _module.InvokeVoidAsync("showLoading", _chartId, null);
            }

            // Capture the phantom state that was actually passed to ECharts. Must be
            // the value at init time (before any subsequent state change), so we can
            // correctly decide notMerge on the next update.
            _lastWasPhantom = IsPhantomActive;
            UpdatePhantomTimer();
            StateHasChanged();
        }
        else if (_initialized)
        {
            UpdatePhantomTimer();

            var json = GetEffectiveJson();
            var isPhantom = IsPhantomActive;
            if (json != _lastJson)
            {
                _lastJson = json;
                // Phantom↔real transition: keep notMerge=false so SERIES values tween
                // (bars move from phantom heights to real heights — feels like data
                // loading, not a snap). Use replaceMerge to fully replace layout
                // components (axes, legend, radar, polar, parallel, visualMap) so the
                // muted skeleton-box styling doesn't bleed into the real chart.
                var phantomFlipped = _lastWasPhantom != isPhantom;
                string? replaceMergeJson = phantomFlipped ? PhantomTransitionReplaceMergeJson : null;
                await _module!.InvokeVoidAsync("updateChart", _chartId, json, false, replaceMergeJson);
            }
            _lastWasPhantom = isPhantom;

            if (ShowLoading != _lastShowLoading)
            {
                _lastShowLoading = ShowLoading;
                if (ShowLoading)
                    await _module!.InvokeVoidAsync("showLoading", _chartId, null);
                else
                    await _module!.InvokeVoidAsync("hideLoading", _chartId);
            }

            if (Theme != _lastTheme)
            {
                _lastTheme = Theme;
                await _module!.InvokeVoidAsync("disposeChart", _chartId);
                var currentJson = _lastJson ?? GetEffectiveJson();
                await _module!.InvokeVoidAsync("initChart", _chartId, currentJson, Theme, EChartsSource);
                await RegisterChartEventsAsync();
            }

            if (Group != _lastGroup)
            {
                _lastGroup = Group;
                if (!string.IsNullOrEmpty(Group))
                    await _module!.InvokeVoidAsync("connectCharts", Group, new[] { _chartId });
            }
        }
    }

    private void UpdatePhantomTimer()
    {
        if (_disposed) return;
        var cycle = EffectivePhantomCycleMs;
        if (IsPhantomActive)
        {
            if (_phantomLoopTask is null || _activePhantomCycleMs != cycle)
            {
                StopPhantomLoop();
                _activePhantomCycleMs = cycle;
                _phantomCts = new CancellationTokenSource();
                var token = _phantomCts.Token;
                _phantomLoopTask = RunPhantomLoopAsync(cycle, token);
            }
        }
        else if (_phantomLoopTask is not null)
        {
            StopPhantomLoop();
            _activePhantomCycleMs = 0;
        }
    }

    private void StopPhantomLoop()
    {
        try { _phantomCts?.Cancel(); } catch (ObjectDisposedException) { }
        _phantomCts?.Dispose();
        _phantomCts = null;
        // Note: we deliberately do NOT await the loop task here to avoid blocking
        // OnAfterRenderAsync. DisposeAsync awaits any final loop task to ensure
        // no callback runs after dispose returns.
        _phantomLoopTask = null;
    }

    private async Task RunPhantomLoopAsync(int cycle, CancellationToken token)
    {
        // PeriodicTimer ticks on a fixed cadence; cancellation breaks the loop
        // immediately and lets DisposeAsync await completion deterministically.
        using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(cycle));
        try
        {
            while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false))
            {
                if (_disposed || token.IsCancellationRequested) return;
                _phantomTick++;
                // Dispatch via SafeAsyncDispatcher so any outer-task fault (circuit
                // disposed mid-tick) is caught — InvokeAsync itself can throw
                // synchronously after dispose.
                SafeAsyncDispatcher.FireAndForget(
                    fn => InvokeAsync(fn),
                    async () =>
                    {
                        if (_disposed || token.IsCancellationRequested) return;
                        if (!_initialized || _module is null || !IsPhantomActive) return;
                        var shape = PhantomShapeOverride ?? ChartPlaceholderFactory.InferShape(Option);
                        var json = ChartPlaceholderFactory.CreateJson(SkeletonKind, _phantomTick, shape);
                        _lastJson = json;
                        try
                        {
                            await _module.InvokeVoidAsync("updateChart", _chartId, json, false);
                        }
                        catch (JSDisconnectedException) { }
                        catch (ObjectDisposedException) { }
                    },
                    "Lumeo.Chart.Phantom");
            }
        }
        catch (OperationCanceledException) { /* expected on Dispose */ }
    }

    private async Task RegisterChartEventsAsync()
    {
        if (_module is null || !_initialized) return;

        if (OnClick.HasDelegate || OnMouseOver.HasDelegate || OnDoubleClick.HasDelegate || OnBrushSelected.HasDelegate || OnDataZoom.HasDelegate)
        {
            _selfRef ??= DotNetObjectReference.Create(this);
            if (OnClick.HasDelegate)
                await _module.InvokeVoidAsync("registerChartEvent", _chartId, "click", _selfRef);
            if (OnMouseOver.HasDelegate)
                await _module.InvokeVoidAsync("registerChartEvent", _chartId, "mouseover", _selfRef);
            if (OnDoubleClick.HasDelegate)
                await _module.InvokeVoidAsync("registerChartEvent", _chartId, "dblclick", _selfRef);
            if (OnBrushSelected.HasDelegate)
                await _module.InvokeVoidAsync("registerChartEvent", _chartId, "brushSelected", _selfRef);
            if (OnDataZoom.HasDelegate)
                await _module.InvokeVoidAsync("registerChartEvent", _chartId, "dataZoom", _selfRef);
        }

        if (!string.IsNullOrEmpty(Group))
            await _module.InvokeVoidAsync("connectCharts", Group, new[] { _chartId });
    }

    private string GetOptionsJson()
    {
        if (!string.IsNullOrEmpty(OptionJson)) return OptionJson;
        try
        {
            return Option?.ToJson() ?? "{}";
        }
        catch (Exception ex)
        {
            Console.Error.WriteLine($"[Lumeo Chart] Serialization failed: {ex.Message}");
            return "{}";
        }
    }

    [JSInvokable]
    public async Task OnChartEvent(string eventName, string dataJson)
    {
        var args = System.Text.Json.JsonSerializer.Deserialize<ChartEventArgs>(dataJson) ?? new();
        if (eventName == "click") await OnClick.InvokeAsync(args);
        else if (eventName == "mouseover") await OnMouseOver.InvokeAsync(args);
        else if (eventName == "dblclick") await OnDoubleClick.InvokeAsync(args);
        else if (eventName == "brushSelected") await OnBrushSelected.InvokeAsync(args);
        else if (eventName == "dataZoom") await OnDataZoom.InvokeAsync(args);
    }

    public async Task<string?> GetImageAsync(string type = "png", int pixelRatio = 2)
    {
        if (_module is null || !_initialized) return null;
        return await _module.InvokeAsync<string?>("getDataURL", _chartId,
            new { type, pixelRatio, backgroundColor = "#fff" });
    }

    public async Task AppendDataAsync(int seriesIndex, string dataJson)
    {
        if (_module is null || !_initialized) return;
        await _module.InvokeVoidAsync("appendData", _chartId, seriesIndex, dataJson);
    }

    public async Task DispatchActionAsync(string actionJson)
    {
        if (_module is null || !_initialized) return;
        await _module.InvokeVoidAsync("dispatchAction", _chartId, actionJson);
    }

    public async Task ResizeAsync()
    {
        if (_module is null || !_initialized) return;
        await _module.InvokeVoidAsync("resizeChart", _chartId);
    }

    public async ValueTask DisposeAsync()
    {
        // Set the disposed flag BEFORE any await so the loop's flag check can
        // short-circuit even if Dispose runs concurrently with a tick.
        _disposed = true;

        // Cancel and await the phantom loop so no callback can run after this
        // method returns — Timer.Dispose() did NOT give us this guarantee.
        try { _phantomCts?.Cancel(); } catch (ObjectDisposedException) { }
        var loop = _phantomLoopTask;
        _phantomLoopTask = null;
        if (loop is not null)
        {
            try { await loop.ConfigureAwait(false); }
            catch (OperationCanceledException) { /* covers TaskCanceledException too */ }
        }
        _phantomCts?.Dispose();
        _phantomCts = null;

        if (_module is not null && _initialized)
        {
            try
            {
                await _module.InvokeVoidAsync("disposeChart", _chartId);
                await _module.DisposeAsync();
            }
            catch (JSDisconnectedException) { }
        }
        _selfRef?.Dispose();
    }

    public class ChartEventArgs
    {
        public string Name { get; set; } = "";
        public string SeriesName { get; set; } = "";
        public int SeriesIndex { get; set; } = -1;
        public int DataIndex { get; set; } = -1;
        public string Value { get; set; } = "";
        public string ComponentType { get; set; } = "";
    }
}
