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;

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

/// <summary>
/// Converts between the persistence-shaped <see cref="EntityAggregate"/> and
/// the wire-shaped <see cref="BundleContentDto"/>. The conversion strips EF
/// identity (FK ints) and references inter-entity links by name so bundles
/// remain portable across environments. Secrets are carved into a per-entity
/// <see cref="SecretsBlock"/> so the bundle's secret-free public surface can
/// be inspected (or, in future, exported without secrets) without touching the
/// rest of the payload.
/// </summary>
public sealed class EntitySerializer
{
    /// <summary>Converts a persistence-shaped <see cref="EntityAggregate"/> to the portable wire-shaped <see cref="BundleContentDto"/>.</summary>
    /// <param name="aggregate">The in-memory aggregate to serialize.</param>
    /// <returns>A <see cref="BundleContentDto"/> with identity-stripped, name-linked entity representations.</returns>
    public BundleContentDto ToBundleContent(EntityAggregate aggregate)
    {
        ArgumentNullException.ThrowIfNull(aggregate);

        var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
        var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);

        // Name-link lookups. Instances/data-connections reference their owning
        // site by the portable SiteIdentifier (not the EF surrogate SiteId); a
        // connection binding references its DataConnection by Name. Build both
        // id→name maps once so the projections below resolve in O(1).
        var siteIdentifierBySiteId = aggregate.Sites.ToDictionary(s => s.Id, s => s.SiteIdentifier);
        var connectionNameByConnectionId = aggregate.DataConnections.ToDictionary(c => c.Id, c => c.Name);

        return new BundleContentDto(
            TemplateFolders: aggregate.TemplateFolders.Select(f => new TemplateFolderDto(
                Name: f.Name,
                ParentName: f.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var pname) ? pname : null,
                SortOrder: f.SortOrder)).ToList(),
            Templates: aggregate.Templates.Select(t =>
            {
                // Build per-template script-id → name lookup once so the alarm
                // projection below resolves OnTriggerScriptId by name in O(1).
                // Scripts can only target sibling scripts on the same template
                // (TemplateAlarm.OnTriggerScriptId FK is scoped to TemplateId),
                // so we don't need a global script index.
                var scriptNameById = t.Scripts.ToDictionary(s => s.Id, s => s.Name);
                return new TemplateDto(
                    Name: t.Name,
                    FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null,
                    BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null,
                    Description: t.Description,
                    Attributes: t.Attributes.Select(a => new TemplateAttributeDto(
                        Name: a.Name,
                        Value: a.Value,
                        DataType: a.DataType,
                        IsLocked: a.IsLocked,
                        Description: a.Description,
                        DataSourceReference: a.DataSourceReference,
                        ElementDataType: a.ElementDataType,
                        LockedInDerived: a.LockedInDerived)).ToList(),
                    Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
                        Name: a.Name,
                        Description: a.Description,
                        PriorityLevel: a.PriorityLevel,
                        TriggerType: a.TriggerType,
                        TriggerConfiguration: a.TriggerConfiguration,
                        IsLocked: a.IsLocked,
                        // Carry the on-trigger script by NAME — the importer resolves
                        // this back to a script id once the parent template's scripts
                        // have been persisted and assigned ids. If the FK doesn't
                        // resolve in this aggregate (e.g. corrupt/orphan row), the
                        // name comes through as null and the importer leaves the
                        // FK null on the imported alarm.
                        OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null,
                        LockedInDerived: a.LockedInDerived)).ToList(),
                    Scripts: t.Scripts.Select(s => new TemplateScriptDto(
                        Name: s.Name,
                        Code: s.Code,
                        TriggerType: s.TriggerType,
                        TriggerConfiguration: s.TriggerConfiguration,
                        ParameterDefinitions: s.ParameterDefinitions,
                        ReturnDefinition: s.ReturnDefinition,
                        IsLocked: s.IsLocked,
                        MinTimeBetweenRuns: s.MinTimeBetweenRuns,
                        ExecutionTimeoutSeconds: s.ExecutionTimeoutSeconds,
                        LockedInDerived: s.LockedInDerived)).ToList(),
                    Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
                        InstanceName: c.InstanceName,
                        ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList())
                {
                    // Native alarm sources carry ALL rows including IsInherited
                    // placeholders — the collision detector depends on them.
                    NativeAlarmSources = t.NativeAlarmSources.Select(n => new TemplateNativeAlarmSourceDto(
                        Name: n.Name,
                        Description: n.Description,
                        ConnectionName: n.ConnectionName,
                        SourceReference: n.SourceReference,
                        ConditionFilter: n.ConditionFilter,
                        IsLocked: n.IsLocked,
                        IsInherited: n.IsInherited,
                        LockedInDerived: n.LockedInDerived)).ToList(),
                };
            }).ToList(),
            SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
                Name: s.Name,
                Code: s.Code,
                ParameterDefinitions: s.ParameterDefinitions,
                ReturnDefinition: s.ReturnDefinition)).ToList(),
            ExternalSystems: aggregate.ExternalSystems.Select(sys =>
            {
                var methods = aggregate.ExternalSystemMethods
                    .Where(m => m.ExternalSystemDefinitionId == sys.Id)
                    .Select(m => new ExternalSystemMethodDto(
                        Name: m.Name,
                        HttpMethod: m.HttpMethod,
                        Path: m.Path,
                        ParameterDefinitions: m.ParameterDefinitions,
                        ReturnDefinition: m.ReturnDefinition))
                    .ToList();

                SecretsBlock? secrets = null;
                if (!string.IsNullOrEmpty(sys.AuthConfiguration))
                {
                    secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
                    {
                        ["AuthConfiguration"] = sys.AuthConfiguration,
                    });
                }

                return new ExternalSystemDto(
                    Name: sys.Name,
                    BaseUrl: sys.EndpointUrl,
                    AuthType: sys.AuthType,
                    MaxRetries: sys.MaxRetries,
                    RetryDelay: sys.RetryDelay,
                    Methods: methods,
                    Secrets: secrets);
            }).ToList(),
            DatabaseConnections: aggregate.DatabaseConnections.Select(dc => new DatabaseConnectionDto(
                Name: dc.Name,
                MaxRetries: dc.MaxRetries,
                RetryDelay: dc.RetryDelay,
                // Connection strings typically embed credentials inline; treat the entire
                // string as secret rather than try to parse out the password fragment.
                Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
                {
                    ["ConnectionString"] = dc.ConnectionString,
                }))).ToList(),
            NotificationLists: aggregate.NotificationLists.Select(nl => new NotificationListDto(
                Name: nl.Name,
                Type: nl.Type,
                // Carry BOTH contacts. Keep any recipient that has at least
                // one contact (email OR phone) — SMS-only recipients have a null
                // EmailAddress and would have been dropped by the old email-only
                // filter. EmailAddress/PhoneNumber are nullable on the DTO so a
                // recipient round-trips exactly the contact(s) it holds.
                Recipients: nl.Recipients
                    .Where(r => r.EmailAddress is not null || r.PhoneNumber is not null)
                    .Select(r => new NotificationRecipientDto(
                        Name: r.Name,
                        EmailAddress: r.EmailAddress,
                        PhoneNumber: r.PhoneNumber)).ToList())).ToList(),
            SmtpConfigs: aggregate.SmtpConfigurations.Select(smtp =>
            {
                SecretsBlock? secrets = null;
                if (!string.IsNullOrEmpty(smtp.Credentials))
                {
                    secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
                    {
                        ["Credentials"] = smtp.Credentials,
                    });
                }

                return new SmtpConfigDto(
                    Host: smtp.Host,
                    Port: smtp.Port,
                    AuthType: smtp.AuthType,
                    FromAddress: smtp.FromAddress,
                    TlsMode: smtp.TlsMode,
                    ConnectionTimeoutSeconds: smtp.ConnectionTimeoutSeconds,
                    MaxConcurrentConnections: smtp.MaxConcurrentConnections,
                    MaxRetries: smtp.MaxRetries,
                    RetryDelay: smtp.RetryDelay,
                    Secrets: secrets,
                    // Non-sensitive delivery metadata: the OAuth2 endpoint pair and
                    // the Smtp/Ews transport selector travel as plain DTO fields.
                    OAuth2Authority: smtp.OAuth2Authority,
                    OAuth2Scope: smtp.OAuth2Scope,
                    Transport: smtp.Transport);
            }).ToList(),
            // Inbound API keys are not transported between environments:
            // the bundle carries API methods only. ApiMethod.ApprovedApiKeyIds is also
            // excluded — it references keys that aren't in the bundle; method→key scopes
            // are re-granted per environment via the admin UI/CLI. The legacy ApiKeys
            // field on the DTO stays null (and is dropped by WhenWritingNull).
            ApiMethods: aggregate.ApiMethods.Select(m => new ApiMethodDto(
                Name: m.Name,
                Script: m.Script,
                ParameterDefinitions: m.ParameterDefinitions,
                ReturnDefinition: m.ReturnDefinition,
                TimeoutSeconds: m.TimeoutSeconds)).ToList())
        {
            // Site/instance-scoped payloads. These are init-only (non-positional)
            // on BundleContentDto, so they're set via object-initializer here.
            Sites = aggregate.Sites.Select(s => new SiteDto(
                SiteIdentifier: s.SiteIdentifier,
                Name: s.Name,
                Description: s.Description,
                NodeAAddress: s.NodeAAddress,
                NodeBAddress: s.NodeBAddress,
                GrpcNodeAAddress: s.GrpcNodeAAddress,
                GrpcNodeBAddress: s.GrpcNodeBAddress)).ToList(),
            DataConnections = aggregate.DataConnections.Select(c =>
            {
                // The protocol-specific Primary/Backup configuration JSON typically
                // embeds endpoints + credentials, so the whole blob rides inside the
                // SecretsBlock (a "share without secrets" export drops it as a unit).
                // Omit a key entirely when its source value is null/empty so the
                // importer's TryGetValue cleanly yields "no value" rather than "".
                var secretValues = new Dictionary<string, string>(StringComparer.Ordinal);
                if (!string.IsNullOrEmpty(c.PrimaryConfiguration))
                {
                    secretValues["PrimaryConfiguration"] = c.PrimaryConfiguration;
                }
                if (!string.IsNullOrEmpty(c.BackupConfiguration))
                {
                    secretValues["BackupConfiguration"] = c.BackupConfiguration;
                }

                return new DataConnectionDto(
                    SiteIdentifier: siteIdentifierBySiteId.TryGetValue(c.SiteId, out var sid) ? sid : string.Empty,
                    Name: c.Name,
                    Protocol: c.Protocol,
                    FailoverRetryCount: c.FailoverRetryCount,
                    Secrets: secretValues.Count > 0 ? new SecretsBlock(secretValues) : null);
            }).ToList(),
            // SMS: mirror the SMTP secret handling exactly — the provider
            // auth token (never a public field) rides inside the SecretsBlock, so a
            // "share without secrets" export can drop it as a unit. Omit the secret
            // entirely when the token is null/empty so the importer's TryGetValue
            // cleanly yields "no value".
            SmsConfigs = aggregate.SmsConfigurations.Select(sms =>
            {
                SecretsBlock? secrets = null;
                if (!string.IsNullOrEmpty(sms.AuthToken))
                {
                    secrets = new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
                    {
                        ["AuthToken"] = sms.AuthToken,
                    });
                }

                return new SmsConfigDto(
                    AccountSid: sms.AccountSid,
                    FromNumber: sms.FromNumber,
                    MessagingServiceSid: sms.MessagingServiceSid,
                    ApiBaseUrl: sms.ApiBaseUrl,
                    ConnectionTimeoutSeconds: sms.ConnectionTimeoutSeconds,
                    MaxRetries: sms.MaxRetries,
                    RetryDelay: sms.RetryDelay,
                    Secrets: secrets);
            }).ToList(),
            Instances = aggregate.Instances.Select(inst => new InstanceDto(
                UniqueName: inst.UniqueName,
                TemplateName: templateNameById.TryGetValue(inst.TemplateId, out var tn) ? tn : string.Empty,
                SiteIdentifier: siteIdentifierBySiteId.TryGetValue(inst.SiteId, out var isid) ? isid : string.Empty,
                // Area travels by NAME — the importer resolves-or-creates the
                // equivalent area under the target site (ResolveOrCreateAreaIdAsync).
                // Resolve the instance's AreaId FK against the aggregate's id→name
                // map; if the FK is null (no area) or unresolved (orphan/absent from
                // the map), the name comes through as null and the importer leaves
                // AreaId null on the imported instance.
                AreaName: inst.AreaId is { } aid && aggregate.AreaNameById.TryGetValue(aid, out var an) ? an : null,
                State: inst.State,
                AttributeOverrides: inst.AttributeOverrides.Select(o => new InstanceAttributeOverrideDto(
                    AttributeName: o.AttributeName,
                    OverrideValue: o.OverrideValue,
                    ElementDataType: o.ElementDataType)).ToList(),
                AlarmOverrides: inst.AlarmOverrides.Select(o => new InstanceAlarmOverrideDto(
                    AlarmCanonicalName: o.AlarmCanonicalName,
                    TriggerConfigurationOverride: o.TriggerConfigurationOverride,
                    PriorityLevelOverride: o.PriorityLevelOverride)).ToList(),
                NativeAlarmSourceOverrides: inst.NativeAlarmSourceOverrides.Select(o => new InstanceNativeAlarmSourceOverrideDto(
                    SourceCanonicalName: o.SourceCanonicalName,
                    ConnectionNameOverride: o.ConnectionNameOverride,
                    SourceReferenceOverride: o.SourceReferenceOverride,
                    ConditionFilterOverride: o.ConditionFilterOverride)).ToList(),
                // Connection bindings reference their DataConnection by NAME — the
                // numeric DataConnectionId FK can't survive a cross-environment
                // bundle, so the importer resolves ConnectionName→target FK at
                // apply time. If the FK doesn't resolve in this aggregate (orphan
                // row), the name comes through empty.
                ConnectionBindings: inst.ConnectionBindings.Select(b => new InstanceConnectionBindingDto(
                    AttributeName: b.AttributeName,
                    ConnectionName: connectionNameByConnectionId.TryGetValue(b.DataConnectionId, out var cn) ? cn : string.Empty,
                    DataSourceReferenceOverride: b.DataSourceReferenceOverride)).ToList())).ToList(),
        };
    }

    /// <summary>Reconstructs a persistence-shaped <see cref="EntityAggregate"/> from a wire-shaped <see cref="BundleContentDto"/>.</summary>
    /// <param name="content">The bundle content DTO to deserialize.</param>
    /// <returns>An <see cref="EntityAggregate"/> with entities linked by name, ready for import conflict resolution.</returns>
    public EntityAggregate FromBundleContent(BundleContentDto content)
    {
        ArgumentNullException.ThrowIfNull(content);

        // Folders: assign synthetic ids by ordinal, resolve parent pointers by name.
        var folders = content.TemplateFolders
            .Select((dto, ix) => new TemplateFolder(dto.Name) { Id = ix + 1, SortOrder = dto.SortOrder })
            .ToList();
        var folderIdByName = folders.ToDictionary(f => f.Name, f => f.Id, StringComparer.Ordinal);
        for (var i = 0; i < folders.Count; i++)
        {
            var parentName = content.TemplateFolders[i].ParentName;
            if (parentName is not null && folderIdByName.TryGetValue(parentName, out var parentId))
            {
                folders[i].ParentFolderId = parentId;
            }
        }

        // Templates: same pattern — assign ids, second pass resolves base + folder.
        var templates = content.Templates
            .Select((dto, ix) =>
            {
                var t = new Template(dto.Name) { Id = ix + 1, Description = dto.Description };
                foreach (var a in dto.Attributes)
                {
                    t.Attributes.Add(new TemplateAttribute(a.Name)
                    {
                        TemplateId = t.Id,
                        Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType),
                        DataType = a.DataType,
                        IsLocked = a.IsLocked,
                        Description = a.Description,
                        DataSourceReference = a.DataSourceReference,
                        ElementDataType = a.ElementDataType,
                        LockedInDerived = a.LockedInDerived,
                    });
                }
                foreach (var al in dto.Alarms)
                {
                    t.Alarms.Add(new TemplateAlarm(al.Name)
                    {
                        TemplateId = t.Id,
                        Description = al.Description,
                        PriorityLevel = al.PriorityLevel,
                        TriggerType = al.TriggerType,
                        TriggerConfiguration = al.TriggerConfiguration,
                        IsLocked = al.IsLocked,
                        LockedInDerived = al.LockedInDerived,
                    });
                }
                foreach (var s in dto.Scripts)
                {
                    t.Scripts.Add(new TemplateScript(s.Name, s.Code)
                    {
                        TemplateId = t.Id,
                        TriggerType = s.TriggerType,
                        TriggerConfiguration = s.TriggerConfiguration,
                        ParameterDefinitions = s.ParameterDefinitions,
                        ReturnDefinition = s.ReturnDefinition,
                        IsLocked = s.IsLocked,
                        MinTimeBetweenRuns = s.MinTimeBetweenRuns,
                        ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
                        LockedInDerived = s.LockedInDerived,
                    });
                }
                foreach (var n in dto.NativeAlarmSources)
                {
                    t.NativeAlarmSources.Add(new TemplateNativeAlarmSource(n.Name)
                    {
                        TemplateId = t.Id,
                        Description = n.Description,
                        ConnectionName = n.ConnectionName,
                        SourceReference = n.SourceReference,
                        ConditionFilter = n.ConditionFilter,
                        IsLocked = n.IsLocked,
                        IsInherited = n.IsInherited,
                        LockedInDerived = n.LockedInDerived,
                    });
                }
                return t;
            })
            .ToList();
        var templateIdByName = templates.ToDictionary(t => t.Name, t => t.Id, StringComparer.Ordinal);
        for (var i = 0; i < templates.Count; i++)
        {
            var dto = content.Templates[i];
            if (dto.FolderName is not null && folderIdByName.TryGetValue(dto.FolderName, out var fid))
            {
                templates[i].FolderId = fid;
            }
            if (dto.BaseTemplateName is not null && templateIdByName.TryGetValue(dto.BaseTemplateName, out var btid))
            {
                templates[i].ParentTemplateId = btid;
            }
            foreach (var compDto in dto.Compositions)
            {
                var comp = new TemplateComposition(compDto.InstanceName)
                {
                    TemplateId = templates[i].Id,
                };
                if (templateIdByName.TryGetValue(compDto.ComposedTemplateName, out var ctid))
                {
                    comp.ComposedTemplateId = ctid;
                }
                templates[i].Compositions.Add(comp);
            }
        }

        var sharedScripts = content.SharedScripts
            .Select((dto, ix) => new SharedScript(dto.Name, dto.Code)
            {
                Id = ix + 1,
                ParameterDefinitions = dto.ParameterDefinitions,
                ReturnDefinition = dto.ReturnDefinition,
            })
            .ToList();

        var externalSystems = new List<ExternalSystemDefinition>();
        var externalSystemMethods = new List<ExternalSystemMethod>();
        for (var ix = 0; ix < content.ExternalSystems.Count; ix++)
        {
            var dto = content.ExternalSystems[ix];
            var sys = new ExternalSystemDefinition(dto.Name, dto.BaseUrl, dto.AuthType)
            {
                Id = ix + 1,
                AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
                MaxRetries = dto.MaxRetries,
                RetryDelay = dto.RetryDelay,
            };
            externalSystems.Add(sys);
            foreach (var m in dto.Methods)
            {
                externalSystemMethods.Add(new ExternalSystemMethod(m.Name, m.HttpMethod, m.Path)
                {
                    ExternalSystemDefinitionId = sys.Id,
                    ParameterDefinitions = m.ParameterDefinitions,
                    ReturnDefinition = m.ReturnDefinition,
                });
            }
        }

        var databaseConnections = content.DatabaseConnections
            .Select((dto, ix) =>
            {
                var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty;
                return new DatabaseConnectionDefinition(dto.Name, connStr)
                {
                    Id = ix + 1,
                    MaxRetries = dto.MaxRetries,
                    RetryDelay = dto.RetryDelay,
                };
            })
            .ToList();

        var notificationLists = content.NotificationLists
            .Select((dto, ix) =>
            {
                var list = new NotificationList(dto.Name) { Id = ix + 1, Type = dto.Type };
                foreach (var r in dto.Recipients)
                {
                    // Reconstruct whichever contact(s) the bundle carries.
                    // Email-present recipients use the email ctor (unchanged
                    // historical path) and additively restore the phone number;
                    // phone-only recipients (null email) take the SMS factory so we
                    // never pass null to the non-null email ctor.
                    var recipient = r.EmailAddress is not null
                        ? new NotificationRecipient(r.Name, r.EmailAddress) { PhoneNumber = r.PhoneNumber }
                        : NotificationRecipient.ForSms(r.Name, r.PhoneNumber ?? string.Empty);
                    recipient.NotificationListId = list.Id;
                    list.Recipients.Add(recipient);
                }
                return list;
            })
            .ToList();

        var smtpConfigurations = content.SmtpConfigs
            .Select((dto, ix) => new SmtpConfiguration(dto.Host, dto.AuthType, dto.FromAddress)
            {
                Id = ix + 1,
                Port = dto.Port,
                Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null,
                TlsMode = dto.TlsMode,
                ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds,
                MaxConcurrentConnections = dto.MaxConcurrentConnections,
                MaxRetries = dto.MaxRetries,
                RetryDelay = dto.RetryDelay,
                // Null on a bundle written before these fields were carried, which
                // is exactly the entity's "use the provider defaults / Smtp" state.
                OAuth2Authority = dto.OAuth2Authority,
                OAuth2Scope = dto.OAuth2Scope,
                Transport = dto.Transport,
            })
            .ToList();

        // SMS: mirror the SMTP path — restore AccountSid/FromNumber and decrypt
        // the auth token back out of the SecretsBlock (null when the key was omitted
        // because the source token was null/empty).
        var smsConfigurations = content.SmsConfigs
            .Select((dto, ix) => new SmsConfiguration(dto.AccountSid, dto.FromNumber)
            {
                Id = ix + 1,
                AuthToken = dto.Secrets?.Values.TryGetValue("AuthToken", out var token) == true ? token : null,
                MessagingServiceSid = dto.MessagingServiceSid,
                ApiBaseUrl = dto.ApiBaseUrl,
                ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds,
                MaxRetries = dto.MaxRetries,
                RetryDelay = dto.RetryDelay,
            })
            .ToList();

        // Inbound API keys are not transported — content.ApiKeys is a
        // legacy field only present on pre-C4 bundles; it is ignored here. The
        // BundleImporter is responsible for counting and reporting any such keys.
        // ApiMethod.ApprovedApiKeyIds is likewise not reconstructed: scopes are
        // re-granted per environment.
        var apiMethods = content.ApiMethods
            .Select((dto, ix) => new ApiMethod(dto.Name, dto.Script)
            {
                Id = ix + 1,
                ParameterDefinitions = dto.ParameterDefinitions,
                ReturnDefinition = dto.ReturnDefinition,
                TimeoutSeconds = dto.TimeoutSeconds,
            })
            .ToList();

        // Sites first — assign synthetic ids by ordinal. Addresses are carried
        // verbatim; the importer decides whether to keep or rewrite them.
        var sites = content.Sites
            .Select((dto, ix) => new Site(dto.Name, dto.SiteIdentifier)
            {
                Id = ix + 1,
                Description = dto.Description,
                NodeAAddress = dto.NodeAAddress,
                NodeBAddress = dto.NodeBAddress,
                GrpcNodeAAddress = dto.GrpcNodeAAddress,
                GrpcNodeBAddress = dto.GrpcNodeBAddress,
            })
            .ToList();
        var siteIdByIdentifier = sites.ToDictionary(s => s.SiteIdentifier, s => s.Id, StringComparer.Ordinal);

        // Data connections: resolve owning site by SiteIdentifier, restore the
        // protocol config from the SecretsBlock (null when the key was omitted).
        var dataConnections = content.DataConnections
            .Select((dto, ix) => new DataConnection(
                dto.Name,
                dto.Protocol,
                siteIdByIdentifier.TryGetValue(dto.SiteIdentifier, out var sid) ? sid : 0)
            {
                Id = ix + 1,
                FailoverRetryCount = dto.FailoverRetryCount,
                PrimaryConfiguration = dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null,
                BackupConfiguration = dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null,
            })
            .ToList();

        // Instances: resolve template + site by name/identifier. Areas DO travel —
        // by NAME on InstanceDto.AreaName (export resolves AreaId→AreaNameById; the
        // importer resolves-or-creates the area under the target site). This helper
        // still materializes AreaId = null for the same reason the connection-binding
        // FK below is a placeholder: FromBundleContent is NOT on the importer's path
        // (BundleImporter walks the raw DTO) and has no target-DB name→id map to
        // resolve against. Round-trip fidelity for the area lives on the DTO, not on
        // this materialized entity. Connection bindings keep their
        // resolved DataConnection NAME on the wire DTO — the importer reads
        // bindings from BundleContentDto.Instances[].ConnectionBindings directly and
        // resolves ConnectionName→the target environment's DataConnectionId at apply
        // time. FromBundleContent is NOT on the importer's path (BundleImporter walks
        // the raw DTO), so here we materialize the binding entity with a placeholder
        // DataConnectionId = 0; it carries AttributeName + DataSourceReferenceOverride
        // so a round-trip preserves the binding shape, but the FK is intentionally
        // unresolved (there is no DataConnection-name→id map valid for the target DB).
        var instances = content.Instances
            .Select((dto, ix) =>
            {
                var inst = new Instance(dto.UniqueName)
                {
                    Id = ix + 1,
                    State = dto.State,
                    TemplateId = templateIdByName.TryGetValue(dto.TemplateName, out var tid) ? tid : 0,
                    SiteId = siteIdByIdentifier.TryGetValue(dto.SiteIdentifier, out var isid) ? isid : 0,
                    AreaId = null,
                };
                foreach (var o in dto.AttributeOverrides)
                {
                    inst.AttributeOverrides.Add(new InstanceAttributeOverride(o.AttributeName)
                    {
                        InstanceId = inst.Id,
                        OverrideValue = o.OverrideValue,
                        ElementDataType = o.ElementDataType,
                    });
                }
                foreach (var o in dto.AlarmOverrides)
                {
                    inst.AlarmOverrides.Add(new InstanceAlarmOverride(o.AlarmCanonicalName)
                    {
                        InstanceId = inst.Id,
                        TriggerConfigurationOverride = o.TriggerConfigurationOverride,
                        PriorityLevelOverride = o.PriorityLevelOverride,
                    });
                }
                foreach (var o in dto.NativeAlarmSourceOverrides)
                {
                    inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride(o.SourceCanonicalName)
                    {
                        InstanceId = inst.Id,
                        ConnectionNameOverride = o.ConnectionNameOverride,
                        SourceReferenceOverride = o.SourceReferenceOverride,
                        ConditionFilterOverride = o.ConditionFilterOverride,
                    });
                }
                foreach (var b in dto.ConnectionBindings)
                {
                    inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
                    {
                        InstanceId = inst.Id,
                        // FK left at 0 on purpose — the importer resolves it from the
                        // DTO's ConnectionName against target-DB DataConnection ids.
                        DataConnectionId = 0,
                        DataSourceReferenceOverride = b.DataSourceReferenceOverride,
                    });
                }
                return inst;
            })
            .ToList();

        return new EntityAggregate(
            TemplateFolders: folders,
            Templates: templates,
            SharedScripts: sharedScripts,
            ExternalSystems: externalSystems,
            ExternalSystemMethods: externalSystemMethods,
            DatabaseConnections: databaseConnections,
            NotificationLists: notificationLists,
            SmtpConfigurations: smtpConfigurations,
            ApiMethods: apiMethods)
        {
            Sites = sites,
            DataConnections = dataConnections,
            Instances = instances,
            SmsConfigurations = smsConfigurations,
        };
    }
}
