@page "/alarms"
@implements IAsyncDisposable
@using ZB.MOM.WW.MxGateway.Server.Alarms
@inject IDashboardLiveDataService LiveData
@inject IOptions<GatewayOptions> GatewayOptions
@inject IGatewayAlarmService AlarmService

<PageTitle>Dashboard Alarms</PageTitle>

<div class="dashboard-page-header">
    <div>
        <h1>Alarms</h1>
        <div class="text-secondary">@HeaderLine()</div>
    </div>
    <div class="d-flex align-items-center gap-2">
        <span class="badge @_providerStatus.BadgeCssClass"
              title="@ProviderStatusTitle()">
            @_providerStatus.Label
        </span>
    </div>
</div>

@if (!GatewayOptions.Value.Alarms.Enabled)
{
    <div class="alert alert-danger">
        Alarm auto-subscribe is disabled (<code>MxGateway:Alarms:Enabled</code> is false). The
        dashboard session is not subscribed to any alarm provider, so this list will stay empty.
        Enable alarms in configuration and restart the gateway.
    </div>
}

@if (!string.IsNullOrWhiteSpace(_queryError))
{
    <div class="alert alert-danger">Alarm query failed: @_queryError</div>
}

@* Warning, not danger: the rows below are all real and the monitor is healthy — only the
   completeness of the set is in doubt, so this must not read as "alarms are broken". *@
@if (_snapshotTruncated)
{
    <div class="alert alert-warning">
        Alarm snapshot may be incomplete — the provider returned a capped fetch, so alarms beyond
        the cap are not listed. Alarms already known stay listed rather than clearing. Raise
        <code>MxGateway:Alarms:MaxAlarmsPerFetch</code> or narrow the subscription if this persists.
    </div>
}

<section class="metric-grid compact">
    <MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
    <MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
    <MetricCard Label="Total Active" Value="@_alarms.Count.ToString("N0")" />
    <MetricCard Label="Showing" Value="@FilteredAlarms().Count.ToString("N0")" />
</section>

<section class="dashboard-section">
    <div class="section-heading">
        <h2>Filters</h2>
    </div>
    <div class="alarm-filters">
        <label class="alarm-filter-check">
            <input type="checkbox" @bind="_showActive" />
            <span>Active (unacked)</span>
        </label>
        <label class="alarm-filter-check">
            <input type="checkbox" @bind="_showAcked" />
            <span>Acknowledged</span>
        </label>
        <div class="alarm-filter-field">
            <label class="form-label" for="alarm-area">Area</label>
            <select id="alarm-area" class="form-select form-select-sm" @bind="_areaFilter">
                <option value="">All areas</option>
                @foreach (string area in Areas())
                {
                    <option value="@area">@area</option>
                }
            </select>
        </div>
        <div class="alarm-filter-field">
            <label class="form-label" for="alarm-sev-min">Min severity</label>
            <input id="alarm-sev-min" type="number" class="form-control form-control-sm" @bind="_minSeverity" />
        </div>
        <div class="alarm-filter-field">
            <label class="form-label" for="alarm-sev-max">Max severity</label>
            <input id="alarm-sev-max" type="number" class="form-control form-control-sm" @bind="_maxSeverity" />
        </div>
        <div class="alarm-filter-field alarm-filter-grow">
            <label class="form-label" for="alarm-search">Search</label>
            <input id="alarm-search" class="form-control form-control-sm"
                   placeholder="Reference, source or description…"
                   @bind="_search" @bind:event="oninput" />
        </div>
    </div>
</section>

<section class="dashboard-section">
    <div class="section-heading">
        <h2>Active Alarms</h2>
    </div>
    @{
        IReadOnlyList<DashboardActiveAlarm> rows = FilteredAlarms();
    }
    @if (rows.Count == 0)
    {
        <div class="empty-state">
            @if (_alarms.Count == 0)
            {
                <span>No alarms are currently Active or ActiveAcked.</span>
            }
            else
            {
                <span>No alarms match the current filters.</span>
            }
        </div>
    }
    else
    {
        <div class="table-responsive">
            <table class="table table-sm dashboard-table">
                <thead>
                    <tr>
                        <th scope="col">State</th>
                        <th scope="col">Severity</th>
                        <th scope="col">Alarm Reference</th>
                        <th scope="col">Source</th>
                        <th scope="col">Type</th>
                        <th scope="col">Area</th>
                        <th scope="col">Last Transition</th>
                        <th scope="col">Operator</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach (DashboardActiveAlarm alarm in rows)
                    {
                        <tr>
                            <td><span class="alarm-state @StateClass(alarm.State)">@StateText(alarm.State)</span></td>
                            <td class="alarm-severity">@alarm.Severity</td>
                            <td>
                                <code>@alarm.Reference</code>
                                @if (!string.IsNullOrWhiteSpace(alarm.Description))
                                {
                                    <div class="alarm-desc">@alarm.Description</div>
                                }
                            </td>
                            <td>@DashboardDisplay.Text(alarm.Source)</td>
                            <td>@DashboardDisplay.Text(alarm.AlarmType)</td>
                            <td>@DashboardDisplay.Text(alarm.Area)</td>
                            <td>@(alarm.LastTransition is { } ts ? DashboardDisplay.DateTime(ts) : "-")</td>
                            <td>
                                @DashboardDisplay.Text(alarm.OperatorUser)
                                @if (!string.IsNullOrWhiteSpace(alarm.OperatorComment))
                                {
                                    <div class="alarm-desc">@alarm.OperatorComment</div>
                                }
                            </td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>
    }
    <div class="browse-search-note">
        Cleared alarms are not retained — this list reflects only alarms currently Active or
        ActiveAcked, refreshed every 3 seconds.
    </div>
</section>

@code {
    private readonly List<DashboardActiveAlarm> _alarms = [];
    private string? _queryError;
    private bool _snapshotTruncated;
    private int? _workerPid;
    private DateTimeOffset? _lastRefresh;
    private int _unackedCount;
    private int _ackedCount;

    private bool _showActive = true;
    private bool _showAcked;
    private string _areaFilter = string.Empty;
    private int _minSeverity;
    private int _maxSeverity = 1000;
    private string _search = string.Empty;

    // Upper bound on waiting for either background loop while disposing, mirroring
    // DashboardPageBase's snapshot-watch drain: both loops marshal renders through the
    // renderer's dispatcher and disposal can run on that same dispatcher, so the wait is
    // bounded rather than unconditional — an unconditional one hangs teardown for good on
    // a wedged dispatcher.
    private static readonly TimeSpan LoopDrainTimeout = TimeSpan.FromSeconds(5);

    private readonly CancellationTokenSource _cts = new();
    private Task? _pollTask;

    private DashboardAlarmProviderStatus _providerStatus = DashboardAlarmProviderStatus.Healthy;
    private Task? _providerStatusTask;

    /// <inheritdoc />
    protected override void OnInitialized()
    {
        _pollTask = PollLoopAsync();
        _providerStatusTask = ProviderStatusLoopAsync();
    }

    private string? ProviderStatusTitle()
    {
        return _providerStatus.IsDegraded && !string.IsNullOrWhiteSpace(_providerStatus.Reason)
            ? _providerStatus.Reason
            : null;
    }

    // The badge tracks the central monitor directly rather than looping back through
    // /hubs/alarms: the alarm service is an in-process multi-subscriber fan-out, so a
    // server-rendered page needs no SignalR client, no loopback socket and no auth token.
    // Alarm rows still come from the 3-second poll below — this loop only feeds the badge.
    private async Task ProviderStatusLoopAsync()
    {
        while (!_cts.IsCancellationRequested)
        {
            try
            {
                await foreach (AlarmFeedMessage message in AlarmService
                    .StreamAsync(alarmFilterPrefix: null, _cts.Token)
                    .ConfigureAwait(false))
                {
                    if (message.PayloadCase != AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
                    {
                        continue;
                    }

                    await InvokeAsync(() =>
                    {
                        _providerStatus = DashboardAlarmProviderStatus.FromFeed(message);
                        StateHasChanged();
                    }).ConfigureAwait(false);
                }
            }
            catch (OperationCanceledException)
            {
                return;
            }
            catch
            {
                // The monitor completes a subscriber's stream when it falls behind, and
                // again when the monitor restarts. Both are recoverable by resubscribing;
                // the badge holds its last value in the meantime.
            }

            try
            {
                await Task.Delay(TimeSpan.FromSeconds(1), _cts.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                return;
            }
        }
    }

    private string HeaderLine()
    {
        string refreshed = _lastRefresh is { } at
            ? $"refreshed {DashboardDisplay.DateTime(at)}"
            : "awaiting first refresh";
        return _workerPid is int pid ? $"{refreshed} · worker pid {pid}" : refreshed;
    }

    private IReadOnlyList<string> Areas()
    {
        return _alarms
            .Select(alarm => alarm.Area)
            .Where(area => !string.IsNullOrWhiteSpace(area))
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .OrderBy(area => area, StringComparer.OrdinalIgnoreCase)
            .ToArray();
    }

    private IReadOnlyList<DashboardActiveAlarm> FilteredAlarms()
    {
        string query = _search.Trim();
        return _alarms
            .Where(MatchesState)
            .Where(alarm => _areaFilter.Length == 0
                || string.Equals(alarm.Area, _areaFilter, StringComparison.OrdinalIgnoreCase))
            .Where(alarm => alarm.Severity >= _minSeverity && alarm.Severity <= _maxSeverity)
            .Where(alarm => query.Length == 0
                || alarm.Reference.Contains(query, StringComparison.OrdinalIgnoreCase)
                || alarm.Source.Contains(query, StringComparison.OrdinalIgnoreCase)
                || alarm.Description.Contains(query, StringComparison.OrdinalIgnoreCase))
            .OrderByDescending(alarm => alarm.Severity)
            .ThenByDescending(alarm => alarm.LastTransition ?? DateTimeOffset.MinValue)
            .ToArray();
    }

    private bool MatchesState(DashboardActiveAlarm alarm)
    {
        return alarm.State switch
        {
            AlarmConditionState.Active => _showActive,
            AlarmConditionState.ActiveAcked => _showAcked,
            _ => true,
        };
    }

    private static string StateText(AlarmConditionState state)
    {
        return state switch
        {
            AlarmConditionState.Active => "Active",
            AlarmConditionState.ActiveAcked => "Acked",
            AlarmConditionState.Inactive => "Inactive",
            _ => "Unknown",
        };
    }

    private static string StateClass(AlarmConditionState state)
    {
        return state switch
        {
            AlarmConditionState.Active => "alarm-state-active",
            AlarmConditionState.ActiveAcked => "alarm-state-acked",
            _ => "alarm-state-other",
        };
    }

    // Fault handling sits inside the loop, matching ProviderStatusLoopAsync: a query or render
    // fault on one tick is transient (a provider blip, a momentarily unavailable session), so it
    // is surfaced on the page and retried on the next tick rather than ending polling for the
    // life of the page. Cancellation is the only exit. The loop method itself therefore cannot
    // fault, which is what DrainAsync in DisposeAsync relies on.
    private async Task PollLoopAsync()
    {
        if (!await PollOnceAsync().ConfigureAwait(false))
        {
            return;
        }

        using PeriodicTimer timer = new(TimeSpan.FromSeconds(3));
        while (true)
        {
            try
            {
                if (!await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
                {
                    return;
                }
            }
            catch (OperationCanceledException)
            {
                return;
            }

            if (!await PollOnceAsync().ConfigureAwait(false))
            {
                return;
            }
        }
    }

    // Returns false only when cancellation has ended the poll; a non-cancellation fault returns
    // true so the caller waits for the next tick and tries again.
    private async Task<bool> PollOnceAsync()
    {
        try
        {
            await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
            return true;
        }
        catch (OperationCanceledException)
        {
            return false;
        }
        catch (Exception ex)
        {
            await ReportPollFaultAsync(ex).ConfigureAwait(false);
            return true;
        }
    }

    private async Task ReportPollFaultAsync(Exception fault)
    {
        try
        {
            await InvokeAsync(() =>
            {
                _queryError = fault.Message;
                StateHasChanged();
            }).ConfigureAwait(false);
        }
        catch
        {
            // Reporting is best-effort: the fault being reported may itself be the teardown race
            // this catch-all exists for — an InvokeAsync against a disposed renderer — in which
            // case the dispatch fails the same way and there is no page left to show it on. The
            // poll loop keeps ticking either way and exits on the next cancellation check.
        }
    }

    private async Task RefreshAlarmsAsync()
    {
        DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
        _queryError = result.Error;
        _snapshotTruncated = result.SnapshotTruncated;
        _workerPid = result.WorkerProcessId;
        _lastRefresh = DateTimeOffset.UtcNow;
        _alarms.Clear();
        _alarms.AddRange(result.Alarms);
        _unackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.Active);
        _ackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.ActiveAcked);
        StateHasChanged();
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        await _cts.CancelAsync().ConfigureAwait(false);

        // Drained together, not one after the other: the wedged dispatcher this bound exists
        // for blocks both loops at once, so sequential drains would time out twice and make
        // the real bound 10 seconds. DrainAsync tolerates a null task.
        await Task.WhenAll(DrainAsync(_pollTask), DrainAsync(_providerStatusTask))
            .ConfigureAwait(false);

        _cts.Dispose();
        GC.SuppressFinalize(this);
    }

    // The accepted cost of the bound is an abandoned loop that keeps its alarm-service
    // subscription (and its poll timer) until it does unwind; the alternative — waiting
    // forever on a dispatcher that is not draining — wedges the circuit teardown itself.
    private static async Task DrainAsync(Task? loop)
    {
        if (loop is null)
        {
            return;
        }

        try
        {
            await loop.WaitAsync(LoopDrainTimeout).ConfigureAwait(false);
        }
        catch (TimeoutException)
        {
        }
        catch (OperationCanceledException)
        {
        }
        catch
        {
            // Other disposal-time errors are best-effort.
        }
    }
}
