using Microsoft.Playwright;
using Xunit;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.SiteCalls;

/// <summary>
/// End-to-end coverage for the central Site Calls page (Site Call Audit #22,
/// follow-ups Task 6).
///
/// <para>
/// Each test seeds its own <c>SiteCalls</c> rows directly into the running
/// cluster's configuration database via <see cref="SiteCallDataSeeder"/>,
/// exercises the UI through Playwright, then best-effort deletes the rows by
/// their <c>Target</c> prefix. The Site Calls page reads the <c>SiteCalls</c>
/// table through the <c>SiteCallAuditActor</c> (a pure read-from-table mirror),
/// so a directly-INSERTed row surfaces exactly as a telemetry-ingested row
/// would — the same seeding model the Audit Log E2E tests use. The pattern
/// keeps each test self-contained without touching
/// <c>infra/mssql/seed-config.sql</c>.
/// </para>
///
/// <para>
/// Scenarios covered (per the Task 6 brief):
/// <list type="bullet">
///   <item><c>PageLoads</c> — the page renders for a Deployment-role user.</item>
///   <item><c>FilterNarrowing</c> — a channel filter narrows the results grid.</item>
///   <item><c>DrillIn</c> — the "View audit history" link deep-links into the
///   Audit Log pre-filtered to the call's TrackedOperationId.</item>
///   <item><c>RetryDiscardVisibility</c> — Retry/Discard appear only on Parked
///   rows, never on Failed (or other) rows.</item>
///   <item><c>RetryClickThrough</c> — clicking Retry on a Parked row confirms
///   the dialog, relays to the owning site, and surfaces an outcome toast.</item>
///   <item><c>DiscardClickThrough</c> — clicking Discard on a Parked row confirms
///   the danger dialog ("Delete"), relays to the owning site, and surfaces an
///   outcome toast.</item>
/// </list>
/// </para>
///
/// <para>
/// The DB-seeding tests are <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot</c>:
/// when the cluster / MSSQL is unreachable they report as Skipped (not Failed),
/// matching the established <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests</c> idiom.
/// </para>
/// </summary>
[Collection("Playwright")]
public class SiteCallsPageTests
{
    private const string SiteCallsUrl = "/site-calls/report";

    private readonly PlaywrightFixture _fixture;

    public SiteCallsPageTests(PlaywrightFixture fixture)
    {
        _fixture = fixture;
    }

    /// <summary>
    /// Sets the Target-keyword search box and commits the value to the server
    /// as its own discrete circuit message before the caller clicks Query.
    /// <para>
    /// The <c>#sc-search</c> input is a Blazor <c>@bind</c>
    /// (commit-on-<c>change</c>): <see cref="ILocator.FillAsync"/> only fires
    /// <c>input</c> events, and the <c>change</c> that actually updates
    /// <c>_targetFilter</c> on the server fires on blur. The original test
    /// relied on the Query <c>ClickAsync</c> itself to blur the field — that
    /// makes the <c>change</c> (blur) and the <c>click</c> a single, near-
    /// simultaneous gesture and races them over the SignalR circuit: when the
    /// <c>click</c> is processed before the <c>change</c> has updated
    /// <c>_targetFilter</c>, <c>Search()</c> runs with a stale (empty) keyword
    /// and the grid returns unfiltered rows.
    /// </para>
    /// <para>
    /// <see cref="ILocator.DispatchEventAsync"/> raises the <c>change</c> as a
    /// fully-awaited action of its own, so its circuit message is enqueued and
    /// sent before the later Query <c>ClickAsync</c>'s message. The SignalR
    /// connection delivers messages in send order and the Blazor circuit
    /// processes them sequentially, so <c>_targetFilter</c> is guaranteed
    /// committed before <c>Search()</c> runs — the two are no longer one
    /// racing gesture.
    /// </para>
    /// </summary>
    private static async Task SetSearchKeywordAsync(IPage page, string keyword)
    {
        var search = page.Locator("#sc-search");
        await search.FillAsync(keyword);
        // Commit the @bind as a discrete change event — not a blur side effect
        // of the subsequent Query click.
        await search.DispatchEventAsync("change");
    }

    [Fact]
    public async Task PageLoads_ForDeploymentUser()
    {
        var page = await _fixture.NewAuthenticatedPageAsync();
        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

        Assert.Contains(SiteCallsUrl, page.Url);
        await Assertions.Expect(page.Locator("h4:has-text('Site Calls')")).ToBeVisibleAsync();
        // The filter card's Query button is the page's primary action.
        await Assertions.Expect(page.Locator("[data-test='site-calls-query']")).ToBeVisibleAsync();
    }

    /// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
    private const string DbUnavailableSkipReason =
        "SiteCallDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
        "or set SCADABRIDGE_PLAYWRIGHT_DB to a reachable connection string.";

    [SkippableFact]
    public async Task FilterNarrowing_ChannelFilterShrinksGrid()
    {
        Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/sc-filter/{runId}/";
        var apiId = Guid.NewGuid();
        var dbId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            // One ApiOutbound row, one DbOutbound row — distinct Targets.
            await SiteCallDataSeeder.InsertSiteCallAsync(
                trackedOperationId: apiId, channel: "ApiOutbound", target: targetPrefix + "api",
                sourceSite: "site-a", status: "Delivered", retryCount: 0,
                createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);
            await SiteCallDataSeeder.InsertSiteCallAsync(
                trackedOperationId: dbId, channel: "DbOutbound", target: targetPrefix + "db",
                sourceSite: "site-a", status: "Delivered", retryCount: 0,
                createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);

            var page = await _fixture.NewAuthenticatedPageAsync();
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // Unfiltered query: both seeded rows appear (the Target keyword scopes
            // to this run so unrelated cluster rows do not interfere).
            await SetSearchKeywordAsync(page, targetPrefix + "api");
            await page.Locator("[data-test='site-calls-query']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // Only the ApiOutbound row matches the exact target keyword. The
            // grid filters with an exact Target match, so the db row must be
            // absent — use the retrying ToHaveCount assertion so the negative
            // check waits out the post-query re-render rather than reading a
            // point-in-time count.
            await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToBeVisibleAsync();
            await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToHaveCountAsync(0);

            // Now filter by Channel = DbOutbound with the db target — the row flips.
            await SetSearchKeywordAsync(page, targetPrefix + "db");
            await page.Locator("#sc-channel").SelectOptionAsync("DbOutbound");
            await page.Locator("[data-test='site-calls-query']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            await Assertions.Expect(page.Locator($"text={targetPrefix}db")).ToBeVisibleAsync();
            await Assertions.Expect(page.Locator($"text={targetPrefix}api")).ToHaveCountAsync(0);
        }
        finally
        {
            await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task DrillIn_ViewAuditHistory_NavigatesToPreFilteredAuditLog()
    {
        Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/sc-drill-in/{runId}/";
        var trackedId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            await SiteCallDataSeeder.InsertSiteCallAsync(
                trackedOperationId: trackedId, channel: "ApiOutbound", target: targetPrefix + "endpoint",
                sourceSite: "site-a", status: "Delivered", retryCount: 0,
                createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);

            var page = await _fixture.NewAuthenticatedPageAsync();
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            await SetSearchKeywordAsync(page, targetPrefix + "endpoint");
            await page.Locator("[data-test='site-calls-query']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The row carries a "View audit history" link whose href is the
            // canonical correlationId deep-link — the TrackedOperationId IS the
            // audit CorrelationId.
            var link = page.Locator($"a[data-test='audit-link-{trackedId}']");
            await Assertions.Expect(link).ToBeVisibleAsync();
            var href = await link.GetAttributeAsync("href");
            Assert.Equal($"/audit/log?correlationId={trackedId}", href);

            // Following the link lands on the Audit Log page with the query-string
            // drill-in context intact.
            await link.ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            Assert.Contains($"correlationId={trackedId}", page.Url);
            await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
        }
        finally
        {
            await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task RetryDiscard_VisibleOnlyOnParkedRows()
    {
        Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/sc-actions/{runId}/";
        var parkedId = Guid.NewGuid();
        var failedId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            // One Parked row (actionable) and one Failed row (terminal — not
            // actionable from central).
            await SiteCallDataSeeder.InsertSiteCallAsync(
                trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
                sourceSite: "site-a", status: "Parked", retryCount: 3,
                lastError: "HTTP 503 from ERP", httpStatus: 503,
                createdAtUtc: now, updatedAtUtc: now);
            await SiteCallDataSeeder.InsertSiteCallAsync(
                trackedOperationId: failedId, channel: "DbOutbound", target: targetPrefix + "failed",
                sourceSite: "site-a", status: "Failed", retryCount: 1,
                lastError: "constraint violation",
                createdAtUtc: now, updatedAtUtc: now, terminalAtUtc: now);

            var page = await _fixture.NewAuthenticatedPageAsync();
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // Query the parked row first.
            await SetSearchKeywordAsync(page, targetPrefix + "parked");
            await page.Locator("[data-test='site-calls-query']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
            await Assertions.Expect(parkedRow).ToBeVisibleAsync();
            // The Parked row exposes both Retry and Discard.
            await Assertions.Expect(parkedRow.Locator("button:has-text('Retry')")).ToBeVisibleAsync();
            await Assertions.Expect(parkedRow.Locator("button:has-text('Discard')")).ToBeVisibleAsync();

            // Now the Failed row — Retry/Discard are absent.
            await SetSearchKeywordAsync(page, targetPrefix + "failed");
            await page.Locator("[data-test='site-calls-query']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var failedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "failed" });
            await Assertions.Expect(failedRow).ToBeVisibleAsync();
            Assert.Equal(0, await failedRow.Locator("button:has-text('Retry')").CountAsync());
            Assert.Equal(0, await failedRow.Locator("button:has-text('Discard')").CountAsync());
        }
        finally
        {
            await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast()
    {
        Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/sc-retry-click/{runId}/";
        var parkedId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            // A single Parked row — the only status from which Retry/Discard can
            // be relayed to the owning site. Unlike the display-only tests above,
            // this one actually relays to the owning site, so the SourceSite must
            // be a *real* site identifier from the running cluster (site-a) and
            // not the cosmetic "plant-a" label: an unknown site has no reachable
            // gRPC endpoint, so CentralCommunicationActor drops the envelope
            // without replying and the relay only resolves on the 10s inner Ask
            // timeout — too slow for the toast assertion below. Relayed to a live
            // site, the site finds no parked S&F message for this freshly-seeded
            // GUID and replies a fast NotParked ack, which still surfaces a toast.
            await SiteCallDataSeeder.InsertSiteCallAsync(
                trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
                sourceSite: "site-a", status: "Parked", retryCount: 3,
                lastError: "HTTP 503 from ERP", httpStatus: 503,
                createdAtUtc: now, updatedAtUtc: now);

            var page = await _fixture.NewAuthenticatedPageAsync();
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            await SetSearchKeywordAsync(page, targetPrefix + "parked");
            await page.Locator("[data-test='site-calls-query']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
            await Assertions.Expect(parkedRow).ToBeVisibleAsync();

            // Click Retry — this opens the confirmation dialog (DialogHost modal).
            await parkedRow.Locator("button:has-text('Retry')").ClickAsync();

            // Confirm the relay in the dialog footer ("Confirm" — the non-danger
            // label; Discard would render "Delete").
            var confirmButton = page.Locator(".modal-footer button:has-text('Confirm')");
            await Assertions.Expect(confirmButton).ToBeVisibleAsync();
            await confirmButton.ClickAsync();

            // The relay outcome surfaces on a toast — Applied, NotParked or, if
            // the owning site is offline in this environment, SiteUnreachable.
            // We only assert that an outcome toast appears (exactly one — the
            // single-toast contract), not which one, since the live cluster
            // state determines the outcome. The wait is generous (15s): the
            // relay round-trips to the site over gRPC, and a worst-case
            // path can sit on the 10s inner relay timeout before the response —
            // and the toast itself auto-dismisses 5s after it appears, so the
            // assertion must catch it inside that window.
            await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(
                1, new() { Timeout = 15_000 });
        }
        finally
        {
            await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    /// <summary>
    /// Mirrors <see cref="RetryClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast"/>
    /// but exercises the Discard path: clicking Discard opens a <em>danger</em>
    /// confirm modal (Dialog.ConfirmAsync with <c>danger: true</c>), whose footer
    /// button is labelled "Delete" (not "Confirm"). Confirming relays a discard to
    /// the owning site and <c>ShowRelayOutcome</c> surfaces exactly one outcome toast.
    /// </summary>
    [SkippableFact]
    public async Task DiscardClickThrough_OnParkedRow_ConfirmsRelayAndShowsOutcomeToast()
    {
        Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/sc-discard-click/{runId}/";
        var parkedId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            // Parked + real site-a so the discard relay resolves fast (NotParked ack for this
            // freshly-seeded GUID), surfacing a toast.
            await SiteCallDataSeeder.InsertSiteCallAsync(
                trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
                sourceSite: "site-a", status: "Parked", retryCount: 3,
                lastError: "HTTP 503 from ERP", httpStatus: 503,
                createdAtUtc: now, updatedAtUtc: now);

            var page = await _fixture.NewAuthenticatedPageAsync();
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            await SetSearchKeywordAsync(page, targetPrefix + "parked");
            await page.Locator("[data-test='site-calls-query']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
            await Assertions.Expect(parkedRow).ToBeVisibleAsync();

            // Discard opens the danger confirm modal.
            await parkedRow.Locator("button:has-text('Discard')").ClickAsync();

            // Danger confirm — labelled "Delete" (Dialog.ConfirmAsync(..., danger: true)).
            var deleteButton = page.Locator(".modal-footer .btn-danger");
            await Assertions.Expect(deleteButton).ToBeVisibleAsync();
            await Assertions.Expect(deleteButton).ToHaveTextAsync("Delete");
            await deleteButton.ClickAsync();

            // One outcome toast (Applied / NotParked / SiteUnreachable — tolerant).
            await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
        }
        finally
        {
            await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    /// <summary>
    /// The Status dropdown filters the grid to the selected lifecycle status.
    /// Seeds two rows sharing a per-run Target prefix — one <c>Parked</c>, one
    /// <c>Delivered</c> — then filters status=Parked: the Parked marker row
    /// surfaces (with a <c>Parked</c> status badge) while the Delivered marker
    /// row is excluded even though both share the prefix. This mirrors
    /// <see cref="FilterNarrowing_ChannelFilterShrinksGrid"/> but exercises the
    /// STATUS axis rather than the channel axis.
    /// <para>
    /// <c>SourceSite</c> is <c>site-a</c> (a permitted site) on both rows —
    /// <c>FilterPermittedAsync</c> drops rows whose source site is not permitted.
    /// </para>
    /// </summary>
    [SkippableFact]
    public async Task StatusFilter_NarrowsToSelectedStatus()
    {
        Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/wave4-sc/{runId}/";
        var parkedId = Guid.NewGuid();
        var deliveredId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            // Two rows sharing the prefix: one Parked, one Delivered. site-a is a
            // permitted source site (FilterPermittedAsync keeps it).
            await SiteCallDataSeeder.InsertSiteCallAsync(
                trackedOperationId: parkedId, channel: "ApiOutbound", target: targetPrefix + "parked",
                sourceSite: "site-a", status: "Parked", retryCount: 3,
                lastError: "HTTP 503 from ERP", httpStatus: 503,
                createdAtUtc: now, updatedAtUtc: now);
            await SiteCallDataSeeder.InsertSiteCallAsync(
                trackedOperationId: deliveredId, channel: "ApiOutbound", target: targetPrefix + "delivered",
                sourceSite: "site-a", status: "Delivered", retryCount: 0,
                createdAtUtc: now, updatedAtUtc: now, httpStatus: 200, terminalAtUtc: now);

            var page = await _fixture.NewAuthenticatedPageAsync();
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // Filter status=Parked and scope to the exact Parked marker — the row
            // surfaces and its status badge reads Parked.
            await page.Locator("#sc-status").SelectOptionAsync("Parked");
            await SetSearchKeywordAsync(page, targetPrefix + "parked");
            await page.ClickAsync("[data-test='site-calls-query']");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var parkedRow = page.Locator("tbody tr", new() { HasText = targetPrefix + "parked" });
            await Assertions.Expect(parkedRow).ToBeVisibleAsync();
            await Assertions.Expect(parkedRow.Locator("span.badge:has-text('Parked')")).ToBeVisibleAsync();

            // Same status=Parked filter, now searching the Delivered marker: the
            // Delivered row is excluded by the status filter, so no row carries
            // its marker. The retrying ToHaveCount waits out the re-render.
            await SetSearchKeywordAsync(page, targetPrefix + "delivered");
            await page.ClickAsync("[data-test='site-calls-query']");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            await Assertions.Expect(
                page.Locator("tbody tr").Filter(new() { HasText = targetPrefix + "delivered" }))
                .ToHaveCountAsync(0);
        }
        finally
        {
            await SiteCallDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    /// <summary>
    /// When the filters match no rows the grid renders the empty-state card
    /// rather than a table. A per-run GUID Target is searched (exact match), so
    /// nothing can match — guaranteed empty without seeding. Asserts both the
    /// absence of data rows and the empty-state literal.
    /// <para>
    /// The empty-state literal lives in <c>div.card &gt; div.card-body…</c>, but
    /// the filter card also uses <c>.card-body</c>, so a bare <c>.card-body</c>
    /// locator is ambiguous under strict mode. We assert the literal via
    /// <see cref="IPage.GetByText(string,PageGetByTextOptions)"/> instead.
    /// </para>
    /// </summary>
    [SkippableFact]
    public async Task EmptyState_NoMatch_ShowsEmptyCard()
    {
        Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync();
        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

        // A per-run GUID target matches nothing (exact match → guaranteed empty).
        await SetSearchKeywordAsync(page, $"playwright-test/wave4-sc-empty/{Guid.NewGuid():N}/none");
        await page.ClickAsync("[data-test='site-calls-query']");
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

        // No data rows, and the empty-state literal renders. GetByText avoids the
        // strict-mode ambiguity of the shared .card-body class.
        await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(0);
        await Assertions.Expect(
            page.GetByText("No cached calls match the current filters."))
            .ToBeVisibleAsync();
    }

    /// <summary>
    /// Keyset pagination traverses forward (Next) and back (Previous) across a
    /// full page boundary. The grid pages at 50 rows ordered
    /// <c>CreatedAtUtc DESC, TrackedOperationId DESC</c>; the Next button is
    /// enabled only when the current page came back exactly full (50 rows), so a
    /// short page (1 row) is the last page.
    /// <para>
    /// <c>Target</c> is an exact match, so seeding 51 rows that all share ONE
    /// identical target string lets a single <c>#sc-search</c> keyword select all
    /// 51 → page 1 = 50 rows, page 2 = 1 row. Staggering <c>createdAtUtc</c> by
    /// the loop index makes the keyset order strict and deterministic. Every row
    /// uses the permitted source site <c>site-a</c> so
    /// <c>FilterPermittedAsync</c> keeps them.
    /// </para>
    /// <para>
    /// Web-first only: each page-transition assertion checks the row COUNT first
    /// (which waits for the keyset fetch to render) BEFORE the pager button
    /// states, because the <c>_loading</c> flag also disables both buttons
    /// mid-fetch — reading button state before the fetch settles would race.
    /// </para>
    /// </summary>
    [SkippableFact]
    public async Task Pagination_KeysetNextAndPrev_TraversesPages()
    {
        Skip.IfNot(await SiteCallDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var prefix = $"playwright-test/wave4-scpage/{runId}/";
        // All 51 rows share ONE identical exact target — a single keyword selects
        // the whole set, which then spans the 50-row page boundary.
        var sharedTarget = prefix + "row";

        try
        {
            // 51 rows, identical target, distinct TrackedOperationId, all site-a /
            // Delivered, timestamps staggered by index so the keyset order
            // (CreatedAtUtc DESC, TrackedOperationId DESC) is strict.
            var now = DateTime.UtcNow;
            for (int i = 0; i < 51; i++)
            {
                var ts = now.AddSeconds(-i);
                await SiteCallDataSeeder.InsertSiteCallAsync(
                    trackedOperationId: Guid.NewGuid(), channel: "ApiOutbound", target: sharedTarget,
                    sourceSite: "site-a", status: "Delivered", retryCount: 0,
                    createdAtUtc: ts, updatedAtUtc: ts);
            }

            var page = await _fixture.NewAuthenticatedPageAsync();
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{SiteCallsUrl}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            await SetSearchKeywordAsync(page, sharedTarget);
            await page.ClickAsync("[data-test='site-calls-query']");

            // The M10 KeysetPager summary span (`Page {N} · {rows} rows`), exposed
            // via its stable data-test hook so a future markup tweak can't make this
            // match ambiguous under strict mode.
            var pageIndicator = page.Locator("[data-test='keyset-summary']");

            // ── Page 1: full page (50 rows). Assert COUNT first (waits for the
            // fetch), then the indicator and the button states. ──
            await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50);
            await Assertions.Expect(pageIndicator).ToContainTextAsync("Page 1");
            await Assertions.Expect(page.Locator("[data-test='keyset-prev']")).ToBeDisabledAsync();
            await Assertions.Expect(page.Locator("[data-test='keyset-next']")).ToBeEnabledAsync();

            // ── Next → Page 2: short page (1 row). Last page, so Next disables. ──
            await page.ClickAsync("[data-test='keyset-next']");
            await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(1);
            await Assertions.Expect(pageIndicator).ToContainTextAsync("Page 2");
            await Assertions.Expect(page.Locator("[data-test='keyset-prev']")).ToBeEnabledAsync();
            await Assertions.Expect(page.Locator("[data-test='keyset-next']")).ToBeDisabledAsync();

            // ── Previous → back on Page 1: full page again, Prev disables. ──
            await page.ClickAsync("[data-test='keyset-prev']");
            await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50);
            await Assertions.Expect(pageIndicator).ToContainTextAsync("Page 1");
            await Assertions.Expect(page.Locator("[data-test='keyset-prev']")).ToBeDisabledAsync();
            // Page 1 is full again on the back-leg, so Next must re-enable —
            // catches a regression that left Next disabled after a page-back.
            await Assertions.Expect(page.Locator("[data-test='keyset-next']")).ToBeEnabledAsync();
        }
        finally
        {
            await SiteCallDataSeeder.DeleteByTargetPrefixAsync(prefix);
        }
    }
}
