@page "/monitoring/parked-messages"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.Communication
@inject ISiteRepository SiteRepository
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@inject CommunicationService CommunicationService
@inject IJSRuntime JS
@inject IDialogService Dialog
@inject ILogger<ParkedMessages> Logger

<div class="container-fluid mt-3">
    <ToastNotification @ref="_toast" />

    <div class="d-flex align-items-baseline flex-wrap mb-3">
        <h4 class="mb-0 me-3">Parked Messages</h4>
        @if (_messages != null && _messages.Count > 0)
        {
            <span class="text-muted small">
                @_totalCount parked · @DistinctTargets target system@(DistinctTargets == 1 ? "" : "s")
                @if (OldestMessage != null)
                {
                    <span> · oldest @Relative(OldestMessage.LastAttemptTimestamp)</span>
                }
                @if (FilteredCount != _messages.Count)
                {
                    <span class="ms-2">(showing @FilteredCount of @_messages.Count)</span>
                }
            </span>
        }
    </div>

    <div class="card mb-3">
        <div class="card-body py-2">
            <div class="row g-2 align-items-end">
                <div class="col-auto">
                    <label class="form-label small mb-1" for="pm-filter-site">Site</label>
                    <select id="pm-filter-site" class="form-select form-select-sm" style="min-width: 180px;"
                            value="@_selectedSiteId" @onchange="OnSiteChanged">
                        <option value="">Select site…</option>
                        @foreach (var site in _sites)
                        {
                            <option value="@site.SiteIdentifier">@site.Name</option>
                        }
                    </select>
                </div>
                <div class="col-auto">
                    <label class="form-label small mb-1" for="pm-filter-cat">Category</label>
                    <select id="pm-filter-cat" class="form-select form-select-sm" style="min-width: 150px;"
                            @bind="_categoryFilter">
                        <option value="">All</option>
                        <option value="ExternalSystem">External system</option>
                        <option value="Notification">Notification</option>
                        <option value="CachedDbWrite">DB write</option>
                    </select>
                </div>
                <div class="col-auto">
                    <label class="form-label small mb-1" for="pm-filter-target">Target</label>
                    <select id="pm-filter-target" class="form-select form-select-sm" style="min-width: 160px;"
                            @bind="_targetFilter">
                        <option value="">Any</option>
                        @foreach (var t in DistinctTargetsList)
                        {
                            <option value="@t">@t</option>
                        }
                    </select>
                </div>
                <div class="col-auto">
                    <label class="form-label small mb-1" for="pm-filter-origin">Origin</label>
                    <select id="pm-filter-origin" class="form-select form-select-sm" style="min-width: 160px;"
                            @bind="_originFilter">
                        <option value="">Any</option>
                        <option value="__none__">(none)</option>
                        @foreach (var o in DistinctOriginsList)
                        {
                            <option value="@o">@o</option>
                        }
                    </select>
                </div>
                <div class="col-auto">
                    <label class="form-label small mb-1" for="pm-filter-age">Age</label>
                    <select id="pm-filter-age" class="form-select form-select-sm" style="min-width: 130px;"
                            @bind="_ageFilter">
                        <option value="All">All</option>
                        <option value="LastHour">Last hour</option>
                        <option value="LastDay">Last 24h</option>
                        <option value="LastWeek">Last 7d</option>
                    </select>
                </div>
                <div class="col">
                    <label class="form-label small mb-1" for="pm-filter-search">Search</label>
                    <input id="pm-filter-search" type="search" class="form-control form-control-sm"
                           placeholder="ID, target, method, error…"
                           @bind="_searchFilter" @bind:event="oninput" />
                </div>
                <div class="col-auto">
                    <button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters"
                            disabled="@(!HasActiveFilters)">Clear</button>
                </div>
                <div class="col-auto">
                    <button class="btn btn-primary btn-sm" @onclick="Search"
                            disabled="@(string.IsNullOrEmpty(_selectedSiteId) || _searching)">
                        @if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
                        Query
                    </button>
                </div>
            </div>
        </div>
    </div>

    @if (_selectedIds.Count > 0)
    {
        <div class="alert alert-secondary py-2 d-flex align-items-center mb-3">
            <strong class="me-3">@_selectedIds.Count selected</strong>
            <button class="btn btn-outline-success btn-sm me-2"
                    @onclick="BulkRetry" disabled="@_bulkInProgress">
                @if (_bulkInProgress && _bulkAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
                Retry selected
            </button>
            <button class="btn btn-outline-danger btn-sm me-2"
                    @onclick="BulkDiscard" disabled="@_bulkInProgress">
                @if (_bulkInProgress && _bulkAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
                Discard selected
            </button>
            <button type="button" class="btn-close ms-auto"
                    aria-label="Clear selection" @onclick="ClearSelection"></button>
        </div>
    }

    @if (_errorMessage != null)
    {
        <div class="alert alert-danger">@_errorMessage</div>
    }

    @if (_messages == null)
    {
        @if (!string.IsNullOrEmpty(_selectedSiteId) && _searching)
        {
            <div class="text-muted small">Loading…</div>
        }
    }
    else if (_messages.Count == 0)
    {
        <div class="card">
            <div class="card-body text-center text-muted py-5">
                <div class="fs-5 mb-1">No parked messages</div>
                <div class="small">Nothing has failed enough to give up on at this site.</div>
            </div>
        </div>
    }
    else
    {
        var filtered = FilteredMessages;
        <div class="table-responsive">
            <table class="table table-sm table-hover mb-2 align-middle parked-table">
                <thead class="table-light">
                    <tr>
                        <th style="width: 36px;">
                            <input class="form-check-input" type="checkbox"
                                   checked="@AllFilteredSelected"
                                   @onchange="ToggleSelectAll"
                                   aria-label="Select all" />
                        </th>
                        <th>Target / Method</th>
                        <th>Origin</th>
                        <th>Error</th>
                        <th style="width: 110px;">Attempts</th>
                        <th style="width: 160px;">Last attempt</th>
                    </tr>
                </thead>
                <tbody>
                    @if (filtered.Count == 0)
                    {
                        <tr><td colspan="6" class="text-muted text-center py-3">No messages match the current filters.</td></tr>
                    }
                    @foreach (var msg in filtered)
                    {
                        var isSelected = _selectedIds.Contains(msg.MessageId);
                        <tr @key="msg.MessageId"
                            class="parked-row @SeverityClass(msg) @(isSelected ? "table-active" : "")"
                            @onclick="() => OpenDrawer(msg)"
                            style="cursor: pointer;">
                            <td @onclick:stopPropagation="true">
                                <input class="form-check-input" type="checkbox"
                                       checked="@isSelected"
                                       @onchange="e => ToggleSelect(msg.MessageId, (bool)e.Value!)"
                                       aria-label="@($"Select {msg.MessageId[..Math.Min(8, msg.MessageId.Length)]}")" />
                            </td>
                            <td>
                                <div class="fw-semibold">@msg.TargetSystem</div>
                                <div class="small text-muted">@msg.MethodName</div>
                            </td>
                            <td>
                                @if (!string.IsNullOrEmpty(msg.OriginInstance))
                                {
                                    <code class="small">@msg.OriginInstance</code>
                                }
                                else
                                {
                                    <span class="text-muted small">—</span>
                                }
                            </td>
                            <td>
                                <div class="text-danger small parked-error-clamp">@msg.ErrorMessage</div>
                            </td>
                            <td>
                                <div class="small font-monospace">
                                    @msg.AttemptCount<span class="text-muted">/@msg.MaxAttempts</span>
                                </div>
                                <div class="progress mt-1" style="height: 3px;">
                                    <div class="progress-bar @AttemptBarClass(msg)"
                                         role="progressbar"
                                         style="width: @AttemptPercent(msg)%;"
                                         aria-valuenow="@msg.AttemptCount"
                                         aria-valuemin="0"
                                         aria-valuemax="@Math.Max(1, msg.MaxAttempts)"></div>
                                </div>
                            </td>
                            <td>
                                <div class="small" title="@AbsoluteUtc(msg.LastAttemptTimestamp)">
                                    @Relative(msg.LastAttemptTimestamp)
                                </div>
                            </td>
                        </tr>
                    }
                </tbody>
            </table>
        </div>

        @if (_totalCount > _pageSize)
        {
            <div class="d-flex justify-content-between align-items-center">
                <span class="text-muted small">
                    Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) · @_totalCount total
                </span>
                <div>
                    <button class="btn btn-outline-secondary btn-sm me-1"
                            @onclick="PrevPage" disabled="@(_pageNumber <= 1)">Previous</button>
                    <button class="btn btn-outline-secondary btn-sm"
                            @onclick="NextPage" disabled="@(_messages.Count < _pageSize)">Next</button>
                </div>
            </div>
        }
    }
</div>

@if (_drawerMessage != null)
{
    <div class="offcanvas-backdrop fade show" @onclick="CloseDrawer"></div>
    <div class="offcanvas offcanvas-end show parked-drawer" tabindex="-1" style="visibility: visible;">
        <div class="offcanvas-header border-bottom">
            <div>
                <div class="text-muted small text-uppercase">Parked message</div>
                <h5 class="offcanvas-title mb-0">@_drawerMessage.TargetSystem</h5>
                <div class="small text-muted">@_drawerMessage.MethodName</div>
            </div>
            <button type="button" class="btn-close" aria-label="Close" @onclick="CloseDrawer"></button>
        </div>
        <div class="offcanvas-body small">
            <dl class="row mb-3">
                <dt class="col-4 text-muted fw-normal">Message ID</dt>
                <dd class="col-8 d-flex align-items-center gap-2">
                    <code class="text-truncate" style="min-width: 0;">@_drawerMessage.MessageId</code>
                    <button class="btn btn-link btn-sm p-0" title="Copy message ID"
                            @onclick="() => CopyAsync(_drawerMessage.MessageId)">📋</button>
                </dd>
                <dt class="col-4 text-muted fw-normal">Category</dt>
                <dd class="col-8">@CategoryLabel(_drawerMessage.Category)</dd>
                <dt class="col-4 text-muted fw-normal">Origin instance</dt>
                <dd class="col-8">
                    @if (!string.IsNullOrEmpty(_drawerMessage.OriginInstance))
                    {
                        <code>@_drawerMessage.OriginInstance</code>
                    }
                    else
                    {
                        <span class="text-muted">—</span>
                    }
                </dd>
                <dt class="col-4 text-muted fw-normal">Attempts</dt>
                <dd class="col-8 font-monospace">@_drawerMessage.AttemptCount / @_drawerMessage.MaxAttempts</dd>
                <dt class="col-4 text-muted fw-normal">Originally enqueued</dt>
                <dd class="col-8">
                    @Relative(_drawerMessage.OriginalTimestamp)
                    <span class="text-muted">· @AbsoluteUtc(_drawerMessage.OriginalTimestamp)</span>
                </dd>
                <dt class="col-4 text-muted fw-normal">Last attempt</dt>
                <dd class="col-8">
                    @Relative(_drawerMessage.LastAttemptTimestamp)
                    <span class="text-muted">· @AbsoluteUtc(_drawerMessage.LastAttemptTimestamp)</span>
                </dd>
            </dl>

            <div class="text-muted text-uppercase small fw-semibold mb-1">Error</div>
            <pre class="bg-light border rounded p-2 small mb-0 parked-error-pre">@_drawerMessage.ErrorMessage</pre>
        </div>
        <div class="border-top p-3 d-flex gap-2">
            <button class="btn btn-outline-success btn-sm flex-grow-1"
                    @onclick="RetryFromDrawer" disabled="@_actionInProgress">
                @if (_actionInProgress && _activeAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
                Retry
            </button>
            <button class="btn btn-outline-danger btn-sm flex-grow-1"
                    @onclick="DiscardFromDrawer" disabled="@_actionInProgress">
                @if (_actionInProgress && _activeAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
                Discard
            </button>
        </div>
    </div>
}

<style>
    .parked-row { border-left: 3px solid transparent; }
    .parked-row.sev-danger { border-left-color: var(--bs-danger); }
    .parked-row.sev-warning { border-left-color: var(--bs-warning); }
    .parked-row.sev-secondary { border-left-color: var(--bs-secondary-bg-subtle); }
    .parked-error-clamp {
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow: hidden;
        max-width: 520px;
    }
    .parked-drawer { width: min(560px, 95vw); }
    .parked-error-pre { white-space: pre-wrap; word-break: break-word; max-height: 300px; overflow-y: auto; }
    .parked-table tbody tr { transition: background-color 0.1s ease; }
</style>

@code {
    private List<Site> _sites = new();
    private string _selectedSiteId = string.Empty;
    private List<ParkedMessageEntry>? _messages;
    private int _totalCount;
    private int _pageNumber = 1;
    private int _pageSize = 50;
    private bool _searching;
    private string? _errorMessage;

    // Filters
    private string _categoryFilter = string.Empty;
    private string _targetFilter = string.Empty;
    private string _originFilter = string.Empty;
    private string _ageFilter = "All";
    private string _searchFilter = string.Empty;

    // Selection
    private readonly HashSet<string> _selectedIds = new();
    private bool _bulkInProgress;
    private string? _bulkAction;

    // Per-row action state
    private bool _actionInProgress;
    private string? _activeAction;

    // Drawer
    private ParkedMessageEntry? _drawerMessage;

    private ToastNotification _toast = default!;

    protected override async Task OnInitializedAsync()
    {
        // Site scoping (CentralUI-002): a scoped Deployment user may only inspect
        // and act on parked messages for the sites they are permitted on.
        _sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
    }

    // True only when the currently selected SiteIdentifier is one this user is
    // permitted on. _sites is already filtered, so membership IS the scope check.
    private bool SelectedSiteIsPermitted =>
        !string.IsNullOrEmpty(_selectedSiteId)
        && _sites.Any(s => s.SiteIdentifier == _selectedSiteId);

    private async Task OnSiteChanged(ChangeEventArgs e)
    {
        _selectedSiteId = e.Value?.ToString() ?? string.Empty;
        if (!string.IsNullOrEmpty(_selectedSiteId))
        {
            await Search();
        }
        else
        {
            _messages = null;
            _selectedIds.Clear();
            _drawerMessage = null;
        }
    }

    private async Task Search()
    {
        _pageNumber = 1;
        _selectedIds.Clear();
        _drawerMessage = null;
        await FetchPage();
    }

    private async Task PrevPage() { _pageNumber--; _selectedIds.Clear(); await FetchPage(); }
    private async Task NextPage() { _pageNumber++; _selectedIds.Clear(); await FetchPage(); }

    private async Task FetchPage()
    {
        _searching = true;
        _errorMessage = null;
        // Site scoping (CentralUI-002): re-check before querying — the dropdown is
        // filtered, but the selection must not be trusted on its own.
        if (!SelectedSiteIsPermitted)
        {
            _errorMessage = "You are not permitted to view parked messages for that site.";
            _messages = null;
            _searching = false;
            return;
        }
        try
        {
            var request = new ParkedMessageQueryRequest(
                CorrelationId: Guid.NewGuid().ToString("N"),
                SiteId: _selectedSiteId,
                PageNumber: _pageNumber,
                PageSize: _pageSize,
                Timestamp: DateTimeOffset.UtcNow);

            var response = await CommunicationService.QueryParkedMessagesAsync(_selectedSiteId, request);
            if (response.Success)
            {
                _messages = response.Messages.ToList();
                _totalCount = response.TotalCount;
            }
            else
            {
                _errorMessage = response.ErrorMessage ?? "Query failed.";
            }
        }
        catch (Exception ex)
        {
            _errorMessage = $"Query failed: {ex.Message}";
        }
        _searching = false;
    }

    private void ClearFilters()
    {
        _categoryFilter = string.Empty;
        _targetFilter = string.Empty;
        _originFilter = string.Empty;
        _ageFilter = "All";
        _searchFilter = string.Empty;
    }

    private bool HasActiveFilters =>
        !string.IsNullOrEmpty(_categoryFilter) ||
        !string.IsNullOrEmpty(_targetFilter) ||
        !string.IsNullOrEmpty(_originFilter) ||
        _ageFilter != "All" ||
        !string.IsNullOrEmpty(_searchFilter);

    private List<ParkedMessageEntry> FilteredMessages
    {
        get
        {
            if (_messages == null) return new();
            IEnumerable<ParkedMessageEntry> q = _messages;

            if (!string.IsNullOrEmpty(_categoryFilter) &&
                Enum.TryParse<StoreAndForwardCategory>(_categoryFilter, out var cat))
                q = q.Where(m => m.Category == cat);

            if (!string.IsNullOrEmpty(_targetFilter))
                q = q.Where(m => m.TargetSystem == _targetFilter);

            if (_originFilter == "__none__")
                q = q.Where(m => string.IsNullOrEmpty(m.OriginInstance));
            else if (!string.IsNullOrEmpty(_originFilter))
                q = q.Where(m => m.OriginInstance == _originFilter);

            if (_ageFilter != "All")
            {
                var cutoff = _ageFilter switch
                {
                    "LastHour" => DateTimeOffset.UtcNow.AddHours(-1),
                    "LastDay" => DateTimeOffset.UtcNow.AddDays(-1),
                    "LastWeek" => DateTimeOffset.UtcNow.AddDays(-7),
                    _ => DateTimeOffset.MinValue
                };
                q = q.Where(m => m.LastAttemptTimestamp >= cutoff);
            }

            if (!string.IsNullOrEmpty(_searchFilter))
            {
                var s = _searchFilter.Trim();
                q = q.Where(m =>
                    m.MessageId.Contains(s, StringComparison.OrdinalIgnoreCase) ||
                    m.TargetSystem.Contains(s, StringComparison.OrdinalIgnoreCase) ||
                    m.MethodName.Contains(s, StringComparison.OrdinalIgnoreCase) ||
                    m.ErrorMessage.Contains(s, StringComparison.OrdinalIgnoreCase) ||
                    (m.OriginInstance ?? string.Empty).Contains(s, StringComparison.OrdinalIgnoreCase));
            }

            return q.ToList();
        }
    }

    private int FilteredCount => FilteredMessages.Count;
    private int DistinctTargets => _messages?.Select(m => m.TargetSystem).Distinct().Count() ?? 0;

    private IEnumerable<string> DistinctTargetsList =>
        _messages?.Select(m => m.TargetSystem).Distinct().OrderBy(s => s).ToList()
            ?? (IEnumerable<string>)Array.Empty<string>();

    private IEnumerable<string> DistinctOriginsList =>
        _messages?.Where(m => !string.IsNullOrEmpty(m.OriginInstance))
            .Select(m => m.OriginInstance!).Distinct().OrderBy(s => s).ToList()
            ?? (IEnumerable<string>)Array.Empty<string>();

    private ParkedMessageEntry? OldestMessage =>
        _messages?.OrderBy(m => m.LastAttemptTimestamp).FirstOrDefault();

    // ── Selection ──

    private bool AllFilteredSelected
    {
        get
        {
            var filtered = FilteredMessages;
            return filtered.Count > 0 && filtered.All(m => _selectedIds.Contains(m.MessageId));
        }
    }

    private void ToggleSelect(string id, bool isChecked)
    {
        if (isChecked) _selectedIds.Add(id);
        else _selectedIds.Remove(id);
    }

    private void ToggleSelectAll(ChangeEventArgs e)
    {
        var on = (bool)e.Value!;
        var filtered = FilteredMessages;
        if (on)
        {
            foreach (var m in filtered) _selectedIds.Add(m.MessageId);
        }
        else
        {
            foreach (var m in filtered) _selectedIds.Remove(m.MessageId);
        }
    }

    private void ClearSelection() => _selectedIds.Clear();

    // ── Drawer ──

    private void OpenDrawer(ParkedMessageEntry msg) => _drawerMessage = msg;
    private void CloseDrawer() => _drawerMessage = null;

    private async Task RetryFromDrawer()
    {
        if (_drawerMessage == null) return;
        var msg = _drawerMessage;
        await RetrySingle(msg);
        CloseDrawer();
    }

    private async Task DiscardFromDrawer()
    {
        if (_drawerMessage == null) return;
        var msg = _drawerMessage;
        var ok = await DiscardSingle(msg);
        if (ok) CloseDrawer();
    }

    // ── Bulk ──

    private async Task BulkRetry()
    {
        var ids = _selectedIds.ToList();
        if (ids.Count == 0) return;
        if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }

        var confirmed = await Dialog.ConfirmAsync(
            "Retry parked messages",
            $"Move {ids.Count} message{(ids.Count == 1 ? "" : "s")} back to the pending queue?");
        if (!confirmed) return;

        _bulkInProgress = true;
        _bulkAction = "Retry";
        int success = 0, failed = 0;
        foreach (var id in ids)
        {
            try
            {
                var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
                var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
                if (resp.Success) success++; else failed++;
            }
            catch { failed++; }
        }
        _toast.ShowSuccess($"{success} queued for retry" + (failed > 0 ? $", {failed} failed" : "."));
        _selectedIds.Clear();
        _bulkInProgress = false;
        _bulkAction = null;
        await FetchPage();
    }

    private async Task BulkDiscard()
    {
        var ids = _selectedIds.ToList();
        if (ids.Count == 0) return;
        if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }

        var confirmed = await Dialog.ConfirmAsync(
            "Discard parked messages",
            $"Permanently discard {ids.Count} message{(ids.Count == 1 ? "" : "s")}? This cannot be undone.",
            danger: true);
        if (!confirmed) return;

        _bulkInProgress = true;
        _bulkAction = "Discard";
        int success = 0, failed = 0;
        foreach (var id in ids)
        {
            try
            {
                var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
                var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
                if (resp.Success) success++; else failed++;
            }
            catch { failed++; }
        }
        _toast.ShowSuccess($"{success} discarded" + (failed > 0 ? $", {failed} failed" : "."));
        _selectedIds.Clear();
        _bulkInProgress = false;
        _bulkAction = null;
        await FetchPage();
    }

    // ── Single actions ──

    private async Task RetrySingle(ParkedMessageEntry msg)
    {
        if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
        _actionInProgress = true;
        _activeAction = "Retry";
        try
        {
            var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
            var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
            if (resp.Success)
            {
                _toast.ShowSuccess($"Message {ShortId(msg.MessageId)} queued for retry.");
                await FetchPage();
            }
            else _toast.ShowError(resp.ErrorMessage ?? "Retry failed.");
        }
        catch (Exception ex) { _toast.ShowError($"Retry failed: {ex.Message}"); }
        _actionInProgress = false;
        _activeAction = null;
    }

    private async Task<bool> DiscardSingle(ParkedMessageEntry msg)
    {
        if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return false; }
        var confirmed = await Dialog.ConfirmAsync(
            "Discard parked message",
            $"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.",
            danger: true);
        if (!confirmed) return false;

        _actionInProgress = true;
        _activeAction = "Discard";
        bool ok = false;
        try
        {
            var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
            var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
            if (resp.Success)
            {
                _toast.ShowSuccess($"Message {ShortId(msg.MessageId)} discarded.");
                ok = true;
                await FetchPage();
            }
            else _toast.ShowError(resp.ErrorMessage ?? "Discard failed.");
        }
        catch (Exception ex) { _toast.ShowError($"Discard failed: {ex.Message}"); }
        _actionInProgress = false;
        _activeAction = null;
        return ok;
    }

    private async Task CopyAsync(string text)
    {
        try
        {
            await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
            _toast.ShowSuccess("Copied to clipboard.");
        }
        catch (JSDisconnectedException)
        {
            // Circuit gone — the page is being torn down; nothing to surface.
            // CentralUI-023: distinguished from a genuine interop failure.
        }
        catch (JSException ex)
        {
            // A real clipboard failure (e.g. permission denied) — surface it to
            // the user and log it so it is not invisible in production.
            Logger.LogWarning(ex, "Clipboard copy failed.");
            _toast.ShowError("Copy failed.");
        }
    }

    // ── Helpers ──

    private static string ShortId(string id) => id[..Math.Min(12, id.Length)];

    private static string Relative(DateTimeOffset t)
    {
        var diff = DateTimeOffset.UtcNow - t;
        if (diff.TotalSeconds < 0) return "just now";
        if (diff.TotalSeconds < 60) return "just now";
        if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
        if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
        if (diff.TotalDays < 30) return $"{(int)diff.TotalDays}d ago";
        return t.UtcDateTime.ToString("yyyy-MM-dd");
    }

    private static string AbsoluteUtc(DateTimeOffset t) =>
        $"{t.UtcDateTime:yyyy-MM-dd HH:mm:ss} UTC";

    private static string SeverityClass(ParkedMessageEntry msg)
    {
        var exhausted = msg.MaxAttempts > 0 && msg.AttemptCount >= msg.MaxAttempts;
        if (!exhausted) return "sev-secondary";
        var age = DateTimeOffset.UtcNow - msg.LastAttemptTimestamp;
        return age < TimeSpan.FromHours(1) ? "sev-danger" : "sev-warning";
    }

    private static int AttemptPercent(ParkedMessageEntry msg)
    {
        if (msg.MaxAttempts <= 0) return 100;
        var pct = (int)Math.Round(msg.AttemptCount * 100.0 / msg.MaxAttempts);
        return Math.Clamp(pct, 0, 100);
    }

    private static string AttemptBarClass(ParkedMessageEntry msg) =>
        msg.AttemptCount >= msg.MaxAttempts ? "bg-danger" : "bg-warning";

    private static string CategoryLabel(StoreAndForwardCategory c) => c switch
    {
        StoreAndForwardCategory.ExternalSystem => "External system",
        StoreAndForwardCategory.Notification => "Notification",
        StoreAndForwardCategory.CachedDbWrite => "Cached DB write",
        _ => c.ToString()
    };
}
