using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
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.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;

namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;

/// <summary>
/// Integration tests for <see cref="BundleImporter.ApplyAsync"/>. Reuses the
/// in-memory host pattern from <see cref="BundleImporterPreviewTests"/> and
/// <c>BundleExporterTests</c>: real repositories, real EF in-memory provider,
/// real Transport pipeline.
/// <para>
/// In-memory EF caveat: <see cref="DbContext.Database.BeginTransactionAsync"/>
/// is a no-op on this provider, so the rollback test depends on ApplyAsync's
/// implementation deferring <c>SaveChangesAsync</c> to a single call just
/// before <c>CommitAsync</c>. The implementation enforces that contract +
/// calls <c>ChangeTracker.Clear()</c> on the catch path to defend against
/// in-memory bleed-through; the rollback test asserts via row counts that the
/// invariant holds.
/// </para>
/// </summary>
public sealed class BundleImporterApplyTests : IDisposable
{
    private readonly ServiceProvider _provider;
    private readonly RecordingScriptArtifactChangeBus _artifactBus = new();

    public BundleImporterApplyTests()
    {
        var services = new ServiceCollection();
        services.AddSingleton<IConfiguration>(
            new ConfigurationBuilder().AddInMemoryCollection().Build());

        var dbName = $"BundleImporterApplyTests_{Guid.NewGuid()}";
        // In-memory provider throws by default when BeginTransactionAsync is
        // called (InMemoryEventId.TransactionIgnoredWarning is escalated to an
        // exception). ApplyAsync legitimately opens a transaction for
        // relational providers; downgrade the warning here so the in-memory
        // run is a no-op and the rest of the apply runs through. See the
        // ApplyAsync XML comment for the rollback-safety contract that makes
        // this safe (single deferred SaveChangesAsync + ChangeTracker.Clear
        // on catch).
        services.AddDbContext<ScadaBridgeDbContext>(opts => opts
            .UseInMemoryDatabase(dbName)
            .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));

        // S10b: secret-bearing columns (SmsConfiguration.AuthToken, SmtpConfiguration
        // .Credentials, …) require the context's encrypting two-arg ctor with a Data
        // Protection key ring. Register an ephemeral provider and override the
        // AddDbContext registration to construct the encrypting context — mirrors the
        // production AddConfigurationDatabase wiring (and BundleImporterRollbackFailureTests).
        services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
        services.AddScoped(sp => new ScadaBridgeDbContext(
            sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
            sp.GetRequiredService<IDataProtectionProvider>()));

        services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
        services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
        services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
        services.AddScoped<INotificationRepository, NotificationRepository>();
        services.AddScoped<IInboundApiRepository, InboundApiRepository>();
        // M8: DependencyResolver now injects ISiteRepository to walk the
        // site/data-connection/instance closure; register it or activation fails.
        services.AddScoped<ISiteRepository, SiteRepository>();
        services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
        // M9-T32: FlatteningPipeline gained an ISharedSchemaRepository constructor
        // dependency for schema-library $ref resolution; register the real repo so
        // AddDeploymentManager()'s IFlatteningPipeline → FlatteningPipeline activates.
        // SharedSchemaRepository only needs ScadaBridgeDbContext, which is already
        // registered above as the in-memory provider.
        services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
        services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
        services.AddScoped<IAuditService, AuditService>();
        services.AddTransport();
        // #05-T14: a recording bus so the post-commit ScriptArtifactsChanged
        // publish is observable. BundleImporter takes IScriptArtifactChangeBus?
        // optionally, so this registration flows in via DI activation.
        services.AddSingleton<IScriptArtifactChangeBus>(_artifactBus);
        // #16 (M8 D2): the stale-instance probe is implemented in DeploymentManager
        // and the flatten/hash primitives in TemplateEngine — register both so
        // BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can
        // compute StaleInstanceIds against genuine revision hashes.
        services.AddTemplateEngine();
        services.AddDeploymentManager();

        _provider = services.BuildServiceProvider();
    }

    public void Dispose() => _provider.Dispose();

    // ---- helpers ----

    /// <summary>
    /// Exports the entire seeded content as a bundle, then immediately loads it
    /// via <see cref="IBundleImporter.LoadAsync"/> and returns the opened
    /// session. Used by every test that needs a session to feed
    /// <see cref="IBundleImporter.ApplyAsync"/>. Selection is "all templates +
    /// all shared scripts" because the tests want the bundle to carry whatever
    /// the test seeded.
    /// </summary>
    private async Task<Guid> ExportAndLoadAsync()
    {
        Stream bundleStream;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
            var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
            var externalSystemIds = await ctx.ExternalSystemDefinitions.Select(e => e.Id).ToListAsync();
            var apiMethodIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
            var selection = new ExportSelection(
                TemplateIds: templateIds,
                SharedScriptIds: sharedScriptIds,
                ExternalSystemIds: externalSystemIds,
                DatabaseConnectionIds: Array.Empty<int>(),
                NotificationListIds: Array.Empty<int>(),
                SmtpConfigurationIds: Array.Empty<int>(),
                ApiMethodIds: apiMethodIds,
                IncludeDependencies: false);
            bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
                passphrase: null, cancellationToken: CancellationToken.None);
        }

        using var ms = new MemoryStream();
        await bundleStream.CopyToAsync(ms);
        ms.Position = 0;

        await using var loadScope = _provider.CreateAsyncScope();
        var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
        var session = await importer.LoadAsync(ms, passphrase: null);
        return session.SessionId;
    }

    private async Task WipeContentAsync()
    {
        await using var scope = _provider.CreateAsyncScope();
        var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
        ctx.Templates.RemoveRange(ctx.Templates);
        ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
        ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
        ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
        await ctx.SaveChangesAsync();
    }

    // ---- tests ----

    [Fact]
    public async Task ApplyAsync_adds_new_artifacts_in_single_transaction()
    {
        // Arrange: seed → export → wipe → apply. The wipe ensures the import
        // is exercising the Add path (the bundle's artifacts are absent from
        // the target).
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
            ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();
        await WipeContentAsync();

        // Act
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var resolutions = new List<ImportResolution>
            {
                new("Template", "Pump", ResolutionAction.Add, null),
                new("SharedScript", "HelperFn", ResolutionAction.Add, null),
            };
            result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
        }

        // Assert
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Pump"));
            Assert.Equal(1, await ctx.SharedScripts.CountAsync(s => s.Name == "HelperFn"));
        }
        Assert.Equal(2, result.Added);
        Assert.Equal(0, result.Overwritten);
        Assert.Equal(0, result.Skipped);
        Assert.NotEqual(Guid.Empty, result.BundleImportId);
    }

    [Fact]
    public async Task ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite()
    {
        // Arrange: seed Pump with Description=new, export, then mutate to
        // Description=old. The bundle still carries "new". Overwrite must
        // restore the description.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "new" });
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
            t.Description = "old";
            await ctx.SaveChangesAsync();
        }

        // Act
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
                user: "bob");
        }

        // Assert
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
            Assert.Equal("new", t.Description);
        }
        Assert.Equal(1, result.Overwritten);
    }

    [Fact]
    public async Task ApplyAsync_skips_artifact_when_resolution_is_Skip()
    {
        // Arrange: identical seed + bundle; Skip resolution should leave
        // target unchanged and bump Skipped count.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "stable" });
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();

        // Act
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Skip, null) },
                user: "bob");
        }

        // Assert
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            // Exactly one Pump still, with Description unchanged.
            var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
            Assert.Equal("stable", t.Description);
        }
        Assert.Equal(1, result.Skipped);
        Assert.Equal(0, result.Added);
        Assert.Equal(0, result.Overwritten);
    }

    [Fact]
    public async Task ApplyAsync_renames_artifact_when_resolution_is_Rename()
    {
        // Arrange: seed X, export, wipe so the Rename target Y doesn't
        // collide. Apply Rename X→Y.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("X") { Description = "orig" });
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();
        await WipeContentAsync();

        // Act
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("Template", "X", ResolutionAction.Rename, "Y") },
                user: "bob");
        }

        // Assert
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            Assert.Equal(0, await ctx.Templates.CountAsync(t => t.Name == "X"));
            Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Y"));
        }
        Assert.Equal(1, result.Renamed);
    }

    [Fact]
    public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails()
    {
        // Arrange: seed an ApiMethod whose script body calls MissingHelper().
        // No SharedScript by that name exists in source or (after wipe) in the
        // target. ApiMethod name-resolution findings are HARD errors (unlike
        // template-script findings, which are advisory as of #05-T19), so this
        // must reject the apply and roll everything back.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();
        await WipeContentAsync();

        // Act
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            await Assert.ThrowsAsync<SemanticValidationException>(() =>
                importer.ApplyAsync(sessionId,
                    new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
                    user: "bob"));
        }

        // Assert — target still wiped (ApiMethod not committed), AND a
        // BundleImportFailed row exists.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            Assert.Equal(0, await ctx.ApiMethods.CountAsync());
            Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
        }

        // T-007: a failed apply used to keep the BundleSession (and its decrypted
        // secrets) in the in-memory store for the full 30-minute TTL. The session
        // must now be removed immediately so the plaintext is released.
        var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
        Assert.Null(sessionStore.Get(sessionId));
    }

    [Fact]
    public async Task ApplyAsync_removes_session_on_success_path_too()
    {
        // T-007: companion to the failed-apply test — the success path must also
        // remove the session (it was already doing so before T-007, but the new
        // test asserts the contract explicitly so a future refactor cannot
        // accidentally leave plaintext in the store).
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("PumpForT007") { Description = "fresh" });
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();
        await WipeContentAsync();

        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("Template", "PumpForT007", ResolutionAction.Add, null) },
                user: "alice");
        }

        var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
        Assert.Null(sessionStore.Get(sessionId));
    }

    [Fact]
    public async Task ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row()
    {
        // The correlation guarantee — every per-entity audit row emitted during
        // ApplyAsync must carry the same BundleImportId as the returned result.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
            ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();
        await WipeContentAsync();
        // Snapshot the audit-row ids before the apply so the assertion only
        // looks at rows the apply itself emitted (the export wrote a
        // BundleExported row too, with no BundleImportId — that's correct, it
        // wasn't part of an import).
        int beforeMaxId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            beforeMaxId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
        }

        // Act
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution>
                {
                    new("Template", "Pump", ResolutionAction.Add, null),
                    new("SharedScript", "HelperFn", ResolutionAction.Add, null),
                },
                user: "bob");
        }

        // Assert
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var newRows = await ctx.AuditLogEntries.Where(a => a.Id > beforeMaxId).ToListAsync();
            // We expect at least: TemplateCreated + SharedScriptCreated + BundleImported.
            Assert.True(newRows.Count >= 3,
                $"Expected at least 3 new audit rows, got {newRows.Count}.");
            Assert.All(newRows, row =>
                Assert.Equal(result.BundleImportId, row.BundleImportId));
        }
    }

    [Fact]
    public async Task ApplyAsync_writes_BundleImported_summary_row_inside_transaction()
    {
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();
        await WipeContentAsync();

        // Act
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Add, null) },
                user: "bob");
        }

        // Assert: BundleImported row exists, has the right SourceEnvironment in
        // its AfterStateJson, and carries the BundleImportId from the result.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImported");
            Assert.NotNull(row);
            Assert.Equal("Bundle", row!.EntityType);
            Assert.Equal(result.BundleImportId, row.BundleImportId);
            Assert.NotNull(row.AfterStateJson);
            Assert.Contains("dev", row.AfterStateJson!, StringComparison.Ordinal);
            // Summary block in payload.
            Assert.Contains("Summary", row.AfterStateJson!, StringComparison.Ordinal);
        }
    }

    [Fact]
    public async Task ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction()
    {
        // Paired with the rollback test — the failure row IS present even
        // though every other write was rolled back, AND it carries
        // BundleImportId == null (the rolled-back id is intentionally
        // disowned from the failure record).
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();
        await WipeContentAsync();

        // Act
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            await Assert.ThrowsAsync<SemanticValidationException>(() =>
                importer.ApplyAsync(sessionId,
                    new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
                    user: "bob"));
        }

        // Assert
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImportFailed");
            Assert.NotNull(row);
            Assert.Equal("Bundle", row!.EntityType);
            // Correlation MUST be null on the failure row — the rolled-back
            // BundleImportId is intentionally disowned.
            Assert.Null(row.BundleImportId);
        }
    }

    [Fact]
    public async Task ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle()
    {
        // T-001 regression. The Overwrite branch used to write only Description
        // / FolderId on the existing template; the bundle's Attributes / Alarms
        // / Scripts collections were silently dropped on the floor. This test
        // seeds a template with one shape, exports it, mutates the target to a
        // divergent shape, then asserts that Overwrite restores every child
        // collection AND emits per-field audit rows.
        //
        // Bundle shape (exported from "Pump"):
        //   Attributes: [SetPoint (Float, 50.0), Pressure (Float, 100.0)]
        //   Alarms:     [HiAlarm (PriorityLevel=1)]
        //   Scripts:    [Init (Code="return 1;")]
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = new Template("Pump") { Description = "from-bundle" };
            t.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Float, Value = "50.0" });
            t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "100.0" });
            t.Alarms.Add(new TemplateAlarm("HiAlarm") { PriorityLevel = 1, TriggerType = AlarmTriggerType.ValueMatch });
            t.Scripts.Add(new TemplateScript("Init", "return 1;"));
            ctx.Templates.Add(t);
            await ctx.SaveChangesAsync();
        }

        var sessionId = await ExportAndLoadAsync();

        // Mutate the target so every child collection diverges from the bundle.
        //   Attributes: SetPoint value mutated, Pressure DELETED, NewAttr ADDED
        //   Alarms:     HiAlarm PriorityLevel mutated, ExtraAlarm ADDED
        //   Scripts:    Init code mutated, ExtraScript ADDED
        // Description also mutated so the scalar field still flips.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var pump = await ctx.Templates
                .Include(t => t.Attributes)
                .Include(t => t.Alarms)
                .Include(t => t.Scripts)
                .SingleAsync(t => t.Name == "Pump");
            pump.Description = "target-mutated";
            var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint");
            setPoint.Value = "999.0"; // mutated
            var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
            pump.Attributes.Remove(pressure);
            ctx.TemplateAttributes.Remove(pressure);
            pump.Attributes.Add(new TemplateAttribute("NewAttr")
            {
                DataType = DataType.String,
                Value = "should-be-deleted-by-overwrite",
            });
            var hiAlarm = pump.Alarms.Single(a => a.Name == "HiAlarm");
            hiAlarm.PriorityLevel = 99; // mutated
            pump.Alarms.Add(new TemplateAlarm("ExtraAlarm")
            {
                PriorityLevel = 5,
                TriggerType = AlarmTriggerType.RangeViolation,
            });
            var initScript = pump.Scripts.Single(s => s.Name == "Init");
            initScript.Code = "return 999;"; // mutated
            pump.Scripts.Add(new TemplateScript("ExtraScript", "return 0;"));
            await ctx.SaveChangesAsync();
        }

        // Capture the audit baseline so we can scope assertions to rows
        // emitted by THIS apply.
        int beforeMaxAuditId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
        }

        // Act — apply Overwrite.
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
                user: "bob");
        }

        // Assert — children mirror the bundle.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var pump = await ctx.Templates
                .Include(t => t.Attributes)
                .Include(t => t.Alarms)
                .Include(t => t.Scripts)
                .SingleAsync(t => t.Name == "Pump");

            Assert.Equal("from-bundle", pump.Description);

            // Attributes — exactly { SetPoint, Pressure }, values restored.
            Assert.Equal(2, pump.Attributes.Count);
            var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint");
            Assert.Equal("50.0", setPoint.Value);
            Assert.Equal(DataType.Float, setPoint.DataType);
            var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
            Assert.Equal("100.0", pressure.Value);
            Assert.DoesNotContain(pump.Attributes, a => a.Name == "NewAttr");

            // Alarms — exactly { HiAlarm }, PriorityLevel restored.
            Assert.Single(pump.Alarms);
            var hi = pump.Alarms.Single();
            Assert.Equal("HiAlarm", hi.Name);
            Assert.Equal(1, hi.PriorityLevel);
            Assert.DoesNotContain(pump.Alarms, a => a.Name == "ExtraAlarm");

            // Scripts — exactly { Init }, code restored.
            Assert.Single(pump.Scripts);
            var init = pump.Scripts.Single();
            Assert.Equal("Init", init.Name);
            Assert.Equal("return 1;", init.Code);
            Assert.DoesNotContain(pump.Scripts, s => s.Name == "ExtraScript");

            // Per-field audit rows — design doc enumerates Added / Updated /
            // Deleted shapes; all of these should appear, all stamped with
            // the BundleImportId from the result.
            var newRows = await ctx.AuditLogEntries
                .Where(a => a.Id > beforeMaxAuditId)
                .ToListAsync();
            Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId));

            // Attribute audit events.
            Assert.Contains(newRows, r => r.Action == "TemplateAttributeUpdated" && r.EntityName == "Pump.SetPoint");
            Assert.Contains(newRows, r => r.Action == "TemplateAttributeAdded" && r.EntityName == "Pump.Pressure");
            Assert.Contains(newRows, r => r.Action == "TemplateAttributeDeleted" && r.EntityName == "Pump.NewAttr");

            // Alarm audit events.
            Assert.Contains(newRows, r => r.Action == "TemplateAlarmUpdated" && r.EntityName == "Pump.HiAlarm");
            Assert.Contains(newRows, r => r.Action == "TemplateAlarmDeleted" && r.EntityName == "Pump.ExtraAlarm");

            // Script audit events.
            Assert.Contains(newRows, r => r.Action == "TemplateScriptUpdated" && r.EntityName == "Pump.Init");
            Assert.Contains(newRows, r => r.Action == "TemplateScriptDeleted" && r.EntityName == "Pump.ExtraScript");
        }
        Assert.Equal(1, result.Overwritten);
    }

    [Fact]
    public async Task ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle()
    {
        // T-002 regression. The ExternalSystem Overwrite branch used to write
        // only EndpointUrl / AuthType / AuthConfiguration on the existing
        // definition; the bundle's Methods collection was silently dropped on
        // the floor. This test seeds an external system with one method
        // shape, exports it, mutates the target's methods to diverge, then
        // asserts that Overwrite restores every method AND emits per-field
        // audit rows.
        //
        // Bundle shape (exported from "Erp"):
        //   Methods: [
        //     GetUser  (GET  /users/{id}, param=A, return=R),
        //     PostJob  (POST /jobs,       param=B, return=S),
        //   ]
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var sys = new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey");
            ctx.ExternalSystemDefinitions.Add(sys);
            await ctx.SaveChangesAsync();
            ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("GetUser", "GET", "/users/{id}")
            {
                ExternalSystemDefinitionId = sys.Id,
                ParameterDefinitions = "A",
                ReturnDefinition = "R",
            });
            ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("PostJob", "POST", "/jobs")
            {
                ExternalSystemDefinitionId = sys.Id,
                ParameterDefinitions = "B",
                ReturnDefinition = "S",
            });
            await ctx.SaveChangesAsync();
        }

        var sessionId = await ExportAndLoadAsync();

        // Mutate the target so the methods diverge from the bundle:
        //   GetUser   — Path mutated (UPDATE expected on Overwrite)
        //   PostJob   — DELETED (ADD expected on Overwrite to restore)
        //   ExtraOp   — ADDED   (DELETE expected on Overwrite to remove)
        // EndpointUrl / AuthType also mutated so the scalar update still fires.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
            sys.EndpointUrl = "https://wrong.example";
            sys.AuthType = "Basic";
            var getUser = await ctx.ExternalSystemMethods
                .SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "GetUser");
            getUser.Path = "/wrong/path";
            var postJob = await ctx.ExternalSystemMethods
                .SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "PostJob");
            ctx.ExternalSystemMethods.Remove(postJob);
            ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("ExtraOp", "DELETE", "/extra")
            {
                ExternalSystemDefinitionId = sys.Id,
                ParameterDefinitions = "X",
                ReturnDefinition = "Y",
            });
            await ctx.SaveChangesAsync();
        }

        // Capture the audit baseline so we can scope assertions to rows
        // emitted by THIS apply.
        int beforeMaxAuditId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
        }

        // Act — apply Overwrite on the external system.
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Overwrite, null) },
                user: "bob");
        }

        // Assert — methods mirror the bundle exactly.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
            Assert.Equal("https://erp.example", sys.EndpointUrl);

            var methods = await ctx.ExternalSystemMethods
                .Where(m => m.ExternalSystemDefinitionId == sys.Id)
                .ToListAsync();
            Assert.Equal(2, methods.Count);

            var getUser = methods.Single(m => m.Name == "GetUser");
            Assert.Equal("GET", getUser.HttpMethod);
            Assert.Equal("/users/{id}", getUser.Path);
            Assert.Equal("A", getUser.ParameterDefinitions);
            Assert.Equal("R", getUser.ReturnDefinition);

            var postJob = methods.Single(m => m.Name == "PostJob");
            Assert.Equal("POST", postJob.HttpMethod);
            Assert.Equal("/jobs", postJob.Path);
            Assert.Equal("B", postJob.ParameterDefinitions);
            Assert.Equal("S", postJob.ReturnDefinition);

            Assert.DoesNotContain(methods, m => m.Name == "ExtraOp");

            // Per-field audit rows — design doc enumerates Added / Updated /
            // Deleted shapes; all of these should appear, all stamped with
            // the BundleImportId from the result.
            var newRows = await ctx.AuditLogEntries
                .Where(a => a.Id > beforeMaxAuditId)
                .ToListAsync();
            Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId));

            Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodUpdated" && r.EntityName == "Erp.GetUser");
            Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodAdded"   && r.EntityName == "Erp.PostJob");
            Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodDeleted" && r.EntityName == "Erp.ExtraOp");
        }
        Assert.Equal(1, result.Overwritten);
    }

    // ─────────────────────────────────────────────────────────────────────
    // S10b: SMS provider config bundle apply. Mirrors the SMTP apply path —
    // create a new config (Add) and overwrite an existing one (matched by the
    // natural key AccountSid), with the auth token decrypted from the
    // SecretsBlock. Hand-packs a BundleContentDto with SmsConfigs set so the
    // test is decoupled from the export selection (covered separately).
    // ─────────────────────────────────────────────────────────────────────
    private async Task<Guid> PackAndLoadSmsBundleAsync(params SmsConfigDto[] smsConfigs)
    {
        var content = new BundleContentDto(
            TemplateFolders: Array.Empty<TemplateFolderDto>(),
            Templates: Array.Empty<TemplateDto>(),
            SharedScripts: Array.Empty<SharedScriptDto>(),
            ExternalSystems: Array.Empty<ExternalSystemDto>(),
            DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
            NotificationLists: Array.Empty<NotificationListDto>(),
            SmtpConfigs: Array.Empty<SmtpConfigDto>(),
            ApiMethods: Array.Empty<ApiMethodDto>())
        {
            SmsConfigs = smsConfigs,
        };

        await using var scope = _provider.CreateAsyncScope();
        var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
        var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
        var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();

        var contentBytes = serializer.SerializeContentBytes(content);
        var manifest = manifestBuilder.Build(
            sourceEnvironment: "dev",
            exportedBy: "alice",
            scadaBridgeVersion: "1.0.0",
            encryption: null,
            summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, SmsConfigs: smsConfigs.Length),
            contents: Array.Empty<ManifestContentEntry>(),
            contentBytes: contentBytes);
        await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);

        using var ms = new MemoryStream();
        await packed.CopyToAsync(ms);
        ms.Position = 0;
        var session = await importer.LoadAsync(ms, passphrase: null);
        return session.SessionId;
    }

    [Fact]
    public async Task ApplyAsync_creates_new_sms_config_with_decrypted_secret()
    {
        var dto = new SmsConfigDto(
            AccountSid: "AC_new_sid",
            FromNumber: "+15551112222",
            MessagingServiceSid: "MG_svc",
            ApiBaseUrl: "https://api.twilio.example",
            ConnectionTimeoutSeconds: 30,
            MaxRetries: 10,
            RetryDelay: TimeSpan.FromMinutes(1),
            Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["AuthToken"] = "fresh-token",
            }));
        var sessionId = await PackAndLoadSmsBundleAsync(dto);

        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("SmsConfiguration", "AC_new_sid", ResolutionAction.Add, null) },
                user: "bob");
        }

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var saved = await ctx.Set<Commons.Entities.Notifications.SmsConfiguration>()
                .SingleAsync(s => s.AccountSid == "AC_new_sid");
            Assert.Equal("+15551112222", saved.FromNumber);
            Assert.Equal("MG_svc", saved.MessagingServiceSid);
            Assert.Equal("fresh-token", saved.AuthToken);
        }
        Assert.Equal(1, result.Added);
    }

    [Fact]
    public async Task ApplyAsync_overwrites_existing_sms_config_matched_by_account_sid()
    {
        // Seed an existing config with the SAME AccountSid but stale field + secret.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Set<Commons.Entities.Notifications.SmsConfiguration>().Add(
                new Commons.Entities.Notifications.SmsConfiguration("AC_existing_sid", "+15550000000")
                {
                    AuthToken = "old-token",
                    ApiBaseUrl = "https://old.example",
                });
            await ctx.SaveChangesAsync();
        }

        var dto = new SmsConfigDto(
            AccountSid: "AC_existing_sid",
            FromNumber: "+15559998888",
            MessagingServiceSid: null,
            ApiBaseUrl: "https://new.example",
            ConnectionTimeoutSeconds: 45,
            MaxRetries: 5,
            RetryDelay: TimeSpan.FromMinutes(2),
            Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
            {
                ["AuthToken"] = "new-token",
            }));
        var sessionId = await PackAndLoadSmsBundleAsync(dto);

        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("SmsConfiguration", "AC_existing_sid", ResolutionAction.Overwrite, null) },
                user: "bob");
        }

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            // Exactly one row for this AccountSid — Overwrite updated in place, no dup.
            var saved = await ctx.Set<Commons.Entities.Notifications.SmsConfiguration>()
                .SingleAsync(s => s.AccountSid == "AC_existing_sid");
            Assert.Equal("+15559998888", saved.FromNumber);
            Assert.Equal("https://new.example", saved.ApiBaseUrl);
            Assert.Equal("new-token", saved.AuthToken);
        }
        Assert.Equal(1, result.Overwritten);
        Assert.Equal(0, result.Added);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Re-arch C4 backward-compat: a LEGACY (pre-C4) bundle still carries an
    // ApiKeys section. The importer must ignore those keys gracefully — it must
    // NOT fail to parse, must NOT create any ApiKey rows, and must surface the
    // ignored-key count on the result (so the operator knows to re-issue keys).
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task ApplyAsync_ignores_legacy_api_keys_in_bundle_without_failing()
    {
        // Arrange: hand-pack a legacy bundle whose content JSON contains an
        // ApiKeys array plus one API method. New exports never emit ApiKeys, so
        // we build the BundleContentDto directly with a populated (non-null)
        // legacy ApiKeys list to faithfully simulate a pre-C4 file.
        var legacyContent = new BundleContentDto(
            TemplateFolders: Array.Empty<TemplateFolderDto>(),
            Templates: Array.Empty<TemplateDto>(),
            SharedScripts: Array.Empty<SharedScriptDto>(),
            ExternalSystems: Array.Empty<ExternalSystemDto>(),
            DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
            NotificationLists: Array.Empty<NotificationListDto>(),
            SmtpConfigs: Array.Empty<SmtpConfigDto>(),
            ApiMethods: new[]
            {
                new ApiMethodDto("CreateOrder", "return 1;",
                    ParameterDefinitions: null, ReturnDefinition: null, TimeoutSeconds: 30),
            },
            ApiKeys: new[]
            {
                new ApiKeyDto("legacy-key-a", "hash-a", IsEnabled: true, Secrets: null),
                new ApiKeyDto("legacy-key-b", "hash-b", IsEnabled: false, Secrets: null),
            });

        Guid sessionId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
            var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();

            var contentBytes = serializer.SerializeContentBytes(legacyContent);

            // Sanity: the packed content really does carry an ApiKeys section, so
            // the test is exercising the legacy-ignore path rather than a no-op.
            var contentJson = System.Text.Encoding.UTF8.GetString(contentBytes);
            Assert.Contains("legacy-key-a", contentJson);

            var manifest = manifestBuilder.Build(
                sourceEnvironment: "legacy-env",
                exportedBy: "alice",
                scadaBridgeVersion: "0.9.0",
                encryption: null,
                summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 1),
                contents: Array.Empty<ManifestContentEntry>(),
                contentBytes: contentBytes);
            await using var packed = serializer.Pack(legacyContent, manifest, passphrase: null, encryptor: null);

            using var ms = new MemoryStream();
            await packed.CopyToAsync(ms);
            ms.Position = 0;
            var session = await importer.LoadAsync(ms, passphrase: null);
            sessionId = session.SessionId;
        }

        // Act — apply with a resolution only for the method; the keys carry no
        // resolutions (the preview never surfaces them) and must be ignored.
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("ApiMethod", "CreateOrder", ResolutionAction.Add, null) },
                user: "bob");
        }

        // Assert — the method WAS created, the ignored count is surfaced, and the
        // import did not fault. Auth re-arch (C5): the SQL Server ApiKey store was
        // retired, so "no keys created" is now structural — the importer has no key
        // sink at all; the legacy ApiKeys section is counted (ApiKeysIgnored) and
        // discarded.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var inboundRepo = scope.ServiceProvider.GetRequiredService<IInboundApiRepository>();
            var methods = await inboundRepo.GetAllApiMethodsAsync();
            Assert.Single(methods);
            Assert.Equal("CreateOrder", methods[0].Name);
        }

        Assert.Equal(2, result.ApiKeysIgnored);
        Assert.Equal(1, result.Added);     // the API method
        Assert.NotEqual(Guid.Empty, result.BundleImportId);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Fix I-3: PreviewAsync must also tolerate a legacy bundle that carries
    // an ApiKeys section without surfacing those keys as importable preview
    // rows. Mirror the ApplyAsync_ignores_legacy_api_keys_in_bundle_without_failing
    // setup: hand-pack a legacy bundle with a populated ApiKeys section plus
    // one ApiMethod, then assert that PreviewAsync completes without fault,
    // surfaces no ApiKey/key preview items, and DOES surface the ApiMethod
    // preview item.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task PreviewAsync_on_legacy_bundle_does_not_surface_key_items()
    {
        // Arrange: hand-pack a legacy bundle whose content JSON contains an
        // ApiKeys array plus one API method. New exports never emit ApiKeys,
        // so we build the BundleContentDto directly with a populated (non-null)
        // legacy ApiKeys list to faithfully simulate a pre-C4 file.
        var legacyContent = new BundleContentDto(
            TemplateFolders: Array.Empty<TemplateFolderDto>(),
            Templates: Array.Empty<TemplateDto>(),
            SharedScripts: Array.Empty<SharedScriptDto>(),
            ExternalSystems: Array.Empty<ExternalSystemDto>(),
            DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
            NotificationLists: Array.Empty<NotificationListDto>(),
            SmtpConfigs: Array.Empty<SmtpConfigDto>(),
            ApiMethods: new[]
            {
                new ApiMethodDto("GetStatus", "return 0;",
                    ParameterDefinitions: null, ReturnDefinition: null, TimeoutSeconds: 30),
            },
            ApiKeys: new[]
            {
                new ApiKeyDto("legacy-key-x", "hash-x", IsEnabled: true, Secrets: null),
            });

        Guid sessionId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
            var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();

            var contentBytes = serializer.SerializeContentBytes(legacyContent);

            // Sanity: the packed content really does carry an ApiKeys section,
            // so the test is exercising the legacy-ignore path rather than a no-op.
            var contentJson = System.Text.Encoding.UTF8.GetString(contentBytes);
            Assert.Contains("legacy-key-x", contentJson);

            var manifest = manifestBuilder.Build(
                sourceEnvironment: "legacy-env",
                exportedBy: "alice",
                scadaBridgeVersion: "0.9.0",
                encryption: null,
                summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 1),
                contents: Array.Empty<ManifestContentEntry>(),
                contentBytes: contentBytes);
            await using var packed = serializer.Pack(legacyContent, manifest, passphrase: null, encryptor: null);

            using var ms = new MemoryStream();
            await packed.CopyToAsync(ms);
            ms.Position = 0;
            var session = await importer.LoadAsync(ms, passphrase: null);
            sessionId = session.SessionId;
        }

        // Act — call PreviewAsync; must not throw.
        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            preview = await importer.PreviewAsync(sessionId);
        }

        // Assert — no ApiKey preview rows surfaced (keys are not transported).
        Assert.DoesNotContain(preview.Items, item =>
            item.EntityType.Contains("Key", StringComparison.OrdinalIgnoreCase) ||
            item.Name.Contains("legacy-key", StringComparison.OrdinalIgnoreCase));

        // The one ApiMethod IS surfaced as a New item (method does not exist in
        // the target DB, so ConflictKind.New is expected).
        Assert.Contains(preview.Items, item =>
            item.EntityType == "ApiMethod" && item.Name == "GetStatus");
    }

    // ─────────────────────────────────────────────────────────────────────
    // #16 (M8 D2): StaleInstanceIds enumeration. Overwriting a template changes
    // its flattened-config hash, so deployed instances of that template drift
    // from their DeployedConfigSnapshot.RevisionHash and must surface as stale
    // in the import result. NotDeployed (incl. freshly-imported) instances are
    // never stale.
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Seeds an Enabled instance of <paramref name="templateName"/> and a
    /// <see cref="DeployedConfigSnapshot"/> whose RevisionHash is the GENUINE hash
    /// the deployment flattening pipeline computes for the instance's CURRENT
    /// (pre-import) config. Returns the instance id. Mirrors how the deploy path
    /// captures a snapshot so the staleness comparison is faithful.
    /// </summary>
    private async Task<int> SeedDeployedInstanceWithRealSnapshotAsync(
        string templateName, string instanceName)
    {
        int instanceId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var template = await ctx.Templates.SingleAsync(t => t.Name == templateName);
            var instance = new Instance(instanceName)
            {
                TemplateId = template.Id,
                SiteId = (await ctx.Sites.FirstAsync()).Id,
                State = InstanceState.Enabled,
            };
            ctx.Instances.Add(instance);
            await ctx.SaveChangesAsync();
            instanceId = instance.Id;

            // Compute the REAL current revision hash via the same pipeline the
            // deploy path uses, then persist it as the deployed snapshot so the
            // import's drift comparison runs against a genuine baseline.
            var pipeline = scope.ServiceProvider.GetRequiredService<IFlatteningPipeline>();
            var flattened = await pipeline.FlattenAndValidateAsync(instanceId);
            Assert.True(flattened.IsSuccess,
                $"Seed flatten failed: {(flattened.IsFailure ? flattened.Error : "(success)")}");
            ctx.DeployedConfigSnapshots.Add(new DeployedConfigSnapshot(
                deploymentId: Guid.NewGuid().ToString(),
                revisionHash: flattened.Value.RevisionHash,
                configurationJson: System.Text.Json.JsonSerializer.Serialize(flattened.Value.Configuration))
            {
                InstanceId = instanceId,
                DeployedAt = DateTimeOffset.UtcNow,
            });
            await ctx.SaveChangesAsync();
        }
        return instanceId;
    }

    private async Task<int> SeedSiteAsync()
    {
        await using var scope = _provider.CreateAsyncScope();
        var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
        var site = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("Plant 1", "plant-1")
        {
            NodeAAddress = "akka://site@10.0.0.1:2552",
            NodeBAddress = "akka://site@10.0.0.2:2552",
        };
        ctx.Sites.Add(site);
        await ctx.SaveChangesAsync();
        return site.Id;
    }

    [Fact]
    public async Task ApplyAsync_overwriting_template_marks_deployed_instance_stale()
    {
        // Arrange — seed Pump with the BUNDLE's (new) attribute shape, export it,
        // then mutate the target template + reseed the deployed snapshot to the
        // OLD shape so an Overwrite restores the new shape and drifts the deployed
        // instance off its captured hash.
        await SeedSiteAsync();
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = new Template("Pump") { Description = "v2" };
            t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
            t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" }); // extra attr in bundle
            ctx.Templates.Add(t);
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();

        // Mutate the target template to the OLD shape (drop Pressure, change Flow),
        // then deploy an instance capturing the OLD config's hash.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
            t.Description = "v1";
            var pressure = t.Attributes.Single(a => a.Name == "Pressure");
            t.Attributes.Remove(pressure);
            ctx.TemplateAttributes.Remove(pressure);
            t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
            await ctx.SaveChangesAsync();
        }
        var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");

        // Act — Overwrite Pump back to the bundle's v2 shape.
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
                user: "bob");
        }

        // Assert — the deployed instance drifted off its snapshot hash and is stale.
        Assert.Equal(1, result.Overwritten);
        Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
    }

    [Fact]
    public async Task ApplyAsync_overwriting_template_does_not_mark_unchanged_template_instance_stale()
    {
        // Two templates: Pump (overwritten with a CHANGED shape) and Valve
        // (overwritten with an IDENTICAL shape — its instance must NOT be stale).
        // The export carries both at their seeded shape; we mutate only Pump in
        // the target so its overwrite changes the hash, while Valve's overwrite
        // is a no-op hash-wise.
        await SeedSiteAsync();
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var pump = new Template("Pump") { Description = "v2" };
            pump.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
            pump.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" });
            ctx.Templates.Add(pump);
            var valve = new Template("Valve") { Description = "stable" };
            valve.Attributes.Add(new TemplateAttribute("Position") { DataType = DataType.Float, Value = "0.5" });
            ctx.Templates.Add(valve);
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();

        // Mutate ONLY Pump in the target so its overwrite changes the hash; leave
        // Valve identical to the bundle so its overwrite is hash-neutral.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var pump = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
            var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
            pump.Attributes.Remove(pressure);
            ctx.TemplateAttributes.Remove(pressure);
            await ctx.SaveChangesAsync();
        }
        var pumpInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
        var valveInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Valve", "Valve-Deployed");

        // Act — Overwrite BOTH templates.
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution>
                {
                    new("Template", "Pump", ResolutionAction.Overwrite, null),
                    new("Template", "Valve", ResolutionAction.Overwrite, null),
                },
                user: "bob");
        }

        // Assert — only Pump's deployed instance is stale; Valve's is not.
        Assert.Contains(pumpInstanceId, result.StaleInstanceIds);
        Assert.DoesNotContain(valveInstanceId, result.StaleInstanceIds);
    }

    [Fact]
    public async Task ApplyAsync_freshly_imported_NotDeployed_instance_is_never_stale()
    {
        // A bundle that carries a template AND an instance of it: the imported
        // instance enters the target NotDeployed (no snapshot), so even though the
        // template is "new" to the target it must NOT appear in StaleInstanceIds.
        // We also overwrite an EXISTING deployed instance's template to prove the
        // stale list contains the deployed one but never the fresh one.
        await SeedSiteAsync();
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = new Template("Pump") { Description = "v2" };
            t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
            ctx.Templates.Add(t);
            await ctx.SaveChangesAsync();
        }
        var sessionId = await ExportAndLoadAsync();

        // Mutate the target template, then deploy an instance capturing the OLD hash.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
            t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
            await ctx.SaveChangesAsync();
        }
        var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");

        // Also seed a NotDeployed instance of the same template (simulates a
        // never-deployed / freshly-imported instance) — it has no snapshot.
        int notDeployedInstanceId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
            var fresh = new Instance("Pump-Fresh")
            {
                TemplateId = t.Id,
                SiteId = (await ctx.Sites.FirstAsync()).Id,
                State = InstanceState.NotDeployed,
            };
            ctx.Instances.Add(fresh);
            await ctx.SaveChangesAsync();
            notDeployedInstanceId = fresh.Id;
        }

        // Act — Overwrite Pump.
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
                user: "bob");
        }

        // Assert — deployed instance is stale; the NotDeployed one is excluded.
        Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
        Assert.DoesNotContain(notDeployedInstanceId, result.StaleInstanceIds);
    }

    /// <summary>
    /// Two consecutive imports overwriting the SAME template must both report the
    /// deployed instance as stale.
    ///
    /// <para>
    /// <c>StaleInstanceProbe</c> memoises its computed revision hash against
    /// <c>ITemplateGraphWatermark</c> readings, in a PROCESS-STATIC dictionary. The
    /// importer commits through the raw <c>ScadaBridgeDbContext</c>, bypassing
    /// <c>TemplateEngineRepository.SaveChangesAsync</c> — the only place that
    /// otherwise derives watermark bumps from the change tracker — so before the
    /// fix nothing an import wrote ever moved the watermark. The second import then
    /// re-served the FIRST import's hash and the instance silently dropped out of
    /// <c>ImportResult.StaleInstanceIds</c>: the operator is told nothing needs
    /// redeploying while the site runs a config that has drifted twice.
    /// </para>
    ///
    /// <para>
    /// The snapshot is refreshed between the two imports (simulating the redeploy
    /// the first stale report prompts), so the second import's staleness verdict
    /// depends entirely on a FRESH hash rather than on the leftover drift.
    /// </para>
    /// </summary>
    [Fact]
    public async Task ApplyAsync_second_overwrite_of_same_template_still_marks_instance_stale()
    {
        // The memo is process-static and keyed by instance id alone; start clean so
        // this test does not inherit another class's entry.
        StaleInstanceProbe.ClearMemos();

        await SeedSiteAsync();

        // Shape A → bundle A.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = new Template("Pump") { Description = "shape-a" };
            t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
            ctx.Templates.Add(t);
            await ctx.SaveChangesAsync();
        }
        var sessionA = await ExportAndLoadAsync();

        // Shape B → bundle B (a DIFFERENT flattened output from shape A).
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
            t.Description = "shape-b";
            t.Attributes.Single(a => a.Name == "Flow").Value = "2.0";
            await ctx.SaveChangesAsync();
        }
        var sessionB = await ExportAndLoadAsync();

        // Target starts on a third shape, and the deployed snapshot captures it.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
            t.Description = "shape-target";
            t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
            await ctx.SaveChangesAsync();
        }
        var instanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");

        // Import #1 — Overwrite to shape A. Drifts the instance off its snapshot.
        ImportResult first;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            first = await importer.ApplyAsync(sessionA,
                new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
                user: "bob");
        }
        Assert.Contains(instanceId, first.StaleInstanceIds);

        // The operator redeploys: the snapshot now matches the post-import-#1 config,
        // so the instance is genuinely up to date going into import #2.
        await RefreshDeployedSnapshotAsync(instanceId);

        // Import #2 — Overwrite the SAME template to shape B. The instance drifts
        // again and must be reported again.
        ImportResult second;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            second = await importer.ApplyAsync(sessionB,
                new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
                user: "bob");
        }

        Assert.Equal(1, second.Overwritten);
        Assert.Contains(instanceId, second.StaleInstanceIds);
    }

    /// <summary>
    /// Re-points the instance's <see cref="DeployedConfigSnapshot"/> at its CURRENT
    /// flattened revision hash — the state a successful redeploy would leave behind.
    /// </summary>
    /// <param name="instanceId">Instance whose snapshot is refreshed.</param>
    /// <returns>A task that completes once the snapshot is updated.</returns>
    private async Task RefreshDeployedSnapshotAsync(int instanceId)
    {
        await using var scope = _provider.CreateAsyncScope();
        var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
        var pipeline = scope.ServiceProvider.GetRequiredService<IFlatteningPipeline>();

        var flattened = await pipeline.FlattenAndValidateAsync(instanceId);
        Assert.True(flattened.IsSuccess,
            $"Snapshot refresh flatten failed: {(flattened.IsFailure ? flattened.Error : "(success)")}");

        var snapshot = await ctx.DeployedConfigSnapshots.SingleAsync(s => s.InstanceId == instanceId);
        snapshot.RevisionHash = flattened.Value.RevisionHash;
        snapshot.ConfigurationJson = System.Text.Json.JsonSerializer.Serialize(flattened.Value.Configuration);
        await ctx.SaveChangesAsync();
    }

    // ============ #05-T14: post-commit ScriptArtifactsChanged publish ============

    [Fact]
    public async Task ApplyAsync_publishes_ScriptArtifactsChanged_per_kind_after_commit()
    {
        // Seed a Template, a SharedScript and an ApiMethod; Overwrite all three.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = new Template("Pump") { Description = "tpl" };
            t.Scripts.Add(new TemplateScript("init", "return 1;"));
            ctx.Templates.Add(t);
            ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
            ctx.ApiMethods.Add(new ApiMethod("DoThing", "return 1;") { TimeoutSeconds = 30 });
            await ctx.SaveChangesAsync();
        }

        Guid sessionId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var selection = new ExportSelection(
                TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(),
                SharedScriptIds: await ctx.SharedScripts.Select(s => s.Id).ToListAsync(),
                ExternalSystemIds: Array.Empty<int>(),
                DatabaseConnectionIds: Array.Empty<int>(),
                NotificationListIds: Array.Empty<int>(),
                SmtpConfigurationIds: Array.Empty<int>(),
                ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
                IncludeDependencies: false);
            var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
                passphrase: null, cancellationToken: CancellationToken.None);
            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            ms.Position = 0;
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(ms, passphrase: null);
            sessionId = session.SessionId;
        }

        // Act — Overwrite all three (they still exist in the target).
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            await importer.ApplyAsync(sessionId,
                new List<ImportResolution>
                {
                    new("Template", "Pump", ResolutionAction.Overwrite, null),
                    new("SharedScript", "HelperFn", ResolutionAction.Overwrite, null),
                    new("ApiMethod", "DoThing", ResolutionAction.Overwrite, null),
                },
                user: "bob");
        }

        // Assert — exactly one notification per kind, post-commit, with the names.
        var byKind = _artifactBus.Received.ToDictionary(n => n.ArtifactKind);
        Assert.Equal(3, _artifactBus.Received.Count);
        Assert.Equal(new[] { "DoThing" }, byKind[ScriptArtifactKinds.ApiMethod].Names);
        Assert.Equal(new[] { "HelperFn" }, byKind[ScriptArtifactKinds.SharedScript].Names);
        Assert.Equal(new[] { "Pump" }, byKind[ScriptArtifactKinds.Template].Names);
        Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source));
    }

    [Fact]
    public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions()
    {
        // ApiMethod imported as Add into a target where the name does not exist.
        // Delete-then-reimport-as-Add is the N3 edge: a node can still hold a
        // _knownBadMethods / compiled-handler entry under that very name, so Adds
        // must notify too (over-approximation is safe per the bus contract).
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.ApiMethods.Add(new ApiMethod("DelmiaRecipeDownload", "return 1;") { TimeoutSeconds = 30 });
            await ctx.SaveChangesAsync();
        }

        Guid sessionId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var selection = new ExportSelection(
                TemplateIds: Array.Empty<int>(),
                SharedScriptIds: Array.Empty<int>(),
                ExternalSystemIds: Array.Empty<int>(),
                DatabaseConnectionIds: Array.Empty<int>(),
                NotificationListIds: Array.Empty<int>(),
                SmtpConfigurationIds: Array.Empty<int>(),
                ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
                IncludeDependencies: false);
            var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
                passphrase: null, cancellationToken: CancellationToken.None);
            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            ms.Position = 0;

            // Delete the target method so the resolution is a genuine Add.
            ctx.ApiMethods.RemoveRange(await ctx.ApiMethods.ToListAsync());
            await ctx.SaveChangesAsync();

            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            sessionId = (await importer.LoadAsync(ms, passphrase: null)).SessionId;
        }

        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            await importer.ApplyAsync(sessionId,
                new List<ImportResolution> { new("ApiMethod", "DelmiaRecipeDownload", ResolutionAction.Add, null) },
                user: "bob");
        }

        var notification = Assert.Single(_artifactBus.Received,
            n => n.ArtifactKind == ScriptArtifactKinds.ApiMethod);
        Assert.Contains("DelmiaRecipeDownload", notification.Names);   // FAILS today: Adds are filtered out
    }

    [Fact]
    public async Task ApplyAsync_failed_apply_publishes_nothing()
    {
        // An ApiMethod whose script calls an unresolved helper fails semantic
        // validation BEFORE commit (ApiMethod findings are hard errors), so
        // nothing is published.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.ApiMethods.Add(new ApiMethod("Bad", "var x = UnknownHelper();"));
            await ctx.SaveChangesAsync();
        }

        Guid sessionId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var selection = new ExportSelection(
                TemplateIds: Array.Empty<int>(),
                SharedScriptIds: Array.Empty<int>(),
                ExternalSystemIds: Array.Empty<int>(),
                DatabaseConnectionIds: Array.Empty<int>(),
                NotificationListIds: Array.Empty<int>(),
                SmtpConfigurationIds: Array.Empty<int>(),
                ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
                IncludeDependencies: false);
            var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
                passphrase: null, cancellationToken: CancellationToken.None);
            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            ms.Position = 0;
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            sessionId = (await importer.LoadAsync(ms, passphrase: null)).SessionId;
        }

        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            await Assert.ThrowsAsync<SemanticValidationException>(() =>
                importer.ApplyAsync(sessionId,
                    new List<ImportResolution> { new("ApiMethod", "Bad", ResolutionAction.Add, null) },
                    user: "bob"));
        }

        Assert.Empty(_artifactBus.Received);
    }

    /// <summary>Records every published notification for assertion.</summary>
    private sealed class RecordingScriptArtifactChangeBus : IScriptArtifactChangeBus
    {
        public List<ScriptArtifactsChanged> Received { get; } = new();

        public void Publish(ScriptArtifactsChanged notification) => Received.Add(notification);

        public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler) =>
            throw new NotSupportedException("The recording bus does not support subscriptions.");
    }
}
