using System.Collections;
using System.Reflection;
using System.Text.Json;

namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;

/// <summary>
/// Structural deep-equality assertion for the site-command round-trip goldens.
/// </summary>
/// <remarks>
/// <para>
/// Record equality is NOT usable here. A C# record's generated <c>Equals</c>
/// compares members with <c>EqualityComparer&lt;T&gt;.Default</c>, which for
/// <c>IReadOnlyList&lt;T&gt;</c> / <c>IReadOnlyDictionary&lt;,&gt;</c> members
/// degrades to reference equality — so <c>Assert.Equal(dto, roundTripped)</c>
/// would fail on every collection-bearing message even when the mapper is
/// perfect, and (worse) would pass vacuously nowhere useful. This walker
/// compares by shape instead: dictionaries by key, sequences element-wise, and
/// everything else property-by-property down to leaf values.
/// </para>
/// <para>
/// The failure message carries the full property path, so a dropped field
/// reports as e.g. <c>DeployArtifactsCommand.ExternalSystems[0].TimeoutSeconds</c>
/// rather than an opaque "objects differ".
/// </para>
/// </remarks>
internal static class StructuralEquality
{
    /// <summary>Asserts two object graphs are structurally identical.</summary>
    /// <param name="expected">The original value.</param>
    /// <param name="actual">The value that came back through the mapper.</param>
    /// <param name="rootName">Name used as the root of the reported property path.</param>
    public static void AssertDeepEqual(object? expected, object? actual, string rootName)
        => Compare(expected, actual, rootName);

    private static void Compare(object? expected, object? actual, string path)
    {
        if (expected is null && actual is null)
        {
            return;
        }

        if (expected is null || actual is null)
        {
            Assert.Fail($"{path}: expected {Describe(expected)} but got {Describe(actual)}.");
            return;
        }

        // JsonElement is the documented lossy escape hatch of LooseValueCodec; it
        // has no useful Equals, so compare the canonical JSON text.
        if (expected is JsonElement expectedJson && actual is JsonElement actualJson)
        {
            Assert.Equal(expectedJson.GetRawText(), actualJson.GetRawText());
            return;
        }

        // Collections are compared by CONTENT, not by concrete container type.
        // Members are declared IReadOnlyList<T>/IReadOnlyDictionary<,>, so the
        // backing type (a collection-expression array here, a List<T> out of the
        // mapper) is not part of the contract. Element and value types below are
        // still compared strictly.
        if (expected is not string && actual is not string)
        {
            if (expected is IDictionary expectedMap && actual is IDictionary actualMap)
            {
                CompareDictionaries(expectedMap, actualMap, path);
                return;
            }

            if (expected is IEnumerable expectedSeq && actual is IEnumerable actualSeq)
            {
                CompareSequences(expectedSeq, actualSeq, path);
                return;
            }
        }

        var expectedType = expected.GetType();
        var actualType = actual.GetType();

        if (expectedType != actualType)
        {
            Assert.Fail(
                $"{path}: type changed across the round trip — expected {expectedType.Name}, got {actualType.Name}.");
            return;
        }

        if (IsLeaf(expectedType))
        {
            Assert.True(
                Equals(expected, actual),
                $"{path}: expected '{expected}' but got '{actual}'.");
            return;
        }

        CompareProperties(expected, actual, expectedType, path);
    }

    private static void CompareDictionaries(IDictionary expected, IDictionary actual, string path)
    {
        Assert.True(
            expected.Count == actual.Count,
            $"{path}: entry count changed — expected {expected.Count}, got {actual.Count}.");

        foreach (DictionaryEntry entry in expected)
        {
            Assert.True(
                actual.Contains(entry.Key),
                $"{path}: key '{entry.Key}' is missing after the round trip.");
            Compare(entry.Value, actual[entry.Key], $"{path}['{entry.Key}']");
        }
    }

    private static void CompareSequences(IEnumerable expected, IEnumerable actual, string path)
    {
        var expectedItems = expected.Cast<object?>().ToList();
        var actualItems = actual.Cast<object?>().ToList();

        Assert.True(
            expectedItems.Count == actualItems.Count,
            $"{path}: element count changed — expected {expectedItems.Count}, got {actualItems.Count}.");

        for (var i = 0; i < expectedItems.Count; i++)
        {
            Compare(expectedItems[i], actualItems[i], $"{path}[{i}]");
        }
    }

    private static void CompareProperties(object expected, object actual, Type type, string path)
    {
        var properties = type
            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
            .ToList();

        Assert.True(properties.Count > 0, $"{path}: {type.Name} exposes no readable properties to compare.");

        foreach (var property in properties)
        {
            Compare(property.GetValue(expected), property.GetValue(actual), $"{path}.{property.Name}");
        }
    }

    private static bool IsLeaf(Type type) =>
        type.IsPrimitive
        || type.IsEnum
        || type == typeof(string)
        || type == typeof(decimal)
        || type == typeof(DateTime)
        || type == typeof(DateTimeOffset)
        || type == typeof(TimeSpan)
        || type == typeof(Guid)
        // Value types with no collection members (e.g. TrackedOperationId) have a
        // correct structural Equals of their own.
        || (type.IsValueType && !type.IsGenericType);

    private static string Describe(object? value) => value is null ? "<null>" : $"{value.GetType().Name}('{value}')";
}
