using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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.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.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;

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

/// <summary>
/// Integration tests for <see cref="ZB.MOM.WW.ScadaBridge.Transport.Import.BundleImporter.PreviewAsync"/>.
/// Reuses the same in-memory host pattern as the exporter tests: real
/// repositories, real EF in-memory provider, real Transport pipeline. Each test
/// seeds the target DB, exports a bundle, then loads + previews it via the
/// importer.
/// </summary>
public sealed class BundleImporterPreviewTests : IDisposable
{
    private readonly ServiceProvider _provider;

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

        var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}";
        services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseInMemoryDatabase(dbName));

        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<IAuditCorrelationContext, AuditCorrelationContext>();
        services.AddScoped<IAuditService, AuditService>();
        services.AddTransport();

        _provider = services.BuildServiceProvider();
    }

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

    private async Task<Stream> ExportTemplatesAsync()
    {
        await using var scope = _provider.CreateAsyncScope();
        var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
        var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
        var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
        var selection = new ExportSelection(
            TemplateIds: ids,
            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: false);

        return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
            passphrase: null, cancellationToken: CancellationToken.None);
    }

    private static async Task<byte[]> StreamToBytes(Stream s)
    {
        using var ms = new MemoryStream();
        await s.CopyToAsync(ms);
        return ms.ToArray();
    }

    /// <summary>Exports every seeded site (and its instance/connection closure) into a bundle.</summary>
    private async Task<Stream> ExportAllSitesAsync()
    {
        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);

        return await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
            passphrase: null, cancellationToken: CancellationToken.None);
    }

    /// <summary>
    /// Seeds a template + a site + a site-scoped data connection + an instance bound
    /// to that connection (with one attribute override) so a full site closure can be
    /// exported and previewed. Returns the seeded site identifier + connection name.
    /// </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" };
        ctx.Sites.Add(site);
        await ctx.SaveChangesAsync();

        var conn = new DataConnection(connectionName, "OpcUa", site.Id)
        {
            PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
            FailoverRetryCount = 3,
        };
        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" });
        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>
    /// Packs an arbitrary <see cref="BundleContentDto"/> into a real, loadable plaintext
    /// bundle so blocker scenarios can reference entities that the export resolver would
    /// never carry (e.g. an instance pointing at a template absent from both bundle and
    /// target). Reuses the production manifest builder + serializer for hash fidelity.
    /// </summary>
    private async Task<byte[]> PackBundleAsync(BundleContentDto content)
    {
        await using var scope = _provider.CreateAsyncScope();
        var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
        var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();

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

        var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
        return await StreamToBytes(packed);
    }

    private static BundleContentDto EmptyContent() => new(
        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>());

    private static InstanceDto SimpleInstanceDto(
        string uniqueName,
        string templateName,
        string siteIdentifier,
        IReadOnlyList<InstanceConnectionBindingDto>? bindings = null) => new(
        UniqueName: uniqueName,
        TemplateName: templateName,
        SiteIdentifier: siteIdentifier,
        AreaName: null,
        State: InstanceState.Enabled,
        AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
        AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
        NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
        ConnectionBindings: bindings ?? Array.Empty<InstanceConnectionBindingDto>());

    [Fact]
    public async Task PreviewAsync_classifies_artifact_as_Identical_when_fields_match()
    {
        // Arrange: seed a template, export it, leave target unchanged. The
        // bundle's DTO is the literal projection of the target, so the diff
        // should classify it as Identical.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "stable" });
            await ctx.SaveChangesAsync();
        }

        var bundleStream = await ExportTemplatesAsync();
        var bytes = await StreamToBytes(bundleStream);

        // Act
        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        // Assert
        var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
        Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
        Assert.Null(pumpItem.FieldDiffJson);
    }

    [Fact]
    public async Task PreviewAsync_classifies_artifact_as_Modified_with_field_diff()
    {
        // Arrange: seed a template with Description="new", export it, then
        // overwrite the target template's Description with "old". The bundle's
        // version differs from the target, so the diff should flag the
        // Description field.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "new" });
            await ctx.SaveChangesAsync();
        }

        var bundleStream = await ExportTemplatesAsync();
        var bytes = await StreamToBytes(bundleStream);

        // Mutate the target between export and preview so the diff has
        // something to report. The bundle still carries Description="new".
        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
        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        // Assert
        var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
        Assert.Equal(ConflictKind.Modified, pumpItem.Kind);
        Assert.NotNull(pumpItem.FieldDiffJson);
        // The diff should mention the Description field by name.
        Assert.Contains("Description", pumpItem.FieldDiffJson!, StringComparison.Ordinal);
    }

    [Fact]
    public async Task PreviewAsync_classifies_artifact_as_New_when_absent_from_target()
    {
        // Arrange: seed a template, export it, then delete it from the target
        // database. The bundle still contains the template, so the diff should
        // classify it as New (target is now empty).
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "to-be-deleted" });
            await ctx.SaveChangesAsync();
        }

        var bundleStream = await ExportTemplatesAsync();
        var bytes = await StreamToBytes(bundleStream);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = await ctx.Templates.SingleAsync();
            ctx.Templates.Remove(t);
            await ctx.SaveChangesAsync();
        }

        // Act
        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        // Assert
        var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
        Assert.Equal(ConflictKind.New, pumpItem.Kind);
        Assert.Null(pumpItem.FieldDiffJson);
    }

    [Fact]
    public async Task PreviewAsync_unchanged_structured_template_classifies_Identical()
    {
        // #05-T23 end-to-end: a template with a base (ParentTemplateId) and a
        // composition, re-imported against an UNCHANGED target, must classify
        // Identical. Before T23, PreviewAsync compared "<id:N>" placeholders for
        // BaseTemplateName / composition target against the bundle's real names,
        // so it always read Modified — defeating the wizard's auto-skip contract.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("BasePump") { Description = "base" });
            ctx.Templates.Add(new Template("FeatureX") { Description = "feature" });
            await ctx.SaveChangesAsync();
            var basePump = await ctx.Templates.SingleAsync(t => t.Name == "BasePump");
            var feature = await ctx.Templates.SingleAsync(t => t.Name == "FeatureX");

            var pump = new Template("Pump") { Description = "derived", ParentTemplateId = basePump.Id };
            ctx.Templates.Add(pump);
            await ctx.SaveChangesAsync();
            pump.Compositions.Add(new TemplateComposition("mod1")
            {
                TemplateId = pump.Id,
                ComposedTemplateId = feature.Id,
            });
            await ctx.SaveChangesAsync();
        }

        // Export everything; do NOT wipe — the target is unchanged.
        var bundleStream = await ExportTemplatesAsync();
        var bytes = await StreamToBytes(bundleStream);

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        var pumpItem = Assert.Single(preview.Items,
            i => i.EntityType == "Template" && i.Name == "Pump");
        Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
    }

    [Fact]
    public async Task PreviewAsync_emits_Warning_when_template_script_dependency_missing()
    {
        // Arrange: seed a template whose script body calls MissingHelper(), and
        // an unrelated HelperFn() shared script that *is* defined but isn't the
        // referenced one. We then export WITHOUT IncludeDependencies and use a
        // selection that only pulls the template — the bundle won't carry
        // MissingHelper (it doesn't exist anywhere) so the preview must flag it.
        //
        // Under #05-T19 a TEMPLATE-script name-resolution finding is ADVISORY
        // (the design-time deploy gate re-validates it), so the preview surfaces
        // it as ConflictKind.Warning — NOT a Blocker.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
            ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey"));

            var t = new Template("Pump") { Description = "broken" };
            t.Scripts.Add(new TemplateScript("init", "var x = MissingHelper();"));
            ctx.Templates.Add(t);
            await ctx.SaveChangesAsync();
        }

        var bundleStream = await ExportTemplatesAsync();
        var bytes = await StreamToBytes(bundleStream);

        // Act
        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        // Assert: MissingHelper surfaces as a Warning (advisory), not a Blocker.
        Assert.Contains(preview.Items, i =>
            i.Kind == ConflictKind.Warning
            && i.Name == "MissingHelper"
            && i.BlockerReason is not null
            && i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
        Assert.DoesNotContain(preview.Items, i =>
            i.Name == "MissingHelper" && i.Kind == ConflictKind.Blocker);
        // Conversely, HelperFn must NOT be flagged at all — it's seeded in the target.
        Assert.DoesNotContain(preview.Items, i => i.Name == "HelperFn");
    }

    [Fact]
    public async Task PreviewAsync_emits_Blocker_when_ApiMethod_dependency_missing()
    {
        // #05-T19 severity split: an ApiMethod script referencing a missing
        // dependency has no downstream deploy gate, so the preview must surface
        // it as a hard Blocker (parity with the apply-time SemanticValidationException).
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.ApiMethods.Add(new ApiMethod("Ingest", "var x = MissingHelper();"));
            await ctx.SaveChangesAsync();
        }

        byte[] bytes;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var apiIds = await ctx.ApiMethods.Select(m => m.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: apiIds,
                IncludeDependencies: false);
            var stream = await exporter.ExportAsync(selection, user: "alice",
                sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None);
            bytes = await StreamToBytes(stream);
        }

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        Assert.Contains(preview.Items, i =>
            i.Kind == ConflictKind.Blocker
            && i.Name == "MissingHelper"
            && i.BlockerReason is not null
            && i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
    }

    [Fact]
    public async Task PreviewAsync_emits_Blocker_when_template_script_has_forbidden_api()
    {
        // #05-T20 — the script trust gate runs in preview too, so a forbidden-API
        // script surfaces as a Blocker row (parity with the apply-time
        // SemanticValidationException). Trust violations block regardless of kind.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = new Template("Malicious") { Description = "forbidden" };
            t.Scripts.Add(new TemplateScript("init", "System.Diagnostics.Process.Start(\"cmd\");"));
            ctx.Templates.Add(t);
            await ctx.SaveChangesAsync();
        }

        var bundleStream = await ExportTemplatesAsync();
        var bytes = await StreamToBytes(bundleStream);

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        Assert.Contains(preview.Items, i =>
            i.Kind == ConflictKind.Blocker
            && i.BlockerReason is not null
            && i.BlockerReason.Contains("Process", StringComparison.Ordinal));
    }

    [Fact]
    public async Task PreviewAsync_does_not_flag_opcua_tag_paths_in_DataSourceReference_as_blockers()
    {
        // Arrange: a template with an attribute whose DataSourceReference is an
        // OPC UA node-address path -- e.g. "ns=3;s=Tank.Level". The segment
        // before the dot ("Tank") used to be parsed by the blocker heuristic as
        // a potential SharedScript reference, even though tag paths live in the
        // device's address space and are not script-callable.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = new Template("Pump") { Description = "tag-path-check" };
            t.Attributes.Add(new TemplateAttribute("Level")
            {
                Value = "0",
                DataSourceReference = "ns=3;s=Tank.Level",
            });
            ctx.Templates.Add(t);
            await ctx.SaveChangesAsync();
        }

        var bundleStream = await ExportTemplatesAsync();
        var bytes = await StreamToBytes(bundleStream);

        // Act
        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        // Assert: "Tank" (the device-owned tag-path root segment) must not be
        // flagged as a missing SharedScript or ExternalSystem reference.
        Assert.DoesNotContain(preview.Items, i =>
            i.Kind == ConflictKind.Blocker && i.Name == "Tank");
    }

    [Fact]
    public async Task PreviewAsync_does_not_flag_stdlib_or_runtime_member_accesses_as_blockers()
    {
        // Arrange: a template script that uses a representative mix of stdlib
        // calls, runtime-API roots, and member-access patterns. None of these
        // are user-defined SharedScripts or ExternalSystems and the previous
        // heuristic was flagging every one of them.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var t = new Template("Pump") { Description = "noise-check" };
            t.Scripts.Add(new TemplateScript("init", """
                var now = DateTimeOffset.UtcNow;
                var s = Convert.ToString(123);
                await Notify.Send("alerts", "msg");
                var x = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM t");
                var y = await ExternalSystem.Call("erp", "ping");
                obj.Dispose();
                """));
            ctx.Templates.Add(t);
            await ctx.SaveChangesAsync();
        }

        var bundleStream = await ExportTemplatesAsync();
        var bytes = await StreamToBytes(bundleStream);

        // Act
        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        // Assert: none of the well-known names produce blocker rows.
        string[] noiseNames =
        {
            "DateTimeOffset", "UtcNow", "Convert", "ToString", "Notify", "Send",
            "Database", "ExecuteScalarAsync", "COUNT", "ExternalSystem", "Call",
            "Dispose",
        };
        foreach (var name in noiseNames)
        {
            Assert.DoesNotContain(preview.Items, i =>
                i.Kind == ConflictKind.Blocker && i.Name == name);
        }
    }

    [Fact]
    public async Task PreviewAsync_multiple_templates_with_children_diffs_each_correctly()
    {
        // Transport-008 regression: PreviewAsync previously fetched each matching
        // template's children via a per-name GetTemplateWithChildrenAsync call
        // (N+1). The bulk variant returns every match in a single query — this
        // test seeds three templates with distinct child collections and asserts
        // the preview hydrates each one so the per-child diff sees the right
        // attribute / alarm / script counts (i.e. the bulk fetch did not lose
        // any child rows compared to the per-name fetch).
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var pump = new Template("Pump") { Description = "p1" };
            pump.Attributes.Add(new TemplateAttribute("Flow"));
            pump.Scripts.Add(new TemplateScript("init", "return 1;"));
            var valve = new Template("Valve") { Description = "v1" };
            valve.Alarms.Add(new TemplateAlarm("HighPressure"));
            var tank = new Template("Tank") { Description = "t1" };
            tank.Attributes.Add(new TemplateAttribute("Level"));
            tank.Attributes.Add(new TemplateAttribute("Temperature"));
            ctx.Templates.AddRange(pump, valve, tank);
            await ctx.SaveChangesAsync();
        }

        var bundleStream = await ExportTemplatesAsync();
        var bytes = await StreamToBytes(bundleStream);

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        // Each template should be diff-classified (Identical, since the bundle
        // is the literal projection of the target). Critically, the diff must
        // succeed for ALL three — a bulk-fetch bug that silently drops rows
        // would surface here as a missing item or a wrong (New) classification.
        var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
        var valveItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Valve");
        var tankItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Tank");
        Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
        Assert.Equal(ConflictKind.Identical, valveItem.Kind);
        Assert.Equal(ConflictKind.Identical, tankItem.Kind);
    }

    // ---- M8 C2: site/connection/instance preview + required-mapping detection + blockers ----

    [Fact]
    public async Task PreviewAsync_into_fresh_target_surfaces_required_mappings_with_null_auto_match()
    {
        // Arrange: seed a full site closure, export it, then wipe the target so the
        // site + connection are absent. The preview must surface the site +
        // connection as required mappings with no auto-match (create-new implied),
        // and classify the site/connection/instance as New.
        await SeedSiteClosureAsync();
        var bundleStream = await ExportAllSitesAsync();
        var bytes = await StreamToBytes(bundleStream);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Instances.RemoveRange(ctx.Instances);
            ctx.DataConnections.RemoveRange(ctx.DataConnections);
            ctx.Sites.RemoveRange(ctx.Sites);
            ctx.Templates.RemoveRange(ctx.Templates);
            await ctx.SaveChangesAsync();
        }

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        var siteMapping = Assert.Single(preview.RequiredSiteMappings, m => m.SourceSiteIdentifier == "plant-1");
        Assert.Null(siteMapping.AutoMatchTargetIdentifier);
        Assert.Equal("Plant 1", siteMapping.SourceSiteName);

        var connMapping = Assert.Single(preview.RequiredConnectionMappings,
            m => m.SourceSiteIdentifier == "plant-1" && m.SourceConnectionName == "OpcUaPrimary");
        Assert.Null(connMapping.AutoMatchTargetName);

        // The fresh target carries none of these, so each is classified New (and the
        // missing template would block — but we wiped the template too, so the
        // instance also blocks; assert the New site/connection at minimum).
        Assert.Contains(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1" && i.Kind == ConflictKind.New);
        // C2: DataConnection preview items are site-qualified ({site}/{name}).
        Assert.Contains(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/OpcUaPrimary" && i.Kind == ConflictKind.New);
    }

    [Fact]
    public async Task PreviewAsync_into_populated_target_auto_matches_site_and_connection()
    {
        // Arrange: seed a site closure, export it, leave the target unchanged. The
        // bundle's site + connection identity-match the target, so both required
        // mappings auto-match and the per-type diffs read Identical.
        await SeedSiteClosureAsync();
        var bundleStream = await ExportAllSitesAsync();
        var bytes = await StreamToBytes(bundleStream);

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        var siteMapping = Assert.Single(preview.RequiredSiteMappings, m => m.SourceSiteIdentifier == "plant-1");
        Assert.Equal("plant-1", siteMapping.AutoMatchTargetIdentifier);

        var connMapping = Assert.Single(preview.RequiredConnectionMappings,
            m => m.SourceSiteIdentifier == "plant-1" && m.SourceConnectionName == "OpcUaPrimary");
        Assert.Equal("OpcUaPrimary", connMapping.AutoMatchTargetName);

        // The site + connection match the target exactly → Identical, not New.
        var siteItem = Assert.Single(preview.Items, i => i.EntityType == "Site" && i.Name == "plant-1");
        Assert.Equal(ConflictKind.Identical, siteItem.Kind);
        // C2: DataConnection preview items are site-qualified ({site}/{name}).
        var connItem = Assert.Single(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "plant-1/OpcUaPrimary");
        Assert.Equal(ConflictKind.Identical, connItem.Kind);

        // No blocker — the template + connection both resolve in the target.
        Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker && i.EntityType == "Instance");
    }

    [Fact]
    public async Task PreviewAsync_two_sites_same_connection_name_emit_two_distinct_site_qualified_items()
    {
        // C2: a bundle with plant-1/OpcUaPrimary + plant-2/OpcUaPrimary must surface
        // TWO distinct DataConnection preview items, each site-qualified
        // ({site}/{name}) — NOT a single collapsed "OpcUaPrimary" item. This is what
        // lets the operator (and the apply path) resolve each site's connection
        // independently. Hand-pack the bundle so both same-named connections are
        // present without a heavy two-site export.
        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", 3, null),
                new DataConnectionDto("plant-2", "OpcUaPrimary", "OpcUa", 3, null),
            },
            Instances = Array.Empty<InstanceDto>(),
        };
        var bytes = await PackBundleAsync(content);

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        var connItems = preview.Items
            .Where(i => i.EntityType == "DataConnection")
            .Select(i => i.Name)
            .OrderBy(n => n, StringComparer.Ordinal)
            .ToList();

        // Two distinct site-qualified items — NOT one bare "OpcUaPrimary".
        Assert.Equal(new[] { "plant-1/OpcUaPrimary", "plant-2/OpcUaPrimary" }, connItems);
        Assert.DoesNotContain(preview.Items, i => i.EntityType == "DataConnection" && i.Name == "OpcUaPrimary");
    }

    [Fact]
    public async Task PreviewAsync_modified_instance_against_hydrated_target_shows_child_diff_not_all_added()
    {
        // Arrange: seed a site closure (instance has a Flow=42 attribute override),
        // export it, then mutate the TARGET instance's override value. The diff must
        // surface a single modified child override — proving CompareInstance received
        // a HYDRATED existing instance (I2). A non-hydrated entity would read the
        // existing children as empty and report the bundle's override as an addition.
        await SeedSiteClosureAsync();
        var bundleStream = await ExportAllSitesAsync();
        var bytes = await StreamToBytes(bundleStream);

        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var ovr = await ctx.InstanceAttributeOverrides.SingleAsync(o => o.AttributeName == "Flow");
            ovr.OverrideValue = "99"; // bundle still carries 42
            await ctx.SaveChangesAsync();
        }

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        var instItem = Assert.Single(preview.Items, i => i.EntityType == "Instance" && i.Name == "Pump-01");
        Assert.Equal(ConflictKind.Modified, instItem.Kind);
        Assert.NotNull(instItem.FieldDiffJson);
        // The diff names the AttributeOverrides collection (a change, NOT an add of
        // every existing child). The hydrated existing instance already had a Flow
        // override, so the diff is a modification, not an addition.
        Assert.Contains("AttributeOverrides", instItem.FieldDiffJson!, StringComparison.Ordinal);
        // A non-hydrated existing would have reported AreaName/State unchanged but
        // every override as Added — assert the diff did NOT explode into the other
        // unchanged children (ConnectionBindings DataSourceReference matches verbatim).
        Assert.DoesNotContain("\"ConnectionBindings\"", instItem.FieldDiffJson!, StringComparison.Ordinal);
    }

    [Fact]
    public async Task PreviewAsync_instance_with_missing_template_emits_blocker()
    {
        // Arrange: hand-build a bundle whose instance references a template present in
        // NEITHER the bundle nor the target. (The real exporter would never produce
        // this — it always carries the instance's template — so we pack directly.)
        var content = EmptyContent() with
        {
            Instances = new[]
            {
                SimpleInstanceDto("Ghost-01", templateName: "NoSuchTemplate", siteIdentifier: "plant-1"),
            },
        };
        var bytes = await PackBundleAsync(content);

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        Assert.Contains(preview.Items, i =>
            i.Kind == ConflictKind.Blocker
            && i.EntityType == "Instance"
            && i.Name == "Ghost-01"
            && i.BlockerReason is not null
            && i.BlockerReason.Contains("NoSuchTemplate", StringComparison.Ordinal));
    }

    [Fact]
    public async Task PreviewAsync_referenced_connection_absent_from_bundle_and_target_emits_blocker()
    {
        // Arrange: seed the template in the target so the instance's TEMPLATE resolves
        // (isolating the connection blocker), then hand-build a bundle whose instance
        // binds an attribute to a connection that is in neither the bundle nor the
        // target.
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Templates.Add(new Template("Pump") { Description = "resolves" });
            await ctx.SaveChangesAsync();
        }

        var content = EmptyContent() with
        {
            Instances = new[]
            {
                SimpleInstanceDto("Pump-99", templateName: "Pump", siteIdentifier: "plant-1",
                    bindings: new[]
                    {
                        new InstanceConnectionBindingDto("Flow", "PhantomConn", DataSourceReferenceOverride: null),
                    }),
            },
        };
        var bytes = await PackBundleAsync(content);

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
            preview = await importer.PreviewAsync(session.SessionId);
        }

        // The unresolved connection blocks… (C2: blocker Name is site-qualified
        // {site}/{name} so two sites' same-named missing connections don't collide).
        Assert.Contains(preview.Items, i =>
            i.Kind == ConflictKind.Blocker
            && i.Name == "plant-1/PhantomConn"
            && i.BlockerReason is not null
            && i.BlockerReason.Contains("PhantomConn", StringComparison.Ordinal));
        // …but the template resolves in the target, so the instance is NOT a
        // missing-template blocker.
        Assert.DoesNotContain(preview.Items, i =>
            i.Kind == ConflictKind.Blocker
            && i.EntityType == "Instance"
            && i.Name == "Pump-99");
    }
}
