using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
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.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
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.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 the M8 D1 site/instance-scoped apply path of
/// <see cref="ZB.MOM.WW.ScadaBridge.Transport.Import.BundleImporter.ApplyAsync"/>:
/// resolve-or-create target sites + data connections from a <see cref="BundleNameMap"/>,
/// upsert instances, and rewire every cross-environment FK (connection-binding
/// <c>DataConnectionId</c>, native-alarm-source <c>ConnectionNameOverride</c>) onto
/// the target's surrogate keys.
/// <para>
/// Reuses the in-memory host pattern from <c>BundleImporterApplyTests</c> /
/// <c>BundleImporterPreviewTests</c>: real repositories, real EF in-memory provider,
/// real Transport pipeline. Bundles are produced by the real exporter (site closure)
/// or hand-packed via <see cref="BundleSerializer"/> for negative cases.
/// </para>
/// </summary>
public sealed class SiteInstanceImportTests : IDisposable
{
    private readonly ServiceProvider _provider;

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

        var dbName = $"SiteInstanceImportTests_{Guid.NewGuid()}";
        // Same in-memory caveat as BundleImporterApplyTests: ApplyAsync opens a
        // transaction (no-op on in-memory) and defers the single real
        // SaveChangesAsync to just before CommitAsync; intermediate flushes are
        // undone on the catch path via ChangeTracker.Clear(). Downgrade the
        // transaction-ignored warning so the in-memory run proceeds.
        services.AddDbContext<ScadaBridgeDbContext>(opts => opts
            .UseInMemoryDatabase(dbName)
            .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));

        services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
        services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
        services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
        services.AddScoped<INotificationRepository, NotificationRepository>();
        services.AddScoped<IInboundApiRepository, InboundApiRepository>();
        services.AddScoped<ISiteRepository, SiteRepository>();
        services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
        services.AddScoped<IAuditService, AuditService>();
        services.AddTransport();

        _provider = services.BuildServiceProvider();
    }

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

    // ──────────────────────────────────────────────────────────────────────
    // Helpers
    // ──────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Seeds a full site closure (template + site + site-scoped data connection +
    /// instance bound to that connection, with an attribute override, an alarm
    /// override and a native-alarm-source override whose ConnectionNameOverride
    /// names the seeded connection) so the export carries every FK shape D1 must
    /// rewire. The instance is seeded <see cref="InstanceState.Enabled"/> so the
    /// import's NotDeployed reset is observable.
    /// </summary>
    private async Task SeedSiteClosureAsync(
        string siteIdentifier = "plant-1",
        string siteName = "Plant 1",
        string connectionName = "OpcUaPrimary",
        string templateName = "Pump",
        string instanceName = "Pump-01")
    {
        await using var scope = _provider.CreateAsyncScope();
        var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

        var template = new Template(templateName) { Description = "pump tpl" };
        template.Attributes.Add(new TemplateAttribute("Flow") { Value = "0" });
        ctx.Templates.Add(template);

        var site = new Site(siteName, siteIdentifier)
        {
            Description = "primary plant",
            NodeAAddress = "akka://site@10.0.0.1:2552",
            NodeBAddress = "akka://site@10.0.0.2:2552",
            GrpcNodeAAddress = "10.0.0.1:8083",
            GrpcNodeBAddress = "10.0.0.2:8083",
        };
        ctx.Sites.Add(site);
        await ctx.SaveChangesAsync();

        var conn = new DataConnection(connectionName, "OpcUa", site.Id)
        {
            PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
            BackupConfiguration = "{\"endpoint\":\"opc.tcp://backup\"}",
            FailoverRetryCount = 5,
        };
        ctx.DataConnections.Add(conn);

        var instance = new Instance(instanceName)
        {
            TemplateId = template.Id,
            SiteId = site.Id,
            State = InstanceState.Enabled,
        };
        instance.AttributeOverrides.Add(new InstanceAttributeOverride("Flow") { OverrideValue = "42" });
        instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm") { PriorityLevelOverride = 7 });
        instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
        {
            ConnectionNameOverride = connectionName,
            SourceReferenceOverride = "ns=3;s=Pump.Alarm",
        });
        ctx.Instances.Add(instance);
        await ctx.SaveChangesAsync();

        instance.ConnectionBindings.Add(new InstanceConnectionBinding("Flow")
        {
            DataConnectionId = conn.Id,
            DataSourceReferenceOverride = "ns=3;s=Pump.Flow",
        });
        await ctx.SaveChangesAsync();
    }

    /// <summary>Exports every seeded site (and its instance/connection closure) into a bundle, then loads it.</summary>
    private async Task<Guid> ExportAllSitesAndLoadAsync()
    {
        Stream bundleStream;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
            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: Array.Empty<int>(),
                IncludeDependencies: true,
                SiteIds: siteIds);
            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;
    }

    /// <summary>Removes all site/instance-scoped rows so the import exercises the CreateNew path against a fresh target.</summary>
    private async Task WipeSiteClosureAsync()
    {
        await using var scope = _provider.CreateAsyncScope();
        var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
        ctx.InstanceConnectionBindings.RemoveRange(ctx.InstanceConnectionBindings);
        ctx.InstanceAttributeOverrides.RemoveRange(ctx.InstanceAttributeOverrides);
        ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides);
        ctx.InstanceNativeAlarmSourceOverrides.RemoveRange(ctx.InstanceNativeAlarmSourceOverrides);
        ctx.Instances.RemoveRange(ctx.Instances);
        ctx.DataConnections.RemoveRange(ctx.DataConnections);
        ctx.Areas.RemoveRange(ctx.Areas);
        ctx.Sites.RemoveRange(ctx.Sites);
        ctx.Templates.RemoveRange(ctx.Templates);
        await ctx.SaveChangesAsync();
    }

    private async Task<ImportResult> ApplyAsync(
        Guid sessionId, IReadOnlyList<ImportResolution> resolutions, BundleNameMap nameMap)
    {
        await using var scope = _provider.CreateAsyncScope();
        var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
        return await importer.ApplyAsync(sessionId, resolutions, user: "bob", ct: CancellationToken.None, nameMap: nameMap);
    }

    /// <summary>
    /// Hand-packs an arbitrary <see cref="BundleContentDto"/> into a real, loadable
    /// plaintext bundle and opens a session. Lets negative-path tests carry an
    /// instance whose TemplateName the export resolver would never emit (e.g. a
    /// template absent from both bundle and target), so the instance pass' guard
    /// can be exercised mid-transaction. Reuses the production manifest builder +
    /// serializer for hash fidelity.
    /// </summary>
    private async Task<Guid> PackAndLoadAsync(BundleContentDto content)
    {
        await using var scope = _provider.CreateAsyncScope();
        var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
        var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
        var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();

        var summary = new BundleSummary(
            Templates: content.Templates.Count,
            TemplateFolders: content.TemplateFolders.Count,
            SharedScripts: content.SharedScripts.Count,
            ExternalSystems: content.ExternalSystems.Count,
            DbConnections: content.DatabaseConnections.Count,
            NotificationLists: content.NotificationLists.Count,
            SmtpConfigs: content.SmtpConfigs.Count,
            ApiMethods: content.ApiMethods.Count,
            Sites: content.Sites.Count,
            DataConnections: content.DataConnections.Count,
            Instances: content.Instances.Count);

        var manifest = manifestBuilder.Build(
            sourceEnvironment: "dev",
            exportedBy: "alice",
            scadaBridgeVersion: "1.0.0",
            encryption: null,
            summary: summary,
            contents: Array.Empty<ManifestContentEntry>(),
            contentBytes: serializer.SerializeContentBytes(content));

        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;
    }

    // ──────────────────────────────────────────────────────────────────────
    // CreateNew into a fresh target
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ApplyAsync_CreateNew_into_fresh_target_creates_site_connection_instance_with_rewired_FKs()
    {
        await SeedSiteClosureAsync();
        var sessionId = await ExportAllSitesAndLoadAsync();
        await WipeSiteClosureAsync();

        // The template must exist for the instance to resolve its TemplateId.
        // The wipe removed it, so re-create it (a real import that carries the
        // template would Add it; here we isolate the site/instance path).
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
            await ctx.SaveChangesAsync();
        }

        var nameMap = new BundleNameMap(
            Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
            Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });

        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Template", "Pump", ResolutionAction.Skip, null), // already present
                new("Site", "plant-1", ResolutionAction.Add, null),
                // C2: DataConnection resolutions are keyed by the site-qualified name.
                new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
                new("Instance", "Pump-01", ResolutionAction.Add, null),
            },
            nameMap);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

            var site = await ctx.Sites.SingleAsync(s => s.SiteIdentifier == "plant-1");
            Assert.Equal("Plant 1", site.Name);
            // Full config carried (D3 "carry full config").
            Assert.Equal("10.0.0.1:8083", site.GrpcNodeAAddress);
            Assert.Equal("akka://site@10.0.0.2:2552", site.NodeBAddress);

            var conn = await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary");
            Assert.Equal(site.Id, conn.SiteId);
            // Primary/Backup config restored from the bundle's secrets block.
            Assert.Contains("opc.tcp://primary", conn.PrimaryConfiguration!);
            Assert.Contains("opc.tcp://backup", conn.BackupConfiguration!);
            Assert.Equal(5, conn.FailoverRetryCount);

            var inst = await ctx.Instances
                .Include(i => i.ConnectionBindings)
                .Include(i => i.AttributeOverrides)
                .Include(i => i.AlarmOverrides)
                .Include(i => i.NativeAlarmSourceOverrides)
                .SingleAsync(i => i.UniqueName == "Pump-01");

            // Imported instances are design-time config — never carried as deployed.
            Assert.Equal(InstanceState.NotDeployed, inst.State);
            Assert.Equal(site.Id, inst.SiteId);

            // FK rewire — binding points at the CREATED connection's surrogate id.
            var binding = Assert.Single(inst.ConnectionBindings);
            Assert.Equal(conn.Id, binding.DataConnectionId);
            Assert.Equal("ns=3;s=Pump.Flow", binding.DataSourceReferenceOverride);

            // Native-alarm override connection-name rewritten to the target name.
            var native = Assert.Single(inst.NativeAlarmSourceOverrides);
            Assert.Equal("OpcUaPrimary", native.ConnectionNameOverride);

            // Override rows present.
            Assert.Single(inst.AttributeOverrides);
            Assert.Equal("42", inst.AttributeOverrides.Single().OverrideValue);
            Assert.Single(inst.AlarmOverrides);
            Assert.Equal(7, inst.AlarmOverrides.Single().PriorityLevelOverride);
        }

        // Counts: site + connection + instance added (template was Skipped).
        Assert.Equal(3, result.Added);
        Assert.Equal(1, result.Skipped);
        // No template was overwritten and the imported instance is NotDeployed, so
        // the D2 (#16) stale-instance enumeration finds nothing.
        Assert.Empty(result.StaleInstanceIds);
    }

    // ──────────────────────────────────────────────────────────────────────
    // MapToExisting into a populated target (FK remap to existing ids)
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ApplyAsync_MapToExisting_remaps_binding_to_existing_target_connection_id()
    {
        // Source env: plant-1 / OpcUaPrimary. Build a bundle from it, then set up
        // a DISTINCT target env that already has plant-1 + OpcUaPrimary with their
        // OWN surrogate ids (achieved by wiping + reseeding so ids differ from the
        // source's). The import must MapToExisting and rebind to the TARGET ids.
        await SeedSiteClosureAsync();
        var sessionId = await ExportAllSitesAndLoadAsync();
        await WipeSiteClosureAsync();

        // Re-seed the target with the SAME identifiers but fresh rows (no instance
        // — the import brings it). Pad with throwaway rows first so the target's
        // surrogate ids do not coincide with the source's by accident.
        int targetConnId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            // Throwaway site/connection to advance the id counters.
            var pad = new Site("Pad", "pad-site");
            ctx.Sites.Add(pad);
            await ctx.SaveChangesAsync();
            ctx.DataConnections.Add(new DataConnection("PadConn", "OpcUa", pad.Id));
            await ctx.SaveChangesAsync();

            ctx.Templates.Add(new Template("Pump") { Description = "target pump" });
            var site = new Site("Plant 1 (target)", "plant-1");
            ctx.Sites.Add(site);
            await ctx.SaveChangesAsync();
            var conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
            {
                PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target-existing\"}",
            };
            ctx.DataConnections.Add(conn);
            await ctx.SaveChangesAsync();
            targetConnId = conn.Id;
        }

        var nameMap = new BundleNameMap(
            Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
            Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });

        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Template", "Pump", ResolutionAction.Skip, null),
                new("Site", "plant-1", ResolutionAction.Skip, null),         // leave target site untouched
                // C2: DataConnection resolutions are keyed by the site-qualified name.
                new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null), // leave target conn untouched
                new("Instance", "Pump-01", ResolutionAction.Add, null),
            },
            nameMap);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

            // Skip left the existing target connection's config untouched — NOT a
            // new connection created from the bundle.
            var conns = await ctx.DataConnections.Where(c => c.Name == "OpcUaPrimary").ToListAsync();
            var conn = Assert.Single(conns);
            Assert.Equal(targetConnId, conn.Id);
            Assert.Contains("target-existing", conn.PrimaryConfiguration!);

            var inst = await ctx.Instances
                .Include(i => i.ConnectionBindings)
                .Include(i => i.NativeAlarmSourceOverrides)
                .SingleAsync(i => i.UniqueName == "Pump-01");

            // FK remapped explicitly to the EXISTING target connection id.
            var binding = Assert.Single(inst.ConnectionBindings);
            Assert.Equal(targetConnId, binding.DataConnectionId);
            Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
        }

        // Site + connection Skipped, instance Added.
        Assert.Equal(1, result.Added);
        Assert.Equal(3, result.Skipped); // template + site + connection
    }

    // ──────────────────────────────────────────────────────────────────────
    // Rename an instance on import
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ApplyAsync_renames_instance_unique_name_on_import()
    {
        await SeedSiteClosureAsync();
        var sessionId = await ExportAllSitesAndLoadAsync();
        await WipeSiteClosureAsync();

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
            await ctx.SaveChangesAsync();
        }

        var nameMap = new BundleNameMap(
            Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
            Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });

        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Template", "Pump", ResolutionAction.Skip, null),
                new("Instance", "Pump-01", ResolutionAction.Rename, "Pump-01-imported"),
            },
            nameMap);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            Assert.Equal(0, await ctx.Instances.CountAsync(i => i.UniqueName == "Pump-01"));
            var renamed = await ctx.Instances
                .Include(i => i.ConnectionBindings)
                .SingleAsync(i => i.UniqueName == "Pump-01-imported");
            // Renamed instance still gets its FKs rewired + state reset.
            Assert.Equal(InstanceState.NotDeployed, renamed.State);
            var conn = await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary");
            Assert.Equal(conn.Id, renamed.ConnectionBindings.Single().DataConnectionId);
        }

        Assert.Equal(1, result.Renamed);
    }

    // ──────────────────────────────────────────────────────────────────────
    // Cross-site rebind: source site maps to a DIFFERENTLY-named target site
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ApplyAsync_maps_source_site_to_differently_named_target_site_and_rebinds_connections()
    {
        await SeedSiteClosureAsync(siteIdentifier: "plant-1", connectionName: "OpcUaPrimary");
        var sessionId = await ExportAllSitesAndLoadAsync();
        await WipeSiteClosureAsync();

        // Target env has a DIFFERENTLY-identified site (plant-west) carrying a
        // same-named connection. The operator maps plant-1 → plant-west.
        int westSiteId;
        int westConnId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
            var west = new Site("Plant West", "plant-west");
            ctx.Sites.Add(west);
            await ctx.SaveChangesAsync();
            westSiteId = west.Id;
            var conn = new DataConnection("OpcUaPrimary", "OpcUa", west.Id)
            {
                PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://west\"}",
            };
            ctx.DataConnections.Add(conn);
            await ctx.SaveChangesAsync();
            westConnId = conn.Id;
        }

        var nameMap = new BundleNameMap(
            Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-west") },
            Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });

        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Template", "Pump", ResolutionAction.Skip, null),
                new("Site", "plant-1", ResolutionAction.Skip, null),
                // C2: DataConnection resolution is keyed by the SOURCE site-qualified name.
                new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null),
                new("Instance", "Pump-01", ResolutionAction.Add, null),
            },
            nameMap);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            // No plant-1 site was created — the bundle's site mapped onto plant-west.
            Assert.Equal(0, await ctx.Sites.CountAsync(s => s.SiteIdentifier == "plant-1"));

            var inst = await ctx.Instances
                .Include(i => i.ConnectionBindings)
                .SingleAsync(i => i.UniqueName == "Pump-01");
            // Instance rebound to the TARGET site + its connection id.
            Assert.Equal(westSiteId, inst.SiteId);
            Assert.Equal(westConnId, inst.ConnectionBindings.Single().DataConnectionId);
        }

        Assert.Equal(1, result.Added);
    }

    // ──────────────────────────────────────────────────────────────────────
    // Rollback: a mid-apply failure persists NOTHING
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ApplyAsync_rolls_back_site_and_connection_when_instance_pass_throws()
    {
        // Hand-pack a bundle whose instance references template "GhostTemplate"
        // that exists in NEITHER the bundle NOR the (empty) target. The reference
        // is rejected in the pre-write validation phase (so the change tracker is
        // still empty), the import aborts, and the transaction rolls back — no
        // site, connection, or instance row may survive, and a BundleImportFailed
        // audit row records the abort. The validation-phase rejection surfaces as
        // a SemanticValidationException, the same all-or-nothing failure contract
        // the script-reference and template-validation checks already use.
        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>())
        {
            Sites = new[]
            {
                new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
            },
            DataConnections = new[]
            {
                new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 3, null),
            },
            Instances = new[]
            {
                new InstanceDto(
                    UniqueName: "Pump-01",
                    TemplateName: "GhostTemplate",
                    SiteIdentifier: "plant-1",
                    AreaName: null,
                    State: InstanceState.Enabled,
                    AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
                    AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
                    NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
                    ConnectionBindings: Array.Empty<InstanceConnectionBindingDto>()),
            },
        };
        var sessionId = await PackAndLoadAsync(content);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var nameMap = new BundleNameMap(
                Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
                Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });
            await Assert.ThrowsAsync<SemanticValidationException>(() =>
                importer.ApplyAsync(
                    sessionId,
                    new List<ImportResolution>
                    {
                        new("Site", "plant-1", ResolutionAction.Add, null),
                        new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
                        new("Instance", "Pump-01", ResolutionAction.Add, null),
                    },
                    user: "bob",
                    ct: CancellationToken.None,
                    nameMap: nameMap));
        }

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            Assert.Equal(0, await ctx.Sites.CountAsync());
            Assert.Equal(0, await ctx.DataConnections.CountAsync());
            Assert.Equal(0, await ctx.Instances.CountAsync());
            // A BundleImportFailed audit row records the aborted import.
            Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
        }
    }

    // ──────────────────────────────────────────────────────────────────────
    // Overwrite an existing instance: child rows replaced from the bundle
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ApplyAsync_Overwrite_existing_instance_replaces_child_rows_and_remaps_binding()
    {
        await SeedSiteClosureAsync();
        var sessionId = await ExportAllSitesAndLoadAsync();

        // Mutate the target instance so its children diverge from the bundle:
        // drop the override + binding, add a junk override. Overwrite must restore
        // the bundle's shape and rebind to the (still-present) target connection.
        int targetConnId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            targetConnId = (await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary")).Id;
            var inst = await ctx.Instances
                .Include(i => i.AttributeOverrides)
                .Include(i => i.ConnectionBindings)
                .SingleAsync(i => i.UniqueName == "Pump-01");
            ctx.InstanceAttributeOverrides.RemoveRange(inst.AttributeOverrides);
            ctx.InstanceConnectionBindings.RemoveRange(inst.ConnectionBindings);
            inst.AttributeOverrides.Clear();
            inst.ConnectionBindings.Clear();
            inst.AttributeOverrides.Add(new InstanceAttributeOverride("Junk") { OverrideValue = "stale" });
            await ctx.SaveChangesAsync();
        }

        var nameMap = new BundleNameMap(
            Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
            Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });

        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Template", "Pump", ResolutionAction.Skip, null),
                new("Site", "plant-1", ResolutionAction.Skip, null),
                // C2: DataConnection resolution is keyed by the site-qualified name.
                new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Skip, null),
                new("Instance", "Pump-01", ResolutionAction.Overwrite, null),
            },
            nameMap);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var inst = await ctx.Instances
                .Include(i => i.AttributeOverrides)
                .Include(i => i.ConnectionBindings)
                .Include(i => i.NativeAlarmSourceOverrides)
                .SingleAsync(i => i.UniqueName == "Pump-01");

            // Bundle's child shape restored — junk override gone, Flow override + binding back.
            Assert.DoesNotContain(inst.AttributeOverrides, o => o.AttributeName == "Junk");
            Assert.Contains(inst.AttributeOverrides, o => o.AttributeName == "Flow" && o.OverrideValue == "42");
            var binding = Assert.Single(inst.ConnectionBindings);
            Assert.Equal(targetConnId, binding.DataConnectionId);
            Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
            Assert.Equal(InstanceState.NotDeployed, inst.State);
        }

        Assert.Equal(1, result.Overwritten);
    }

    // ──────────────────────────────────────────────────────────────────────
    // C1 — binding references a TARGET connection the bundle did NOT carry
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ApplyAsync_binding_to_target_connection_omitted_from_bundle_resolves_to_existing_id_not_zero()
    {
        // C1 regression: a valid bundle can carry an instance whose binding (and
        // native-alarm-source override) references a connection that exists in the
        // TARGET but was NOT carried in the bundle's DataConnections. Preview does
        // NOT block it (it auto-matches the target). Before the C1 Pass-2 fix the
        // connection map MISSED for that binding → DataConnectionId defaulted to 0
        // (an invalid FK). After the fix the map resolves the EXISTING target
        // connection's id and both the binding + the native-alarm override rewrite
        // correctly. Hand-pack the bundle so DataConnections is empty while the
        // instance still references the connection by name.
        int targetConnId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
            var site = new Site("Plant 1", "plant-1");
            ctx.Sites.Add(site);
            await ctx.SaveChangesAsync();
            var conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
            {
                PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target-existing\"}",
            };
            ctx.DataConnections.Add(conn);
            await ctx.SaveChangesAsync();
            targetConnId = conn.Id;
        }

        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>())
        {
            // Site carried, but the connection is DELIBERATELY OMITTED from the bundle.
            Sites = new[]
            {
                new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
            },
            DataConnections = Array.Empty<DataConnectionDto>(),
            Instances = new[]
            {
                new InstanceDto(
                    UniqueName: "Pump-01",
                    TemplateName: "Pump",
                    SiteIdentifier: "plant-1",
                    AreaName: null,
                    State: InstanceState.Enabled,
                    AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
                    AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
                    NativeAlarmSourceOverrides: new[]
                    {
                        new InstanceNativeAlarmSourceOverrideDto(
                            SourceCanonicalName: "NativeSrc",
                            ConnectionNameOverride: "OpcUaPrimary",
                            SourceReferenceOverride: "ns=3;s=Pump.Alarm",
                            ConditionFilterOverride: null),
                    },
                    ConnectionBindings: new[]
                    {
                        new InstanceConnectionBindingDto(
                            AttributeName: "Flow",
                            ConnectionName: "OpcUaPrimary",
                            DataSourceReferenceOverride: "ns=3;s=Pump.Flow"),
                    }),
            },
        };
        var sessionId = await PackAndLoadAsync(content);

        var nameMap = new BundleNameMap(
            Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
            // No explicit connection mapping — the connection auto-matches the
            // existing target connection within plant-1 (the C1 Pass-2 path).
            Connections: Array.Empty<ConnectionMapping>());

        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Site", "plant-1", ResolutionAction.Skip, null),
                new("Instance", "Pump-01", ResolutionAction.Add, null),
            },
            nameMap);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

            // No new connection was created — the omitted-from-bundle connection
            // auto-matched the EXISTING target row.
            var conn = Assert.Single(await ctx.DataConnections.Where(c => c.Name == "OpcUaPrimary").ToListAsync());
            Assert.Equal(targetConnId, conn.Id);

            var inst = await ctx.Instances
                .Include(i => i.ConnectionBindings)
                .Include(i => i.NativeAlarmSourceOverrides)
                .SingleAsync(i => i.UniqueName == "Pump-01");

            // THE FIX: binding FK points at the EXISTING target connection id — NOT 0.
            var binding = Assert.Single(inst.ConnectionBindings);
            Assert.NotEqual(0, binding.DataConnectionId);
            Assert.Equal(targetConnId, binding.DataConnectionId);

            // Native-alarm-source override connection name carries through.
            Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
        }

        Assert.Equal(1, result.Added);
    }

    [Fact]
    public async Task ApplyAsync_binding_to_connection_in_neither_bundle_nor_target_fails_instead_of_writing_zero()
    {
        // C1 guard: a binding naming a connection present in NEITHER the bundle nor
        // the target must FAIL the import (caught in the pre-write validation phase
        // as a SemanticValidationException) rather than silently write
        // DataConnectionId = 0. Nothing may persist.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
            ctx.Sites.Add(new Site("Plant 1", "plant-1"));
            await ctx.SaveChangesAsync();
        }

        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>())
        {
            DataConnections = Array.Empty<DataConnectionDto>(),
            Instances = new[]
            {
                new InstanceDto(
                    UniqueName: "Pump-01",
                    TemplateName: "Pump",
                    SiteIdentifier: "plant-1",
                    AreaName: null,
                    State: InstanceState.Enabled,
                    AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
                    AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
                    NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
                    ConnectionBindings: new[]
                    {
                        new InstanceConnectionBindingDto(
                            AttributeName: "Flow",
                            ConnectionName: "GhostConn",
                            DataSourceReferenceOverride: null),
                    }),
            },
        };
        var sessionId = await PackAndLoadAsync(content);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var nameMap = new BundleNameMap(
                Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
                Connections: Array.Empty<ConnectionMapping>());
            await Assert.ThrowsAsync<SemanticValidationException>(() =>
                importer.ApplyAsync(
                    sessionId,
                    new List<ImportResolution>
                    {
                        new("Site", "plant-1", ResolutionAction.Skip, null),
                        new("Instance", "Pump-01", ResolutionAction.Add, null),
                    },
                    user: "bob",
                    ct: CancellationToken.None,
                    nameMap: nameMap));
        }

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            // No instance with a zero (or any) binding persisted.
            Assert.Equal(0, await ctx.Instances.CountAsync());
            Assert.Equal(0, await ctx.InstanceConnectionBindings.CountAsync());
        }
    }

    // ──────────────────────────────────────────────────────────────────────
    // M-4 — native-alarm-source ConnectionNameOverride RENAME-REDIRECT
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ApplyAsync_native_alarm_override_connection_name_rewritten_to_redirected_target_name()
    {
        // M-4 regression. The existing tests only cover the same-name identity case
        // (source connection name == target connection name), so a bug that fails to
        // rewrite ConnectionNameOverride on a RENAME-REDIRECT would go unnoticed.
        // Here the operator maps the source connection "SourceConn" to a
        // DIFFERENTLY-named existing target connection "TargetConn" via an explicit
        // MapToExisting. After import, the imported instance's native-alarm-source
        // override (and its connection binding) must carry the TARGET name, not the
        // source's.
        int targetConnId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
            var site = new Site("Plant 1", "plant-1");
            ctx.Sites.Add(site);
            await ctx.SaveChangesAsync();
            // The target connection is named DIFFERENTLY from the source's.
            var conn = new DataConnection("TargetConn", "OpcUa", site.Id)
            {
                PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target\"}",
            };
            ctx.DataConnections.Add(conn);
            await ctx.SaveChangesAsync();
            targetConnId = conn.Id;
        }

        // Hand-pack a bundle whose instance references the SOURCE connection name
        // "SourceConn" (both in its binding and its native-alarm override). The
        // bundle does NOT carry the connection — the redirect resolves it against
        // the existing target "TargetConn".
        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>())
        {
            Sites = new[]
            {
                new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
            },
            DataConnections = Array.Empty<DataConnectionDto>(),
            Instances = new[]
            {
                new InstanceDto(
                    UniqueName: "Pump-01",
                    TemplateName: "Pump",
                    SiteIdentifier: "plant-1",
                    AreaName: null,
                    State: InstanceState.Enabled,
                    AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
                    AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
                    NativeAlarmSourceOverrides: new[]
                    {
                        new InstanceNativeAlarmSourceOverrideDto(
                            SourceCanonicalName: "NativeSrc",
                            ConnectionNameOverride: "SourceConn",
                            SourceReferenceOverride: "ns=3;s=Pump.Alarm",
                            ConditionFilterOverride: null),
                    },
                    ConnectionBindings: new[]
                    {
                        new InstanceConnectionBindingDto(
                            AttributeName: "Flow",
                            ConnectionName: "SourceConn",
                            DataSourceReferenceOverride: "ns=3;s=Pump.Flow"),
                    }),
            },
        };
        var sessionId = await PackAndLoadAsync(content);

        // Explicit redirect: source "SourceConn" → target "TargetConn" (DIFFERENT name).
        var nameMap = new BundleNameMap(
            Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
            Connections: new[]
            {
                new ConnectionMapping("plant-1", "SourceConn", MappingAction.MapToExisting, "TargetConn"),
            });

        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Site", "plant-1", ResolutionAction.Skip, null),
                new("Instance", "Pump-01", ResolutionAction.Add, null),
            },
            nameMap);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

            // No new connection created — the redirect resolved the existing target.
            var conn = Assert.Single(await ctx.DataConnections.ToListAsync());
            Assert.Equal("TargetConn", conn.Name);
            Assert.Equal(targetConnId, conn.Id);

            var inst = await ctx.Instances
                .Include(i => i.ConnectionBindings)
                .Include(i => i.NativeAlarmSourceOverrides)
                .SingleAsync(i => i.UniqueName == "Pump-01");

            // THE FIX: the native-alarm-source override carries the TARGET name,
            // not the source's "SourceConn".
            var native = Assert.Single(inst.NativeAlarmSourceOverrides);
            Assert.Equal("TargetConn", native.ConnectionNameOverride);

            // The connection binding FK rewires to the redirected target connection.
            var binding = Assert.Single(inst.ConnectionBindings);
            Assert.Equal(targetConnId, binding.DataConnectionId);
        }

        Assert.Equal(1, result.Added);
    }

    // ──────────────────────────────────────────────────────────────────────
    // C2 — two sites with same-named connections resolve independently
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ApplyAsync_two_sites_same_connection_name_apply_per_site_resolution_independently()
    {
        // C2 regression: connection names are unique only WITHIN a site, so a bundle
        // with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must NOT collapse onto a
        // single resolution. Both target connections exist; the operator Overwrites
        // plant-1's and Skips plant-2's. The site-qualified resolution key routes the
        // Overwrite to plant-1's connection ONLY and leaves plant-2's untouched.
        int plant1ConnId, plant2ConnId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var s1 = new Site("Plant 1", "plant-1");
            var s2 = new Site("Plant 2", "plant-2");
            ctx.Sites.AddRange(s1, s2);
            await ctx.SaveChangesAsync();
            var c1 = new DataConnection("OpcUaPrimary", "OpcUa", s1.Id)
            {
                PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://p1-existing\"}",
                FailoverRetryCount = 1,
            };
            var c2 = new DataConnection("OpcUaPrimary", "OpcUa", s2.Id)
            {
                PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://p2-existing\"}",
                FailoverRetryCount = 2,
            };
            ctx.DataConnections.AddRange(c1, c2);
            await ctx.SaveChangesAsync();
            plant1ConnId = c1.Id;
            plant2ConnId = c2.Id;
        }

        // Hand-pack a bundle carrying both same-named connections, each with a
        // DISTINCT incoming protocol-config so an Overwrite is observable.
        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>())
        {
            Sites = new[]
            {
                new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
                new SiteDto("plant-2", "Plant 2", null, null, null, null, null),
            },
            DataConnections = new[]
            {
                new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 9,
                    new SecretsBlock(new Dictionary<string, string>
                    {
                        ["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://p1-from-bundle\"}",
                    })),
                new DataConnectionDto("plant-2", "OpcUaPrimary", "OpcUa", 9,
                    new SecretsBlock(new Dictionary<string, string>
                    {
                        ["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://p2-from-bundle\"}",
                    })),
            },
            Instances = Array.Empty<InstanceDto>(),
        };
        var sessionId = await PackAndLoadAsync(content);

        var nameMap = new BundleNameMap(
            Sites: new[]
            {
                new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1"),
                new SiteMapping("plant-2", MappingAction.MapToExisting, "plant-2"),
            },
            Connections: new[]
            {
                new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary"),
                new ConnectionMapping("plant-2", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary"),
            });

        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Site", "plant-1", ResolutionAction.Skip, null),
                new("Site", "plant-2", ResolutionAction.Skip, null),
                // Per-site resolutions — keyed by the site-qualified name (C2).
                new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Overwrite, null),
                new("DataConnection", "plant-2/OpcUaPrimary", ResolutionAction.Skip, null),
            },
            nameMap);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

            // plant-1's connection was Overwritten with the bundle's config.
            var c1 = await ctx.DataConnections.SingleAsync(c => c.Id == plant1ConnId);
            Assert.Contains("p1-from-bundle", c1.PrimaryConfiguration!);
            Assert.Equal(9, c1.FailoverRetryCount);

            // plant-2's same-named connection was LEFT UNTOUCHED by the Skip — the
            // bare-name collision bug would have applied plant-1's Overwrite here too.
            var c2 = await ctx.DataConnections.SingleAsync(c => c.Id == plant2ConnId);
            Assert.Contains("p2-existing", c2.PrimaryConfiguration!);
            Assert.Equal(2, c2.FailoverRetryCount);

            // Still exactly two connections — no duplicates created.
            Assert.Equal(2, await ctx.DataConnections.CountAsync(c => c.Name == "OpcUaPrimary"));
        }

        // One Overwrite (plant-1 conn), three Skips (two sites + plant-2 conn).
        Assert.Equal(1, result.Overwritten);
        Assert.Equal(3, result.Skipped);
    }

    // ──────────────────────────────────────────────────────────────────────
    // #229 — empty-ConnectionName binding is silently SKIPPED (not written)
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ApplyAsync_empty_ConnectionName_binding_is_skipped_not_written()
    {
        // Regression guard for commit 3782ebda: an InstanceConnectionBindingDto
        // whose ConnectionName is empty represents an orphan binding — the source
        // connection FK was excluded from the export. Before the fix the importer
        // wrote DataConnectionId = 0 (an invalid FK, FK-violating at commit on a
        // relational provider). After the fix the binding row is silently skipped.
        // Assert: (a) the import SUCCEEDS (no exception), (b) NO binding row is
        // written, and (c) a non-empty binding in the SAME instance IS written so
        // we know the loop executed and only the empty-name entry was dropped.
        int targetConnId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
            var site = new Site("Plant 1", "plant-1");
            ctx.Sites.Add(site);
            await ctx.SaveChangesAsync();
            var conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
            {
                PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
            };
            ctx.DataConnections.Add(conn);
            await ctx.SaveChangesAsync();
            targetConnId = conn.Id;
        }

        // Hand-pack a bundle that carries two bindings for the same instance:
        //   - "Flow"   → ConnectionName = ""  (orphan / empty — must be SKIPPED)
        //   - "Status" → ConnectionName = "OpcUaPrimary" (valid — must be WRITTEN)
        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>())
        {
            Sites = new[]
            {
                new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
            },
            DataConnections = Array.Empty<DataConnectionDto>(),
            Instances = new[]
            {
                new InstanceDto(
                    UniqueName: "Pump-01",
                    TemplateName: "Pump",
                    SiteIdentifier: "plant-1",
                    AreaName: null,
                    State: InstanceState.Enabled,
                    AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
                    AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
                    NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
                    ConnectionBindings: new[]
                    {
                        // Empty ConnectionName — orphan binding; must be SKIPPED.
                        new InstanceConnectionBindingDto(
                            AttributeName: "Flow",
                            ConnectionName: "",
                            DataSourceReferenceOverride: "ns=3;s=Pump.Flow"),
                        // Valid ConnectionName — must be written with the correct FK.
                        new InstanceConnectionBindingDto(
                            AttributeName: "Status",
                            ConnectionName: "OpcUaPrimary",
                            DataSourceReferenceOverride: "ns=3;s=Pump.Status"),
                    }),
            },
        };
        var sessionId = await PackAndLoadAsync(content);

        var nameMap = new BundleNameMap(
            Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
            Connections: Array.Empty<ConnectionMapping>());

        // (a) Import must SUCCEED — no exception thrown.
        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Site", "plant-1", ResolutionAction.Skip, null),
                new("Instance", "Pump-01", ResolutionAction.Add, null),
            },
            nameMap);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

            var inst = await ctx.Instances
                .Include(i => i.ConnectionBindings)
                .SingleAsync(i => i.UniqueName == "Pump-01");

            // (b) The empty-ConnectionName binding for "Flow" must NOT be written.
            Assert.DoesNotContain(inst.ConnectionBindings, b => b.AttributeName == "Flow");

            // (c) The valid "Status" binding was written with the correct FK — proves
            // the loop ran and it was only the empty-name entry that was dropped.
            var statusBinding = Assert.Single(inst.ConnectionBindings, b => b.AttributeName == "Status");
            Assert.Equal(targetConnId, statusBinding.DataConnectionId);
            Assert.NotEqual(0, statusBinding.DataConnectionId);
        }

        Assert.Equal(1, result.Added);
    }

    // ──────────────────────────────────────────────────────────────────────
    // N4 (plan R2-05 T7): warn on overrides of LOCKED template members
    // ──────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task Import_OverrideOnLockedAttribute_EmitsWarning_ImportStillSucceeds()
    {
        // Template attribute "SetPoint" IsLocked=true; instance carries an
        // InstanceAttributeOverride for "SetPoint" — the flattener drops it, so the
        // import surfaces an advisory warning but the row IS still written.
        await SeedLockedMemberClosureAsync(lockAttribute: true);

        var sessionId = await ExportAllSitesAndLoadAsync();
        await WipeSiteClosureAsync();
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
            await ctx.SaveChangesAsync();
        }

        var nameMap = new BundleNameMap(
            Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
            Connections: Array.Empty<ConnectionMapping>());

        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Template", "Pump", ResolutionAction.Skip, null),
                new("Site", "plant-1", ResolutionAction.Add, null),
                new("Instance", "Pump-01", ResolutionAction.Add, null),
            },
            nameMap);

        Assert.Contains(result.Warnings, w =>
            w.Contains("SetPoint", StringComparison.Ordinal)
            && w.Contains("locked", StringComparison.OrdinalIgnoreCase));

        // Behaviour unchanged: the override row IS still written (bundle fidelity).
        await using (var verify = _provider.CreateAsyncScope())
        {
            var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            Assert.True(await ctx.InstanceAttributeOverrides.AnyAsync(o => o.AttributeName == "SetPoint"));
        }
    }

    [Fact]
    public async Task Preview_OverrideOnLockedNativeAlarmSource_ShowsWarningRow()
    {
        // Template native-alarm-source "NativeSrc" IsLocked=true; instance carries a
        // NativeAlarmSourceOverride for it. The wizard shows a non-blocking Warning row.
        await SeedLockedMemberClosureAsync(lockNativeSource: true);

        var sessionId = await ExportAllSitesAndLoadAsync();

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            preview = await importer.PreviewAsync(sessionId);
        }

        Assert.Contains(preview.Items, b =>
            b.Kind == ConflictKind.Warning && b.EntityType == "Instance"
            && b.BlockerReason!.Contains("locked", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public async Task Import_OverrideOnUnlockedAttribute_NoLockWarning()
    {
        // Same shape, but "SetPoint" is NOT locked — no lock warning must appear.
        await SeedLockedMemberClosureAsync(lockAttribute: false);

        var sessionId = await ExportAllSitesAndLoadAsync();
        await WipeSiteClosureAsync();
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
            await ctx.SaveChangesAsync();
        }

        var nameMap = new BundleNameMap(
            Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
            Connections: Array.Empty<ConnectionMapping>());

        var result = await ApplyAsync(
            sessionId,
            new List<ImportResolution>
            {
                new("Template", "Pump", ResolutionAction.Skip, null),
                new("Site", "plant-1", ResolutionAction.Add, null),
                new("Instance", "Pump-01", ResolutionAction.Add, null),
            },
            nameMap);

        Assert.DoesNotContain(result.Warnings, w =>
            w.Contains("locked", StringComparison.OrdinalIgnoreCase));
    }

    /// <summary>
    /// Seeds a template ("Pump") + site ("plant-1") + instance ("Pump-01") whose
    /// overrides target the given members; <paramref name="lockAttribute"/> /
    /// <paramref name="lockNativeSource"/> toggle the template member's IsLocked flag.
    /// The attribute override is always present; the native-source override (and its
    /// connection) are seeded only when a native source is involved.
    /// </summary>
    private async Task SeedLockedMemberClosureAsync(bool lockAttribute = false, bool lockNativeSource = false)
    {
        await using var scope = _provider.CreateAsyncScope();
        var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

        var template = new Template("Pump") { Description = "pump tpl" };
        template.Attributes.Add(new TemplateAttribute("SetPoint") { Value = "0", IsLocked = lockAttribute });
        if (lockNativeSource)
        {
            template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("NativeSrc")
            {
                ConnectionName = "OpcUaPrimary",
                SourceReference = "ns=3;s=Pump.Alarm",
                IsLocked = true,
            });
        }
        ctx.Templates.Add(template);

        var site = new Site("Plant 1", "plant-1")
        {
            NodeAAddress = "akka://site@10.0.0.1:2552",
            NodeBAddress = "akka://site@10.0.0.2:2552",
            GrpcNodeAAddress = "10.0.0.1:8083",
            GrpcNodeBAddress = "10.0.0.2:8083",
        };
        ctx.Sites.Add(site);
        await ctx.SaveChangesAsync();

        DataConnection? conn = null;
        if (lockNativeSource)
        {
            conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
            {
                PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
                FailoverRetryCount = 5,
            };
            ctx.DataConnections.Add(conn);
        }

        var instance = new Instance("Pump-01")
        {
            TemplateId = template.Id,
            SiteId = site.Id,
            State = InstanceState.Enabled,
        };
        instance.AttributeOverrides.Add(new InstanceAttributeOverride("SetPoint") { OverrideValue = "42" });
        if (lockNativeSource)
        {
            instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
            {
                ConnectionNameOverride = "OpcUaPrimary",
                SourceReferenceOverride = "ns=3;s=Pump.Alarm2",
            });
        }
        ctx.Instances.Add(instance);
        await ctx.SaveChangesAsync();
    }
}
