using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Export;
using TransportExportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportExport;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;

/// <summary>
/// bUnit + logic tests for the TransportExport wizard (Component #24, Task T21).
///
/// <para>
/// Covers the four contract points the design plan calls out:
/// </para>
/// <list type="number">
///   <item>Step 1 renders the template tree plus every flat artifact group.</item>
///   <item>Step 2 surfaces the dependency-resolved closure (seed vs auto-included).</item>
///   <item>Step 4 invokes <see cref="IBundleExporter.ExportAsync"/> with the user's
///         selected ids and authenticated identity.</item>
///   <item>The page-level <c>RequireDesign</c> policy denies a user lacking the
///         Design role (router enforcement; the component code-behind never sees
///         the request).</item>
/// </list>
///
/// <para>
/// JS interop is set to loose mode so the TreeView's sessionStorage round-trip
/// and the transport-bundle download interop don't need stubs per test. The
/// <c>scadabridgeTransport.downloadBundle</c> call returns void — loose mode is
/// the lighter wiring than re-stubbing it in every export-path test.
/// </para>
/// </summary>
public class TransportExportPageTests : BunitContext
{
    private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
    private readonly IExternalSystemRepository _externalRepo = Substitute.For<IExternalSystemRepository>();
    private readonly INotificationRepository _notificationRepo = Substitute.For<INotificationRepository>();
    private readonly IInboundApiRepository _inboundApiRepo = Substitute.For<IInboundApiRepository>();
    private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
    private readonly IBundleExporter _exporter = Substitute.For<IBundleExporter>();

    public TransportExportPageTests()
    {
        JSInterop.Mode = JSRuntimeMode.Loose;

        // Default empty repos so OnInitializedAsync doesn't throw — individual
        // tests override the bits they care about.
        _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template>()));
        _templateRepo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
        _templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<SharedScript>>(new List<SharedScript>()));
        _externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<ExternalSystemDefinition>>(new List<ExternalSystemDefinition>()));
        _externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<DatabaseConnectionDefinition>>(new List<DatabaseConnectionDefinition>()));
        _notificationRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList>()));
        _notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
        _inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
        // Empty site/instance defaults — the M8 site/instance picker calls these on load
        // and the DependencyResolver fans out over sites on resolve.
        _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>()));
        _siteRepo.GetInstancesBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>()));
        _siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<DataConnection>>(new List<DataConnection>()));

        Services.AddSingleton(_templateRepo);
        Services.AddSingleton(_externalRepo);
        Services.AddSingleton(_notificationRepo);
        Services.AddSingleton(_inboundApiRepo);
        Services.AddSingleton(_siteRepo);
        Services.AddSingleton(_exporter);
        // DependencyResolver is sealed but its dependencies are the repositories above
        // (template/external/notification/inbound + ISiteRepository for M8 site/instance
        // export) — registering the concrete type is enough.
        Services.AddSingleton<DependencyResolver>();
        Services.AddSingleton<IOptions<TransportOptions>>(
            Microsoft.Extensions.Options.Options.Create(new TransportOptions
            {
                SourceEnvironment = "test-cluster",
            }));

        var principal = BuildPrincipal("alice", "Designer");
        Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
        Services.AddAuthorizationCore();
    }

    private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles)
    {
        var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, username) };
        claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
        return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 1: Step 1 renders the template tree and every flat artifact group.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public void Renders_step1_with_template_tree_and_artifact_checkboxes()
    {
        // A single template + a couple of artifacts so the lists aren't empty.
        var template = new Template("Pump") { Id = 1 };
        var script = new SharedScript("Helpers", "// noop") { Id = 10 };
        var externalSystem = new ExternalSystemDefinition("ERP", "https://erp.example.com", "ApiKey")
        {
            Id = 20,
        };
        var db = new DatabaseConnectionDefinition("Hist", "Server=.;") { Id = 30 };
        var notifList = new NotificationList("Ops") { Id = 40 };
        var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "no-reply@example.com") { Id = 50 };
        // S10c: an SMS provider config, mirroring the SMTP entry above. Labelled by
        // AccountSid; the AuthToken is a secret and must never reach the markup.
        var sms = new SmsConfiguration("AC123", "+15551230001") { Id = 60, AuthToken = "super-secret-token" };
        // Inbound API keys are not transported between environments (re-arch C4) — the
        // export page no longer offers a keys selection list, only API methods.
        var apiMethod = new ApiMethod("CreateOrder", "// noop") { Id = 70 };

        _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
        _templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<SharedScript>>(new List<SharedScript> { script }));
        _externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<ExternalSystemDefinition>>(
                new List<ExternalSystemDefinition> { externalSystem }));
        _externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<DatabaseConnectionDefinition>>(
                new List<DatabaseConnectionDefinition> { db }));
        _notificationRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList> { notifList }));
        _notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration> { smtp }));
        _notificationRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<SmsConfiguration>>(new List<SmsConfiguration> { sms }));
        _inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod> { apiMethod }));

        var cut = Render<TransportExportPage>();
        cut.WaitForState(() => cut.Markup.Contains("Pump"));

        // All flat groups (plus templates) are present. There is intentionally NO
        // API-keys group: inbound API keys are not transported (re-arch C4).
        foreach (var groupId in new[]
        {
            "group-templates",
            "group-shared-scripts",
            "group-external-systems",
            "group-db-connections",
            "group-notification-lists",
            "group-smtp-configs",
            "group-sms-configs",
            "group-api-methods",
        })
        {
            Assert.NotNull(cut.Find($"[data-testid='{groupId}']"));
        }

        // The API-keys selection group is gone. The explanatory note that used to sit
        // beside it moved into docs/requirements/Component-Transport.md (UI cleanup
        // sweep 2026-08-11) — the absence of the group is the assertion that matters.
        Assert.Empty(cut.FindAll("[data-testid='group-api-keys']"));

        // Sanity: each artifact shows its label.
        Assert.Contains("Helpers", cut.Markup);
        Assert.Contains("ERP", cut.Markup);
        Assert.Contains("Hist", cut.Markup);
        Assert.Contains("Ops", cut.Markup);
        Assert.Contains("smtp.example.com", cut.Markup);
        // S10c: the SMS config shows its AccountSid label, never its secret AuthToken.
        Assert.Contains("AC123", cut.Markup);
        Assert.DoesNotContain("super-secret-token", cut.Markup);
        Assert.Contains("CreateOrder", cut.Markup);

        // Next button is disabled while no selection exists.
        var next = cut.FindAll("button").First(b => b.TextContent.Trim() == "Next");
        Assert.True(next.HasAttribute("disabled"));
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 2: Step 2 shows resolved dependencies — auto-included templates pulled
    // in because a seed template composes them.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task Step2_shows_resolved_dependencies_after_clicking_next()
    {
        // Seed template "Pump" composes "Motor". The user selects Pump only;
        // the resolver pulls Motor in transitively.
        var pump = new Template("Pump") { Id = 1 };
        pump.Compositions.Add(new TemplateComposition("MotorSlot")
        {
            Id = 100,
            ComposedTemplateId = 2,
        });
        var motor = new Template("Motor") { Id = 2 };

        _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { pump, motor }));
        _templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<Template?>(pump));
        _templateRepo.GetTemplateWithChildrenAsync(2, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<Template?>(motor));

        var cut = Render<TransportExportPage>();
        cut.WaitForState(() => cut.Markup.Contains("Pump"));

        // The template-tree renders a checkbox per node — tick the one whose
        // sibling label is "Pump". (TemplateFolderTree uses .tv-checkbox.)
        var pumpRow = cut.FindAll("li[role='treeitem']")
            .First(li => li.TextContent.Contains("Pump"));
        var checkbox = pumpRow.QuerySelector("input.tv-checkbox");
        Assert.NotNull(checkbox);
        checkbox!.Change(true);

        // Click "Next" to advance to Step 2; the resolver call is awaited
        // inside GoToReviewAsync — bUnit's WaitForState handles the re-render.
        var next = cut.FindAll("button").First(b => b.TextContent.Trim() == "Next");
        await next.ClickAsync(new());

        cut.WaitForAssertion(() =>
        {
            // Step 2 shows the seed/auto split — Motor lands under "Auto-included".
            var autoGroup = cut.Find("[data-testid='auto-group']");
            Assert.Contains("Motor", autoGroup.TextContent);
        });

        var seedGroup = cut.Find("[data-testid='seed-group']");
        Assert.Contains("Pump", seedGroup.TextContent);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 3: Walks the wizard end-to-end and verifies BundleExporter.ExportAsync
    // is invoked with the user-selected ids and the authenticated identity.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task Step4_triggers_ExportAsync_with_selected_artifacts_and_user_identity()
    {
        var template = new Template("Pump") { Id = 1 };
        _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
        _templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<Template?>(template));

        // Exporter returns a tiny in-memory bundle stream.
        _exporter
            .ExportAsync(
                Arg.Any<ExportSelection>(),
                Arg.Any<string>(),
                Arg.Any<string>(),
                Arg.Any<string?>(),
                Arg.Any<CancellationToken>())
            .Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));

        var cut = Render<TransportExportPage>();
        cut.WaitForState(() => cut.Markup.Contains("Pump"));

        // Tick Pump.
        var pumpCheckbox = cut.FindAll("li[role='treeitem']")
            .First(li => li.TextContent.Contains("Pump"))
            .QuerySelector("input.tv-checkbox");
        Assert.NotNull(pumpCheckbox);
        pumpCheckbox!.Change(true);

        // Advance Step 1 → 2.
        await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
        cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));

        // Advance Step 2 → 3.
        await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
        cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));

        // Fill matching passphrases. The inputs are wired with @bind:event="oninput",
        // so use Input() rather than Change() to fire the right event.
        var passphraseInput = cut.Find("#passphrase");
        passphraseInput.Input("hunter2hunter2");
        var confirmInput = cut.Find("#passphrase-confirm");
        confirmInput.Input("hunter2hunter2");

        // Click "Export" — the only enabled button labeled "Export" at this step.
        await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());

        // Step 4 renders the download summary once ExportAsync resolves.
        cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));

        await _exporter.Received(1).ExportAsync(
            Arg.Is<ExportSelection>(s =>
                s.TemplateIds.Contains(1)
                && s.IncludeDependencies),
            "alice",
            "test-cluster",
            "hunter2hunter2",
            Arg.Any<CancellationToken>());
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 4: A user without the Design role fails the RequireDesign policy.
    // The router enforces [Authorize(Policy=...)] at request time — bUnit
    // doesn't model routing, so we verify the policy itself denies the
    // principal (the same gate the router consults).
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task Page_returns_unauthorized_for_user_without_Design_role()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddScadaBridgeAuthorization();
        using var provider = services.BuildServiceProvider();
        var authService = provider.GetRequiredService<IAuthorizationService>();

        // Administrator user — has a role but it isn't Designer.
        var principal = BuildPrincipal("bob", "Administrator");
        var result = await authService.AuthorizeAsync(
            principal, null, AuthorizationPolicies.RequireDesign);

        Assert.False(result.Succeeded);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 5 (M8 E1): Step 1 renders the Sites & Instances group, listing each
    // site and (when expanded) its instances.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public void Renders_step1_sites_group_with_sites_and_instances()
    {
        var site = new Site("North Plant", "north") { Id = 5 };
        var instance = new Instance("north/pump-01") { Id = 50, SiteId = 5 };

        _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { site }));
        _siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { instance }));

        var cut = Render<TransportExportPage>();
        cut.WaitForState(() => cut.Markup.Contains("North Plant"));

        // The Sites & Instances group is present alongside the existing artifact groups.
        Assert.NotNull(cut.Find("[data-testid='group-sites']"));
        Assert.Contains("North Plant", cut.Markup);
        Assert.Contains("north", cut.Markup);

        // The site row carries a site checkbox; expanding reveals the instance checkbox.
        Assert.NotNull(cut.Find("#chk-site-5"));
        var expandToggle = cut.Find("[data-testid='site-row'] button");
        expandToggle.Click();

        cut.WaitForAssertion(() =>
        {
            Assert.NotNull(cut.Find("[data-testid='site-instances']"));
            Assert.NotNull(cut.Find("#chk-instance-50"));
            Assert.Contains("north/pump-01", cut.Markup);
        });
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 6 (M8 E1): Selecting a site flows its id into ExportSelection.SiteIds;
    // selecting an individual instance flows its id into InstanceIds. Verified at
    // the resolver/exporter boundary (the same contract DependencyResolver reads).
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task Step4_export_carries_selected_site_and_instance_ids()
    {
        var siteA = new Site("North Plant", "north") { Id = 5 };
        var siteB = new Site("South Plant", "south") { Id = 6 };
        var instanceB = new Instance("south/pump-09") { Id = 90, SiteId = 6 };

        _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site> { siteA, siteB }));
        _siteRepo.GetInstancesBySiteIdAsync(5, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance>()));
        _siteRepo.GetInstancesBySiteIdAsync(6, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { instanceB }));
        // Resolver fetches selected entities by id; return them so the closure is non-empty.
        _siteRepo.GetSiteByIdAsync(5, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<Site?>(siteA));

        _exporter
            .ExportAsync(
                Arg.Any<ExportSelection>(),
                Arg.Any<string>(),
                Arg.Any<string>(),
                Arg.Any<string?>(),
                Arg.Any<CancellationToken>())
            .Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));

        var cut = Render<TransportExportPage>();
        cut.WaitForState(() => cut.Markup.Contains("North Plant"));

        // Tick site 5 (North Plant) directly.
        cut.Find("#chk-site-5").Change(true);

        // Expand South Plant and tick its instance individually.
        var southRow = cut.FindAll("[data-testid='site-row']")
            .First(r => r.GetAttribute("data-site-id") == "6");
        southRow.QuerySelector("button")!.Click();
        cut.WaitForState(() => cut.FindAll("#chk-instance-90").Count > 0);
        cut.Find("#chk-instance-90").Change(true);

        // Step 1 → 2 → 3 → export.
        await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
        cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));
        await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
        cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));

        cut.Find("#passphrase").Input("hunter2hunter2");
        cut.Find("#passphrase-confirm").Input("hunter2hunter2");
        await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
        cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));

        await _exporter.Received(1).ExportAsync(
            Arg.Is<ExportSelection>(s =>
                s.SiteIds.Contains(5)
                && s.InstanceIds.Contains(90)),
            "alice",
            "test-cluster",
            "hunter2hunter2",
            Arg.Any<CancellationToken>());
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 8 (S10c): the Step 1 SMS section renders available SMS configs by
    // AccountSid (never AuthToken), and ticking one flows its entity id into
    // ExportSelection.SmsConfigurationIds — mirroring the SMTP-by-Host contract.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task Step4_export_carries_selected_sms_configuration_ids()
    {
        var sms = new SmsConfiguration("AC777", "+15559990001") { Id = 77, AuthToken = "do-not-leak" };
        _notificationRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<SmsConfiguration>>(new List<SmsConfiguration> { sms }));
        // The resolver fetches the selected SMS config by id when building the closure.
        _notificationRepo.GetSmsConfigurationByIdAsync(77, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<SmsConfiguration?>(sms));

        _exporter
            .ExportAsync(
                Arg.Any<ExportSelection>(),
                Arg.Any<string>(),
                Arg.Any<string>(),
                Arg.Any<string?>(),
                Arg.Any<CancellationToken>())
            .Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 0x50, 0x4b, 0x03, 0x04 })));

        var cut = Render<TransportExportPage>();
        cut.WaitForState(() => cut.Markup.Contains("AC777"));

        // The SMS section renders the available config by AccountSid; its secret
        // AuthToken is never rendered.
        Assert.NotNull(cut.Find("[data-testid='group-sms-configs']"));
        Assert.DoesNotContain("do-not-leak", cut.Markup);

        // Tick the SMS config checkbox (id pattern mirrors the SMTP flat-list wiring).
        cut.Find("#chk-SmsConfiguration-77").Change(true);

        // Step 1 → 2 → 3 → export.
        await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
        cut.WaitForAssertion(() => Assert.Contains("Selected by you", cut.Markup));
        await cut.FindAll("button").First(b => b.TextContent.Trim() == "Next").ClickAsync(new());
        cut.WaitForAssertion(() => Assert.Contains("Passphrase", cut.Markup));

        cut.Find("#passphrase").Input("hunter2hunter2");
        cut.Find("#passphrase-confirm").Input("hunter2hunter2");
        await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
        cut.WaitForAssertion(() => Assert.Contains("Bundle ready", cut.Markup));

        await _exporter.Received(1).ExportAsync(
            Arg.Is<ExportSelection>(s => s.SmsConfigurationIds.Contains(77)),
            "alice",
            "test-cluster",
            "hunter2hunter2",
            Arg.Any<CancellationToken>());
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 9 (S10c): CountSecrets counts a populated SMS AuthToken, mirroring
    // the SMTP Credentials contribution to the Step 3 secrets banner.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public void CountSecrets_includes_sms_configuration_auth_token()
    {
        var smsWithToken = new SmsConfiguration("AC1", "+15551110000") { Id = 1, AuthToken = "tok" };
        var smsNoToken = new SmsConfiguration("AC2", "+15552220000") { Id = 2, AuthToken = null };

        var resolved = new ResolvedExport(
            TemplateFolders: Array.Empty<TemplateFolder>(),
            Templates: Array.Empty<Template>(),
            SharedScripts: Array.Empty<SharedScript>(),
            ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
            ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
            DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
            NotificationLists: Array.Empty<NotificationList>(),
            SmtpConfigs: Array.Empty<SmtpConfiguration>(),
            ApiMethods: Array.Empty<ApiMethod>(),
            ContentManifest: Array.Empty<ManifestContentEntry>())
        {
            SmsConfigs = new List<SmsConfiguration> { smsWithToken, smsNoToken },
        };

        // Only the config with a non-empty AuthToken counts.
        Assert.Equal(1, TransportExportPage.CountSecrets(resolved));
    }

    // ─────────────────────────────────────────────────────────────────────
    // Static helpers — exercised directly so the file-naming + secret-count
    // contract is unit-pinned independently of the rendering surface.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public void BuildFilename_produces_pattern_and_sanitises_source_environment()
    {
        var fixedTime = new DateTimeOffset(2026, 5, 24, 13, 45, 22, TimeSpan.Zero);
        var filename = TransportExportPage.BuildFilename("dev/cluster a", fixedTime);
        Assert.Equal("scadabundle-dev-cluster-a-2026-05-24-134522.scadabundle", filename);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 7 (M8 C2-SECRET-1): CountSecrets includes site data-connection
    // PrimaryConfiguration and BackupConfiguration fields in the banner total.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public void CountSecrets_includes_data_connection_primary_and_backup_configurations()
    {
        // One data connection with both fields populated — expect 2 secrets counted.
        var dcBoth = new DataConnection("PlcA", "OpcUa", 10) { Id = 1,
            PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://plc-a:4840\"}", BackupConfiguration = "{\"endpoint\":\"opc.tcp://plc-a-backup:4840\"}" };

        var resolved = new ResolvedExport(
            TemplateFolders: Array.Empty<TemplateFolder>(),
            Templates: Array.Empty<Template>(),
            SharedScripts: Array.Empty<SharedScript>(),
            ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
            ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
            DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
            NotificationLists: Array.Empty<NotificationList>(),
            SmtpConfigs: Array.Empty<SmtpConfiguration>(),
            ApiMethods: Array.Empty<ApiMethod>(),
            ContentManifest: Array.Empty<ManifestContentEntry>())
        {
            DataConnections = new List<DataConnection> { dcBoth },
        };

        Assert.Equal(2, TransportExportPage.CountSecrets(resolved));
    }

    [Fact]
    public void CountSecrets_counts_only_non_empty_data_connection_configurations()
    {
        // Primary only — backup is null.
        var dcPrimaryOnly = new DataConnection("PlcB", "OpcUa", 10) { Id = 2,
            PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://plc-b:4840\"}", BackupConfiguration = null };

        // Neither field set — should not contribute.
        var dcEmpty = new DataConnection("PlcC", "OpcUa", 10) { Id = 3,
            PrimaryConfiguration = null, BackupConfiguration = null };

        var resolved = new ResolvedExport(
            TemplateFolders: Array.Empty<TemplateFolder>(),
            Templates: Array.Empty<Template>(),
            SharedScripts: Array.Empty<SharedScript>(),
            ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
            ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
            DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
            NotificationLists: Array.Empty<NotificationList>(),
            SmtpConfigs: Array.Empty<SmtpConfiguration>(),
            ApiMethods: Array.Empty<ApiMethod>(),
            ContentManifest: Array.Empty<ManifestContentEntry>())
        {
            DataConnections = new List<DataConnection> { dcPrimaryOnly, dcEmpty },
        };

        Assert.Equal(1, TransportExportPage.CountSecrets(resolved));
    }

    [Fact]
    public void CountSecrets_returns_zero_when_no_data_connections()
    {
        var resolved = new ResolvedExport(
            TemplateFolders: Array.Empty<TemplateFolder>(),
            Templates: Array.Empty<Template>(),
            SharedScripts: Array.Empty<SharedScript>(),
            ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
            ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
            DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
            NotificationLists: Array.Empty<NotificationList>(),
            SmtpConfigs: Array.Empty<SmtpConfiguration>(),
            ApiMethods: Array.Empty<ApiMethod>(),
            ContentManifest: Array.Empty<ManifestContentEntry>());

        Assert.Equal(0, TransportExportPage.CountSecrets(resolved));
    }
}
