@* Live driver-status panel — subscribes to /hubs/driverstatus and shows state chip,
   last-success age, 5-min error count, and last error message.
   Enabled=false renders a static "Disabled" notice and never opens the hub.
   DriverOperator-gated Reconnect/Restart buttons appear for authorised users. *@
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@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.Drivers
@inject AuthenticationStateProvider AuthState
@inject IAuthorizationService AuthorizationService
@inject IAdminOperationsClient AdminOps
@inject IDriverStatusSnapshotStore StatusStore
@inject IDriverResilienceStatusStore ResilienceStore

<section class="panel rise mt-3" style="animation-delay:.04s; @(_stale ? "opacity:0.5;" : "")">
    <div class="panel-head d-flex align-items-center gap-2">
        <span>Driver status</span>
        @if (_snapshot is not null)
        {
            <span class="chip @ChipClass(_snapshot.State)">@_snapshot.State</span>
        }
        else if (!Enabled)
        {
            <span class="chip chip-idle">Disabled</span>
        }
        else if (_connecting)
        {
            <span class="chip chip-idle">Connecting&hellip;</span>
        }
    </div>

    <div style="padding:1rem">
        @if (!Enabled)
        {
            <p class="mb-0" style="color:var(--ink-soft)">Disabled &mdash; not deployed. Enable the driver and save to start receiving live status.</p>
        }
        else if (_error is not null)
        {
            <p class="mb-0" style="color:var(--bad)">SignalR error: @_error</p>
        }
        else if (_snapshot is null)
        {
            <p class="mb-0" style="color:var(--ink-faint)">Awaiting first snapshot&hellip;</p>
        }
        else
        {
            <div class="d-flex flex-wrap gap-3 align-items-baseline">
                <span style="color:var(--ink-soft)">
                    Last success:
                    @if (_snapshot.LastSuccessfulReadUtc is { } t)
                    {
                        <strong>@HumanizeAge(t) ago</strong>
                    }
                    else
                    {
                        <strong>never</strong>
                    }
                </span>

                @if (_snapshot.ErrorCount5Min > 0)
                {
                    <span class="chip chip-bad">@_snapshot.ErrorCount5Min error@(_snapshot.ErrorCount5Min == 1 ? "" : "s") / 5 min</span>
                }
            </div>

            @if (_snapshot.LastError is { Length: > 0 } lastError)
            {
                <details class="mt-2" style="font-size:0.85rem">
                    <summary style="cursor:pointer; color:var(--ink-soft)">Last error</summary>
                    <pre class="mt-1 mb-0" style="white-space:pre-wrap; word-break:break-word; color:var(--bad); font-size:0.8rem">@lastError</pre>
                </details>
            }
        }

        @* --- Resilience (retry storm / circuit-breaker) — fed by the driver-resilience-status DPS store --- *@
        @if (Enabled)
        {
            @if (_resilience.Count == 0)
            {
                <p class="mb-0 mt-3" style="color:var(--ink-faint); font-size:0.85rem">No resilience activity recorded yet.</p>
            }
            else
            {
                var active = _resilience.Values
                    .Where(h => h.BreakerOpen || h.ConsecutiveFailures > 0 || h.CurrentInFlight > 0 || IsStale(h))
                    .OrderBy(h => h.HostName, StringComparer.Ordinal)
                    .ToList();

                <div class="mt-3">
                    <div style="color:var(--ink-soft); font-size:0.8rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em">Resilience</div>
                    @if (active.Count == 0)
                    {
                        <p class="mb-0 mt-1" style="color:var(--ink-faint); font-size:0.85rem">All targets healthy.</p>
                    }
                    else
                    {
                        @foreach (var host in active)
                        {
                            var stale = IsStale(host);
                            <div class="d-flex flex-wrap gap-2 align-items-baseline mt-1" style="@(stale ? "opacity:0.5;" : "")">
                                <span style="color:var(--ink-soft); font-size:0.85rem"><strong>@host.HostName</strong></span>
                                @if (host.BreakerOpen)
                                {
                                    <span class="chip chip-bad">
                                        Breaker OPEN@(host.LastBreakerOpenUtc is { } opened ? $" · opened {HumanizeAge(opened)} ago" : "")
                                    </span>
                                }
                                @if (host.ConsecutiveFailures > 0)
                                {
                                    <span class="chip chip-warn">@host.ConsecutiveFailures consecutive failure@(host.ConsecutiveFailures == 1 ? "" : "s")</span>
                                }
                                @if (host.CurrentInFlight > 0)
                                {
                                    <span class="chip chip-idle">in-flight @host.CurrentInFlight</span>
                                }
                                @if (stale)
                                {
                                    <span class="chip chip-idle">stale</span>
                                }
                            </div>
                        }
                    }
                </div>
            }
        }

        @* --- Reconnect / Restart action buttons (DriverOperator-gated) --- *@
        @if (_canOperate && Enabled)
        {
            <div class="d-flex gap-2 align-items-center mt-3">
                <button type="button"
                        class="btn btn-sm btn-outline-secondary"
                        disabled="@_busyReconnect"
                        @onclick="ReconnectAsync"
                        title="Re-establish driver transport without restarting the actor">
                    @if (_busyReconnect)
                    {
                        <span class="spinner-border spinner-border-sm me-1"></span>
                        <span>Reconnecting&hellip;</span>
                    }
                    else
                    {
                        <span>Reconnect</span>
                    }
                </button>

                <button type="button"
                        class="btn btn-sm btn-outline-danger"
                        disabled="@_busyRestart"
                        @onclick="() => _showRestartConfirm = true"
                        title="Stop and respawn the driver actor — briefly interrupts active subscriptions">
                    @if (_busyRestart)
                    {
                        <span class="spinner-border spinner-border-sm me-1"></span>
                        <span>Restarting&hellip;</span>
                    }
                    else
                    {
                        <span>Restart</span>
                    }
                </button>

                @if (_opResultMessage is not null)
                {
                    <span class="chip @(_opResultOk ? "chip-ok" : "chip-bad")" style="font-size:0.8rem">@_opResultMessage</span>
                }
            </div>

            @* Inline confirm dialog for Restart (no JS confirm — keeps SignalR event loop unblocked) *@
            @if (_showRestartConfirm)
            {
                <div class="mt-2 p-2 border rounded" style="background:var(--surface-raised,#fff); border-color:var(--bad,#dc3545)!important; max-width:420px; font-size:0.9rem">
                    <p class="mb-2" style="color:var(--ink)">
                        Restart driver <code>@DriverInstanceId</code>?<br />
                        <span style="color:var(--ink-soft)">This briefly interrupts active subscriptions and clears in-memory state.</span>
                    </p>
                    <div class="d-flex gap-2">
                        <button type="button" class="btn btn-sm btn-danger" @onclick="RestartConfirmedAsync">Confirm restart</button>
                        <button type="button" class="btn btn-sm btn-outline-secondary" @onclick="() => _showRestartConfirm = false">Cancel</button>
                    </div>
                </div>
            }
        }
    </div>
</section>

@code {
    [Parameter, EditorRequired] public string DriverInstanceId { get; set; } = "";
    /// <summary>Cluster identifier forwarded in Reconnect/Restart messages for audit.</summary>
    [Parameter] public string ClusterId { get; set; } = "";
    [Parameter] public bool Enabled { get; set; } = true;

    private DriverHealthChanged? _snapshot;
    private DateTime _lastUpdateUtc = DateTime.MinValue;
    private bool _stale;

    // Per-host resilience snapshots for THIS instance, keyed by HostName. Fed by the
    // driver-resilience-status DPS store (read in-process, same house pattern as the health store).
    private readonly Dictionary<string, DriverResilienceStatusChanged> _resilience = new(StringComparer.Ordinal);
    private bool _connecting;
    private string? _error;
    private System.Threading.Timer? _timer;

    // Authorization
    private bool _canOperate;

    // Action state
    private bool _busyReconnect;
    private bool _busyRestart;
    private bool _showRestartConfirm;
    private string? _opResultMessage;
    private bool _opResultOk;
    private System.Threading.Timer? _opResultClearTimer;

    protected override async Task OnInitializedAsync()
    {
        // Check DriverOperator authorization so buttons only render for permitted users.
        // The username for audit logging is re-read at button-click time (not captured here)
        // so token-refreshes mid-session land in audit entries accurately.
        var auth = await AuthState.GetAuthenticationStateAsync();
        var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
        _canOperate = authResult.Succeeded;

        if (!Enabled)
            return;

        _connecting = true;

        // Tick every 5 s to refresh the stale-dimming check and humanized ages.
        _timer = new System.Threading.Timer(_ =>
        {
            _stale = _snapshot is not null &&
                     (DateTime.UtcNow - _lastUpdateUtc).TotalSeconds > 30;
            InvokeAsync(StateHasChanged);
        }, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));

        // Read live status straight from the in-process snapshot store rather than opening a
        // self-targeted SignalR connection. This component runs server-side (Blazor
        // InteractiveServer), so a HubConnection to the browser's public URL (e.g.
        // http://localhost:9200 behind Traefik) would dial that port from *inside* the container —
        // where only Kestrel's :9000 listens — and fail with "Connection refused". The store is fed
        // on every admin node by DriverStatusSignalRBridge (a per-node DistributedPubSub
        // subscriber), so the local singleton is always current regardless of which replica serves
        // this circuit.
        try
        {
            StatusStore.SnapshotChanged += OnSnapshotChanged;
            if (StatusStore.TryGet(DriverInstanceId, out var snap))
            {
                _snapshot = snap;
                _lastUpdateUtc = DateTime.UtcNow;
            }

            // Prime + subscribe the resilience store (same in-process read pattern as the health store).
            ResilienceStore.SnapshotChanged += OnResilienceChanged;
            foreach (var r in ResilienceStore.GetForInstance(DriverInstanceId))
                _resilience[r.HostName] = r;
        }
        catch (Exception ex)
        {
            _error = ex.Message;
        }
        finally
        {
            _connecting = false;
        }
    }

    // Invoked by the snapshot store (on the bridge actor's thread) for every driver instance;
    // ignore snapshots for other instances and marshal onto the render sync context.
    private void OnSnapshotChanged(DriverHealthChanged snap)
    {
        if (!string.Equals(snap.DriverInstanceId, DriverInstanceId, StringComparison.Ordinal))
            return;

        _snapshot = snap;
        _lastUpdateUtc = DateTime.UtcNow;
        _stale = false;
        InvokeAsync(StateHasChanged);
    }

    // Invoked by the resilience store (on the bridge actor's thread) for every (instance, host);
    // ignore snapshots for other instances and marshal onto the render sync context.
    private void OnResilienceChanged(DriverResilienceStatusChanged msg)
    {
        if (!string.Equals(msg.DriverInstanceId, DriverInstanceId, StringComparison.Ordinal))
            return;

        _resilience[msg.HostName] = msg;
        InvokeAsync(StateHasChanged);
    }

    // A host row is stale once its last publish is older than 15 s (3× the 5 s publish interval) —
    // covers driver-node death, where entries freeze. Re-evaluated by the existing 5 s render timer.
    private static bool IsStale(DriverResilienceStatusChanged h) =>
        (DateTime.UtcNow - h.PublishedUtc).TotalSeconds > 15;

    private async Task ReconnectAsync()
    {
        _busyReconnect = true;
        _opResultMessage = null;
        StateHasChanged();
        try
        {
            var userName = await GetCurrentUserNameAsync();
            using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
            var result = await AdminOps.AskAsync<ReconnectDriverResult>(
                new ReconnectDriver(ClusterId, DriverInstanceId, userName, Guid.NewGuid()),
                cts.Token);
            ShowOpResult(result.Ok, result.Ok ? "Reconnect dispatched" : (result.Message ?? "Failed"));
        }
        catch (Exception ex)
        {
            ShowOpResult(false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
        }
        finally
        {
            _busyReconnect = false;
            StateHasChanged();
        }
    }

    private async Task RestartConfirmedAsync()
    {
        _showRestartConfirm = false;
        _busyRestart = true;
        _opResultMessage = null;
        StateHasChanged();
        try
        {
            var userName = await GetCurrentUserNameAsync();
            using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(15));
            var result = await AdminOps.AskAsync<RestartDriverResult>(
                new RestartDriver(ClusterId, DriverInstanceId, userName, Guid.NewGuid()),
                cts.Token);
            ShowOpResult(result.Ok, result.Ok ? "Restart dispatched" : (result.Message ?? "Failed"));
        }
        catch (Exception ex)
        {
            ShowOpResult(false, ex.Message.Length > 60 ? ex.Message[..60] + "…" : ex.Message);
        }
        finally
        {
            _busyRestart = false;
            StateHasChanged();
        }
    }

    /// <summary>
    ///     Re-reads the AuthenticationState at call time so the username forwarded to the
    ///     audit log reflects the current claims-principal — survives token refresh / role
    ///     change during a long-lived Blazor circuit. Returns "unknown" if no Name claim is
    ///     present (auth requirements upstream should normally prevent this).
    /// </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(bool ok, string message)
    {
        _opResultOk = ok;
        _opResultMessage = message;
        // Auto-clear the result chip after 8 s. System.Threading.Timer is used (not
        // System.Timers.Timer) so DisposeAsync can drain any in-flight callback.
        _opResultClearTimer?.Dispose();
        _opResultClearTimer = new System.Threading.Timer(_ =>
        {
            _opResultMessage = null;
            InvokeAsync(StateHasChanged);
        }, null, TimeSpan.FromSeconds(8), Timeout.InfiniteTimeSpan);
    }

    public async ValueTask DisposeAsync()
    {
        // Unsubscribe first so the singleton stores can't invoke a handler on a disposed component.
        StatusStore.SnapshotChanged -= OnSnapshotChanged;
        ResilienceStore.SnapshotChanged -= OnResilienceChanged;
        // Drain BOTH timers so an in-flight callback can't invoke StateHasChanged on a component
        // that's already gone. System.Threading.Timer's async dispose awaits any in-flight
        // callback (.NET 6+).
        if (_timer is not null) await _timer.DisposeAsync();
        if (_opResultClearTimer is not null) await _opResultClearTimer.DisposeAsync();
    }

    // Map DriverState string → chip CSS class using the 4 defined theme variants.
    private static string ChipClass(string? state) => state switch
    {
        "Healthy"      => "chip-ok",
        "Degraded"     => "chip-warn",
        "Connecting"   => "chip-warn",
        "Reconnecting" => "chip-warn",
        "Faulted"      => "chip-bad",
        _              => "chip-idle",   // Unknown, Initializing, null
    };

    private static string HumanizeAge(DateTime utc)
    {
        var age = DateTime.UtcNow - utc;
        if (age.TotalSeconds < 2)   return "just now";
        if (age.TotalSeconds < 60)  return $"{(int)age.TotalSeconds}s";
        if (age.TotalMinutes < 60)  return $"{(int)age.TotalMinutes}m {age.Seconds}s";
        if (age.TotalHours   < 24)  return $"{(int)age.TotalHours}h {age.Minutes}m";
        return $"{(int)age.TotalDays}d {age.Hours}h";
    }
}
