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

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

/// <summary>
/// E2E coverage for the inbound API-key create form (<c>/admin/api-keys/create</c>,
/// rendered by <c>ApiKeyForm.razor</c>). One happy-path test asserts the one-time token
/// reveal after a successful create (and tears the key down via the CLI in a <c>finally</c>),
/// plus two validation-edge tests that assert the exact inline messages the razor renders
/// for an empty name and for a key with no method selected.
///
/// <para>
/// The <see cref="ApiSurfaceFixture"/> provisions a single inbound api-method so the form
/// renders at least one method checkbox (<c>#method-access-{MethodId}</c>); this fixture
/// owns that method (cleaned at fixture dispose). Created keys are owned by the tests and
/// deleted per-test.
/// </para>
///
/// <para>
/// Verified against <c>ApiKeyForm.razor</c>: the name input is the single
/// <c>input[type='text'].form-control.form-control-sm</c> (editable on create); the Save
/// button is the success button with text "Save"; the created-token panel exposes
/// <c>[data-test='created-token']</c> with an adjacent "Copy" button; inline validation
/// renders in <c>div.text-danger.small</c> with the literal messages
/// <c>"Name is required."</c> and <c>"Select at least one API method for this key."</c>.
/// </para>
/// </summary>
[Collection("Playwright")]
public sealed class ApiKeyCrudTests : IClassFixture<ApiSurfaceFixture>
{
    private readonly PlaywrightFixture _fixture;
    private readonly ApiSurfaceFixture _api;

    public ApiKeyCrudTests(PlaywrightFixture fixture, ApiSurfaceFixture api)
    {
        _fixture = fixture;
        _api = api;
    }

    /// <summary>
    /// Happy path: filling the name, checking the fixture method, and saving creates the key
    /// and reveals the one-time token panel (<c>[data-test='created-token']</c>) with its
    /// adjacent "Copy" button. MUTATES — the created key is deleted via the CLI in a
    /// <c>finally</c> (resolve by name, best-effort delete) so nothing leaks.
    /// </summary>
    [SkippableFact]
    public async Task Create_RevealsOneTimeToken()
    {
        Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        var keyName = CliRunner.UniqueName("apikey");

        try
        {
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys/create");

            // Blazor Server page renders a LoadingSpinner while OnInitializedAsync loads the
            // method list; web-first wait for the name input before driving it.
            var nameInput = page.Locator("input[type='text'].form-control.form-control-sm").First;
            await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 15_000 });

            var saveButton = page.GetByRole(AriaRole.Button, new() { Name = "Save" });

            // Same under-load hydration race as Create_NoMethods (see that test): this page is
            // InteractiveServer with prerendering, so the form renders (and is "visible") before
            // blazor.web.js hydrates the component and attaches its @bind onchange / @onchange
            // handlers over the SignalR circuit. Under full-suite load that hydration lands late,
            // so a name fill / checkbox check dispatched against the not-yet-live circuit is
            // silently dropped — SaveKey then short-circuits on the empty _formName (or empty
            // method set) and no key is created, so the token panel never appears.
            //
            // PROVE the circuit is interactive with an observable server round-trip first: clicking
            // Save on the empty form forces SaveKey to run and render "Name is required." (harmless,
            // creates nothing). Once that message appears the component is hydrated and its handlers
            // are live, so the subsequent name fill + checkbox check are guaranteed to round-trip.
            await saveButton.ClickAsync();
            await Assertions.Expect(page.Locator("div.text-danger.small"))
                .ToContainTextAsync("Name is required.", new() { Timeout = 15_000 });

            await nameInput.FillAsync(keyName);
            await nameInput.DispatchEventAsync("change");
            await page.Locator($"#method-access-{_api.MethodId}").CheckAsync();
            await saveButton.ClickAsync();

            await Assertions.Expect(page.Locator("[data-test='created-token']"))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
            await Assertions.Expect(page.GetByRole(AriaRole.Button, new() { Name = "Copy" }))
                .ToBeVisibleAsync();
        }
        finally
        {
            var keyId = await CliRunner.ResolveApiKeyIdByNameAsync(keyName);
            if (keyId is not null)
            {
                await CliRunner.DeleteApiKeyAsync(keyId);
            }
        }
    }

    /// <summary>
    /// Validation edge: an empty name (with a method checked) blocks the save and renders the
    /// literal <c>"Name is required."</c> message in <c>div.text-danger</c>; no token panel
    /// appears, so nothing is created and there is no teardown.
    /// </summary>
    [SkippableFact]
    public async Task Create_EmptyName_ShowsValidationError()
    {
        Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");

        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys/create");

        // Web-first wait on the method checkbox guarantees the form (and its method list)
        // has rendered before we interact.
        var methodCheckbox = page.Locator($"#method-access-{_api.MethodId}");
        await Assertions.Expect(methodCheckbox).ToBeVisibleAsync(new() { Timeout = 15_000 });

        // Leave the name blank; check the method so only the name rule fires.
        await methodCheckbox.CheckAsync();
        await page.GetByRole(AriaRole.Button, new() { Name = "Save" }).ClickAsync();

        var error = page.Locator("div.text-danger.small");
        await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 10_000 });
        await Assertions.Expect(error).ToContainTextAsync("Name is required.");

        // Nothing should have been created — the token panel must never appear.
        await Assertions.Expect(page.Locator("[data-test='created-token']")).ToHaveCountAsync(0);
    }

    /// <summary>
    /// Validation edge: a named key with no method selected blocks the save and renders the
    /// literal <c>"Select at least one API method for this key."</c> message in
    /// <c>div.text-danger</c>; no token panel appears, so nothing is created and there is no
    /// teardown.
    /// </summary>
    [SkippableFact]
    public async Task Create_NoMethods_ShowsValidationError()
    {
        Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");

        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys/create");

        var nameInput = page.Locator("input[type='text'].form-control.form-control-sm").First;
        await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 15_000 });

        var saveButton = page.GetByRole(AriaRole.Button, new() { Name = "Save" });
        var error = page.Locator("div.text-danger.small");

        // ROOT CAUSE of the under-load flake: the page is InteractiveServer with prerendering,
        // so the name <input> renders (and is "visible") from the prerendered HTML BEFORE
        // blazor.web.js hydrates this component and attaches its @bind onchange handler over the
        // SignalR circuit. Under full-suite load (circuit pressure from the 4-context cap) that
        // hydration lands late, so a name FillAsync + change dispatched against the not-yet-live
        // circuit is silently dropped — _formName stays empty server-side and SaveKey short-
        // circuits to "Name is required." (the rule checked BEFORE the methods rule in
        // ApiKeyForm.SaveKey). In isolation the circuit hydrates fast enough that this never bites.
        //
        // Fix: PROVE the circuit is interactive with an observable server round-trip before we
        // rely on the name commit. Clicking Save with an empty name forces SaveKey to run on the
        // server and render the "Name is required." message — a circuit-driven re-render. Waiting
        // for that message guarantees the component is hydrated and its onchange handler is live.
        // Only THEN do we fill the name + dispatch change (now guaranteed to round-trip) and Save
        // again, so _formName is committed and the methods rule — not the name rule — fires.
        await saveButton.ClickAsync();
        await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 15_000 });
        await Assertions.Expect(error).ToContainTextAsync("Name is required.");

        // Circuit is now provably interactive. Commit the name as its own discrete change message
        // (ordered before the Save click over the in-order SignalR channel), leaving ALL method
        // checkboxes unchecked so only the method rule can fire.
        await nameInput.FillAsync(CliRunner.UniqueName("apikey"));
        await nameInput.DispatchEventAsync("change");
        await saveButton.ClickAsync();

        await Assertions.Expect(error).ToBeVisibleAsync(new() { Timeout = 10_000 });
        await Assertions.Expect(error).ToContainTextAsync("Select at least one API method for this key.");

        // The name-only submit must not create a key — no token panel.
        await Assertions.Expect(page.Locator("[data-test='created-token']")).ToHaveCountAsync(0);
    }

    /// <summary>
    /// Enable/disable round-trip on the list page (<c>/admin/api-keys</c>, rendered by
    /// <c>ApiKeys.razor</c>). MUTATES — creates a key via the CLI, drives the per-row kebab
    /// (<c>button[aria-label="More actions for {name}"]</c>, a Bootstrap <c>data-bs-toggle="dropdown"</c>)
    /// to Disable then Enable. The authoritative state indicator is the
    /// <c>span.badge.bg-secondary</c> "Disabled" badge the razor renders on a disabled row's name
    /// cell: it must appear after Disable and be gone after Enable. The CLI-created key is deleted
    /// in a <c>finally</c> (best-effort) so nothing leaks.
    /// </summary>
    [SkippableFact]
    public async Task ToggleEnabled_TransitionsDisabledBadge()
    {
        Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        var keyName = CliRunner.UniqueName("apikey");
        var keyId = await CliRunner.CreateApiKeyAsync(keyName, _api.MethodName);

        try
        {
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys");

            // Web-first: the row for our key (by name) must render before we drive it.
            var row = page.Locator("tr").Filter(new() { HasText = keyName });
            await Assertions.Expect(row).ToBeVisibleAsync(new() { Timeout = 15_000 });

            // Scope ALL dropdown interactions to THIS row's .dropdown container so the kebab and
            // its menu items can never multi-match against another row's (hidden) menu under
            // Playwright strict mode (e.g. when the list has test residue / multiple keys).
            var rowDropdown = row.Locator(".dropdown");
            var kebab = rowDropdown.Locator("button[aria-label^='More actions']");
            var disabledBadge = row.Locator("span.badge.bg-secondary");

            // Open the kebab (Bootstrap dropdown) and click Disable. Gate the click on the item's
            // visibility so we don't race the Bootstrap open-transition before the menu is .show.
            await kebab.ClickAsync();
            var disableItem = rowDropdown.Locator(".dropdown-menu button.dropdown-item")
                .Filter(new() { HasText = "Disable" });
            await Assertions.Expect(disableItem).ToBeVisibleAsync();
            await disableItem.ClickAsync();

            // Authoritative: the "Disabled" badge appears on the row's name cell.
            await Assertions.Expect(disabledBadge).ToBeVisibleAsync(new() { Timeout = 10_000 });

            // Re-open the kebab and click Enable; the badge must disappear from this row.
            await kebab.ClickAsync();
            var enableItem = rowDropdown.Locator(".dropdown-menu button.dropdown-item")
                .Filter(new() { HasText = "Enable" });
            await Assertions.Expect(enableItem).ToBeVisibleAsync();
            await enableItem.ClickAsync();

            await Assertions.Expect(disabledBadge).ToHaveCountAsync(0, new() { Timeout = 10_000 });
        }
        finally
        {
            await CliRunner.DeleteApiKeyAsync(keyId);
        }
    }

    /// <summary>
    /// Delete-with-confirm removes the row (<c>/admin/api-keys</c>, rendered by <c>ApiKeys.razor</c>).
    /// MUTATES — creates a key via the CLI, then deletes it through the UI: kebab →
    /// <c>.dropdown-item.text-danger</c> "Delete" → the confirm dialog ("Delete API Key", rendered by
    /// <c>DialogHost.razor</c>) → <c>.modal-footer .btn-danger</c>. The row for that name must then be
    /// gone. The CLI delete in the <c>finally</c> is an idempotent safety net (no-op if the UI already
    /// removed it).
    /// </summary>
    [SkippableFact]
    public async Task Delete_WithConfirm_RemovesRow()
    {
        Skip.IfNot(_api.Available, ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync("multi-role", "password");
        var keyName = CliRunner.UniqueName("apikey");
        var keyId = await CliRunner.CreateApiKeyAsync(keyName, _api.MethodName);

        try
        {
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/api-keys");

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

            // Scope ALL dropdown interactions to THIS row's .dropdown container so the kebab and
            // its menu items can never multi-match against another row's (hidden) menu under
            // Playwright strict mode (e.g. when the list has test residue / multiple keys).
            var rowDropdown = row.Locator(".dropdown");
            var kebab = rowDropdown.Locator("button[aria-label^='More actions']");

            // Open the kebab and click the danger "Delete" item. Gate the click on the item's
            // visibility so we don't race the Bootstrap open-transition before the menu is .show.
            await kebab.ClickAsync();
            var deleteItem = rowDropdown.Locator(".dropdown-menu button.dropdown-item")
                .Filter(new() { HasText = "Delete" });
            await Assertions.Expect(deleteItem).ToBeVisibleAsync();
            await deleteItem.ClickAsync();

            // The confirm dialog must appear before we confirm.
            await Assertions.Expect(page.Locator(".modal-title").Filter(new() { HasText = "Delete API Key" }))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
            await page.Locator(".modal-footer .btn-danger").ClickAsync();

            // The row for that name must be gone after the delete round-trips.
            await Assertions.Expect(page.Locator("tr").Filter(new() { HasText = keyName }))
                .ToHaveCountAsync(0, new() { Timeout = 10_000 });
        }
        finally
        {
            await CliRunner.DeleteApiKeyAsync(keyId);
        }
    }
}
