@page "/fleet"
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IServiceScopeFactory ScopeFactory
@implements IDisposable

<h1 class="mb-4">Fleet status</h1>

<div class="d-flex align-items-center mb-3 gap-2">
    <button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
        @if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
        Refresh
    </button>
    <span class="text-muted small">
        Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
    </span>
</div>

@if (_rows is null)
{
    <p>Loading…</p>
}
else if (_rows.Count == 0)
{
    <div class="alert alert-info">
        No node state recorded yet. Nodes publish their state to the central DB on each poll; if
        this list is empty, either no nodes have been registered or the poller hasn't run yet.
    </div>
}
else
{
    <div class="row g-3 mb-4">
        <div class="col-md-3">
            <div class="card"><div class="card-body">
                <h6 class="text-muted mb-1">Nodes</h6>
                <div class="fs-3">@_rows.Count</div>
            </div></div>
        </div>
        <div class="col-md-3">
            <div class="card border-success"><div class="card-body">
                <h6 class="text-muted mb-1">Applied</h6>
                <div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
            </div></div>
        </div>
        <div class="col-md-3">
            <div class="card border-warning"><div class="card-body">
                <h6 class="text-muted mb-1">Stale</h6>
                <div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
            </div></div>
        </div>
        <div class="col-md-3">
            <div class="card border-danger"><div class="card-body">
                <h6 class="text-muted mb-1">Failed</h6>
                <div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
            </div></div>
        </div>
    </div>

    <table class="table table-hover align-middle">
        <thead>
            <tr>
                <th>Node</th>
                <th>Cluster</th>
                <th>Generation</th>
                <th>Status</th>
                <th>Last applied</th>
                <th>Last seen</th>
                <th>Error</th>
            </tr>
        </thead>
        <tbody>
        @foreach (var r in _rows)
        {
            <tr class="@RowClass(r)">
                <td><code>@r.NodeId</code></td>
                <td>@r.ClusterId</td>
                <td>@(r.GenerationId?.ToString() ?? "—")</td>
                <td>
                    <span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
                </td>
                <td>@FormatAge(r.AppliedAt)</td>
                <td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
                <td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
            </tr>
        }
        </tbody>
    </table>
}

@code {
    // Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
    // the most recent published state without polling ahead of the broadcaster.
    private const int RefreshIntervalSeconds = 5;

    private List<FleetNodeRow>? _rows;
    private bool _refreshing;
    private DateTime? _lastRefreshUtc;
    private Timer? _timer;

    protected override async Task OnInitializedAsync()
    {
        await RefreshAsync();
        _timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
            state: null,
            dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
            period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
    }

    private async Task RefreshAsync()
    {
        if (_refreshing) return;
        _refreshing = true;
        try
        {
            using var scope = ScopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
            var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
                .Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
                    s.NodeId, n.ClusterId, s.CurrentGenerationId,
                    s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
                    s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
                .OrderBy(r => r.ClusterId)
                .ThenBy(r => r.NodeId)
                .ToListAsync();
            _rows = rows;
            _lastRefreshUtc = DateTime.UtcNow;
        }
        finally
        {
            _refreshing = false;
            StateHasChanged();
        }
    }

    private static bool IsStale(FleetNodeRow r)
    {
        if (r.SeenAt is null) return true;
        return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
    }

    private static string RowClass(FleetNodeRow r) => r.Status switch
    {
        "Failed" => "table-danger",
        _ when IsStale(r) => "table-warning",
        _ => "",
    };

    private static string StatusBadge(string? status) => status switch
    {
        "Applied" => "bg-success",
        "Failed" => "bg-danger",
        "Applying" => "bg-info",
        _ => "bg-secondary",
    };

    private static string FormatAge(DateTime? t)
    {
        if (t is null) return "—";
        var age = DateTime.UtcNow - t.Value;
        if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
        if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
        if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
        return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
    }

    public void Dispose() => _timer?.Dispose();

    internal sealed record FleetNodeRow(
        string NodeId, string ClusterId, long? GenerationId,
        string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
}
