using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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.Scripting;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;

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

/// <summary>
/// Three-phase bundle importer: <see cref="LoadAsync"/> validates the
/// bundle envelope (manifest + content hash + decryption) and opens a
/// session; <see cref="PreviewAsync"/> diffs the bundle's DTOs against the
/// current target database; <see cref="ApplyAsync"/> writes the chosen
/// resolutions through the audited repositories. All three phases are
/// fully implemented.
/// <para>
/// Audit-row responsibility: repository mutation methods in
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories</c> are thin EF wrappers
/// and do NOT emit audit rows. <see cref="ApplyAsync"/> therefore writes
/// each per-entity audit row explicitly via <see cref="IAuditService.LogAsync"/>;
/// the scoped <see cref="IAuditCorrelationContext.BundleImportId"/> is
/// automatically stamped on each row by the audit service.
/// </para>
/// <para>
/// If repository methods are ever changed to emit audit rows themselves,
/// the explicit <c>LogAsync</c> calls in this class must be removed to
/// avoid double-logging.
/// </para>
/// </summary>
public sealed class BundleImporter : IBundleImporter
{
    // All bundle content deserialization goes through BundleJsonOptions.Default.
    // IMPORTANT — unknown JSON properties must remain ALLOWED (the default
    // JsonUnmappedMemberHandling.Skip). Legacy bundles may carry a top-level
    // "apiKeys" array and/or "ApprovedApiKeyIds" inside "apiMethods[]" entries.
    // Setting JsonUnmappedMemberHandling.Disallow here would cause those bundles
    // to fail deserialization, breaking backward-compat. This tolerance is
    // load-bearing and must not be changed. (Fix I-2)
    private static readonly JsonSerializerOptions ContentJsonOptions = BundleJsonOptions.Default;

    private readonly BundleSerializer _bundleSerializer;
    private readonly ManifestValidator _manifestValidator;
    private readonly BundleSecretEncryptor _encryptor;
    private readonly ArtifactDiff _diff = new();
    private readonly EntitySerializer _entitySerializer;
    private readonly IAuditService _auditService;
    private readonly IAuditCorrelationContext _correlationContext;
    private readonly ScadaBridgeDbContext _dbContext;
    private readonly ITemplateEngineRepository _templateRepo;
    private readonly IExternalSystemRepository _externalRepo;
    private readonly INotificationRepository _notificationRepo;
    private readonly IInboundApiRepository _inboundApiRepo;
    private readonly ISiteRepository _siteRepo;
    private readonly IBundleSessionStore _sessionStore;
    private readonly BundleUnlockRateLimiter _unlockRateLimiter;
    private readonly IOptions<TransportOptions> _options;
    private readonly TimeProvider _timeProvider;
    private readonly SemanticValidator _semanticValidator;
    // Optional. Implemented in DeploymentManager (where the flattening
    // pipeline lives) and surfaced via the Commons IStaleInstanceProbe seam. Null
    // in a host that registers Transport without DeploymentManager — staleness is
    // then best-effort empty (informational only, never gates the import).
    private readonly IStaleInstanceProbe? _staleInstanceProbe;
    private readonly IScriptArtifactChangeBus? _scriptArtifactChangeBus;
    // Optional. Bumped once per apply ATTEMPT (see ApplyAsync) because this
    // importer commits through the raw DbContext, bypassing
    // TemplateEngineRepository.SaveChangesAsync — the only place that otherwise
    // derives watermark bumps from the change tracker. Null in hosts that don't
    // register it; the caches then simply never get this invalidation signal.
    private readonly ITemplateGraphWatermark? _watermark;
    private readonly ILogger<BundleImporter>? _logger;

    /// <summary>
    /// Initializes a new <see cref="BundleImporter"/> with all required dependencies.
    /// </summary>
    /// <param name="bundleSerializer">Serializer for reading bundle zip archives.</param>
    /// <param name="manifestValidator">Validates the bundle manifest on load.</param>
    /// <param name="encryptor">Handles AES-256-GCM decryption of encrypted bundles.</param>
    /// <param name="entitySerializer">Deserializes entity DTOs from bundle content.</param>
    /// <param name="sessionStore">In-memory session store for loaded bundle state.</param>
    /// <param name="unlockRateLimiter">Rate limiter for passphrase unlock attempts per client IP.</param>
    /// <param name="options">Transport configuration options.</param>
    /// <param name="timeProvider">Abstracted time provider for testability.</param>
    /// <param name="templateRepo">Template engine repository for diff and apply.</param>
    /// <param name="externalRepo">External system repository for diff and apply.</param>
    /// <param name="notificationRepo">Notification repository for diff and apply.</param>
    /// <param name="inboundApiRepo">Inbound API repository for diff and apply.</param>
    /// <param name="siteRepo">Site repository — supplies the target sites and site-scoped data connections that the preview's site/connection auto-match resolves against.</param>
    /// <param name="auditService">Audit service for writing per-entity import audit rows.</param>
    /// <param name="correlationContext">Correlation context that carries the active BundleImportId.</param>
    /// <param name="dbContext">EF Core context used to commit the import transaction.</param>
    /// <param name="semanticValidator">Validates template references before applying.</param>
    /// <param name="staleInstanceProbe">Optional: recomputes a deployed instance's current revision hash so the importer can enumerate instances stale-ed by a template overwrite. Null when no flattening pipeline is registered (Transport-without-DeploymentManager hosts) — staleness is then skipped.</param>
    /// <param name="scriptArtifactChangeBus">Optional: node-local bus notified AFTER a successful commit that script-bearing artifacts (ApiMethod / SharedScript / Template) were overwritten or renamed, so compiled-handler caches can invalidate. Null on hosts that don't register it (staleness/invalidation then relies on the consumer's own self-heal).</param>
    /// <param name="logger">Optional logger.</param>
    /// <param name="watermark">Optional: template-graph version watermark, bumped once per apply attempt so the flatten-session caches and the process-wide staleness memo cannot serve pre-import (or rolled-back) readings. Null on hosts that don't register it.</param>
    public BundleImporter(
        BundleSerializer bundleSerializer,
        ManifestValidator manifestValidator,
        BundleSecretEncryptor encryptor,
        EntitySerializer entitySerializer,
        IBundleSessionStore sessionStore,
        BundleUnlockRateLimiter unlockRateLimiter,
        IOptions<TransportOptions> options,
        TimeProvider timeProvider,
        ITemplateEngineRepository templateRepo,
        IExternalSystemRepository externalRepo,
        INotificationRepository notificationRepo,
        IInboundApiRepository inboundApiRepo,
        ISiteRepository siteRepo,
        IAuditService auditService,
        IAuditCorrelationContext correlationContext,
        ScadaBridgeDbContext dbContext,
        SemanticValidator semanticValidator,
        IStaleInstanceProbe? staleInstanceProbe = null,
        IScriptArtifactChangeBus? scriptArtifactChangeBus = null,
        ILogger<BundleImporter>? logger = null,
        ITemplateGraphWatermark? watermark = null)
    {
        _bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
        _manifestValidator = manifestValidator ?? throw new ArgumentNullException(nameof(manifestValidator));
        _encryptor = encryptor ?? throw new ArgumentNullException(nameof(encryptor));
        _entitySerializer = entitySerializer ?? throw new ArgumentNullException(nameof(entitySerializer));
        _sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore));
        _unlockRateLimiter = unlockRateLimiter ?? throw new ArgumentNullException(nameof(unlockRateLimiter));
        _options = options ?? throw new ArgumentNullException(nameof(options));
        _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
        _templateRepo = templateRepo ?? throw new ArgumentNullException(nameof(templateRepo));
        _externalRepo = externalRepo ?? throw new ArgumentNullException(nameof(externalRepo));
        _notificationRepo = notificationRepo ?? throw new ArgumentNullException(nameof(notificationRepo));
        _inboundApiRepo = inboundApiRepo ?? throw new ArgumentNullException(nameof(inboundApiRepo));
        _siteRepo = siteRepo ?? throw new ArgumentNullException(nameof(siteRepo));
        _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
        _correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
        _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
        _semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
        _staleInstanceProbe = staleInstanceProbe;
        _scriptArtifactChangeBus = scriptArtifactChangeBus;
        _watermark = watermark;
        _logger = logger;
    }

    /// <inheritdoc />
    public async Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default)
    {
        ArgumentNullException.ThrowIfNull(bundleStream);

        // Copy to a seekable buffer — manifest + content readers each open a
        // fresh ZipArchive over the same bytes, so the upstream stream needs to
        // be seekable. A caller-supplied FileStream is seekable but a Kestrel
        // request stream is not, so we always normalise to MemoryStream.
        var ms = new MemoryStream();
        await bundleStream.CopyToAsync(ms, ct).ConfigureAwait(false);
        ms.Position = 0;

        // Size cap is in MB; multiply in long arithmetic so the comparison
        // doesn't overflow at the int boundary for large MaxBundleSizeMb.
        var maxBytes = _options.Value.MaxBundleSizeMb * 1024L * 1024L;
        if (ms.Length > maxBytes)
        {
            throw new InvalidOperationException(
                $"Bundle exceeds maximum allowed size of {_options.Value.MaxBundleSizeMb} MB.");
        }

        // T-006: zip-bomb / decompression-bomb defences. We enforce three caps
        // BEFORE any entry is decompressed by ReadManifest / ReadContentBytes:
        //   1) total entry count
        //   2) per-entry decompressed length
        //   3) per-entry compression ratio (Length / CompressedLength)
        // Each cap is configurable via TransportOptions so an operator can tune
        // for an environment with legitimately large or unusually compressible
        // bundles. Using ZipArchiveEntry.Length / .CompressedLength avoids
        // reading the entry payload at all.
        ms.Position = 0;
        ValidateArchiveEnvelope(ms);

        BundleManifest manifest;
        try
        {
            ms.Position = 0;
            manifest = _bundleSerializer.ReadManifest(ms);
        }
        catch (InvalidDataException)
        {
            // Preserve the serializer's specific "manifest missing/null" message
            // — the caller wants to surface a precise diagnostic to the operator.
            throw;
        }
        catch (Exception ex)
        {
            throw new InvalidDataException("Bundle is missing or has a malformed manifest.json.", ex);
        }

        ms.Position = 0;
        var contentBytes = _bundleSerializer.ReadContentBytes(ms, manifest);

        // Validate format version + content-hash + manifest shape. Reject paths
        // surface as distinct exceptions so the UI can disambiguate the cause.
        var validation = _manifestValidator.Validate(manifest, contentBytes);
        switch (validation)
        {
            case ManifestValidationResult.UnsupportedFormatVersion:
                throw new NotSupportedException(
                    $"Bundle format version {manifest.BundleFormatVersion} is not supported by this cluster.");
            case ManifestValidationResult.ContentHashMismatch:
                throw new InvalidDataException(
                    "Bundle content hash does not match manifest — file may be corrupt.");
            case ManifestValidationResult.MalformedManifest:
                throw new InvalidDataException("Bundle manifest is malformed.");
            case ManifestValidationResult.Ok:
                break;
            default:
                throw new InvalidDataException($"Unrecognised manifest validation result: {validation}.");
        }

        // Decrypt when the manifest carries EncryptionMetadata.
        //
        // T-003: lockout enforcement is server-side and keyed by ContentHash so a
        // second tab / CLI caller re-uploading the same bundle bytes cannot
        // side-step the limit by skipping the Razor page. The counter is
        // consulted BEFORE attempting decrypt (rejects further attempts on an
        // already-locked bundle) and incremented on a CryptographicException
        // from _encryptor.Decrypt; a successful decrypt clears the counter so a
        // legitimate operator who eventually types the right passphrase is not
        // penalised for earlier typos.
        byte[] decryptedContent;
        if (manifest.Encryption is not null)
        {
            if (string.IsNullOrEmpty(passphrase))
            {
                throw new ArgumentException(
                    "Passphrase required for encrypted bundle.", nameof(passphrase));
            }

            var maxAttempts = _options.Value.MaxUnlockAttemptsPerSession;
            var priorFailures = _sessionStore.GetUnlockFailureCount(manifest.ContentHash);
            if (priorFailures >= maxAttempts)
            {
                throw new BundleLockedException(manifest.ContentHash, priorFailures);
            }

            var aad = Encryption.BundleManifestAad.Compute(manifest);
            try
            {
                decryptedContent = _encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase, aad);
            }
            catch (CryptographicException)
            {
                var newCount = _sessionStore.IncrementUnlockFailureCount(manifest.ContentHash);
                if (newCount >= maxAttempts)
                {
                    // Surface the lockout as the typed exception so the caller can
                    // distinguish "wrong passphrase, try again" from "no more attempts".
                    throw new BundleLockedException(manifest.ContentHash, newCount);
                }
                // Otherwise rebubble the CryptographicException so the UI's
                // wrong-passphrase audit + retry path continues to work unchanged.
                throw;
            }

            _sessionStore.ClearUnlockFailures(manifest.ContentHash);
        }
        else
        {
            decryptedContent = contentBytes;
        }

        var ttl = TimeSpan.FromMinutes(_options.Value.BundleSessionTtlMinutes);
        var session = new BundleSession
        {
            SessionId = Guid.NewGuid(),
            Manifest = manifest,
            DecryptedContent = decryptedContent,
            ExpiresAt = _timeProvider.GetUtcNow() + ttl,
        };
        return _sessionStore.Open(session);
    }

    /// <summary>
    /// T-006: validates the zip envelope against the configured caps BEFORE any
    /// entry payload is decompressed. Reads only the central-directory headers
    /// (<see cref="ZipArchiveEntry.Length"/> / <see cref="ZipArchiveEntry.CompressedLength"/>)
    /// so a hostile bundle can't OOM the central node through this method itself.
    /// </summary>
    /// <param name="bundleBytes">Buffered bundle bytes. Position is preserved.</param>
    private void ValidateArchiveEnvelope(MemoryStream bundleBytes)
    {
        var opts = _options.Value;
        var maxEntries = opts.MaxBundleEntryCount;
        var maxEntryDecompressed = opts.MaxBundleEntryDecompressedMb * 1024L * 1024L;
        var maxRatio = opts.MaxBundleEntryCompressionRatio;

        var savedPosition = bundleBytes.Position;
        try
        {
            bundleBytes.Position = 0;
            using var archive = new ZipArchive(bundleBytes, ZipArchiveMode.Read, leaveOpen: true);

            if (archive.Entries.Count > maxEntries)
            {
                throw new InvalidDataException(
                    $"Bundle contains {archive.Entries.Count} zip entries; the configured maximum is {maxEntries}.");
            }

            foreach (var entry in archive.Entries)
            {
                if (entry.Length > maxEntryDecompressed)
                {
                    throw new InvalidDataException(
                        $"Bundle entry '{entry.FullName}' declares a decompressed size of {entry.Length} bytes; "
                        + $"the configured maximum is {maxEntryDecompressed} bytes "
                        + $"({opts.MaxBundleEntryDecompressedMb} MB).");
                }

                // CompressedLength of 0 means store-only or empty — skip ratio check.
                if (entry.CompressedLength > 0 && entry.Length / entry.CompressedLength > maxRatio)
                {
                    throw new InvalidDataException(
                        $"Bundle entry '{entry.FullName}' has compression ratio "
                        + $"{entry.Length / entry.CompressedLength}x; the configured maximum is {maxRatio}x.");
                }
            }
        }
        finally
        {
            bundleBytes.Position = savedPosition;
        }
    }

    /// <inheritdoc />
    public async Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
    {
        var session = _sessionStore.Get(sessionId)
            ?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired.");
        if (session.Locked)
        {
            throw new InvalidOperationException($"Bundle session {sessionId} is locked.");
        }

        BundleContentDto content;
        try
        {
            content = JsonSerializer.Deserialize<BundleContentDto>(session.DecryptedContent, ContentJsonOptions)
                ?? throw new InvalidDataException("Session content deserialized to null.");
        }
        catch (JsonException ex)
        {
            throw new InvalidDataException("Session content is not a valid BundleContentDto.", ex);
        }

        var items = new List<ImportPreviewItem>();

        // ---- TemplateFolders ----
        var allFolders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
        var folderByName = allFolders.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
        var folderNameById = allFolders.ToDictionary(f => f.Id, f => f.Name);
        foreach (var fDto in content.TemplateFolders)
        {
            folderByName.TryGetValue(fDto.Name, out var existing);
            items.Add(_diff.CompareTemplateFolder(fDto, existing, folderNameById));
        }

        // ---- Templates ----
        // Previously this loop iterated GetAllTemplatesAsync()
        // and called GetTemplateWithChildrenAsync(stub.Id) once per matching
        // name (classic N+1). The bulk variant fetches every matching template
        // with children eager-loaded in a single round-trip.
        var bundleTemplateNames = content.Templates.Select(t => t.Name);
        var hydratedTemplates = await _templateRepo
            .GetTemplatesWithChildrenAsync(bundleTemplateNames, ct)
            .ConfigureAwait(false);
        var hydratedByName = hydratedTemplates
            .ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
        var allTemplateStubs = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
        var templateNameById = allTemplateStubs.ToDictionary(t => t.Id, t => t.Name);
        foreach (var tDto in content.Templates)
        {
            hydratedByName.TryGetValue(tDto.Name, out var existing);
            items.Add(_diff.CompareTemplate(tDto, existing, folderNameById, templateNameById));
        }

        // ---- SharedScripts ----
        foreach (var s in content.SharedScripts)
        {
            var existing = await _templateRepo.GetSharedScriptByNameAsync(s.Name, ct).ConfigureAwait(false);
            items.Add(_diff.CompareSharedScript(s, existing));
        }

        // ---- ExternalSystems (+ their methods) ----
        foreach (var es in content.ExternalSystems)
        {
            var existing = await _externalRepo.GetExternalSystemByNameAsync(es.Name, ct).ConfigureAwait(false);
            IReadOnlyList<Commons.Entities.ExternalSystems.ExternalSystemMethod>? methods = null;
            if (existing is not null)
            {
                methods = await _externalRepo.GetMethodsByExternalSystemIdAsync(existing.Id, ct).ConfigureAwait(false);
            }
            items.Add(_diff.CompareExternalSystem(es, existing, methods));
        }

        // ---- DatabaseConnections ----
        foreach (var db in content.DatabaseConnections)
        {
            var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(db.Name, ct).ConfigureAwait(false);
            items.Add(_diff.CompareDatabaseConnection(db, existing));
        }

        // ---- NotificationLists ----
        foreach (var nl in content.NotificationLists)
        {
            var existing = await _notificationRepo.GetListByNameAsync(nl.Name, ct).ConfigureAwait(false);
            items.Add(_diff.CompareNotificationList(nl, existing));
        }

        // ---- SmtpConfigurations (no by-host lookup — scan GetAll) ----
        var allSmtp = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
        var smtpByHost = allSmtp.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);
        foreach (var sm in content.SmtpConfigs)
        {
            smtpByHost.TryGetValue(sm.Host, out var existing);
            items.Add(_diff.CompareSmtpConfiguration(sm, existing));
        }

        // ---- SmsConfigurations (no by-AccountSid lookup — scan GetAll) ----
        var allSms = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false);
        var smsBySid = allSms.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal);
        foreach (var sms in content.SmsConfigs)
        {
            smsBySid.TryGetValue(sms.AccountSid, out var existing);
            items.Add(_diff.CompareSmsConfiguration(sms, existing));
        }

        // ---- ApiKeys ----
        // Inbound API keys are not transported between environments.
        // New bundles never carry a keys section. A legacy bundle may still contain
        // one; we do NOT surface those keys as importable preview rows (they would
        // offer Add/Overwrite actions for keys that can't be meaningfully re-created
        // from a hash). They are counted, ignored, and reported at apply time.

        // ---- ApiMethods ----
        foreach (var m in content.ApiMethods)
        {
            var existing = await _inboundApiRepo.GetMethodByNameAsync(m.Name, ct).ConfigureAwait(false);
            items.Add(_diff.CompareApiMethod(m, existing));
        }

        // ---- Site/instance-scoped types ----
        // Sites/DataConnections/Instances are referenced by stable string identity
        // (SiteIdentifier / connection Name / UniqueName) and resolved against the
        // TARGET environment's own surrogate keys. We auto-match the source site by
        // identifier; the result drives both the per-type diffs (a matched target
        // connection / instance feeds CompareDataConnection / CompareInstance) and
        // the operator-facing required-mapping list built below.
        //
        // Cache target-site lookups + their data connections so we don't re-query
        // the same site once per instance / connection / native-alarm override.
        var targetSiteByIdentifier = new Dictionary<string, Site?>(StringComparer.Ordinal);
        var targetConnectionsBySiteIdentifier =
            new Dictionary<string, IReadOnlyList<DataConnection>>(StringComparer.Ordinal);

        async Task<Site?> ResolveTargetSiteAsync(string siteIdentifier)
        {
            if (targetSiteByIdentifier.TryGetValue(siteIdentifier, out var cached)) return cached;
            var site = await _siteRepo.GetSiteByIdentifierAsync(siteIdentifier, ct).ConfigureAwait(false);
            targetSiteByIdentifier[siteIdentifier] = site;
            return site;
        }

        async Task<IReadOnlyList<DataConnection>> ResolveTargetConnectionsAsync(string siteIdentifier)
        {
            if (targetConnectionsBySiteIdentifier.TryGetValue(siteIdentifier, out var cached)) return cached;
            var site = await ResolveTargetSiteAsync(siteIdentifier).ConfigureAwait(false);
            IReadOnlyList<DataConnection> conns = site is null
                ? Array.Empty<DataConnection>()
                : await _siteRepo.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false);
            targetConnectionsBySiteIdentifier[siteIdentifier] = conns;
            return conns;
        }

        // ---- Sites ----
        foreach (var siteDto in content.Sites)
        {
            var existing = await ResolveTargetSiteAsync(siteDto.SiteIdentifier).ConfigureAwait(false);
            items.Add(_diff.CompareSite(siteDto, existing));
        }

        // ---- DataConnections (site-scoped; matched by name within the auto-matched target site) ----
        // Connection names are unique only WITHIN a site, so the preview item's
        // identity is SITE-QUALIFIED (`{SiteIdentifier}/{Name}`). The diff CONTENT is
        // unchanged — only the item Name is qualified (after CompareDataConnection
        // returns) so two sites' same-named connections resolve to distinct items and
        // the operator's per-item Skip/Overwrite applies to the right site's connection.
        // ApplyDataConnectionsAsync looks the resolution up by this same qualified key.
        foreach (var dcDto in content.DataConnections)
        {
            var targetConns = await ResolveTargetConnectionsAsync(dcDto.SiteIdentifier).ConfigureAwait(false);
            var existing = targetConns.FirstOrDefault(c => string.Equals(c.Name, dcDto.Name, StringComparison.Ordinal));
            var item = _diff.CompareDataConnection(dcDto, existing);
            items.Add(item with { Name = QualifiedConnectionName(dcDto.SiteIdentifier, dcDto.Name) });
        }

        // ---- Instances (hydrated target + resolved template/site/area names; review item I2) ----
        foreach (var instDto in content.Instances)
        {
            // GetInstanceByUniqueNameAsync eagerly Includes all four child nav
            // collections (AttributeOverrides / AlarmOverrides / ConnectionBindings /
            // NativeAlarmSourceOverrides), so the entity handed to CompareInstance is
            // HYDRATED — its children diff correctly instead of every incoming child
            // reading as an addition (review item I2).
            var existing = await _templateRepo
                .GetInstanceByUniqueNameAsync(instDto.UniqueName, ct).ConfigureAwait(false);

            string? existingTemplateName = null;
            string? existingSiteIdentifier = null;
            string? existingAreaName = null;
            if (existing is not null)
            {
                // The entity stores template/site/area as numeric FKs that can't be
                // compared cross-environment, so resolve each to the same stable
                // string identity the incoming DTO carries.
                var tmpl = await _templateRepo.GetTemplateByIdAsync(existing.TemplateId, ct).ConfigureAwait(false);
                existingTemplateName = tmpl?.Name;

                var site = await _siteRepo.GetSiteByIdAsync(existing.SiteId, ct).ConfigureAwait(false);
                existingSiteIdentifier = site?.SiteIdentifier;

                if (existing.AreaId is int areaId)
                {
                    var area = await _templateRepo.GetAreaByIdAsync(areaId, ct).ConfigureAwait(false);
                    existingAreaName = area?.Name;
                }
            }

            items.Add(_diff.CompareInstance(
                instDto, existing, existingTemplateName, existingSiteIdentifier, existingAreaName));
        }

        // ---- Required site/connection mappings ----
        var (requiredSites, requiredConnections) = await BuildRequiredMappingsAsync(
            content, ResolveTargetSiteAsync, ResolveTargetConnectionsAsync).ConfigureAwait(false);

        // ---- Blocker detection ----
        items.AddRange(await DetectBlockersAsync(content, ResolveTargetConnectionsAsync, ct).ConfigureAwait(false));

        return new ImportPreview(sessionId, items, requiredSites, requiredConnections);
    }

    /// <summary>
    /// Collects the distinct set of source sites and (site, connection) pairs the
    /// bundle references, auto-matching each against the TARGET environment by
    /// identity, and returns the operator-facing required-mapping lists.
    /// <para>
    /// Site references are drawn from every instance, every site, and every
    /// data-connection in the bundle. Connection references are drawn from every
    /// instance connection-binding, every non-null native-alarm-source
    /// <c>ConnectionNameOverride</c>, and every bundled data-connection. A source
    /// site auto-matches when the target has a site with the same identifier; a
    /// connection auto-matches when that target site additionally carries a
    /// connection of the same name. No match leaves <c>AutoMatchTarget*</c> null —
    /// the operator must supply an explicit mapping (or accept create-new) at
    /// apply time. The diff path above already populated the resolver caches, so
    /// these lookups are served from memory.
    /// </para>
    /// </summary>
    private static async Task<(IReadOnlyList<RequiredSiteMapping> Sites, IReadOnlyList<RequiredConnectionMapping> Connections)>
        BuildRequiredMappingsAsync(
            BundleContentDto content,
            Func<string, Task<Site?>> resolveTargetSite,
            Func<string, Task<IReadOnlyList<DataConnection>>> resolveTargetConnections)
    {
        // Distinct source-site identifiers, with a best-effort display name.
        // SiteDto carries a Name; instances / data-connections only carry the
        // identifier, so default that to the identifier when no SiteDto is present.
        var siteNameByIdentifier = new Dictionary<string, string>(StringComparer.Ordinal);
        void NoteSite(string identifier, string? name)
        {
            if (string.IsNullOrEmpty(identifier)) return;
            if (!siteNameByIdentifier.TryGetValue(identifier, out var existing)
                || (string.Equals(existing, identifier, StringComparison.Ordinal) && !string.IsNullOrEmpty(name)))
            {
                siteNameByIdentifier[identifier] = string.IsNullOrEmpty(name) ? identifier : name;
            }
        }

        foreach (var s in content.Sites) NoteSite(s.SiteIdentifier, s.Name);
        foreach (var i in content.Instances) NoteSite(i.SiteIdentifier, null);
        foreach (var dc in content.DataConnections) NoteSite(dc.SiteIdentifier, null);

        // Distinct (sourceSite, connectionName) pairs referenced anywhere.
        var connectionRefs = new HashSet<(string Site, string Name)>();
        foreach (var i in content.Instances)
        {
            NoteSite(i.SiteIdentifier, null);
            foreach (var b in i.ConnectionBindings)
            {
                if (!string.IsNullOrEmpty(b.ConnectionName))
                {
                    connectionRefs.Add((i.SiteIdentifier, b.ConnectionName));
                }
            }
            foreach (var n in i.NativeAlarmSourceOverrides)
            {
                if (!string.IsNullOrEmpty(n.ConnectionNameOverride))
                {
                    connectionRefs.Add((i.SiteIdentifier, n.ConnectionNameOverride));
                }
            }
        }
        foreach (var dc in content.DataConnections)
        {
            if (!string.IsNullOrEmpty(dc.Name))
            {
                connectionRefs.Add((dc.SiteIdentifier, dc.Name));
            }
        }

        var siteMappings = new List<RequiredSiteMapping>();
        foreach (var identifier in siteNameByIdentifier.Keys.OrderBy(k => k, StringComparer.Ordinal))
        {
            var target = await resolveTargetSite(identifier).ConfigureAwait(false);
            siteMappings.Add(new RequiredSiteMapping(
                SourceSiteIdentifier: identifier,
                SourceSiteName: siteNameByIdentifier[identifier],
                AutoMatchTargetIdentifier: target?.SiteIdentifier));
        }

        var connectionMappings = new List<RequiredConnectionMapping>();
        foreach (var (site, name) in connectionRefs
            .OrderBy(r => r.Site, StringComparer.Ordinal)
            .ThenBy(r => r.Name, StringComparer.Ordinal))
        {
            // Auto-match only WITHIN the auto-matched target site: a connection of
            // the same name under a different site is not a valid match.
            var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
            var matched = targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal));
            connectionMappings.Add(new RequiredConnectionMapping(
                SourceSiteIdentifier: site,
                SourceConnectionName: name,
                AutoMatchTargetName: matched ? name : null));
        }

        return (siteMappings, connectionMappings);
    }

    /// <summary>
    /// Surfaces unresolved cross-entity references: a TemplateScript or
    /// ApiMethod body that name-mentions a SharedScript or ExternalSystem that
    /// is in neither the bundle nor the target database. We reuse the same
    /// substring-with-word-boundary scan as <c>DependencyResolver</c>; the
    /// implementations are kept in lockstep but not factored out yet because
    /// the resolver's scan operates on entity Code while the importer's scan
    /// operates on DTO Code — same algorithm, different inputs.
    /// </summary>
    private async Task<IReadOnlyList<ImportPreviewItem>> DetectBlockersAsync(
        BundleContentDto content,
        Func<string, Task<IReadOnlyList<DataConnection>>> resolveTargetConnections,
        CancellationToken ct)
    {
        var blockers = new List<ImportPreviewItem>();

        // ---- Instance template + referenced-connection blockers ----
        // The set of template names the import can satisfy = (in-bundle templates)
        // ∪ (templates already in the target DB). An instance whose TemplateName is
        // in neither is unresolvable.
        var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
        foreach (var t in content.Templates) bundleTemplateNames.Add(t.Name);
        // Honour a rename: a bundled template resolved as Rename is created under
        // its new name, but at preview time no resolution has been chosen yet, so
        // the in-bundle name is the original DTO name — which is what an instance
        // references. (Explicit rename remap is a D-wave apply-time concern.)
        var targetTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
        var targetTemplateNames = new HashSet<string>(StringComparer.Ordinal);
        foreach (var t in targetTemplates)
        {
            targetTemplateNames.Add(t.Name);
        }

        foreach (var inst in content.Instances)
        {
            if (string.IsNullOrEmpty(inst.TemplateName)) continue;
            if (bundleTemplateNames.Contains(inst.TemplateName)) continue;
            if (targetTemplateNames.Contains(inst.TemplateName)) continue;
            blockers.Add(new ImportPreviewItem(
                EntityType: "Instance",
                Name: inst.UniqueName,
                ExistingVersion: null,
                IncomingVersion: null,
                Kind: ConflictKind.Blocker,
                FieldDiffJson: null,
                BlockerReason: $"Template '{inst.TemplateName}' not found in bundle or target."));
        }

        // A referenced (sourceSite, connectionName) pair is resolvable when it is
        // either carried in the bundle's DataConnections OR auto-matches a connection
        // of the same name in the auto-matched target site. Genuinely-missing
        // references are blockers. (Explicit operator connection maps are applied in
        // a later wave; preview's auto-match is identity-based.)
        var bundleConnections = new HashSet<(string Site, string Name)>();
        foreach (var dc in content.DataConnections)
        {
            if (!string.IsNullOrEmpty(dc.Name)) bundleConnections.Add((dc.SiteIdentifier, dc.Name));
        }

        // Distinct referenced pairs from instance bindings + native-alarm overrides.
        var referencedConnections = new HashSet<(string Site, string Name)>();
        foreach (var inst in content.Instances)
        {
            foreach (var b in inst.ConnectionBindings)
            {
                if (!string.IsNullOrEmpty(b.ConnectionName))
                {
                    referencedConnections.Add((inst.SiteIdentifier, b.ConnectionName));
                }
            }
            foreach (var n in inst.NativeAlarmSourceOverrides)
            {
                if (!string.IsNullOrEmpty(n.ConnectionNameOverride))
                {
                    referencedConnections.Add((inst.SiteIdentifier, n.ConnectionNameOverride));
                }
            }
        }

        foreach (var (site, name) in referencedConnections
            .OrderBy(r => r.Site, StringComparer.Ordinal)
            .ThenBy(r => r.Name, StringComparer.Ordinal))
        {
            if (bundleConnections.Contains((site, name))) continue;
            var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
            if (targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal))) continue;
            // Site-qualify the blocker Name so two sites' same-named-but-missing
            // connections surface as distinct blockers (a bare name would collide).
            blockers.Add(new ImportPreviewItem(
                EntityType: "Instance",
                Name: QualifiedConnectionName(site, name),
                ExistingVersion: null,
                IncomingVersion: null,
                Kind: ConflictKind.Blocker,
                FieldDiffJson: null,
                BlockerReason: $"Connection '{site}/{name}' unresolved — present in neither bundle nor target."));
        }

        // Known-resolvable names = (in-bundle) ∪ (already-in-target).
        var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
        var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);

        var sharedScriptNames = new HashSet<string>(StringComparer.Ordinal);
        foreach (var s in content.SharedScripts) sharedScriptNames.Add(s.Name);
        foreach (var s in allSharedScripts) sharedScriptNames.Add(s.Name);

        var externalSystemNames = new HashSet<string>(StringComparer.Ordinal);
        foreach (var e in content.ExternalSystems) externalSystemNames.Add(e.Name);
        foreach (var e in allExternalSystems) externalSystemNames.Add(e.Name);

        // Heuristic: collect a small candidate vocabulary of identifiers used
        // by the bundle's scripts that are NOT one of the known-good names, and
        // check whether each one was previously a SharedScript or ExternalSystem
        // (i.e. matches the naming-convention shape of an identifier reference).
        // For v1, we walk the SharedScripts the bundle *expects* — by scanning
        // bodies and reporting any identifier-shaped token that resolves to a
        // SharedScript by historical record... but that's overreach.
        //
        // Simpler + sufficient v1: scan every script body in the bundle's
        // templates + ApiMethods, and for each occurrence of "Name(" where
        // Name is a valid identifier, if Name appears in NEITHER set, surface
        // it as a Blocker. This catches the documented use-case
        // (HelperFn() / ErpSystem.Call()) without combinatorial blowup.
        var referencedFromTemplates = new HashSet<string>(StringComparer.Ordinal);
        var referencedFromApiMethods = new HashSet<string>(StringComparer.Ordinal);
        var locallyDeclaredInTemplates = new HashSet<string>(StringComparer.Ordinal);
        var locallyDeclaredInApiMethods = new HashSet<string>(StringComparer.Ordinal);
        foreach (var t in content.Templates)
        {
            foreach (var s in t.Scripts)
            {
                CollectCallIdentifiers(s.Code, referencedFromTemplates);
                CollectLocalDeclarations(s.Code, locallyDeclaredInTemplates);
            }
            foreach (var a in t.Attributes)
            {
                // Attribute.Value is a typed-literal default (int/double/string/list,
                // decoded by AttributeValueCodec — never compiled), so it is not a
                // trust surface; we still scan it for identifier-shaped tokens, but
                // any finding is only ever an advisory template warning.
                // DataSourceReference is an OPC UA node address path (e.g.
                // "ns=3;s=Tank.Level") owned by the device and must NOT be scanned,
                // or the dot delimiter trips the heuristic into flagging the address
                // segments as missing SharedScript/ExternalSystem references.
                CollectCallIdentifiers(a.Value, referencedFromTemplates);
                CollectLocalDeclarations(a.Value, locallyDeclaredInTemplates);
            }
        }
        foreach (var m in content.ApiMethods)
        {
            CollectCallIdentifiers(m.Script, referencedFromApiMethods);
            CollectLocalDeclarations(m.Script, locallyDeclaredInApiMethods);
        }

        // For each candidate, only report it if it looks like a resource
        // reference (PascalCase, length > 1), isn't a well-known language /
        // runtime / SQL token, AND isn't present anywhere we can satisfy it. A
        // candidate genuinely unresolved in an ApiMethod (referenced there and NOT
        // locally declared there) is a hard Blocker; a candidate genuinely
        // unresolved only in a template is a non-blocking Warning.
        var allCandidates = new HashSet<string>(referencedFromTemplates, StringComparer.Ordinal);
        allCandidates.UnionWith(referencedFromApiMethods);
        foreach (var candidate in allCandidates.OrderBy(n => n, StringComparer.Ordinal))
        {
            if (!LooksLikeResourceName(candidate)) continue;
            if (KnownNonReferenceNames.Contains(candidate)) continue;
            var isShared = sharedScriptNames.Contains(candidate);
            var isExternal = externalSystemNames.Contains(candidate);
            if (isShared || isExternal) continue;

            var apiUnresolved = referencedFromApiMethods.Contains(candidate)
                && !locallyDeclaredInApiMethods.Contains(candidate);
            var templateUnresolved = referencedFromTemplates.Contains(candidate)
                && !locallyDeclaredInTemplates.Contains(candidate);
            if (!apiUnresolved && !templateUnresolved) continue; // satisfied by a local decl in its own origin

            var fromApiMethod = apiUnresolved;
            blockers.Add(new ImportPreviewItem(
                EntityType: "Reference",
                Name: candidate,
                ExistingVersion: null,
                IncomingVersion: null,
                Kind: fromApiMethod ? ConflictKind.Blocker : ConflictKind.Warning,
                FieldDiffJson: null,
                BlockerReason: fromApiMethod
                    ? $"References SharedScript or ExternalSystem '{candidate}' not present in bundle or target."
                    : $"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target — advisory; the deploy-time gate re-validates."));
        }

        foreach (var (kind, entityName, scriptLabel, code) in EnumerateTrustGatedScripts(content, resolutionMap: null))
        {
            IReadOnlyList<string> violations;
            try
            {
                violations = ScriptTrustValidator.FindViolations(code);
            }
            catch (Exception ex)
            {
                // Fail closed (mirrors the apply-time gate): a validator throw on
                // one body surfaces as a Blocker for that script, not a preview crash.
                _logger?.LogWarning(ex,
                    "Script trust analysis threw for {Kind} '{EntityName}' script '{ScriptLabel}' during preview; surfacing as a Blocker.",
                    kind, entityName, scriptLabel);
                blockers.Add(new ImportPreviewItem(
                    EntityType: kind,
                    Name: $"{entityName}.{scriptLabel}",
                    ExistingVersion: null,
                    IncomingVersion: null,
                    Kind: ConflictKind.Blocker,
                    FieldDiffJson: null,
                    BlockerReason: "Script trust analysis failed to complete — rejected."));
                continue;
            }
            foreach (var violation in violations)
            {
                blockers.Add(new ImportPreviewItem(
                    EntityType: kind,
                    Name: $"{entityName}.{scriptLabel}",
                    ExistingVersion: null,
                    IncomingVersion: null,
                    Kind: ConflictKind.Blocker,
                    FieldDiffJson: null,
                    BlockerReason: $"Script trust violation — {violation}"));
            }
        }

        // N4: advisory warning rows for instance overrides that target a LOCKED
        // template member — the flattener drops them, so surface the inert row in the
        // wizard (Kind: Warning, non-blocking). resolutionMap is null at preview time,
        // so every instance is scanned.
        foreach (var (instance, message) in CollectLockedOverrideWarnings(content, resolutionMap: null, targetTemplates))
        {
            blockers.Add(new ImportPreviewItem(
                EntityType: "Instance",
                Name: instance,
                ExistingVersion: null,
                IncomingVersion: null,
                Kind: ConflictKind.Warning,
                FieldDiffJson: null,
                BlockerReason: message));
        }

        return blockers;
    }

    private static void CollectCallIdentifiers(string? body, HashSet<string> sink)
    {
        if (string.IsNullOrEmpty(body)) return;
        // Find every "Identifier(" or "Identifier." occurrence. The boundary
        // before the identifier must NOT be an identifier char so we don't
        // match the trailing portion of a longer token, AND must not be a
        // dot — otherwise `obj.Method()` would flag `Method` as a top-level
        // reference. Member-access trailing identifiers are skipped.
        for (var i = 0; i < body.Length; i++)
        {
            if (!IsIdentifierStart(body[i])) continue;
            if (i > 0 && (IsIdentifierChar(body[i - 1]) || body[i - 1] == '.')) continue;
            var start = i;
            while (i < body.Length && IsIdentifierChar(body[i])) i++;
            if (i >= body.Length) break;
            var trailing = body[i];
            if (trailing == '(' || trailing == '.')
            {
                sink.Add(body[start..i]);
            }
        }
    }

    /// <summary>
    /// Collects the names of every local function / method DECLARED inside a
    /// script body, so the reference heuristic doesn't mistake a script's own
    /// helper (<c>decimal ComputeRate(...) => ...; return ComputeRate(x);</c>)
    /// for a missing SharedScript / ExternalSystem. Uses a Roslyn script-kind
    /// parse (partial trees are fine — a body with syntax errors still yields
    /// the declarations Roslyn could recover). Purely additive noise-suppression:
    /// on any parse failure the sink is simply left unchanged.
    /// </summary>
    private static void CollectLocalDeclarations(string? body, HashSet<string> sink)
    {
        if (string.IsNullOrEmpty(body)) return;
        try
        {
            var root = CSharpSyntaxTree
                .ParseText(body, new CSharpParseOptions(kind: SourceCodeKind.Script))
                .GetRoot();
            foreach (var fn in root.DescendantNodes().OfType<LocalFunctionStatementSyntax>())
            {
                sink.Add(fn.Identifier.ValueText);
            }
            foreach (var m in root.DescendantNodes().OfType<MethodDeclarationSyntax>())
            {
                sink.Add(m.Identifier.ValueText);
            }
        }
        catch
        {
            // Best-effort: a parse failure just means no declarations are
            // excluded for this body — the finding (at worst) surfaces as an
            // advisory warning, never a hard error for template scripts.
        }
    }

    /// <summary>
    /// Enumerates every executable C# surface a bundle carries that the
    /// trust gate must vet: non-Skip template scripts + their Expression-trigger
    /// bodies, template alarm Expression-trigger bodies, shared scripts,
    /// ApiMethod scripts, AND instance alarm-override trigger expressions
    /// (<c>InstanceAlarmOverrideDto.TriggerConfigurationOverride</c>). Expression
    /// triggers — template alarms AND the instance overrides that replace them in
    /// the flattened config — compile and execute at the site
    /// (`TriggerExpressionGlobals`), so they are a genuine trust surface, not just
    /// script bodies. A null <paramref name="resolutionMap"/> (preview time, before
    /// resolutions are chosen) gates everything. Yields <c>(kind, entityName,
    /// scriptLabel, code)</c> so callers can format either an error string or a
    /// preview row.
    /// </summary>
    private static IEnumerable<(string Kind, string EntityName, string ScriptLabel, string Code)> EnumerateTrustGatedScripts(
        BundleContentDto content,
        Dictionary<(string, string), ImportResolution>? resolutionMap)
    {
        foreach (var t in content.Templates)
        {
            if (IsSkipResolution(resolutionMap, "Template", t.Name)) continue;
            foreach (var s in t.Scripts)
            {
                if (!string.IsNullOrEmpty(s.Code))
                {
                    yield return ("Template", t.Name, s.Name, s.Code);
                }
                var scriptExpr = ExtractTriggerExpression(s.TriggerType, s.TriggerConfiguration);
                if (!string.IsNullOrEmpty(scriptExpr))
                {
                    yield return ("Template", t.Name, $"{s.Name} (trigger expression)", scriptExpr);
                }
            }
            foreach (var a in t.Alarms)
            {
                var alarmExpr = ExtractTriggerExpression(a.TriggerType.ToString(), a.TriggerConfiguration);
                if (!string.IsNullOrEmpty(alarmExpr))
                {
                    yield return ("Template", t.Name, $"{a.Name} (alarm trigger expression)", alarmExpr);
                }
            }
        }
        foreach (var s in content.SharedScripts)
        {
            if (IsSkipResolution(resolutionMap, "SharedScript", s.Name)) continue;
            if (!string.IsNullOrEmpty(s.Code))
            {
                yield return ("SharedScript", s.Name, s.Name, s.Code);
            }
        }
        foreach (var m in content.ApiMethods)
        {
            if (IsSkipResolution(resolutionMap, "ApiMethod", m.Name)) continue;
            if (!string.IsNullOrEmpty(m.Script))
            {
                yield return ("ApiMethod", m.Name, m.Name, m.Script);
            }
        }
        foreach (var i in content.Instances)
        {
            if (IsSkipResolution(resolutionMap, "Instance", i.UniqueName)) continue;
            foreach (var o in i.AlarmOverrides)
            {
                // The DTO carries no trigger type (the type lives on the template
                // alarm), so gate ANY override config carrying an {"expression":...}
                // string body: if the overridden alarm is not Expression-triggered
                // the body never executes, but vetting it anyway is fail-safe — the
                // structured (HiLo/threshold) configs have no "expression" key, so
                // there is no false-positive channel.
                var expr = ExtractExpressionBody(o.TriggerConfigurationOverride);
                if (!string.IsNullOrEmpty(expr))
                {
                    yield return ("Instance", i.UniqueName,
                        $"{o.AlarmCanonicalName} (alarm trigger override expression)", expr);
                }
            }
        }
    }

    /// <summary>
    /// Extracts the C# boolean expression from an <c>{"expression":"…"}</c> trigger
    /// configuration when the trigger type is <c>Expression</c> — the same shape
    /// the site's <c>TriggerExpressionGlobals</c> compiles. Returns null for any
    /// non-Expression trigger, empty config, or malformed JSON (nothing to gate).
    /// </summary>
    private static string? ExtractTriggerExpression(string? triggerType, string? triggerConfigJson)
    {
        if (triggerType is null
            || !triggerType.Equals("Expression", StringComparison.OrdinalIgnoreCase))
        {
            return null;
        }
        return ExtractExpressionBody(triggerConfigJson);
    }

    /// <summary>
    /// Extracts the C# boolean expression from an <c>{"expression":"…"}</c> trigger
    /// configuration, WITHOUT a trigger-type guard — used where the caller has no
    /// trigger type to check (an instance alarm override; the type lives on the
    /// template alarm). A config carrying no <c>"expression"</c> string key (the
    /// structured HiLo/threshold shapes) or malformed JSON yields null — nothing to
    /// gate.
    /// </summary>
    private static string? ExtractExpressionBody(string? triggerConfigJson)
    {
        if (string.IsNullOrWhiteSpace(triggerConfigJson))
        {
            return null;
        }
        try
        {
            using var doc = JsonDocument.Parse(triggerConfigJson);
            if (doc.RootElement.ValueKind == JsonValueKind.Object
                && doc.RootElement.TryGetProperty("expression", out var expr)
                && expr.ValueKind == JsonValueKind.String)
            {
                return expr.GetString();
            }
        }
        catch (JsonException)
        {
            // Malformed trigger config isn't executable as an expression — nothing
            // to gate here (a separate validation surfaces the malformed config).
        }
        return null;
    }

    private static bool IsSkipResolution(
        Dictionary<(string, string), ImportResolution>? resolutionMap, string entityType, string name)
        => resolutionMap != null
            && ResolveOrDefault(resolutionMap, entityType, name).Action == ResolutionAction.Skip;

    /// <summary>
    /// Best-effort scan for instance overrides that target a LOCKED template member.
    /// The flattener drops such overrides (an override on a locked attribute / alarm /
    /// native-alarm-source never takes effect), so the bundle carries an inert row —
    /// this surfaces an advisory warning so the operator learns the config won't apply,
    /// WITHOUT blocking the import or changing what gets written (bundle fidelity keeps
    /// the row; the flattener stays the behavioural authority).
    /// <para>
    /// Matches locked members by DIRECT name only — own + inherited placeholder rows
    /// both carry the <c>IsLocked</c> flag, so a lock on either matches. Overrides
    /// addressing composed path-qualified members (<c>y1.z.Val</c>) or derived-shadow
    /// locks (<c>LockedInDerived</c> on a base the placeholder row doesn't reflect) may
    /// not match a direct row — an unmatched name emits NO warning (never a false
    /// positive; the ManagementActor and flattener remain the enforcement points). The
    /// instance's template is resolved from the bundle DTO (the version about to be
    /// written) first, else the pre-existing target template.
    /// </para>
    /// </summary>
    private static IReadOnlyList<(string Instance, string Message)> CollectLockedOverrideWarnings(
        BundleContentDto content,
        Dictionary<(string, string), ImportResolution>? resolutionMap,
        IReadOnlyList<Template> targetTemplates)
    {
        var warnings = new List<(string, string)>();

        var bundleTemplates = new Dictionary<string, TemplateDto>(StringComparer.Ordinal);
        foreach (var t in content.Templates) bundleTemplates[t.Name] = t;
        var targetByName = new Dictionary<string, Template>(StringComparer.Ordinal);
        foreach (var t in targetTemplates) targetByName[t.Name] = t;

        foreach (var inst in content.Instances)
        {
            if (IsSkipResolution(resolutionMap, "Instance", inst.UniqueName)) continue;
            if (string.IsNullOrEmpty(inst.TemplateName)) continue;

            HashSet<string> lockedAttrs;
            HashSet<string> lockedAlarms;
            HashSet<string> lockedSources;
            if (bundleTemplates.TryGetValue(inst.TemplateName, out var dto))
            {
                lockedAttrs = new(dto.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
                lockedAlarms = new(dto.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
                lockedSources = new(dto.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
            }
            else if (targetByName.TryGetValue(inst.TemplateName, out var tgt))
            {
                lockedAttrs = new(tgt.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
                lockedAlarms = new(tgt.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
                lockedSources = new(tgt.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
            }
            else
            {
                // Template unresolvable — a separate blocker/validation covers that.
                continue;
            }

            foreach (var o in inst.AttributeOverrides)
            {
                if (lockedAttrs.Contains(o.AttributeName))
                    warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "attribute", o.AttributeName)));
            }
            foreach (var o in inst.AlarmOverrides)
            {
                if (lockedAlarms.Contains(o.AlarmCanonicalName))
                    warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "alarm", o.AlarmCanonicalName)));
            }
            foreach (var o in inst.NativeAlarmSourceOverrides)
            {
                if (lockedSources.Contains(o.SourceCanonicalName))
                    warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "native-alarm-source", o.SourceCanonicalName)));
            }
        }
        return warnings;
    }

    private static string LockedOverrideMessage(string unique, string kind, string name) =>
        $"Instance '{unique}' {kind} override '{name}' targets a LOCKED template member — " +
        "the flattener ignores it, so this part of the bundle's instance config will never take effect.";

    /// <summary>
    /// Names that look like PascalCase references but are never user-defined
    /// SharedScripts or ExternalSystems. Filters the false-positive noise the
    /// identifier scan produces against real script bodies: .NET stdlib types
    /// and helpers, ScadaBridge runtime API roots, and common SQL keywords that
    /// appear inside string literals. Match is case-sensitive (Ordinal).
    /// </summary>
    private static readonly HashSet<string> KnownNonReferenceNames = new(StringComparer.Ordinal)
    {
        // .NET / C# stdlib
        "Boolean", "Byte", "Char", "Console", "Convert", "DateTime",
        "DateTimeOffset", "Decimal", "Dispose", "Double", "Enumerable",
        "Exception", "Guid", "Int16", "Int32", "Int64", "List", "Math",
        "Now", "Object", "Single", "String", "Task", "TimeSpan", "ToBoolean",
        "ToDateTime", "ToDecimal", "ToDouble", "ToInt16", "ToInt32", "ToInt64",
        "ToList", "ToSingle", "ToString", "UtcNow",

        // ScadaBridge script runtime API roots and well-known members
        "Attribute", "Attributes", "Call", "CallScript", "CallShared",
        "Connection", "CreateCommand", "Database", "ExecuteAsync",
        "ExecuteNonQueryAsync", "ExecuteReaderAsync", "ExecuteScalarAsync",
        "ExternalSystem", "GetAsync", "GetAttribute", "Instance", "Notify",
        "Parameters", "Request", "Response", "Route", "Scheduler", "Scripts",
        "Send", "SetAsync", "SetAttribute",

        // SQL keywords commonly seen inside string literals
        "COUNT", "FROM", "GROUP", "INSERT", "JOIN", "ORDER", "SELECT",
        "UPDATE", "WHERE", "HAVING", "VALUES", "DELETE", "DISTINCT", "LIMIT",

        // Extended stdlib / BCL surface commonly reached from scripts.
        "Regex", "Match", "Matches", "IsMatch", "Replace", "Split",
        "StringBuilder", "Append", "AppendLine", "Parse", "TryParse",
        "Format", "Join", "Abs", "Round", "Min", "Max", "Floor", "Ceiling",
        "Pow", "Sqrt", "Json", "Serialize", "Deserialize", "Where", "Select",
        "First", "FirstOrDefault", "Any", "All", "Count", "Sum", "Average",
        "OrderBy", "OrderByDescending", "GroupBy", "Distinct", "Add", "Remove",
        "Clear", "Contains", "ContainsKey", "TryGetValue", "StartsWith",
        "EndsWith", "Substring", "Trim", "ToUpper", "ToLower",
    };

    private static bool LooksLikeResourceName(string name)
    {
        if (name.Length < 2) return false;
        if (!char.IsUpper(name[0])) return false;
        for (var i = 1; i < name.Length; i++)
        {
            if (!IsIdentifierChar(name[i])) return false;
        }
        return true;
    }

    private static bool IsIdentifierStart(char c) => c == '_' || char.IsLetter(c);
    private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);

    /// <summary>
    /// The site-qualified identity (<c>{siteIdentifier}/{connectionName}</c>) used
    /// for every <c>DataConnection</c> preview item Name + blocker Name, and for the
    /// matching resolution lookup in <see cref="ApplyDataConnectionsAsync"/>. Connection
    /// names are unique only WITHIN a site, so a bare-name key would collapse two sites'
    /// same-named connections onto one resolution entry. Callers building a resolution
    /// map from <c>preview.Items</c> keyed by <c>(EntityType, Name)</c> get the
    /// site-qualified key for free.
    /// </summary>
    private static string QualifiedConnectionName(string siteIdentifier, string connectionName) =>
        $"{siteIdentifier}/{connectionName}";

    /// <inheritdoc />
    public async Task<ImportResult> ApplyAsync(
        Guid sessionId,
        IReadOnlyList<ImportResolution> resolutions,
        string user,
        CancellationToken ct = default,
        BundleNameMap? nameMap = null)
    {
        ArgumentNullException.ThrowIfNull(resolutions);
        ArgumentNullException.ThrowIfNull(user);

        // Normalise null → Empty so the resolve-or-create logic always has a map
        // to consult. An empty map means "no explicit operator mappings" — every
        // referenced site/connection falls through to the identity auto-match.
        nameMap ??= BundleNameMap.Empty;

        var session = _sessionStore.Get(sessionId)
            ?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired.");
        if (session.Locked)
        {
            throw new InvalidOperationException($"Bundle session {sessionId} is locked.");
        }

        BundleContentDto content;
        try
        {
            content = JsonSerializer.Deserialize<BundleContentDto>(session.DecryptedContent, ContentJsonOptions)
                ?? throw new InvalidDataException("Session content deserialized to null.");
        }
        catch (JsonException ex)
        {
            throw new InvalidDataException("Session content is not a valid BundleContentDto.", ex);
        }

        var bundleImportId = Guid.NewGuid();
        // Set the correlation BEFORE the transaction so any audit writes
        // triggered during the apply pick up the BundleImportId — AuditService
        // reads the scoped context at the moment LogAsync is called.
        _correlationContext.BundleImportId = bundleImportId;

        // The central ConfigurationDb context is configured with
        // EnableRetryOnFailure (ServiceCollectionExtensions), and
        // SqlServerRetryingExecutionStrategy REJECTS a user-initiated
        // transaction opened outside the strategy ("The configured execution
        // strategy 'SqlServerRetryingExecutionStrategy' does not support
        // user-initiated transactions."), so opening BeginTransactionAsync
        // directly here throws on any real SQL Server. Run the whole
        // BeginTransaction -> apply -> Commit unit INSIDE the execution strategy
        // so the strategy owns it and re-executes it as one retriable unit on a
        // transient fault. On the in-memory provider CreateExecutionStrategy()
        // returns a non-retrying strategy, so this stays a single straight-
        // through call there (behaviour unchanged).
        //
        // Retry idempotency: the strategy re-runs the WHOLE delegate on a
        // transient fault, so the change tracker is cleared at the top of the
        // delegate (adds staged by the failed attempt, whose transaction has
        // already rolled back, must not leak into the retry) and ApplyMergeAsync
        // rebuilds its own summary/resolution accumulators per call — otherwise
        // a retry would double-apply. A rollback failure captured inside the
        // delegate is surfaced to the outer catch via rollbackFailureMessage so
        // the BundleImportFailed row still records both faults.
        var strategy = _dbContext.Database.CreateExecutionStrategy();
        string? rollbackFailureMessage = null;
        ImportResult result;
        try
        {
            result = await strategy.ExecuteAsync(async () =>
            {
                // Reset per-attempt state so a retried delegate re-runs clean.
                _dbContext.ChangeTracker.Clear();
                rollbackFailureMessage = null;

                // BeginTransactionAsync is a no-op on the in-memory EF provider
                // (which logs an InMemoryEventId.TransactionIgnoredWarning by
                // default). To keep rollback semantics testable on in-memory AND
                // correct on relational providers, ApplyMergeAsync defers the
                // SINGLE final SaveChangesAsync until the very end — every
                // Add*Async + LogAsync call only stages on the change tracker,
                // so throwing before that naturally undoes the whole apply on
                // both providers.
                var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
                try
                {
                    // ApplyMergeAsync stages the whole merge and issues the
                    // single deferred final SaveChangesAsync itself; here we only
                    // commit the transaction it ran under.
                    var applied = await ApplyMergeAsync(
                        content, resolutions, nameMap, session, bundleImportId, user, ct).ConfigureAwait(false);
                    await tx.CommitAsync(ct).ConfigureAwait(false);
                    await tx.DisposeAsync().ConfigureAwait(false);
                    BumpGraphWatermark();
                    return applied;
                }
                catch
                {
                    // Roll back explicitly (rather than leaning on Dispose) so a
                    // rollback that itself throws does not mask the ORIGINAL
                    // exception — capture it and let the original propagate to
                    // the strategy, which decides whether to retry.
                    try
                    {
                        await tx.RollbackAsync(ct).ConfigureAwait(false);
                    }
                    catch (Exception rbEx)
                    {
                        rollbackFailureMessage = rbEx.Message;
                    }
                    try { await tx.DisposeAsync().ConfigureAwait(false); }
                    catch { /* dispose-after-throw must not mask the original cause */ }

                    // Clear the change tracker — on the in-memory provider the
                    // rollback is a no-op and the staged adds would otherwise
                    // persist on the next SaveChangesAsync (a retry or the
                    // failure-row write below).
                    _dbContext.ChangeTracker.Clear();

                    // Bump on the ROLLBACK path too. ComputeStaleInstanceIdsAsync
                    // runs PRE-commit (it reads the staged change tracker), so a
                    // rolled-back attempt has already written StaleInstanceProbe
                    // memos describing state that never landed. Leaving the
                    // watermark untouched would keep those memos "current"
                    // forever — including for the strategy's own next retry.
                    BumpGraphWatermark();
                    throw;
                }
            }).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            // The strategy exhausted its retries (or threw a non-transient
            // error). The failed attempt already rolled back and cleared the
            // change tracker inside the delegate, so all that remains here is
            // the top-level BundleImportFailed audit row.
            //
            // Clear correlation FIRST so the failure row doesn't carry the now-
            // rolled-back BundleImportId. The contract is: BundleImportFailed
            // exists at top level (no correlation) so audit consumers can see
            // imports that aborted before any rows landed.
            _correlationContext.BundleImportId = null;
            try
            {
                await _auditService.LogAsync(
                    user: user,
                    action: "BundleImportFailed",
                    entityType: "Bundle",
                    entityId: bundleImportId.ToString(),
                    entityName: session.Manifest.SourceEnvironment,
                    afterState: new
                    {
                        BundleImportId = bundleImportId,
                        Reason = ex.Message,
                        ExceptionType = ex.GetType().FullName,
                        RollbackException = rollbackFailureMessage,
                    },
                    cancellationToken: ct).ConfigureAwait(false);
                await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
            }
            catch
            {
                // Audit-write is best-effort per design §10 ("Audit-write failure
                // NEVER aborts the user-facing action — audit is best-effort, the
                // action's own success/failure path is authoritative"). Swallow
                // any failure here so the original exception below propagates
                // unchanged rather than being masked by an audit-layer fault.
            }

            // T-007: a failed apply used to leave the BundleSession (with its
            // decrypted secrets) in the in-memory store for the full 30-minute
            // TTL — 10 failed 100 MB imports = 1 GB of plaintext still rooted.
            // Drop the session here too so the secrets are released as soon as
            // the failure surfaces, not when the next Get() happens to evict.
            ZeroDecryptedContent(session);
            _sessionStore.Remove(sessionId);
            throw;
        }
        finally
        {
            // Always clear — even on the success path the correlation only
            // applies to the apply we just finished. Subsequent operations on
            // this scope (e.g. a second concurrent apply on a circuit) must
            // not inherit the import id.
            _correlationContext.BundleImportId = null;
        }

        // Post-commit side effects — run ONCE, only after a durable commit, so a
        // retried attempt neither publishes twice nor releases the session early.
        PublishScriptArtifactChanges(resolutions);

        // T-007: zero out the decrypted plaintext BEFORE remove so any
        // caller-held reference (e.g. the Razor page that built the
        // ImportPreview) sees the cleared buffer too. Remove drops the
        // dictionary entry; together they release the secrets immediately
        // instead of leaving them in process memory for the full TTL.
        ZeroDecryptedContent(session);
        _sessionStore.Remove(sessionId);

        return result;
    }

    /// <summary>
    /// Applies one bundle's merge inside the caller's transaction: semantic
    /// validation, every Apply* pass, the name-keyed FK rewires, the stale-
    /// instance computation and the BundleImported audit row, ending with the
    /// deferred final SaveChangesAsync staged by the caller. Does NOT begin or
    /// commit the transaction — <see cref="ApplyAsync"/> owns it so the whole
    /// unit can run inside the context's execution strategy (EnableRetryOnFailure
    /// rejects user-initiated transactions opened outside the strategy). Safe to
    /// re-run: the caller clears the change tracker before each attempt, and
    /// every accumulator here (summary counts, resolution map) is rebuilt per
    /// call.
    /// </summary>
    private async Task<ImportResult> ApplyMergeAsync(
        BundleContentDto content,
        IReadOnlyList<ImportResolution> resolutions,
        BundleNameMap nameMap,
        BundleSession session,
        Guid bundleImportId,
        string user,
        CancellationToken ct)
    {
        var resolutionMap = resolutions.ToDictionary(
            r => (r.EntityType, r.Name),
            r => r);
        var summary = new ImportSummary();

        // Inbound API keys are not transported between environments.
        // A legacy bundle may still contain a keys section; we ignore those keys
        // entirely (never re-create them) but count them so the result can tell
        // the operator to re-issue keys on this environment.
        var apiKeysIgnored = content.ApiKeys?.Count ?? 0;

        // Run semantic validation FIRST — before any writes are staged.
        // This is purely a name-resolution scan over the in-memory DTOs +
        // pre-existing target reads, so it has no ordering dependency on
        // the Apply* helpers. Failing here means the change tracker is
        // still empty, which keeps the rollback contract simple on both
        // the in-memory and relational providers (intermediate
        // SaveChangesAsync between Apply* and the second-pass rewire
        // would otherwise prevent the in-memory provider from undoing
        // already-flushed template rows on validation failure — the
        // in-memory transaction is a no-op, so the only safe pattern is
        // ONE deferred SaveChangesAsync at the very end of try-block).
        //
        // Skip-resolved DTOs are excluded from the in-bundle name set so
        // a Skip on a dependency surfaces as a missing-reference error
        // rather than silently passing.
        var (validationErrors, validationWarnings) =
            await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
        // Validate every site / connection / template reference the
        // site-instance payload depends on BEFORE any row is staged. Running
        // this in the validation phase (not as an apply-pass guard) preserves
        // the rollback contract: a structurally-unresolvable bundle fails with
        // an empty change tracker, so nothing is half-written on the in-memory
        // provider (where the intermediate site/connection flush can't be
        // undone by ChangeTracker.Clear). The apply passes keep defensive
        // guards, but in normal operation those never fire.
        validationErrors = validationErrors.Count > 0
            ? validationErrors
            : await ValidateSiteInstanceReferencesAsync(content, resolutionMap, nameMap, ct).ConfigureAwait(false);
        if (validationErrors.Count > 0)
        {
            throw new SemanticValidationException(validationErrors);
        }

        // Advisory template-script reference findings never block the import;
        // log them and surface them on the ImportResult so the operator sees
        // the same advisory the preview showed. The deploy-time gate is the
        // authoritative re-validation.
        if (validationWarnings.Count > 0)
        {
            _logger?.LogWarning(
                "Bundle import {BundleImportId}: {Count} advisory import warning(s): {Warnings}",
                bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings));
        }

        // ---- Site/instance-scoped apply: sites + connections FIRST ----
        // Sites are the FK target for both data connections (SiteId) and
        // instances (SiteId); data connections are the FK target for instance
        // connection bindings (DataConnectionId) and the rewrite target for
        // native-alarm-source ConnectionNameOverride. Resolve-or-create both
        // BEFORE the central-config apply so the maps the instance pass needs
        // are fully populated, then flush so newly-created site/connection ids
        // materialise on the relational provider. (The instance pass itself
        // runs AFTER the central-config flush so it can also resolve template
        // ids by name.)
        var siteBySourceIdentifier = await ApplySitesAsync(
            content, nameMap, resolutionMap, user, summary, ct).ConfigureAwait(false);
        // Flush BETWEEN the site pass and the connection pass: a create-missing
        // site has Id == 0 until SaveChanges on a relational provider, and the
        // connection pass stamps DataConnection.SiteId as a raw scalar (no EF
        // navigation, so no fix-up) — without this flush the insert fails the
        // FK on real SQL Server (FK_DataConnections_Sites_SiteId, error 547).
        // The in-memory provider assigns ids eagerly on AddAsync, which is why
        // in-process tests never hit this. Rides the same outer transaction.
        await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
        var connectionMaps = await ApplyDataConnectionsAsync(
            content, nameMap, siteBySourceIdentifier, resolutionMap, user, summary, ct).ConfigureAwait(false);
        // Flush so site + connection surrogate ids are assigned (relational
        // provider) before the instance pass wires its FKs. In-memory assigns
        // ids on AddAsync, so this is mostly a no-op there, but it keeps the
        // ordering correct on a real DB. Rides the same outer transaction.
        await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);

        await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
        await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
        await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
        await ApplyExternalSystemsAsync(content.ExternalSystems, resolutionMap, user, summary, ct).ConfigureAwait(false);
        await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false);
        await ApplyNotificationListsAsync(content.NotificationLists, resolutionMap, user, summary, ct).ConfigureAwait(false);
        await ApplySmtpConfigsAsync(content.SmtpConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
        await ApplySmsConfigsAsync(content.SmsConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
        // Inbound API keys are NOT applied from a bundle — any keys
        // in a legacy bundle were counted above (apiKeysIgnored) and are skipped.
        await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);

        // Second-pass rewire of name-keyed
        // FKs that can only be resolved AFTER every template's scripts and
        // child rows have been staged. We flush here so Pass A can look up
        // the just-persisted scripts by name and Pass B can look up the
        // just-persisted templates by name; both passes stage further
        // mutations that ride the SAME outer transaction (committed below).
        //
        // EF tracking note: AddAsync on the in-memory provider assigns
        // synthetic ids eagerly, so this intermediate flush mostly
        // materialises identity values on a relational provider. The
        // rollback contract is preserved because semantic validation
        // already ran above — any throw from this point onward represents
        // a successful merge that the user wants to keep, and the only
        // remaining failure surface is the final BundleImported audit
        // write itself.
        await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
        await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
        await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
        await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
        // Folder parent edges are name-keyed too — resolve them AFTER the flush
        // above so every imported folder has a materialised id to point at
        // (the Add pass created the folders parent-less). Mirrors the template
        // inheritance/composition rewires.
        await ResolveFolderParentEdgesAsync(content.TemplateFolders, resolutionMap, user, ct).ConfigureAwait(false);
        // External-system methods have no navigation on the parent definition,
        // so an Add can't cascade them — persist them here, once the parent
        // system has a real id from the flush above. (Overwrite already syncs
        // its methods inline against the existing parent id.)
        await PersistAddedExternalSystemMethodsAsync(content.ExternalSystems, resolutionMap, user, ct).ConfigureAwait(false);

        // ---- Import-time acyclicity guard over the merged template graph ----
        // The two rewire passes above have STAGED (not yet saved) the bundle's
        // inheritance (ParentTemplateId) and composition edges onto the tracked
        // target templates. A bundle can still close a loop through a pre-existing
        // target template — e.g. Overwrite a base so it now inherits from (or
        // composes) one of its own descendants. The design-time save-gate enforces
        // acyclicity on individual edits, but an import merges many edges at once,
        // so re-check the whole merged graph here. On a cycle we throw and the
        // catch below rolls the whole import back (ChangeTracker.Clear on the
        // in-memory provider) BEFORE any of it is persisted; deliberately no
        // SaveChanges precedes this check so nothing survives that rollback.
        EnsureNoTemplateGraphCycles();

        // ---- Site/instance-scoped apply: instances LAST ----
        // The instance pass needs every FK target materialised first:
        //   • template id  — resolved by name against the just-flushed central
        //     config (Templates were flushed above for the alarm/composition
        //     rewire), plus pre-existing target templates;
        //   • site id      — from the site map built by ApplySitesAsync;
        //   • connection id — from the connection map built by
        //     ApplyDataConnectionsAsync.
        // It writes the instance row + its four child collections (attribute /
        // alarm / native-alarm-source overrides + connection bindings) and
        // rewires every name-keyed FK to the target environment's ids.
        await ApplyInstancesAsync(
            content, resolutionMap, siteBySourceIdentifier, connectionMaps,
            user, summary, ct).ConfigureAwait(false);

        // ---- Pre-commit point: stale-instance computation ----
        // Everything the import will write is staged on the change tracker at
        // this point but NOT yet committed. This computes StaleInstanceIds here
        // — target instances whose template was overwritten by this import and
        // therefore now carry a stale flattened-config revision — and threads
        // the resulting list into the ImportResult below (replacing the
        // Array.Empty<int>() placeholder). The single deferred SaveChangesAsync
        // is the next statement, so a read against the change tracker here sees
        // the full post-apply graph before commit.
        var staleInstanceIds = await ComputeStaleInstanceIdsAsync(
            content, resolutionMap, ct).ConfigureAwait(false);

        await _auditService.LogAsync(
            user: user,
            action: "BundleImported",
            entityType: "Bundle",
            entityId: bundleImportId.ToString(),
            entityName: session.Manifest.SourceEnvironment,
            afterState: new
            {
                BundleImportId = bundleImportId,
                session.Manifest.SourceEnvironment,
                session.Manifest.ContentHash,
                Summary = new
                {
                    summary.Added,
                    summary.Overwritten,
                    summary.Skipped,
                    summary.Renamed,
                },
                // Legacy inbound API keys present in the bundle that
                // were ignored (not transported / not re-created here).
                ApiKeysIgnored = apiKeysIgnored,
            },
            cancellationToken: ct).ConfigureAwait(false);

        await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);

        return new ImportResult(
            BundleImportId: bundleImportId,
            Added: summary.Added,
            Overwritten: summary.Overwritten,
            Skipped: summary.Skipped,
            Renamed: summary.Renamed,
            StaleInstanceIds: staleInstanceIds,
            AuditEventCorrelation: bundleImportId.ToString(),
            ApiKeysIgnored: apiKeysIgnored,
            Warnings: validationWarnings);
    }

    /// <summary>
    /// Invalidates every template-graph cache keyed on the
    /// <see cref="ITemplateGraphWatermark"/>, called once per apply ATTEMPT — after
    /// the commit on the success path, and after the rollback on the failure path.
    ///
    /// <para>
    /// <b>Why the importer must do this itself.</b> Watermark bumps are normally
    /// derived from the change tracker by
    /// <c>TemplateEngineRepository.SaveChangesAsync</c>, but this importer commits
    /// through the raw <c>ScadaBridgeDbContext</c> (a single deferred
    /// <c>SaveChangesAsync</c> plus a handful of intra-transaction flushes), so not
    /// one of its writes went through that path. The observable bug: a second
    /// import overwriting the same template could be OMITTED from
    /// <c>ImportResult.StaleInstanceIds</c>, because <c>StaleInstanceProbe</c>'s
    /// process-static memo — validated purely against watermark readings that never
    /// moved — served the FIRST import's revision hash.
    /// </para>
    ///
    /// <para>
    /// <b>Why both paths, and why <see cref="ITemplateGraphWatermark.BumpAll"/>.</b>
    /// The probe runs pre-commit, so a rolled-back attempt leaves memos for state
    /// that never landed; bumping on rollback is what makes the memo non-transactional
    /// yet still sound (the simplest correct shape — a transactional memo would mean
    /// threading commit/rollback awareness through a process-static cache). And an
    /// import touches an arbitrary, unattributed set of templates, instances and data
    /// connections, so the unattributed global bump is both the correct granularity
    /// and cheap: an import is a rare, heavyweight operation, and the watermark can
    /// only ever cause EXTRA flattening work, never stale work.
    /// </para>
    /// </summary>
    private void BumpGraphWatermark() => _watermark?.BumpAll();

    /// <summary>
    /// T-007: zeros the session's <see cref="BundleSession.DecryptedContent"/>
    /// buffer in place so any caller still holding a reference observes the
    /// cleared bytes. Best-effort — a null/empty buffer is a no-op.
    /// </summary>
    private static void ZeroDecryptedContent(BundleSession session)
    {
        var buf = session.DecryptedContent;
        if (buf is { Length: > 0 })
        {
            Array.Clear(buf, 0, buf.Length);
        }
    }

    /// <summary>
    /// Enumerates the deployed instances stale-ed by this import.
    /// Overwriting a template changes the flattened-config hash of every instance
    /// of that template, so a deployed instance's freshly-computed revision hash
    /// will no longer match the hash captured in its <c>DeployedConfigSnapshot</c>
    /// at deploy time — it surfaces as "stale" on the Deployments page. This walks
    /// the templates THIS import OVERWROTE, finds each one's deployed instances
    /// (State <see cref="InstanceState.Enabled"/>/<see cref="InstanceState.Disabled"/>
    /// with a snapshot), recomputes their current hash through
    /// <see cref="IStaleInstanceProbe"/> (which reads the just-staged template
    /// rows on the shared change tracker), and collects the ids whose hash drifted.
    /// <para>
    /// Freshly-IMPORTED instances are excluded structurally: they enter the target
    /// <see cref="InstanceState.NotDeployed"/> (no snapshot), so they're "new", not
    /// "stale". Best-effort: a probe that returns null or throws for one instance
    /// is skipped (logged), never aborting the import — staleness is informational
    /// and the import's own success path is authoritative. Returns an empty list
    /// when no probe is wired (Transport-without-DeploymentManager hosts).
    /// </para>
    /// </summary>
    private async Task<IReadOnlyList<int>> ComputeStaleInstanceIdsAsync(
        BundleContentDto content,
        Dictionary<(string EntityType, string Name), ImportResolution> resolutionMap,
        CancellationToken ct)
    {
        // No flattening pipeline registered → can't recompute hashes. Staleness is
        // informational, so degrade gracefully to "none".
        if (_staleInstanceProbe is null || content.Templates.Count == 0)
        {
            return Array.Empty<int>();
        }

        // Which templates did THIS import overwrite? A bundle template is
        // overwritten when its resolution Action == Overwrite AND a target template
        // of that name already exists (otherwise the Overwrite branch falls through
        // to Add — see ApplyTemplatesAsync). Map those names to their target ids.
        var targetTemplateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
            .GroupBy(t => t.Name, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);

        var overwrittenTemplateIds = new HashSet<int>();
        foreach (var dto in content.Templates)
        {
            var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
            if (resolution.Action != ResolutionAction.Overwrite) continue;
            if (targetTemplateIdByName.TryGetValue(dto.Name, out var templateId))
            {
                overwrittenTemplateIds.Add(templateId);
            }
        }

        if (overwrittenTemplateIds.Count == 0)
        {
            return Array.Empty<int>();
        }

        var staleIds = new List<int>();
        foreach (var templateId in overwrittenTemplateIds)
        {
            var instances = await _templateRepo
                .GetInstancesByTemplateIdAsync(templateId, ct).ConfigureAwait(false);
            foreach (var instance in instances)
            {
                // NotDeployed instances (including everything this import just
                // created/overwrote) are "new", not "stale" — skip them.
                if (instance.State is not (InstanceState.Enabled or InstanceState.Disabled))
                {
                    continue;
                }

                try
                {
                    // A deployed instance has a snapshot; its absence means the
                    // instance is not actually deployed (defensive — State already
                    // gated this) so there's no baseline to compare against.
                    var snapshot = await _dbContext.DeployedConfigSnapshots
                        .AsNoTracking()
                        .Where(s => s.InstanceId == instance.Id)
                        .OrderByDescending(s => s.DeployedAt)
                        .FirstOrDefaultAsync(ct)
                        .ConfigureAwait(false);
                    if (snapshot is null) continue;

                    var currentHash = await _staleInstanceProbe
                        .GetCurrentRevisionHashAsync(instance.Id, ct).ConfigureAwait(false);
                    // null => hash indeterminate; don't claim staleness either way.
                    if (currentHash is null) continue;

                    if (!string.Equals(currentHash, snapshot.RevisionHash, StringComparison.Ordinal))
                    {
                        staleIds.Add(instance.Id);
                    }
                }
                catch (Exception ex)
                {
                    // Best-effort: a single instance's hash failure must not abort
                    // the (already-successful) import. Skip it and move on.
                    _logger?.LogWarning(ex,
                        "Stale-instance hash computation failed for instance {InstanceId} (template {TemplateId}); skipping.",
                        instance.Id, templateId);
                }
            }
        }

        return staleIds;
    }

    /// <summary>Mutable per-apply counter struct, accumulated through every helper.</summary>
    private sealed class ImportSummary
    {
        /// <summary>Number of artifacts inserted as new entries.</summary>
        public int Added { get; set; }
        /// <summary>Number of artifacts overwritten with the bundle version.</summary>
        public int Overwritten { get; set; }
        /// <summary>Number of artifacts skipped (left unchanged).</summary>
        public int Skipped { get; set; }
        /// <summary>Number of artifacts renamed to avoid a collision.</summary>
        public int Renamed { get; set; }
    }

    /// <summary>
    /// Returns the resolution for the given (entityType, name) tuple, defaulting to
    /// <see cref="ResolutionAction.Add"/> when no explicit resolution was supplied —
    /// the diff engine surfaces every artifact in the preview so any missing
    /// entry means the UI didn't override the default, which for a New artifact
    /// is Add. This keeps the apply tolerant of partial-resolution payloads.
    /// </summary>
    private static ImportResolution ResolveOrDefault(
        Dictionary<(string EntityType, string Name), ImportResolution> map,
        string entityType,
        string name)
    {
        return map.TryGetValue((entityType, name), out var r)
            ? r
            : new ImportResolution(entityType, name, ResolutionAction.Add, RenameTo: null);
    }

    private async Task ApplyTemplateFoldersAsync(
        IReadOnlyList<TemplateFolderDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;
        var existing = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
        var byName = existing.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);

        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(map, "TemplateFolder", dto.Name);
            switch (resolution.Action)
            {
                case ResolutionAction.Skip:
                    summary.Skipped++;
                    break;
                case ResolutionAction.Rename:
                {
                    var name = resolution.RenameTo ?? dto.Name;
                    var folder = new TemplateFolder(name) { SortOrder = dto.SortOrder };
                    await _templateRepo.AddFolderAsync(folder, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "TemplateFolder", "0", name,
                        new { folder.Name, folder.SortOrder, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
                    summary.Renamed++;
                    break;
                }
                case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex):
                    ex.SortOrder = dto.SortOrder;
                    await _templateRepo.UpdateFolderAsync(ex, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Update", "TemplateFolder", ex.Id.ToString(), ex.Name,
                        new { ex.Name, ex.SortOrder }, ct).ConfigureAwait(false);
                    summary.Overwritten++;
                    break;
                case ResolutionAction.Add:
                case ResolutionAction.Overwrite:
                default:
                {
                    var folder = new TemplateFolder(dto.Name) { SortOrder = dto.SortOrder };
                    await _templateRepo.AddFolderAsync(folder, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "TemplateFolder", "0", folder.Name,
                        new { folder.Name, folder.SortOrder }, ct).ConfigureAwait(false);
                    summary.Added++;
                    break;
                }
            }
        }
    }

    private async Task ApplyTemplatesAsync(
        IReadOnlyList<TemplateDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;
        var stubs = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
        var byName = stubs.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);

        // Folder ids must be materialised before we wire templates' FolderId
        // FKs. ApplyTemplateFoldersAsync staged the rows via AddFolderAsync
        // but did NOT call SaveChanges -- on a relational provider that means
        // every new folder still has Id=0 in the change tracker. Flushing
        // here is safe: we're inside the outer import transaction (begun in
        // ApplyAsync), so any later throw still rolls everything back as one
        // unit. The cost is one extra round-trip per import, negligible.
        await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
        var folders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
        var folderIdByName = folders.ToDictionary(f => f.Name, f => f.Id, StringComparer.Ordinal);

        // Honour folder renames -- a TemplateFolder resolved as Rename was
        // written under its new name; templates that reference the old name in
        // the bundle DTO must map to the renamed folder, not be orphaned.
        int? ResolveFolderId(string? folderName)
        {
            if (folderName is null) return null;
            var folderResolution = ResolveOrDefault(map, "TemplateFolder", folderName);
            var lookupName = (folderResolution.Action == ResolutionAction.Rename
                && !string.IsNullOrEmpty(folderResolution.RenameTo))
                ? folderResolution.RenameTo
                : folderName;
            return folderIdByName.TryGetValue(lookupName, out var fid) ? fid : (int?)null;
        }

        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(map, "Template", dto.Name);
            var folderId = ResolveFolderId(dto.FolderName);
            switch (resolution.Action)
            {
                case ResolutionAction.Skip:
                    summary.Skipped++;
                    break;
                case ResolutionAction.Rename:
                {
                    var name = resolution.RenameTo ?? dto.Name;
                    var t = BuildTemplate(dto, overrideName: name);
                    t.FolderId = folderId;
                    await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "Template", "0", name,
                        new { Name = name, dto.Description, FolderId = folderId, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
                    summary.Renamed++;
                    break;
                }
                case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex):
                    ex.Description = dto.Description;
                    ex.FolderId = folderId;
                    await _templateRepo.UpdateTemplateAsync(ex, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Update", "Template", ex.Id.ToString(), ex.Name,
                        new { ex.Name, ex.Description, ex.FolderId }, ct).ConfigureAwait(false);
                    // T-001: Overwrite must also synchronise child collections —
                    // attributes / alarms / scripts diverging between the bundle
                    // and the target must round-trip. Composition rewire is
                    // handled by ResolveCompositionEdgesAsync after the global
                    // flush; alarm→script FKs are rewired by
                    // ResolveAlarmScriptLinksAsync. The helpers below stage the
                    // child diffs (add / update / delete) onto the tracked
                    // entity and emit one audit row per detected change.
                    await SyncTemplateAttributesAsync(ex, dto, user, ct).ConfigureAwait(false);
                    await SyncTemplateAlarmsAsync(ex, dto, user, ct).ConfigureAwait(false);
                    await SyncTemplateScriptsAsync(ex, dto, user, ct).ConfigureAwait(false);
                    await SyncTemplateNativeAlarmSourcesAsync(ex, dto, user, ct).ConfigureAwait(false);
                    summary.Overwritten++;
                    break;
                case ResolutionAction.Add:
                case ResolutionAction.Overwrite:
                default:
                {
                    var t = BuildTemplate(dto, overrideName: null);
                    t.FolderId = folderId;
                    await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "Template", "0", t.Name,
                        new { t.Name, t.Description, t.FolderId }, ct).ConfigureAwait(false);
                    summary.Added++;
                    break;
                }
            }
        }
    }

    /// <summary>
    /// Builds a <see cref="Template"/> from a bundle DTO, copying attributes /
    /// alarms / scripts. Two name-keyed FKs are NOT wired here because they
    /// require post-flush identity values:
    /// <list type="bullet">
    /// <item><c>TemplateAlarm.OnTriggerScriptId</c> — points at a sibling
    /// <c>TemplateScript</c>; resolved by <see cref="ResolveAlarmScriptLinksAsync"/>
    /// once <c>SaveChangesAsync</c> has assigned script ids.</item>
    /// <item><c>TemplateComposition.ComposedTemplateId</c> — points at any
    /// other persisted <c>Template</c>; resolved by
    /// <see cref="ResolveCompositionEdgesAsync"/> once all bundled templates
    /// have been flushed and any pre-existing target templates can be joined
    /// in by name.</item>
    /// </list>
    /// Both resolution passes run inside the same outer import transaction.
    /// <paramref name="overrideName"/> supports the Rename resolution; pass
    /// <c>null</c> to keep the DTO's original name. Renamed templates are
    /// looked up by their <em>imported</em> name (i.e. <c>RenameTo</c>) when
    /// the second pass resolves their alarm/composition FKs; however, bundle
    /// DTOs that reference a renamed template by its <em>original</em> name
    /// will still fall through to the unresolved-audit path — call sites are
    /// not rewritten in v1.
    /// </summary>
    /// <summary>
    /// Publishes one <see cref="ScriptArtifactsChanged"/> per script-bearing
    /// artifact kind (ApiMethod / SharedScript / Template) for every non-Skip
    /// resolution — Overwrite, Rename, AND Add — using the post-resolution (renamed)
    /// names. An Add is NOT "nothing cached yet": an artifact deleted on the target and
    /// re-imported as Add under the same name can still have a stale
    /// compiled-handler/<c>_knownBadMethods</c> entry on any node, so it must notify too.
    /// Over-approximation stays safe (the bus is advisory, at-least-once; consumers
    /// self-heal), which matches its stated contract. Only Skip resolutions are excluded
    /// (nothing was written). Fully guarded: never throws (it runs after the transaction
    /// has committed).
    /// </summary>
    private void PublishScriptArtifactChanges(IReadOnlyList<ImportResolution> resolutions)
    {
        if (_scriptArtifactChangeBus is null)
        {
            return;
        }

        try
        {
            PublishKind(resolutions, "ApiMethod", ScriptArtifactKinds.ApiMethod);
            PublishKind(resolutions, "SharedScript", ScriptArtifactKinds.SharedScript);
            PublishKind(resolutions, "Template", ScriptArtifactKinds.Template);
        }
        catch (Exception ex)
        {
            // The import already committed — a publish fault must never surface as
            // an import failure. Swallow-and-log (the bus itself also guards
            // per-subscriber, so this only catches pathological cases).
            _logger?.LogError(ex, "Publishing script-artifact-change notifications after a committed bundle import failed; ignored.");
        }

        void PublishKind(IReadOnlyList<ImportResolution> all, string entityType, string kind)
        {
            var names = all
                .Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal)
                    && r.Action is not ResolutionAction.Skip)
                .Select(r => r.Action == ResolutionAction.Rename ? r.RenameTo ?? r.Name : r.Name)
                .Distinct(StringComparer.Ordinal)
                .ToList();
            if (names.Count == 0)
            {
                return;
            }

            _scriptArtifactChangeBus!.Publish(new ScriptArtifactsChanged(
                ArtifactKind: kind,
                Names: names,
                Source: "BundleImport",
                OccurredAtUtc: _timeProvider.GetUtcNow()));
        }
    }

    private static Template BuildTemplate(TemplateDto dto, string? overrideName)
    {
        var t = new Template(overrideName ?? dto.Name) { Description = dto.Description };
        foreach (var a in dto.Attributes)
        {
            t.Attributes.Add(new TemplateAttribute(a.Name)
            {
                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)
            {
                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)
            {
                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)
            {
                Description = n.Description,
                ConnectionName = n.ConnectionName,
                SourceReference = n.SourceReference,
                ConditionFilter = n.ConditionFilter,
                IsLocked = n.IsLocked,
                IsInherited = n.IsInherited,
                LockedInDerived = n.LockedInDerived,
            });
        }
        return t;
    }

    /// <summary>
    /// T-001 — Overwrite child sync (attributes). Diffs the DTO's
    /// <c>Attributes</c> against the existing template's attribute collection
    /// by name and stages add / update / delete on the tracked entity. Emits
    /// one <c>TemplateAttributeAdded</c> / <c>TemplateAttributeUpdated</c> /
    /// <c>TemplateAttributeDeleted</c> audit row per detected change — the
    /// per-field rows the design doc's Configuration Audit Trail table
    /// enumerates for the "Template overwritten" action.
    /// <para>
    /// Update detection compares every scalar field (Value, DataType,
    /// ElementDataType, IsLocked, Description, DataSourceReference) — no field
    /// change → no audit row, so an idempotent overwrite produces no noise.
    /// </para>
    /// </summary>
    private async Task SyncTemplateAttributesAsync(
        Template ex,
        TemplateDto dto,
        string user,
        CancellationToken ct)
    {
        var existingByName = ex.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
        var dtoByName = dto.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);

        // Deletes — attributes present on the target but not in the bundle.
        foreach (var existing in existingByName.Values.ToList())
        {
            if (dtoByName.ContainsKey(existing.Name)) continue;
            await _templateRepo.DeleteTemplateAttributeAsync(existing.Id, ct).ConfigureAwait(false);
            ex.Attributes.Remove(existing);
            await _auditService.LogAsync(
                user,
                "TemplateAttributeDeleted",
                "TemplateAttribute",
                existing.Id.ToString(),
                $"{ex.Name}.{existing.Name}",
                new { TemplateName = ex.Name, AttributeName = existing.Name },
                ct).ConfigureAwait(false);
        }

        // Adds + Updates.
        foreach (var attrDto in dto.Attributes)
        {
            // Normalise List values to the native-typed JSON form on import so the
            // comparison (and the persisted value) match what the target already
            // stores natively — otherwise an idempotent re-import of an old-form
            // bundle would spuriously report a Value change.
            var normalizedValue = ImportValueNormalizer.NormalizeListValue(
                attrDto.Value, attrDto.DataType, attrDto.ElementDataType, _logger, attrDto.Name);
            if (existingByName.TryGetValue(attrDto.Name, out var current))
            {
                // Update only if any field actually changed — single-sourced through
                // TemplateChildEquality. Compare against the DTO with its
                // List value already normalised so an idempotent re-import of an
                // old-form bundle doesn't spuriously report a Value change.
                bool changed = !TemplateChildEquality.AttributesEqual(
                    current, attrDto with { Value = normalizedValue });
                if (!changed) continue;

                current.Value = normalizedValue;
                current.DataType = attrDto.DataType;
                current.IsLocked = attrDto.IsLocked;
                current.Description = attrDto.Description;
                current.DataSourceReference = attrDto.DataSourceReference;
                current.ElementDataType = attrDto.ElementDataType;
                current.LockedInDerived = attrDto.LockedInDerived;
                await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false);
                await _auditService.LogAsync(
                    user,
                    "TemplateAttributeUpdated",
                    "TemplateAttribute",
                    current.Id.ToString(),
                    $"{ex.Name}.{current.Name}",
                    new
                    {
                        TemplateName = ex.Name,
                        AttributeName = current.Name,
                        current.Value,
                        current.DataType,
                        current.ElementDataType,
                        current.IsLocked,
                        current.LockedInDerived,
                        current.Description,
                        current.DataSourceReference,
                    },
                    ct).ConfigureAwait(false);
            }
            else
            {
                var newAttr = new TemplateAttribute(attrDto.Name)
                {
                    Value = normalizedValue,
                    DataType = attrDto.DataType,
                    IsLocked = attrDto.IsLocked,
                    Description = attrDto.Description,
                    DataSourceReference = attrDto.DataSourceReference,
                    ElementDataType = attrDto.ElementDataType,
                    LockedInDerived = attrDto.LockedInDerived,
                };
                ex.Attributes.Add(newAttr);
                await _auditService.LogAsync(
                    user,
                    "TemplateAttributeAdded",
                    "TemplateAttribute",
                    "0",
                    $"{ex.Name}.{newAttr.Name}",
                    new
                    {
                        TemplateName = ex.Name,
                        AttributeName = newAttr.Name,
                        newAttr.Value,
                        newAttr.DataType,
                        newAttr.ElementDataType,
                        newAttr.IsLocked,
                        newAttr.LockedInDerived,
                        newAttr.Description,
                        newAttr.DataSourceReference,
                    },
                    ct).ConfigureAwait(false);
            }
        }
    }

    /// <summary>
    /// T-001 — Overwrite child sync (alarms). Mirrors
    /// <see cref="SyncTemplateAttributesAsync"/> for the alarm collection.
    /// Updated / added alarms have their <c>OnTriggerScriptId</c> cleared so
    /// the post-flush <see cref="ResolveAlarmScriptLinksAsync"/> pass re-binds
    /// the FK from the DTO's <c>OnTriggerScriptName</c> against the synced
    /// script collection. Audit rows: <c>TemplateAlarmAdded</c> /
    /// <c>TemplateAlarmUpdated</c> / <c>TemplateAlarmDeleted</c>.
    /// </summary>
    private async Task SyncTemplateAlarmsAsync(
        Template ex,
        TemplateDto dto,
        string user,
        CancellationToken ct)
    {
        var existingByName = ex.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
        var dtoByName = dto.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);

        // On-trigger script is referenced by name in the bundle; resolve the
        // persisted FK back to a name over this template's scripts so the shared
        // equality can compare it. Scripts are synced after alarms, so
        // this reflects the pre-sync (current) script set — which is exactly the
        // set current.OnTriggerScriptId points into.
        var scriptNameById = TemplateChildEquality.ScriptNameResolver(ex.Scripts);

        foreach (var existing in existingByName.Values.ToList())
        {
            if (dtoByName.ContainsKey(existing.Name)) continue;
            await _templateRepo.DeleteTemplateAlarmAsync(existing.Id, ct).ConfigureAwait(false);
            ex.Alarms.Remove(existing);
            await _auditService.LogAsync(
                user,
                "TemplateAlarmDeleted",
                "TemplateAlarm",
                existing.Id.ToString(),
                $"{ex.Name}.{existing.Name}",
                new { TemplateName = ex.Name, AlarmName = existing.Name },
                ct).ConfigureAwait(false);
        }

        foreach (var alarmDto in dto.Alarms)
        {
            if (existingByName.TryGetValue(alarmDto.Name, out var current))
            {
                // Single-sourced through TemplateChildEquality — includes
                // the on-trigger script binding, which the diff also compares.
                bool changed = !TemplateChildEquality.AlarmsEqual(current, alarmDto, scriptNameById);
                if (!changed)
                {
                    // Always reset the script FK on Overwrite so the post-flush
                    // resolve pass owns the binding (the DTO's script name is
                    // the authoritative reference); leaving a stale FK would
                    // silently survive Overwrite when the user expected the
                    // bundle to be the source of truth.
                    if ((current.OnTriggerScriptId is not null) ||
                        !string.IsNullOrEmpty(alarmDto.OnTriggerScriptName))
                    {
                        current.OnTriggerScriptId = null;
                        await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
                    }
                    continue;
                }

                current.Description = alarmDto.Description;
                current.PriorityLevel = alarmDto.PriorityLevel;
                current.TriggerType = alarmDto.TriggerType;
                current.TriggerConfiguration = alarmDto.TriggerConfiguration;
                current.IsLocked = alarmDto.IsLocked;
                current.LockedInDerived = alarmDto.LockedInDerived;
                current.OnTriggerScriptId = null; // re-resolved post-flush.
                await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
                await _auditService.LogAsync(
                    user,
                    "TemplateAlarmUpdated",
                    "TemplateAlarm",
                    current.Id.ToString(),
                    $"{ex.Name}.{current.Name}",
                    new
                    {
                        TemplateName = ex.Name,
                        AlarmName = current.Name,
                        current.Description,
                        current.PriorityLevel,
                        current.TriggerType,
                        current.TriggerConfiguration,
                        current.IsLocked,
                        current.LockedInDerived,
                        OnTriggerScriptName = alarmDto.OnTriggerScriptName,
                    },
                    ct).ConfigureAwait(false);
            }
            else
            {
                var newAlarm = new TemplateAlarm(alarmDto.Name)
                {
                    Description = alarmDto.Description,
                    PriorityLevel = alarmDto.PriorityLevel,
                    TriggerType = alarmDto.TriggerType,
                    TriggerConfiguration = alarmDto.TriggerConfiguration,
                    IsLocked = alarmDto.IsLocked,
                    LockedInDerived = alarmDto.LockedInDerived,
                };
                ex.Alarms.Add(newAlarm);
                await _auditService.LogAsync(
                    user,
                    "TemplateAlarmAdded",
                    "TemplateAlarm",
                    "0",
                    $"{ex.Name}.{newAlarm.Name}",
                    new
                    {
                        TemplateName = ex.Name,
                        AlarmName = newAlarm.Name,
                        newAlarm.Description,
                        newAlarm.PriorityLevel,
                        newAlarm.TriggerType,
                        newAlarm.TriggerConfiguration,
                        newAlarm.IsLocked,
                        newAlarm.LockedInDerived,
                        OnTriggerScriptName = alarmDto.OnTriggerScriptName,
                    },
                    ct).ConfigureAwait(false);
            }
        }
    }

    /// <summary>
    /// T-001 — Overwrite child sync (scripts). Mirrors
    /// <see cref="SyncTemplateAttributesAsync"/> for the script collection.
    /// Audit rows: <c>TemplateScriptAdded</c> / <c>TemplateScriptUpdated</c> /
    /// <c>TemplateScriptDeleted</c>.
    /// </summary>
    private async Task SyncTemplateScriptsAsync(
        Template ex,
        TemplateDto dto,
        string user,
        CancellationToken ct)
    {
        var existingByName = ex.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
        var dtoByName = dto.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);

        foreach (var existing in existingByName.Values.ToList())
        {
            if (dtoByName.ContainsKey(existing.Name)) continue;
            await _templateRepo.DeleteTemplateScriptAsync(existing.Id, ct).ConfigureAwait(false);
            ex.Scripts.Remove(existing);
            await _auditService.LogAsync(
                user,
                "TemplateScriptDeleted",
                "TemplateScript",
                existing.Id.ToString(),
                $"{ex.Name}.{existing.Name}",
                new { TemplateName = ex.Name, ScriptName = existing.Name },
                ct).ConfigureAwait(false);
        }

        foreach (var scriptDto in dto.Scripts)
        {
            if (existingByName.TryGetValue(scriptDto.Name, out var current))
            {
                // Single-sourced through TemplateChildEquality.
                bool changed = !TemplateChildEquality.ScriptsEqual(current, scriptDto);
                if (!changed) continue;

                current.Code = scriptDto.Code;
                current.TriggerType = scriptDto.TriggerType;
                current.TriggerConfiguration = scriptDto.TriggerConfiguration;
                current.ParameterDefinitions = scriptDto.ParameterDefinitions;
                current.ReturnDefinition = scriptDto.ReturnDefinition;
                current.IsLocked = scriptDto.IsLocked;
                current.MinTimeBetweenRuns = scriptDto.MinTimeBetweenRuns;
                current.ExecutionTimeoutSeconds = scriptDto.ExecutionTimeoutSeconds;
                current.LockedInDerived = scriptDto.LockedInDerived;
                await _templateRepo.UpdateTemplateScriptAsync(current, ct).ConfigureAwait(false);
                await _auditService.LogAsync(
                    user,
                    "TemplateScriptUpdated",
                    "TemplateScript",
                    current.Id.ToString(),
                    $"{ex.Name}.{current.Name}",
                    new
                    {
                        TemplateName = ex.Name,
                        ScriptName = current.Name,
                        current.TriggerType,
                        current.TriggerConfiguration,
                        current.IsLocked,
                        current.LockedInDerived,
                    },
                    ct).ConfigureAwait(false);
            }
            else
            {
                var newScript = new TemplateScript(scriptDto.Name, scriptDto.Code)
                {
                    TriggerType = scriptDto.TriggerType,
                    TriggerConfiguration = scriptDto.TriggerConfiguration,
                    ParameterDefinitions = scriptDto.ParameterDefinitions,
                    ReturnDefinition = scriptDto.ReturnDefinition,
                    IsLocked = scriptDto.IsLocked,
                    MinTimeBetweenRuns = scriptDto.MinTimeBetweenRuns,
                    ExecutionTimeoutSeconds = scriptDto.ExecutionTimeoutSeconds,
                    LockedInDerived = scriptDto.LockedInDerived,
                };
                ex.Scripts.Add(newScript);
                await _auditService.LogAsync(
                    user,
                    "TemplateScriptAdded",
                    "TemplateScript",
                    "0",
                    $"{ex.Name}.{newScript.Name}",
                    new
                    {
                        TemplateName = ex.Name,
                        ScriptName = newScript.Name,
                        newScript.TriggerType,
                        newScript.TriggerConfiguration,
                        newScript.IsLocked,
                        newScript.LockedInDerived,
                    },
                    ct).ConfigureAwait(false);
            }
        }
    }

    /// <summary>
    /// Overwrite child sync (native alarm sources). Mirrors
    /// <see cref="SyncTemplateAttributesAsync"/> for the
    /// <c>NativeAlarmSources</c> collection: diffs the DTO's sources against the
    /// existing template's sources by name and stages add / update / delete on
    /// the tracked entity. Audit rows:
    /// <c>TemplateNativeAlarmSourceAdded</c> / <c>…Updated</c> / <c>…Deleted</c>.
    /// <para>
    /// Update detection compares every writable field (Description,
    /// ConnectionName, SourceReference, ConditionFilter, IsLocked, IsInherited,
    /// LockedInDerived) — an idempotent overwrite produces no audit noise.
    /// </para>
    /// </summary>
    private async Task SyncTemplateNativeAlarmSourcesAsync(
        Template ex,
        TemplateDto dto,
        string user,
        CancellationToken ct)
    {
        var existingByName = ex.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
        var dtoByName = dto.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);

        // Deletes — sources present on the target but not in the bundle.
        foreach (var existing in existingByName.Values.ToList())
        {
            if (dtoByName.ContainsKey(existing.Name)) continue;
            await _templateRepo.DeleteTemplateNativeAlarmSourceAsync(existing.Id, ct).ConfigureAwait(false);
            ex.NativeAlarmSources.Remove(existing);
            await _auditService.LogAsync(
                user,
                "TemplateNativeAlarmSourceDeleted",
                "TemplateNativeAlarmSource",
                existing.Id.ToString(),
                $"{ex.Name}.{existing.Name}",
                new { TemplateName = ex.Name, SourceName = existing.Name },
                ct).ConfigureAwait(false);
        }

        // Adds + Updates.
        foreach (var srcDto in dto.NativeAlarmSources)
        {
            if (existingByName.TryGetValue(srcDto.Name, out var current))
            {
                // Single-sourced through TemplateChildEquality.
                bool changed = !TemplateChildEquality.NativeAlarmSourcesEqual(current, srcDto);
                if (!changed) continue;

                current.Description = srcDto.Description;
                current.ConnectionName = srcDto.ConnectionName;
                current.SourceReference = srcDto.SourceReference;
                current.ConditionFilter = srcDto.ConditionFilter;
                current.IsLocked = srcDto.IsLocked;
                current.IsInherited = srcDto.IsInherited;
                current.LockedInDerived = srcDto.LockedInDerived;
                await _templateRepo.UpdateTemplateNativeAlarmSourceAsync(current, ct).ConfigureAwait(false);
                await _auditService.LogAsync(
                    user,
                    "TemplateNativeAlarmSourceUpdated",
                    "TemplateNativeAlarmSource",
                    current.Id.ToString(),
                    $"{ex.Name}.{current.Name}",
                    new
                    {
                        TemplateName = ex.Name,
                        SourceName = current.Name,
                        current.Description,
                        current.ConnectionName,
                        current.SourceReference,
                        current.ConditionFilter,
                        current.IsLocked,
                        current.IsInherited,
                        current.LockedInDerived,
                    },
                    ct).ConfigureAwait(false);
            }
            else
            {
                var newSource = new TemplateNativeAlarmSource(srcDto.Name)
                {
                    Description = srcDto.Description,
                    ConnectionName = srcDto.ConnectionName,
                    SourceReference = srcDto.SourceReference,
                    ConditionFilter = srcDto.ConditionFilter,
                    IsLocked = srcDto.IsLocked,
                    IsInherited = srcDto.IsInherited,
                    LockedInDerived = srcDto.LockedInDerived,
                };
                ex.NativeAlarmSources.Add(newSource);
                await _auditService.LogAsync(
                    user,
                    "TemplateNativeAlarmSourceAdded",
                    "TemplateNativeAlarmSource",
                    "0",
                    $"{ex.Name}.{newSource.Name}",
                    new
                    {
                        TemplateName = ex.Name,
                        SourceName = newSource.Name,
                        newSource.Description,
                        newSource.ConnectionName,
                        newSource.SourceReference,
                        newSource.ConditionFilter,
                        newSource.IsLocked,
                        newSource.IsInherited,
                        newSource.LockedInDerived,
                    },
                    ct).ConfigureAwait(false);
            }
        }
    }

    /// <summary>
    /// Pass A of the post-template-flush rewire.
    /// For every imported template (Add / Overwrite / Rename) whose bundle DTO
    /// carries any alarm with <c>OnTriggerScriptName</c>, look up the
    /// persisted alarm + script by name on the same template and set the FK.
    /// If the named script is missing from the imported template, leave the
    /// FK null and emit a <c>BundleImportAlarmScriptUnresolved</c> audit row
    /// (correlation context still carries <c>BundleImportId</c>).
    /// </summary>
    private async Task ResolveAlarmScriptLinksAsync(
        IReadOnlyList<TemplateDto> dtos,
        Dictionary<(string, string), ImportResolution> resolutionMap,
        string user,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;

        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;

            // Resolve the imported template by its post-rename name. Skip-
            // resolved DTOs were already filtered out above; everything else
            // landed under either the DTO name or RenameTo.
            var importedName = resolution.Action == ResolutionAction.Rename
                ? (resolution.RenameTo ?? dto.Name)
                : dto.Name;

            // Use the children-loaded variant so the alarm + script collections
            // are populated — the unfiltered GetAllTemplatesAsync returns the
            // tracked entities EF assigned ids to during the flush, but the
            // navigation collections aren't necessarily included.
            var stub = _dbContext.Templates.Local.FirstOrDefault(t =>
                string.Equals(t.Name, importedName, StringComparison.Ordinal));
            if (stub is null) continue;
            var template = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false);
            if (template is null) continue;

            foreach (var alarmDto in dto.Alarms)
            {
                if (string.IsNullOrEmpty(alarmDto.OnTriggerScriptName)) continue;

                var alarm = template.Alarms.FirstOrDefault(a =>
                    string.Equals(a.Name, alarmDto.Name, StringComparison.Ordinal));
                if (alarm is null) continue;

                var script = template.Scripts.FirstOrDefault(s =>
                    string.Equals(s.Name, alarmDto.OnTriggerScriptName, StringComparison.Ordinal));
                if (script is null)
                {
                    // Unresolved — emit warning audit row, leave FK null.
                    await _auditService.LogAsync(
                        user,
                        "BundleImportAlarmScriptUnresolved",
                        "TemplateAlarm",
                        alarm.Id.ToString(),
                        $"{template.Name}.{alarm.Name}",
                        new
                        {
                            TemplateName = template.Name,
                            AlarmName = alarm.Name,
                            UnresolvedScriptName = alarmDto.OnTriggerScriptName,
                        },
                        ct).ConfigureAwait(false);
                    continue;
                }

                alarm.OnTriggerScriptId = script.Id;
                await _templateRepo.UpdateTemplateAlarmAsync(alarm, ct).ConfigureAwait(false);
            }
        }
    }

    /// <summary>
    /// Pass B of the post-template-flush rewire. For every
    /// imported template (Add / Overwrite / Rename) whose bundle DTO carries
    /// any <c>Compositions</c>, replace the persisted template's existing
    /// composition rows with new ones whose <c>ComposedTemplateId</c> is
    /// resolved from <c>ComposedTemplateName</c> by looking up the now-
    /// persisted template (just-imported set first, then pre-existing target).
    /// <para>
    /// Overwrite semantics: the persisted template's existing composition
    /// rows are CLEARED before re-adding from the bundle so an Overwrite
    /// truly overwrites the composition graph and doesn't silently retain
    /// stale edges that aren't in the bundle anymore.
    /// </para>
    /// <para>
    /// When <c>ComposedTemplateName</c> cannot be resolved — most commonly
    /// because the user chose Skip on the referenced template — we emit a
    /// <c>BundleImportCompositionUnresolved</c> audit row and skip the edge.
    /// We deliberately do NOT throw: a Skip-resolved dependency is a
    /// legitimate operator choice, not an import-killing error.
    /// </para>
    /// </summary>
    private async Task ResolveCompositionEdgesAsync(
        IReadOnlyList<TemplateDto> dtos,
        Dictionary<(string, string), ImportResolution> resolutionMap,
        string user,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;

        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;
            if (dto.Compositions.Count == 0) continue;

            var importedName = resolution.Action == ResolutionAction.Rename
                ? (resolution.RenameTo ?? dto.Name)
                : dto.Name;

            var parentStub = _dbContext.Templates.Local.FirstOrDefault(t =>
                string.Equals(t.Name, importedName, StringComparison.Ordinal));
            if (parentStub is null) continue;
            var parent = await _templateRepo.GetTemplateWithChildrenAsync(parentStub.Id, ct).ConfigureAwait(false);
            if (parent is null) continue;

            // Clear existing rows (Overwrite-truly-overwrites). EF Core tracks
            // the deletes via the navigation; explicit DeleteAsync ensures the
            // change is staged even when the relational provider doesn't
            // detect orphans automatically.
            if (parent.Compositions.Count > 0)
            {
                var existingComps = parent.Compositions.ToList();
                foreach (var existing in existingComps)
                {
                    await _templateRepo.DeleteTemplateCompositionAsync(existing.Id, ct).ConfigureAwait(false);
                }
                parent.Compositions.Clear();
            }

            foreach (var compDto in dto.Compositions)
            {
                if (string.IsNullOrEmpty(compDto.ComposedTemplateName))
                {
                    await _auditService.LogAsync(
                        user,
                        "BundleImportCompositionUnresolved",
                        "TemplateComposition",
                        "0",
                        $"{parent.Name}.{compDto.InstanceName}",
                        new
                        {
                            OwnerTemplateName = parent.Name,
                            compDto.InstanceName,
                            UnresolvedComposedTemplateName = (string?)null,
                            Reason = "Composition DTO carried empty ComposedTemplateName.",
                        },
                        ct).ConfigureAwait(false);
                    continue;
                }

                // Resolve the composed template by name — first against the
                // tracked Local set (which includes anything just imported,
                // pre-flush or post-flush), then against the wider target DB
                // (pre-existing rows not staged in this transaction).
                var composedStub = _dbContext.Templates.Local.FirstOrDefault(t =>
                    string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal));
                int? composedId = composedStub?.Id;
                if (composedId is null)
                {
                    var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
                    composedId = allTargets
                        .FirstOrDefault(t => string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal))?.Id;
                }

                if (composedId is null)
                {
                    await _auditService.LogAsync(
                        user,
                        "BundleImportCompositionUnresolved",
                        "TemplateComposition",
                        "0",
                        $"{parent.Name}.{compDto.InstanceName}",
                        new
                        {
                            OwnerTemplateName = parent.Name,
                            compDto.InstanceName,
                            UnresolvedComposedTemplateName = compDto.ComposedTemplateName,
                            Reason = "Composed template name not present in bundle or target.",
                        },
                        ct).ConfigureAwait(false);
                    continue;
                }

                var comp = new TemplateComposition(compDto.InstanceName)
                {
                    TemplateId = parent.Id,
                    ComposedTemplateId = composedId.Value,
                };
                await _templateRepo.AddTemplateCompositionAsync(comp, ct).ConfigureAwait(false);
            }
        }
    }

    /// <summary>
    /// Second-pass rewire for template inheritance edges (<c>ParentTemplateId</c>),
    /// mirroring <see cref="ResolveCompositionEdgesAsync"/>. The bundle carries the
    /// base by name (<c>TemplateDto.BaseTemplateName</c>); the create/overwrite pass
    /// never sets the FK, so without this pass a derived template lands as a root and
    /// its inheritance is silently lost (arch-review C3). Runs after templates are
    /// flushed so both the derived template and its base are resolvable by name.
    /// Never throws — an unresolvable base leaves the edge null and records an audit
    /// row, keeping the import all-or-nothing on real faults only.
    /// </summary>
    private async Task ResolveInheritanceEdgesAsync(
        IReadOnlyList<TemplateDto> dtos,
        Dictionary<(string, string), ImportResolution> resolutionMap,
        string user,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;

        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;

            var importedName = resolution.Action == ResolutionAction.Rename
                ? (resolution.RenameTo ?? dto.Name)
                : dto.Name;

            // The just-imported/updated template is tracked in the Local set.
            var template = _dbContext.Templates.Local.FirstOrDefault(t =>
                string.Equals(t.Name, importedName, StringComparison.Ordinal));
            if (template is null) continue;

            // A root-template bundle — or an Overwrite that dropped the base —
            // must clear any stale parent edge on the target row.
            if (string.IsNullOrEmpty(dto.BaseTemplateName))
            {
                template.ParentTemplateId = null;
                continue;
            }

            // Resolve the base template's PERSISTED name through the resolution
            // map (it may have been renamed on import), then look it up by name —
            // first in the tracked Local set (anything imported this run), then in
            // the wider target DB (pre-existing rows not staged in this transaction).
            var baseResolution = ResolveOrDefault(resolutionMap, "Template", dto.BaseTemplateName);
            var baseName = baseResolution.Action == ResolutionAction.Rename
                ? (baseResolution.RenameTo ?? dto.BaseTemplateName)
                : dto.BaseTemplateName;

            var baseStub = _dbContext.Templates.Local.FirstOrDefault(t =>
                string.Equals(t.Name, baseName, StringComparison.Ordinal));
            int? baseId = baseStub?.Id;
            if (baseId is null)
            {
                var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
                baseId = allTargets
                    .FirstOrDefault(t => string.Equals(t.Name, baseName, StringComparison.Ordinal))?.Id;
            }

            if (baseId is null)
            {
                // Unresolvable base (e.g. it was Skip-resolved and isn't in the
                // target). Leave the edge null and record it; never throw.
                template.ParentTemplateId = null;
                await _auditService.LogAsync(
                    user,
                    "BundleImportBaseTemplateUnresolved",
                    "Template",
                    "0",
                    importedName,
                    new
                    {
                        DerivedTemplateName = importedName,
                        UnresolvedBaseTemplateName = dto.BaseTemplateName,
                        ResolvedBaseName = baseName,
                        Reason = "Base template name not present in bundle or target.",
                    },
                    ct).ConfigureAwait(false);
                continue;
            }

            template.ParentTemplateId = baseId.Value;
        }
    }

    /// <summary>
    /// Post-rewire acyclicity guard: reads the fully-merged template graph off the
    /// change tracker (staged, not-yet-saved edges included) and rejects the import
    /// if the bundle's inheritance or composition edges would persist a cycle —
    /// e.g. an Overwrite that makes a base inherit from, or compose, its own
    /// descendant. Delegates to the authoritative TemplateEngine
    /// <see cref="CycleDetector"/>. Throws <see cref="SemanticValidationException"/>
    /// on the first cycle found; the caller's catch rolls the whole import back.
    /// </summary>
    /// <remarks>
    /// The template list is taken from the change tracker rather than a fresh query
    /// so it reflects the staged edges without a premature flush. It is complete for
    /// cycle detection: any edge that references a pre-existing target template forces
    /// a full <c>GetAllTemplatesAsync</c> load in the rewire passes (tracking every
    /// template), and any all-imported cycle consists solely of tracked (Local)
    /// templates — so every node of any candidate cycle is present.
    /// </remarks>
    private void EnsureNoTemplateGraphCycles()
    {
        var templates = _dbContext.ChangeTracker.Entries<Template>()
            .Where(e => e.State != EntityState.Deleted)
            .Select(e => e.Entity)
            .ToList();
        if (templates.Count == 0) return;

        foreach (var t in templates)
        {
            if (t.ParentTemplateId.HasValue)
            {
                var cycle = CycleDetector.DetectInheritanceCycle(t.Id, t.ParentTemplateId.Value, templates);
                if (cycle is not null)
                {
                    throw new SemanticValidationException(new[]
                    {
                        $"Import would create a template inheritance cycle: {cycle}",
                    });
                }
            }

            foreach (var comp in t.Compositions)
            {
                if (_dbContext.Entry(comp).State == EntityState.Deleted) continue;
                var cycle = CycleDetector.DetectCompositionCycle(t.Id, comp.ComposedTemplateId, templates);
                if (cycle is not null)
                {
                    throw new SemanticValidationException(new[]
                    {
                        $"Import would create a template composition cycle: {cycle}",
                    });
                }
            }
        }
    }

    private async Task ApplySharedScriptsAsync(
        IReadOnlyList<SharedScriptDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;
        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(map, "SharedScript", dto.Name);
            var existing = await _templateRepo.GetSharedScriptByNameAsync(dto.Name, ct).ConfigureAwait(false);
            switch (resolution.Action)
            {
                case ResolutionAction.Skip:
                    summary.Skipped++;
                    break;
                case ResolutionAction.Rename:
                {
                    var name = resolution.RenameTo ?? dto.Name;
                    var s = new SharedScript(name, dto.Code)
                    {
                        ParameterDefinitions = dto.ParameterDefinitions,
                        ReturnDefinition = dto.ReturnDefinition,
                    };
                    await _templateRepo.AddSharedScriptAsync(s, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "SharedScript", "0", name,
                        new { s.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
                    summary.Renamed++;
                    break;
                }
                case ResolutionAction.Overwrite when existing is not null:
                    existing.Code = dto.Code;
                    existing.ParameterDefinitions = dto.ParameterDefinitions;
                    existing.ReturnDefinition = dto.ReturnDefinition;
                    await _templateRepo.UpdateSharedScriptAsync(existing, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Update", "SharedScript", existing.Id.ToString(), existing.Name,
                        new { existing.Name }, ct).ConfigureAwait(false);
                    summary.Overwritten++;
                    break;
                case ResolutionAction.Add:
                case ResolutionAction.Overwrite:
                default:
                {
                    var s = new SharedScript(dto.Name, dto.Code)
                    {
                        ParameterDefinitions = dto.ParameterDefinitions,
                        ReturnDefinition = dto.ReturnDefinition,
                    };
                    await _templateRepo.AddSharedScriptAsync(s, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "SharedScript", "0", s.Name,
                        new { s.Name }, ct).ConfigureAwait(false);
                    summary.Added++;
                    break;
                }
            }
        }
    }

    private async Task ApplyExternalSystemsAsync(
        IReadOnlyList<ExternalSystemDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;
        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(map, "ExternalSystem", dto.Name);
            var existing = await _externalRepo.GetExternalSystemByNameAsync(dto.Name, ct).ConfigureAwait(false);
            switch (resolution.Action)
            {
                case ResolutionAction.Skip:
                    summary.Skipped++;
                    break;
                case ResolutionAction.Rename:
                {
                    var name = resolution.RenameTo ?? dto.Name;
                    var sys = BuildExternalSystem(dto, overrideName: name);
                    await _externalRepo.AddExternalSystemAsync(sys, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "ExternalSystem", "0", name,
                        new { sys.Name, sys.EndpointUrl, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
                    summary.Renamed++;
                    break;
                }
                case ResolutionAction.Overwrite when existing is not null:
                    existing.EndpointUrl = dto.BaseUrl;
                    existing.AuthType = dto.AuthType;
                    existing.AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null;
                    existing.MaxRetries = dto.MaxRetries;
                    existing.RetryDelay = dto.RetryDelay;
                    await _externalRepo.UpdateExternalSystemAsync(existing, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Update", "ExternalSystem", existing.Id.ToString(), existing.Name,
                        new { existing.Name, existing.EndpointUrl }, ct).ConfigureAwait(false);
                    // T-002: Overwrite must also synchronise the Methods child
                    // collection — added / removed / modified methods on the
                    // bundle DTO must round-trip. Mirrors the T-001 template
                    // child-sync helpers (attributes / alarms / scripts): each
                    // helper emits one audit row per detected change.
                    await SyncExternalSystemMethodsAsync(existing, dto, user, ct).ConfigureAwait(false);
                    summary.Overwritten++;
                    break;
                case ResolutionAction.Add:
                case ResolutionAction.Overwrite:
                default:
                {
                    var sys = BuildExternalSystem(dto, overrideName: null);
                    await _externalRepo.AddExternalSystemAsync(sys, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "ExternalSystem", "0", sys.Name,
                        new { sys.Name, sys.EndpointUrl }, ct).ConfigureAwait(false);
                    summary.Added++;
                    break;
                }
            }
        }
    }

    private static ExternalSystemDefinition BuildExternalSystem(ExternalSystemDto dto, string? overrideName)
    {
        var sys = new ExternalSystemDefinition(overrideName ?? dto.Name, dto.BaseUrl, dto.AuthType)
        {
            AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
            MaxRetries = dto.MaxRetries,
            RetryDelay = dto.RetryDelay,
        };
        return sys;
    }

    /// <summary>
    /// T-002 — Overwrite child sync (ExternalSystem methods). Mirrors the
    /// T-001 <c>SyncTemplate*Async</c> helpers: name-keyed diff between the
    /// bundle DTO and the persisted children, then add / update / delete via
    /// the repository with one audit row per detected change. Methods are
    /// NOT a navigation on <see cref="ExternalSystemDefinition"/> (the FK
    /// runs from <see cref="ExternalSystemMethod.ExternalSystemDefinitionId"/>
    /// to the parent) so the helper drives the repo directly rather than
    /// mutating a tracked collection like the template helpers do.
    /// <para>
    /// Audit rows: <c>ExternalSystemMethodAdded</c> /
    /// <c>ExternalSystemMethodUpdated</c> / <c>ExternalSystemMethodDeleted</c>.
    /// Idempotent: scalar-field comparison gates the Update audit row, so an
    /// Overwrite against an already-matching method produces no noise.
    /// </para>
    /// </summary>
    private async Task SyncExternalSystemMethodsAsync(
        ExternalSystemDefinition ex,
        ExternalSystemDto dto,
        string user,
        CancellationToken ct)
    {
        var existingMethods = await _externalRepo
            .GetMethodsByExternalSystemIdAsync(ex.Id, ct)
            .ConfigureAwait(false);
        var existingByName = existingMethods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
        var dtoByName = dto.Methods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);

        // Deletes — methods present on the target but not in the bundle.
        foreach (var existing in existingByName.Values.ToList())
        {
            if (dtoByName.ContainsKey(existing.Name)) continue;
            await _externalRepo.DeleteExternalSystemMethodAsync(existing.Id, ct).ConfigureAwait(false);
            await _auditService.LogAsync(
                user,
                "ExternalSystemMethodDeleted",
                "ExternalSystemMethod",
                existing.Id.ToString(),
                $"{ex.Name}.{existing.Name}",
                new { ExternalSystemName = ex.Name, MethodName = existing.Name },
                ct).ConfigureAwait(false);
        }

        // Adds + Updates.
        foreach (var methodDto in dto.Methods)
        {
            if (existingByName.TryGetValue(methodDto.Name, out var current))
            {
                // Update only if any field actually changed — mirrors the
                // ArtifactDiff.ExternalSystemMethodsEqual comparator.
                bool changed =
                    !string.Equals(current.HttpMethod, methodDto.HttpMethod, StringComparison.Ordinal) ||
                    !string.Equals(current.Path, methodDto.Path, StringComparison.Ordinal) ||
                    !string.Equals(current.ParameterDefinitions, methodDto.ParameterDefinitions, StringComparison.Ordinal) ||
                    !string.Equals(current.ReturnDefinition, methodDto.ReturnDefinition, StringComparison.Ordinal);
                if (!changed) continue;

                current.HttpMethod = methodDto.HttpMethod;
                current.Path = methodDto.Path;
                current.ParameterDefinitions = methodDto.ParameterDefinitions;
                current.ReturnDefinition = methodDto.ReturnDefinition;
                await _externalRepo.UpdateExternalSystemMethodAsync(current, ct).ConfigureAwait(false);
                await _auditService.LogAsync(
                    user,
                    "ExternalSystemMethodUpdated",
                    "ExternalSystemMethod",
                    current.Id.ToString(),
                    $"{ex.Name}.{current.Name}",
                    new
                    {
                        ExternalSystemName = ex.Name,
                        MethodName = current.Name,
                        current.HttpMethod,
                        current.Path,
                        current.ParameterDefinitions,
                        current.ReturnDefinition,
                    },
                    ct).ConfigureAwait(false);
            }
            else
            {
                var newMethod = new ExternalSystemMethod(methodDto.Name, methodDto.HttpMethod, methodDto.Path)
                {
                    ExternalSystemDefinitionId = ex.Id,
                    ParameterDefinitions = methodDto.ParameterDefinitions,
                    ReturnDefinition = methodDto.ReturnDefinition,
                };
                await _externalRepo.AddExternalSystemMethodAsync(newMethod, ct).ConfigureAwait(false);
                await _auditService.LogAsync(
                    user,
                    "ExternalSystemMethodAdded",
                    "ExternalSystemMethod",
                    "0",
                    $"{ex.Name}.{newMethod.Name}",
                    new
                    {
                        ExternalSystemName = ex.Name,
                        MethodName = newMethod.Name,
                        newMethod.HttpMethod,
                        newMethod.Path,
                        newMethod.ParameterDefinitions,
                        newMethod.ReturnDefinition,
                    },
                    ct).ConfigureAwait(false);
            }
        }
    }

    /// <summary>
    /// Second-pass rewire of the name-keyed <see cref="TemplateFolder.ParentFolderId"/>
    /// edge. The Add pass creates folders parent-less (parent may be later in the
    /// bundle and have no id yet); after the folder flush, resolve each folder's
    /// bundle <c>ParentName</c> (through the rename resolution map) to a persisted
    /// folder id and set the FK. Mirrors <see cref="ResolveInheritanceEdgesAsync"/>.
    /// </summary>
    private async Task ResolveFolderParentEdgesAsync(
        IReadOnlyList<TemplateFolderDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;
        var allFolders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
        var byName = allFolders.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);

        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(map, "TemplateFolder", dto.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;

            var childName = resolution.Action == ResolutionAction.Rename
                ? resolution.RenameTo ?? dto.Name
                : dto.Name;
            if (!byName.TryGetValue(childName, out var child)) continue;

            int? parentId = null;
            if (dto.ParentName is not null)
            {
                // A renamed parent is persisted under its RenameTo name.
                var parentResolution = ResolveOrDefault(map, "TemplateFolder", dto.ParentName);
                var parentName = parentResolution.Action == ResolutionAction.Rename
                    ? parentResolution.RenameTo ?? dto.ParentName
                    : dto.ParentName;
                if (byName.TryGetValue(parentName, out var parent))
                {
                    parentId = parent.Id;
                }
            }

            if (child.ParentFolderId != parentId)
            {
                child.ParentFolderId = parentId;
                await _templateRepo.UpdateFolderAsync(child, ct).ConfigureAwait(false);
            }
        }
    }

    /// <summary>
    /// Persists an added external system's method rows. <see cref="ExternalSystemMethod"/>
    /// has no navigation on its parent definition, so an Add can't cascade its
    /// methods the way template children do — they are added here after the parent
    /// system has a materialised id from the central-config flush. Idempotent: a
    /// method already present (e.g. a genuine Overwrite that synced its methods
    /// inline) is skipped, so re-processing every non-Skip system is safe.
    /// </summary>
    private async Task PersistAddedExternalSystemMethodsAsync(
        IReadOnlyList<ExternalSystemDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;

        foreach (var dto in dtos)
        {
            if (dto.Methods.Count == 0) continue;
            var resolution = ResolveOrDefault(map, "ExternalSystem", dto.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;

            var persistedName = resolution.Action == ResolutionAction.Rename
                ? resolution.RenameTo ?? dto.Name
                : dto.Name;
            var sys = await _externalRepo.GetExternalSystemByNameAsync(persistedName, ct).ConfigureAwait(false);
            if (sys is null) continue;

            var existing = await _externalRepo.GetMethodsByExternalSystemIdAsync(sys.Id, ct).ConfigureAwait(false);
            var existingNames = existing.Select(m => m.Name).ToHashSet(StringComparer.Ordinal);

            foreach (var methodDto in dto.Methods)
            {
                if (existingNames.Contains(methodDto.Name)) continue; // already synced (Overwrite) or already added.
                var method = new ExternalSystemMethod(methodDto.Name, methodDto.HttpMethod, methodDto.Path)
                {
                    ExternalSystemDefinitionId = sys.Id,
                    ParameterDefinitions = methodDto.ParameterDefinitions,
                    ReturnDefinition = methodDto.ReturnDefinition,
                };
                await _externalRepo.AddExternalSystemMethodAsync(method, ct).ConfigureAwait(false);
                await _auditService.LogAsync(
                    user,
                    "ExternalSystemMethodAdded",
                    "ExternalSystemMethod",
                    "0",
                    $"{sys.Name}.{method.Name}",
                    new
                    {
                        ExternalSystemName = sys.Name,
                        MethodName = method.Name,
                        method.HttpMethod,
                        method.Path,
                        method.ParameterDefinitions,
                        method.ReturnDefinition,
                    },
                    ct).ConfigureAwait(false);
            }
        }
    }

    private async Task ApplyDatabaseConnectionsAsync(
        IReadOnlyList<DatabaseConnectionDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;
        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(map, "DatabaseConnection", dto.Name);
            var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(dto.Name, ct).ConfigureAwait(false);
            var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty;

            switch (resolution.Action)
            {
                case ResolutionAction.Skip:
                    summary.Skipped++;
                    break;
                case ResolutionAction.Rename:
                {
                    var name = resolution.RenameTo ?? dto.Name;
                    var c = new DatabaseConnectionDefinition(name, connStr)
                    {
                        MaxRetries = dto.MaxRetries,
                        RetryDelay = dto.RetryDelay,
                    };
                    await _externalRepo.AddDatabaseConnectionAsync(c, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "DatabaseConnection", "0", name,
                        new { c.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
                    summary.Renamed++;
                    break;
                }
                case ResolutionAction.Overwrite when existing is not null:
                    existing.ConnectionString = connStr;
                    existing.MaxRetries = dto.MaxRetries;
                    existing.RetryDelay = dto.RetryDelay;
                    await _externalRepo.UpdateDatabaseConnectionAsync(existing, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Update", "DatabaseConnection", existing.Id.ToString(), existing.Name,
                        new { existing.Name }, ct).ConfigureAwait(false);
                    summary.Overwritten++;
                    break;
                case ResolutionAction.Add:
                case ResolutionAction.Overwrite:
                default:
                {
                    var c = new DatabaseConnectionDefinition(dto.Name, connStr)
                    {
                        MaxRetries = dto.MaxRetries,
                        RetryDelay = dto.RetryDelay,
                    };
                    await _externalRepo.AddDatabaseConnectionAsync(c, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "DatabaseConnection", "0", c.Name,
                        new { c.Name }, ct).ConfigureAwait(false);
                    summary.Added++;
                    break;
                }
            }
        }
    }

    private async Task ApplyNotificationListsAsync(
        IReadOnlyList<NotificationListDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;
        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(map, "NotificationList", dto.Name);
            var existing = await _notificationRepo.GetListByNameAsync(dto.Name, ct).ConfigureAwait(false);

            switch (resolution.Action)
            {
                case ResolutionAction.Skip:
                    summary.Skipped++;
                    break;
                case ResolutionAction.Rename:
                {
                    var name = resolution.RenameTo ?? dto.Name;
                    var list = BuildNotificationList(dto, overrideName: name);
                    await _notificationRepo.AddNotificationListAsync(list, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "NotificationList", "0", name,
                        new { list.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
                    summary.Renamed++;
                    break;
                }
                case ResolutionAction.Overwrite when existing is not null:
                    existing.Type = dto.Type;
                    // Recipient sync is structural — clear + re-add. The repo
                    // mutates the navigation collection, EF tracks the delete.
                    existing.Recipients.Clear();
                    foreach (var r in dto.Recipients)
                    {
                        existing.Recipients.Add(BuildRecipient(r));
                    }
                    await _notificationRepo.UpdateNotificationListAsync(existing, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Update", "NotificationList", existing.Id.ToString(), existing.Name,
                        new { existing.Name, RecipientCount = existing.Recipients.Count }, ct).ConfigureAwait(false);
                    summary.Overwritten++;
                    break;
                case ResolutionAction.Add:
                case ResolutionAction.Overwrite:
                default:
                {
                    var list = BuildNotificationList(dto, overrideName: null);
                    await _notificationRepo.AddNotificationListAsync(list, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "NotificationList", "0", list.Name,
                        new { list.Name, RecipientCount = list.Recipients.Count }, ct).ConfigureAwait(false);
                    summary.Added++;
                    break;
                }
            }
        }
    }

    private static NotificationList BuildNotificationList(NotificationListDto dto, string? overrideName)
    {
        var list = new NotificationList(overrideName ?? dto.Name) { Type = dto.Type };
        foreach (var r in dto.Recipients)
        {
            list.Recipients.Add(BuildRecipient(r));
        }
        return list;
    }

    // Reconstruct a recipient carrying whichever contact(s) the bundle holds.
    // Email-only and SMS-only round-trip exactly; a recipient with both keeps both.
    // Build via the email ctor when an address is present (so the historical
    // non-null-email path is unchanged) and additively restore the phone number;
    // otherwise fall back to the SMS factory for phone-only recipients.
    private static NotificationRecipient BuildRecipient(NotificationRecipientDto r)
    {
        if (r.EmailAddress is not null)
        {
            return new NotificationRecipient(r.Name, r.EmailAddress)
            {
                PhoneNumber = r.PhoneNumber,
            };
        }

        return NotificationRecipient.ForSms(r.Name, r.PhoneNumber ?? string.Empty);
    }

    private async Task ApplySmtpConfigsAsync(
        IReadOnlyList<SmtpConfigDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;
        var all = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
        var byHost = all.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);

        foreach (var dto in dtos)
        {
            // SmtpConfiguration is keyed by Host in the diff engine — mirror
            // that here so a Rename targets Host, not an arbitrary "name".
            var resolution = ResolveOrDefault(map, "SmtpConfiguration", dto.Host);
            switch (resolution.Action)
            {
                case ResolutionAction.Skip:
                    summary.Skipped++;
                    break;
                case ResolutionAction.Rename:
                {
                    var host = resolution.RenameTo ?? dto.Host;
                    var smtp = BuildSmtp(dto, overrideHost: host);
                    await _notificationRepo.AddSmtpConfigurationAsync(smtp, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "SmtpConfiguration", "0", host,
                        new { smtp.Host, RenamedFrom = dto.Host }, ct).ConfigureAwait(false);
                    summary.Renamed++;
                    break;
                }
                case ResolutionAction.Overwrite when byHost.TryGetValue(dto.Host, out var ex):
                    ApplySmtpFields(ex, dto);
                    await _notificationRepo.UpdateSmtpConfigurationAsync(ex, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Update", "SmtpConfiguration", ex.Id.ToString(), ex.Host,
                        new { ex.Host }, ct).ConfigureAwait(false);
                    summary.Overwritten++;
                    break;
                case ResolutionAction.Add:
                case ResolutionAction.Overwrite:
                default:
                {
                    var smtp = BuildSmtp(dto, overrideHost: null);
                    await _notificationRepo.AddSmtpConfigurationAsync(smtp, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "SmtpConfiguration", "0", smtp.Host,
                        new { smtp.Host }, ct).ConfigureAwait(false);
                    summary.Added++;
                    break;
                }
            }
        }
    }

    private static SmtpConfiguration BuildSmtp(SmtpConfigDto dto, string? overrideHost)
    {
        var smtp = new SmtpConfiguration(overrideHost ?? dto.Host, dto.AuthType, dto.FromAddress);
        ApplySmtpFields(smtp, dto);
        return smtp;
    }

    private static void ApplySmtpFields(SmtpConfiguration target, SmtpConfigDto dto)
    {
        target.Port = dto.Port;
        target.AuthType = dto.AuthType;
        target.FromAddress = dto.FromAddress;
        target.TlsMode = dto.TlsMode;
        target.ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds;
        target.MaxConcurrentConnections = dto.MaxConcurrentConnections;
        target.MaxRetries = dto.MaxRetries;
        target.RetryDelay = dto.RetryDelay;
        // Non-sensitive delivery metadata; null on a bundle written before these
        // were carried, which is the entity's "provider defaults / Smtp" state.
        // Consequence, by design: a legacy (pre-Transport-field) bundle applied with
        // Overwrite carries nulls here and so downgrades an EWS-configured target back
        // to SMTP — visible to the operator as a Transport row in the import preview diff.
        target.OAuth2Authority = dto.OAuth2Authority;
        target.OAuth2Scope = dto.OAuth2Scope;
        target.Transport = dto.Transport;
        target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null;
    }

    // SMS: mirrors ApplySmtpConfigsAsync exactly. SmsConfiguration is keyed by
    // AccountSid (the diff engine's natural key — analogous to SMTP's Host), so a
    // Rename targets AccountSid and Overwrite matches an existing config by AccountSid.
    // The provider auth token is decrypted out of the SecretsBlock via the same path
    // SMTP uses for its Credentials secret.
    private async Task ApplySmsConfigsAsync(
        IReadOnlyList<SmsConfigDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;
        var all = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false);
        var bySid = all.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal);

        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(map, "SmsConfiguration", dto.AccountSid);
            switch (resolution.Action)
            {
                case ResolutionAction.Skip:
                    summary.Skipped++;
                    break;
                case ResolutionAction.Rename:
                {
                    var sid = resolution.RenameTo ?? dto.AccountSid;
                    var sms = BuildSms(dto, overrideAccountSid: sid);
                    await _notificationRepo.AddSmsConfigurationAsync(sms, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "SmsConfiguration", "0", sid,
                        new { sms.AccountSid, RenamedFrom = dto.AccountSid }, ct).ConfigureAwait(false);
                    summary.Renamed++;
                    break;
                }
                case ResolutionAction.Overwrite when bySid.TryGetValue(dto.AccountSid, out var ex):
                    ApplySmsFields(ex, dto);
                    await _notificationRepo.UpdateSmsConfigurationAsync(ex, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Update", "SmsConfiguration", ex.Id.ToString(), ex.AccountSid,
                        new { ex.AccountSid }, ct).ConfigureAwait(false);
                    summary.Overwritten++;
                    break;
                case ResolutionAction.Add:
                case ResolutionAction.Overwrite:
                default:
                {
                    var sms = BuildSms(dto, overrideAccountSid: null);
                    await _notificationRepo.AddSmsConfigurationAsync(sms, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "SmsConfiguration", "0", sms.AccountSid,
                        new { sms.AccountSid }, ct).ConfigureAwait(false);
                    summary.Added++;
                    break;
                }
            }
        }
    }

    private static SmsConfiguration BuildSms(SmsConfigDto dto, string? overrideAccountSid)
    {
        var sms = new SmsConfiguration(overrideAccountSid ?? dto.AccountSid, dto.FromNumber);
        ApplySmsFields(sms, dto);
        return sms;
    }

    private static void ApplySmsFields(SmsConfiguration target, SmsConfigDto dto)
    {
        target.FromNumber = dto.FromNumber;
        target.MessagingServiceSid = dto.MessagingServiceSid;
        target.ApiBaseUrl = dto.ApiBaseUrl;
        target.ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds;
        target.MaxRetries = dto.MaxRetries;
        target.RetryDelay = dto.RetryDelay;
        target.AuthToken = dto.Secrets?.Values.TryGetValue("AuthToken", out var token) == true ? token : null;
    }

    // ApplyApiKeysAsync was removed: inbound API keys are not
    // transported between environments, so a bundle never re-creates keys. Any keys
    // present in a legacy bundle are counted and ignored in ApplyAsync.

    private async Task ApplyApiMethodsAsync(
        IReadOnlyList<ApiMethodDto> dtos,
        Dictionary<(string, string), ImportResolution> map,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        if (dtos.Count == 0) return;
        foreach (var dto in dtos)
        {
            var resolution = ResolveOrDefault(map, "ApiMethod", dto.Name);
            var existing = await _inboundApiRepo.GetMethodByNameAsync(dto.Name, ct).ConfigureAwait(false);
            switch (resolution.Action)
            {
                case ResolutionAction.Skip:
                    summary.Skipped++;
                    break;
                case ResolutionAction.Rename:
                {
                    var name = resolution.RenameTo ?? dto.Name;
                    var m = BuildApiMethod(dto, overrideName: name);
                    await _inboundApiRepo.AddApiMethodAsync(m, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "ApiMethod", "0", name,
                        new { m.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
                    summary.Renamed++;
                    break;
                }
                case ResolutionAction.Overwrite when existing is not null:
                    existing.Script = dto.Script;
                    // Method→key scopes are not transported and the
                    // ApprovedApiKeyIds column was dropped with the SQL Server ApiKey
                    // entity: scopes are re-granted per environment in the
                    // shared ZB.MOM.WW.Auth.ApiKeys store, never via an imported bundle.
                    existing.ParameterDefinitions = dto.ParameterDefinitions;
                    existing.ReturnDefinition = dto.ReturnDefinition;
                    existing.TimeoutSeconds = dto.TimeoutSeconds;
                    await _inboundApiRepo.UpdateApiMethodAsync(existing, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Update", "ApiMethod", existing.Id.ToString(), existing.Name,
                        new { existing.Name }, ct).ConfigureAwait(false);
                    summary.Overwritten++;
                    break;
                case ResolutionAction.Add:
                case ResolutionAction.Overwrite:
                default:
                {
                    var m = BuildApiMethod(dto, overrideName: null);
                    await _inboundApiRepo.AddApiMethodAsync(m, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "ApiMethod", "0", m.Name,
                        new { m.Name }, ct).ConfigureAwait(false);
                    summary.Added++;
                    break;
                }
            }
        }
    }

    private static ApiMethod BuildApiMethod(ApiMethodDto dto, string? overrideName)
    {
        // Method→key scopes are not transported; the ApprovedApiKeyIds
        // column that once carried them was dropped with the SQL Server ApiKey entity.
        // Scopes are re-granted per environment in the shared
        // ZB.MOM.WW.Auth.ApiKeys store.
        return new ApiMethod(overrideName ?? dto.Name, dto.Script)
        {
            ParameterDefinitions = dto.ParameterDefinitions,
            ReturnDefinition = dto.ReturnDefinition,
            TimeoutSeconds = dto.TimeoutSeconds,
        };
    }

    // ─────────────────────────────────────────────────────────────────────
    // Site/instance-scoped apply.
    //
    // Three passes (sites → connections → instances) resolve every cross-
    // environment name reference against the TARGET database's own surrogate
    // keys, honouring the operator-supplied BundleNameMap with an identity
    // auto-match fallback for references the map doesn't mention. Sites and
    // connections run before the central-config apply (they are FK targets);
    // instances run after the central-config flush (they additionally need
    // template ids by name). All three ride the single outer transaction begun
    // in ApplyAsync, so a throw anywhere rolls back the whole import.
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Resolve-or-create every target <see cref="Site"/> the bundle references,
    /// returning a <c>sourceSiteIdentifier → target Site</c> map (each value
    /// carries the target environment's surrogate <see cref="Site.Id"/>).
    /// <para>
    /// A site's mapping is taken from <paramref name="nameMap"/> (matched by
    /// <see cref="SiteMapping.SourceSiteIdentifier"/>); when the bundle carries
    /// no explicit entry we auto-match by identity — an existing target site with
    /// the same identifier resolves to <see cref="MappingAction.MapToExisting"/>,
    /// otherwise <see cref="MappingAction.CreateNew"/>. <c>CreateNew</c> inserts a
    /// site from the full <see cref="SiteDto"/> payload (display name, description,
    /// and the verbatim Node A/B + gRPC Node A/B addresses — the "carry full
    /// config" decision). <c>MapToExisting</c> honours the site's
    /// <see cref="ImportResolution"/>: <see cref="ResolutionAction.Skip"/> leaves
    /// the target untouched; <see cref="ResolutionAction.Overwrite"/> applies the
    /// bundle's fields onto the existing row.
    /// </para>
    /// <para>
    /// Sites that are <em>referenced</em> by an instance (or data connection) but
    /// not carried in <see cref="BundleContentDto.Sites"/> must already exist in
    /// the target — they auto-match to the existing target site. An unresolvable
    /// reference is a hard error (caught at preview as a blocker; guarded
    /// defensively here so a stale resolution payload can't write an orphan FK).
    /// </para>
    /// </summary>
    private async Task<Dictionary<string, Site>> ApplySitesAsync(
        BundleContentDto content,
        BundleNameMap nameMap,
        Dictionary<(string, string), ImportResolution> resolutionMap,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        var result = new Dictionary<string, Site>(StringComparer.Ordinal);
        var siteMappingByIdentifier = nameMap.Sites
            .GroupBy(m => m.SourceSiteIdentifier, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);

        // Pass 1 — sites the bundle CARRIES (full SiteDto payload available).
        foreach (var siteDto in content.Sites)
        {
            if (result.ContainsKey(siteDto.SiteIdentifier)) continue;

            siteMappingByIdentifier.TryGetValue(siteDto.SiteIdentifier, out var mapping);
            var existing = await _siteRepo
                .GetSiteByIdentifierAsync(siteDto.SiteIdentifier, ct).ConfigureAwait(false);

            // Auto-match when the operator didn't supply an explicit mapping:
            // existing target → MapToExisting, otherwise CreateNew.
            var action = mapping?.Action
                ?? (existing is not null ? MappingAction.MapToExisting : MappingAction.CreateNew);

            // An explicit MapToExisting can target a DIFFERENTLY-named site; honour
            // the operator's TargetSiteIdentifier when supplied, else fall back to
            // the same-identifier match.
            Site? target = existing;
            if (action == MappingAction.MapToExisting
                && mapping?.TargetSiteIdentifier is { Length: > 0 } targetId
                && !string.Equals(targetId, siteDto.SiteIdentifier, StringComparison.Ordinal))
            {
                target = await _siteRepo.GetSiteByIdentifierAsync(targetId, ct).ConfigureAwait(false);
            }

            if (action == MappingAction.CreateNew || target is null)
            {
                var created = BuildSite(siteDto);
                await _siteRepo.AddSiteAsync(created, ct).ConfigureAwait(false);
                await _auditService.LogAsync(user, "Create", "Site", "0", created.SiteIdentifier,
                    new { created.SiteIdentifier, created.Name }, ct).ConfigureAwait(false);
                summary.Added++;
                result[siteDto.SiteIdentifier] = created;
                continue;
            }

            // MapToExisting — honour the site's own conflict resolution.
            var resolution = ResolveOrDefault(resolutionMap, "Site", siteDto.SiteIdentifier);
            if (resolution.Action == ResolutionAction.Overwrite)
            {
                ApplySiteFields(target, siteDto);
                await _siteRepo.UpdateSiteAsync(target, ct).ConfigureAwait(false);
                await _auditService.LogAsync(user, "Update", "Site", target.Id.ToString(), target.SiteIdentifier,
                    new { target.SiteIdentifier, target.Name }, ct).ConfigureAwait(false);
                summary.Overwritten++;
            }
            else
            {
                // Skip / Add(no-op for existing) — leave the target untouched.
                summary.Skipped++;
            }
            result[siteDto.SiteIdentifier] = target;
        }

        // Pass 2 — sites REFERENCED but not carried (instances / connections).
        // These must already exist in the target (no DTO to create from). Auto-
        // match by identity; honour an explicit MapToExisting redirect.
        foreach (var identifier in EnumerateReferencedSiteIdentifiers(content))
        {
            if (result.ContainsKey(identifier)) continue;

            siteMappingByIdentifier.TryGetValue(identifier, out var mapping);
            var lookupId = mapping?.Action == MappingAction.MapToExisting
                && mapping.TargetSiteIdentifier is { Length: > 0 } t
                ? t
                : identifier;
            var target = await _siteRepo.GetSiteByIdentifierAsync(lookupId, ct).ConfigureAwait(false);
            if (target is null)
            {
                // Defensive guard — preview surfaces this as a blocker, but a stale
                // resolution payload could still reach apply. Fail the whole import
                // rather than write an instance/connection with an orphan SiteId.
                throw new InvalidOperationException(
                    $"Site '{identifier}' is referenced by the bundle but not present in the target "
                    + "and not carried in the bundle — cannot resolve a target site.");
            }
            result[identifier] = target;
        }

        return result;
    }

    /// <summary>
    /// Distinct source-site identifiers referenced by instances and data
    /// connections but not necessarily carried as a <see cref="SiteDto"/>.
    /// </summary>
    private static IEnumerable<string> EnumerateReferencedSiteIdentifiers(BundleContentDto content)
    {
        var seen = new HashSet<string>(StringComparer.Ordinal);
        foreach (var i in content.Instances)
        {
            if (!string.IsNullOrEmpty(i.SiteIdentifier) && seen.Add(i.SiteIdentifier))
                yield return i.SiteIdentifier;
        }
        foreach (var dc in content.DataConnections)
        {
            if (!string.IsNullOrEmpty(dc.SiteIdentifier) && seen.Add(dc.SiteIdentifier))
                yield return dc.SiteIdentifier;
        }
    }

    private static Site BuildSite(SiteDto dto) => new(dto.Name, dto.SiteIdentifier)
    {
        Description = dto.Description,
        NodeAAddress = dto.NodeAAddress,
        NodeBAddress = dto.NodeBAddress,
        GrpcNodeAAddress = dto.GrpcNodeAAddress,
        GrpcNodeBAddress = dto.GrpcNodeBAddress,
    };

    private static void ApplySiteFields(Site target, SiteDto dto)
    {
        target.Name = dto.Name;
        target.Description = dto.Description;
        target.NodeAAddress = dto.NodeAAddress;
        target.NodeBAddress = dto.NodeBAddress;
        target.GrpcNodeAAddress = dto.GrpcNodeAAddress;
        target.GrpcNodeBAddress = dto.GrpcNodeBAddress;
    }

    /// <summary>
    /// The resolved-connection maps the instance pass needs:
    /// <see cref="IdBySourceRef"/> rewires connection-binding FKs
    /// (<c>(sourceSite, sourceName) → target DataConnectionId</c>);
    /// <see cref="TargetNameBySourceRef"/> rewrites native-alarm-source
    /// <c>ConnectionNameOverride</c> values to the MAPPED target connection name
    /// (<c>(sourceSite, sourceName) → target connection Name</c>) so a
    /// differently-named MapToExisting redirect carries through.
    /// </summary>
    private readonly record struct ResolvedConnectionMaps(
        Dictionary<(string Site, string Name), int> IdBySourceRef,
        Dictionary<(string Site, string Name), string> TargetNameBySourceRef);

    /// <summary>
    /// Resolve-or-create every target <see cref="DataConnection"/> the bundle
    /// references, returning the id + target-name maps (see
    /// <see cref="ResolvedConnectionMaps"/>) the instance pass uses to rewire
    /// connection-binding FKs and native-alarm-source <c>ConnectionNameOverride</c>
    /// rewrites.
    /// <para>
    /// A connection's mapping is taken from <paramref name="nameMap"/> (matched by
    /// <see cref="ConnectionMapping.SourceSiteIdentifier"/> +
    /// <see cref="ConnectionMapping.SourceConnectionName"/>); with no explicit
    /// entry we auto-match by name WITHIN the mapped target site. <c>CreateNew</c>
    /// inserts a connection under the mapped target site, restoring
    /// <c>PrimaryConfiguration</c> / <c>BackupConfiguration</c> from the DTO's
    /// <see cref="SecretsBlock"/>. <c>MapToExisting</c> honours the connection's
    /// <see cref="ImportResolution"/> (Overwrite applies the bundle fields; Skip
    /// leaves the target row untouched).
    /// </para>
    /// </summary>
    private async Task<ResolvedConnectionMaps> ApplyDataConnectionsAsync(
        BundleContentDto content,
        BundleNameMap nameMap,
        Dictionary<string, Site> siteBySourceIdentifier,
        Dictionary<(string, string), ImportResolution> resolutionMap,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        var result = new Dictionary<(string, string), int>();
        var targetNameByRef = new Dictionary<(string Site, string Name), string>();
        var connMappingByRef = nameMap.Connections
            .GroupBy(m => (m.SourceSiteIdentifier, m.SourceConnectionName))
            .ToDictionary(g => g.Key, g => g.First());

        // Memoise the target site's existing connections (one query per site).
        var targetConnsBySiteId = new Dictionary<int, IReadOnlyList<DataConnection>>();
        async Task<IReadOnlyList<DataConnection>> TargetConnsAsync(int siteId)
        {
            if (targetConnsBySiteId.TryGetValue(siteId, out var cached)) return cached;
            var conns = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, ct).ConfigureAwait(false);
            targetConnsBySiteId[siteId] = conns;
            return conns;
        }

        foreach (var dcDto in content.DataConnections)
        {
            var key = (dcDto.SiteIdentifier, dcDto.Name);
            if (result.ContainsKey(key)) continue;

            if (!siteBySourceIdentifier.TryGetValue(dcDto.SiteIdentifier, out var targetSite))
            {
                // Should never happen: ApplySitesAsync resolved every referenced
                // site (including those only referenced by a connection). Guard so a
                // missing entry fails the import instead of writing an orphan FK.
                throw new InvalidOperationException(
                    $"Data connection '{dcDto.SiteIdentifier}/{dcDto.Name}' references a site that "
                    + "could not be resolved to a target.");
            }

            connMappingByRef.TryGetValue(key, out var mapping);
            var targetConns = await TargetConnsAsync(targetSite.Id).ConfigureAwait(false);

            // The connection name to match in the target — an explicit
            // MapToExisting may redirect to a differently-named target connection.
            var targetName = mapping?.Action == MappingAction.MapToExisting
                && mapping.TargetConnectionName is { Length: > 0 } tn
                ? tn
                : dcDto.Name;
            var existing = targetConns.FirstOrDefault(c =>
                string.Equals(c.Name, targetName, StringComparison.Ordinal));

            var action = mapping?.Action
                ?? (existing is not null ? MappingAction.MapToExisting : MappingAction.CreateNew);

            if (action == MappingAction.CreateNew || existing is null)
            {
                var created = BuildDataConnection(dcDto, targetSite.Id);
                await _siteRepo.AddDataConnectionAsync(created, ct).ConfigureAwait(false);
                await _auditService.LogAsync(user, "Create", "DataConnection", "0",
                    $"{targetSite.SiteIdentifier}/{created.Name}",
                    new { created.Name, created.Protocol, SiteIdentifier = targetSite.SiteIdentifier }, ct)
                    .ConfigureAwait(false);
                summary.Added++;
                result[key] = created.Id;
                targetNameByRef[key] = created.Name;
                continue;
            }

            // MapToExisting — honour the connection's own conflict resolution.
            // The resolution is keyed by the SITE-QUALIFIED name
            // (`{SiteIdentifier}/{Name}`), matching the qualified preview-item Name —
            // so a same-named connection under a different site can't pick up the
            // wrong Skip/Overwrite.
            var resolution = ResolveOrDefault(
                resolutionMap, "DataConnection", QualifiedConnectionName(dcDto.SiteIdentifier, dcDto.Name));
            if (resolution.Action == ResolutionAction.Overwrite)
            {
                ApplyDataConnectionFields(existing, dcDto);
                await _siteRepo.UpdateDataConnectionAsync(existing, ct).ConfigureAwait(false);
                await _auditService.LogAsync(user, "Update", "DataConnection", existing.Id.ToString(),
                    $"{targetSite.SiteIdentifier}/{existing.Name}",
                    new { existing.Name, existing.Protocol }, ct).ConfigureAwait(false);
                summary.Overwritten++;
            }
            else
            {
                summary.Skipped++;
            }
            result[key] = existing.Id;
            // existing.Name is the TARGET connection name — for a same-name match
            // it equals dcDto.Name, for a redirect it's the redirected target.
            targetNameByRef[key] = existing.Name;
        }

        // ---- Pass 2 — referenced-but-not-carried connections ----
        // A binding (or native-alarm-source override) can name a connection that
        // exists in the TARGET database but was NOT carried in the bundle's
        // DataConnections (e.g. exported without it). Preview correctly does NOT
        // block it — it auto-matches against the target. But the main loop above
        // only populated the maps for connections the bundle CARRIES, so without
        // this pass IdBySourceRef would MISS for such a binding and the instance
        // pass would write DataConnectionId = 0 (an invalid FK). Mirror the
        // site path's Pass 2: for every distinct (sourceSite, connName) referenced
        // by an instance binding / native-alarm override that isn't already mapped,
        // resolve the target site (honouring a nameMap redirect to a differently-
        // named target connection) and look up the EXISTING target connection by
        // name, populating both maps with its real id + name.
        foreach (var (sourceSite, connName) in EnumerateReferencedConnectionRefs(content))
        {
            var key = (sourceSite, connName);
            if (result.ContainsKey(key)) continue; // already mapped by the carried-connection loop.

            if (!siteBySourceIdentifier.TryGetValue(sourceSite, out var targetSite))
            {
                // ApplySitesAsync resolved every referenced site; guard so a missing
                // entry fails the import rather than writing an orphan FK.
                throw new InvalidOperationException(
                    $"Connection '{sourceSite}/{connName}' references a site that could not be "
                    + "resolved to a target.");
            }

            connMappingByRef.TryGetValue(key, out var mapping);
            var targetName = mapping?.Action == MappingAction.MapToExisting
                && mapping.TargetConnectionName is { Length: > 0 } tn
                ? tn
                : connName;

            var targetConns = await TargetConnsAsync(targetSite.Id).ConfigureAwait(false);
            var existing = targetConns.FirstOrDefault(c =>
                string.Equals(c.Name, targetName, StringComparison.Ordinal));
            if (existing is null)
            {
                // Should already be a preview blocker (present in neither bundle nor
                // target). Fail with a clear message rather than letting the instance
                // pass write DataConnectionId = 0.
                throw new InvalidOperationException(
                    $"Connection '{sourceSite}/{connName}' is referenced by the bundle but is present "
                    + "in neither the bundle nor the target — cannot resolve a target connection.");
            }

            result[key] = existing.Id;
            targetNameByRef[key] = existing.Name;
        }

        return new ResolvedConnectionMaps(result, targetNameByRef);
    }

    /// <summary>
    /// Every distinct <c>(sourceSiteIdentifier, connectionName)</c> pair an instance
    /// references — via a <see cref="InstanceConnectionBindingDto.ConnectionName"/> or a
    /// non-null <see cref="InstanceNativeAlarmSourceOverrideDto.ConnectionNameOverride"/>.
    /// Drives the connection-map Pass 2 that resolves references the bundle didn't carry.
    /// </summary>
    private static IEnumerable<(string Site, string Name)> EnumerateReferencedConnectionRefs(BundleContentDto content)
    {
        var seen = new HashSet<(string, string)>();
        foreach (var inst in content.Instances)
        {
            foreach (var b in inst.ConnectionBindings)
            {
                if (!string.IsNullOrEmpty(b.ConnectionName)
                    && seen.Add((inst.SiteIdentifier, b.ConnectionName)))
                {
                    yield return (inst.SiteIdentifier, b.ConnectionName);
                }
            }
            foreach (var n in inst.NativeAlarmSourceOverrides)
            {
                if (!string.IsNullOrEmpty(n.ConnectionNameOverride)
                    && seen.Add((inst.SiteIdentifier, n.ConnectionNameOverride)))
                {
                    yield return (inst.SiteIdentifier, n.ConnectionNameOverride);
                }
            }
        }
    }

    private static DataConnection BuildDataConnection(DataConnectionDto dto, int siteId) =>
        new(dto.Name, dto.Protocol, siteId)
        {
            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,
        };

    private static void ApplyDataConnectionFields(DataConnection target, DataConnectionDto dto)
    {
        target.Protocol = dto.Protocol;
        target.FailoverRetryCount = dto.FailoverRetryCount;
        target.PrimaryConfiguration =
            dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null;
        target.BackupConfiguration =
            dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null;
    }

    /// <summary>
    /// Upsert every <see cref="InstanceDto"/> in the bundle, rewiring its
    /// cross-environment name references to the target's surrogate keys:
    /// <list type="bullet">
    /// <item><c>TemplateName</c> → target template id (just-imported set first,
    /// then pre-existing target); an unresolved template was a preview blocker —
    /// guarded here so a stale payload fails the import rather than writing an
    /// orphan FK.</item>
    /// <item><c>SiteIdentifier</c> → target site id (from the site map).</item>
    /// <item><c>AreaName</c> → an area under the target site, created if missing.</item>
    /// <item>each <c>ConnectionBinding.ConnectionName</c> → target
    /// <c>DataConnectionId</c> (from the connection map).</item>
    /// <item>each <c>NativeAlarmSourceOverride.ConnectionNameOverride</c> →
    /// rewritten to the MAPPED target connection name.</item>
    /// </list>
    /// Imported instances are always written with <see cref="InstanceState.NotDeployed"/>
    /// — an imported instance is design-time configuration, never carried as
    /// live/deployed across environments. Identity is the <c>UniqueName</c>
    /// (hydrated via <see cref="ITemplateEngineRepository.GetInstanceByUniqueNameAsync"/>,
    /// which eager-loads all four child collections). The instance's
    /// <see cref="ImportResolution"/> drives Add / Overwrite / Skip / Rename; on
    /// Overwrite the existing child rows are deleted-then-readded so the bundle is
    /// the source of truth (mirrors the template-overwrite child-sync pattern).
    /// </summary>
    private async Task ApplyInstancesAsync(
        BundleContentDto content,
        Dictionary<(string, string), ImportResolution> resolutionMap,
        Dictionary<string, Site> siteBySourceIdentifier,
        ResolvedConnectionMaps connectionMaps,
        string user,
        ImportSummary summary,
        CancellationToken ct)
    {
        if (content.Instances.Count == 0) return;

        // Build a target-template name→id map once (just-imported templates were
        // flushed before this pass; pre-existing target templates count too).
        var templateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
            .GroupBy(t => t.Name, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);

        // Memoise area resolution per (siteId, areaName) so two instances under the
        // same area don't each create a duplicate row.
        var areaIdByKey = new Dictionary<(int SiteId, string Name), int>();

        foreach (var dto in content.Instances)
        {
            var resolution = ResolveOrDefault(resolutionMap, "Instance", dto.UniqueName);
            if (resolution.Action == ResolutionAction.Skip)
            {
                summary.Skipped++;
                continue;
            }

            // Resolve the template id by name (post-rename: an imported template
            // resolved as Rename was written under RenameTo, which is what the
            // instance's TemplateName must already match in a self-consistent
            // bundle; v1 does not rewrite instance TemplateName references).
            if (!templateIdByName.TryGetValue(dto.TemplateName, out var templateId))
            {
                throw new InvalidOperationException(
                    $"Instance '{dto.UniqueName}' references template '{dto.TemplateName}' which is "
                    + "present in neither the bundle nor the target.");
            }

            if (!siteBySourceIdentifier.TryGetValue(dto.SiteIdentifier, out var targetSite))
            {
                throw new InvalidOperationException(
                    $"Instance '{dto.UniqueName}' references site '{dto.SiteIdentifier}' which could "
                    + "not be resolved to a target.");
            }

            int? areaId = await ResolveOrCreateAreaIdAsync(
                dto.AreaName, targetSite.Id, areaIdByKey, ct).ConfigureAwait(false);

            switch (resolution.Action)
            {
                case ResolutionAction.Rename:
                {
                    var name = resolution.RenameTo ?? dto.UniqueName;
                    var inst = BuildInstance(
                        dto, name, templateId, targetSite, areaId, connectionMaps);
                    await _templateRepo.AddInstanceAsync(inst, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "Instance", "0", name,
                        new { UniqueName = name, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier, RenamedFrom = dto.UniqueName },
                        ct).ConfigureAwait(false);
                    summary.Renamed++;
                    break;
                }
                case ResolutionAction.Overwrite:
                {
                    var existing = await _templateRepo
                        .GetInstanceByUniqueNameAsync(dto.UniqueName, ct).ConfigureAwait(false);
                    if (existing is null)
                    {
                        // Overwrite chosen but no target row — treat as Add (the
                        // preview's "existing" read may have raced a delete). Write
                        // a fresh instance under the bundle's UniqueName.
                        var added = BuildInstance(
                            dto, dto.UniqueName, templateId, targetSite, areaId, connectionMaps);
                        await _templateRepo.AddInstanceAsync(added, ct).ConfigureAwait(false);
                        await _auditService.LogAsync(user, "Create", "Instance", "0", added.UniqueName,
                            new { added.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
                            ct).ConfigureAwait(false);
                        summary.Added++;
                        break;
                    }

                    // Rewire scalar FKs + state, then replace child rows.
                    existing.TemplateId = templateId;
                    existing.SiteId = targetSite.Id;
                    existing.AreaId = areaId;
                    existing.State = InstanceState.NotDeployed;
                    await ReplaceInstanceChildrenAsync(
                        existing, dto, dto.SiteIdentifier, connectionMaps, ct)
                        .ConfigureAwait(false);
                    await _templateRepo.UpdateInstanceAsync(existing, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Update", "Instance", existing.Id.ToString(), existing.UniqueName,
                        new { existing.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
                        ct).ConfigureAwait(false);
                    summary.Overwritten++;
                    break;
                }
                case ResolutionAction.Add:
                default:
                {
                    var inst = BuildInstance(
                        dto, dto.UniqueName, templateId, targetSite, areaId, connectionMaps);
                    await _templateRepo.AddInstanceAsync(inst, ct).ConfigureAwait(false);
                    await _auditService.LogAsync(user, "Create", "Instance", "0", inst.UniqueName,
                        new { inst.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
                        ct).ConfigureAwait(false);
                    summary.Added++;
                    break;
                }
            }
        }
    }

    /// <summary>
    /// Resolves an instance's area by name within the target site, creating the
    /// area if it doesn't exist. Returns null when <paramref name="areaName"/> is
    /// null/empty (the instance has no area). Memoised via
    /// <paramref name="areaIdByKey"/> so repeated references resolve to one row.
    /// </summary>
    private async Task<int?> ResolveOrCreateAreaIdAsync(
        string? areaName,
        int siteId,
        Dictionary<(int SiteId, string Name), int> areaIdByKey,
        CancellationToken ct)
    {
        if (string.IsNullOrEmpty(areaName)) return null;

        var key = (siteId, areaName);
        if (areaIdByKey.TryGetValue(key, out var cached)) return cached;

        var existingAreas = await _templateRepo.GetAreasBySiteIdAsync(siteId, ct).ConfigureAwait(false);
        var match = existingAreas.FirstOrDefault(a => string.Equals(a.Name, areaName, StringComparison.Ordinal));
        if (match is not null)
        {
            areaIdByKey[key] = match.Id;
            return match.Id;
        }

        var area = new Area(areaName) { SiteId = siteId };
        await _templateRepo.AddAreaAsync(area, ct).ConfigureAwait(false);
        // Flush so the area's surrogate id materialises before it's used as an
        // instance FK (relational provider). Rides the outer transaction.
        await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
        areaIdByKey[key] = area.Id;
        return area.Id;
    }

    /// <summary>
    /// Builds a new <see cref="Instance"/> from a DTO with all four child
    /// collections populated and every cross-environment FK rewired to the
    /// target. State is always <see cref="InstanceState.NotDeployed"/>.
    /// </summary>
    private static Instance BuildInstance(
        InstanceDto dto,
        string uniqueName,
        int templateId,
        Site targetSite,
        int? areaId,
        ResolvedConnectionMaps connectionMaps)
    {
        var inst = new Instance(uniqueName)
        {
            TemplateId = templateId,
            SiteId = targetSite.Id,
            AreaId = areaId,
            State = InstanceState.NotDeployed,
        };
        // The connection maps are keyed by the SOURCE site identifier (dto.SiteIdentifier),
        // NOT the target's — a cross-site MapToExisting redirect resolves the binding
        // through the source key, then the map already points at the target's id/name.
        PopulateInstanceChildren(inst, dto, dto.SiteIdentifier, connectionMaps);
        return inst;
    }

    /// <summary>
    /// Populates the four child collections on a tracked/new instance from the
    /// DTO. Connection bindings rewire <c>ConnectionName</c> → the target
    /// <c>DataConnectionId</c>; native-alarm-source overrides rewrite
    /// <c>ConnectionNameOverride</c> to the MAPPED target connection name. The
    /// connection map is keyed by (sourceSiteIdentifier, sourceConnectionName) —
    /// the instance's own <c>SiteIdentifier</c> is the source-site key.
    /// </summary>
    private static void PopulateInstanceChildren(
        Instance inst,
        InstanceDto dto,
        string sourceSiteIdentifier,
        ResolvedConnectionMaps connectionMaps)
    {
        foreach (var o in dto.AttributeOverrides)
        {
            inst.AttributeOverrides.Add(new InstanceAttributeOverride(o.AttributeName)
            {
                OverrideValue = o.OverrideValue,
                ElementDataType = o.ElementDataType,
            });
        }
        foreach (var o in dto.AlarmOverrides)
        {
            inst.AlarmOverrides.Add(new InstanceAlarmOverride(o.AlarmCanonicalName)
            {
                TriggerConfigurationOverride = o.TriggerConfigurationOverride,
                PriorityLevelOverride = o.PriorityLevelOverride,
            });
        }
        foreach (var o in dto.NativeAlarmSourceOverrides)
        {
            // Rewrite the connection-name override to the MAPPED target connection
            // name (the binding FK is an id, but this override stores a NAME). When
            // the source name maps to a differently-named target connection we must
            // carry the target name forward; otherwise keep the original (it already
            // names a target connection, e.g. an unmapped pass-through).
            inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride(o.SourceCanonicalName)
            {
                ConnectionNameOverride = RewriteConnectionName(
                    o.ConnectionNameOverride, sourceSiteIdentifier, connectionMaps),
                SourceReferenceOverride = o.SourceReferenceOverride,
                ConditionFilterOverride = o.ConditionFilterOverride,
            });
        }
        foreach (var b in dto.ConnectionBindings)
        {
            // A binding may carry NO connection name — an unbound attribute, or a
            // binding whose source connection was not included in the export. There
            // is no meaningful target FK to write (DataConnectionId = 0 would be an
            // invalid FK and FK-violate at commit on a relational provider), so skip
            // the binding row entirely rather than persist a broken binding.
            if (string.IsNullOrEmpty(b.ConnectionName))
            {
                continue;
            }
            // Resolve ConnectionName → target DataConnectionId. After Pass 2
            // in ApplyDataConnectionsAsync, the map carries an entry for every
            // referenced connection — carried in the bundle OR auto-matched in the
            // target. A non-empty name that STILL doesn't resolve is a structural
            // error (it should already have been a preview blocker + a pre-write
            // validation failure); THROW rather than write an invalid FK.
            if (!connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out var connId))
            {
                throw new InvalidOperationException(
                    $"Instance '{inst.UniqueName}' binding for attribute '{b.AttributeName}' references "
                    + $"connection '{sourceSiteIdentifier}/{b.ConnectionName}' which could not be resolved "
                    + "to a target connection (present in neither bundle nor target).");
            }
            inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
            {
                DataConnectionId = connId,
                DataSourceReferenceOverride = b.DataSourceReferenceOverride,
            });
        }
    }

    /// <summary>
    /// Replaces an existing instance's four child collections with the bundle's
    /// (Overwrite semantics — the bundle is the source of truth). Deletes the
    /// current rows via the repo, clears the tracked navigations, then re-adds
    /// from the DTO with FKs rewired. Mirrors the template-overwrite child sync.
    /// </summary>
    private async Task ReplaceInstanceChildrenAsync(
        Instance existing,
        InstanceDto dto,
        string sourceSiteIdentifier,
        ResolvedConnectionMaps connectionMaps,
        CancellationToken ct)
    {
        foreach (var o in existing.AttributeOverrides.ToList())
        {
            await _templateRepo.DeleteInstanceAttributeOverrideAsync(o.Id, ct).ConfigureAwait(false);
        }
        existing.AttributeOverrides.Clear();
        foreach (var o in existing.AlarmOverrides.ToList())
        {
            await _templateRepo.DeleteInstanceAlarmOverrideAsync(o.Id, ct).ConfigureAwait(false);
        }
        existing.AlarmOverrides.Clear();
        foreach (var o in existing.NativeAlarmSourceOverrides.ToList())
        {
            await _templateRepo.DeleteInstanceNativeAlarmSourceOverrideAsync(o.Id, ct).ConfigureAwait(false);
        }
        existing.NativeAlarmSourceOverrides.Clear();
        foreach (var b in existing.ConnectionBindings.ToList())
        {
            await _templateRepo.DeleteInstanceConnectionBindingAsync(b.Id, ct).ConfigureAwait(false);
        }
        existing.ConnectionBindings.Clear();

        PopulateInstanceChildren(existing, dto, sourceSiteIdentifier, connectionMaps);
    }

    /// <summary>
    /// Rewrites a source connection-name reference (a native-alarm-source
    /// <c>ConnectionNameOverride</c>) to the MAPPED target connection name. The
    /// lookup is <c>(sourceSite, sourceName) → target Name</c> via the
    /// target-name map <see cref="ApplyDataConnectionsAsync"/> built — so a
    /// differently-named MapToExisting redirect (sourceConn "OpcA" mapped onto
    /// target "OpcUaPrimary") carries through correctly. Returns the original
    /// name unchanged when it doesn't resolve in the map (an unmapped
    /// pass-through that already names a target connection — e.g. a connection
    /// that auto-matched an existing target but wasn't carried in the bundle's
    /// DataConnections, so it isn't in the map).
    /// </summary>
    private static string? RewriteConnectionName(
        string? sourceName,
        string sourceSiteIdentifier,
        ResolvedConnectionMaps connectionMaps)
    {
        if (string.IsNullOrEmpty(sourceName)) return sourceName;
        return connectionMaps.TargetNameBySourceRef.TryGetValue((sourceSiteIdentifier, sourceName), out var targetName)
            ? targetName
            : sourceName;
    }

    /// <summary>
    /// Validates every cross-environment reference the site/instance payload
    /// depends on, BEFORE any row is staged — so a structurally-unresolvable
    /// bundle fails with an empty change tracker (preserving the all-or-nothing
    /// rollback contract on every EF provider, including the in-memory one whose
    /// intermediate flush can't be undone by <c>ChangeTracker.Clear</c>).
    /// <para>Checks, mirroring the resolve-or-create logic the apply passes use:</para>
    /// <list type="bullet">
    /// <item>every non-Skip instance's <c>TemplateName</c> resolves to an in-bundle
    /// (non-Skip) template or a pre-existing target template;</item>
    /// <item>every referenced site resolves — carried in the bundle (auto-creatable),
    /// or mapped/auto-matched to an existing target site;</item>
    /// <item>every referenced connection resolves — carried in the bundle
    /// (auto-creatable under its mapped site), or mapped/auto-matched to an existing
    /// connection in the mapped target site.</item>
    /// </list>
    /// </summary>
    private async Task<IReadOnlyList<string>> ValidateSiteInstanceReferencesAsync(
        BundleContentDto content,
        Dictionary<(string, string), ImportResolution> resolutionMap,
        BundleNameMap nameMap,
        CancellationToken ct)
    {
        if (content.Instances.Count == 0 && content.DataConnections.Count == 0 && content.Sites.Count == 0)
        {
            return Array.Empty<string>();
        }

        var errors = new List<string>();

        var siteMappingByIdentifier = nameMap.Sites
            .GroupBy(m => m.SourceSiteIdentifier, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
        var connMappingByRef = nameMap.Connections
            .GroupBy(m => (m.SourceSiteIdentifier, m.SourceConnectionName))
            .ToDictionary(g => g.Key, g => g.First());

        // Sites carried in the bundle are always resolvable (CreateNew or
        // MapToExisting both yield a target).
        var bundleSiteIdentifiers = new HashSet<string>(
            content.Sites.Select(s => s.SiteIdentifier), StringComparer.Ordinal);

        // Resolve a source site identifier → target Site (memoised). Null = the
        // source site has no resolvable target AND isn't carried in the bundle.
        var targetSiteCache = new Dictionary<string, Site?>(StringComparer.Ordinal);
        async Task<Site?> ResolveTargetSiteAsync(string identifier)
        {
            if (targetSiteCache.TryGetValue(identifier, out var cached)) return cached;
            siteMappingByIdentifier.TryGetValue(identifier, out var mapping);
            var lookupId = mapping?.Action == MappingAction.MapToExisting
                && mapping.TargetSiteIdentifier is { Length: > 0 } t
                ? t
                : identifier;
            var site = await _siteRepo.GetSiteByIdentifierAsync(lookupId, ct).ConfigureAwait(false);
            targetSiteCache[identifier] = site;
            return site;
        }

        // ---- Site references (instances + connections + carried sites) ----
        foreach (var identifier in EnumerateReferencedSiteIdentifiers(content)
            .Concat(content.Sites.Select(s => s.SiteIdentifier))
            .Distinct(StringComparer.Ordinal))
        {
            if (bundleSiteIdentifiers.Contains(identifier)) continue; // carried → creatable
            var target = await ResolveTargetSiteAsync(identifier).ConfigureAwait(false);
            if (target is null)
            {
                errors.Add(
                    $"Site '{identifier}' is referenced by the bundle but is neither carried in the "
                    + "bundle nor resolvable to an existing target site.");
            }
        }

        // ---- Connection references (bindings + native-alarm overrides + carried) ----
        var bundleConnectionRefs = new HashSet<(string Site, string Name)>(
            content.DataConnections.Select(dc => (dc.SiteIdentifier, dc.Name)));

        var referencedConnections = new HashSet<(string Site, string Name)>(bundleConnectionRefs);
        foreach (var inst in content.Instances)
        {
            var resolution = ResolveOrDefault(resolutionMap, "Instance", inst.UniqueName);
            if (resolution.Action == ResolutionAction.Skip) continue;
            foreach (var b in inst.ConnectionBindings)
            {
                if (!string.IsNullOrEmpty(b.ConnectionName))
                    referencedConnections.Add((inst.SiteIdentifier, b.ConnectionName));
            }
            foreach (var n in inst.NativeAlarmSourceOverrides)
            {
                if (!string.IsNullOrEmpty(n.ConnectionNameOverride))
                    referencedConnections.Add((inst.SiteIdentifier, n.ConnectionNameOverride));
            }
        }

        foreach (var (site, name) in referencedConnections)
        {
            if (bundleConnectionRefs.Contains((site, name))) continue; // carried → creatable
            var targetSite = await ResolveTargetSiteAsync(site).ConfigureAwait(false);
            if (targetSite is null) continue; // already flagged above as a site error
            connMappingByRef.TryGetValue((site, name), out var mapping);
            var targetName = mapping?.Action == MappingAction.MapToExisting
                && mapping.TargetConnectionName is { Length: > 0 } tn
                ? tn
                : name;
            var conns = await _siteRepo.GetDataConnectionsBySiteIdAsync(targetSite.Id, ct).ConfigureAwait(false);
            if (!conns.Any(c => string.Equals(c.Name, targetName, StringComparison.Ordinal)))
            {
                errors.Add(
                    $"Connection '{site}/{name}' is referenced by the bundle but is neither carried in "
                    + "the bundle nor resolvable to an existing connection in the mapped target site.");
            }
        }

        // ---- Instance template references ----
        var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
        foreach (var t in content.Templates)
        {
            var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;
            bundleTemplateNames.Add(t.Name);
            if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
                bundleTemplateNames.Add(resolution.RenameTo);
        }
        var targetTemplateNames = new HashSet<string>(
            (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false)).Select(t => t.Name),
            StringComparer.Ordinal);

        foreach (var inst in content.Instances)
        {
            var resolution = ResolveOrDefault(resolutionMap, "Instance", inst.UniqueName);
            if (resolution.Action == ResolutionAction.Skip) continue;
            if (string.IsNullOrEmpty(inst.TemplateName)) continue;
            if (bundleTemplateNames.Contains(inst.TemplateName)) continue;
            if (targetTemplateNames.Contains(inst.TemplateName)) continue;
            errors.Add(
                $"Instance '{inst.UniqueName}' references template '{inst.TemplateName}' which is "
                + "present in neither the bundle nor the target.");
        }

        return errors;
    }

    /// <summary>
    /// Two-tier semantic validation run before any rows are flushed:
    /// <list type="number">
    /// <item><b>Pass 1 — minimal name-resolution scan.</b> Catches the
    /// import-specific crash surface that the full <c>SemanticValidator</c>
    /// can't see: identifier-shaped call targets in
    /// <c>TemplateScript</c> / <c>ApiMethod</c> bodies that resolve to neither
    /// an in-bundle nor a pre-existing target <c>SharedScript</c> /
    /// <c>ExternalSystem</c>. Skip-resolved DTOs are excluded from the
    /// in-bundle name set so a Skip that would have provided a dependency
    /// surfaces here. Fails fast: if Pass 1 finds errors, Pass 2 is not run.</item>
    /// <item><b>Pass 2 — full <see cref="SemanticValidator"/>.</b> For each
    /// template being imported (Add / Overwrite / Rename — not Skip), build a
    /// per-template <see cref="FlattenedConfiguration"/> directly from the DTO
    /// (single-template scope — no inheritance / composition resolution, since
    /// the inheritance chain is reconstructed only at deploy time) and invoke
    /// the same validator the deployment pipeline uses. Errors from every
    /// template are aggregated into one list so the operator sees the full
    /// surface at once. SharedScripts are passed as <see cref="ResolvedScript"/>
    /// values combining bundle + target so call-target checks resolve in either
    /// direction.</item>
    /// </list>
    /// <para>
    /// Per-template scoping is intentional: pre-existing target templates that
    /// haven't been touched by this bundle aren't run through the validator —
    /// otherwise a latent validation issue on an unrelated template (one the
    /// operator isn't trying to import) would block the import.
    /// </para>
    /// </summary>
    private async Task<(IReadOnlyList<string> Errors, IReadOnlyList<string> Warnings)> RunSemanticValidationAsync(
        BundleContentDto content,
        Dictionary<(string, string), ImportResolution> resolutionMap,
        CancellationToken ct)
    {
        var errors = new List<string>();
        var warnings = new List<string>();

        // ---- Pass 0: script trust gate ----
        // Bundle import is the fifth script-trust call site. Every executable C#
        // surface a bundle carries (template / shared / ApiMethod script bodies
        // AND template script + alarm Expression-trigger bodies) is run through
        // the shared ScriptTrustValidator BEFORE name resolution — a forbidden-API
        // verdict is authoritative (not the false-positive-prone name heuristic),
        // so it is a HARD error for all kinds, and it must not be masked by a
        // name-resolution error. The verdict cache keeps repeat cost nil.
        foreach (var (kind, entityName, scriptLabel, code) in EnumerateTrustGatedScripts(content, resolutionMap))
        {
            IReadOnlyList<string> violations;
            try
            {
                violations = ScriptTrustValidator.FindViolations(code);
            }
            catch (Exception ex)
            {
                // Fail closed: a validator throw on one pathological body must not
                // abort the whole import — treat it as a hard blocker FOR THAT
                // script so the offender is named and the rest still surface.
                _logger?.LogWarning(ex,
                    "Script trust analysis threw for {Kind} '{EntityName}' script '{ScriptLabel}'; treating as a hard blocker.",
                    kind, entityName, scriptLabel);
                errors.Add($"{kind} '{entityName}' script '{scriptLabel}': trust analysis failed to complete — rejected.");
                continue;
            }
            foreach (var violation in violations)
            {
                errors.Add($"{kind} '{entityName}' script '{scriptLabel}': {violation}");
            }
        }
        // Fail fast on trust violations: the operator sees trust errors OR name-
        // resolution errors, not both at once. `warnings` is always empty here
        // (Pass 0 emits only errors), so nothing surfacable is dropped.
        if (errors.Count > 0) return (errors, warnings);

        // N4: advisory warnings for instance overrides that target a LOCKED template
        // member. Best-effort, non-blocking (rides ImportResult.Warnings); the row is
        // still written (bundle fidelity) — the flattener is the behavioural authority.
        var lockScanTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
        foreach (var (_, message) in CollectLockedOverrideWarnings(content, resolutionMap, lockScanTemplates))
        {
            warnings.Add(message);
        }

        // ---- Pass 1: minimal name-resolution scan ----

        // Build the known-resolvable set. For in-bundle entries, EXCLUDE the
        // Skip-resolved names — those aren't being written, so they can't
        // satisfy a downstream reference. Renamed entries register under both
        // their original DTO name (so the script body in the bundle still
        // resolves) AND the new name; the v1 import doesn't rewrite call sites.
        var sharedScriptNames = new HashSet<string>(StringComparer.Ordinal);
        foreach (var s in content.SharedScripts)
        {
            var resolution = ResolveOrDefault(resolutionMap, "SharedScript", s.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;
            sharedScriptNames.Add(s.Name);
            if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
            {
                sharedScriptNames.Add(resolution.RenameTo);
            }
        }
        var externalSystemNames = new HashSet<string>(StringComparer.Ordinal);
        foreach (var e in content.ExternalSystems)
        {
            var resolution = ResolveOrDefault(resolutionMap, "ExternalSystem", e.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;
            externalSystemNames.Add(e.Name);
            if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
            {
                externalSystemNames.Add(resolution.RenameTo);
            }
        }

        // Pre-existing target entries always count as resolvable.
        var preExistingSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
        foreach (var s in preExistingSharedScripts)
        {
            sharedScriptNames.Add(s.Name);
        }
        foreach (var e in await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false))
        {
            externalSystemNames.Add(e.Name);
        }

        // Collect every identifier-shaped call target from the bundle's
        // templates + api methods, keyed by ORIGIN (severity split).
        // We only check the bundle's bodies here (matching PreviewAsync's blocker
        // scan); pre-existing target rows are assumed already validated when they
        // were originally written. Local-function / method declarations in a body
        // are collected too so a script's own helper isn't flagged as missing.
        var referencedFromTemplates = new HashSet<string>(StringComparer.Ordinal);
        var referencedFromApiMethods = new HashSet<string>(StringComparer.Ordinal);
        var locallyDeclaredInTemplates = new HashSet<string>(StringComparer.Ordinal);
        var locallyDeclaredInApiMethods = new HashSet<string>(StringComparer.Ordinal);
        foreach (var t in content.Templates)
        {
            // Skip-resolved templates aren't being written, so their script
            // references don't need to resolve.
            var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;
            foreach (var s in t.Scripts)
            {
                CollectCallIdentifiers(s.Code, referencedFromTemplates);
                CollectLocalDeclarations(s.Code, locallyDeclaredInTemplates);
            }
            foreach (var a in t.Attributes)
            {
                // Attribute.Value is a typed-literal default (never compiled), so it
                // is not a trust surface; scanned only for the advisory name heuristic.
                // DataSourceReference is an OPC UA address-space path (e.g.
                // "ns=3;s=Tank.Level") and must NOT be scanned, or the dot
                // delimiter will flag tag-path segments as missing references.
                // Symmetric with DetectBlockersAsync.
                CollectCallIdentifiers(a.Value, referencedFromTemplates);
                CollectLocalDeclarations(a.Value, locallyDeclaredInTemplates);
            }
        }
        foreach (var m in content.ApiMethods)
        {
            var resolution = ResolveOrDefault(resolutionMap, "ApiMethod", m.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;
            CollectCallIdentifiers(m.Script, referencedFromApiMethods);
            CollectLocalDeclarations(m.Script, locallyDeclaredInApiMethods);
        }

        // ApiMethod references genuinely unresolved (referenced there and NOT locally
        // declared there) are hard errors (no downstream deploy gate); template-only
        // unresolved references are advisory warnings (the deploy gate re-validates
        // with a real compile). Local-declaration exclusion is per-origin so a
        // template helper cannot mask a missing ApiMethod reference.
        var allCandidates = new HashSet<string>(referencedFromTemplates, StringComparer.Ordinal);
        allCandidates.UnionWith(referencedFromApiMethods);
        foreach (var candidate in allCandidates.OrderBy(n => n, StringComparer.Ordinal))
        {
            if (!LooksLikeResourceName(candidate)) continue;
            if (KnownNonReferenceNames.Contains(candidate)) continue;
            if (sharedScriptNames.Contains(candidate) || externalSystemNames.Contains(candidate)) continue;

            var apiUnresolved = referencedFromApiMethods.Contains(candidate)
                && !locallyDeclaredInApiMethods.Contains(candidate);
            var templateUnresolved = referencedFromTemplates.Contains(candidate)
                && !locallyDeclaredInTemplates.Contains(candidate);
            if (apiUnresolved)
            {
                errors.Add(
                    $"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
            }
            else if (templateUnresolved)
            {
                warnings.Add(
                    $"Template script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target — advisory; re-validated at deploy time.");
            }
        }

        // Fail fast — running the full validator over templates that already
        // failed name resolution would produce duplicate / lower-quality errors
        // (the missing identifier shows up there as "callee not found" too).
        // Warnings ride along regardless — they never gate the import.
        if (errors.Count > 0) return (errors, warnings);

        // ---- Pass 2: full SemanticValidator over imported templates ----

        // Build the shared-script catalog the validator uses to resolve
        // CallShared targets. Combine in-bundle (non-Skip) + pre-existing
        // target — same resolution model as Pass 1's name set.
        var sharedScripts = new List<ResolvedScript>();
        foreach (var s in content.SharedScripts)
        {
            var resolution = ResolveOrDefault(resolutionMap, "SharedScript", s.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;
            var name = resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
                ? resolution.RenameTo
                : s.Name;
            sharedScripts.Add(new ResolvedScript
            {
                CanonicalName = name,
                Code = s.Code,
                ParameterDefinitions = s.ParameterDefinitions,
                ReturnDefinition = s.ReturnDefinition,
            });
        }
        foreach (var s in preExistingSharedScripts)
        {
            // Pre-existing target wins on duplicate name only when the bundle
            // didn't supply it; otherwise the bundle's version (the one about
            // to be written) is the right signature surface to validate against.
            if (sharedScripts.Any(rs => string.Equals(rs.CanonicalName, s.Name, StringComparison.Ordinal))) continue;
            sharedScripts.Add(new ResolvedScript
            {
                CanonicalName = s.Name,
                Code = s.Code,
                ParameterDefinitions = s.ParameterDefinitions,
                ReturnDefinition = s.ReturnDefinition,
            });
        }

        foreach (var dto in content.Templates)
        {
            var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
            if (resolution.Action == ResolutionAction.Skip) continue;

            var importedName = resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
                ? resolution.RenameTo
                : dto.Name;

            var config = BuildFlattenedConfigForValidation(dto, importedName);
            var result = _semanticValidator.Validate(config, sharedScripts);
            foreach (var entry in result.Errors)
            {
                errors.Add($"Template '{importedName}': {entry.Message}");
            }
        }

        return (errors, warnings);
    }

    /// <summary>
    /// Builds a <see cref="FlattenedConfiguration"/> for a single template DTO
    /// — the validator's input contract. The bundle DTO carries only the
    /// template's own attributes / alarms / scripts (no inheritance / no
    /// composition resolution at design time), so the flattening here is a
    /// straight 1:1 copy with the alarm on-trigger-script name carried through
    /// as the canonical name (the same script's bare name, since composed
    /// modules aren't expanded at import time). This is intentionally narrower
    /// than the production <c>FlatteningService</c> pipeline, which needs a
    /// concrete <c>Instance</c> plus site / connection context that doesn't
    /// exist yet at design time. The deployment-time flatten will revalidate
    /// against the full graph; this pass catches the same-template-scope
    /// errors that operators would otherwise only hit at deploy time.
    /// </summary>
    private static FlattenedConfiguration BuildFlattenedConfigForValidation(TemplateDto dto, string templateName)
    {
        var attributes = new List<ResolvedAttribute>(dto.Attributes.Count);
        foreach (var a in dto.Attributes)
        {
            attributes.Add(new ResolvedAttribute
            {
                CanonicalName = a.Name,
                Value = a.Value,
                DataType = a.DataType.ToString(),
                ElementDataType = a.ElementDataType?.ToString(),
                IsLocked = a.IsLocked,
                Description = a.Description,
                DataSourceReference = a.DataSourceReference,
                Source = "Template",
            });
        }

        var alarms = new List<ResolvedAlarm>(dto.Alarms.Count);
        foreach (var al in dto.Alarms)
        {
            alarms.Add(new ResolvedAlarm
            {
                CanonicalName = al.Name,
                Description = al.Description,
                PriorityLevel = al.PriorityLevel,
                IsLocked = al.IsLocked,
                TriggerType = al.TriggerType.ToString(),
                TriggerConfiguration = al.TriggerConfiguration,
                // The bundle carries the on-trigger script by NAME (not id);
                // at this single-template-scope validation step the bare name
                // IS the canonical name, so just pass it through.
                OnTriggerScriptCanonicalName = string.IsNullOrEmpty(al.OnTriggerScriptName) ? null : al.OnTriggerScriptName,
                Source = "Template",
            });
        }

        var scripts = new List<ResolvedScript>(dto.Scripts.Count);
        foreach (var s in dto.Scripts)
        {
            scripts.Add(new ResolvedScript
            {
                CanonicalName = s.Name,
                Code = s.Code,
                IsLocked = s.IsLocked,
                TriggerType = s.TriggerType,
                TriggerConfiguration = s.TriggerConfiguration,
                ParameterDefinitions = s.ParameterDefinitions,
                ReturnDefinition = s.ReturnDefinition,
                MinTimeBetweenRuns = s.MinTimeBetweenRuns,
                ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
                Source = "Template",
            });
        }

        return new FlattenedConfiguration
        {
            InstanceUniqueName = templateName,
            TemplateId = 0,
            SiteId = 0,
            AreaId = null,
            Attributes = attributes,
            Alarms = alarms,
            Scripts = scripts,
            Connections = null,
            GeneratedAtUtc = DateTimeOffset.UtcNow,
        };
    }
}
