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

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

/// <summary>
/// Browser-level coverage for the M9 design surfaces, run against the live dev cluster:
/// <list type="bullet">
///   <item>the <c>/design/templates</c> tree search filter (TemplateFolderTree pruning),</item>
///   <item>the <c>/design/schema-library</c> page (render-smoke + UI create→delete round-trip),</item>
///   <item>the <c>/design/connections</c> "Move to Site…" dialog render (MoveDataConnectionDialog).</item>
/// </list>
/// Every test seeds only uniquely-named throwaway entities (<c>zztest-*</c>) and best-effort
/// cleans them up; none mutate the canonical <c>site-a</c>/<c>site-b</c>/<c>site-c</c> rows or any
/// pre-seeded entity. The move test asserts the dialog renders its picker only — it never
/// completes the move, so shared site state is untouched.
/// </summary>
[Collection("Playwright")]
public class M9SurfaceTests
{
    private readonly PlaywrightFixture _pw;

    public M9SurfaceTests(PlaywrightFixture pw)
    {
        _pw = pw;
    }

    /// <summary>
    /// Type a unique needle into the Templates page search box and confirm
    /// <see cref="TemplateFolderTree"/> prunes the tree: the matching template's label is
    /// shown while a sibling throwaway template (different unique suffix) is removed from the
    /// DOM. The filter rebuilds <c>_visibleRoots</c> from matches only — non-matches are gone,
    /// not CSS-hidden — so a <c>ToHaveCountAsync(0)</c> on the non-match is the correct assertion.
    /// </summary>
    [SkippableFact]
    public async Task TemplateSearch_FiltersTree()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        // Two root templates with disjoint unique names. We search for `matchName` and assert
        // `otherName` is filtered out — both share the zztest- prefix but differ past it, so a
        // substring search on the full match name cannot also match the other.
        var matchName = CliRunner.UniqueName("tmplmatch");
        var otherName = CliRunner.UniqueName("tmplother");
        var matchId = await CliRunner.CreateTemplateAsync(matchName);
        var otherId = await CliRunner.CreateTemplateAsync(otherName);

        try
        {
            var page = await _pw.NewAuthenticatedPageAsync();
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/templates");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
            await Assertions.Expect(page.Locator("h4:has-text('Templates')")).ToBeVisibleAsync();

            // Both templates are root nodes, so both labels are present before filtering.
            await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = matchName }))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });
            await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = otherName }))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });

            // Type the full match name into the search box (placeholder "Search templates...").
            // @oninput drives _searchText → TemplateFolderTree.Filter → ApplyFilter on each keystroke.
            await page.Locator("input[placeholder='Search templates...']").FillAsync(matchName);

            // The match remains; the other template is pruned out of the tree entirely.
            await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = matchName }))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
            await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = otherName }))
                .ToHaveCountAsync(0, new() { Timeout = 10_000 });

            // Clearing the search via the ✕ button restores the full tree (both labels back).
            await page.Locator("button[title='Clear search']").ClickAsync();
            await Assertions.Expect(page.Locator("span.tv-label", new() { HasText = otherName }))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
        }
        finally
        {
            await CliRunner.DeleteTemplateAsync(matchId);
            await CliRunner.DeleteTemplateAsync(otherId);
        }
    }

    /// <summary>
    /// Full UI round-trip on the Schema Library page: open the editor with "New Schema", save a
    /// uniquely-named library schema (seeded with the page's default object schema), confirm it
    /// lands in the table and renders its <c>lib:Name</c> reference, then delete it via the row's
    /// Delete button + the global DialogHost confirm. Doubles as the page render-smoke: the page,
    /// the SchemaBuilder editor, and the success/delete toasts all render with no circuit crash.
    /// The DB safety-net <c>finally</c> only fires if the UI delete failed mid-flow.
    /// </summary>
    [SkippableFact]
    public async Task SchemaLibrary_CreateDelete_RoundTrips()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        // Schema names allow only a constrained identifier set, and the CLI has no schema
        // command, so build a unique-but-valid name from the GUID suffix (letters/digits only).
        var name = "Zztest" + Guid.NewGuid().ToString("N")[..10];
        var page = await _pw.NewAuthenticatedPageAsync();

        try
        {
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/schema-library");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // ── RENDER-SMOKE ──────────────────────────────────────────────────────────────
            // The page heading + New Schema button must render (no circuit crash on load).
            await Assertions.Expect(page.Locator("h4:has-text('Schema Library')")).ToBeVisibleAsync();
            await page.ClickAsync("button.btn.btn-primary.btn-sm:has-text('New Schema')");

            // The editor card opens with the SchemaBuilder; its Name input is now visible.
            // (If the SchemaBuilder threw on render, the circuit would tear down and this fails.)
            var nameInput = page.Locator("input[placeholder='e.g. Address']");
            await Assertions.Expect(nameInput).ToBeVisibleAsync(new() { Timeout = 10_000 });

            // ── CREATE ────────────────────────────────────────────────────────────────────
            await nameInput.FillAsync(name);
            await page.ClickAsync("button.btn.btn-success:has-text('Save')");

            // Success toast fires and the row appears in the table with its lib: reference.
            await Assertions.Expect(page.Locator(".toast", new() { HasText = "created" }))
                .ToHaveCountAsync(1, new() { Timeout = 15_000 });
            await Assertions.Expect(page.Locator($"td.fw-semibold:has-text('{name}')"))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
            await Assertions.Expect(page.Locator($"td code:has-text('lib:{name}')"))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });

            // ── DELETE ────────────────────────────────────────────────────────────────────
            // Scope the Delete click to this schema's own row so we never click a sibling's button.
            var row = page.Locator("tr").Filter(new() { Has = page.Locator($"td.fw-semibold:has-text('{name}')") });
            await row.Locator("button.btn-outline-danger:has-text('Delete')").ClickAsync();

            // Global DialogHost confirm modal (has `fade`); the danger button is labelled "Delete".
            var confirmBtn = page.Locator(".modal-footer .btn-danger:has-text('Delete')");
            await Assertions.Expect(confirmBtn).ToBeVisibleAsync(new() { Timeout = 5_000 });
            await confirmBtn.ClickAsync();

            // The row is gone after delete.
            await Assertions.Expect(page.Locator($"td.fw-semibold:has-text('{name}')"))
                .ToHaveCountAsync(0, new() { Timeout = 10_000 });
        }
        finally
        {
            // Best-effort DB safety-net: the happy path deletes via the UI, so this finds
            // nothing; it only removes an orphan if the UI delete failed mid-flow. There is no
            // CLI schema command, so this is a direct DELETE on the uniquely-named row.
            await TryDeleteSchemaByNameAsync(name);
        }
    }

    /// <summary>
    /// CLI-seed a throwaway connection on site-a, open its kebab → "Move to Site…", and assert the
    /// MoveDataConnectionDialog opens with its site <c>&lt;select&gt;</c> picker rendered. This does
    /// NOT submit the move — the dialog is cancelled — so no shared site state is mutated. Two-plus
    /// sites exist on the live cluster, so the picker (rather than the "no other site" fallback)
    /// renders.
    /// </summary>
    [SkippableFact]
    public async Task MoveConnectionDialog_OpensWithSitePicker()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        var siteId = await CliRunner.ResolveSiteIdAsync("site-a");
        var name = CliRunner.UniqueName("dconn");
        var id = await CliRunner.CreateDataConnectionAsync(siteId, name);

        try
        {
            var page = await _pw.NewAuthenticatedPageAsync();
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/connections");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
            await Assertions.Expect(page.Locator("h4:has-text('Connections')")).ToBeVisibleAsync();

            // Reveal all nodes so the new connection (a child of its site) is in the tree.
            var bulkDropdown = page.Locator(".dropdown")
                .Filter(new() { Has = page.Locator("button.dropdown-toggle:has-text('Bulk actions')") });
            await bulkDropdown.Locator("button.dropdown-toggle:has-text('Bulk actions')").ClickAsync();
            await bulkDropdown.Locator(".dropdown-menu .dropdown-item:has-text('Expand all')").ClickAsync();

            var node = page.Locator("span.tv-label", new() { HasText = name });
            await Assertions.Expect(node).ToBeVisibleAsync(new() { Timeout = 10_000 });

            // Scope all dropdown interaction to THIS node's own .dc-node-actions wrapper, keyed by
            // the unique aria-label (mirrors DataConnectionCrudTests' strict-mode-safe approach).
            var nodeActions = page.Locator(".dc-node-actions")
                .Filter(new() { Has = page.Locator($"button[aria-label='More actions for {name}']") });

            await node.HoverAsync();
            await nodeActions.Locator("button.dc-kebab").ClickAsync(new() { Force = true });

            await nodeActions.Locator(".dropdown-menu .dropdown-item:has-text('Move to Site…')")
                .ClickAsync();

            // The DialogHost custom dialog opens with the move title and the site picker select.
            await Assertions.Expect(page.Locator($".modal-title:has-text(\"Move '{name}' to site…\")"))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
            var picker = page.Locator(".modal-body select.form-select");
            await Assertions.Expect(picker).ToBeVisibleAsync(new() { Timeout = 10_000 });

            // The picker must offer at least one candidate site (site-b/site-c exist on the live
            // cluster), proving the picker — not the "No other site is available" fallback — rendered.
            Assert.True(await picker.Locator("option").CountAsync() >= 1);

            // Cancel — DO NOT submit. No shared site state is mutated.
            await page.Locator(".modal-footer .btn-outline-secondary:has-text('Cancel')").ClickAsync();
        }
        finally
        {
            await CliRunner.DeleteDataConnectionAsync(id);
        }
    }

    /// <summary>
    /// Best-effort direct DELETE of a library schema by name, for teardown. There is no CLI
    /// schema command, so the test owns this fallback. Swallows every failure so it can never
    /// mask the original test outcome.
    /// </summary>
    private static async Task TryDeleteSchemaByNameAsync(string name)
    {
        try
        {
            await using var conn = new SqlConnection(PlaywrightDbConnection.ConnectionString);
            await conn.OpenAsync();
            await using var cmd = conn.CreateCommand();
            cmd.CommandText = "DELETE FROM SharedSchemas WHERE Name = @name;";
            cmd.Parameters.AddWithValue("@name", name);
            await cmd.ExecuteNonQueryAsync();
        }
        catch
        {
            // Best-effort — never fail (or mask) the test on teardown of a uniquely-named row.
        }
    }
}
