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

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

/// <summary>
/// End-to-end CRUD round-trip for the LDAP Group Mappings admin page.
/// Covers create → edit → delete via the UI against the running dev cluster.
/// </summary>
[Collection("Playwright")]
public class LdapMappingCrudTests
{
    private readonly PlaywrightFixture _fixture;

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

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

        // Truncated to 18 chars to stay within the form field's maximum input length
        // while still being unique (the zztest-grp- prefix + 7 hex chars from the GUID).
        var group = $"zztest-grp-{Guid.NewGuid():N}"[..18];

        var page = await _fixture.NewAuthenticatedPageAsync();

        try
        {
            // ── CREATE ────────────────────────────────────────────────────────────────
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The LDAP Group Name label has no `for=` attribute so GetByLabel does not
            // work. Locate the input that immediately follows the label text instead.
            await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm").FillAsync(group);
            // Scope the role select to the div.mb-2 that owns the "Role" label so a
            // second select (Site Scope) on the edit page cannot cause a strict-mode
            // violation. The LdapMappingForm.razor uses Blazor @bind, not <form>,
            // so the "Role" label's parent div.mb-2 is the tightest stable scope.
            await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Designer");
            await page.ClickAsync("button.btn.btn-success:has-text('Save')");

            // Wait for Blazor enhanced navigation back to the list page.
            await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/create");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The new row must be visible on the list.
            var newRow = page.Locator("tr", new() { HasText = group });
            await Assertions.Expect(newRow).ToBeVisibleAsync();

            // ── EDIT ──────────────────────────────────────────────────────────────────
            // Click the Edit button within that row.
            await newRow.Locator("button.btn.btn-outline-primary.btn-sm:has-text('Edit')").ClickAsync();

            await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings/", excludePath: "/create");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // Change the role to Viewer and save.
            await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Viewer");
            await page.ClickAsync("button.btn.btn-success:has-text('Save')");

            await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/edit");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // Row must still be present after the edit.
            var editedRow = page.Locator("tr", new() { HasText = group });
            await Assertions.Expect(editedRow).ToBeVisibleAsync();

            // ── DELETE (no confirmation dialog) ───────────────────────────────────────
            // Scope all dropdown interactions to the row's .dropdown container so we
            // never accidentally match a Delete button from another row's menu.
            var rowDropdown = editedRow.Locator(".dropdown");
            var kebab = rowDropdown.Locator("button[aria-label^='More actions']");
            await kebab.ClickAsync();

            // Click Delete in the now-open dropdown within this row — no confirm dialog.
            var deleteBtn = rowDropdown.Locator(".dropdown-menu button.dropdown-item.text-danger");
            await deleteBtn.ClickAsync();

            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // The row must be gone.
            await Assertions.Expect(page.Locator("tr", new() { HasText = group }))
                .ToHaveCountAsync(0, new() { Timeout = 10_000 });
        }
        finally
        {
            // Best-effort safety net: if the test failed mid-way and the mapping was
            // not deleted by the UI, clean it up via the CLI so it doesn't leak.
            // Uses `security role-mapping list` + `security role-mapping delete --id`
            // (CLI verbs from SecurityCommands.cs BuildRoleMapping). All exceptions
            // are swallowed — teardown must never mask the test's own failure.
            try
            {
                using var doc = await CliRunner.RunJsonAsync("security", "role-mapping", "list");
                if (doc.RootElement.ValueKind == JsonValueKind.Array)
                {
                    foreach (var mapping in doc.RootElement.EnumerateArray())
                    {
                        if (mapping.TryGetProperty("ldapGroupName", out var grpProp)
                            && grpProp.ValueKind == JsonValueKind.String
                            && string.Equals(grpProp.GetString(), group, StringComparison.Ordinal)
                            && mapping.TryGetProperty("id", out var idProp)
                            && idProp.TryGetInt32(out var mappingId))
                        {
                            await CliRunner.RunAsync(
                                "security", "role-mapping", "delete",
                                "--id", mappingId.ToString(System.Globalization.CultureInfo.InvariantCulture));
                            break;
                        }
                    }
                }
            }
            catch
            {
                // Best-effort — the mapping may already be deleted (happy path) or
                // the cluster may be unreachable; never fail teardown.
            }
        }
    }

    /// <summary>
    /// The create form's <c>SaveMapping()</c> validates manually (no EditForm /
    /// DataAnnotations): a blank LDAP Group Name renders "LDAP Group Name is
    /// required." in <c>div.text-danger.small.mt-2</c> and returns early; a present
    /// group but blank role renders "Role is required." Both branches return before
    /// any persistence, so nothing is created and no teardown is needed. The first
    /// Save is a full circuit roundtrip — proving the Blazor circuit is live — which
    /// makes the second-leg fill (group → blank role) safe from the prerender race.
    /// </summary>
    [SkippableFact]
    public async Task Save_MissingGroupName_ShowsRequiredError()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        var page = await _fixture.NewAuthenticatedPageAsync();

        await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
        await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

        // ── Leg 1: blank group, role selected → "LDAP Group Name is required." ─────
        // Leave the group input blank; pick a role so the group-blank branch (checked
        // FIRST in SaveMapping) is unambiguously the one that fires.
        await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
            .SelectOptionAsync("Designer");
        await page.ClickAsync("button.btn.btn-success:has-text('Save')");

        await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
            .ToHaveTextAsync("LDAP Group Name is required.");
        await Assertions.Expect(page)
            .ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/ldap-mappings/create"));

        // ── Leg 2: group filled, role blank → "Role is required." ──────────────────
        // The first Save roundtrip proved the circuit live, so filling now is safe.
        var group = $"zztest-grp-{Guid.NewGuid():N}"[..18];
        await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm")
            .FillAsync(group);
        await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
            .SelectOptionAsync(new[] { "" });
        await page.ClickAsync("button.btn.btn-success:has-text('Save')");

        await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
            .ToHaveTextAsync("Role is required.");
        await Assertions.Expect(page)
            .ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/ldap-mappings/create"));

        // Both validations returned early → nothing persisted → no teardown.
    }

    /// <summary>
    /// Attempting to create a mapping whose LDAP group already exists violates the DB
    /// unique index on <c>LdapGroupName</c>. The form has no friendly pre-check, so
    /// the persistence exception is caught and surfaced as "Save failed: {message}"
    /// in <c>div.text-danger.small.mt-2</c>, keeping the user on the create page.
    /// The duplicate is CLI-seeded (and torn down) so this asserts the save-failure
    /// branch without depending on the raw DB error text.
    /// </summary>
    [SkippableFact]
    public async Task Create_DuplicateGroup_ShowsSaveFailedError()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        var group = CliRunner.UniqueName("grp");
        var seededId = await CliRunner.CreateRoleMappingAsync(group, "Designer");

        try
        {
            var page = await _fixture.NewAuthenticatedPageAsync();

            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/admin/ldap-mappings/create");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // Fill the SAME group name the CLI just seeded → unique-index violation.
            await page.Locator("label:has-text('LDAP Group Name') + input.form-control.form-control-sm")
                .FillAsync(group);
            await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
                .SelectOptionAsync("Viewer");
            await page.ClickAsync("button.btn.btn-success:has-text('Save')");

            // The catch block prefixes the raw DB message with "Save failed:" — assert
            // only on that stable prefix, never the provider-specific tail.
            await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
                .ToContainTextAsync("Save failed");
            await Assertions.Expect(page)
                .ToHaveURLAsync(new System.Text.RegularExpressions.Regex("/admin/ldap-mappings/create"));
        }
        finally
        {
            // Best-effort: remove the CLI-seeded mapping; the UI never persisted a
            // duplicate (the save failed), so only the seed needs cleanup.
            await CliRunner.DeleteRoleMappingAsync(seededId);
        }
    }
}
