using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.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.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;

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

/// <summary>
/// #05-T8 — per-entity <b>structural</b> round-trip guard.
/// <para>
/// For every transported entity type, seed a MAXIMALLY populated entity (every
/// writable data property set to a non-default value), export it through the real
/// <see cref="IBundleExporter"/>, import it into a fresh target through the real
/// <see cref="IBundleImporter"/> (<c>LoadAsync</c>→<c>ApplyAsync</c>, Add path;
/// site/instance types via a <see cref="BundleNameMap"/> with
/// <see cref="MappingAction.CreateNew"/>), reload, and compare BY REFLECTION.
/// </para>
/// <para>
/// The reflection sweep (<see cref="AssertRoundTripEqual{T}"/>) is the point: it
/// walks every public writable scalar/string property and fails on the first one
/// that did not survive export→import, EXCEPT an explicit per-type exclusion list.
/// A future entity property that no DTO carries will therefore fail this test
/// instead of silently vanishing — exactly the class of regression that lost
/// <c>LockedInDerived</c>, script cadence/timeout and native-alarm sources before
/// Tasks 3–5. Excluded properties are limited to: surrogate <c>Id</c>s, FK id
/// columns that are legitimately remapped to the target's own surrogate keys,
/// inheritance-placeholder flags that are re-derived at import (never carried), and
/// the one documented design reset (imported <see cref="Instance.State"/> →
/// <see cref="InstanceState.NotDeployed"/>). Each exclusion is annotated inline.
/// </para>
/// <para>
/// Both the "source" and "imported" entities are re-read from the DB (AsNoTracking)
/// so both pass through identical persistence normalization — the comparison
/// isolates the export→import boundary, not save-time value conversion.
/// </para>
/// <para>
/// GUARD STATE (2026-07-09): five residual fidelity holes are currently exposed by
/// this suite and reported to the orchestrator rather than masked (per the T8
/// discipline — a hole is a real regression, never a test to weaken). Each has a
/// GREEN serializer-level unit test, proving the DTO carries the data and the loss
/// is in the full import/export pipeline — exactly the class this integration guard
/// exists to catch:
///   (1) <c>ExternalSystemDefinition.MaxRetries</c> — dropped on import Add.
///   (2) <c>ExternalSystemDefinition.RetryDelay</c> — dropped on import Add.
///   (3) <c>ExternalSystemMethod</c> (all rows) — dropped on import Add
///       (BuildExternalSystem carries no methods; only the Overwrite path syncs them).
///   (4) <c>NotificationRecipient</c> (all rows) — dropped on EXPORT
///       (GetNotificationListByIdAsync uses FindAsync with no Include(Recipients)).
///   (5) <c>TemplateFolder.ParentFolderId</c> — hierarchy flattened on import Add
///       (the folder Add ignores the DTO's ParentName).
/// The three tests that surface these ((RED) below) are intentionally failing until a
/// separate production task closes the gaps.
/// </para>
/// </summary>
public sealed class RoundTripEquivalenceTests : IDisposable
{
    private readonly ServiceProvider _provider;

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

        var dbName = $"RoundTripEquivalenceTests_{Guid.NewGuid()}";
        services.AddDbContext<ScadaBridgeDbContext>(opts => opts
            .UseInMemoryDatabase(dbName)
            .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));

        services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
        services.AddScoped(sp => new ScadaBridgeDbContext(
            sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
            sp.GetRequiredService<IDataProtectionProvider>()));

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

        _provider = services.BuildServiceProvider();
    }

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

    // ──────────────────────────────────────────────────────────────────────
    // Reflection sweep
    // ──────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Asserts that every public writable scalar/string property of <paramref name="source"/>
    /// equals the same property on <paramref name="imported"/>, except those in
    /// <paramref name="excluded"/>. Navigation collections and FK-id props excluded by
    /// name are ignored — they are asserted separately by each test.
    /// </summary>
    private static void AssertRoundTripEqual<T>(T source, T imported, params string[] excluded)
    {
        foreach (var p in typeof(T).GetProperties().Where(p => p.CanWrite
            && !excluded.Contains(p.Name)
            && (p.PropertyType.IsValueType || p.PropertyType == typeof(string))))
        {
            Assert.True(Equals(p.GetValue(source), p.GetValue(imported)),
                $"{typeof(T).Name}.{p.Name} did not survive export→import " +
                $"(source={p.GetValue(source)}, imported={p.GetValue(imported)})");
        }
    }

    // ──────────────────────────────────────────────────────────────────────
    // Pipeline helpers
    // ──────────────────────────────────────────────────────────────────────

    private static readonly int[] None = [];

    /// <summary>An all-empty export selection; layer the relevant ids on with a <c>with</c> expression.</summary>
    private static ExportSelection EmptySelection(bool includeDependencies = false) =>
        new(None, None, None, None, None, None, None, includeDependencies);

    private async Task RunAsync(Func<ScadaBridgeDbContext, Task> action)
    {
        await using var scope = _provider.CreateAsyncScope();
        var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
        await action(ctx);
    }

    private async Task<Guid> ExportAndLoadAsync(Func<ScadaBridgeDbContext, Task<ExportSelection>> selectionFactory)
    {
        Stream bundleStream;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var selection = await selectionFactory(ctx);
            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>Applies the bundle with the given resolutions (unlisted entities default to Add).</summary>
    private async Task<ImportResult> ApplyAsync(
        Guid sessionId, IReadOnlyList<ImportResolution> resolutions, BundleNameMap? nameMap = null)
    {
        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);
    }

    // ══════════════════════════════════════════════════════════════════════
    // Template + its five child kinds + folders
    // ══════════════════════════════════════════════════════════════════════

    [Fact]
    public async Task RoundTrip_template_closure_preserves_every_writable_property()
    {
        // A folder hierarchy (root → child), a "Composed" base template that
        // "MaxTemplate" composes, and a maximally-populated "MaxTemplate" carrying one
        // of each child kind (attribute/alarm/script/native-alarm-source/composition).
        await RunAsync(async ctx =>
        {
            var root = new TemplateFolder("Root") { SortOrder = 3 };
            ctx.TemplateFolders.Add(root);
            await ctx.SaveChangesAsync();
            var child = new TemplateFolder("Pumps") { ParentFolderId = root.Id, SortOrder = 7 };
            ctx.TemplateFolders.Add(child);
            await ctx.SaveChangesAsync();

            var composed = new Template("Composed") { Description = "composed base", FolderId = child.Id };
            ctx.Templates.Add(composed);
            await ctx.SaveChangesAsync();

            var tpl = new Template("MaxTemplate")
            {
                Description = "maximally populated template",
                FolderId = child.Id,
            };
            tpl.Attributes.Add(new TemplateAttribute("Tags")
            {
                DataType = DataType.List,
                ElementDataType = DataType.String,
                Value = "[\"alpha\",\"beta\"]",
                IsLocked = true,
                Description = "attr description",
                DataSourceReference = "ns=1;s=Tags",
                LockedInDerived = true,
            });
            tpl.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
            {
                TriggerType = "Periodic",
                TriggerConfiguration = "{\"interval\":5}",
                ParameterDefinitions = "[{\"name\":\"x\"}]",
                ReturnDefinition = "void",
                IsLocked = true,
                MinTimeBetweenRuns = TimeSpan.FromSeconds(30),
                ExecutionTimeoutSeconds = 42,
                LockedInDerived = true,
            });
            tpl.Alarms.Add(new TemplateAlarm("High")
            {
                Description = "high alarm",
                PriorityLevel = 4,
                IsLocked = true,
                TriggerType = AlarmTriggerType.RangeViolation,
                TriggerConfiguration = "{\"threshold\":100}",
                LockedInDerived = true,
            });
            tpl.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler")
            {
                Description = "boiler native alarms",
                ConnectionName = "OpcUaPrimary",
                SourceReference = "ns=3;s=Boiler.Alarm",
                ConditionFilter = "severity>500",
                IsLocked = true,
                LockedInDerived = true,
            });
            tpl.Compositions.Add(new TemplateComposition("MotorA") { ComposedTemplateId = composed.Id });
            ctx.Templates.Add(tpl);
            await ctx.SaveChangesAsync();

            // Wire the alarm's on-trigger script FK now that ids are assigned.
            var script = tpl.Scripts.Single();
            tpl.Alarms.Single().OnTriggerScriptId = script.Id;
            await ctx.SaveChangesAsync();
        });

        // Ground truth (persisted state), re-read detached.
        Template srcTemplate = null!;
        await RunAsync(async ctx => srcTemplate = await LoadTemplateAsync(ctx, "MaxTemplate"));

        var sessionId = await ExportAndLoadAsync(async ctx =>
        {
            var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
            return EmptySelection() with { TemplateIds = ids };
        });

        // Wipe so the import exercises the Add path against a fresh target.
        await RunAsync(async ctx =>
        {
            ctx.Templates.RemoveRange(ctx.Templates);
            ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
            await ctx.SaveChangesAsync();
        });

        await ApplyAsync(sessionId, []);

        await RunAsync(async ctx =>
        {
            var imported = await LoadTemplateAsync(ctx, "MaxTemplate");
            var impChild = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Pumps");

            // Template: Id + FKs remapped by name; IsDerived is engine-managed.
            AssertRoundTripEqual(srcTemplate, imported,
                nameof(Template.Id), nameof(Template.ParentTemplateId), nameof(Template.FolderId),
                nameof(Template.OwnerCompositionId), nameof(Template.IsDerived));

            // Template→folder linkage survives by name (folder-to-parent linkage is
            // covered — and currently RED — by RoundTrip_template_folders_*).
            Assert.Equal(impChild.Id, imported.FolderId);

            // TemplateAttribute. IsInherited is an inheritance placeholder re-derived at
            // import (not carried on a base-template row).
            AssertRoundTripEqual(srcTemplate.Attributes.Single(), imported.Attributes.Single(),
                nameof(TemplateAttribute.Id), nameof(TemplateAttribute.TemplateId),
                nameof(TemplateAttribute.IsInherited));

            // TemplateScript.
            AssertRoundTripEqual(srcTemplate.Scripts.Single(), imported.Scripts.Single(),
                nameof(TemplateScript.Id), nameof(TemplateScript.TemplateId),
                nameof(TemplateScript.IsInherited));

            // TemplateAlarm. OnTriggerScriptId is an in-aggregate FK re-resolved by name.
            var srcAlarm = srcTemplate.Alarms.Single();
            var impAlarm = imported.Alarms.Single();
            AssertRoundTripEqual(srcAlarm, impAlarm,
                nameof(TemplateAlarm.Id), nameof(TemplateAlarm.TemplateId),
                nameof(TemplateAlarm.OnTriggerScriptId), nameof(TemplateAlarm.IsInherited));
            // On-trigger script linkage survives by name (re-points at the new script id).
            Assert.Equal(imported.Scripts.Single().Id, impAlarm.OnTriggerScriptId);

            // TemplateNativeAlarmSource — DTO carries IsInherited, so it IS swept.
            AssertRoundTripEqual(srcTemplate.NativeAlarmSources.Single(), imported.NativeAlarmSources.Single(),
                nameof(TemplateNativeAlarmSource.Id), nameof(TemplateNativeAlarmSource.TemplateId));

            // TemplateComposition. TemplateId + ComposedTemplateId remapped by name.
            AssertRoundTripEqual(srcTemplate.Compositions.Single(), imported.Compositions.Single(),
                nameof(TemplateComposition.Id), nameof(TemplateComposition.TemplateId),
                nameof(TemplateComposition.ComposedTemplateId));
        });
    }

    private static async Task<Template> LoadTemplateAsync(ScadaBridgeDbContext ctx, string name) =>
        await ctx.Templates
            .AsNoTracking()
            .Include(t => t.Attributes)
            .Include(t => t.Alarms)
            .Include(t => t.Scripts)
            .Include(t => t.NativeAlarmSources)
            .Include(t => t.Compositions)
            .SingleAsync(t => t.Name == name);

    [Fact]
    public async Task RoundTrip_template_folders_preserve_hierarchy_and_sort_order()
    {
        // Folders travel as the ancestor chain of an exported template, so seed a
        // root→child hierarchy with a template in the child folder.
        await RunAsync(async ctx =>
        {
            var root = new TemplateFolder("Root") { SortOrder = 3 };
            ctx.TemplateFolders.Add(root);
            await ctx.SaveChangesAsync();
            var child = new TemplateFolder("Pumps") { ParentFolderId = root.Id, SortOrder = 7 };
            ctx.TemplateFolders.Add(child);
            await ctx.SaveChangesAsync();
            ctx.Templates.Add(new Template("Pump") { FolderId = child.Id });
            await ctx.SaveChangesAsync();
        });

        TemplateFolder srcRoot = null!, srcChild = null!;
        await RunAsync(async ctx =>
        {
            srcRoot = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Root");
            srcChild = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Pumps");
        });

        var sessionId = await ExportAndLoadAsync(async ctx =>
        {
            var ids = await ctx.Templates.Select(t => t.Id).ToListAsync();
            return EmptySelection() with { TemplateIds = ids };
        });

        await RunAsync(async ctx =>
        {
            ctx.Templates.RemoveRange(ctx.Templates);
            ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
            await ctx.SaveChangesAsync();
        });

        await ApplyAsync(sessionId, []);

        await RunAsync(async ctx =>
        {
            var impRoot = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Root");
            var impChild = await ctx.TemplateFolders.AsNoTracking().SingleAsync(f => f.Name == "Pumps");

            // Name + SortOrder round-trip (GREEN). ParentFolderId remapped by name.
            AssertRoundTripEqual(srcRoot, impRoot,
                nameof(TemplateFolder.Id), nameof(TemplateFolder.ParentFolderId));
            AssertRoundTripEqual(srcChild, impChild,
                nameof(TemplateFolder.Id), nameof(TemplateFolder.ParentFolderId));

            // (RED) HOLE #5 — folder hierarchy is flattened on import Add: the folder
            // Add pass builds `new TemplateFolder(dto.Name) { SortOrder }` and never
            // consults the DTO's ParentName, so the child lands parent-less. The
            // serializer-level Roundtrip_template_folder_preserves_hierarchy test is
            // GREEN — the loss is purely in BundleImporter's folder Add pass.
            Assert.Equal(impRoot.Id, impChild.ParentFolderId);
        });
    }

    // ══════════════════════════════════════════════════════════════════════
    // SharedScript
    // ══════════════════════════════════════════════════════════════════════

    [Fact]
    public async Task RoundTrip_shared_script_preserves_every_writable_property()
    {
        await RunAsync(async ctx =>
        {
            ctx.SharedScripts.Add(new SharedScript("Helpers", "int Add(int a,int b)=>a+b;")
            {
                ParameterDefinitions = "[{\"name\":\"a\"}]",
                ReturnDefinition = "int",
            });
            await ctx.SaveChangesAsync();
        });

        SharedScript src = null!;
        await RunAsync(async ctx => src = await ctx.SharedScripts.AsNoTracking().SingleAsync());

        var sessionId = await ExportAndLoadAsync(async ctx =>
        {
            var ids = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
            return EmptySelection() with { SharedScriptIds = ids };
        });

        await RunAsync(async ctx =>
        {
            ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
            await ctx.SaveChangesAsync();
        });

        await ApplyAsync(sessionId, []);

        await RunAsync(async ctx =>
        {
            var imported = await ctx.SharedScripts.AsNoTracking().SingleAsync();
            AssertRoundTripEqual(src, imported, nameof(SharedScript.Id));
        });
    }

    // ══════════════════════════════════════════════════════════════════════
    // ExternalSystemDefinition + ExternalSystemMethod
    // ══════════════════════════════════════════════════════════════════════

    [Fact]
    public async Task RoundTrip_external_system_and_method_preserve_every_writable_property()
    {
        await RunAsync(async ctx =>
        {
            var sys = new ExternalSystemDefinition("erp", "https://erp/api", "ApiKey")
            {
                AuthConfiguration = "{\"apiKey\":\"super-secret\"}", // rides SecretsBlock
                MaxRetries = 6,
                RetryDelay = TimeSpan.FromSeconds(17),
            };
            ctx.ExternalSystemDefinitions.Add(sys);
            await ctx.SaveChangesAsync();

            ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("PostOrder", "POST", "/orders")
            {
                ExternalSystemDefinitionId = sys.Id,
                ParameterDefinitions = "[{\"name\":\"body\"}]",
                ReturnDefinition = "{\"id\":\"int\"}",
            });
            await ctx.SaveChangesAsync();
        });

        ExternalSystemDefinition srcSys = null!;
        ExternalSystemMethod srcMethod = null!;
        await RunAsync(async ctx =>
        {
            srcSys = await ctx.ExternalSystemDefinitions.AsNoTracking().SingleAsync();
            srcMethod = await ctx.ExternalSystemMethods.AsNoTracking().SingleAsync();
        });

        var sessionId = await ExportAndLoadAsync(async ctx =>
        {
            var ids = await ctx.ExternalSystemDefinitions.Select(s => s.Id).ToListAsync();
            return EmptySelection() with { ExternalSystemIds = ids };
        });

        await RunAsync(async ctx =>
        {
            ctx.ExternalSystemMethods.RemoveRange(ctx.ExternalSystemMethods);
            ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions);
            await ctx.SaveChangesAsync();
        });

        await ApplyAsync(sessionId, []);

        await RunAsync(async ctx =>
        {
            var impSys = await ctx.ExternalSystemDefinitions.AsNoTracking().SingleAsync();

            // (RED) HOLES #1/#2 — MaxRetries + RetryDelay are dropped on import Add:
            // BuildExternalSystem builds `new ExternalSystemDefinition(name, url, authType)
            // { AuthConfiguration }` and never copies the DTO's retry config. The
            // serializer-level Roundtrip_external_system_preserves_retry_config test is
            // GREEN — the loss is purely in BundleImporter's Add path.
            AssertRoundTripEqual(srcSys, impSys, nameof(ExternalSystemDefinition.Id));

            // (RED) HOLE #3 — ExternalSystemMethod rows are dropped on import Add:
            // only the Overwrite path calls SyncExternalSystemMethodsAsync; the Add path
            // carries no methods (the definition entity has no Methods navigation to
            // cascade). The serializer-level round-trip carries them; the pipeline loses
            // them into a fresh target.
            var methodCount = await ctx.ExternalSystemMethods.CountAsync();
            Assert.True(methodCount == 1,
                $"FIDELITY HOLE: ExternalSystemMethod dropped on import Add (expected 1, got {methodCount}).");
            var impMethod = await ctx.ExternalSystemMethods.AsNoTracking().SingleAsync();
            AssertRoundTripEqual(srcMethod, impMethod,
                nameof(ExternalSystemMethod.Id), nameof(ExternalSystemMethod.ExternalSystemDefinitionId));
            Assert.Equal(impSys.Id, impMethod.ExternalSystemDefinitionId);
        });
    }

    // ══════════════════════════════════════════════════════════════════════
    // DatabaseConnectionDefinition (External-System DB connection)
    // ══════════════════════════════════════════════════════════════════════

    [Fact]
    public async Task RoundTrip_database_connection_preserves_every_writable_property()
    {
        await RunAsync(async ctx =>
        {
            ctx.DatabaseConnectionDefinitions.Add(
                new DatabaseConnectionDefinition("erp-db", "Server=db;Database=erp;User=sa;Password=p@ss")
                {
                    MaxRetries = 8,
                    RetryDelay = TimeSpan.FromSeconds(21),
                });
            await ctx.SaveChangesAsync();
        });

        DatabaseConnectionDefinition src = null!;
        await RunAsync(async ctx => src = await ctx.DatabaseConnectionDefinitions.AsNoTracking().SingleAsync());

        var sessionId = await ExportAndLoadAsync(async ctx =>
        {
            var ids = await ctx.DatabaseConnectionDefinitions.Select(d => d.Id).ToListAsync();
            return EmptySelection() with { DatabaseConnectionIds = ids };
        });

        await RunAsync(async ctx =>
        {
            ctx.DatabaseConnectionDefinitions.RemoveRange(ctx.DatabaseConnectionDefinitions);
            await ctx.SaveChangesAsync();
        });

        await ApplyAsync(sessionId, []);

        await RunAsync(async ctx =>
        {
            var imported = await ctx.DatabaseConnectionDefinitions.AsNoTracking().SingleAsync();
            // ConnectionString rides the SecretsBlock; the sweep verifies it survives.
            AssertRoundTripEqual(src, imported, nameof(DatabaseConnectionDefinition.Id));
        });
    }

    // ══════════════════════════════════════════════════════════════════════
    // NotificationList + NotificationRecipient
    // ══════════════════════════════════════════════════════════════════════

    [Fact]
    public async Task RoundTrip_notification_list_and_recipient_preserve_every_writable_property()
    {
        await RunAsync(async ctx =>
        {
            var list = new NotificationList("on-call") { Type = NotificationType.Sms };
            // A recipient carrying BOTH contacts so every writable field is non-default.
            list.Recipients.Add(new NotificationRecipient("Pager", "pager@example.com")
            {
                PhoneNumber = "+15551234567",
            });
            ctx.NotificationLists.Add(list);
            await ctx.SaveChangesAsync();
        });

        NotificationList srcList = null!;
        NotificationRecipient srcRecipient = null!;
        await RunAsync(async ctx =>
        {
            srcList = await ctx.NotificationLists.AsNoTracking().SingleAsync();
            srcRecipient = await ctx.NotificationRecipients.AsNoTracking().SingleAsync();
        });

        var sessionId = await ExportAndLoadAsync(async ctx =>
        {
            var ids = await ctx.NotificationLists.Select(n => n.Id).ToListAsync();
            return EmptySelection() with { NotificationListIds = ids };
        });

        await RunAsync(async ctx =>
        {
            ctx.NotificationRecipients.RemoveRange(ctx.NotificationRecipients);
            ctx.NotificationLists.RemoveRange(ctx.NotificationLists);
            await ctx.SaveChangesAsync();
        });

        await ApplyAsync(sessionId, []);

        await RunAsync(async ctx =>
        {
            var impList = await ctx.NotificationLists.AsNoTracking().SingleAsync();

            // List scalars (Name, Type) round-trip (GREEN).
            AssertRoundTripEqual(srcList, impList, nameof(NotificationList.Id));

            // (RED) HOLE #4 — recipients are dropped on EXPORT: the dependency
            // resolver loads each selected list via GetNotificationListByIdAsync, which
            // uses FindAsync(id) with NO Include(Recipients), so the exported DTO carries
            // an empty Recipients collection and the import adds a recipient-less list.
            // The serializer-level Roundtrip_*_recipient_* tests are GREEN — the loss is
            // in the export repository query, not the DTO.
            var recipientCount = await ctx.NotificationRecipients.CountAsync();
            Assert.True(recipientCount == 1,
                $"FIDELITY HOLE: NotificationRecipient dropped through export→import (expected 1, got {recipientCount}).");
            var impRecipient = await ctx.NotificationRecipients.AsNoTracking().SingleAsync();
            AssertRoundTripEqual(srcRecipient, impRecipient,
                nameof(NotificationRecipient.Id), nameof(NotificationRecipient.NotificationListId));
            Assert.Equal(impList.Id, impRecipient.NotificationListId);
        });
    }

    // ══════════════════════════════════════════════════════════════════════
    // SmtpConfiguration
    // ══════════════════════════════════════════════════════════════════════

    [Fact]
    public async Task RoundTrip_smtp_configuration_preserves_every_writable_property()
    {
        await RunAsync(async ctx =>
        {
            ctx.SmtpConfigurations.Add(new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
            {
                Port = 587,
                Credentials = "user:p@ssw0rd", // rides SecretsBlock
                TlsMode = "StartTls",
                ConnectionTimeoutSeconds = 25,
                MaxConcurrentConnections = 4,
                MaxRetries = 9,
                RetryDelay = TimeSpan.FromSeconds(33),
                // Non-sensitive delivery metadata. Left null, these three were
                // silently dropped by the bundle without the guard noticing
                // (null == null), so they are seeded explicitly here.
                Transport = "Ews",
                OAuth2Authority = "https://login.example.test/token",
                OAuth2Scope = "https://mail.example.test/.default",
            });
            await ctx.SaveChangesAsync();
        });

        SmtpConfiguration src = null!;
        await RunAsync(async ctx => src = await ctx.SmtpConfigurations.AsNoTracking().SingleAsync());

        var sessionId = await ExportAndLoadAsync(async ctx =>
        {
            var ids = await ctx.SmtpConfigurations.Select(s => s.Id).ToListAsync();
            return EmptySelection() with { SmtpConfigurationIds = ids };
        });

        await RunAsync(async ctx =>
        {
            ctx.SmtpConfigurations.RemoveRange(ctx.SmtpConfigurations);
            await ctx.SaveChangesAsync();
        });

        await ApplyAsync(sessionId, []);

        await RunAsync(async ctx =>
        {
            var imported = await ctx.SmtpConfigurations.AsNoTracking().SingleAsync();
            AssertRoundTripEqual(src, imported, nameof(SmtpConfiguration.Id));
        });
    }

    // ══════════════════════════════════════════════════════════════════════
    // SmsConfiguration
    // ══════════════════════════════════════════════════════════════════════

    [Fact]
    public async Task RoundTrip_sms_configuration_preserves_every_writable_property()
    {
        await RunAsync(async ctx =>
        {
            ctx.SmsConfigurations.Add(new SmsConfiguration("AC123", "+15550001111")
            {
                AuthToken = "twilio-super-secret", // rides SecretsBlock
                MessagingServiceSid = "MG999",
                ApiBaseUrl = "https://api.twilio.com",
                ConnectionTimeoutSeconds = 20,
                MaxRetries = 7,
                RetryDelay = TimeSpan.FromSeconds(45),
            });
            await ctx.SaveChangesAsync();
        });

        SmsConfiguration src = null!;
        await RunAsync(async ctx => src = await ctx.SmsConfigurations.AsNoTracking().SingleAsync());

        var sessionId = await ExportAndLoadAsync(async ctx =>
        {
            var ids = await ctx.SmsConfigurations.Select(s => s.Id).ToListAsync();
            return EmptySelection() with { SmsConfigurationIds = ids };
        });

        await RunAsync(async ctx =>
        {
            ctx.SmsConfigurations.RemoveRange(ctx.SmsConfigurations);
            await ctx.SaveChangesAsync();
        });

        await ApplyAsync(sessionId, []);

        await RunAsync(async ctx =>
        {
            var imported = await ctx.SmsConfigurations.AsNoTracking().SingleAsync();
            AssertRoundTripEqual(src, imported, nameof(SmsConfiguration.Id));
        });
    }

    // ══════════════════════════════════════════════════════════════════════
    // ApiMethod
    // ══════════════════════════════════════════════════════════════════════

    [Fact]
    public async Task RoundTrip_api_method_preserves_every_writable_property()
    {
        await RunAsync(async ctx =>
        {
            // Script must not name-mention an identifier the importer's semantic scan
            // would treat as a referenced SharedScript/ExternalSystem — keep it literal.
            ctx.ApiMethods.Add(new ApiMethod("Ingest", "return 42;")
            {
                ParameterDefinitions = "[{\"name\":\"x\",\"type\":\"Int32\"}]",
                ReturnDefinition = "{\"ok\":\"Boolean\"}",
                TimeoutSeconds = 55,
            });
            await ctx.SaveChangesAsync();
        });

        ApiMethod src = null!;
        await RunAsync(async ctx => src = await ctx.ApiMethods.AsNoTracking().SingleAsync());

        var sessionId = await ExportAndLoadAsync(async ctx =>
        {
            var ids = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
            return EmptySelection() with { ApiMethodIds = ids };
        });

        await RunAsync(async ctx =>
        {
            ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
            await ctx.SaveChangesAsync();
        });

        await ApplyAsync(sessionId, []);

        await RunAsync(async ctx =>
        {
            var imported = await ctx.ApiMethods.AsNoTracking().SingleAsync();
            AssertRoundTripEqual(src, imported, nameof(ApiMethod.Id));
        });
    }

    // ══════════════════════════════════════════════════════════════════════
    // Site + DataConnection + Instance + all four instance-child kinds
    // ══════════════════════════════════════════════════════════════════════

    [Fact]
    public async Task RoundTrip_site_closure_preserves_every_writable_property()
    {
        await RunAsync(async ctx =>
        {
            var template = new Template("Pump") { Description = "pump tpl" };
            ctx.Templates.Add(template);

            var site = new Site("Plant 1", "plant-1")
            {
                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("OpcUaPrimary", "OpcUa", site.Id)
            {
                PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}", // rides SecretsBlock
                BackupConfiguration = "{\"endpoint\":\"opc.tcp://backup\"}",   // rides SecretsBlock
                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("Flow")
            {
                OverrideValue = "[42,43]",
                ElementDataType = DataType.Int32,
            });
            instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm")
            {
                TriggerConfigurationOverride = "{\"hi\":90}",
                PriorityLevelOverride = 7,
            });
            instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
            {
                ConnectionNameOverride = "OpcUaPrimary",
                SourceReferenceOverride = "ns=3;s=Pump.Alarm",
                ConditionFilterOverride = "severity>200",
            });
            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();
        });

        Site srcSite = null!;
        DataConnection srcConn = null!;
        Instance srcInstance = null!;
        await RunAsync(async ctx =>
        {
            srcSite = await ctx.Sites.AsNoTracking().SingleAsync(s => s.SiteIdentifier == "plant-1");
            srcConn = await ctx.DataConnections.AsNoTracking().SingleAsync(c => c.Name == "OpcUaPrimary");
            srcInstance = await LoadInstanceAsync(ctx, "Pump-01");
        });

        var sessionId = await ExportAndLoadAsync(async ctx =>
        {
            var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
            return EmptySelection(includeDependencies: true) with { SiteIds = siteIds };
        });

        // Wipe the site closure; keep a fresh "Pump" template so the instance resolves.
        await RunAsync(async ctx =>
        {
            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.Sites.RemoveRange(ctx.Sites);
            ctx.Templates.RemoveRange(ctx.Templates);
            await ctx.SaveChangesAsync();
            ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
            await ctx.SaveChangesAsync();
        });

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

        await ApplyAsync(
            sessionId,
            [
                new("Template", "Pump", ResolutionAction.Skip, null),
                new("Site", "plant-1", ResolutionAction.Add, null),
                new("DataConnection", "plant-1/OpcUaPrimary", ResolutionAction.Add, null),
                new("Instance", "Pump-01", ResolutionAction.Add, null),
            ],
            nameMap);

        await RunAsync(async ctx =>
        {
            var impSite = await ctx.Sites.AsNoTracking().SingleAsync(s => s.SiteIdentifier == "plant-1");
            var impConn = await ctx.DataConnections.AsNoTracking().SingleAsync(c => c.Name == "OpcUaPrimary");
            var impInstance = await LoadInstanceAsync(ctx, "Pump-01");

            // Site.
            AssertRoundTripEqual(srcSite, impSite, nameof(Site.Id));

            // DataConnection. SiteId remapped; Primary/Backup config ride the SecretsBlock.
            AssertRoundTripEqual(srcConn, impConn, nameof(DataConnection.Id), nameof(DataConnection.SiteId));
            Assert.Equal(impSite.Id, impConn.SiteId);

            // Instance. TemplateId/SiteId/AreaId remapped; State is a documented design
            // reset (imported instances always land NotDeployed).
            AssertRoundTripEqual(srcInstance, impInstance,
                nameof(Instance.Id), nameof(Instance.TemplateId), nameof(Instance.SiteId),
                nameof(Instance.AreaId), nameof(Instance.State));
            Assert.Equal(InstanceState.NotDeployed, impInstance.State);
            Assert.Equal(impSite.Id, impInstance.SiteId);

            // InstanceAttributeOverride.
            AssertRoundTripEqual(srcInstance.AttributeOverrides.Single(), impInstance.AttributeOverrides.Single(),
                nameof(InstanceAttributeOverride.Id), nameof(InstanceAttributeOverride.InstanceId));

            // InstanceAlarmOverride.
            AssertRoundTripEqual(srcInstance.AlarmOverrides.Single(), impInstance.AlarmOverrides.Single(),
                nameof(InstanceAlarmOverride.Id), nameof(InstanceAlarmOverride.InstanceId));

            // InstanceNativeAlarmSourceOverride.
            AssertRoundTripEqual(srcInstance.NativeAlarmSourceOverrides.Single(), impInstance.NativeAlarmSourceOverrides.Single(),
                nameof(InstanceNativeAlarmSourceOverride.Id), nameof(InstanceNativeAlarmSourceOverride.InstanceId));

            // InstanceConnectionBinding. DataConnectionId remapped to the created conn.
            var impBinding = impInstance.ConnectionBindings.Single();
            AssertRoundTripEqual(srcInstance.ConnectionBindings.Single(), impBinding,
                nameof(InstanceConnectionBinding.Id), nameof(InstanceConnectionBinding.InstanceId),
                nameof(InstanceConnectionBinding.DataConnectionId));
            Assert.Equal(impConn.Id, impBinding.DataConnectionId);
        });
    }

    private static async Task<Instance> LoadInstanceAsync(ScadaBridgeDbContext ctx, string uniqueName) =>
        await ctx.Instances
            .AsNoTracking()
            .Include(i => i.AttributeOverrides)
            .Include(i => i.AlarmOverrides)
            .Include(i => i.NativeAlarmSourceOverrides)
            .Include(i => i.ConnectionBindings)
            .SingleAsync(i => i.UniqueName == uniqueName);
}
