@page "/alerts"
@* Live alarm tail via SignalR. Subscribes to /hubs/alerts and shows the most-recent
   AlarmTransitionEvent entries published by ScriptedAlarmActor (Runtime/ScriptedAlarms)
   and the AB CIP ALMD bridge. *@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin
@using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts
@inject IInProcessBroadcaster<AlarmTransitionEvent> Alarms
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
@inject IAdminOperationsClient AdminOps
@implements IAsyncDisposable

<div class="d-flex justify-content-between align-items-center mb-3">
    <h4 class="mb-0">Alerts</h4>
    <div class="d-flex align-items-center gap-2">
        <span class="conn-pill" data-state="@(_connected ? "connected" : "disconnected")">
            <span class="dot"></span><span>@(_connected ? "live" : "disconnected")</span>
        </span>
        <button class="btn btn-sm btn-outline-secondary" @onclick="ClearAsync">Clear</button>
    </div>
</div>

<section class="panel notice rise" style="animation-delay:.02s">
    Live alarm transitions from the cluster's <span class="mono">alerts</span> DPS topic. Shows
    the most-recent @Capacity entries since the page opened; reload for a fresh window. Sources:
    ScriptedAlarmActor, native driver alarm bridges (AB CIP ALMD, Galaxy where wired).
</section>

@if (_rows.Count == 0)
{
    <section class="panel notice rise mt-3" style="animation-delay:.08s">
        No alarms in the current window. The table will populate as soon as a
        ScriptedAlarmActor or driver alarm bridge publishes a transition.
    </section>
}
else
{
    <section class="panel rise mt-3" style="animation-delay:.08s">
        <div class="panel-head">Recent transitions (@_rows.Count)</div>
        <div class="table-wrap">
            <table class="data-table">
                <thead>
                    <tr>
                        <th>Time</th>
                        <th>Alarm</th>
                        <th>Equipment</th>
                        <th>Kind</th>
                        <th class="num">Severity</th>
                        <th>User</th>
                        <th>Message</th>
                        @if (_canOperate)
                        {
                            <th>Actions</th>
                        }
                    </tr>
                </thead>
                <tbody>
                    @foreach (var e in _rows)
                    {
                        <tr>
                            <td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
                            <td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
                            <td><span class="mono small">@e.EquipmentPath</span></td>
                            <td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
                            <td class="num">@e.Severity</td>
                            <td>@e.User</td>
                            <td>@e.Message</td>
                            @if (_canOperate)
                            {
                                @* DriverOperator-gated Acknowledge / Shelve / Unshelve. Each routes through
                                   the AdminOperationsActor singleton, which republishes onto the cluster
                                   'alarm-commands' topic; the owning node applies it (ownership filter). *@
                                <td>
                                    <div class="d-flex gap-1 align-items-center">
                                        <button type="button"
                                                class="btn btn-sm btn-outline-secondary"
                                                disabled="@_busyAlarmId.Equals(e.AlarmId)"
                                                @onclick="() => AcknowledgeAsync(e.AlarmId)"
                                                title="Acknowledge this alarm">Ack</button>
                                        <button type="button"
                                                class="btn btn-sm btn-outline-secondary"
                                                disabled="@_busyAlarmId.Equals(e.AlarmId)"
                                                @onclick="() => ShelveAsync(e.AlarmId, ShelveKind.OneShot)"
                                                title="Shelve this alarm until it next clears">Shelve</button>
                                        <input type="number" min="1" class="form-control form-control-sm" style="width:4.5rem"
                                               disabled="@_busyAlarmId.Equals(e.AlarmId)"
                                               @bind:get="GetShelveMinutes(e.AlarmId)" @bind:set="m => SetShelveMinutes(e.AlarmId, m)"
                                               title="Timed-shelve duration in minutes" />
                                        <button type="button"
                                                class="btn btn-sm btn-outline-secondary"
                                                disabled="@_busyAlarmId.Equals(e.AlarmId)"
                                                @onclick="() => ShelveTimedAsync(e.AlarmId)"
                                                title="Shelve this alarm for the chosen number of minutes">Shelve (timed)</button>
                                        <button type="button"
                                                class="btn btn-sm btn-outline-secondary"
                                                disabled="@_busyAlarmId.Equals(e.AlarmId)"
                                                @onclick="() => ShelveAsync(e.AlarmId, ShelveKind.Unshelve)"
                                                title="Remove an existing shelve">Unshelve</button>
                                    </div>
                                    @if (_opResultAlarmId.Equals(e.AlarmId) && _opResultMessage is not null)
                                    {
                                        <span class="chip @(_opResultOk ? "chip-ok" : "chip-bad")" style="font-size:0.8rem">@_opResultMessage</span>
                                    }
                                </td>
                            }
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    </section>
}

@code {
    private const int Capacity = 200;

    private readonly List<AlarmTransitionEvent> _rows = new();
    private bool _connected;

    // Authorization — DriverOperator gates the per-row Ack/Shelve/Unshelve controls.
    private bool _canOperate;

    // Per-row action state. Only one alarm action is in flight at a time; the busy/result
    // fields are keyed by AlarmId so the spinner + result chip attach to the right row.
    private string _busyAlarmId = "";
    private string _opResultAlarmId = "";
    private string? _opResultMessage;
    private bool _opResultOk;

    // Auto-clear timer for the per-row result chip (mirrors DriverStatusPanel): the chip is set in
    // ShowOpResult and cleared 8 s later so it doesn't persist until the next action.
    private System.Threading.Timer? _opResultClearTimer;
    private object? _opResultClearToken;

    // Per-row timed-shelve duration (minutes). Keyed by AlarmId so each row's number input is
    // independent — binding every row to one shared field would couple all the inputs together.
    private const int DefaultShelveMinutes = 5;
    private readonly Dictionary<string, int> _shelveMinutes = new();
    private int GetShelveMinutes(string alarmId) =>
        _shelveMinutes.TryGetValue(alarmId, out var m) ? m : DefaultShelveMinutes;
    private void SetShelveMinutes(string alarmId, int minutes) =>
        _shelveMinutes[alarmId] = minutes < 1 ? 1 : minutes;

    protected override async Task OnInitializedAsync()
    {
        // Live alarm tail straight from the in-process broadcaster (fed by AlertSignalRBridge off the
        // 'alerts' DPS topic). A Blazor Server component can't self-connect a SignalR HubConnection
        // behind a reverse proxy — see IInProcessBroadcaster — so we subscribe in-process instead.
        // Subscribe to the health signal BEFORE reading IsConnected so a state change between the
        // read and the subscribe can't be missed (TOCTOU) — mirrors DriverStatusPanel's
        // SnapshotChanged-then-TryGet ordering.
        Alarms.ConnectionStateChanged += OnConnectionStateChanged;
        Alarms.Received += OnAlarm;
        _connected = Alarms.IsConnected;

        // Check DriverOperator authorization so the per-row action controls only render for
        // permitted users. The username is re-read at click time (GetCurrentUserNameAsync) so a
        // mid-session token refresh lands in the published command + audit accurately.
        var auth = await AuthState.GetAuthenticationStateAsync();
        var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
        _canOperate = authResult.Succeeded;
    }

    private async Task AcknowledgeAsync(string alarmId)
    {
        _busyAlarmId = alarmId;
        _opResultMessage = null;
        StateHasChanged();
        try
        {
            var user = await GetCurrentUserNameAsync();
            using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
            var result = await AdminOps.AcknowledgeAlarmAsync(
                alarmId, user, comment: null, cts.Token);
            ShowOpResult(alarmId, result.Ok, result.Ok ? "Ack sent" : (result.Message ?? "Failed"));
        }
        catch (Exception ex)
        {
            ShowOpResult(alarmId, false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
        }
        finally
        {
            _busyAlarmId = "";
            StateHasChanged();
        }
    }

    // Thin wrapper for the per-row "Shelve (timed)" button: shelves until now + the row's chosen
    // minutes. OneShot/Unshelve call ShelveAsync directly with the default null unshelve-at.
    private Task ShelveTimedAsync(string alarmId) =>
        ShelveAsync(alarmId, ShelveKind.Timed, DateTime.UtcNow.AddMinutes(GetShelveMinutes(alarmId)));

    private async Task ShelveAsync(string alarmId, ShelveKind kind, DateTime? unshelveAtUtc = null)
    {
        _busyAlarmId = alarmId;
        _opResultMessage = null;
        StateHasChanged();
        try
        {
            var user = await GetCurrentUserNameAsync();
            using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
            var result = await AdminOps.ShelveAlarmAsync(
                alarmId, user, kind, unshelveAtUtc: unshelveAtUtc, comment: null, cts.Token);
            var verb = kind == ShelveKind.Unshelve ? "Unshelve" : "Shelve";
            ShowOpResult(alarmId, result.Ok, result.Ok ? $"{verb} sent" : (result.Message ?? "Failed"));
        }
        catch (Exception ex)
        {
            ShowOpResult(alarmId, false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
        }
        finally
        {
            _busyAlarmId = "";
            StateHasChanged();
        }
    }

    /// <summary>
    ///     Re-reads the AuthenticationState at call time so the operator name forwarded to the
    ///     command + audit reflects the current claims-principal (survives token refresh during a
    ///     long-lived circuit). Returns "unknown" if no Name claim is present.
    /// </summary>
    private async Task<string> GetCurrentUserNameAsync()
    {
        var auth = await AuthState.GetAuthenticationStateAsync();
        return auth.User.Identity?.Name
            ?? auth.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
            ?? "unknown";
    }

    private void ShowOpResult(string alarmId, bool ok, string message)
    {
        _opResultAlarmId = alarmId;
        _opResultOk = ok;
        _opResultMessage = message;
        StateHasChanged();
        // Auto-clear the result chip after 8 s (mirrors DriverStatusPanel). System.Threading.Timer
        // is used (not System.Timers.Timer) so DisposeAsync can drain any in-flight callback.
        var token = _opResultClearToken = new object();
        _opResultClearTimer?.Dispose();
        _opResultClearTimer = new System.Threading.Timer(_ =>
            InvokeAsync(() =>
            {
                // Ignore a stale fire: a newer action superseded this timer's chip. (Timer.Dispose does
                // not drain an already-queued callback, so without this a stale timer could clear a
                // freshly-set result from a different row.)
                if (!ReferenceEquals(_opResultClearToken, token)) return;
                _opResultMessage = null;
                _opResultAlarmId = "";
                StateHasChanged();
            }),
            null, TimeSpan.FromSeconds(8), Timeout.InfiniteTimeSpan);
    }

    private void OnAlarm(AlarmTransitionEvent evt) =>
        // Marshal both the mutation and the re-render onto the circuit sync context so this can't
        // race ClearAsync (which runs there) over the shared _rows list.
        InvokeAsync(() =>
        {
            _rows.Insert(0, evt);
            if (_rows.Count > Capacity) _rows.RemoveAt(_rows.Count - 1);
            StateHasChanged();
        });

    // Raised by the broadcaster (on the bridge actor's thread) whenever the upstream DPS
    // subscription's health flips. Marshal onto the circuit sync context like OnAlarm so the live
    // pill re-renders safely.
    private void OnConnectionStateChanged(bool connected) =>
        InvokeAsync(() => { _connected = connected; StateHasChanged(); });

    private async Task ClearAsync()
    {
        _rows.Clear();
        await InvokeAsync(StateHasChanged);
    }

    private static string KindChipClass(string kind) => kind switch
    {
        "Activated" => "chip-alert",
        "Cleared" => "chip-ok",
        "Acknowledged" or "Confirmed" => "chip-caution",
        "Shelved" or "Disabled" => "chip-idle",
        _ => "chip-idle",
    };

    public async ValueTask DisposeAsync()
    {
        // Unsubscribe BOTH broadcaster events first so the singleton can't invoke a handler on a
        // disposed component, then drain the auto-clear timer (its async dispose awaits any in-flight
        // callback so it can't call StateHasChanged on a gone component).
        Alarms.ConnectionStateChanged -= OnConnectionStateChanged;
        Alarms.Received -= OnAlarm;
        if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync();
    }
}
