using Microsoft.Playwright;
using Xunit;

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

/// <summary>
/// End-to-end coverage for the central Notification Report page's Retry / Discard
/// actions on parked notifications (Notification Outbox #21).
///
/// <para>
/// Each test seeds its own <c>Parked</c> row directly into the running cluster's
/// configuration database via <see cref="NotificationDataSeeder"/>, exercises the UI
/// through Playwright, then best-effort deletes the row by its unique <c>ListName</c>
/// marker. The Notification Report page reads the <c>Notifications</c> table through the
/// <c>NotificationOutboxActor</c> singleton — its query path is a pure read-from-table
/// projection (no default time window), so a directly-INSERTed row surfaces exactly as a
/// site-ingested row would. Crucially, the actor's manual Retry / Discard handlers act
/// purely on the central row (load by id → flip <c>Status</c> → persist) with NO site
/// relay, so a directly-seeded Parked row is genuinely retryable / discardable from
/// central and the action's success toast (<c>ToastNotification.ShowSuccess</c>) appears.
/// This is therefore a real mutating action test, not merely a render guard.
/// </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>SiteCallsPageTests</c> idiom.
/// </para>
/// </summary>
[Collection("Playwright")]
public class NotificationActionTests
{
    private const string NotificationReportUrl = "/notifications/report";

    private readonly PlaywrightFixture _fixture;

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

    /// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
    private const string DbUnavailableSkipReason =
        "NotificationDataSeeder 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.";

    /// <summary>
    /// Commits a <c>@bind</c> (commit-on-<c>change</c>) form control to the server as its
    /// own discrete circuit message before the caller clicks Query. Same rationale as
    /// <c>SiteCallsPageTests.SetSearchKeywordAsync</c>: <see cref="ILocator.SelectOptionAsync"/>
    /// already raises a <c>change</c>, and <see cref="ILocator.FillAsync"/> only fires
    /// <c>input</c> — so the subject search box needs an explicit <c>change</c> dispatch so
    /// its bound value is committed on the circuit before the Query click, not raced against
    /// the click's blur side effect.
    /// </summary>
    private static async Task SetSubjectKeywordAsync(IPage page, string keyword)
    {
        var search = page.Locator("#no-search");
        await search.FillAsync(keyword);
        await search.DispatchEventAsync("change");
    }

    /// <summary>
    /// Seeds a Parked notification, navigates to the report, narrows to it (status=Parked +
    /// subject keyword), and returns the row locator once it is visible.
    /// </summary>
    private async Task<(IPage Page, ILocator Row)> SeedAndLocateParkedRowAsync(
        Guid notificationId, string listNameMarker, string subject)
    {
        await NotificationDataSeeder.InsertParkedNotificationAsync(
            notificationId: notificationId,
            listNameMarker: listNameMarker,
            subject: subject,
            sourceSite: "site-a",
            retryCount: 3);

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

        await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();

        // Narrow to the seeded row: Parked status (so only Retry/Discard-bearing rows
        // render) plus the unique subject keyword. The status select is a @bind
        // commit-on-change, so SelectOptionAsync's own change event commits it; the
        // subject search box needs the explicit change dispatch.
        await page.Locator("#no-status").SelectOptionAsync("Parked");
        await SetSubjectKeywordAsync(page, subject);
        await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

        var row = page.Locator("tbody tr", new() { HasText = subject });
        await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });
        return (page, row);
    }

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

        var runId = Guid.NewGuid().ToString("N");
        var marker = $"zztest-notif-retry-{runId}";
        var notificationId = Guid.NewGuid();
        var subject = $"zztest retry {runId}";

        try
        {
            var (page, row) = await SeedAndLocateParkedRowAsync(notificationId, marker, subject);

            // The Retry button is only rendered for Parked rows (btn-outline-success).
            await Assertions.Expect(row.Locator("button.btn.btn-outline-success.btn-sm")).ToBeVisibleAsync();
            await row.Locator("button.btn.btn-outline-success.btn-sm").ClickAsync();

            // Confirm the action — non-danger footer button labelled "Confirm".
            var confirmButton = page.Locator(".modal-footer .btn-primary");
            await Assertions.Expect(confirmButton).ToBeVisibleAsync();
            await Assertions.Expect(confirmButton).ToHaveTextAsync("Confirm");
            await confirmButton.ClickAsync();

            // The retry resolves purely against the central row (no site relay), so a
            // single success toast appears. We assert exactly one toast (the single-toast
            // contract), tolerant of the exact outcome text. The wait is generous (15s)
            // and the toast auto-dismisses 5s after it appears, so we use a single
            // web-first retrying assertion to avoid a TOCTOU race with the auto-dismiss.
            await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
        }
        finally
        {
            await NotificationDataSeeder.DeleteByMarkerAsync(marker);
        }
    }

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

        var runId = Guid.NewGuid().ToString("N");
        var marker = $"zztest-notif-discard-{runId}";
        var notificationId = Guid.NewGuid();
        var subject = $"zztest discard {runId}";

        try
        {
            var (page, row) = await SeedAndLocateParkedRowAsync(notificationId, marker, subject);

            // The Discard button is only rendered for Parked rows (btn-outline-danger).
            await Assertions.Expect(row.Locator("button.btn.btn-outline-danger.btn-sm")).ToBeVisibleAsync();
            await row.Locator("button.btn.btn-outline-danger.btn-sm").ClickAsync();

            // Confirm the action — danger footer button labelled "Delete" (the discard
            // dialog opens with danger: true).
            var deleteButton = page.Locator(".modal-footer .btn-danger");
            await Assertions.Expect(deleteButton).ToBeVisibleAsync();
            await Assertions.Expect(deleteButton).ToHaveTextAsync("Delete");
            await deleteButton.ClickAsync();

            // The discard moves the central row to Discarded and surfaces a single success
            // toast. Same single-toast / outcome-tolerant assertion as Retry: a single
            // web-first retrying assertion to avoid a TOCTOU race with the auto-dismiss.
            await Assertions.Expect(page.Locator(".toast")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
        }
        finally
        {
            await NotificationDataSeeder.DeleteByMarkerAsync(marker);
        }
    }

    /// <summary>
    /// Combined-filter narrowing: two Parked rows share one <c>ListName</c> marker but carry
    /// distinct subjects. Applying the exact-match list filter (the marker) plus
    /// status=Parked plus the subject keyword for only ONE of the two rows must surface that
    /// row and exclude its sibling. This proves the filters compose (AND-narrowing) rather
    /// than widening — the marker isolates the pair from ambient cluster rows, and the
    /// subject keyword discriminates between them.
    /// </summary>
    [SkippableFact]
    public async Task FilterCombination_StatusPlusList_NarrowsToMatch()
    {
        Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var marker = $"zztest-notif-wave4-{runId}";

        try
        {
            // Two Parked rows under the SAME ListName marker, distinct subjects.
            // Seeded inside the try so a mid-seed failure is still cleaned up by the finally.
            await NotificationDataSeeder.InsertParkedNotificationAsync(
                Guid.NewGuid(), marker, "wave4-alpha", "site-a");
            await NotificationDataSeeder.InsertParkedNotificationAsync(
                Guid.NewGuid(), marker, "wave4-beta", "site-a");

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

            await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();

            // #no-list is an exact-match text input bound @bind (commit-on-change), so
            // FillAsync (which only fires `input`) must be followed by an explicit
            // `change` dispatch to commit the bound value before the Query roundtrip —
            // same rationale as SetSubjectKeywordAsync for the subject box.
            await page.Locator("#no-list").FillAsync(marker);
            await page.Locator("#no-list").DispatchEventAsync("change");

            // Status select commits on its own SelectOptionAsync change event.
            await page.Locator("#no-status").SelectOptionAsync("Parked");

            // Subject keyword for ONLY the alpha row.
            await SetSubjectKeywordAsync(page, "wave4-alpha");

            await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The combined filters narrow to alpha: alpha visible, beta absent.
            var alphaRow = page.Locator("tbody tr", new() { HasText = "wave4-alpha" });
            await Assertions.Expect(alphaRow).ToBeVisibleAsync(new() { Timeout = 15_000 });
            await Assertions.Expect(
                page.Locator("tbody tr").Filter(new() { HasText = "wave4-beta" }))
                .ToHaveCountAsync(0);
        }
        finally
        {
            await NotificationDataSeeder.DeleteByMarkerAsync(marker);
        }
    }

    /// <summary>
    /// The row detail modal opens on a DOUBLE-CLICK of a non-Actions row cell (there is no
    /// dedicated open button) and closes via the header X and the footer Close button. Both
    /// close affordances are wired to the same <c>CloseDetail</c> handler; this exercises
    /// each path independently (open → X-close → reopen → footer-close).
    /// </summary>
    [SkippableFact]
    public async Task DetailModal_OpenOnDblClick_CloseViaXAndFooter()
    {
        Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var marker = $"zztest-notif-wave4-{runId}";

        try
        {
            // Seeded inside the try so a mid-seed failure is still cleaned up by the finally.
            await NotificationDataSeeder.InsertParkedNotificationAsync(
                Guid.NewGuid(), marker, "wave4-detail", "site-a");

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

            await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();

            // Narrow to the seeded row by its exact ListName marker (+ Parked status).
            await page.Locator("#no-list").FillAsync(marker);
            await page.Locator("#no-list").DispatchEventAsync("change");
            await page.Locator("#no-status").SelectOptionAsync("Parked");
            await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var row = page.Locator("tbody tr").Filter(new() { HasText = "wave4-detail" });
            await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });

            // Open the modal: double-click the SUBJECT cell (a non-Actions cell). The
            // Actions cell carries @ondblclick:stopPropagation, so double-clicking it would
            // not open the modal — the subject cell is the correct target.
            await row.Locator("td", new() { HasText = "wave4-detail" }).DblClickAsync();
            await Assertions.Expect(page.Locator(".modal.show.d-block")).ToBeVisibleAsync();
            // Scope the title to the open modal — the page also renders Retry/Discard
            // confirm dialogs that carry their own .modal-title.
            await Assertions.Expect(page.Locator(".modal.show.d-block .modal-title")).ToContainTextAsync("Notification Detail");

            // Close via the header X.
            await page.ClickAsync("button.btn-close[aria-label='Close']");
            await Assertions.Expect(page.Locator(".modal.show.d-block")).ToHaveCountAsync(0);

            // Re-open (double-click the subject cell again).
            await row.Locator("td", new() { HasText = "wave4-detail" }).DblClickAsync();
            await Assertions.Expect(page.Locator(".modal.show.d-block")).ToBeVisibleAsync();

            // Close via the footer Close button.
            await page.ClickAsync(".modal-footer button.btn-outline-secondary:has-text('Close')");
            await Assertions.Expect(page.Locator(".modal.show.d-block")).ToHaveCountAsync(0);

            // Escape-to-close is NOT wired on the Notification detail modal (no keydown handler); covering the X and footer-Close paths that ARE wired (backdrop also closes but is omitted for brevity).
        }
        finally
        {
            await NotificationDataSeeder.DeleteByMarkerAsync(marker);
        }
    }

    /// <summary>
    /// The "Stuck only" filter narrows the result set to genuinely stuck rows. A
    /// notification IsStuck iff its <c>Status ∈ {Pending, Retrying}</c> AND its
    /// <c>CreatedAt &lt; now − StuckAgeThreshold</c> (default 10 min). Two rows share one
    /// <c>ListName</c> marker: a genuinely-stuck row (<c>Retrying</c>, back-dated 15 min for
    /// margin) and a non-stuck <c>Parked@now</c> row. Querying by the marker alone surfaces
    /// BOTH; toggling Stuck-only ON must drop the fresh row and keep only the stuck one.
    /// This uses the POSITIVE form (stuck row present, fresh row gone): the default
    /// <c>StuckAgeThreshold</c> is 10 min — verified in NotificationOutboxOptions and the
    /// docker cluster carries no override — so a Retrying row back-dated 15 min passes both
    /// the repository's StuckOnly SQL predicate and the page's <c>IsStuck</c> derivation.
    /// </summary>
    [SkippableFact]
    public async Task StuckOnlyFilter_NarrowsToStuckRows()
    {
        Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var marker = $"zztest-notif-wave4-{runId}";

        try
        {
            // Genuinely-stuck row: Retrying + back-dated 15 min (> the 10-min StuckAgeThreshold).
            // Seeded inside the try so a mid-seed failure is still cleaned up by the finally.
            await NotificationDataSeeder.InsertNotificationAsync(
                Guid.NewGuid(), marker, "wave4-stuck",
                status: "Retrying", createdAt: DateTimeOffset.UtcNow.AddMinutes(-15),
                sourceSite: "site-a");
            // Non-stuck row: Parked @ now (Parked is a terminal status → never stuck, regardless of age).
            await NotificationDataSeeder.InsertParkedNotificationAsync(
                Guid.NewGuid(), marker, "wave4-fresh", "site-a");

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

            await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();

            // Narrow to the pair by the exact ListName marker (commit the @bind value with a
            // change dispatch — FillAsync only fires `input`), then Query.
            await page.Locator("#no-list").FillAsync(marker);
            await page.Locator("#no-list").DispatchEventAsync("change");
            await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The marker alone selects BOTH rows (proves the stuck filter is what narrows,
            // not the marker).
            await Assertions.Expect(
                page.Locator("tbody tr").Filter(new() { HasText = "wave4-stuck" }))
                .ToHaveCountAsync(1, new() { Timeout = 15_000 });
            await Assertions.Expect(
                page.Locator("tbody tr").Filter(new() { HasText = "wave4-fresh" }))
                .ToHaveCountAsync(1);

            // Toggle Stuck-only ON and re-Query.
            await page.Locator("#no-stuck-only").CheckAsync();
            await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // Only the stuck row survives: fresh gone, stuck present.
            await Assertions.Expect(
                page.Locator("tbody tr").Filter(new() { HasText = "wave4-fresh" }))
                .ToHaveCountAsync(0, new() { Timeout = 15_000 });
            var stuckRow = page.Locator("tbody tr").Filter(new() { HasText = "wave4-stuck" });
            await Assertions.Expect(stuckRow).ToHaveCountAsync(1);

            // And the surviving row carries the stuck badge (positive corroboration that the
            // page classifies it as stuck, not merely that the SQL filter kept it).
            await Assertions.Expect(
                stuckRow.Locator("span.badge.bg-warning.text-dark:has-text('Stuck')"))
                .ToBeVisibleAsync();
        }
        finally
        {
            await NotificationDataSeeder.DeleteByMarkerAsync(marker);
        }
    }

    /// <summary>
    /// Page-number pagination: the pager renders only when <c>_totalCount &gt; 50</c>
    /// (page size 50). Seeding 51 rows under one <c>ListName</c> marker yields exactly two
    /// pages (50 + 1). The test drives the page-NUMBER pager forward (Next) and back
    /// (Previous), asserting the row count, the page indicator, and the Previous/Next
    /// enabled/disabled states at each step. Per the harness contract the row COUNT is
    /// asserted FIRST at each step — it waits out the fetch, during which <c>_loading</c>
    /// disables both pager buttons — so the button-state assertions never race the in-flight
    /// query.
    /// </summary>
    [SkippableFact]
    public async Task Pagination_PageNumberNextAndPrev_TraversesPages()
    {
        Skip.IfNot(await NotificationDataSeeder.IsAvailableAsync(), DbUnavailableSkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var marker = $"zztest-notif-wave4-{runId}";

        try
        {
            // 51 Parked rows under one marker → 2 pages (50 + 1); the pager renders (> 50).
            // Seeded inside the try so a mid-seed failure is still cleaned up by the finally.
            for (int i = 0; i < 51; i++)
            {
                await NotificationDataSeeder.InsertParkedNotificationAsync(
                    Guid.NewGuid(), marker, $"wave4-page-{i:D2}", "site-a");
            }

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

            await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();

            await page.Locator("#no-list").FillAsync(marker);
            await page.Locator("#no-list").DispatchEventAsync("change");
            await page.Locator("button.btn-primary:has-text('Query')").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // Pager locators (M10 OffsetPager). Previous/Next are the pager's data-test
            // hooks (the page header's Refresh button is also a .btn-outline-secondary.btn-sm,
            // so we no longer scope by text). The summary span carries the page/total readout
            // ("Page {N} of {Y} · {Total} total").
            var prev = page.Locator("[data-test='pager-prev']");
            var next = page.Locator("[data-test='pager-next']");
            var indicator = page.Locator("[data-test='pager-summary']");

            // ── Page 1 ── (count first — it waits out the fetch — then indicator + buttons).
            await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50, new() { Timeout = 15_000 });
            await Assertions.Expect(indicator).ToContainTextAsync("Page 1");
            await Assertions.Expect(prev).ToBeDisabledAsync();
            await Assertions.Expect(next).ToBeEnabledAsync();

            // ── Forward to page 2 ──
            await next.ClickAsync();
            await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(1, new() { Timeout = 15_000 });
            await Assertions.Expect(indicator).ToContainTextAsync("Page 2");
            await Assertions.Expect(prev).ToBeEnabledAsync();
            await Assertions.Expect(next).ToBeDisabledAsync();

            // ── Back to page 1 ──
            await prev.ClickAsync();
            await Assertions.Expect(page.Locator("tbody tr")).ToHaveCountAsync(50, new() { Timeout = 15_000 });
            await Assertions.Expect(indicator).ToContainTextAsync("Page 1");
            await Assertions.Expect(prev).ToBeDisabledAsync();
            await Assertions.Expect(next).ToBeEnabledAsync();
        }
        finally
        {
            await NotificationDataSeeder.DeleteByMarkerAsync(marker);
        }
    }
}
