@page "/"
@implements IDisposable
@inject OverviewSnapshotStore Store
@inject TimeProvider Time

@* The whole dashboard, rendered from the snapshot store. This page never polls
   an instance: it subscribes to the store and re-renders when the poller swaps
   a snapshot in, plus a slow local tick so ages and staleness advance on their
   own between sweeps. *@

@* Read once per render and threaded through everything below, so every card, KPI
   and group header on one paint agrees on what "now" is. Reading the clock at
   render — rather than caching it when a snapshot arrives — is what lets the view
   age on its own: a paused or stuck poller publishes nothing, and the cards still
   cross into Stale. *@
@{
    var now = Time.GetUtcNow();
}

<PageTitle>SCADA Overview</PageTitle>

<div class="page-head rise">
    <h1>Fleet Overview</h1>
    <span class="page-meta">@Meta</span>
    <span class="spacer"></span>
    <span class="conn-pill" data-state="@(_paused ? "disconnected" : "connected")">
        <span class="dot"></span>@(_paused ? "Paused" : "Polling")
    </span>
    <button class="rail-btn" type="button" data-testid="pause-toggle" @onclick="TogglePause">
        @(_paused ? "Resume auto-refresh" : "Pause auto-refresh")
    </button>
</div>

@if (_snapshot.Applications.Count == 0)
{
    <p class="page-meta" data-testid="empty-registry">
        No instances have been polled yet.
    </p>
}
else
{
    <div class="agg-grid rise">
        <div class="agg-card">
            <div class="agg-label">Instances</div>
            <div class="agg-value">@_instances.Count<span class="agg-sub">registered</span></div>
        </div>
        <div class="agg-card good">
            <div class="agg-label">Up</div>
            <div class="agg-value" data-testid="kpi-up">@Count(now, InstanceStatus.Up)</div>
        </div>
        <div class="agg-card @(Count(now, InstanceStatus.Degraded) > 0 ? "caution" : null)">
            <div class="agg-label">Degraded</div>
            <div class="agg-value" data-testid="kpi-degraded">@Count(now, InstanceStatus.Degraded)</div>
        </div>
        <div class="agg-card @(Count(now, InstanceStatus.Down) > 0 ? "alert" : null)">
            <div class="agg-label">Down</div>
            <div class="agg-value" data-testid="kpi-down">@Count(now, InstanceStatus.Down)</div>
        </div>
        <div class="agg-card @(Count(now, InstanceStatus.Unreachable) > 0 ? "alert" : null)">
            <div class="agg-label">Unreachable</div>
            <div class="agg-value" data-testid="kpi-unreachable">@Count(now, InstanceStatus.Unreachable)</div>
        </div>
        <div class="agg-card quiet">
            <div class="agg-label">Stale</div>
            <div class="agg-value" data-testid="kpi-stale">@StaleCount(now)</div>
        </div>
    </div>

    @foreach (var application in _snapshot.Applications)
    {
        <section class="app-section rise" id="@MainLayout.Slug(application.Name)" data-testid="application">
            <div class="app-head">
                <h2>@application.Name</h2>
                <StatusPill State="RollupState(application)">@RollupLabel(application)</StatusPill>
                <span class="spacer"></span>
                <span class="app-meta">@AppMeta(application)</span>
            </div>

            @foreach (var group in Layout(application))
            {
                <div class="group">
                    @if (group.Key is { } groupName)
                    {
                        var state = application.GroupFor(groupName);
                        <div class="group-head">
                            <span class="group-name">@groupName</span>
                            @if (state?.LeaderInstance is { } leaderInstance)
                            {
                                <StatusPill State="StatusState.Info">Leader · @leaderInstance</StatusPill>
                            }
                            else if (state?.LeaderAddress is { } leaderAddress)
                            {
                                @* Unresolved address: shown raw rather than hidden — a leader on a
                                   host that is not in the registry is itself the finding. *@
                                <StatusPill State="StatusState.Info">Leader · @leaderAddress</StatusPill>
                            }
                            @if (state?.Disagreement == true)
                            {
                                <StatusPill State="StatusState.Bad">
                                    Split brain · @string.Join(" vs ", state.ReportedLeaders)
                                </StatusPill>
                            }
                            <span class="group-meta">@GroupMeta(group, now)</span>
                        </div>
                    }
                    <div class="card-grid">
                        @foreach (var instance in group)
                        {
                            <InstanceCard Instance="instance"
                                          Now="now"
                                          IsLeader="IsLeader(application, instance)" />
                        }
                    </div>
                </div>
            }
        </section>
    }
}

@code {
    /// <summary>
    /// How often the page re-renders on its own. Independent of the poll interval: its job is to
    /// age the "last poll" figures and let a card cross into Stale even when no sweep lands —
    /// which is exactly the case a stuck poller produces.
    /// </summary>
    private static readonly TimeSpan UiTick = TimeSpan.FromSeconds(5);

    private OverviewSnapshot _snapshot = OverviewSnapshot.Empty;
    private IReadOnlyList<InstanceSnapshot> _instances = [];
    private bool _paused;
    private ITimer? _timer;

    protected override void OnInitialized()
    {
        Adopt(Store.Current);
        Store.Changed += OnSnapshotChanged;
        _timer = Time.CreateTimer(_ => InvokeAsync(StateHasChanged), null, UiTick, UiTick);
    }

    private void OnSnapshotChanged(OverviewSnapshot snapshot)
    {
        // Pause stops ADOPTING new snapshots; the poller keeps sweeping. That is what makes the
        // pause useful — the frozen view then visibly ages into Stale, so a paused dashboard can
        // never be mistaken for a live one.
        if (_paused)
            return;

        InvokeAsync(() =>
        {
            Adopt(snapshot);
            StateHasChanged();
        });
    }

    private void Adopt(OverviewSnapshot snapshot)
    {
        _snapshot = snapshot;
        _instances = snapshot.AllInstances.ToList();
    }

    private void TogglePause()
    {
        _paused = !_paused;

        if (!_paused)
            Adopt(Store.Current);
    }

    private int Count(DateTimeOffset now, InstanceStatus status) =>
        _instances.Count(i => i.Status == status && !i.IsStale(now));

    private int StaleCount(DateTimeOffset now) => _instances.Count(i => i.IsStale(now));

    private string Meta
    {
        get
        {
            var generated = _snapshot.GeneratedUtc == default
                ? "never"
                : _snapshot.GeneratedUtc.ToLocalTime().ToString("HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);

            return $"last sweep {generated} · {_instances.Count} instances";
        }
    }

    /// <summary>
    /// Groups an application's instances for display: named cluster groups first in registry
    /// order, then the ungrouped ones.
    /// </summary>
    /// <param name="application">The application to lay out.</param>
    /// <returns>The instance groups.</returns>
    private static IEnumerable<IGrouping<string?, InstanceSnapshot>> Layout(ApplicationSnapshot application) =>
        application.Instances
            .GroupBy(i => i.Group, StringComparer.Ordinal)
            .OrderBy(g => g.Key is null)
            .ThenBy(g => g.Key, StringComparer.Ordinal);

    private static string AppMeta(ApplicationSnapshot application)
    {
        var clusters = application.Groups.Count;
        var instances = application.Instances.Count;
        var clusterText = clusters == 0 ? "" : $" · {clusters} cluster{(clusters == 1 ? "" : "s")}";

        return $"{instances} instance{(instances == 1 ? "" : "s")}{clusterText}";
    }

    private static string GroupMeta(IGrouping<string?, InstanceSnapshot> group, DateTimeOffset now)
    {
        var members = group.Count();
        var missing = group.Count(i => i.Status is InstanceStatus.Unreachable || i.IsStale(now));

        return $"{members} member{(members == 1 ? "" : "s")} · {missing} unreachable";
    }

    private static bool IsLeader(ApplicationSnapshot application, InstanceSnapshot instance) =>
        instance.Group is { } group &&
        application.GroupFor(group)?.LeaderInstance is { } leader &&
        string.Equals(leader, instance.Instance, StringComparison.Ordinal);

    private static StatusState RollupState(ApplicationSnapshot application) => application.Worst switch
    {
        InstanceStatus.Up => StatusState.Ok,
        InstanceStatus.Degraded => StatusState.Warn,
        InstanceStatus.Down or InstanceStatus.Unreachable => StatusState.Bad,
        _ => StatusState.Idle,
    };

    private static string RollupLabel(ApplicationSnapshot application) => application.Worst switch
    {
        InstanceStatus.Up => "Up",
        InstanceStatus.Degraded => "Degraded",
        InstanceStatus.Down => "Down",
        InstanceStatus.Unreachable => "Unreachable",
        _ => "Pending",
    };

    /// <inheritdoc/>
    public void Dispose()
    {
        // Both are required: an un-detached handler would call StateHasChanged on a disposed
        // renderer once per sweep for the life of the process.
        Store.Changed -= OnSnapshotChanged;
        _timer?.Dispose();
    }
}
