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

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

/// <summary>
/// End-to-end round-trip for the Transport Import wizard (Component #24, Task T22).
///
/// <para>
/// The test exercises the full five-step Apply path at
/// <c>/design/transport/import</c> against the running dev cluster, using a
/// synthetic single-template bundle the test itself exports via the CLI:
/// </para>
/// <list type="number">
///   <item>Create a throwaway template (one Double attribute) and export it to an
///         encrypted <c>.scadabundle</c> on the host temp dir.</item>
///   <item>Delete the source template so the importer sees the bundle's template
///         as <see cref="ConflictKind.New"/> — it renders as a static <c>Add</c>
///         row with no blocker, so the diff step has nothing to resolve.</item>
///   <item>Drive the wizard: upload → passphrase → diff (Next) → confirm
///         (type the source-environment name) → Apply → assert the success
///         summary and the audit drill-in link.</item>
/// </list>
///
/// <para>
/// Remote file upload note: the Playwright browser runs in a Docker container
/// (<c>ws://localhost:3000</c>) with no host-filesystem volume mount, so the
/// staged bundle on the host is NOT visible to the remote browser as a path.
/// Playwright's <see cref="ILocator.SetInputFilesAsync(string, LocatorSetInputFilesOptions?)"/>
/// handles this transparently: the Playwright client (running on the host, in this
/// test process) reads the file and streams its bytes over the WebSocket to the
/// remote browser, which materialises it for the <c>&lt;input type=file&gt;</c>.
/// This test is the de-risking evidence that the path-based overload works over a
/// WS-connected remote browser.
/// </para>
/// </summary>
[Collection("Playwright")]
public sealed class TransportImportTests
{
    private readonly PlaywrightFixture _fixture;

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

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

        // The source-environment name doubles as the Step-4 confirm phrase, so it
        // must round-trip verbatim through the bundle manifest and back into the UI.
        var env = CliRunner.UniqueName("env");
        var tmplName = CliRunner.UniqueName("imp");
        var pass = "pw-" + env;

        // Stage the exported bundle on the host temp dir. SetInputFilesAsync streams
        // the bytes to the remote browser, so the path need not be visible inside
        // the Playwright container.
        var bundlePath = Path.Combine(Path.GetTempPath(), tmplName + ".scadabundle");

        try
        {
            // ── ARRANGE: build + export a synthetic single-template bundle ─────────────
            int tmplId = await CliRunner.CreateTemplateAsync(tmplName);
            await CliRunner.AddAttributeAsync(tmplId, "Value", "Double");
            await CliRunner.BundleExportAsync(bundlePath, tmplId, pass, env);

            // Delete the source so the import diff classifies the bundle's template
            // as New (Add) rather than Identical/Modified — no blocker, no conflict
            // resolution required, Apply proceeds cleanly.
            await CliRunner.DeleteTemplateAsync(tmplId);

            var page = await _fixture.NewAuthenticatedPageAsync();

            // ── STEP 1: Upload ────────────────────────────────────────────────────────
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            // De-risk the remote upload: stream the host file to the container browser.
            await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);

            // The bundle is encrypted (we exported with a passphrase), so the page
            // peeks the manifest with no passphrase, fails to decrypt, and shows the
            // encrypted-bundle notice instead of the (unencrypted-only) manifest
            // summary. Either way, the Step-1 Next button only renders once the file
            // was read successfully — its appearance proves SetInputFiles worked.
            await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });

            await page.Locator("button.btn.btn-primary:has-text('Next')").ClickAsync();

            // ── STEP 2: Passphrase ──────────────────────────────────────────────────────
            await Assertions.Expect(page.Locator("#import-passphrase"))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
            await page.Locator("#import-passphrase").FillAsync(pass);
            await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync();

            // ── STEP 3: Diff ────────────────────────────────────────────────────────────
            // Wait for the passphrase input to disappear before asserting the diff
            // summary — this gives a clearer failure when decrypt/preview is slow and
            // prevents a race between the decrypt completion and the render of the diff.
            await Assertions.Expect(page.Locator("#import-passphrase"))
                .ToBeHiddenAsync(new() { Timeout = 20_000 });

            // A brand-new template renders as a static Add item with no blocker, so
            // the diff summary appears and the Next button is enabled.
            await Assertions.Expect(page.Locator("[data-testid='diff-summary']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });

            var diffNext = page.Locator("button.btn.btn-primary:has-text('Next')");
            await Assertions.Expect(diffNext).ToBeEnabledAsync(new() { Timeout = 15_000 });
            await diffNext.ClickAsync();

            // ── STEP 4: Confirm ──────────────────────────────────────────────────────────
            // The Apply button is disabled until the typed text matches the bundle's
            // source-environment name exactly.
            await Assertions.Expect(page.Locator("#confirm-env"))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
            await page.Locator("#confirm-env").FillAsync(env);

            var applyBtn = page.Locator("button.btn.btn-danger:has-text('Apply Import')");
            await Assertions.Expect(applyBtn).ToBeEnabledAsync();
            await applyBtn.ClickAsync();

            // ── STEP 5: Result ───────────────────────────────────────────────────────────
            var resultSummary = page.Locator("div.alert.alert-success[data-testid='result-summary']");
            await Assertions.Expect(resultSummary).ToBeVisibleAsync(new() { Timeout = 20_000 });
            await Assertions.Expect(resultSummary).ToContainTextAsync("Import complete.");

            // The audit drill-in link must carry the correlated BundleImportId.
            var auditLink = page.Locator("a:has-text('Audit trail →')");
            await Assertions.Expect(auditLink).ToBeVisibleAsync();
            var href = await auditLink.GetAttributeAsync("href");
            Assert.NotNull(href);
            Assert.Matches(@"^/audit/configuration\?bundleImportId=[0-9a-fA-F\-]{36}$", href);
        }
        finally
        {
            // Best-effort teardown: drop any template the test left behind (the source
            // template, plus the one created by a successful import — both carry the
            // tmplName prefix) and delete the staged bundle file.
            try
            {
                foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(tmplName))
                {
                    await CliRunner.DeleteTemplateAsync(id);
                }
            }
            catch
            {
                // Best-effort — never mask the test's own failure.
            }

            try { File.Delete(bundlePath); } catch { /* best-effort */ }
        }
    }

    /// <summary>
    /// Negative path: feed a real encrypted bundle, then submit the WRONG
    /// passphrase at Step 2. Per
    /// <c>TransportImport.razor.cs::SubmitPassphraseAsync</c>, the importer throws
    /// <see cref="System.Security.Cryptography.CryptographicException"/>, which
    /// increments <c>_failedUnlockAttempts</c> to 1 (below the configured
    /// <c>MaxUnlockAttemptsPerSession</c> of 3, so no lockout / no re-upload),
    /// sets <c>_errorMessage = "Wrong passphrase. Please try again."</c>, clears
    /// the passphrase field, and leaves <c>_step</c> on Passphrase. The wizard
    /// therefore renders the <c>[data-testid='error-message']</c> alert, keeps
    /// <c>#import-passphrase</c> visible, and never reaches the Diff step — so
    /// <c>[data-testid='diff-summary']</c> stays absent.
    ///
    /// <para>
    /// We never reach the diff/apply, so the source template is deliberately NOT
    /// deleted before import; teardown drops it by name prefix.
    /// </para>
    /// </summary>
    [SkippableFact]
    public async Task ImportWithWrongPassphrase_ShowsErrorAndStaysOnPassphraseStep()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        var tmplName = CliRunner.UniqueName("wrongpass");
        var bundlePath = Path.Combine(Path.GetTempPath(), tmplName + ".scadabundle");

        try
        {
            // ── ARRANGE: build + export a synthetic single-template encrypted bundle ──
            int tmplId = await CliRunner.CreateTemplateAsync(tmplName);
            await CliRunner.AddAttributeAsync(tmplId, "Value", "Double");
            await CliRunner.BundleExportAsync(bundlePath, tmplId, "correct-passphrase-1", "src-env");

            var page = await _fixture.NewAuthenticatedPageAsync();

            // ── STEP 1: Upload ────────────────────────────────────────────────────────
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);
            await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });

            await page.Locator("button.btn.btn-primary:has-text('Next')").ClickAsync();

            // ── STEP 2: Passphrase (wrong) ────────────────────────────────────────────
            await Assertions.Expect(page.Locator("#import-passphrase"))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
            await page.Locator("#import-passphrase").FillAsync("WRONG-passphrase-xyz");
            await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync();

            // The wrong passphrase surfaces the typed error, keeps the passphrase
            // input visible (still on Step 2), and never reveals the diff summary.
            await Assertions.Expect(page.Locator("[data-testid='error-message']"))
                .ToContainTextAsync("Wrong passphrase. Please try again.", new() { Timeout = 10_000 });
            await Assertions.Expect(page.Locator("#import-passphrase")).ToBeVisibleAsync();
            // Assert the diff step is genuinely absent (the @switch never rendered it), not merely
            // hidden — ToBeHiddenAsync is vacuously true for an element that doesn't exist.
            await Assertions.Expect(page.Locator("[data-testid='diff-summary']")).ToHaveCountAsync(0);

            // Secondary indicator: one failed attempt recorded (1 of MaxUnlockAttempts).
            await Assertions.Expect(page.Locator("[data-testid='unlock-attempts']"))
                .ToContainTextAsync("Failed unlock attempts: 1 of");
        }
        finally
        {
            // The source template was never deleted (we never reached apply), so
            // teardown drops it by name prefix and removes the staged bundle.
            try
            {
                foreach (var id in await CliRunner.ListTemplateIdsByNamePrefixAsync(tmplName))
                {
                    await CliRunner.DeleteTemplateAsync(id);
                }
            }
            catch
            {
                // Best-effort — never mask the test's own failure.
            }

            try { File.Delete(bundlePath); } catch { /* best-effort */ }
        }
    }

    /// <summary>
    /// M8 Map step (#230): drive an import whose bundle carries a <c>Site</c>, so the
    /// preview surfaces a <see cref="RequiredSiteMapping"/> and the wizard's Step-3
    /// <strong>Map</strong> sub-section (<c>[data-testid='map-section']</c>) renders.
    ///
    /// <para>
    /// ARRANGE: create a throwaway site with a unique <c>SiteIdentifier</c>, export a
    /// site-scoped bundle, then DELETE the source site. With no same-identifier site
    /// in the destination the importer finds no auto-match, so the Map row defaults to
    /// "Create new" — the operator must engage the dropdown. The site itself diffs as
    /// <see cref="ConflictKind.New"/> (a static Add, no blocker), so Next is enabled
    /// throughout; the assertion value is that the Map UI renders and that picking an
    /// existing target (the live <c>site-a</c>) is accepted and the wizard advances to
    /// Confirm.
    /// </para>
    ///
    /// <para>
    /// The test deliberately STOPS at the Confirm step and never clicks Apply: mapping
    /// the source site onto <c>site-a</c> would Overwrite the live site on apply, and
    /// the Map-step coverage doesn't require a mutation to be proven. Reaching Confirm
    /// with the mapping chosen is the evidence that resolving the reference unblocks the
    /// wizard.
    /// </para>
    /// </summary>
    [SkippableFact]
    public async Task ImportSiteBundle_RendersMapStep_AndMappingEnablesNext()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        var env = CliRunner.UniqueName("env");
        // The SiteIdentifier doubles as the export selector and the source identifier
        // the Map row shows; keep it inside the zztest- namespace so a unique-prefix
        // teardown can find it even if the test fails before the in-line delete.
        var siteIdentifier = CliRunner.UniqueName("siteid");
        var siteName = CliRunner.UniqueName("site");
        var pass = "pw-" + env;
        var bundlePath = Path.Combine(Path.GetTempPath(), siteIdentifier + ".scadabundle");

        // The live cluster's canonical site — the existing target the operator maps to.
        const string ExistingTargetIdentifier = "site-a";

        try
        {
            // ── ARRANGE: create + export a site-scoped bundle, then drop the source ──
            await CliRunner.CreateSiteAsync(siteName, siteIdentifier);
            await CliRunner.BundleExportSiteAsync(bundlePath, siteIdentifier, pass, env);

            // Delete the source site so the destination has no same-identifier match:
            // the Map row then has no auto-match and defaults to "Create new".
            foreach (var id in await CliRunner.ListSiteIdsByIdentifierPrefixAsync(siteIdentifier))
            {
                await CliRunner.DeleteSiteAsync(id);
            }

            var page = await _fixture.NewAuthenticatedPageAsync();

            // ── STEP 1: Upload ────────────────────────────────────────────────────────
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);
            await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });
            await page.Locator("button.btn.btn-primary:has-text('Next')").ClickAsync();

            // ── STEP 2: Passphrase ──────────────────────────────────────────────────────
            await Assertions.Expect(page.Locator("#import-passphrase"))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
            await page.Locator("#import-passphrase").FillAsync(pass);
            await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync();

            // ── STEP 3: Diff + Map ───────────────────────────────────────────────────────
            await Assertions.Expect(page.Locator("#import-passphrase"))
                .ToBeHiddenAsync(new() { Timeout = 20_000 });

            // The Map sub-section renders because the bundle references a source site.
            await Assertions.Expect(page.Locator("[data-testid='map-section']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });
            await Assertions.Expect(page.Locator("[data-testid='map-sites-table']")).ToBeVisibleAsync();

            // Exactly one source-site row, for the identifier the bundle carried.
            var siteRow = page.Locator("tr[data-testid='map-site-row']");
            await Assertions.Expect(siteRow).ToHaveCountAsync(1);
            await Assertions.Expect(siteRow).ToContainTextAsync(siteIdentifier);

            // The per-site dropdown carries a "Create new" option plus every existing
            // destination site. Map the source onto the live site-a target.
            var siteSelect = page.Locator($"[data-testid='map-site-select-{siteIdentifier}']");
            await Assertions.Expect(siteSelect).ToBeVisibleAsync(new() { Timeout = 10_000 });
            await siteSelect.SelectOptionAsync(ExistingTargetIdentifier);
            await Assertions.Expect(siteSelect).ToHaveValueAsync(ExistingTargetIdentifier);

            // The site item is a New (Add) row — no blocker — so Next stays enabled and
            // advances to Confirm. (We do NOT apply: see the method remarks.)
            var diffNext = page.Locator("button.btn.btn-primary:has-text('Next')");
            await Assertions.Expect(diffNext).ToBeEnabledAsync(new() { Timeout = 15_000 });
            await diffNext.ClickAsync();

            // ── STEP 4: Confirm (reached, not submitted) ──────────────────────────────────
            await Assertions.Expect(page.Locator("#confirm-env"))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
        }
        finally
        {
            // Best-effort teardown: drop the source site (already deleted in the happy
            // path, but recover if the test failed before the in-line delete) and the
            // staged bundle. We never applied, so no destination entity was created.
            try
            {
                foreach (var id in await CliRunner.ListSiteIdsByIdentifierPrefixAsync(siteIdentifier))
                {
                    await CliRunner.DeleteSiteAsync(id);
                }
            }
            catch
            {
                // Best-effort — never mask the test's own failure.
            }

            try { File.Delete(bundlePath); } catch { /* best-effort */ }
        }
    }

    /// <summary>
    /// M8 per-line diff (#230): drive an import where a bundled shared script's
    /// <c>Code</c> diverges from the destination's, so the preview classifies it
    /// <see cref="ConflictKind.Modified"/> and the row's "Field diff" carries a
    /// structured <c>lineDiff</c> — rendered by the <c>LineDiffView</c> component as a
    /// GitHub-style +/- list.
    ///
    /// <para>
    /// ARRANGE: create a shared script with a known multi-line body, export it, then
    /// UPDATE the destination copy's code to a divergent body. On import the
    /// <c>ArtifactDiff.CompareSharedScript</c> Code comparison emits a
    /// <c>changes[].lineDiff</c> payload (Myers hunks with at least one <c>add</c> and
    /// one <c>remove</c> for the changed line), which the diff row surfaces inside a
    /// collapsed <c>&lt;details&gt;</c>.
    /// </para>
    ///
    /// <para>
    /// The test STOPS at the diff step (no Apply): the assertion target is the render
    /// of the +/- hunk rows, not the mutation. Expanding the <c>&lt;details&gt;</c> is
    /// required because the line-diff block is not laid out until the disclosure opens.
    /// </para>
    /// </summary>
    [SkippableFact]
    public async Task ImportModifiedSharedScript_RendersPerLineDiff()
    {
        Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);

        var env = CliRunner.UniqueName("env");
        var scriptName = CliRunner.UniqueName("shsc");
        var pass = "pw-" + env;
        var bundlePath = Path.Combine(Path.GetTempPath(), scriptName + ".scadabundle");

        // A small multi-line body whose middle line changes between export and import.
        // The differing line yields a remove (old) + add (new) hunk; the surrounding
        // lines stay as context, so the render shows all three op classes.
        const string CodeV1 = "var a = 1;\nvar b = 2;\nreturn a + b;";
        const string CodeV2 = "var a = 1;\nvar b = 99;\nreturn a + b;";

        int scriptId = 0;
        try
        {
            // ── ARRANGE: create + export the shared script, then diverge the destination ──
            scriptId = await CliRunner.CreateSharedScriptAsync(scriptName, CodeV1);
            await CliRunner.BundleExportSharedScriptAsync(bundlePath, scriptName, pass, env);

            // Update the destination's copy so the incoming (bundle) body differs — the
            // preview now classifies the shared script as Modified with a code lineDiff.
            await CliRunner.UpdateSharedScriptCodeAsync(scriptId, scriptName, CodeV2);

            var page = await _fixture.NewAuthenticatedPageAsync();

            // ── STEP 1: Upload ────────────────────────────────────────────────────────
            await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/design/transport/import");
            await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

            await page.Locator("#bundle-input").SetInputFilesAsync(bundlePath);
            await Assertions.Expect(page.Locator("[data-testid='encrypted-bundle-notice']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });
            await page.Locator("button.btn.btn-primary:has-text('Next')").ClickAsync();

            // ── STEP 2: Passphrase ──────────────────────────────────────────────────────
            await Assertions.Expect(page.Locator("#import-passphrase"))
                .ToBeVisibleAsync(new() { Timeout = 10_000 });
            await page.Locator("#import-passphrase").FillAsync(pass);
            await page.Locator("button.btn.btn-primary:has-text('Unlock')").ClickAsync();

            // ── STEP 3: Diff — locate the Modified shared-script row's line diff ──────────
            await Assertions.Expect(page.Locator("#import-passphrase"))
                .ToBeHiddenAsync(new() { Timeout = 20_000 });
            await Assertions.Expect(page.Locator("[data-testid='diff-summary']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });

            // The Modified row attaches a code line-diff cell. The structured payload is
            // wrapped in [data-testid='code-line-diff'] inside a collapsed <details>; the
            // line-diff block is laid out only once the disclosure is opened, so open it.
            var codeLineDiff = page.Locator("[data-testid='code-line-diff']");
            await Assertions.Expect(codeLineDiff).ToHaveCountAsync(1, new() { Timeout = 15_000 });

            // The code line-diff sits inside NESTED <details> (the diff-row expander wraps the
            // per-field "Field diff" expander), so a `:has([code-line-diff]) > summary` selector
            // matches BOTH summaries. Force every disclosure open via JS — deterministic and
            // independent of nested click-ordering — so the LineDiffView lays out and is visible.
            await page.EvaluateAsync("() => document.querySelectorAll('details').forEach(d => d.open = true)");

            // The LineDiffView root + at least one add and one remove hunk render.
            await Assertions.Expect(page.Locator("[data-testid='line-diff']"))
                .ToBeVisibleAsync(new() { Timeout = 15_000 });
            await Assertions.Expect(page.Locator("[data-testid='line-diff-add']").First)
                .ToBeVisibleAsync(new() { Timeout = 15_000 });
            await Assertions.Expect(page.Locator("[data-testid='line-diff-remove']").First)
                .ToBeVisibleAsync(new() { Timeout = 15_000 });

            // Both add and remove hunks are present (≥1 each) — a genuine +/- per-line diff,
            // not a context-only render. (We don't assert specific line text: the exact diff
            // content depends on CLI-seeded source/destination bodies and is brittle to pin.)
            Assert.True(await page.Locator("[data-testid='line-diff-add']").CountAsync() >= 1);
            Assert.True(await page.Locator("[data-testid='line-diff-remove']").CountAsync() >= 1);
        }
        finally
        {
            // Best-effort teardown: drop the shared script + the staged bundle. We never
            // applied, so the destination only carries the (now-diverged) source script.
            try
            {
                if (scriptId != 0)
                {
                    await CliRunner.DeleteSharedScriptAsync(scriptId);
                }
            }
            catch
            {
                // Best-effort — never mask the test's own failure.
            }

            try { File.Delete(bundlePath); } catch { /* best-effort */ }
        }
    }
}
