@namespace Lumeo
@using Lumeo.Services
@implements IAsyncDisposable

@*  rc.42 — Per-toast Position routing.
    Up to rc.41 we mounted a single <ToastViewport Position="Position"> and every
    toast landed there, silently ignoring ToastOptions.Position (the per-call
    override added in rc.37). Now we route each toast to a viewport keyed by its
    effective position (options.Position ?? provider Position). The provider's
    own default Position viewport is ALWAYS rendered (even when empty) so
    consumers that rely on the viewport DOM being present — for styling, layout
    measurement, or test assertions on position classes — still see it. Extra
    viewports are only spawned when a toast actually targets a different
    position. *@
@{
    var positionsToRender = _toasts
        .Select(t => ResolvePosition(t.Options.Position))
        .Distinct()
        .ToList();
    if (!positionsToRender.Contains(Position))
    {
        positionsToRender.Insert(0, Position);
    }
}
@foreach (var pos in positionsToRender)
{
    <ToastViewport Position="@pos" @key="@pos">
        @foreach (var toast in _toasts.Where(t => ResolvePosition(t.Options.Position) == pos))
        {
            <Toast Variant="@toast.Options.Variant" Class="@(toast.Leaving ? $"animate-toast-out {toast.Options.Class}" : toast.Options.Class)" @key="@toast.Id">
                @if (toast.Options.CustomContent is not null)
                {
                    @toast.Options.CustomContent
                }
                else
                {
                    <div class="grid gap-1">
                        <ToastTitle>@toast.Options.Title</ToastTitle>
                        @if (toast.Options.Description is not null)
                        {
                            <ToastDescription>@toast.Options.Description</ToastDescription>
                        }
                        @if (toast.Options.ActionLabel is not null)
                        {
                            <ToastAction OnClick="@(() => InvokeAction(toast))" Label="@toast.Options.ActionLabel" />
                        }
                    </div>
                }
                @if (toast.Options.Dismissible)
                {
                    <ToastClose OnClose="@(() => Remove(toast.Id))" />
                }
            </Toast>
        }
    </ToastViewport>
}


@inject ToastService ToastService

@code {
    private readonly List<ToastItem> _toasts = new();
    private readonly Dictionary<string, CancellationTokenSource> _timers = new();
    private readonly Dictionary<string, CancellationTokenSource> _exitTimers = new();

    [Parameter] public ToastViewport.ToastPosition Position { get; set; } = ToastViewport.ToastPosition.BottomRight;
    [Parameter] public int DefaultDuration { get; set; } = 5000;
    [Parameter] public int MaxToasts { get; set; } = 5;

    protected override void OnInitialized()
    {
        ToastService.OnShow += HandleShow;
        ToastService.OnDismiss += HandleDismiss;
        ToastService.OnUpdate += HandleUpdate;
    }

    private void HandleShow(ToastMessage message)
    {
        SafeAsyncDispatcher.FireAndForget(InvokeAsync, () => HandleShowAsync(message), "ToastProvider.HandleShow");
    }

    private async Task HandleShowAsync(ToastMessage message)
    {
        var item = new ToastItem(message.Id, message.Options);

        // Enforce MaxToasts: evict oldest non-persistent toasts
        while (_toasts.Count >= MaxToasts)
        {
            var oldest = _toasts.FirstOrDefault(t => t.Options.Duration != 0);
            if (oldest is null) break;
            await RemoveItemAsync(oldest.Id);
        }

        _toasts.Add(item);
        StateHasChanged();

        var duration = message.Options.Duration ?? DefaultDuration;
        if (duration > 0)
        {
            var cts = new CancellationTokenSource();
            _timers[message.Id] = cts;
            SafeAsyncDispatcher.FireAndForget(InvokeAsync, () => DismissAfterDelayAsync(message.Id, duration, cts.Token), "ToastProvider.DismissAfterDelay");
        }
    }

    private void HandleDismiss(string toastId)
    {
        SafeAsyncDispatcher.FireAndForget(InvokeAsync, () => HandleDismissAsync(toastId), "ToastProvider.HandleDismiss");
    }

    private async Task HandleDismissAsync(string toastId)
    {
        if (toastId == "*")
        {
            var ids = _toasts.Select(t => t.Id).ToList();
            await Task.WhenAll(ids.Select(id => RemoveItemAsync(id)));
        }
        else
        {
            await RemoveItemAsync(toastId);
        }
        StateHasChanged();
    }

    private void HandleUpdate(string toastId, ToastOptions options)
    {
        SafeAsyncDispatcher.FireAndForget(InvokeAsync, () => HandleUpdateAsync(toastId, options), "ToastProvider.HandleUpdate");
    }

    private async Task HandleUpdateAsync(string toastId, ToastOptions options)
    {
        var index = _toasts.FindIndex(t => t.Id == toastId);
        if (index < 0) return;

        var updated = new ToastItem(toastId, options);
        _toasts[index] = updated;

        // Reset timer
        CancelTimer(toastId);
        var duration = options.Duration ?? DefaultDuration;
        if (duration > 0)
        {
            var cts = new CancellationTokenSource();
            _timers[toastId] = cts;
            SafeAsyncDispatcher.FireAndForget(InvokeAsync, () => DismissAfterDelayAsync(toastId, duration, cts.Token), "ToastProvider.DismissAfterDelay");
        }

        StateHasChanged();
    }

    private async Task DismissAfterDelayAsync(string id, int duration, CancellationToken token)
    {
        try
        {
            await Task.Delay(duration, token);
            // Use the async variant so the list removal is followed by StateHasChanged —
            // without it the toast was dropped from _toasts but never re-rendered away.
            await InvokeAsync(() => RemoveItemAsync(id));
        }
        catch (TaskCanceledException)
        {
            // Timer was cancelled — toast was manually dismissed or updated
        }
    }

    private void InvokeAction(ToastItem toast)
    {
        toast.Options.OnAction?.Invoke();
    }

    // Exit animation duration — must match `.animate-toast-out` in lumeo.css (220 ms).
    private const int ExitAnimationMs = 220;

    private void Remove(string id)
    {
        SafeAsyncDispatcher.FireAndForget(InvokeAsync, () => RemoveWithExitAsync(id, runOnDismiss: true), "ToastProvider.Remove");
    }

    private void RemoveItem(string id)
    {
        CancelTimer(id);
        _toasts.RemoveAll(t => t.Id == id);
    }

    private async Task RemoveItemAsync(string id)
    {
        await RemoveWithExitAsync(id, runOnDismiss: false);
    }

    private async Task RemoveWithExitAsync(string id, bool runOnDismiss)
    {
        var toast = _toasts.FirstOrDefault(t => t.Id == id);
        if (toast is null) return;

        CancelTimer(id);
        if (runOnDismiss)
        {
            toast.Options.OnDismiss?.Invoke();
        }
        // Swap in the leaving marker so the render loop adds the exit animation class,
        // then drop the toast after the animation would have played.
        var index = _toasts.IndexOf(toast);
        if (index >= 0)
        {
            _toasts[index] = toast with { Leaving = true };
            StateHasChanged();
            var exitCts = new CancellationTokenSource();
            _exitTimers[id] = exitCts;
            try
            {
                await Task.Delay(ExitAnimationMs, exitCts.Token);
            }
            catch (OperationCanceledException) { }
            finally
            {
                if (_exitTimers.TryGetValue(id, out var storedCts) && storedCts == exitCts)
                {
                    _exitTimers.Remove(id);
                    exitCts.Dispose();
                }
            }
        }
        _toasts.RemoveAll(t => t.Id == id);
        await InvokeAsync(StateHasChanged);
    }

    private void CancelTimer(string id)
    {
        if (_timers.TryGetValue(id, out var cts))
        {
            cts.Cancel();
            cts.Dispose();
            _timers.Remove(id);
        }
        if (_exitTimers.TryGetValue(id, out var exitCts))
        {
            exitCts.Cancel();
            exitCts.Dispose();
            _exitTimers.Remove(id);
        }
    }

    public async ValueTask DisposeAsync()
    {
        ToastService.OnShow -= HandleShow;
        ToastService.OnDismiss -= HandleDismiss;
        ToastService.OnUpdate -= HandleUpdate;

        foreach (var cts in _timers.Values)
        {
            cts.Cancel();
            cts.Dispose();
        }
        _timers.Clear();

        foreach (var cts in _exitTimers.Values)
        {
            cts.Cancel();
            cts.Dispose();
        }
        _exitTimers.Clear();

        await Task.CompletedTask;
    }

    private record ToastItem(string Id, ToastOptions Options, bool Leaving = false);

    /// <summary>Resolves a per-toast <see cref="ToastPosition"/> to the
    /// <see cref="ToastViewport.ToastPosition"/> the viewport component uses,
    /// falling back to the provider's default <see cref="Position"/> when the
    /// toast didn't override. Two enums exist (one in
    /// <c>Lumeo.Services</c> for the service-facing API, one nested in
    /// <c>ToastViewport</c> for the visual component) — same members,
    /// integer-cast bridges them. (rc.42)</summary>
    private ToastViewport.ToastPosition ResolvePosition(ToastPosition? optionsPosition)
        => optionsPosition is { } p ? (ToastViewport.ToastPosition)(int)p : Position;
}
