using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;

namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;

/// <summary>
/// Compares two FlattenedConfigurations (deployed vs current) and produces a ConfigurationDiff
/// showing Added, Removed, and Changed entries for attributes, alarms, scripts,
/// and native alarm sources.
/// </summary>
public class DiffService
{
    /// <summary>
    /// Computes the diff between an old (deployed) and new (current) flattened configuration.
    /// </summary>
    /// <param name="oldConfig">The previously deployed configuration. Null for first-time deployment.</param>
    /// <param name="newConfig">The current flattened configuration.</param>
    /// <param name="oldRevisionHash">The revision hash of the old config, if any.</param>
    /// <param name="newRevisionHash">The revision hash of the new config.</param>
    /// <returns>A ConfigurationDiff with all changes.</returns>
    public ConfigurationDiff ComputeDiff(
        FlattenedConfiguration? oldConfig,
        FlattenedConfiguration newConfig,
        string? oldRevisionHash = null,
        string? newRevisionHash = null)
    {
        ArgumentNullException.ThrowIfNull(newConfig);

        var attributeChanges = ComputeEntityDiff(
            oldConfig?.Attributes ?? [],
            newConfig.Attributes,
            a => a.CanonicalName,
            AttributesEqual);

        var alarmChanges = ComputeEntityDiff(
            oldConfig?.Alarms ?? [],
            newConfig.Alarms,
            a => a.CanonicalName,
            AlarmsEqual);

        var scriptChanges = ComputeEntityDiff(
            oldConfig?.Scripts ?? [],
            newConfig.Scripts,
            s => s.CanonicalName,
            ScriptsEqual);

        var nativeAlarmSourceChanges = ComputeEntityDiff(
            oldConfig?.NativeAlarmSources ?? [],
            newConfig.NativeAlarmSources,
            n => n.CanonicalName,
            NativeAlarmSourcesEqual);

        // Surface standalone connection endpoint/protocol/
        // failover drift. Per-attribute binding changes already show up under
        // AttributeChanges, but a connection's own ConfigurationJson /
        // BackupConfigurationJson / Protocol / FailoverRetryCount edits do not —
        // those only appear here.
        var connectionChanges = ComputeConnectionsDiff(oldConfig, newConfig);

        return new ConfigurationDiff
        {
            InstanceUniqueName = newConfig.InstanceUniqueName,
            OldRevisionHash = oldRevisionHash,
            NewRevisionHash = newRevisionHash,
            AttributeChanges = attributeChanges,
            AlarmChanges = alarmChanges,
            ScriptChanges = scriptChanges,
            NativeAlarmSourceChanges = nativeAlarmSourceChanges,
            ConnectionChanges = connectionChanges
        };
    }

    private static List<DiffEntry<T>> ComputeEntityDiff<T>(
        IReadOnlyList<T> oldItems,
        IReadOnlyList<T> newItems,
        Func<T, string> getCanonicalName,
        Func<T, T, bool> areEqual)
    {
        var result = new List<DiffEntry<T>>();

        var oldMap = oldItems.ToDictionary(getCanonicalName, x => x, StringComparer.Ordinal);
        var newMap = newItems.ToDictionary(getCanonicalName, x => x, StringComparer.Ordinal);

        // Find removed and changed
        foreach (var (name, oldItem) in oldMap)
        {
            if (!newMap.TryGetValue(name, out var newItem))
            {
                result.Add(new DiffEntry<T>
                {
                    CanonicalName = name,
                    ChangeType = DiffChangeType.Removed,
                    OldValue = oldItem,
                    NewValue = default
                });
            }
            else if (!areEqual(oldItem, newItem))
            {
                result.Add(new DiffEntry<T>
                {
                    CanonicalName = name,
                    ChangeType = DiffChangeType.Changed,
                    OldValue = oldItem,
                    NewValue = newItem
                });
            }
        }

        // Find added
        foreach (var (name, newItem) in newMap)
        {
            if (!oldMap.ContainsKey(name))
            {
                result.Add(new DiffEntry<T>
                {
                    CanonicalName = name,
                    ChangeType = DiffChangeType.Added,
                    OldValue = default,
                    NewValue = newItem
                });
            }
        }

        return result.OrderBy(d => d.CanonicalName, StringComparer.Ordinal).ToList();
    }

    private static bool AttributesEqual(ResolvedAttribute a, ResolvedAttribute b) =>
        a.CanonicalName == b.CanonicalName &&
        a.Value == b.Value &&
        a.DataType == b.DataType &&
        // ElementDataType is part of the revision hash, so the diff must
        // treat a List element-type change (e.g. Int32 → Double) with identical
        // JSON-encoded values as a Changed attribute — keeping the structured
        // diff in parity with the staleness hash.
        a.ElementDataType == b.ElementDataType &&
        a.Description == b.Description &&
        a.IsLocked == b.IsLocked &&
        a.DataSourceReference == b.DataSourceReference &&
        a.BoundDataConnectionId == b.BoundDataConnectionId;

    private static bool AlarmsEqual(ResolvedAlarm a, ResolvedAlarm b) =>
        a.CanonicalName == b.CanonicalName &&
        a.Description == b.Description &&
        a.PriorityLevel == b.PriorityLevel &&
        a.IsLocked == b.IsLocked &&
        a.TriggerType == b.TriggerType &&
        a.TriggerConfiguration == b.TriggerConfiguration &&
        a.OnTriggerScriptCanonicalName == b.OnTriggerScriptCanonicalName;

    private static bool NativeAlarmSourcesEqual(ResolvedNativeAlarmSource a, ResolvedNativeAlarmSource b) =>
        a.CanonicalName == b.CanonicalName &&
        a.ConnectionName == b.ConnectionName &&
        a.SourceReference == b.SourceReference &&
        a.ConditionFilter == b.ConditionFilter &&
        a.IsLocked == b.IsLocked;

    private static bool ScriptsEqual(ResolvedScript a, ResolvedScript b) =>
        a.CanonicalName == b.CanonicalName &&
        a.Code == b.Code &&
        a.IsLocked == b.IsLocked &&
        a.TriggerType == b.TriggerType &&
        a.TriggerConfiguration == b.TriggerConfiguration &&
        a.ParameterDefinitions == b.ParameterDefinitions &&
        a.ReturnDefinition == b.ReturnDefinition &&
        a.MinTimeBetweenRuns == b.MinTimeBetweenRuns &&
        a.ExecutionTimeoutSeconds == b.ExecutionTimeoutSeconds;

    /// <summary>
    /// Compares two <see cref="ConnectionConfig"/> instances for equality across
    /// the fields that travel in the deployment package: protocol, primary and
    /// backup configuration JSON, and failover retry count. Used by callers that
    /// need to detect connection-endpoint drift.
    /// </summary>
    /// <param name="a">First connection configuration.</param>
    /// <param name="b">Second connection configuration.</param>
    /// <returns>True when both configurations are equal.</returns>
    public static bool ConnectionsEqual(ConnectionConfig a, ConnectionConfig b)
    {
        ArgumentNullException.ThrowIfNull(a);
        ArgumentNullException.ThrowIfNull(b);

        return a.Protocol == b.Protocol &&
            a.ConfigurationJson == b.ConfigurationJson &&
            a.BackupConfigurationJson == b.BackupConfigurationJson &&
            a.FailoverRetryCount == b.FailoverRetryCount;
    }

    /// <summary>
    /// Produces a per-connection diff between two flattened
    /// configurations, emitting Added / Removed / Changed entries keyed by the
    /// connection name. Mirrors the existing <see cref="ComputeEntityDiff{T}"/>
    /// shape used for attributes / alarms / scripts. Called by
    /// <see cref="ComputeDiff"/> to populate
    /// <see cref="ConfigurationDiff.ConnectionChanges"/>, and exposed publicly so
    /// callers can compute connection drift in isolation. A null
    /// <c>Connections</c> dictionary on either side is treated as the empty map.
    /// </summary>
    /// <param name="oldConfig">The previously deployed configuration, or null
    /// for first-time deployment.</param>
    /// <param name="newConfig">The current flattened configuration.</param>
    /// <returns>Added / Removed / Changed entries keyed by connection name,
    /// sorted ordinally by <see cref="DiffEntry{T}.CanonicalName"/>.</returns>
    public IReadOnlyList<DiffEntry<ConnectionConfig>> ComputeConnectionsDiff(
        FlattenedConfiguration? oldConfig,
        FlattenedConfiguration newConfig)
    {
        ArgumentNullException.ThrowIfNull(newConfig);

        // Project both sides to (name, config) pairs. A null Connections
        // dictionary models the no-connections case (first deploy, or all
        // bindings cleared) — treat it as empty so the diff still reports the
        // counterpart side's entries as Added / Removed rather than throwing.
        var oldPairs = (oldConfig?.Connections ?? new Dictionary<string, ConnectionConfig>())
            .Select(kvp => (kvp.Key, kvp.Value))
            .ToList();
        var newPairs = (newConfig.Connections ?? new Dictionary<string, ConnectionConfig>())
            .Select(kvp => (kvp.Key, kvp.Value))
            .ToList();

        return ComputeEntityDiff(
            oldPairs,
            newPairs,
            pair => pair.Key,
            (a, b) => ConnectionsEqual(a.Value, b.Value))
            .Select(entry => new DiffEntry<ConnectionConfig>
            {
                CanonicalName = entry.CanonicalName,
                ChangeType = entry.ChangeType,
                OldValue = entry.OldValue.Value,
                NewValue = entry.NewValue.Value,
            })
            .ToList();
    }
}
