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 System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;

namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization;

public sealed class EntitySerializerTests
{
    private static EntityAggregate MakeEmptyAggregate() => new(
        TemplateFolders: Array.Empty<TemplateFolder>(),
        Templates: Array.Empty<Template>(),
        SharedScripts: Array.Empty<SharedScript>(),
        ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
        ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
        DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
        NotificationLists: Array.Empty<NotificationList>(),
        SmtpConfigurations: Array.Empty<SmtpConfiguration>(),
        ApiMethods: Array.Empty<ApiMethod>());

    [Fact]
    public void ToDto_carves_external_system_credentials_into_secrets_block()
    {
        var sys = new ExternalSystemDefinition("erp", "https://erp/api", "ApiKey")
        {
            Id = 1,
            AuthConfiguration = "{\"apiKey\":\"super-secret\"}",
        };
        var aggregate = MakeEmptyAggregate() with { ExternalSystems = new[] { sys } };

        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(aggregate);

        var dtoSys = Assert.Single(dto.ExternalSystems);
        Assert.NotNull(dtoSys.Secrets);
        Assert.True(dtoSys.Secrets!.Values.ContainsKey("AuthConfiguration"));
        Assert.Equal("{\"apiKey\":\"super-secret\"}", dtoSys.Secrets.Values["AuthConfiguration"]);
        // Public part does not carry the secret.
        Assert.Equal("erp", dtoSys.Name);
        Assert.Equal("https://erp/api", dtoSys.BaseUrl);
        Assert.Equal("ApiKey", dtoSys.AuthType);
    }

    [Fact]
    public void ToDto_carves_smtp_password_into_secrets_block()
    {
        var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
        {
            Id = 1,
            Port = 587,
            Credentials = "user:p@ssw0rd",
        };
        var aggregate = MakeEmptyAggregate() with { SmtpConfigurations = new[] { smtp } };

        var dto = new EntitySerializer().ToBundleContent(aggregate);

        var dtoSmtp = Assert.Single(dto.SmtpConfigs);
        Assert.NotNull(dtoSmtp.Secrets);
        Assert.Equal("user:p@ssw0rd", dtoSmtp.Secrets!.Values["Credentials"]);
        Assert.Equal("smtp.example.com", dtoSmtp.Host);
        Assert.Equal(587, dtoSmtp.Port);
    }

    [Fact]
    public void Roundtrip_template_preserves_attributes_alarms_scripts_composition()
    {
        var folder = new TemplateFolder("root") { Id = 1, SortOrder = 0 };
        var basic = new Template("Basic") { Id = 1, FolderId = 1, Description = "base" };
        basic.Attributes.Add(new TemplateAttribute("Pressure")
        {
            Id = 1,
            TemplateId = 1,
            DataType = DataType.Double,
            Value = "0",
            IsLocked = true,
            Description = "PSI",
        });
        basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
        {
            Id = 1,
            TemplateId = 1,
            TriggerType = "Periodic",
            ParameterDefinitions = "[]",
            ReturnDefinition = "void",
            IsLocked = false,
            MinTimeBetweenRuns = TimeSpan.FromSeconds(30),
        });
        basic.Alarms.Add(new TemplateAlarm("High")
        {
            Id = 1,
            TemplateId = 1,
            PriorityLevel = 2,
            TriggerType = AlarmTriggerType.RangeViolation,
            TriggerConfiguration = "{\"threshold\":100}",
            IsLocked = false,
            // FU-B / #37 — alarm fires "OnUpdate" script when triggered. The FK is
            // an in-aggregate script id; the DTO carries the name and the
            // importer resolves the FK on the way back in.
            OnTriggerScriptId = 1,
        });

        var assembly = new Template("Assembly") { Id = 2, FolderId = 1 };
        assembly.Compositions.Add(new TemplateComposition("MotorA")
        {
            Id = 1,
            TemplateId = 2,
            ComposedTemplateId = 1, // refers to "Basic".
        });

        var aggregate = MakeEmptyAggregate() with
        {
            TemplateFolders = new[] { folder },
            Templates = new[] { basic, assembly },
        };

        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(aggregate);

        // FU-B / #37 — verify the DTO carries OnTriggerScriptName by NAME (not id).
        // The bundle is portable across environments so a script-id FK can't
        // survive a round-trip; resolution back to a script id is the importer's
        // job once the parent template's scripts have been re-persisted.
        var dtoBasic = Assert.Single(dto.Templates, t => t.Name == "Basic");
        var dtoAlarm = Assert.Single(dtoBasic.Alarms);
        Assert.Equal("OnUpdate", dtoAlarm.OnTriggerScriptName);

        var roundTripped = sut.FromBundleContent(dto);

        var rtBasic = Assert.Single(roundTripped.Templates, t => t.Name == "Basic");
        var rtAttr = Assert.Single(rtBasic.Attributes);
        Assert.Equal("Pressure", rtAttr.Name);
        Assert.Equal(DataType.Double, rtAttr.DataType);
        Assert.Equal("0", rtAttr.Value);
        Assert.True(rtAttr.IsLocked);

        var rtAlarm = Assert.Single(rtBasic.Alarms);
        Assert.Equal("High", rtAlarm.Name);
        Assert.Equal(AlarmTriggerType.RangeViolation, rtAlarm.TriggerType);
        Assert.Equal("{\"threshold\":100}", rtAlarm.TriggerConfiguration);
        Assert.Equal(2, rtAlarm.PriorityLevel);
        // FromBundleContent leaves OnTriggerScriptId null — the importer
        // resolves it post-persist when target-DB script ids are known.
        Assert.Null(rtAlarm.OnTriggerScriptId);

        var rtScript = Assert.Single(rtBasic.Scripts);
        Assert.Equal("OnUpdate", rtScript.Name);
        Assert.Equal("return 1;", rtScript.Code);
        Assert.Equal("Periodic", rtScript.TriggerType);
        Assert.Equal(TimeSpan.FromSeconds(30), rtScript.MinTimeBetweenRuns);

        var rtAssembly = Assert.Single(roundTripped.Templates, t => t.Name == "Assembly");
        var rtComp = Assert.Single(rtAssembly.Compositions);
        Assert.Equal("MotorA", rtComp.InstanceName);
    }

    [Fact]
    public void Roundtrip_template_preserves_LockedInDerived_on_attribute_alarm_script()
    {
        var basic = new Template("Basic") { Id = 1 };
        basic.Attributes.Add(new TemplateAttribute("Pressure")
        {
            Id = 1,
            TemplateId = 1,
            DataType = DataType.Double,
            LockedInDerived = true,
        });
        basic.Alarms.Add(new TemplateAlarm("High")
        {
            Id = 1,
            TemplateId = 1,
            TriggerType = AlarmTriggerType.RangeViolation,
            LockedInDerived = true,
        });
        basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
        {
            Id = 1,
            TemplateId = 1,
            LockedInDerived = true,
        });

        var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } };

        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(aggregate);

        // Export carries the flag on all three child DTOs.
        var dtoBasic = Assert.Single(dto.Templates);
        Assert.True(Assert.Single(dtoBasic.Attributes).LockedInDerived);
        Assert.True(Assert.Single(dtoBasic.Alarms).LockedInDerived);
        Assert.True(Assert.Single(dtoBasic.Scripts).LockedInDerived);

        // FromBundleContent restores it onto the entities.
        var rtBasic = Assert.Single(sut.FromBundleContent(dto).Templates);
        Assert.True(Assert.Single(rtBasic.Attributes).LockedInDerived);
        Assert.True(Assert.Single(rtBasic.Alarms).LockedInDerived);
        Assert.True(Assert.Single(rtBasic.Scripts).LockedInDerived);
    }

    [Fact]
    public void Roundtrip_template_preserves_NativeAlarmSources()
    {
        var basic = new Template("Basic") { Id = 1 };
        basic.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler")
        {
            Id = 1,
            TemplateId = 1,
            Description = "boiler alarms",
            ConnectionName = "OpcUaPrimary",
            SourceReference = "ns=3;s=Boiler.Alarm",
            ConditionFilter = "severity>500",
            IsLocked = true,
            IsInherited = true,
            LockedInDerived = true,
        });

        var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } };

        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(aggregate);

        var dtoSrc = Assert.Single(Assert.Single(dto.Templates).NativeAlarmSources);
        Assert.Equal("Boiler", dtoSrc.Name);
        Assert.Equal("OpcUaPrimary", dtoSrc.ConnectionName);
        Assert.Equal("ns=3;s=Boiler.Alarm", dtoSrc.SourceReference);
        Assert.Equal("severity>500", dtoSrc.ConditionFilter);
        Assert.True(dtoSrc.IsLocked);
        Assert.True(dtoSrc.IsInherited);
        Assert.True(dtoSrc.LockedInDerived);

        var rtSrc = Assert.Single(Assert.Single(sut.FromBundleContent(dto).Templates).NativeAlarmSources);
        Assert.Equal("Boiler", rtSrc.Name);
        Assert.Equal("boiler alarms", rtSrc.Description);
        Assert.Equal("OpcUaPrimary", rtSrc.ConnectionName);
        Assert.Equal("ns=3;s=Boiler.Alarm", rtSrc.SourceReference);
        Assert.Equal("severity>500", rtSrc.ConditionFilter);
        Assert.True(rtSrc.IsLocked);
        Assert.True(rtSrc.IsInherited);
        Assert.True(rtSrc.LockedInDerived);
    }

    [Fact]
    public void OldForm_bundle_json_without_LockedInDerived_deserializes_to_false()
    {
        // A bundle written before LockedInDerived existed: the property is absent
        // from every template-child object. It must deserialize to false, not throw.
        // PascalCase keys + string enum names — the on-wire form BundleJsonOptions
        // produces (no naming policy, JsonStringEnumConverter, case-sensitive). All
        // required collections present; LockedInDerived absent on every child.
        const string json = """
            {
              "TemplateFolders": [],
              "Templates": [
                {
                  "Name": "Legacy",
                  "FolderName": null,
                  "BaseTemplateName": null,
                  "Description": null,
                  "Attributes": [
                    { "Name": "A", "Value": "1", "DataType": "Double", "IsLocked": false,
                      "Description": null, "DataSourceReference": null }
                  ],
                  "Alarms": [
                    { "Name": "Alm", "Description": null, "PriorityLevel": 1,
                      "TriggerType": "RangeViolation", "TriggerConfiguration": null, "IsLocked": false,
                      "OnTriggerScriptName": null }
                  ],
                  "Scripts": [
                    { "Name": "S", "Code": "return 1;", "TriggerType": null,
                      "TriggerConfiguration": null, "ParameterDefinitions": null,
                      "ReturnDefinition": null, "IsLocked": false }
                  ],
                  "Compositions": []
                }
              ],
              "SharedScripts": [],
              "ExternalSystems": [],
              "DatabaseConnections": [],
              "NotificationLists": [],
              "SmtpConfigs": [],
              "ApiMethods": []
            }
            """;

        var content = JsonSerializer.Deserialize<BundleContentDto>(json, BundleJsonOptions.Default)!;
        var template = Assert.Single(content.Templates);
        Assert.False(Assert.Single(template.Attributes).LockedInDerived);
        Assert.False(Assert.Single(template.Alarms).LockedInDerived);
        Assert.False(Assert.Single(template.Scripts).LockedInDerived);
    }

    [Fact]
    public void Roundtrip_template_folder_preserves_hierarchy()
    {
        var root = new TemplateFolder("Root") { Id = 1, SortOrder = 0 };
        var child = new TemplateFolder("Pumps") { Id = 2, ParentFolderId = 1, SortOrder = 1 };

        var aggregate = MakeEmptyAggregate() with
        {
            TemplateFolders = new[] { root, child },
        };

        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(aggregate);
        var rt = sut.FromBundleContent(dto);

        Assert.Equal(2, rt.TemplateFolders.Count);
        var rtRoot = Assert.Single(rt.TemplateFolders, f => f.Name == "Root");
        var rtChild = Assert.Single(rt.TemplateFolders, f => f.Name == "Pumps");
        Assert.Null(rtRoot.ParentFolderId);
        // Hierarchy is preserved by name reference; new local ids get assigned but
        // the child's parent must still point at the row whose name is "Root".
        Assert.NotNull(rtChild.ParentFolderId);
        Assert.Equal(rtRoot.Id, rtChild.ParentFolderId);
    }

    [Fact]
    public void Roundtrip_external_system_preserves_retry_config()
    {
        var sys = new ExternalSystemDefinition("billing", "https://billing/api", "Basic")
        {
            Id = 1,
            MaxRetries = 5,
            RetryDelay = TimeSpan.FromSeconds(15),
        };
        var aggregate = MakeEmptyAggregate() with { ExternalSystems = new[] { sys } };

        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(aggregate);
        var roundTripped = sut.FromBundleContent(dto);

        var rtSys = Assert.Single(roundTripped.ExternalSystems);
        Assert.Equal("billing", rtSys.Name);
        Assert.Equal(5, rtSys.MaxRetries);
        Assert.Equal(TimeSpan.FromSeconds(15), rtSys.RetryDelay);
    }

    [Fact]
    public void FromDto_with_null_SecretsBlock_yields_entity_with_default_empty_secrets()
    {
        var dto = new BundleContentDto(
            TemplateFolders: Array.Empty<TemplateFolderDto>(),
            Templates: Array.Empty<TemplateDto>(),
            SharedScripts: Array.Empty<SharedScriptDto>(),
            ExternalSystems: new[]
            {
                new ExternalSystemDto("erp", "https://x", "None", MaxRetries: 3, RetryDelay: TimeSpan.FromSeconds(5), Array.Empty<ExternalSystemMethodDto>(), Secrets: null),
            },
            DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
            NotificationLists: Array.Empty<NotificationListDto>(),
            SmtpConfigs: Array.Empty<SmtpConfigDto>(),
            ApiMethods: Array.Empty<ApiMethodDto>());

        var aggregate = new EntitySerializer().FromBundleContent(dto);

        var sys = Assert.Single(aggregate.ExternalSystems);
        Assert.Null(sys.AuthConfiguration);
    }

    [Fact]
    public void Roundtrip_List_attribute_preserves_ElementDataType()
    {
        // A template with a DataType.List attribute whose element type is String.
        var template = new Template("Pump") { Id = 1 };
        template.Attributes.Add(new TemplateAttribute("Tags")
        {
            Id = 1,
            TemplateId = 1,
            DataType = DataType.List,
            ElementDataType = DataType.String,
            Value = "[\"a\",\"b\"]",
            IsLocked = false,
        });

        var aggregate = MakeEmptyAggregate() with { Templates = new[] { template } };

        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(aggregate);

        // Export side: DTO must carry ElementDataType.
        var dtoTemplate = Assert.Single(dto.Templates);
        var dtoAttr = Assert.Single(dtoTemplate.Attributes);
        Assert.Equal(DataType.List, dtoAttr.DataType);
        Assert.Equal(DataType.String, dtoAttr.ElementDataType);

        // Import side: entity reconstructed from DTO preserves the value.
        var roundTripped = sut.FromBundleContent(dto);
        var rtTemplate = Assert.Single(roundTripped.Templates);
        var rtAttr = Assert.Single(rtTemplate.Attributes);
        Assert.Equal(DataType.List, rtAttr.DataType);
        Assert.Equal(DataType.String, rtAttr.ElementDataType);
        Assert.Equal("[\"a\",\"b\"]", rtAttr.Value);
    }

    private static BundleContentDto MakeContentWithListAttribute(
        string value, DataType elementType)
    {
        var template = new TemplateDto(
            Name: "Pump",
            FolderName: null,
            BaseTemplateName: null,
            Description: null,
            Attributes: new[]
            {
                new TemplateAttributeDto(
                    Name: "Tags",
                    Value: value,
                    DataType: DataType.List,
                    IsLocked: false,
                    Description: null,
                    DataSourceReference: null,
                    ElementDataType: elementType),
            },
            Alarms: Array.Empty<TemplateAlarmDto>(),
            Scripts: Array.Empty<TemplateScriptDto>(),
            Compositions: Array.Empty<TemplateCompositionDto>());

        return new BundleContentDto(
            TemplateFolders: Array.Empty<TemplateFolderDto>(),
            Templates: new[] { template },
            SharedScripts: Array.Empty<SharedScriptDto>(),
            ExternalSystems: Array.Empty<ExternalSystemDto>(),
            DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
            NotificationLists: Array.Empty<NotificationListDto>(),
            SmtpConfigs: Array.Empty<SmtpConfigDto>(),
            ApiMethods: Array.Empty<ApiMethodDto>());
    }

    [Fact]
    public void Import_normalizes_old_form_Int32_list_value_to_native_json()
    {
        // Pre-native bundle: quoted Int32 list elements.
        var dto = MakeContentWithListAttribute("[\"10\",\"20\"]", DataType.Int32);

        var aggregate = new EntitySerializer().FromBundleContent(dto);

        var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
        Assert.Equal(DataType.List, attr.DataType);
        Assert.Equal(DataType.Int32, attr.ElementDataType);
        // Imported native: numbers unquoted.
        Assert.Equal("[10,20]", attr.Value);
    }

    [Fact]
    public void Import_leaves_string_list_value_quoted()
    {
        var dto = MakeContentWithListAttribute("[\"a\",\"b\"]", DataType.String);

        var aggregate = new EntitySerializer().FromBundleContent(dto);

        var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
        Assert.Equal(DataType.List, attr.DataType);
        Assert.Equal(DataType.String, attr.ElementDataType);
        // Strings stay quoted in native form.
        Assert.Equal("[\"a\",\"b\"]", attr.Value);
    }

    [Fact]
    public void Import_leaves_malformed_list_value_unchanged_without_throwing()
    {
        // Truncated JSON array — Decode throws FormatException; the import must
        // still succeed and carry the value through verbatim (DB normalizer is
        // the backstop).
        var dto = MakeContentWithListAttribute("[\"a\"", DataType.String);

        var aggregate = new EntitySerializer().FromBundleContent(dto);

        var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
        Assert.Equal("[\"a\"", attr.Value);
    }

    [Fact]
    public void Import_leaves_scalar_attribute_value_unchanged()
    {
        var template = new TemplateDto(
            Name: "Sensor",
            FolderName: null,
            BaseTemplateName: null,
            Description: null,
            Attributes: new[]
            {
                new TemplateAttributeDto(
                    Name: "Pressure",
                    Value: "42.0",
                    DataType: DataType.Double,
                    IsLocked: false,
                    Description: null,
                    DataSourceReference: null,
                    ElementDataType: null),
            },
            Alarms: Array.Empty<TemplateAlarmDto>(),
            Scripts: Array.Empty<TemplateScriptDto>(),
            Compositions: Array.Empty<TemplateCompositionDto>());
        var dto = new BundleContentDto(
            TemplateFolders: Array.Empty<TemplateFolderDto>(),
            Templates: new[] { template },
            SharedScripts: Array.Empty<SharedScriptDto>(),
            ExternalSystems: Array.Empty<ExternalSystemDto>(),
            DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
            NotificationLists: Array.Empty<NotificationListDto>(),
            SmtpConfigs: Array.Empty<SmtpConfigDto>(),
            ApiMethods: Array.Empty<ApiMethodDto>());

        var aggregate = new EntitySerializer().FromBundleContent(dto);

        var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
        Assert.Equal(DataType.Double, attr.DataType);
        Assert.Equal("42.0", attr.Value);
    }

    [Fact]
    public void Roundtrip_scalar_attribute_with_null_ElementDataType_remains_null()
    {
        // Backward-compat: an old bundle DTO with null ElementDataType must not throw
        // and must produce a scalar attribute with null ElementDataType.
        var template = new Template("Sensor") { Id = 1 };
        template.Attributes.Add(new TemplateAttribute("Pressure")
        {
            Id = 1,
            TemplateId = 1,
            DataType = DataType.Double,
            ElementDataType = null,
            Value = "42.0",
            IsLocked = false,
        });

        var aggregate = MakeEmptyAggregate() with { Templates = new[] { template } };

        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(aggregate);

        var dtoTemplate = Assert.Single(dto.Templates);
        var dtoAttr = Assert.Single(dtoTemplate.Attributes);
        Assert.Equal(DataType.Double, dtoAttr.DataType);
        Assert.Null(dtoAttr.ElementDataType);

        var roundTripped = sut.FromBundleContent(dto);
        var rtAttr = Assert.Single(Assert.Single(roundTripped.Templates).Attributes);
        Assert.Equal(DataType.Double, rtAttr.DataType);
        Assert.Null(rtAttr.ElementDataType);
    }

    // --- M8 (B2): site / data-connection / instance mapping ------------------

    /// <summary>
    /// Builds a populated aggregate: one template (so the instance's TemplateName
    /// resolves), one site, one OPC UA data connection on that site, and one
    /// instance with one of each child collection (attribute/alarm/native-alarm-
    /// source override + a connection binding pointing at the data connection).
    /// </summary>
    private static EntityAggregate MakePopulatedSiteAggregate()
    {
        var template = new Template("Pump") { Id = 7 };

        var site = new Site("North Plant", "north")
        {
            Id = 3,
            Description = "primary",
            NodeAAddress = "akka://sys@10.0.0.1:4053",
            NodeBAddress = "akka://sys@10.0.0.2:4053",
            GrpcNodeAAddress = "https://10.0.0.1:8083",
            GrpcNodeBAddress = "https://10.0.0.2:8083",
        };

        var conn = new DataConnection("PlcLink", "OpcUa", siteId: 3)
        {
            Id = 11,
            FailoverRetryCount = 5,
            PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://plc1:4840\",\"password\":\"s3cret\"}",
            BackupConfiguration = "{\"endpoint\":\"opc.tcp://plc2:4840\"}",
        };

        var inst = new Instance("Pump-001")
        {
            Id = 21,
            TemplateId = 7,
            SiteId = 3,
            State = InstanceState.Enabled,
        };
        inst.AttributeOverrides.Add(new InstanceAttributeOverride("Setpoint")
        {
            InstanceId = 21,
            OverrideValue = "55",
        });
        inst.AlarmOverrides.Add(new InstanceAlarmOverride("HighPressure")
        {
            InstanceId = 21,
            TriggerConfigurationOverride = "{\"hi\":90}",
            PriorityLevelOverride = 1,
        });
        inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("PlcAlarms")
        {
            InstanceId = 21,
            SourceReferenceOverride = "ns=2;s=Pump1",
        });
        inst.ConnectionBindings.Add(new InstanceConnectionBinding("Pressure")
        {
            InstanceId = 21,
            DataConnectionId = 11,
            DataSourceReferenceOverride = "ns=2;s=Pump1.P",
        });

        return MakeEmptyAggregate() with
        {
            Templates = new[] { template },
            Sites = new[] { site },
            DataConnections = new[] { conn },
            Instances = new[] { inst },
        };
    }

    [Fact]
    public void ToDto_maps_site_connection_instance_with_secrets_and_name_links()
    {
        var aggregate = MakePopulatedSiteAggregate();

        var dto = new EntitySerializer().ToBundleContent(aggregate);

        // Site: straight field copy, referenced by SiteIdentifier downstream.
        var dtoSite = Assert.Single(dto.Sites);
        Assert.Equal("north", dtoSite.SiteIdentifier);
        Assert.Equal("North Plant", dtoSite.Name);
        Assert.Equal("akka://sys@10.0.0.1:4053", dtoSite.NodeAAddress);
        Assert.Equal("https://10.0.0.2:8083", dtoSite.GrpcNodeBAddress);

        // Data connection: owning site resolved to SiteIdentifier; the protocol
        // config lands in the SecretsBlock — NOT in any public field of the DTO.
        var dtoConn = Assert.Single(dto.DataConnections);
        Assert.Equal("north", dtoConn.SiteIdentifier);
        Assert.Equal("PlcLink", dtoConn.Name);
        Assert.Equal("OpcUa", dtoConn.Protocol);
        Assert.Equal(5, dtoConn.FailoverRetryCount);
        Assert.NotNull(dtoConn.Secrets);
        Assert.Equal(
            "{\"endpoint\":\"opc.tcp://plc1:4840\",\"password\":\"s3cret\"}",
            dtoConn.Secrets!.Values["PrimaryConfiguration"]);
        Assert.Equal(
            "{\"endpoint\":\"opc.tcp://plc2:4840\"}",
            dtoConn.Secrets.Values["BackupConfiguration"]);
        // The secret must not leak into any non-secret string property of the DTO.
        Assert.DoesNotContain("s3cret", dtoConn.SiteIdentifier);
        Assert.DoesNotContain("s3cret", dtoConn.Name);
        Assert.DoesNotContain("s3cret", dtoConn.Protocol);

        // Instance: template + site resolved by name/identifier (never numeric id).
        var dtoInst = Assert.Single(dto.Instances);
        Assert.Equal("Pump-001", dtoInst.UniqueName);
        Assert.Equal("Pump", dtoInst.TemplateName);
        Assert.Equal("north", dtoInst.SiteIdentifier);
        Assert.Equal(InstanceState.Enabled, dtoInst.State);
        // Areas don't travel yet (no Areas collection on the aggregate).
        Assert.Null(dtoInst.AreaName);

        // All four child collections survive.
        var dtoAttr = Assert.Single(dtoInst.AttributeOverrides);
        Assert.Equal("Setpoint", dtoAttr.AttributeName);
        Assert.Equal("55", dtoAttr.OverrideValue);

        var dtoAlarm = Assert.Single(dtoInst.AlarmOverrides);
        Assert.Equal("HighPressure", dtoAlarm.AlarmCanonicalName);
        Assert.Equal("{\"hi\":90}", dtoAlarm.TriggerConfigurationOverride);
        Assert.Equal(1, dtoAlarm.PriorityLevelOverride);

        var dtoNative = Assert.Single(dtoInst.NativeAlarmSourceOverrides);
        Assert.Equal("PlcAlarms", dtoNative.SourceCanonicalName);
        Assert.Equal("ns=2;s=Pump1", dtoNative.SourceReferenceOverride);

        // Binding carries the RESOLVED connection NAME (not the numeric FK id).
        var dtoBinding = Assert.Single(dtoInst.ConnectionBindings);
        Assert.Equal("Pressure", dtoBinding.AttributeName);
        Assert.Equal("PlcLink", dtoBinding.ConnectionName);
        Assert.Equal("ns=2;s=Pump1.P", dtoBinding.DataSourceReferenceOverride);
    }

    [Fact]
    public void ToDto_omits_secret_key_when_backup_configuration_is_empty()
    {
        var aggregate = MakeEmptyAggregate() with
        {
            Sites = new[] { new Site("S", "s") { Id = 1 } },
            DataConnections = new[]
            {
                new DataConnection("c", "OpcUa", siteId: 1)
                {
                    Id = 1,
                    PrimaryConfiguration = "{\"e\":\"x\"}",
                    BackupConfiguration = null,
                },
            },
        };

        var dto = new EntitySerializer().ToBundleContent(aggregate);

        var dtoConn = Assert.Single(dto.DataConnections);
        Assert.NotNull(dtoConn.Secrets);
        Assert.True(dtoConn.Secrets!.Values.ContainsKey("PrimaryConfiguration"));
        // Null source value => key omitted entirely (not an empty string).
        Assert.False(dtoConn.Secrets.Values.ContainsKey("BackupConfiguration"));
    }

    [Fact]
    public void Roundtrip_site_connection_instance_restores_config_from_secrets_and_binding_name()
    {
        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(MakePopulatedSiteAggregate());

        var rt = sut.FromBundleContent(dto);

        // Site reconstructed with a synthetic id; identity + addresses preserved.
        var rtSite = Assert.Single(rt.Sites);
        Assert.Equal("north", rtSite.SiteIdentifier);
        Assert.Equal("North Plant", rtSite.Name);
        Assert.Equal("akka://sys@10.0.0.1:4053", rtSite.NodeAAddress);

        // Data connection: protocol config restored from the SecretsBlock; owning
        // site FK resolved to the reconstructed site's synthetic id.
        var rtConn = Assert.Single(rt.DataConnections);
        Assert.Equal("PlcLink", rtConn.Name);
        Assert.Equal("OpcUa", rtConn.Protocol);
        Assert.Equal(5, rtConn.FailoverRetryCount);
        Assert.Equal(rtSite.Id, rtConn.SiteId);
        Assert.Equal(
            "{\"endpoint\":\"opc.tcp://plc1:4840\",\"password\":\"s3cret\"}",
            rtConn.PrimaryConfiguration);
        Assert.Equal("{\"endpoint\":\"opc.tcp://plc2:4840\"}", rtConn.BackupConfiguration);

        // Instance: template + site FKs resolved to the reconstructed synthetic ids;
        // Area never travels so AreaId stays null.
        var rtInst = Assert.Single(rt.Instances);
        Assert.Equal("Pump-001", rtInst.UniqueName);
        Assert.Equal(InstanceState.Enabled, rtInst.State);
        Assert.Equal(Assert.Single(rt.Templates).Id, rtInst.TemplateId);
        Assert.Equal(rtSite.Id, rtInst.SiteId);
        Assert.Null(rtInst.AreaId);

        // Child overrides survive the round-trip.
        Assert.Equal("Setpoint", Assert.Single(rtInst.AttributeOverrides).AttributeName);
        Assert.Equal("HighPressure", Assert.Single(rtInst.AlarmOverrides).AlarmCanonicalName);
        Assert.Equal("PlcAlarms", Assert.Single(rtInst.NativeAlarmSourceOverrides).SourceCanonicalName);

        // The binding's resolved ConnectionName is preserved on the wire DTO so the
        // importer (D1) can resolve it to a target-DB FK at apply time. The
        // reconstructed entity carries AttributeName + override but leaves the
        // numeric DataConnectionId at its unresolved placeholder (0).
        var dtoBinding = Assert.Single(Assert.Single(dto.Instances).ConnectionBindings);
        Assert.Equal("PlcLink", dtoBinding.ConnectionName);
        var rtBinding = Assert.Single(rtInst.ConnectionBindings);
        Assert.Equal("Pressure", rtBinding.AttributeName);
        Assert.Equal("ns=2;s=Pump1.P", rtBinding.DataSourceReferenceOverride);
        Assert.Equal(0, rtBinding.DataConnectionId);
    }

    // --- S10: SMS recipient PhoneNumber + SmsConfiguration round-trip ---------

    [Fact]
    public void ToDto_carries_sms_recipient_phone_number()
    {
        var list = new NotificationList("on-call") { Id = 1, Type = NotificationType.Sms };
        list.Recipients.Add(NotificationRecipient.ForSms("Pager", "+15551234567"));
        var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } };

        var dto = new EntitySerializer().ToBundleContent(aggregate);

        var dtoList = Assert.Single(dto.NotificationLists);
        Assert.Equal(NotificationType.Sms, dtoList.Type);
        var dtoRecipient = Assert.Single(dtoList.Recipients);
        Assert.Equal("Pager", dtoRecipient.Name);
        Assert.Equal("+15551234567", dtoRecipient.PhoneNumber);
        // SMS-only recipient: no email is carried (the old email-only filter would
        // have dropped this recipient entirely).
        Assert.Null(dtoRecipient.EmailAddress);
    }

    [Fact]
    public void Roundtrip_sms_recipient_preserves_phone_number()
    {
        var list = new NotificationList("on-call") { Id = 1, Type = NotificationType.Sms };
        list.Recipients.Add(NotificationRecipient.ForSms("Pager", "+15551234567"));
        var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } };

        var sut = new EntitySerializer();
        var rt = sut.FromBundleContent(sut.ToBundleContent(aggregate));

        var rtList = Assert.Single(rt.NotificationLists);
        Assert.Equal(NotificationType.Sms, rtList.Type);
        var rtRecipient = Assert.Single(rtList.Recipients);
        Assert.Equal("Pager", rtRecipient.Name);
        Assert.Equal("+15551234567", rtRecipient.PhoneNumber);
        Assert.Null(rtRecipient.EmailAddress);
    }

    [Fact]
    public void Roundtrip_email_recipient_preserves_email_and_leaves_phone_null()
    {
        var list = new NotificationList("alerts") { Id = 1, Type = NotificationType.Email };
        list.Recipients.Add(NotificationRecipient.ForEmail("Ops", "ops@example.com"));
        var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } };

        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(aggregate);
        var rt = sut.FromBundleContent(dto);

        var dtoRecipient = Assert.Single(Assert.Single(dto.NotificationLists).Recipients);
        Assert.Equal("ops@example.com", dtoRecipient.EmailAddress);
        Assert.Null(dtoRecipient.PhoneNumber);

        var rtRecipient = Assert.Single(Assert.Single(rt.NotificationLists).Recipients);
        Assert.Equal("Ops", rtRecipient.Name);
        Assert.Equal("ops@example.com", rtRecipient.EmailAddress);
        Assert.Null(rtRecipient.PhoneNumber);
    }

    [Fact]
    public void Roundtrip_recipient_with_both_contacts_preserves_both()
    {
        var list = new NotificationList("dual") { Id = 1, Type = NotificationType.Sms };
        // A recipient carrying BOTH an email and a phone number must round-trip
        // both contacts (neither is lost on import).
        list.Recipients.Add(new NotificationRecipient("Both", "both@example.com")
        {
            PhoneNumber = "+15559876543",
        });
        var aggregate = MakeEmptyAggregate() with { NotificationLists = new[] { list } };

        var sut = new EntitySerializer();
        var rt = sut.FromBundleContent(sut.ToBundleContent(aggregate));

        var rtRecipient = Assert.Single(Assert.Single(rt.NotificationLists).Recipients);
        Assert.Equal("both@example.com", rtRecipient.EmailAddress);
        Assert.Equal("+15559876543", rtRecipient.PhoneNumber);
    }

    [Fact]
    public void FromDto_recipient_without_phone_number_yields_null_phone()
    {
        // Backward-compat at the DTO level: a recipient DTO built without a phone
        // number (old bundles never carried one) imports with PhoneNumber == null.
        var dto = new BundleContentDto(
            TemplateFolders: Array.Empty<TemplateFolderDto>(),
            Templates: Array.Empty<TemplateDto>(),
            SharedScripts: Array.Empty<SharedScriptDto>(),
            ExternalSystems: Array.Empty<ExternalSystemDto>(),
            DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
            NotificationLists: new[]
            {
                new NotificationListDto(
                    "alerts",
                    NotificationType.Email,
                    new[] { new NotificationRecipientDto("Ops", "ops@example.com") }),
            },
            SmtpConfigs: Array.Empty<SmtpConfigDto>(),
            ApiMethods: Array.Empty<ApiMethodDto>());

        var aggregate = new EntitySerializer().FromBundleContent(dto);

        var recipient = Assert.Single(Assert.Single(aggregate.NotificationLists).Recipients);
        Assert.Equal("ops@example.com", recipient.EmailAddress);
        Assert.Null(recipient.PhoneNumber);
    }

    [Fact]
    public void ToDto_carves_sms_auth_token_into_secrets_block()
    {
        var sms = new SmsConfiguration("AC123", "+15550001111")
        {
            Id = 1,
            AuthToken = "twilio-super-secret",
            MessagingServiceSid = "MG999",
            ApiBaseUrl = "https://api.twilio.com",
            ConnectionTimeoutSeconds = 20,
            MaxRetries = 7,
            RetryDelay = TimeSpan.FromSeconds(45),
        };
        var aggregate = MakeEmptyAggregate() with { SmsConfigurations = new[] { sms } };

        var dto = new EntitySerializer().ToBundleContent(aggregate);

        var dtoSms = Assert.Single(dto.SmsConfigs);
        Assert.Equal("AC123", dtoSms.AccountSid);
        Assert.Equal("+15550001111", dtoSms.FromNumber);
        Assert.Equal("MG999", dtoSms.MessagingServiceSid);
        // The auth token lands in the SecretsBlock — NOT in any public field.
        Assert.NotNull(dtoSms.Secrets);
        Assert.Equal("twilio-super-secret", dtoSms.Secrets!.Values["AuthToken"]);
        Assert.DoesNotContain("twilio-super-secret", dtoSms.AccountSid);
        Assert.DoesNotContain("twilio-super-secret", dtoSms.FromNumber);
    }

    [Fact]
    public void Roundtrip_sms_config_restores_auth_token_from_secrets_block()
    {
        var sms = new SmsConfiguration("AC123", "+15550001111")
        {
            Id = 1,
            AuthToken = "twilio-super-secret",
            MessagingServiceSid = "MG999",
            ApiBaseUrl = "https://api.twilio.com",
            ConnectionTimeoutSeconds = 20,
            MaxRetries = 7,
            RetryDelay = TimeSpan.FromSeconds(45),
        };
        var aggregate = MakeEmptyAggregate() with { SmsConfigurations = new[] { sms } };

        var sut = new EntitySerializer();
        var rt = sut.FromBundleContent(sut.ToBundleContent(aggregate));

        var rtSms = Assert.Single(rt.SmsConfigurations);
        Assert.Equal("AC123", rtSms.AccountSid);
        Assert.Equal("+15550001111", rtSms.FromNumber);
        Assert.Equal("MG999", rtSms.MessagingServiceSid);
        Assert.Equal("https://api.twilio.com", rtSms.ApiBaseUrl);
        Assert.Equal(20, rtSms.ConnectionTimeoutSeconds);
        Assert.Equal(7, rtSms.MaxRetries);
        Assert.Equal(TimeSpan.FromSeconds(45), rtSms.RetryDelay);
        // Secret decrypted back out of the SecretsBlock.
        Assert.Equal("twilio-super-secret", rtSms.AuthToken);
    }

    [Fact]
    public void ToDto_omits_sms_secret_key_when_auth_token_is_null()
    {
        var sms = new SmsConfiguration("AC123", "+15550001111") { Id = 1, AuthToken = null };
        var aggregate = MakeEmptyAggregate() with { SmsConfigurations = new[] { sms } };

        var dto = new EntitySerializer().ToBundleContent(aggregate);

        var dtoSms = Assert.Single(dto.SmsConfigs);
        // No token => no SecretsBlock at all (mirrors the SMTP "empty credentials"
        // behaviour where the whole block stays null).
        Assert.Null(dtoSms.Secrets);

        // And it round-trips to a null token without throwing.
        var rt = new EntitySerializer().FromBundleContent(dto);
        Assert.Null(Assert.Single(rt.SmsConfigurations).AuthToken);
    }

    // --- EWS: Transport + the pre-existing OAuth2 authority/scope drop ---------

    [Fact]
    public void Roundtrip_smtp_config_preserves_transport_and_oauth2_fields()
    {
        // Transport is the EWS selector; OAuth2Authority/OAuth2Scope predate it and
        // were silently dropped by the bundle (they were never on the DTO). All
        // three are non-sensitive endpoint metadata, so they ride as plain DTO
        // fields — only Credentials belongs in the SecretsBlock.
        var smtp = new SmtpConfiguration("https://mail.example.test/EWS/Exchange.asmx", "Basic", "noreply@example.com")
        {
            Id = 1,
            Port = 587,
            Credentials = "user:p@ssw0rd",
            TlsMode = "StartTLS",
            ConnectionTimeoutSeconds = 25,
            MaxConcurrentConnections = 3,
            MaxRetries = 4,
            RetryDelay = TimeSpan.FromSeconds(90),
            Transport = "Ews",
            OAuth2Authority = "https://login.example.test/token",
            OAuth2Scope = "https://mail.example.test/.default",
        };
        var aggregate = MakeEmptyAggregate() with { SmtpConfigurations = new[] { smtp } };

        var sut = new EntitySerializer();
        var dto = sut.ToBundleContent(aggregate);

        var dtoSmtp = Assert.Single(dto.SmtpConfigs);
        Assert.Equal("Ews", dtoSmtp.Transport);
        Assert.Equal("https://login.example.test/token", dtoSmtp.OAuth2Authority);
        Assert.Equal("https://mail.example.test/.default", dtoSmtp.OAuth2Scope);

        var rt = sut.FromBundleContent(dto);

        var rtSmtp = Assert.Single(rt.SmtpConfigurations);
        Assert.Equal("Ews", rtSmtp.Transport);
        Assert.Equal("https://login.example.test/token", rtSmtp.OAuth2Authority);
        Assert.Equal("https://mail.example.test/.default", rtSmtp.OAuth2Scope);
        // The pre-existing fields still survive alongside the new ones.
        Assert.Equal("https://mail.example.test/EWS/Exchange.asmx", rtSmtp.Host);
        Assert.Equal(587, rtSmtp.Port);
        Assert.Equal("StartTLS", rtSmtp.TlsMode);
        Assert.Equal("user:p@ssw0rd", rtSmtp.Credentials);
        Assert.Equal(25, rtSmtp.ConnectionTimeoutSeconds);
        Assert.Equal(3, rtSmtp.MaxConcurrentConnections);
        Assert.Equal(4, rtSmtp.MaxRetries);
        Assert.Equal(TimeSpan.FromSeconds(90), rtSmtp.RetryDelay);
    }

    [Fact]
    public void FromDto_smtp_config_without_transport_or_oauth2_fields_yields_nulls()
    {
        // Backward-compat at the DTO level (mirrors the PhoneNumber precedent): an
        // SMTP DTO built without the three trailing params — as an old bundle's JSON
        // deserializes, since those properties are simply absent — imports with all
        // three null, i.e. legacy SMTP delivery and provider-default OAuth2 endpoints.
        var dto = 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: new[]
            {
                new SmtpConfigDto(
                    Host: "smtp.example.com",
                    Port: 587,
                    AuthType: "Basic",
                    FromAddress: "noreply@example.com",
                    TlsMode: "StartTLS",
                    ConnectionTimeoutSeconds: 30,
                    MaxConcurrentConnections: 2,
                    MaxRetries: 3,
                    RetryDelay: TimeSpan.FromSeconds(60),
                    Secrets: null),
            },
            ApiMethods: Array.Empty<ApiMethodDto>());

        var aggregate = new EntitySerializer().FromBundleContent(dto);

        var smtp = Assert.Single(aggregate.SmtpConfigurations);
        Assert.Null(smtp.Transport);
        Assert.Null(smtp.OAuth2Authority);
        Assert.Null(smtp.OAuth2Scope);
        Assert.Equal("smtp.example.com", smtp.Host);
    }

    [Fact]
    public void SmtpConfigDto_omits_null_transport_and_oauth2_fields_from_json()
    {
        // WhenWritingNull keeps the three new properties out of a plain-SMTP
        // bundle's JSON entirely, so the wire shape is unchanged for existing
        // exports (additive evolution — no bundleFormatVersion bump).
        var dto = new SmtpConfigDto(
            Host: "smtp.example.com",
            Port: 587,
            AuthType: "Basic",
            FromAddress: "noreply@example.com",
            TlsMode: "StartTLS",
            ConnectionTimeoutSeconds: 30,
            MaxConcurrentConnections: 2,
            MaxRetries: 3,
            RetryDelay: TimeSpan.FromSeconds(60),
            Secrets: null);

        var json = JsonSerializer.Serialize(dto, BundleJsonOptions.Default);

        Assert.DoesNotContain("Transport", json, StringComparison.Ordinal);
        Assert.DoesNotContain("OAuth2Authority", json, StringComparison.Ordinal);
        Assert.DoesNotContain("OAuth2Scope", json, StringComparison.Ordinal);
    }
}
