using System.Security.Claims;
using Akka.Actor;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using SiteCallsReportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.SiteCalls.SiteCallsReport;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages;

/// <summary>
/// bUnit rendering tests for the Site Calls report page (Site Call Audit #22).
///
/// Testability note: <see cref="CommunicationService"/> is a concrete class with
/// non-virtual methods, so NSubstitute cannot intercept it. The page's calls all
/// route through an injected <see cref="IActorRef"/> (the Site Call Audit proxy),
/// so the tests wire a real, lightweight <see cref="ActorSystem"/> with a scripted
/// <see cref="ReceiveActor"/> that replies with fixed responses — the same seam
/// <c>SetSiteCallAudit</c> exists for. Mirrors <see cref="NotificationReportPageTests"/>.
/// </summary>
public class SiteCallsReportPageTests : BunitContext
{
    private readonly ActorSystem _system = ActorSystem.Create("site-calls-report-tests");
    private readonly CommunicationService _comms;

    // K14: KPI-history facade for the Trends section. Defaults to a known
    // non-empty (≥2-point) series so an expanded chart draws a polyline;
    // individual tests reconfigure it (e.g. to throw) before rendering.
    private readonly IKpiHistoryQueryService _kpiHistory = Substitute.For<IKpiHistoryQueryService>();

    private static readonly Guid ParkedId = Guid.Parse("11111111-1111-1111-1111-111111111111");
    private static readonly Guid FailedId = Guid.Parse("22222222-2222-2222-2222-222222222222");

    // Mutable scripted reply — individual tests can override before rendering.
    private SiteCallQueryResponse _queryReply = new(
        "q", true, null,
        new List<SiteCallSummary>
        {
            new(ParkedId, "plant-a", "ApiOutbound", "ERP.GetOrder", "Parked",
                RetryCount: 3, LastError: "HTTP 503 from ERP", HttpStatus: 503,
                CreatedAtUtc: DateTime.UtcNow.AddMinutes(-30), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-5),
                TerminalAtUtc: null, IsStuck: true),
            new(FailedId, "plant-b", "DbOutbound", "Historian.Write", "Failed",
                RetryCount: 1, LastError: "constraint violation", HttpStatus: null,
                CreatedAtUtc: DateTime.UtcNow.AddHours(-2), UpdatedAtUtc: DateTime.UtcNow.AddHours(-2),
                TerminalAtUtc: DateTime.UtcNow.AddHours(-2), IsStuck: false),
        },
        NextAfterCreatedAtUtc: null,
        NextAfterId: null);

    // Records the most recent retry/discard requests the actor received.
    private readonly List<SiteCallQueryRequest> _queryRequests = new();
    private readonly List<RetrySiteCallRequest> _retryRequests = new();
    private readonly List<DiscardSiteCallRequest> _discardRequests = new();

    // Scripted relay responses — overridable per test.
    private RetrySiteCallResponse _retryReply =
        new("q", SiteCallRelayOutcome.Applied, true, true, null);
    private DiscardSiteCallResponse _discardReply =
        new("q", SiteCallRelayOutcome.Applied, true, true, null);

    public SiteCallsReportPageTests()
    {
        _comms = new CommunicationService(
            Options.Create(new CommunicationOptions()),
            NullLogger<CommunicationService>.Instance);

        var auditProxy = _system.ActorOf(Props.Create(() => new ScriptedSiteCallAuditActor(this)));
        _comms.SetSiteCallAudit(auditProxy);

        Services.AddSingleton(_comms);
        Services.AddSingleton<IDialogService>(new AlwaysConfirmDialogService());

        // K14: by default every trend series resolves to a known ≥2-point series so
        // an expanded chart draws a polyline. Tests that need a failure reconfigure
        // this substitute before rendering.
        _kpiHistory.GetSeriesAsync(
                Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
                Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(new List<KpiSeriesPoint>
            {
                new(DateTime.UtcNow.AddHours(-2), 3),
                new(DateTime.UtcNow.AddHours(-1), 7),
                new(DateTime.UtcNow, 5),
            }));
        Services.AddSingleton(_kpiHistory);

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
            {
                new("Plant A", "plant-a") { Id = 1 },
                new("Plant B", "plant-b") { Id = 2 },
            }));
        Services.AddSingleton(siteRepo);

        var claims = new[]
        {
            new Claim(JwtTokenService.UsernameClaimType, "tester"),
            new Claim(JwtTokenService.RoleClaimType, "Deployer"),
        };
        var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
        Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
        Services.AddAuthorizationCore();
        Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
    }

    [Fact]
    public void Page_RequiresDeploymentPolicy()
    {
        var attr = typeof(SiteCallsReportPage)
            .GetCustomAttributes(typeof(AuthorizeAttribute), true)
            .Cast<AuthorizeAttribute>()
            .FirstOrDefault();

        Assert.NotNull(attr);
        Assert.Equal(AuthorizationPolicies.RequireDeployment, attr!.Policy);
    }

    [Fact]
    public void Renders_SiteCallRows()
    {
        var cut = Render<SiteCallsReportPage>();

        cut.WaitForAssertion(() =>
        {
            Assert.Contains("ERP.GetOrder", cut.Markup);
            Assert.Contains("Historian.Write", cut.Markup);
        });
    }

    [Fact]
    public void StuckRow_IsBadged()
    {
        var cut = Render<SiteCallsReportPage>();

        cut.WaitForAssertion(() =>
        {
            var stuckRow = cut.FindAll("tbody tr")
                .First(r => r.TextContent.Contains("ERP.GetOrder"));
            Assert.Contains("badge", stuckRow.InnerHtml);
            Assert.Contains("Stuck", stuckRow.TextContent);
        });
    }

    [Fact]
    public void RetryDiscardButtons_ShownOnlyOnParkedRows()
    {
        var cut = Render<SiteCallsReportPage>();

        cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));

        var parkedRow = cut.FindAll("tbody tr")
            .First(r => r.TextContent.Contains("ERP.GetOrder"));
        var failedRow = cut.FindAll("tbody tr")
            .First(r => r.TextContent.Contains("Historian.Write"));

        // The Parked row carries Retry + Discard buttons.
        Assert.Contains(parkedRow.QuerySelectorAll("button"),
            b => b.TextContent.Contains("Retry"));
        Assert.Contains(parkedRow.QuerySelectorAll("button"),
            b => b.TextContent.Contains("Discard"));

        // The Failed row carries neither — Retry/Discard are Parked-only.
        Assert.DoesNotContain(failedRow.QuerySelectorAll("button"),
            b => b.TextContent.Contains("Retry"));
        Assert.DoesNotContain(failedRow.QuerySelectorAll("button"),
            b => b.TextContent.Contains("Discard"));
    }

    [Fact]
    public void ClickRetry_OnParkedRow_RelaysRetryToOwningSite()
    {
        var cut = Render<SiteCallsReportPage>();

        cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));

        var parkedRow = cut.FindAll("tbody tr")
            .First(r => r.TextContent.Contains("ERP.GetOrder"));
        var retryButton = parkedRow.QuerySelectorAll("button")
            .First(b => b.TextContent.Contains("Retry"));

        retryButton.Click();

        cut.WaitForAssertion(() =>
        {
            Assert.Single(_retryRequests);
            Assert.Equal(ParkedId, _retryRequests[0].TrackedOperationId);
            // The relay carries the owning site so central can route it.
            Assert.Equal("plant-a", _retryRequests[0].SourceSite);
        });
    }

    [Fact]
    public void ClickDiscard_OnParkedRow_RelaysDiscardToOwningSite()
    {
        var cut = Render<SiteCallsReportPage>();

        cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));

        var parkedRow = cut.FindAll("tbody tr")
            .First(r => r.TextContent.Contains("ERP.GetOrder"));
        var discardButton = parkedRow.QuerySelectorAll("button")
            .First(b => b.TextContent.Contains("Discard"));

        discardButton.Click();

        cut.WaitForAssertion(() =>
        {
            Assert.Single(_discardRequests);
            Assert.Equal(ParkedId, _discardRequests[0].TrackedOperationId);
            Assert.Equal("plant-a", _discardRequests[0].SourceSite);
        });
    }

    [Fact]
    public void RetryRelay_SiteUnreachable_ShowsDistinctMessage()
    {
        // The relay never reached the owning site — a transient transport
        // condition, surfaced distinctly from a generic failure.
        _retryReply = new RetrySiteCallResponse(
            "q", SiteCallRelayOutcome.SiteUnreachable, Success: false, SiteReachable: false,
            ErrorMessage: "Site plant-a is offline — relay not delivered.");

        var cut = Render<SiteCallsReportPage>();
        cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));

        var parkedRow = cut.FindAll("tbody tr")
            .First(r => r.TextContent.Contains("ERP.GetOrder"));
        parkedRow.QuerySelectorAll("button")
            .First(b => b.TextContent.Contains("Retry"))
            .Click();

        cut.WaitForAssertion(() =>
            Assert.Contains("offline", cut.Markup));
    }

    [Fact]
    public void QueryFailure_ShowsErrorMessage()
    {
        _queryReply = new SiteCallQueryResponse(
            "q", false, "site call query backend unavailable",
            new List<SiteCallSummary>(), null, null);

        var cut = Render<SiteCallsReportPage>();

        cut.WaitForAssertion(() =>
            Assert.Contains("site call query backend unavailable", cut.Markup));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Drill-in — every row carries a "View audit history" link to
    // /audit/log?correlationId={TrackedOperationId}.
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public void SiteCallRow_ViewAuditHistory_Link_HasCorrectHref()
    {
        var cut = Render<SiteCallsReportPage>();

        cut.WaitForAssertion(() =>
        {
            // Both rows (Parked + Failed) surface the link — the drill-in is
            // row-scope, not status-scope.
            var parkedRow = cut.FindAll("tbody tr")
                .First(r => r.TextContent.Contains("ERP.GetOrder"));
            var link = parkedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
            Assert.NotNull(link);
            Assert.Equal(
                $"/audit/log?correlationId={ParkedId}",
                link!.GetAttribute("href"));
            Assert.Contains("View audit history", link.TextContent);

            var failedRow = cut.FindAll("tbody tr")
                .First(r => r.TextContent.Contains("Historian.Write"));
            var failedLink = failedRow.QuerySelector("a[data-test^=\"audit-link-\"]");
            Assert.NotNull(failedLink);
            Assert.Equal(
                $"/audit/log?correlationId={FailedId}",
                failedLink!.GetAttribute("href"));
        });
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Keyset paging — Next is driven by the response's NextAfter* cursor, not by
    // page numbers; the request echoes the cursor back to the actor.
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public void Paging_NextButton_HiddenWhenNoFurtherPage()
    {
        // The default reply returns 2 rows and no NextAfter* cursor — there is no
        // further page, so Next is disabled.
        var cut = Render<SiteCallsReportPage>();
        cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));

        var next = cut.Find("[data-test='keyset-next']");
        Assert.True(next.HasAttribute("disabled"));
        var prev = cut.Find("[data-test='keyset-prev']");
        Assert.True(prev.HasAttribute("disabled"));
    }

    [Fact]
    public void Paging_NextButton_AdvancesUsingKeysetCursor()
    {
        // A full page (PageSize=50 rows) plus a NextAfter* cursor: Next is live
        // and, when clicked, the follow-up query carries that cursor.
        var firstPage = new List<SiteCallSummary>();
        for (var i = 0; i < 50; i++)
        {
            firstPage.Add(new SiteCallSummary(
                Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered",
                RetryCount: 0, LastError: null, HttpStatus: 200,
                CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i),
                TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false));
        }

        var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
        var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999");
        _queryReply = new SiteCallQueryResponse(
            "q", true, null, firstPage,
            NextAfterCreatedAtUtc: cursorCreated,
            NextAfterId: cursorId);

        var cut = Render<SiteCallsReportPage>();
        cut.WaitForState(() => cut.Markup.Contains("ERP.Op0"));

        var next = cut.Find("[data-test='keyset-next']");
        Assert.False(next.HasAttribute("disabled"));

        next.Click();

        cut.WaitForAssertion(() =>
        {
            // Two queries fired: the initial load and the Next click. The second
            // carries the keyset cursor echoed by the first response.
            Assert.Equal(2, _queryRequests.Count);
            Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc);
            Assert.Equal(cursorId, _queryRequests[1].AfterId);
        });
    }

    [Fact]
    public void Paging_PrevButton_PopsBackStackAndRefetchesPriorCursor()
    {
        // The keyset back-stack is the trickiest paging path: Next pushes the
        // current cursor, Prev pops it and refetches that prior page. Page 1 is
        // opened with the empty (null, null) cursor, so after Next→Previous the
        // follow-up query must carry (null, null) again.
        var firstPage = new List<SiteCallSummary>();
        for (var i = 0; i < 50; i++)
        {
            firstPage.Add(new SiteCallSummary(
                Guid.NewGuid(), "plant-a", "ApiOutbound", $"ERP.Op{i}", "Delivered",
                RetryCount: 0, LastError: null, HttpStatus: 200,
                CreatedAtUtc: DateTime.UtcNow.AddMinutes(-i), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-i),
                TerminalAtUtc: DateTime.UtcNow.AddMinutes(-i), IsStuck: false));
        }

        var cursorCreated = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
        var cursorId = Guid.Parse("99999999-9999-9999-9999-999999999999");
        _queryReply = new SiteCallQueryResponse(
            "q", true, null, firstPage,
            NextAfterCreatedAtUtc: cursorCreated,
            NextAfterId: cursorId);

        var cut = Render<SiteCallsReportPage>();
        cut.WaitForState(() => cut.Markup.Contains("ERP.Op0"));

        // Step forward — query 2 carries the keyset cursor.
        var next = cut.Find("[data-test='keyset-next']");
        next.Click();
        cut.WaitForAssertion(() =>
        {
            Assert.Equal(2, _queryRequests.Count);
            Assert.Equal(cursorCreated, _queryRequests[1].AfterCreatedAtUtc);
        });

        // Previous is now live (the back-stack has one entry); click it.
        var prev = cut.Find("[data-test='keyset-prev']");
        Assert.False(prev.HasAttribute("disabled"));
        prev.Click();

        cut.WaitForAssertion(() =>
        {
            // Query 3 is the Previous refetch — the back-stack popped the page-1
            // cursor, which is the empty (null, null) first-page cursor.
            Assert.Equal(3, _queryRequests.Count);
            Assert.Null(_queryRequests[2].AfterCreatedAtUtc);
            Assert.Null(_queryRequests[2].AfterId);
            // Back on page 1, the back-stack is empty again so Previous re-disables.
            Assert.True(cut.Find("[data-test='keyset-prev']").HasAttribute("disabled"));
        });
    }

    [Fact]
    public void RetryRelay_NotParked_ShowsInfoMessage_AndExactlyOneToast()
    {
        // NotParked is a definitive answer from the site (nothing to do), not a
        // failure — it surfaces as a single info toast, never an error. This
        // also guards the single-toast contract: a non-Applied outcome must
        // produce exactly one toast.
        _retryReply = new RetrySiteCallResponse(
            "q", SiteCallRelayOutcome.NotParked, Success: false, SiteReachable: true,
            ErrorMessage: "The cached call is no longer parked.");

        var cut = Render<SiteCallsReportPage>();
        cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));

        var parkedRow = cut.FindAll("tbody tr")
            .First(r => r.TextContent.Contains("ERP.GetOrder"));
        parkedRow.QuerySelectorAll("button")
            .First(b => b.TextContent.Contains("Retry"))
            .Click();

        cut.WaitForAssertion(() =>
        {
            Assert.Contains("no longer parked", cut.Markup);
            // Exactly one toast — the ShowRelayOutcome switch owns the single
            // toast; no second (error) toast piggybacks on the same response.
            Assert.Single(cut.FindAll(".toast"));
        });
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Query-string drill-in — the Health-dashboard Site Call KPI tiles deep-link
    // here with ?status=Parked (Parked tile) and ?stuck=true (Stuck tile). The
    // params must seed the filter BEFORE the first query so the initial grid load
    // is already filtered, and the filter card controls must reflect the values.
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public void NavigateWithStatusParkedParam_LoadsGridPreFilteredToParked()
    {
        // The Parked KPI tile emits ?status=Parked — set the URI before render.
        var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
        nav.NavigateTo("/site-calls/report?status=Parked");

        var cut = Render<SiteCallsReportPage>();

        cut.WaitForAssertion(() =>
        {
            // The first (and only) query the page issues carries the Parked
            // status filter — the grid load is pre-filtered, not unfiltered.
            Assert.Single(_queryRequests);
            Assert.Equal("Parked", _queryRequests[0].StatusFilter);

            // The Status <select> control reflects the seeded value so the
            // operator sees the filter and can Clear it.
            var statusSelect = cut.Find("#sc-status");
            Assert.Equal("Parked", statusSelect.GetAttribute("value"));
        });
    }

    [Fact]
    public void NavigateWithStuckTrueParam_LoadsGridWithStuckFilterApplied()
    {
        // The Stuck KPI tile emits ?stuck=true.
        var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
        nav.NavigateTo("/site-calls/report?stuck=true");

        var cut = Render<SiteCallsReportPage>();

        cut.WaitForAssertion(() =>
        {
            // The first query carries StuckOnly = true.
            Assert.Single(_queryRequests);
            Assert.True(_queryRequests[0].StuckOnly);

            // The "Stuck only" checkbox is checked.
            var stuckCheckbox = cut.Find("#sc-stuck-only");
            Assert.True(stuckCheckbox.HasAttribute("checked"));
        });
    }

    [Fact]
    public void NavigateWithNoQueryParams_LoadsGridUnfiltered()
    {
        // No drill-in params — the page loads exactly as before: an unfiltered
        // query and no status/stuck filter set on the controls.
        var cut = Render<SiteCallsReportPage>();

        cut.WaitForAssertion(() =>
        {
            Assert.Single(_queryRequests);
            Assert.Null(_queryRequests[0].StatusFilter);
            Assert.False(_queryRequests[0].StuckOnly);

            var statusSelect = cut.Find("#sc-status");
            Assert.True(string.IsNullOrEmpty(statusSelect.GetAttribute("value")));
            var stuckCheckbox = cut.Find("#sc-stuck-only");
            Assert.False(stuckCheckbox.HasAttribute("checked"));
        });
    }

    [Fact]
    public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows()
    {
        // CentralUI-028: a Deployment user scoped to Plant A only must not see
        // Plant B rows in the grid, even though the query response carried both.
        // Last AuthenticationStateProvider registration wins on resolution.
        var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
        {
            new Claim(JwtTokenService.UsernameClaimType, "scoped"),
            new Claim(JwtTokenService.RoleClaimType, "Deployer"),
            new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only
        }, "TestAuth"));
        Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));

        var cut = Render<SiteCallsReportPage>();
        cut.WaitForState(() => cut.FindAll("table tbody tr").Count > 0,
            TimeSpan.FromSeconds(2));

        var rows = cut.FindAll("table tbody tr");
        Assert.Single(rows);
        // Plant A row only; Plant B (FailedId) row must be filtered out.
        Assert.Contains(ParkedId.ToString("N")[..12], rows[0].TextContent);
        Assert.DoesNotContain(FailedId.ToString("N")[..12], cut.Markup);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Trends (K14) — a collapsible section with one KpiTrendChart per metric
    // (buffered / parked / failedLastInterval) for SiteCallAudit / Global. The
    // section is collapsed on init; expanding it lazy-loads the series.
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public void TrendsSection_Container_AlwaysRenders()
    {
        var cut = Render<SiteCallsReportPage>();

        // The collapsible container (with its toggle) is present even while
        // collapsed — only the charts are gated behind the expand.
        cut.WaitForAssertion(() =>
            Assert.NotNull(cut.Find("[data-test='site-calls-trends']")));
    }

    [Fact]
    public void TrendsSection_WhenExpanded_RendersChartsFromKnownSeries()
    {
        var cut = Render<SiteCallsReportPage>();
        cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));

        // Collapsed on init — no chart yet.
        Assert.Empty(cut.FindAll("[data-test^='kpi-trend-']"));

        // Expand the Trends section; the lazy series load fills the charts.
        cut.Find("[data-test='site-calls-trends-toggle']").Click();

        cut.WaitForAssertion(() =>
        {
            // At least one trend chart rendered, and (because the series has ≥2
            // points) it draws a polyline rather than the unavailable placeholder.
            var charts = cut.FindAll("[data-test^='kpi-trend-']");
            Assert.NotEmpty(charts);
            Assert.Contains("<polyline", cut.Markup);
        });
    }

    [Fact]
    public void TrendsSection_WindowToggle_ReloadsSeries()
    {
        var cut = Render<SiteCallsReportPage>();
        cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));

        cut.Find("[data-test='site-calls-trends-toggle']").Click();
        cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-test^='kpi-trend-']")));

        // First expand issued 3 GetSeriesAsync calls (one per metric).
        _kpiHistory.ReceivedCalls();
        var afterExpand = _kpiHistory.ReceivedCalls().Count();

        // Switch the window to 7d — the series re-load (another 3 calls).
        cut.FindAll("button").First(b => b.TextContent.Trim() == "7d").Click();

        cut.WaitForAssertion(() =>
            Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand));
    }

    [Fact]
    public void TrendsSection_LongRangeWindows_RenderAndReloadSeries()
    {
        // The 30d/90d buttons (plan #22 T7) render alongside 24h/7d and clicking
        // one re-loads the series — the query service then routes the long window
        // to hourly rollups transparently.
        var cut = Render<SiteCallsReportPage>();
        cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));

        cut.Find("[data-test='site-calls-trends-toggle']").Click();
        cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("[data-test^='kpi-trend-']")));

        // Both long-range buttons are present in the toggle group.
        Assert.Contains(cut.FindAll("button"), b => b.TextContent.Trim() == "30d");
        Assert.Contains(cut.FindAll("button"), b => b.TextContent.Trim() == "90d");

        var afterExpand = _kpiHistory.ReceivedCalls().Count();

        // Switch the window to 90d — the series re-load (another batch of calls).
        cut.FindAll("button").First(b => b.TextContent.Trim() == "90d").Click();

        cut.WaitForAssertion(() =>
            Assert.True(_kpiHistory.ReceivedCalls().Count() > afterExpand));
    }

    [Fact]
    public void TrendsSection_WhenKpiHistoryThrows_PageStillRenders()
    {
        // Best-effort contract: a GetSeriesAsync throw must never break the page.
        _kpiHistory.GetSeriesAsync(
                Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
                Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
            .Returns<Task<IReadOnlyList<KpiSeriesPoint>>>(_ =>
                throw new InvalidOperationException("KPI backend down"));

        var cut = Render<SiteCallsReportPage>();
        cut.WaitForState(() => cut.Markup.Contains("ERP.GetOrder"));

        // Expanding triggers the failing loads — the grid and trends container
        // both survive; the charts fall back to the unavailable placeholder.
        cut.Find("[data-test='site-calls-trends-toggle']").Click();

        cut.WaitForAssertion(() =>
        {
            Assert.Contains("ERP.GetOrder", cut.Markup);
            Assert.NotNull(cut.Find("[data-test='site-calls-trends']"));
            // No polyline — the charts degraded to the unavailable em-dash state.
            Assert.DoesNotContain("<polyline", cut.Markup);
        });
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            _system.Terminate().Wait(TimeSpan.FromSeconds(5));
        }
        base.Dispose(disposing);
    }

    /// <summary>
    /// Stand-in for the Site Call Audit actor. Replies to each message type with
    /// the test's currently-scripted response.
    /// </summary>
    private sealed class ScriptedSiteCallAuditActor : ReceiveActor
    {
        public ScriptedSiteCallAuditActor(SiteCallsReportPageTests test)
        {
            Receive<SiteCallQueryRequest>(r =>
            {
                test._queryRequests.Add(r);
                Sender.Tell(test._queryReply);
            });
            Receive<RetrySiteCallRequest>(r =>
            {
                test._retryRequests.Add(r);
                Sender.Tell(test._retryReply);
            });
            Receive<DiscardSiteCallRequest>(r =>
            {
                test._discardRequests.Add(r);
                Sender.Tell(test._discardReply);
            });
        }
    }

    /// <summary>A dialog service that auto-confirms, so action paths run end-to-end.</summary>
    private sealed class AlwaysConfirmDialogService : IDialogService
    {
        public Task<bool> ConfirmAsync(string title, string message, bool danger = false)
            => Task.FromResult(true);

        public Task<string?> PromptAsync(
            string title, string label, string initialValue = "", string? placeholder = null)
            => Task.FromResult<string?>(null);

        public Task<TResult?> ShowAsync<TResult>(
            string title,
            Microsoft.AspNetCore.Components.RenderFragment<DialogContext<TResult>> body,
            string? size = null)
            => Task.FromResult<TResult?>(default);
    }
}
