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.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
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
/// fields appear in <c>changes</c> with old/new values (or hashes for large
/// blobs like script code). Per-line / Myers-style diff is explicitly out of
/// scope for v1 — the design plan defers it to a follow-up task. Script
/// bodies record only a line-count delta to give the operator a sense of the
/// change without paying the diff cost up front.
/// </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>
    /// <returns>An import preview item describing the conflict type and differences.</returns>
    public ImportPreviewItem CompareTemplate(TemplateDto incoming, Template? existing)
    {
        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), incoming.FolderName);
        AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing), incoming.BaseTemplateName);

        // 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.
        DiffChildren(
            existing.Attributes,
            incoming.Attributes,
            e => e.Name,
            i => i.Name,
            AttributesEqual,
            "Attributes",
            changes);

        DiffChildren(
            existing.Alarms,
            incoming.Alarms,
            e => e.Name,
            i => i.Name,
            AlarmsEqual,
            "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) == i.ComposedTemplateName,
            "Compositions",
            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,
            (e, i) => e.EmailAddress == i.EmailAddress,
            "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());

        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);
    }

    // CompareApiKey was removed in re-arch C4: 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 (re-arch C4) 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);
    }

    // ---- 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)
    {
        // Script bodies can be large — record a line-count delta + change marker
        // instead of inlining the full text so the diff JSON stays compact.
        var sameNullness = existing is null == incoming is null;
        var bothPresentAndEqual = sameNullness && (existing is null || string.Equals(existing, incoming, StringComparison.Ordinal));
        if (bothPresentAndEqual) return;

        var oldLines = existing?.Split('\n').Length ?? 0;
        var newLines = incoming?.Split('\n').Length ?? 0;
        changes.Add(new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>"));
    }

    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 (!ScriptsEqual(ex, inc))
            {
                var oldLines = ex.Code.Split('\n').Length;
                var newLines = inc.Code.Split('\n').Length;
                changes.Add(new FieldChange($"Scripts.{name}", $"<{oldLines} lines>", $"<{newLines} lines>"));
            }
        }
    }

    private static bool AttributesEqual(TemplateAttribute e, TemplateAttributeDto i) =>
        e.Value == i.Value
        && e.DataType == i.DataType
        && e.IsLocked == i.IsLocked
        && e.Description == i.Description
        && e.DataSourceReference == i.DataSourceReference;

    private static bool AlarmsEqual(TemplateAlarm e, TemplateAlarmDto i) =>
        e.Description == i.Description
        && e.PriorityLevel == i.PriorityLevel
        && e.TriggerType == i.TriggerType
        && e.TriggerConfiguration == i.TriggerConfiguration
        && e.IsLocked == i.IsLocked;

    private static bool ScriptsEqual(TemplateScript e, TemplateScriptDto i) =>
        string.Equals(e.Code, i.Code, StringComparison.Ordinal)
        && e.TriggerType == i.TriggerType
        && e.TriggerConfiguration == i.TriggerConfiguration
        && e.ParameterDefinitions == i.ParameterDefinitions
        && e.ReturnDefinition == i.ReturnDefinition
        && e.IsLocked == i.IsLocked;

    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)
    {
        // Templates carry only a FK to the folder; the EntitySerializer projects
        // it to a name. The diff doesn't have access to a folder lookup at
        // CompareTemplate scope, so fall back to "<id:N>" when only the id is
        // known. The PreviewAsync caller can pass a hydrated Template (via
        // GetTemplateWithChildrenAsync) and the folder name typically isn't on
        // it — this branch is a deliberate best-effort.
        return t.FolderId is null ? null : $"<id:{t.FolderId}>";
    }

    private static string? BaseTemplateNameOf(Template t)
    {
        return t.ParentTemplateId is null ? null : $"<id:{t.ParentTemplateId}>";
    }

    private static string CompositionTargetNameOf(TemplateComposition comp)
    {
        return $"<id:{comp.ComposedTemplateId}>";
    }

    private sealed record FieldChange(
        [property: JsonPropertyName("field")] string Field,
        [property: JsonPropertyName("oldValue")] string? OldValue,
        [property: JsonPropertyName("newValue")] string? NewValue);

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