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

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

/// <summary>
/// Compares an incoming bundle DTO against the existing entity in the target
/// database and produces a single <see cref="ImportPreviewItem"/> that
/// classifies the conflict and (for <c>Modified</c>) carries a coarse field
/// diff in JSON.
/// <para>
/// "Coarse" means: each persistent field is compared as a value; differing
/// non-code fields appear in <c>changes</c> with old/new values. Code / large-
/// text fields (script bodies, API-method scripts) instead carry a structured
/// per-line Myers diff: the <see cref="FieldChange.OldValue"/> /
/// <see cref="FieldChange.NewValue"/> keep a compact <c>&lt;N lines&gt;</c>
/// summary for fallback rendering, and a <see cref="FieldChange.LineDiff"/>
/// payload carries the hunk lines (+/-/context) plus add/remove totals and a
/// truncation flag. The diff is size-capped via <see cref="LineDiffer"/>'s
/// <c>maxLines</c> cap so <see cref="ImportPreviewItem.FieldDiffJson"/> stays
/// bounded.
/// </para>
/// <para>
/// Entity versions are not yet tracked on the POCOs, so the
/// <see cref="ImportPreviewItem.ExistingVersion"/> / <see cref="ImportPreviewItem.IncomingVersion"/>
/// fields are always <c>null</c> here. They are reserved for a future
/// optimistic-concurrency feature.
/// </para>
/// </summary>
public sealed class ArtifactDiff
{
    private static readonly JsonSerializerOptions DiffJsonOptions = new()
    {
        WriteIndented = false,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };

    // ---- Templates ----
    /// <summary>
    /// Compares an incoming template against the existing template in the database.
    /// </summary>
    /// <param name="incoming">The incoming template from the bundle.</param>
    /// <param name="existing">The existing template in the database, or null if new.</param>
    /// <param name="folderNameById">Optional folder-id→name map so an existing template's
    /// <c>FolderId</c> resolves to the same name the bundle carries; without it, folder
    /// identity falls back to a <c>&lt;id:N&gt;</c> placeholder that never matches a bundle
    /// name (spurious Modified on every re-import).</param>
    /// <param name="templateNameById">Optional template-id→name map resolving the existing
    /// template's <c>ParentTemplateId</c> (base) and each composition's <c>ComposedTemplateId</c>
    /// to names, with the same placeholder fallback semantics.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareTemplate(
        TemplateDto incoming,
        Template? existing,
        IReadOnlyDictionary<int, string>? folderNameById = null,
        IReadOnlyDictionary<int, string>? templateNameById = null)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null)
        {
            return New("Template", incoming.Name);
        }

        var changes = new List<FieldChange>();
        AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
        AddIfDifferent(changes, "FolderName", FolderNameOf(existing, folderNameById), incoming.FolderName);
        AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing, templateNameById), incoming.BaseTemplateName);

        // Bundles reference the alarm on-trigger script by name; resolve the
        // persisted FK back to a name over this template's own scripts so the
        // shared equality can compare it. See TemplateChildEquality.
        var scriptNameById = TemplateChildEquality.ScriptNameResolver(existing.Scripts);

        // Children: compare each child collection by name. We track which names
        // were added, which were removed, and which existed on both sides but
        // diverged in body. We use coarse value equality / line counts for
        // scripts so the diff JSON stays under a few KB per item. Change
        // detection is single-sourced through TemplateChildEquality so the diff
        // can never disagree with what an Overwrite sync writes.
        DiffChildren(
            existing.Attributes,
            incoming.Attributes,
            e => e.Name,
            i => i.Name,
            TemplateChildEquality.AttributesEqual,
            "Attributes",
            changes);

        DiffChildren(
            existing.Alarms,
            incoming.Alarms,
            e => e.Name,
            i => i.Name,
            (e, i) => TemplateChildEquality.AlarmsEqual(e, i, scriptNameById),
            "Alarms",
            changes);

        DiffScriptChildren(existing.Scripts, incoming.Scripts, changes);

        // Compositions diff by InstanceName since ComposedTemplateId vs
        // ComposedTemplateName aren't directly comparable. The bundle side
        // already serializes to the name form, so the comparison reduces to
        // (InstanceName, ComposedTemplateName) tuple equality.
        DiffChildren(
            existing.Compositions,
            incoming.Compositions,
            e => e.InstanceName,
            i => i.InstanceName,
            (e, i) => CompositionTargetNameOf(e, templateNameById) == i.ComposedTemplateName,
            "Compositions",
            changes);

        DiffChildren(
            existing.NativeAlarmSources,
            incoming.NativeAlarmSources,
            e => e.Name,
            i => i.Name,
            TemplateChildEquality.NativeAlarmSourcesEqual,
            "NativeAlarmSources",
            changes);

        return BuildItem("Template", incoming.Name, changes);
    }

    /// <summary>
    /// Compares an incoming shared script against the existing shared script in the database.
    /// </summary>
    /// <param name="incoming">The incoming shared script from the bundle.</param>
    /// <param name="existing">The existing shared script in the database, or null if new.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareSharedScript(SharedScriptDto incoming, SharedScript? existing)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("SharedScript", incoming.Name);

        var changes = new List<FieldChange>();
        AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
        AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
        AddCodeChangeIfDifferent(changes, "Code", existing.Code, incoming.Code);

        return BuildItem("SharedScript", incoming.Name, changes);
    }

    /// <summary>
    /// Compares an incoming external system against the existing external system in the database.
    /// </summary>
    /// <param name="incoming">The incoming external system from the bundle.</param>
    /// <param name="existing">The existing external system in the database, or null if new.</param>
    /// <param name="existingMethods">The existing external system methods, or null if new.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareExternalSystem(ExternalSystemDto incoming, ExternalSystemDefinition? existing, IReadOnlyList<ExternalSystemMethod>? existingMethods)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("ExternalSystem", incoming.Name);

        var changes = new List<FieldChange>();
        AddIfDifferent(changes, "BaseUrl", existing.EndpointUrl, incoming.BaseUrl);
        AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType);

        // Secrets: presence-only comparison (we never echo the value in the diff).
        var existingHasSecret = !string.IsNullOrEmpty(existing.AuthConfiguration);
        var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthConfiguration");
        if (existingHasSecret != incomingHasSecret)
        {
            changes.Add(new FieldChange("Secrets.AuthConfiguration",
                existingHasSecret ? "<present>" : null,
                incomingHasSecret ? "<present>" : null));
        }

        // Methods are name-keyed children.
        var existingForCompare = existingMethods ?? Array.Empty<ExternalSystemMethod>();
        DiffChildren(
            existingForCompare,
            incoming.Methods,
            e => e.Name,
            i => i.Name,
            ExternalSystemMethodsEqual,
            "Methods",
            changes);

        return BuildItem("ExternalSystem", incoming.Name, changes);
    }

    /// <summary>
    /// Compares an incoming database connection against the existing database connection in the database.
    /// </summary>
    /// <param name="incoming">The incoming database connection from the bundle.</param>
    /// <param name="existing">The existing database connection in the database, or null if new.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareDatabaseConnection(DatabaseConnectionDto incoming, DatabaseConnectionDefinition? existing)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("DatabaseConnection", incoming.Name);

        var changes = new List<FieldChange>();
        AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
        AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());

        // ConnectionString lives in Secrets only — presence-only comparison.
        var existingHasSecret = !string.IsNullOrEmpty(existing.ConnectionString);
        var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("ConnectionString");
        if (existingHasSecret != incomingHasSecret)
        {
            changes.Add(new FieldChange("Secrets.ConnectionString",
                existingHasSecret ? "<present>" : null,
                incomingHasSecret ? "<present>" : null));
        }

        return BuildItem("DatabaseConnection", incoming.Name, changes);
    }

    /// <summary>
    /// Compares an incoming notification list against the existing notification list in the database.
    /// </summary>
    /// <param name="incoming">The incoming notification list from the bundle.</param>
    /// <param name="existing">The existing notification list in the database, or null if new.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareNotificationList(NotificationListDto incoming, NotificationList? existing)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("NotificationList", incoming.Name);

        var changes = new List<FieldChange>();
        AddIfDifferent(changes, "Type", existing.Type.ToString(), incoming.Type.ToString());

        DiffChildren(
            existing.Recipients,
            incoming.Recipients,
            e => e.Name,
            i => i.Name,
            // A recipient is unchanged only when BOTH contacts match — a phone
            // number added/changed on an existing recipient is a real diff.
            (e, i) => e.EmailAddress == i.EmailAddress && e.PhoneNumber == i.PhoneNumber,
            "Recipients",
            changes);

        return BuildItem("NotificationList", incoming.Name, changes);
    }

    /// <summary>
    /// Compares an incoming SMTP configuration against the existing SMTP configuration in the database.
    /// </summary>
    /// <param name="incoming">The incoming SMTP configuration from the bundle.</param>
    /// <param name="existing">The existing SMTP configuration in the database, or null if new.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareSmtpConfiguration(SmtpConfigDto incoming, SmtpConfiguration? existing)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("SmtpConfiguration", incoming.Host);

        var changes = new List<FieldChange>();
        AddIfDifferent(changes, "Port", existing.Port, incoming.Port);
        AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType);
        AddIfDifferent(changes, "FromAddress", existing.FromAddress, incoming.FromAddress);
        AddIfDifferent(changes, "TlsMode", existing.TlsMode, incoming.TlsMode);
        AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds);
        AddIfDifferent(changes, "MaxConcurrentConnections", existing.MaxConcurrentConnections, incoming.MaxConcurrentConnections);
        AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
        AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
        // Non-sensitive delivery metadata: diffed by value (unlike the credential,
        // which is presence-only below). Both sides null on pre-EWS bundles, so a
        // legacy configuration still classifies Identical.
        AddIfDifferent(changes, "OAuth2Authority", existing.OAuth2Authority, incoming.OAuth2Authority);
        AddIfDifferent(changes, "OAuth2Scope", existing.OAuth2Scope, incoming.OAuth2Scope);
        AddIfDifferent(changes, "Transport", existing.Transport, incoming.Transport);

        var existingHasSecret = !string.IsNullOrEmpty(existing.Credentials);
        var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("Credentials");
        if (existingHasSecret != incomingHasSecret)
        {
            changes.Add(new FieldChange("Secrets.Credentials",
                existingHasSecret ? "<present>" : null,
                incomingHasSecret ? "<present>" : null));
        }

        return BuildItem("SmtpConfiguration", incoming.Host, changes);
    }

    /// <summary>
    /// Compares an incoming SMS provider configuration against the existing one in
    /// the database. Mirrors <see cref="CompareSmtpConfiguration"/>: keyed by
    /// the natural key <c>AccountSid</c>, with the auth token diffed presence-only
    /// (it lives in <see cref="SecretsBlock"/>, never compared by value).
    /// </summary>
    /// <param name="incoming">The incoming SMS configuration from the bundle.</param>
    /// <param name="existing">The existing SMS configuration in the database, or null if new.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareSmsConfiguration(SmsConfigDto incoming, SmsConfiguration? existing)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("SmsConfiguration", incoming.AccountSid);

        var changes = new List<FieldChange>();
        AddIfDifferent(changes, "FromNumber", existing.FromNumber, incoming.FromNumber);
        AddIfDifferent(changes, "MessagingServiceSid", existing.MessagingServiceSid, incoming.MessagingServiceSid);
        AddIfDifferent(changes, "ApiBaseUrl", existing.ApiBaseUrl, incoming.ApiBaseUrl);
        AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds);
        AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
        AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());

        var existingHasSecret = !string.IsNullOrEmpty(existing.AuthToken);
        var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthToken");
        if (existingHasSecret != incomingHasSecret)
        {
            changes.Add(new FieldChange("Secrets.AuthToken",
                existingHasSecret ? "<present>" : null,
                incomingHasSecret ? "<present>" : null));
        }

        return BuildItem("SmsConfiguration", incoming.AccountSid, changes);
    }

    // CompareApiKey was removed: inbound API keys are not transported
    // between environments, so the import preview never diffs keys.

    /// <summary>
    /// Compares an incoming API method against the existing API method in the database.
    /// </summary>
    /// <param name="incoming">The incoming API method from the bundle.</param>
    /// <param name="existing">The existing API method in the database, or null if new.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareApiMethod(ApiMethodDto incoming, ApiMethod? existing)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("ApiMethod", incoming.Name);

        var changes = new List<FieldChange>();
        // ApprovedApiKeyIds is not transported and is excluded from the diff.
        AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
        AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
        AddIfDifferent(changes, "TimeoutSeconds", existing.TimeoutSeconds, incoming.TimeoutSeconds);
        AddCodeChangeIfDifferent(changes, "Script", existing.Script, incoming.Script);

        return BuildItem("ApiMethod", incoming.Name, changes);
    }

    /// <summary>
    /// Compares an incoming template folder against the existing template folder in the database.
    /// </summary>
    /// <param name="incoming">The incoming template folder from the bundle.</param>
    /// <param name="existing">The existing template folder in the database, or null if new.</param>
    /// <param name="folderNameById">A mapping of folder IDs to names for resolving parent folder references.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareTemplateFolder(TemplateFolderDto incoming, TemplateFolder? existing, IReadOnlyDictionary<int, string> folderNameById)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("TemplateFolder", incoming.Name);

        var changes = new List<FieldChange>();
        AddIfDifferent(changes, "SortOrder", existing.SortOrder, incoming.SortOrder);
        var existingParentName = existing.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var n) ? n : null;
        AddIfDifferent(changes, "ParentName", existingParentName, incoming.ParentName);
        return BuildItem("TemplateFolder", incoming.Name, changes);
    }

    // ---- Site / Connection / Instance ----

    /// <summary>
    /// Compares an incoming site against the existing site in the database.
    /// Identity is the <c>SiteIdentifier</c>; the diff is coarse over the
    /// display name, description, and the four cluster/gRPC node addresses.
    /// </summary>
    /// <param name="incoming">The incoming site from the bundle.</param>
    /// <param name="existing">The existing site in the database, or null if new.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareSite(SiteDto incoming, Site? existing)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("Site", incoming.SiteIdentifier);

        var changes = new List<FieldChange>();
        AddIfDifferent(changes, "Name", existing.Name, incoming.Name);
        AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
        AddIfDifferent(changes, "NodeAAddress", existing.NodeAAddress, incoming.NodeAAddress);
        AddIfDifferent(changes, "NodeBAddress", existing.NodeBAddress, incoming.NodeBAddress);
        AddIfDifferent(changes, "GrpcNodeAAddress", existing.GrpcNodeAAddress, incoming.GrpcNodeAAddress);
        AddIfDifferent(changes, "GrpcNodeBAddress", existing.GrpcNodeBAddress, incoming.GrpcNodeBAddress);

        return BuildItem("Site", incoming.SiteIdentifier, changes);
    }

    /// <summary>
    /// Compares an incoming site-scoped data connection (the <c>Sites.DataConnection</c>
    /// entity, NOT the External-System database connection) against the existing one.
    /// The Primary/Backup protocol configuration lives in the DTO's
    /// <see cref="SecretsBlock"/>, so it is compared <b>presence-only</b> — the diff
    /// never echoes the configuration value, mirroring the external-system / DB-connection
    /// secret handling.
    /// </summary>
    /// <param name="incoming">The incoming data connection from the bundle.</param>
    /// <param name="existing">The existing data connection in the database, or null if new.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareDataConnection(DataConnectionDto incoming, DataConnection? existing)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("DataConnection", incoming.Name);

        var changes = new List<FieldChange>();
        AddIfDifferent(changes, "Protocol", existing.Protocol, incoming.Protocol);
        AddIfDifferent(changes, "FailoverRetryCount", existing.FailoverRetryCount, incoming.FailoverRetryCount);

        // Protocol config rides in Secrets (PrimaryConfiguration / BackupConfiguration).
        // Presence-only comparison — never echo the config value.
        AddSecretPresenceChange(changes, "Secrets.PrimaryConfiguration",
            !string.IsNullOrEmpty(existing.PrimaryConfiguration),
            HasSecretKey(incoming.Secrets, "PrimaryConfiguration"));
        AddSecretPresenceChange(changes, "Secrets.BackupConfiguration",
            !string.IsNullOrEmpty(existing.BackupConfiguration),
            HasSecretKey(incoming.Secrets, "BackupConfiguration"));

        return BuildItem("DataConnection", incoming.Name, changes);
    }

    /// <summary>
    /// Compares an incoming deployable instance against the existing one. Identity
    /// is the <c>UniqueName</c>; the diff is coarse over the template/site/area/state
    /// scalar fields plus a name-keyed child diff over the four override/binding
    /// collections.
    /// <para>
    /// The caller is responsible for passing a <b>hydrated</b> <see cref="Instance"/>
    /// (its <c>AttributeOverrides</c>, <c>AlarmOverrides</c>, <c>NativeAlarmSourceOverrides</c>,
    /// and <c>ConnectionBindings</c> navigation collections eagerly loaded). A
    /// non-hydrated entity reads as having no children, which would surface every
    /// incoming child as an addition.
    /// </para>
    /// </summary>
    /// <param name="incoming">The incoming instance from the bundle.</param>
    /// <param name="existing">The existing (hydrated) instance in the database, or null if new.</param>
    /// <param name="existingTemplateName">The existing instance's template name (the entity carries only a FK), or null if unknown.</param>
    /// <param name="existingSiteIdentifier">The existing instance's site identifier (the entity carries only a FK), or null if unknown.</param>
    /// <param name="existingAreaName">The existing instance's area name (the entity carries only a FK), or null if unknown / unset.</param>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareInstance(
        InstanceDto incoming,
        Instance? existing,
        string? existingTemplateName = null,
        string? existingSiteIdentifier = null,
        string? existingAreaName = null)
    {
        ArgumentNullException.ThrowIfNull(incoming);
        if (existing is null) return New("Instance", incoming.UniqueName);

        var changes = new List<FieldChange>();
        // The entity stores template/site/area as numeric FKs that can't be compared
        // cross-environment; the caller resolves them to names. When a name wasn't
        // supplied we skip that scalar rather than emit a spurious "<id:N>" diff.
        if (existingTemplateName is not null)
        {
            AddIfDifferent(changes, "TemplateName", existingTemplateName, incoming.TemplateName);
        }
        if (existingSiteIdentifier is not null)
        {
            AddIfDifferent(changes, "SiteIdentifier", existingSiteIdentifier, incoming.SiteIdentifier);
        }
        AddIfDifferent(changes, "AreaName", existingAreaName, incoming.AreaName);
        AddIfDifferent(changes, "State", existing.State.ToString(), incoming.State.ToString());

        DiffChildren(
            existing.AttributeOverrides,
            incoming.AttributeOverrides,
            e => e.AttributeName,
            i => i.AttributeName,
            (e, i) => e.OverrideValue == i.OverrideValue && e.ElementDataType == i.ElementDataType,
            "AttributeOverrides",
            changes);

        DiffChildren(
            existing.AlarmOverrides,
            incoming.AlarmOverrides,
            e => e.AlarmCanonicalName,
            i => i.AlarmCanonicalName,
            (e, i) => e.TriggerConfigurationOverride == i.TriggerConfigurationOverride
                && e.PriorityLevelOverride == i.PriorityLevelOverride,
            "AlarmOverrides",
            changes);

        DiffChildren(
            existing.NativeAlarmSourceOverrides,
            incoming.NativeAlarmSourceOverrides,
            e => e.SourceCanonicalName,
            i => i.SourceCanonicalName,
            (e, i) => e.ConnectionNameOverride == i.ConnectionNameOverride
                && e.SourceReferenceOverride == i.SourceReferenceOverride
                && e.ConditionFilterOverride == i.ConditionFilterOverride,
            "NativeAlarmSourceOverrides",
            changes);

        DiffChildren(
            existing.ConnectionBindings,
            incoming.ConnectionBindings,
            e => e.AttributeName,
            i => i.AttributeName,
            // ConnectionName resolves to a FK on the entity (DataConnectionId) that
            // can't be compared cross-environment, so the binding diff compares only
            // the per-attribute DataSourceReference override.
            (e, i) => e.DataSourceReferenceOverride == i.DataSourceReferenceOverride,
            "ConnectionBindings",
            changes);

        return BuildItem("Instance", incoming.UniqueName, changes);
    }

    // ---- Helpers ----

    private static ImportPreviewItem New(string entityType, string name) =>
        new(entityType, name, ExistingVersion: null, IncomingVersion: null, ConflictKind.New, FieldDiffJson: null, BlockerReason: null);

    private static ImportPreviewItem BuildItem(string entityType, string name, List<FieldChange> changes)
    {
        if (changes.Count == 0)
        {
            return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null);
        }
        var diff = new FieldDiff(
            Adds: Array.Empty<string>(),
            Removes: Array.Empty<string>(),
            Changes: changes);
        return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Modified, FieldDiffJson: JsonSerializer.Serialize(diff, DiffJsonOptions), BlockerReason: null);
    }

    private static void AddIfDifferent<T>(List<FieldChange> changes, string field, T existing, T incoming)
    {
        if (!Equals(existing, incoming))
        {
            changes.Add(new FieldChange(field, existing?.ToString(), incoming?.ToString()));
        }
    }

    private static void AddCodeChangeIfDifferent(List<FieldChange> changes, string field, string? existing, string? incoming)
    {
        // Code/large-text fields: when they differ, emit a real per-line Myers diff.
        // The OldValue/NewValue keep a compact "<N lines>" summary as a
        // fallback for renderers that don't consume the structured payload; the
        // LineDiff carries the +/- hunk lines. Identical code emits no change.
        var sameNullness = existing is null == incoming is null;
        var bothPresentAndEqual = sameNullness && (existing is null || string.Equals(existing, incoming, StringComparison.Ordinal));
        if (bothPresentAndEqual) return;

        changes.Add(BuildCodeFieldChange(field, existing, incoming));
    }

    /// <summary>
    /// Builds a <see cref="FieldChange"/> for a code/large-text field carrying both
    /// the compact line-count summary and the structured per-line Myers diff. Only
    /// ever called for the named code fields (TemplateScript.Code, SharedScript.Code,
    /// ApiMethod.Script) — never for secret-bearing fields — so the verbatim line
    /// text emitted here is never sensitive.
    /// </summary>
    private static FieldChange BuildCodeFieldChange(string field, string? existing, string? incoming)
    {
        var oldLines = existing?.Split('\n').Length ?? 0;
        var newLines = incoming?.Split('\n').Length ?? 0;
        // LineDiffer caps at maxLines (default 400); the payload's Truncated flag
        // tells the UI when the hunk list was clipped so the JSON stays bounded.
        var diff = LineDiffer.Diff(existing, incoming);
        return new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>", ToPayload(diff));
    }

    private static LineDiffPayload ToPayload(LineDiffResult diff)
    {
        var hunks = new List<LineDiffHunk>(diff.Lines.Count);
        foreach (var line in diff.Lines)
        {
            hunks.Add(new LineDiffHunk(OpName(line.Op), line.Text, line.OldLineNo, line.NewLineNo));
        }
        return new LineDiffPayload(hunks, diff.Truncated, diff.AddedCount, diff.RemovedCount);
    }

    private static string OpName(LineDiffOp op) => op switch
    {
        LineDiffOp.Add => "add",
        LineDiffOp.Remove => "remove",
        _ => "context",
    };

    /// <summary>True if <paramref name="secrets"/> carries the named key.</summary>
    private static bool HasSecretKey(SecretsBlock? secrets, string key) =>
        secrets is not null && secrets.Values.ContainsKey(key);

    /// <summary>
    /// Records a presence-only secret change (a flip in whether a value is set on
    /// each side) using the <c>&lt;present&gt;</c> / null marker convention. The
    /// secret value itself is NEVER read into the diff — only its presence.
    /// </summary>
    private static void AddSecretPresenceChange(List<FieldChange> changes, string field, bool existingHasSecret, bool incomingHasSecret)
    {
        if (existingHasSecret != incomingHasSecret)
        {
            changes.Add(new FieldChange(field,
                existingHasSecret ? "<present>" : null,
                incomingHasSecret ? "<present>" : null));
        }
    }

    private static void DiffChildren<TExisting, TIncoming>(
        IEnumerable<TExisting> existing,
        IEnumerable<TIncoming> incoming,
        Func<TExisting, string> existingKey,
        Func<TIncoming, string> incomingKey,
        Func<TExisting, TIncoming, bool> equal,
        string childCategory,
        List<FieldChange> changes)
    {
        var existingByName = existing.GroupBy(existingKey, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
        var incomingByName = incoming.GroupBy(incomingKey, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);

        var added = incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();
        var removed = existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();

        foreach (var name in added)
        {
            changes.Add(new FieldChange($"{childCategory}.{name}", null, "<added>"));
        }
        foreach (var name in removed)
        {
            changes.Add(new FieldChange($"{childCategory}.{name}", "<present>", null));
        }
        foreach (var (name, e) in existingByName)
        {
            if (!incomingByName.TryGetValue(name, out var i)) continue;
            if (!equal(e, i))
            {
                changes.Add(new FieldChange($"{childCategory}.{name}", "<modified>", "<modified>"));
            }
        }
    }

    /// <summary>
    /// Scripts get a finer-grained per-row line-count delta so the preview UI
    /// can show the operator which scripts changed and roughly how much.
    /// </summary>
    private static void DiffScriptChildren(
        IEnumerable<TemplateScript> existing,
        IEnumerable<TemplateScriptDto> incoming,
        List<FieldChange> changes)
    {
        var existingByName = existing.GroupBy(s => s.Name, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
        var incomingByName = incoming.GroupBy(s => s.Name, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);

        foreach (var name in incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
        {
            var inc = incomingByName[name];
            var newLines = inc.Code.Split('\n').Length;
            changes.Add(new FieldChange($"Scripts.{name}", null, $"<added, {newLines} lines>"));
        }
        foreach (var name in existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
        {
            var ex = existingByName[name];
            var oldLines = ex.Code.Split('\n').Length;
            changes.Add(new FieldChange($"Scripts.{name}", $"<present, {oldLines} lines>", null));
        }
        foreach (var (name, ex) in existingByName)
        {
            if (!incomingByName.TryGetValue(name, out var inc)) continue;
            if (!TemplateChildEquality.ScriptsEqual(ex, inc))
            {
                // A script row can diverge in Code and/or its trigger/param metadata.
                // The structured line diff covers the Code body; when only the
                // metadata changed the hunk list is all-context (no +/-), which still
                // tells the operator the row changed.
                changes.Add(BuildCodeFieldChange($"Scripts.{name}", ex.Code, inc.Code));
            }
        }
    }

    private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) =>
        e.HttpMethod == i.HttpMethod
        && e.Path == i.Path
        && e.ParameterDefinitions == i.ParameterDefinitions
        && e.ReturnDefinition == i.ReturnDefinition;

    private static string? FolderNameOf(Template t, IReadOnlyDictionary<int, string>? folderNameById)
    {
        // Templates carry only a FK to the folder; the EntitySerializer projects
        // it to a name. When PreviewAsync supplies a folder-id→name map
        // the id resolves to the real name so an unchanged folder assignment reads
        // Identical; without the map (or on a miss) we fall back to "<id:N>", which
        // keeps the older unit-test callers valid but never matches a bundle name.
        if (t.FolderId is null) return null;
        if (folderNameById != null && folderNameById.TryGetValue(t.FolderId.Value, out var name)) return name;
        return $"<id:{t.FolderId}>";
    }

    private static string? BaseTemplateNameOf(Template t, IReadOnlyDictionary<int, string>? templateNameById)
    {
        if (t.ParentTemplateId is null) return null;
        if (templateNameById != null && templateNameById.TryGetValue(t.ParentTemplateId.Value, out var name)) return name;
        return $"<id:{t.ParentTemplateId}>";
    }

    private static string CompositionTargetNameOf(TemplateComposition comp, IReadOnlyDictionary<int, string>? templateNameById)
    {
        if (templateNameById != null && templateNameById.TryGetValue(comp.ComposedTemplateId, out var name)) return name;
        return $"<id:{comp.ComposedTemplateId}>";
    }

    private sealed record FieldChange(
        [property: JsonPropertyName("field")] string Field,
        [property: JsonPropertyName("oldValue")] string? OldValue,
        [property: JsonPropertyName("newValue")] string? NewValue,
        // Present only for code / large-text fields. Null (and omitted from
        // the JSON by the WhenWritingNull policy) for ordinary coarse fields.
        [property: JsonPropertyName("lineDiff")] LineDiffPayload? LineDiff = null);

    /// <summary>
    /// Serializable projection of <see cref="LineDiffResult"/> embedded inside a
    /// code <see cref="FieldChange"/>. <see cref="Hunks"/> is in source order and
    /// may be truncated (see <see cref="Truncated"/>); <see cref="AddedCount"/> /
    /// <see cref="RemovedCount"/> always report the full diff totals. The UI (E2)
    /// renders the +/- view directly from <see cref="Hunks"/>.
    /// </summary>
    private sealed record LineDiffPayload(
        [property: JsonPropertyName("hunks")] IReadOnlyList<LineDiffHunk> Hunks,
        [property: JsonPropertyName("truncated")] bool Truncated,
        [property: JsonPropertyName("addedCount")] int AddedCount,
        [property: JsonPropertyName("removedCount")] int RemovedCount);

    /// <summary>
    /// One emitted line of a code line-diff. <see cref="Op"/> is the lower-case
    /// op name (<c>context</c> / <c>add</c> / <c>remove</c>); line numbers are
    /// 1-based and null where the side does not participate (an <c>add</c> has no
    /// old line number, a <c>remove</c> has no new line number).
    /// </summary>
    private sealed record LineDiffHunk(
        [property: JsonPropertyName("op")] string Op,
        [property: JsonPropertyName("text")] string Text,
        [property: JsonPropertyName("oldLineNo")] int? OldLineNo,
        [property: JsonPropertyName("newLineNo")] int? NewLineNo);

    private sealed record FieldDiff(
        [property: JsonPropertyName("adds")] IReadOnlyList<string> Adds,
        [property: JsonPropertyName("removes")] IReadOnlyList<string> Removes,
        [property: JsonPropertyName("changes")] IReadOnlyList<FieldChange> Changes);
}
