using Microsoft.Playwright;
using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;

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

/// <summary>
/// End-to-end coverage for the central Audit Log surface (#23 M7-T16 / Bundle H).
///
/// <para>
/// Each test seeds its own <c>AuditLog</c> rows directly into the running cluster's
/// configuration database via <see cref="AuditDataSeeder"/>, exercises the UI
/// through Playwright against <c>http://scadabridge-traefik</c>, then best-effort
/// deletes the rows by their <c>Target</c> prefix. The seed/cleanup pattern keeps
/// each test self-contained without touching <c>infra/mssql/seed-config.sql</c>.
/// </para>
///
/// <para>
/// Scenarios covered (per the M7-T16 brief):
/// <list type="bullet">
///   <item><c>FilterNarrowing</c> — the channel filter narrows the results grid.</item>
///   <item><c>DrilldownDrawer_JsonPrettyPrint</c> — JSON request bodies pretty-print.</item>
///   <item><c>CopyAsCurlButton_VisibleOnApiInbound</c> — cURL action visible for API rows.</item>
///   <item><c>DrillInFromCorrelationId_AutoLoadsAuditLog</c> — query-string drill-in
///   auto-loads the grid (the exact path the Notifications "View audit history"
///   link relies on; verified by reproducing the link target directly because
///   seeding a notification visible to the report page requires the Akka query
///   path, not just an INSERT).</item>
///   <item><c>DrillInFromExecutionId_LandsOnAuditLogWithFilterContext</c> — the
///   <c>?executionId=</c> drill-in (the drawer's "View this execution" action)
///   auto-loads the grid filtered by ExecutionId.</item>
///   <item><c>DrillInFromParentExecution_FiltersGridToSpawnerExecution</c> — the
///   drawer's "View parent execution" action on a spawned (child) row drills in
///   to <c>?executionId={ParentExecutionId}</c>, auto-loading the spawner's
///   rows.</item>
///   <item><c>DoubleClickTreeNode_OpensExecutionRowModal</c> — double-clicking a
///   node on the execution-tree page opens <c>ExecutionDetailModal</c>, walking
///   list → row → detail before closing.</item>
///   <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
///   the report page wires drill-in links when notifications are present.</item>
///   <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
///   the AuditExport policy, click initiates a download.</item>
///   <item><c>PermissionGating_DesignerWithoutOperationalAudit_SeesNotAuthorized</c>
///   — the page-level <c>[Authorize(Policy = OperationalAudit)]</c> gate blocks a
///   Design-only user.</item>
/// </list>
/// </para>
/// </summary>
[Collection("Playwright")]
public class AuditLogPageTests
{
    private readonly PlaywrightFixture _fixture;

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

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

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

        try
        {
            // One ApiOutbound row, one DbOutbound row — distinct Targets so the
            // grid renders predictable rows we can assert on by data-test id.
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: apiEventId,
                occurredAtUtc: now,
                channel: "ApiOutbound",
                kind: "ApiCall",
                status: "Delivered",
                target: targetPrefix + "api",
                httpStatus: 200,
                durationMs: 42);

            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: dbEventId,
                occurredAtUtc: now,
                channel: "DbOutbound",
                kind: "DbWrite",
                status: "Delivered",
                target: targetPrefix + "db",
                durationMs: 17);

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

            // Pre-Apply, both rows are absent because the grid stays empty until
            // the user filters. Pick the ApiOutbound channel, then Apply.
            await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound");
            await page.Locator("[data-test='filter-apply']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The seeded ApiOutbound row is visible; the DbOutbound row is not
            // (it was filtered out by the channel filter).
            var apiRow = page.Locator($"[data-test='grid-row-{apiEventId}']");
            var dbRow = page.Locator($"[data-test='grid-row-{dbEventId}']");
            await Assertions.Expect(apiRow).ToBeVisibleAsync();
            Assert.Equal(0, await dbRow.CountAsync());
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task FilterCombination_ChannelPlusTarget_NarrowsToMatch()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        // Two rows differing in channel AND target. Applying the channel filter
        // (ApiOutbound) AND a contains-match target filter together should narrow
        // the grid to the single matching row — proving the filters AND rather
        // than OR. The match row is ApiOutbound + target ".../match"; the other
        // row is DbOutbound + target ".../other" and is excluded on both axes.
        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/wave4-audit/{runId}/";
        var matchId = Guid.NewGuid();
        var otherId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            // Row 1 — the match: ApiOutbound channel, target ends in "match".
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: matchId,
                occurredAtUtc: now,
                channel: "ApiOutbound",
                kind: "ApiCall",
                status: "Delivered",
                target: targetPrefix + "match",
                httpStatus: 200,
                durationMs: 42);

            // Row 2 — the non-match channel: DbOutbound, target ends in "other".
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: otherId,
                occurredAtUtc: now,
                channel: "DbOutbound",
                kind: "DbWrite",
                status: "Delivered",
                target: targetPrefix + "other",
                durationMs: 17);

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

            // Apply channel AND target together.
            await page.Locator("[data-test='filter-channel-select']").SelectOptionAsync("ApiOutbound");
            await page.FillAsync("#audit-target", targetPrefix + "match");
            await page.ClickAsync("[data-test='filter-apply']");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The match row is visible; the other row is absent — the two filters
            // AND, so a row must satisfy both to survive.
            await Assertions.Expect(page.Locator($"[data-test='grid-row-{matchId}']")).ToBeVisibleAsync();
            await Assertions.Expect(page.Locator($"[data-test='grid-row-{otherId}']")).ToHaveCountAsync(0);
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task EmptyResults_AfterApply_ShowsEmptyState()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        // A fresh random executionId GUID matches nothing — the ExecutionId
        // filter is an exact match, so a never-seeded GUID is guaranteed-empty by
        // construction. After Apply the grid renders zero rows and the empty-state
        // message. No seeding, no teardown.
        var page = await _fixture.NewAuthenticatedPageAsync();
        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

        await page.FillAsync("#audit-execution-id", Guid.NewGuid().ToString());
        await page.ClickAsync("[data-test='filter-apply']");
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

        // Zero grid rows AND the empty-state literal inside the grid container.
        await Assertions.Expect(page.Locator("[data-test^='grid-row-']")).ToHaveCountAsync(0);
        await Assertions.Expect(page.Locator("[data-test='audit-results-grid']"))
            .ToContainTextAsync("No audit events match the current filter.");
    }

    [SkippableFact]
    public async Task DrilldownDrawer_JsonPrettyPrintsRequestBody()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/drilldown-json/{runId}/";
        var eventId = Guid.NewGuid();
        var now = DateTime.UtcNow;
        var requestJson = "{\"method\":\"POST\",\"headers\":{\"X-Api-Key\":\"abc123\"},\"body\":{\"unit\":\"pump-7\",\"value\":42.5}}";

        try
        {
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: eventId,
                occurredAtUtc: now,
                channel: "ApiOutbound",
                kind: "ApiCall",
                status: "Delivered",
                target: targetPrefix + "endpoint",
                httpStatus: 200,
                durationMs: 31,
                requestSummary: requestJson);

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

            // Apply with no chips picked — default time-range LastHour matches the
            // freshly-seeded row.
            await page.Locator("[data-test='filter-apply']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var row = page.Locator($"[data-test='grid-row-{eventId}']");
            await Assertions.Expect(row).ToBeVisibleAsync();
            await row.ClickAsync();

            var drawer = page.Locator("[data-test='audit-drilldown-drawer']");
            await Assertions.Expect(drawer).ToBeVisibleAsync();

            // The Request body section is rendered as a <pre> with the indented
            // JSON. Pretty-printed JSON inserts newlines + spaces — the verbatim
            // single-line string we seeded has neither. Asserting on "headers"
            // being followed by a colon+newline confirms System.Text.Json's
            // WriteIndented produced the body, not the raw RequestSummary.
            var requestBody = page.Locator("[data-test='request-body'] pre");
            await Assertions.Expect(requestBody).ToBeVisibleAsync();
            var bodyText = await requestBody.TextContentAsync();
            Assert.NotNull(bodyText);
            // Pretty-printed JSON contains a newline after the opening '{'.
            Assert.Contains("\n", bodyText);
            // The first-level keys are indented (System.Text.Json uses 2 spaces).
            Assert.Contains("  \"method\"", bodyText);
            // And the inner object's body is also pretty-printed (nested indent).
            Assert.Contains("    \"unit\"", bodyText);
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task CopyAsCurlButton_IsVisibleAndClickableForApiInbound()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/curl-button/{runId}/";
        var eventId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: eventId,
                occurredAtUtc: now,
                channel: "ApiInbound",
                kind: "InboundRequest",
                status: "Delivered",
                target: targetPrefix + "method",
                actor: "playwright-test-key",
                httpStatus: 200,
                durationMs: 12,
                requestSummary: "{\"method\":\"POST\",\"headers\":{\"X-API-Key\":\"redacted\"},\"body\":{\"ping\":true}}");

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

            await page.Locator("[data-test='filter-apply']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var row = page.Locator($"[data-test='grid-row-{eventId}']");
            await Assertions.Expect(row).ToBeVisibleAsync();
            await row.ClickAsync();

            // The Copy-as-cURL button is only rendered for API channels — the
            // drawer's IsApiChannel guard. We assert visibility + clickability
            // only; clipboard content varies between headless and headed runs.
            var curlButton = page.Locator("[data-test='copy-as-curl']");
            await Assertions.Expect(curlButton).ToBeVisibleAsync();
            await Assertions.Expect(curlButton).ToBeEnabledAsync();
            // Clicking should not throw or crash the page — clipboard interop
            // failures are swallowed by CopyCurl(). The page stays alive.
            await curlButton.ClickAsync();
            await Assertions.Expect(page.Locator("[data-test='audit-drilldown-drawer']")).ToBeVisibleAsync();
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task DrillInFromCorrelationId_LandsOnAuditLogWithFilterContext()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

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

        try
        {
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: eventId,
                occurredAtUtc: now,
                channel: "Notification",
                kind: "NotifySend",
                status: "Delivered",
                target: targetPrefix + "ops-list",
                correlationId: correlationId,
                durationMs: 8);

            var page = await _fixture.NewAuthenticatedPageAsync();

            // This is the exact URL the Notifications "View audit history" link
            // produces: /audit/log?correlationId={NotificationId}. We assert the
            // drill-in lands on the Audit Log page, the query-string survives,
            // and the audit results grid surface is rendered (the filter bar +
            // grid are present, so an operator can refine + Apply from here).
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?correlationId={correlationId}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            Assert.Contains($"correlationId={correlationId}", page.Url);
            await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
            await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync();
            await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync();

            // Bundle D auto-load: the query-string drill-in populates the grid
            // WITHOUT an Apply click. The grid resolves the ?correlationId= filter
            // on OnInitialized and the seeded row appears.
            //
            // This was previously blocked by an EF "A second operation was started
            // on this context instance" error — the page-level auto-load raced
            // AuditFilterBar.GetAllSitesAsync() on the shared scoped Blazor
            // DbContext. AuditLogQueryService now opens its own DI scope per query
            // (scope-per-query), so the auto-load no longer contends with the
            // filter bar's site enumeration and the assertion is restored.
            var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
            await Assertions.Expect(seededRow).ToBeVisibleAsync();
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task DrillInFromExecutionId_LandsOnAuditLogWithFilterContext()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        // Mirrors the correlationId drill-in: the "View this execution" drawer
        // action navigates to /audit/log?executionId={ExecutionId}. We seed a row
        // carrying that ExecutionId, hit the deep link directly, and assert the
        // page deserializes the param and auto-loads the seeded row.
        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/exec-drill-in/{runId}/";
        var executionId = Guid.NewGuid();
        var eventId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: eventId,
                occurredAtUtc: now,
                channel: "ApiOutbound",
                kind: "ApiCall",
                status: "Delivered",
                target: targetPrefix + "endpoint",
                executionId: executionId,
                httpStatus: 200,
                durationMs: 11);

            var page = await _fixture.NewAuthenticatedPageAsync();

            // The exact URL the drawer's "View this execution" button produces.
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={executionId}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            Assert.Contains($"executionId={executionId}", page.Url);
            await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
            await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync();
            await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync();

            // Auto-load: the query-string drill-in resolves the ?executionId=
            // filter on OnInitialized and the seeded row appears without an
            // Apply click.
            var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
            await Assertions.Expect(seededRow).ToBeVisibleAsync();

            // The ExecutionId column renders the row's short-form value.
            var execCell = page.Locator($"[data-test='execution-id-{eventId}']");
            await Assertions.Expect(execCell).ToBeVisibleAsync();
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task DrillInFromParentExecution_FiltersGridToSpawnerExecution()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        // The drawer's "View parent execution" action navigates a routed (child)
        // row to /audit/log?executionId={ParentExecutionId}. We seed a spawner row
        // (its ExecutionId == the parent id) and a child row (ParentExecutionId
        // pointing at the spawner), open the child's drawer, click the action, and
        // assert the grid auto-loads filtered to the spawner's own rows.
        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/parent-exec-drill-in/{runId}/";
        var parentExecutionId = Guid.NewGuid();
        var spawnerEventId = Guid.NewGuid();
        var childEventId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            // The spawner execution's own row — carries ExecutionId == parentExecutionId.
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: spawnerEventId,
                occurredAtUtc: now,
                channel: "ApiInbound",
                kind: "InboundRequest",
                status: "Delivered",
                target: targetPrefix + "spawner",
                executionId: parentExecutionId,
                httpStatus: 200,
                durationMs: 7);

            // The child (spawned) row — ParentExecutionId points at the spawner.
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: childEventId,
                occurredAtUtc: now,
                channel: "ApiOutbound",
                kind: "ApiCall",
                status: "Delivered",
                target: targetPrefix + "child",
                executionId: Guid.NewGuid(),
                parentExecutionId: parentExecutionId,
                httpStatus: 200,
                durationMs: 13);

            var page = await _fixture.NewAuthenticatedPageAsync();

            // Land on the child row via its ParentExecutionId filter, open the drawer.
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?parentExecutionId={parentExecutionId}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var childRow = page.Locator($"[data-test='grid-row-{childEventId}']");
            await Assertions.Expect(childRow).ToBeVisibleAsync();
            await childRow.ClickAsync();

            // The "View parent execution" action drills in to the spawner.
            var viewParent = page.Locator("[data-test='view-parent-execution']");
            await Assertions.Expect(viewParent).ToBeVisibleAsync();
            await viewParent.ClickAsync();
            // The drawer's NavigateTo is a same-page (query-string-only) Blazor
            // navigation: it pushes history.pushState over the SignalR circuit
            // rather than triggering a document load, so WaitForLoadState would
            // return before the URL settles. WaitForURLAsync is the correct wait
            // primitive for SPA/pushState navigations.
            await page.WaitForURLAsync($"**/audit/log?executionId={parentExecutionId}");

            // The drill-in lands on ?executionId={parentExecutionId} and auto-loads
            // the spawner's own row.
            Assert.Contains($"executionId={parentExecutionId}", page.Url);
            var spawnerRow = page.Locator($"[data-test='grid-row-{spawnerEventId}']");
            await Assertions.Expect(spawnerRow).ToBeVisibleAsync();
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task DrillInToExecutionChain_RendersTree_AndNodeClickFiltersGrid()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        // Audit Log ParentExecutionId feature, Task 10: the drawer's "View
        // execution chain" action opens /audit/execution-tree?executionId={id}.
        // We seed a spawner row + a child row, open the child's drawer, click
        // "View execution chain", assert the tree renders BOTH executions, then
        // click the spawner node and assert the Audit Log grid filters to it.
        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/exec-chain-tree/{runId}/";
        var parentExecutionId = Guid.NewGuid();
        var childExecutionId = Guid.NewGuid();
        var spawnerEventId = Guid.NewGuid();
        var childEventId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            // Spawner execution's own row.
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: spawnerEventId,
                occurredAtUtc: now,
                channel: "ApiInbound",
                kind: "InboundRequest",
                status: "Delivered",
                target: targetPrefix + "spawner",
                executionId: parentExecutionId,
                httpStatus: 200,
                durationMs: 7);

            // Child (spawned) row — links to the spawner via ParentExecutionId.
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: childEventId,
                occurredAtUtc: now,
                channel: "ApiOutbound",
                kind: "ApiCall",
                status: "Delivered",
                target: targetPrefix + "child",
                executionId: childExecutionId,
                parentExecutionId: parentExecutionId,
                httpStatus: 200,
                durationMs: 13);

            var page = await _fixture.NewAuthenticatedPageAsync();

            // Open the child row's drawer via its ExecutionId filter.
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={childExecutionId}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var childRow = page.Locator($"[data-test='grid-row-{childEventId}']");
            await Assertions.Expect(childRow).ToBeVisibleAsync();
            await childRow.ClickAsync();

            // "View execution chain" opens the tree view.
            var viewChain = page.Locator("[data-test='view-execution-chain']");
            await Assertions.Expect(viewChain).ToBeVisibleAsync();
            await viewChain.ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The tree page rendered both executions as nodes.
            Assert.Contains($"executionId={childExecutionId}", page.Url);
            await Assertions.Expect(page.Locator($"[data-test='tree-node-{parentExecutionId}']")).ToBeVisibleAsync();
            await Assertions.Expect(page.Locator($"[data-test='tree-node-{childExecutionId}']")).ToBeVisibleAsync();

            // Clicking the spawner node's link filters the Audit Log to its rows.
            await page.Locator($"[data-test='tree-node-link-{parentExecutionId}']").ClickAsync();
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            Assert.Contains($"executionId={parentExecutionId}", page.Url);
            await Assertions.Expect(page.Locator($"[data-test='grid-row-{spawnerEventId}']")).ToBeVisibleAsync();
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task DoubleClickTreeNode_OpensExecutionRowModal()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        // Execution-Tree Node Detail Modal feature, Task 5: double-clicking a
        // node on the /audit/execution-tree page opens ExecutionDetailModal —
        // a modal listing that execution's audit rows, with click-through to
        // each row's full <AuditEventDetail> view. We seed ONE execution with
        // TWO audit rows (so the modal opens to the list view, not straight to
        // a single-row detail), open the tree, double-click the node, walk
        // list → row → detail, then close the modal.
        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/exec-node-modal/{runId}/";
        var executionId = Guid.NewGuid();
        var inboundEventId = Guid.NewGuid();
        var outboundEventId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            // Two rows sharing the same ExecutionId — an inbound request and an
            // outbound call it made. The shared ExecutionId makes the tree node
            // multi-row, so the modal lands on the list view.
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: inboundEventId,
                occurredAtUtc: now,
                channel: "ApiInbound",
                kind: "InboundRequest",
                status: "Delivered",
                target: targetPrefix + "inbound",
                executionId: executionId,
                httpStatus: 200,
                durationMs: 9);

            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: outboundEventId,
                occurredAtUtc: now,
                channel: "ApiOutbound",
                kind: "ApiCall",
                status: "Delivered",
                target: targetPrefix + "outbound",
                executionId: executionId,
                httpStatus: 200,
                durationMs: 21);

            var page = await _fixture.NewAuthenticatedPageAsync();

            // Open the execution tree directly for the seeded execution.
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/execution-tree?executionId={executionId}");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The seeded execution renders as a tree node.
            var nodeBody = page.Locator($"[data-test='tree-node-{executionId}'] .execution-tree-body");
            await Assertions.Expect(nodeBody).ToBeVisibleAsync();

            // Double-clicking the node body raises ExecutionTree's @ondblclick,
            // which is a Blazor Server (InteractiveServer) handler — it only
            // fires once the SignalR circuit is live. NetworkIdle can settle
            // before the circuit connects, so a single early DblClick can be
            // dropped. Retry the double-click until the modal appears.
            var modal = page.Locator("[data-test='execution-detail-modal']");
            for (var attempt = 0; attempt < 10 && await modal.CountAsync() == 0; attempt++)
            {
                await nodeBody.DblClickAsync();
                try
                {
                    await modal.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 1000 });
                }
                catch (TimeoutException)
                {
                    // Circuit not connected yet — loop and re-issue the dblclick.
                }
            }

            await Assertions.Expect(modal).ToBeVisibleAsync();

            // The modal opens on the list view — one button per audit row.
            var inboundRow = page.Locator($"[data-test='execution-detail-row-{inboundEventId}']");
            var outboundRow = page.Locator($"[data-test='execution-detail-row-{outboundEventId}']");
            await Assertions.Expect(inboundRow).ToBeVisibleAsync();
            await Assertions.Expect(outboundRow).ToBeVisibleAsync();

            // Clicking a row switches the modal to that row's full detail —
            // the shared <AuditEventDetail> field block renders.
            await outboundRow.ClickAsync();
            await Assertions.Expect(page.Locator("[data-test='drawer-fields']")).ToBeVisibleAsync();

            // Closing the modal tears it down. The close click round-trips
            // over the SignalR circuit before the @if(IsOpen) block re-renders
            // away, so use the auto-retrying assertion rather than a bare
            // CountAsync.
            await page.Locator("[data-test='execution-detail-close']").ClickAsync();
            await Assertions.Expect(modal).ToHaveCountAsync(0);
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [Fact]
    public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
    {
        // Lighter-weight than the auto-load test above: we don't need MSSQL,
        // because we only verify that the Notifications page is reachable and
        // is gated by the right policy — the link target itself is exercised by
        // DrillInFromCorrelationId_AutoLoadsAuditLog. If the notifications list
        // is empty (no seed) we still validate the empty-state markup is in
        // place; if rows are present we confirm at least one "View audit history"
        // link is rendered.
        var page = await _fixture.NewAuthenticatedPageAsync();
        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/notifications/report");
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

        // Page reachable for the multi-role user; the Notification Report page
        // is Deployment-gated, so this also covers that authorization path.
        Assert.Contains("/notifications/report", page.Url);
        await Assertions.Expect(page.Locator("h4:has-text('Notification Report')")).ToBeVisibleAsync();

        // When there are notifications visible, the "View audit history" link
        // is rendered. When there are none, we just confirm the empty-state.
        var auditLinks = page.Locator("a:has-text('View audit history')");
        var linkCount = await auditLinks.CountAsync();
        if (linkCount > 0)
        {
            var firstLink = auditLinks.First;
            var href = await firstLink.GetAttributeAsync("href");
            Assert.NotNull(href);
            Assert.StartsWith("/audit/log?correlationId=", href);
        }
        // No notifications visible? The page still rendered correctly, which is
        // all we can assert without exercising the Akka query path to seed one.
    }

    [Fact]
    public async Task ExportCsv_LinkVisibleForAuditExportUserAndTriggersDownload()
    {
        // Multi-role test user holds Admin, which grants AuditExport — the
        // Export-CSV anchor is gated by AuthorizeView Policy=AuditExport.
        var page = await _fixture.NewAuthenticatedPageAsync();
        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

        var exportLink = page.Locator("a:has-text('Export CSV')");
        await Assertions.Expect(exportLink).ToBeVisibleAsync();
        var href = await exportLink.GetAttributeAsync("href");
        Assert.NotNull(href);
        Assert.StartsWith("/api/centralui/audit/export", href!);

        // Click the link via Playwright's download API so the framework drains
        // the response body instead of letting the browser leave a dangling
        // navigation. The endpoint streams text/csv; we assert the suggested
        // filename matches the audit-log-{timestamp}.csv pattern from
        // AuditExportEndpoints.HandleExportAsync.
        var download = await page.RunAndWaitForDownloadAsync(async () =>
        {
            await exportLink.ClickAsync();
        });

        Assert.NotNull(download);
        Assert.StartsWith("audit-log-", download.SuggestedFilename);
        Assert.EndsWith(".csv", download.SuggestedFilename);
    }

    [Fact]
    public async Task PermissionGating_DesignerWithoutOperationalAudit_IsDeniedAccess()
    {
        // The designer LDAP user holds only the Design role, which does NOT
        // grant OperationalAudit (Component-AuditLog.md §Authorization +
        // AuthorizationPolicies.OperationalAuditRoles). The page-level
        // [Authorize(Policy = OperationalAudit)] attribute is enforced by the
        // ASP.NET Core authorization middleware, which redirects an
        // authenticated-but-unauthorized user to the cookie scheme's
        // AccessDenied path (/Account/AccessDenied?ReturnUrl=...) BEFORE the
        // Blazor router runs — so the audit page never renders.
        var page = await _fixture.NewAuthenticatedPageAsync("designer", "password");
        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log");
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

        // Access was denied: we landed on the AccessDenied path, not the audit
        // page. The ReturnUrl carries the original /audit/log target.
        Assert.Contains("/Account/AccessDenied", page.Url);
        Assert.Contains("ReturnUrl", page.Url);

        // The audit results grid never rendered for the unauthorized user.
        Assert.Equal(0, await page.Locator("[data-test='audit-results-grid']").CountAsync());
    }

    [SkippableFact]
    public async Task NonApiRow_Drawer_OmitsCopyAsCurl()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        // The Copy-as-cURL action is gated on AuditEventDetail.IsApiChannel, which
        // returns true only for ApiOutbound/ApiInbound. A DbOutbound row's drawer
        // must therefore render WITHOUT the cURL button — the negative of
        // CopyAsCurlButton_IsVisibleAndClickableForApiInbound. We seed one
        // DbOutbound row, open its drawer, and assert the drawer is open AND the
        // cURL button is absent.
        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/wave4-audit2/{runId}/";
        var dbId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: dbId,
                occurredAtUtc: now,
                channel: "DbOutbound",
                kind: "DbWrite",
                status: "Delivered",
                target: targetPrefix + "dbrow",
                durationMs: 17);

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

            // Filter to the seeded row (contains-match), then Apply to populate the
            // grid — it stays empty until the user filters.
            await page.FillAsync("#audit-target", targetPrefix + "dbrow");
            await page.ClickAsync("[data-test='filter-apply']");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var row = page.Locator($"[data-test='grid-row-{dbId}']");
            await Assertions.Expect(row).ToBeVisibleAsync();
            await row.ClickAsync();

            // The drawer opens for the DbOutbound row, but the cURL action — gated
            // on IsApiChannel — is absent. Wait for the AuditEventDetail body
            // ([data-test='drawer-fields']) to render BEFORE the negative count
            // assertion, so a not-yet-rendered child can't false-pass count 0.
            await Assertions.Expect(page.Locator("[data-test='audit-drilldown-drawer']")).ToBeVisibleAsync();
            await Assertions.Expect(page.Locator("[data-test='drawer-fields']")).ToBeVisibleAsync();
            await Assertions.Expect(page.Locator("[data-test='copy-as-curl']")).ToHaveCountAsync(0);
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }

    [SkippableFact]
    public async Task Drawer_CloseControls_DismissTheDrawer()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        // The drawer offers two wired close paths — the header X
        // ([data-test='drawer-close']) and the footer Close button
        // ([data-test='drawer-close-footer']), both bound to HandleClose. We open
        // the drawer, dismiss via the X, re-open, then dismiss via the footer,
        // asserting the drawer is gone (count 0) after each.
        // Escape-to-close is NOT wired on AuditDrilldownDrawer (only
        // ExecutionDetailModal has a keydown handler). The backdrop is also wired
        // to HandleClose, but we cover the X and footer-Close paths here for brevity.
        var runId = Guid.NewGuid().ToString("N");
        var targetPrefix = $"playwright-test/wave4-audit2c/{runId}/";
        var apiId = Guid.NewGuid();
        var now = DateTime.UtcNow;

        try
        {
            await AuditDataSeeder.InsertAuditEventAsync(
                eventId: apiId,
                occurredAtUtc: now,
                channel: "ApiOutbound",
                kind: "ApiCall",
                status: "Delivered",
                target: targetPrefix + "row",
                httpStatus: 200,
                durationMs: 42);

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

            await page.FillAsync("#audit-target", targetPrefix + "row");
            await page.ClickAsync("[data-test='filter-apply']");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            var row = page.Locator($"[data-test='grid-row-{apiId}']");
            var drawer = page.Locator("[data-test='audit-drilldown-drawer']");

            // Open, then dismiss via the header X.
            await Assertions.Expect(row).ToBeVisibleAsync();
            await row.ClickAsync();
            await Assertions.Expect(drawer).ToBeVisibleAsync();
            await page.ClickAsync("[data-test='drawer-close']");
            await Assertions.Expect(drawer).ToHaveCountAsync(0);

            // Re-open, then dismiss via the footer Close button.
            await row.ClickAsync();
            await Assertions.Expect(drawer).ToBeVisibleAsync();
            await page.ClickAsync("[data-test='drawer-close-footer']");
            await Assertions.Expect(drawer).ToHaveCountAsync(0);
        }
        finally
        {
            await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
        }
    }
}
