using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls;

/// <summary>
/// Code-behind for the central Site Calls report page. A
/// near-mirror of <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Notifications.NotificationReport"/>:
/// it queries the central <c>SiteCalls</c> table via
/// <see cref="ZB.MOM.WW.ScadaBridge.Communication.CommunicationService.QuerySiteCallsAsync"/>,
/// shows a filterable/keyset-paged grid and a detail modal, and relays Retry/Discard
/// of <c>Parked</c> cached calls to their owning site.
///
/// <para>
/// Unlike the Notification report, the query response uses a <c>(CreatedAtUtc DESC,
/// TrackedOperationId DESC)</c> keyset cursor rather than page numbers, so paging
/// keeps a stack of the cursors that opened each page (to step backwards) plus the
/// response's <c>NextAfter*</c> cursor (to step forwards).
/// </para>
///
/// <para>
/// Retry/Discard relay to the owning site has a distinct <see cref="SiteCallRelayOutcome.SiteUnreachable"/>
/// outcome — central is an eventually-consistent mirror, not the source of truth, so
/// a relay that never reaches the site is a transient transport condition, surfaced
/// to the operator differently from a generic failure.
/// </para>
///
/// <para>
/// Query-string drill-in: the Health-dashboard Site Call KPI tiles deep-link here
/// with <c>?status=Parked</c> (Parked tile) or <c>?stuck=true</c> (Stuck tile). On
/// initialization those params seed <see cref="_statusFilter"/> / <see cref="_stuckOnly"/>
/// BEFORE the first <see cref="RefreshAll"/>, so the first grid load is already
/// filtered and the filter card controls reflect the seeded values. Parsing is lax
/// — an absent, blank, or unrecognised value is silently dropped and the page loads
/// unfiltered, mirroring <c>AuditLogPage</c>'s drill-in convention.
/// </para>
/// </summary>
public partial class SiteCallsReport
{
    private const int PageSize = 50;

    [Inject] private NavigationManager Navigation { get; set; } = null!;
    [Inject] private SiteScopeService SiteScope { get; set; } = null!;
    [Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = null!;

    // The Status filter <select> options — the exact strings the dropdown binds and
    // the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
    // filter when it matches one of these (case-insensitively); anything else is
    // dropped so a hand-crafted bad URL still renders the page unfiltered.
    private static readonly string[] ValidStatuses =
    {
        "Submitted", "Forwarded", "Attempted", "Delivered", "Parked", "Failed", "Discarded",
    };

    private ToastNotification _toast = default!;
    private List<Site> _sites = new();
    // Unfiltered site list so a permitted-site lookup resolves
    // correctly for a SourceSite whose Site was filtered out of the dropdown.
    private List<Site> _allSites = new();
    private bool _siteScopeSystemWide;
    private HashSet<int> _permittedSiteIds = new();

    // List
    private List<SiteCallSummary>? _siteCalls;
    private bool _loading;
    private string? _listError;
    private bool _actionInProgress;

    // Keyset paging. The first page is opened with the empty (null, null) cursor.
    // _cursorStack holds the cursors of the PREVIOUSLY visited pages — it is empty
    // on page 1, has one entry on page 2, and so on; Previous pops it. _nextCursor
    // is the cursor for the following page, echoed back by the last query.
    private readonly Stack<(DateTime? AfterCreatedAtUtc, Guid? AfterId)> _cursorStack = new();
    private (DateTime? AfterCreatedAtUtc, Guid? AfterId) _currentCursor = (null, null);
    private (DateTime? AfterCreatedAtUtc, Guid? AfterId)? _nextCursor;

    // Row detail modal. Only the row's IDENTIFIER is held — never the
    // SiteCallSummary record itself — so the modal cannot outlive the row it was
    // opened for. DetailRow() re-resolves it from the page currently on screen on
    // every render; a row that has left the page resolves to null and the modal
    // renders nothing (it self-closes) rather than showing a stale snapshot.
    private Guid? _detailSiteCallId;
    private SiteCallDetail? _detail;
    private bool _detailLoading;
    private string? _detailError;

    // Filters
    private string _statusFilter = string.Empty;
    private string _channelFilter = string.Empty;
    private string _siteFilter = string.Empty;
    private string _targetFilter = string.Empty;
    private string _nodeFilter = string.Empty;
    private bool _stuckOnly;
    private DateTime? _fromFilter;
    private DateTime? _toFilter;

    // ── Trends ──────────────────────────────────────────────────────────────
    // Collapsible KPI-history charts for the SiteCallAudit / Global series. The
    // section is collapsed on init and the series load lazily on first expand
    // (and on each 24h/7d window toggle), so the page's primary job — the call
    // list — never waits on the history query. Each metric carries its own
    // series + availability + error so one metric's failure leaves the other two
    // charts intact; a GetSeriesAsync throw never breaks the grid above.
    private bool _trendsExpanded;
    private bool _trendsLoading;
    private int _windowHours = 24;

    private IReadOnlyList<KpiSeriesPoint>? _bufferedSeries;
    private bool _bufferedAvailable = true;
    private string? _bufferedError;

    private IReadOnlyList<KpiSeriesPoint>? _parkedSeries;
    private bool _parkedAvailable = true;
    private string? _parkedError;

    private IReadOnlyList<KpiSeriesPoint>? _failedSeries;
    private bool _failedAvailable = true;
    private string? _failedError;

    private bool HasNextPage => _nextCursor is not null;

    /// <summary>
    /// Resolves the row the detail modal is open for from the page currently on
    /// screen. Returns null when no row is selected, when the list has not loaded,
    /// or when the selected row is no longer in the page — the modal's visibility
    /// gate, so a refresh that drops the row closes the surface instead of leaving
    /// it stranded on a stale record.
    /// </summary>
    private SiteCallSummary? DetailRow() =>
        _detailSiteCallId is { } id
            ? _siteCalls?.FirstOrDefault(c => c.TrackedOperationId == id)
            : null;

    /// <inheritdoc />
    protected override async Task OnInitializedAsync()
    {
        try
        {
            _allSites = (await SiteRepository.GetAllSitesAsync()).ToList();
            // Restrict the source-site dropdown to the user's
            // permitted set. System-wide users see the full list back unchanged.
            _sites = await SiteScope.FilterSitesAsync(_allSites);
            _siteScopeSystemWide = await SiteScope.IsSystemWideAsync();
            _permittedSiteIds = new HashSet<int>(await SiteScope.PermittedSiteIdsAsync());
        }
        catch (Exception ex)
        {
            // Non-fatal — the source-site filter just falls back to raw site IDs.
            Logger.LogWarning(ex, "Failed to load sites for the Site Calls source-site filter.");
        }

        // Seed filters from ?status= / ?stuck= BEFORE the first fetch so the initial
        // grid load is already filtered (and the filter card controls reflect it).
        ApplyQueryStringFilters();

        await RefreshAll();
    }

    /// <summary>
    /// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
    /// string. <c>?status=&lt;status&gt;</c> seeds <see cref="_statusFilter"/> when it
    /// matches a known status (case-insensitive); <c>?stuck=true</c> seeds
    /// <see cref="_stuckOnly"/>. Lax parsing — an absent, blank, or unrecognised value
    /// is silently dropped, leaving the filter empty (the no-param behaviour).
    /// </summary>
    private void ApplyQueryStringFilters()
    {
        var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
        var query = QueryHelpers.ParseQuery(uri.Query);

        if (query.Count == 0)
        {
            return;
        }

        if (query.TryGetValue("status", out var statusValues))
        {
            var v = statusValues.ToString();
            // Round-trip the dropdown's own option strings (the KPI tile emits the
            // canonical casing, e.g. ?status=Parked); normalise to that casing so the
            // <select> binds. An unrecognised value leaves the filter unset.
            var match = ValidStatuses.FirstOrDefault(
                s => string.Equals(s, v?.Trim(), StringComparison.OrdinalIgnoreCase));
            if (match is not null)
            {
                _statusFilter = match;
            }
        }

        if (query.TryGetValue("stuck", out var stuckValues)
            && bool.TryParse(stuckValues.ToString(), out var stuck))
        {
            _stuckOnly = stuck;
        }
    }

    /// <summary>Re-fetch the current page (Refresh button, and after a relay action).</summary>
    private async Task RefreshAll()
    {
        await FetchPage(_currentCursor);
    }

    /// <summary>Apply the filters and start again from the first page.</summary>
    private async Task Search()
    {
        _cursorStack.Clear();
        await FetchPage((null, null));
    }

    private async Task PrevPage()
    {
        if (_cursorStack.Count == 0)
        {
            return;
        }

        // The top of the stack is the cursor of the page BEFORE the current one.
        var previousCursor = _cursorStack.Pop();
        await FetchPage(previousCursor);
    }

    private async Task NextPage()
    {
        if (_nextCursor is not { } next)
        {
            return;
        }

        // Stepping forward: remember the current page's cursor so Previous can
        // return to it.
        _cursorStack.Push(_currentCursor);
        await FetchPage(next);
    }

    /// <summary>
    /// Fetch one keyset page starting after <paramref name="cursor"/>.
    /// </summary>
    private async Task FetchPage(
        (DateTime? AfterCreatedAtUtc, Guid? AfterId) cursor)
    {
        _loading = true;
        _listError = null;
        try
        {
            var request = new SiteCallQueryRequest(
                CorrelationId: Guid.NewGuid().ToString("N"),
                StatusFilter: NullIfEmpty(_statusFilter),
                SourceSiteFilter: NullIfEmpty(_siteFilter),
                ChannelFilter: NullIfEmpty(_channelFilter),
                TargetKeyword: NullIfEmpty(_targetFilter),
                StuckOnly: _stuckOnly,
                FromUtc: ToUtc(_fromFilter),
                ToUtc: ToUtc(_toFilter),
                AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
                AfterId: cursor.AfterId,
                PageSize: PageSize,
                SourceNodeFilter: NullIfEmpty(_nodeFilter));

            var response = await CommunicationService.QuerySiteCallsAsync(request);
            if (response.Success)
            {
                // Drop any row whose source site is outside the
                // user's permitted set, as a row-level safety net behind the
                // dropdown restriction.
                _siteCalls = await FilterPermittedAsync(response.SiteCalls);
                _currentCursor = cursor;

                // The response echoes the last row's cursor. A short page (fewer
                // rows than requested) has no further page even if a cursor came
                // back, so gate Next on a full page too.
                _nextCursor = response.NextAfterCreatedAtUtc is { } nextCreated
                              && response.NextAfterId is { } nextId
                              && _siteCalls.Count == PageSize
                    ? (nextCreated, nextId)
                    : null;
            }
            else
            {
                _listError = response.ErrorMessage ?? "Query failed.";
            }
        }
        catch (Exception ex)
        {
            _listError = $"Query failed: {ex.Message}";
        }
        _loading = false;
    }

    private async Task RetrySiteCall(SiteCallSummary c)
    {
        // Server-side re-check before relaying — a Retry relay must
        // not fire for a site outside the caller's permitted set, even if the row
        // somehow appeared in the grid.
        if (!await IsRowSiteAllowedAsync(c.SourceSite))
        {
            _toast.ShowError("You are not permitted to act on cached calls for this site.");
            return;
        }

        var confirmed = await Dialog.ConfirmAsync(
            "Retry cached call",
            $"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
            $"to site {SiteName(c.SourceSite)}?");
        if (!confirmed) return;

        _actionInProgress = true;
        try
        {
            var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
            var response = await CommunicationService.RetrySiteCallAsync(
                new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
            ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
                appliedMessage: $"Retry of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
            if (response.Success)
            {
                await RefreshAll();
            }
        }
        catch (Exception ex)
        {
            _toast.ShowError($"Retry failed: {ex.Message}");
        }
        _actionInProgress = false;
    }

    private async Task DiscardSiteCall(SiteCallSummary c)
    {
        if (!await IsRowSiteAllowedAsync(c.SourceSite))
        {
            _toast.ShowError("You are not permitted to act on cached calls for this site.");
            return;
        }

        var confirmed = await Dialog.ConfirmAsync(
            "Discard cached call",
            $"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
            $"to site {SiteName(c.SourceSite)}? This cannot be undone.",
            danger: true);
        if (!confirmed) return;

        _actionInProgress = true;
        try
        {
            var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
            var response = await CommunicationService.DiscardSiteCallAsync(
                new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
            ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
                appliedMessage: $"Discard of {ShortId(c.TrackedOperationId)} relayed to {SiteName(c.SourceSite)}.");
            if (response.Success)
            {
                await RefreshAll();
            }
        }
        catch (Exception ex)
        {
            _toast.ShowError($"Discard failed: {ex.Message}");
        }
        _actionInProgress = false;
    }

    /// <summary>
    /// Surface a relay outcome on the toast — exactly one toast per relay
    /// response. The <see cref="SiteCallRelayOutcome.SiteUnreachable"/> case is
    /// deliberately distinct from a generic failure: the action was not applied
    /// but the operator can retry once the site is back online.
    /// </summary>
    /// <remarks>
    /// The <see cref="SiteCallRelayOutcome"/> switch is exhaustive, so it owns
    /// the single toast. <paramref name="siteReachable"/> is a redundant
    /// cross-check on the same signal (the contract sets it <c>false</c> only
    /// for <see cref="SiteCallRelayOutcome.SiteUnreachable"/>); it is folded
    /// INTO the <see cref="SiteCallRelayOutcome.OperationFailed"/> case rather
    /// than firing a second toast — an <c>OperationFailed</c> response that also
    /// reports an unreachable site shows the unreachable wording, once.
    /// </remarks>
    private void ShowRelayOutcome(
        SiteCallRelayOutcome outcome, bool siteReachable, string? errorMessage, string appliedMessage)
    {
        switch (outcome)
        {
            case SiteCallRelayOutcome.Applied:
                _toast.ShowSuccess(appliedMessage);
                break;
            case SiteCallRelayOutcome.NotParked:
                _toast.ShowInfo(errorMessage
                    ?? "The site reported nothing to do — the cached call is no longer parked.");
                break;
            case SiteCallRelayOutcome.SiteUnreachable:
                _toast.ShowError(errorMessage
                    ?? "Site unreachable — the relay did not reach the owning site. "
                       + "Try again once the site is back online.");
                break;
            case SiteCallRelayOutcome.OperationFailed when !siteReachable:
                // An OperationFailed response that nonetheless reports the site
                // unreachable: trust the reachability signal and show the
                // unreachable wording instead of the generic failure message.
                _toast.ShowError(errorMessage
                    ?? "Site unreachable — the relay did not reach the owning site. "
                       + "Try again once the site is back online.");
                break;
            case SiteCallRelayOutcome.OperationFailed:
            default:
                _toast.ShowError(errorMessage ?? "The site could not apply the action.");
                break;
        }
    }

    private async Task ShowDetail(SiteCallSummary c)
    {
        // The summary fields render immediately from the grid row; the full detail
        // (HttpStatus, all timestamps, LastError) fills in once the fetch completes.
        _detailSiteCallId = c.TrackedOperationId;
        _detail = null;
        _detailError = null;
        _detailLoading = true;
        StateHasChanged();

        try
        {
            var response = await CommunicationService.GetSiteCallDetailAsync(
                new SiteCallDetailRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId));
            if (response.Success && response.Detail != null)
            {
                _detail = response.Detail;
            }
            else
            {
                _detailError = response.ErrorMessage ?? "Failed to load site call detail.";
            }
        }
        catch (Exception ex)
        {
            _detailError = $"Failed to load site call detail: {ex.Message}";
        }
        _detailLoading = false;
    }

    private void CloseDetail()
    {
        _detailSiteCallId = null;
        _detail = null;
        _detailError = null;
        _detailLoading = false;
    }

    private async Task RetryFromDetail(SiteCallSummary c)
    {
        await RetrySiteCall(c);
        // RefreshAll replaces the row list; close the modal so the user sees the
        // refreshed grid rather than a now-stale detail snapshot.
        CloseDetail();
    }

    private async Task DiscardFromDetail(SiteCallSummary c)
    {
        await DiscardSiteCall(c);
        CloseDetail();
    }

    private void ClearFilters()
    {
        _statusFilter = string.Empty;
        _channelFilter = string.Empty;
        _siteFilter = string.Empty;
        _targetFilter = string.Empty;
        _nodeFilter = string.Empty;
        _stuckOnly = false;
        _fromFilter = null;
        _toFilter = null;
    }

    private bool HasActiveFilters =>
        !string.IsNullOrEmpty(_statusFilter) ||
        !string.IsNullOrEmpty(_channelFilter) ||
        !string.IsNullOrEmpty(_siteFilter) ||
        !string.IsNullOrEmpty(_targetFilter) ||
        !string.IsNullOrEmpty(_nodeFilter) ||
        _stuckOnly ||
        _fromFilter != null ||
        _toFilter != null;

    private string SiteName(string siteId) =>
        _sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;

    private static string? NullIfEmpty(string s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();

    /// <summary>
    /// <c>&lt;input type="datetime-local"&gt;</c> binds with
    /// <see cref="DateTimeKind.Unspecified"/> and the value is the operator's
    /// browser-local wall-clock. Tag it <see cref="DateTimeKind.Local"/> and
    /// convert to UTC before the value enters the wire query — otherwise the
    /// From/To window is silently shifted by the operator's UTC offset.
    /// </summary>
    private static DateTime? ToUtc(DateTime? value) =>
        value.HasValue
            ? DateTime.SpecifyKind(value.Value, DateTimeKind.Local).ToUniversalTime()
            : (DateTime?)null;

    /// <summary>
    /// The <c>SiteCalls</c> timestamps are UTC <see cref="DateTime"/>; wrap them as
    /// a <see cref="DateTimeOffset"/> for <c>TimestampDisplay</c>.
    /// </summary>
    private static DateTimeOffset? AsOffset(DateTime? value) =>
        value == null
            ? null
            : new DateTimeOffset(DateTime.SpecifyKind(value.Value, DateTimeKind.Utc));

    // A Guid's "N" format is always exactly 32 hex chars, so the [..12] slice is
    // always in range — no length guard needed.
    private static string ShortId(Guid id) => id.ToString("N")[..12];

    private static string StatusBadgeClass(string status) => status switch
    {
        "Delivered" => "bg-success",
        "Parked" => "bg-danger",
        "Failed" => "bg-danger",
        "Attempted" => "bg-warning text-dark",
        "Forwarded" => "bg-info text-dark",
        "Submitted" => "bg-info text-dark",
        "Discarded" => "bg-secondary",
        _ => "bg-light text-dark"
    };

    /// <summary>
    /// Drops any site-call row whose source site resolves to a Site.Id outside the
    /// caller's permitted set. System-wide users get the list back unchanged.
    /// Lookup uses <c>_allSites</c> (not <c>_sites</c>) so a row whose Site was
    /// filtered OUT of the dropdown is correctly classified as out-of-scope.
    /// </summary>
    private Task<List<SiteCallSummary>> FilterPermittedAsync(
        IEnumerable<SiteCallSummary> calls)
    {
        if (_siteScopeSystemWide)
            return Task.FromResult(calls.ToList());

        var filtered = calls
            .Where(c =>
            {
                var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == c.SourceSite);
                return resolved is null || _permittedSiteIds.Contains(resolved.Id);
            })
            .ToList();
        return Task.FromResult(filtered);
    }

    /// <summary>
    /// Server-side re-check for the Retry/Discard relay. True for a system-wide
    /// user, or when the row's source site maps to a Site.Id in the permitted set.
    /// </summary>
    private Task<bool> IsRowSiteAllowedAsync(string sourceSite)
    {
        if (_siteScopeSystemWide)
            return Task.FromResult(true);

        var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSite);
        if (resolved is null)
            return Task.FromResult(true);

        return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
    }

    // ── Trends ──────────────────────────────────────────────────────────────

    /// <summary>
    /// Expand/collapse the Trends section. The series load on first expand (and
    /// stay loaded across collapse/expand cycles); collapsing leaves the loaded
    /// series in place so re-expanding is instant. A re-fetch only happens on the
    /// window toggle.
    /// </summary>
    private async Task ToggleTrendsAsync()
    {
        _trendsExpanded = !_trendsExpanded;
        if (_trendsExpanded && _bufferedSeries is null && _parkedSeries is null && _failedSeries is null)
        {
            await LoadTrendsAsync();
        }
    }

    /// <summary>Switch the trend window (24h / 7d) and re-load the series.</summary>
    private async Task SetWindowAsync(int windowHours)
    {
        if (_windowHours == windowHours)
        {
            return;
        }

        _windowHours = windowHours;
        await LoadTrendsAsync();
    }

    /// <summary>
    /// Load all three SiteCallAudit / Global trend series for the current window.
    /// Best-effort: each <see cref="IKpiHistoryQueryService.GetSeriesAsync"/> is
    /// wrapped independently so one metric's failure renders only that chart
    /// unavailable, and a throw never propagates to break the Site Calls grid.
    /// </summary>
    private async Task LoadTrendsAsync()
    {
        _trendsLoading = true;
        try
        {
            var toUtc = DateTime.UtcNow;
            var fromUtc = toUtc - TimeSpan.FromHours(_windowHours);

            (_bufferedSeries, _bufferedAvailable, _bufferedError) =
                await LoadSeriesAsync(KpiMetrics.SiteCallAudit.Buffered, fromUtc, toUtc);
            (_parkedSeries, _parkedAvailable, _parkedError) =
                await LoadSeriesAsync(KpiMetrics.SiteCallAudit.Parked, fromUtc, toUtc);
            (_failedSeries, _failedAvailable, _failedError) =
                await LoadSeriesAsync(KpiMetrics.SiteCallAudit.FailedLastInterval, fromUtc, toUtc);
        }
        finally
        {
            // try/finally so an unexpected throw before this line can't lock the
            // spinner/disabled state on forever. The per-metric LoadSeriesAsync
            // try/catches already swallow GetSeriesAsync failures, so this guards
            // only the truly unexpected.
            _trendsLoading = false;
        }
    }

    /// <summary>
    /// Fetch one SiteCallAudit / Global series, swallowing any failure into the
    /// chart's unavailable state. Returns the points (null on failure), the
    /// availability flag, and a short error message for the chart placeholder.
    /// </summary>
    private async Task<(IReadOnlyList<KpiSeriesPoint>? Points, bool Available, string? Error)>
        LoadSeriesAsync(string metric, DateTime fromUtc, DateTime toUtc)
    {
        try
        {
            var points = await KpiHistory.GetSeriesAsync(
                KpiSources.SiteCallAudit, metric, KpiScopes.Global, scopeKey: null, fromUtc, toUtc);
            return (points, true, null);
        }
        catch (Exception ex)
        {
            // A KPI-history hiccup must never break the Site Calls page — the
            // chart degrades to its unavailable placeholder instead.
            Logger.LogWarning(ex, "Failed to load Site Calls trend series for metric {Metric}.", metric);
            return (null, false, "Trend data unavailable.");
        }
    }
}
