@page "/sessions/{SessionId}"
@inherits DashboardPageBase
@* Load-bearing: DisposeAsync below hides the base method with `new`, so Blazor only calls
   it because this directive re-declares IAsyncDisposable on the derived component. Drop
   this line and the base's DisposeAsync runs instead — the event subscription and pump
   leak, silently. *@
@implements IAsyncDisposable
@using ZB.MOM.WW.MxGateway.Contracts.Proto
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardSessionAdminService SessionAdminService
@inject IDashboardSessionEventSubscriber EventSubscriber
@inject IDashboardSessionAcl SessionAcl

<PageTitle>Dashboard Session</PageTitle>

@if (Snapshot is null)
{
    <div class="empty-state">Loading session.</div>
}
else if (CurrentSession is null)
{
    <section class="dashboard-section">
        <h1 class="h4 mb-3">Session Not Found</h1>
        <p class="mb-0">The session is not present in the current snapshot.</p>
    </section>
}
else
{
    <div class="dashboard-page-header">
        <div>
            <h1>Session Details</h1>
            <div class="text-secondary"><code>@CurrentSession.SessionId</code></div>
        </div>
        <div class="d-flex align-items-center gap-2">
            <StatusBadge Text="@CurrentSession.State.ToString()" />
            @if (CanManage)
            {
                <div class="btn-group btn-group-sm" role="group" aria-label="Session admin actions">
                    <button type="button" class="btn btn-outline-warning"
                            disabled="@IsBusy"
                            @onclick="RequestClose">
                        Close session
                    </button>
                    <button type="button" class="btn btn-outline-danger"
                            disabled="@IsBusy"
                            @onclick="RequestKill">
                        Kill worker
                    </button>
                </div>
            }
        </div>
    </div>

    @if (CanManage && !string.IsNullOrWhiteSpace(ResultMessage))
    {
        <div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
            @ResultMessage
        </div>
    }

    @if (CanManage)
    {
        <ConfirmDialog IsOpen="@(PendingAction is not null)"
                       Title="@(PendingAction?.Title ?? string.Empty)"
                       Message="@(PendingAction?.Message ?? string.Empty)"
                       ConfirmLabel="@(PendingAction?.ConfirmLabel ?? "Confirm")"
                       ConfirmButtonClass="@(PendingAction?.ConfirmButtonClass ?? "btn-primary")"
                       IsBusy="IsBusy"
                       OnConfirm="ConfirmPendingAsync"
                       OnCancel="CancelPending" />
    }

    <section class="dashboard-section">
        <div class="section-heading">
            <h2>Session</h2>
        </div>
        <div class="table-responsive">
            <table class="table table-sm dashboard-table details-table">
                <tbody>
                    <tr><th scope="row">Backend</th><td>@CurrentSession.BackendName</td></tr>
                    <tr><th scope="row">Client identity</th><td>@DashboardDisplay.Text(CurrentSession.ClientIdentity)</td></tr>
                    <tr><th scope="row">Client session</th><td>@DashboardDisplay.Text(CurrentSession.ClientSessionName)</td></tr>
                    <tr><th scope="row">Client correlation</th><td>@DashboardDisplay.Text(CurrentSession.ClientCorrelationId)</td></tr>
                    <tr><th scope="row">Opened</th><td>@DashboardDisplay.DateTime(CurrentSession.OpenedAt)</td></tr>
                    <tr><th scope="row">Last activity</th><td>@DashboardDisplay.DateTime(CurrentSession.LastClientActivityAt)</td></tr>
                    <tr><th scope="row">Lease expires</th><td>@DashboardDisplay.DateTime(CurrentSession.LeaseExpiresAt)</td></tr>
                    <tr><th scope="row">Events received</th><td>@DashboardDisplay.Count(CurrentSession.EventsReceived)</td></tr>
                    <tr><th scope="row">Last fault</th><td>@DashboardDisplay.Text(CurrentSession.LastFault)</td></tr>
                </tbody>
            </table>
        </div>
    </section>

    <section class="dashboard-section">
        <div class="section-heading">
            <h2>Worker</h2>
        </div>
        <div class="table-responsive">
            <table class="table table-sm dashboard-table details-table">
                <tbody>
                    <tr><th scope="row">Process id</th><td>@(CurrentSession.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td></tr>
                    <tr><th scope="row">State</th><td><StatusBadge Text="@(CurrentSession.WorkerState?.ToString() ?? "-")" /></td></tr>
                    <tr><th scope="row">Last heartbeat</th><td>@DashboardDisplay.DateTime(CurrentSession.LastWorkerHeartbeatAt)</td></tr>
                </tbody>
            </table>
        </div>
    </section>

    <section class="dashboard-section">
        <div class="section-heading">
            <h2>Recent events</h2>
            <span class="conn-pill" data-state="@(_eventsConnected ? "connected" : "disconnected")">
                <span class="dot"></span>
                <span>@(_eventsConnected ? "live" : "offline")</span>
            </span>
        </div>
        @if (!_eventsAuthorized)
        {
            <div class="empty-state">Not authorized for this session's events.</div>
        }
        else if (_recentEvents.Count == 0)
        {
            <div class="empty-state">
                Waiting for events. The dashboard subscribes to this session's events directly, so
                rows appear as the session's worker emits them while this page is open — no gRPC
                client has to be consuming the session.
            </div>
        }
        else
        {
            <div class="table-responsive">
                <table class="table table-sm dashboard-table">
                    <thead>
                        <tr>
                            <th scope="col">Worker seq</th>
                            <th scope="col">Family</th>
                            <th scope="col">Server</th>
                            <th scope="col">Item</th>
                            <th scope="col">Status</th>
                        </tr>
                    </thead>
                    <tbody>
                        @foreach (MxEvent evt in _recentEvents)
                        {
                            <tr>
                                <td class="num mono">@evt.WorkerSequence</td>
                                <td>@evt.Family</td>
                                <td class="num mono">@evt.ServerHandle</td>
                                <td class="num mono">@evt.ItemHandle</td>
                                <td>@DashboardDisplay.Text(EventStatusLabel(evt))</td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>
        }
    </section>
}

@code {
    private const int MaxRecentEvents = 50;

    [Parameter]
    public string SessionId { get; set; } = string.Empty;

    private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
        string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));

    // Upper bound on waiting for the event pump while detaching, mirroring
    // DashboardPageBase's snapshot-watch drain: the pump marshals renders through the
    // renderer's dispatcher and a detach can run on that same dispatcher, so the wait
    // is bounded rather than unconditional.
    private static readonly TimeSpan EventPumpDrainTimeout = TimeSpan.FromSeconds(5);

    // Written only on the renderer's dispatcher (the lifecycle methods below), and read
    // on it from inside the pump's dispatched callback — that pairing is what makes the
    // stale-batch guard in PumpEventsAsync reliable.
    private IDashboardEventSubscription? _eventSubscription;
    private CancellationTokenSource? _eventPumpCancellation;
    private Task? _eventPumpTask;
    private bool _eventsConnected;
    // Renders the denial message in place of the events panel's empty state. Starts true so the
    // panel reads as "waiting" until the gate has actually been evaluated for a session id;
    // AttachEventsAsync is the only writer, and it writes on the renderer's dispatcher.
    private bool _eventsAuthorized = true;
    private string? _subscribedSessionId;
    // Identifies the attach currently entitled to publish subscription state. Bumped
    // synchronously by OnParametersSetAsync before it awaits anything, so a suspended
    // AttachEventsAsync continuation can tell that a newer parameter set overtook it — the
    // same dispatcher-owned identity idea as the ReferenceEquals guards in PumpEventsAsync
    // and MarkDisconnectedAsync, one level up. Without it, the await on the authentication
    // state opens a window in which a rapid A -> B navigation lets the stale continuation
    // re-read the live SessionId and attach B a second time, orphaning B's first
    // subscription (never disposed, its viewer registration never released, its pump never
    // cancelled) behind the fields it overwrites.
    private int _attachGeneration;
    private readonly LinkedList<MxEvent> _recentEvents = new();

    private bool CanManage { get; set; }

    private bool IsBusy { get; set; }

    private string? ResultMessage { get; set; }

    private bool LastOperationSucceeded { get; set; }

    protected override async Task OnInitializedAsync()
    {
        await base.OnInitializedAsync().ConfigureAwait(false);

        AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
            .ConfigureAwait(false);
        CanManage = SessionAdminService.CanManage(authenticationState.User);
    }

    protected override async Task OnParametersSetAsync()
    {
        if (!string.Equals(_subscribedSessionId, SessionId, StringComparison.Ordinal))
        {
            // Claimed before the first await, so every attach that follows carries a token
            // that a later parameter set can invalidate. DetachEventsAsync needs no such
            // guard: it captures and nulls the live fields synchronously before it awaits,
            // so a resumed detach only unwinds what it already took ownership of.
            int generation = ++_attachGeneration;

            // Deliberately no ConfigureAwait(false): the resumption must stay on the
            // renderer's dispatcher so the new subscription is published to
            // _eventSubscription from the same thread the pump's guard reads it on.
            await DetachEventsAsync();
            await AttachEventsAsync(generation);
        }
    }

    private PendingConfirm? PendingAction { get; set; }

    private void RequestClose()
    {
        if (IsBusy)
        {
            return;
        }

        PendingAction = new PendingConfirm(
            Title: "Close session?",
            Message: $"Gracefully close session {SessionId}? The worker will be shut down.",
            ConfirmLabel: "Close",
            ConfirmButtonClass: "btn-warning",
            Action: user => SessionAdminService.CloseSessionAsync(user, SessionId, CancellationToken.None));
    }

    private void RequestKill()
    {
        if (IsBusy)
        {
            return;
        }

        PendingAction = new PendingConfirm(
            Title: "Kill worker?",
            Message: $"Forcefully kill the worker for session {SessionId}? This skips graceful shutdown.",
            ConfirmLabel: "Kill",
            ConfirmButtonClass: "btn-danger",
            Action: user => SessionAdminService.KillWorkerAsync(user, SessionId, CancellationToken.None));
    }

    private void CancelPending()
    {
        if (!IsBusy)
        {
            PendingAction = null;
        }
    }

    private async Task ConfirmPendingAsync()
    {
        if (IsBusy || PendingAction is null)
        {
            return;
        }

        Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardSessionAdminResult>> action = PendingAction.Action;

        IsBusy = true;
        try
        {
            AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
                .ConfigureAwait(false);
            CanManage = SessionAdminService.CanManage(authenticationState.User);
            DashboardSessionAdminResult result = await action(authenticationState.User).ConfigureAwait(false);
            ResultMessage = result.Message;
            LastOperationSucceeded = result.Succeeded;
        }
        finally
        {
            IsBusy = false;
            PendingAction = null;
        }
    }

    private sealed record PendingConfirm(
        string Title,
        string Message,
        string ConfirmLabel,
        string ConfirmButtonClass,
        Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardSessionAdminResult>> Action);

    // The dashboard runs in the same process as the event mirror, so this page reads
    // the session's mirrored events straight from it. It used to open a loopback
    // SignalR connection to /hubs/events — mint a hub token, negotiate, hold a
    // WebSocket, serialize every event — to reach data already sitting in memory.
    // IDashboardSessionEventSubscriber resolves to the same singleton that serves
    // IDashboardEventBroadcaster, and the subscription registers with
    // EventsHubViewerRegistry, so the "nobody is watching" gate keeps working for
    // both audiences.
    // ACL posture matches the hub path exactly: IDashboardSessionAcl gates this seam with the
    // same decision EventsHub.SubscribeSession applies (SEC-25 / TST-15). The gate wraps only
    // whether a subscription is created at all — the generation guards, the pump, and the detach
    // coupling below it are untouched, so a denied page holds no subscription to leak and never
    // registers a viewer, which keeps the broadcaster's mirror off for that session.
    private async Task AttachEventsAsync(int generation)
    {
        if (string.IsNullOrWhiteSpace(SessionId))
        {
            return;
        }

        // Deliberately no ConfigureAwait(false): the decision and everything it publishes must
        // land back on the renderer's dispatcher, which is where the fields below are owned.
        AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();

        // Checked before ANY field write and before Subscribe, because both are the damage: a
        // newer parameter set may have run start-to-finish while this continuation was parked,
        // and SessionId now reads as ITS session. Attaching here would not bypass the ACL (the
        // newer attach already cleared the same session), but it would strand the live
        // subscription — overwritten in place, so nothing ever disposes it or releases its
        // viewer registration, and the mirror stays on for a session nobody is watching. A
        // stale attach owns nothing, so it returns rather than detaching: tearing down here
        // would destroy the newer attach's subscription.
        if (generation != _attachGeneration)
        {
            return;
        }

        _subscribedSessionId = SessionId;
        _eventsAuthorized = SessionAcl.CanViewSession(authenticationState.User, SessionId);

        if (!_eventsAuthorized)
        {
            // No subscription, no pump, no viewer registration — the panel renders the denial.
            return;
        }

        _eventSubscription = EventSubscriber.Subscribe(SessionId);
        _eventPumpCancellation = new CancellationTokenSource();
        _eventsConnected = true;

        // Deliberately not awaited: the pump runs for as long as the page watches this
        // session and is cancelled and drained by DetachEventsAsync.
        _eventPumpTask = PumpEventsAsync(_eventSubscription, _eventPumpCancellation.Token);
    }

    private async Task PumpEventsAsync(IDashboardEventSubscription subscription, CancellationToken cancellationToken)
    {
        try
        {
            while (await subscription.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
            {
                // Drain what is queued and render once: a burst costs one render pass,
                // not one per event. Reading past the display cap would be wasted work,
                // and anything left queued is picked up on the next pass.
                List<MxEvent> batch = new();
                while (batch.Count < MaxRecentEvents && subscription.Reader.TryRead(out MxEvent? mxEvent))
                {
                    batch.Add(mxEvent);
                }

                if (batch.Count == 0)
                {
                    continue;
                }

                await InvokeAsync(() =>
                {
                    // The batch was read before this callback was dispatched, and a
                    // session switch can land in between. Rendering it then would show
                    // the previous session's events under the new session's heading, so
                    // a batch whose subscription is no longer the live one is dropped.
                    // Safe as an unsynchronized read: _eventSubscription is written on
                    // this same dispatcher.
                    if (!ReferenceEquals(_eventSubscription, subscription))
                    {
                        return;
                    }

                    foreach (MxEvent mxEvent in batch)
                    {
                        _recentEvents.AddFirst(mxEvent);
                    }

                    while (_recentEvents.Count > MaxRecentEvents)
                    {
                        _recentEvents.RemoveLast();
                    }

                    StateHasChanged();
                }).ConfigureAwait(false);
            }
        }
        catch (OperationCanceledException)
        {
            // The page navigated to another session or was disposed.
        }
        catch (ObjectDisposedException)
        {
            // Either the renderer went away mid-dispatch, or the drain below timed out
            // and disposed the cancellation source this loop is still reading.
        }
        finally
        {
            await MarkDisconnectedAsync(subscription).ConfigureAwait(false);
        }
    }

    // This pump is the only thing feeding the "live" pill, so the pill goes dark the moment
    // the pump stops — the subscription's channel completing under a page that is still
    // watching (the broadcaster dropped it, the session ended) is exactly what the pill
    // exists to show, and without this it read "live" until navigation.
    // A pump whose subscription has already been replaced must not touch it: the newer
    // subscription's pump owns the pill now. Same dispatcher-owned identity check the
    // render batch uses — and detach nulls _eventSubscription before cancelling, so a
    // detach-driven exit correctly falls through here without repainting.
    private async Task MarkDisconnectedAsync(IDashboardEventSubscription subscription)
    {
        try
        {
            await InvokeAsync(() =>
            {
                if (!ReferenceEquals(_eventSubscription, subscription))
                {
                    return;
                }

                _eventsConnected = false;
                StateHasChanged();
            }).ConfigureAwait(false);
        }
        catch (ObjectDisposedException)
        {
            // The renderer went away; there is no pill left to repaint.
        }
        catch (OperationCanceledException)
        {
            // The circuit is tearing down; same.
        }
    }

    private async Task DetachEventsAsync()
    {
        IDashboardEventSubscription? subscription = _eventSubscription;
        CancellationTokenSource? cancellation = _eventPumpCancellation;
        Task? pump = _eventPumpTask;
        _eventSubscription = null;
        _eventPumpCancellation = null;
        _eventPumpTask = null;
        _eventsConnected = false;
        _subscribedSessionId = null;
        _recentEvents.Clear();

        // Cancel and drop the subscription before draining. Disposing it releases the
        // viewer registration — the whole point of the gate — and completes the channel,
        // so the pump has an exit even if cancellation is missed.
        cancellation?.Cancel();
        subscription?.Dispose();

        try
        {
            if (pump is not null)
            {
                await pump.WaitAsync(EventPumpDrainTimeout);
            }
        }
        catch
        {
            // Detach-time errors (including a drain timeout) are best-effort.
        }

        // Disposed after the drain so the pump is no longer reading the token.
        cancellation?.Dispose();
    }

    private static string EventStatusLabel(MxEvent evt)
    {
        return evt.Family == MxEventFamily.OnAlarmTransition
            ? evt.OnAlarmTransition?.AlarmFullReference ?? string.Empty
            : string.Empty;
    }

    // `new` hides DashboardPageBase.DisposeAsync rather than overriding it (the base method
    // is not virtual), so this runs only via the IAsyncDisposable interface slot the
    // `@implements IAsyncDisposable` directive at the top of this file re-declares on the
    // derived type. Remove either half and disposal silently resolves to the base method:
    // the snapshot watch is cancelled, the event subscription and pump are not.
    public new async ValueTask DisposeAsync()
    {
        await DetachEventsAsync();
        await base.DisposeAsync().ConfigureAwait(false);
    }
}
