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

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

public class RevisionHashServiceTests
{
    private readonly RevisionHashService _sut = new();

    [Fact]
    public void ComputeHash_SameContent_SameHash()
    {
        var config1 = CreateConfig("Instance1", "25.0");
        var config2 = CreateConfig("Instance1", "25.0");

        var hash1 = _sut.ComputeHash(config1);
        var hash2 = _sut.ComputeHash(config2);

        Assert.Equal(hash1, hash2);
    }

    [Fact]
    public void ComputeHash_DifferentContent_DifferentHash()
    {
        var config1 = CreateConfig("Instance1", "25.0");
        var config2 = CreateConfig("Instance1", "50.0");

        var hash1 = _sut.ComputeHash(config1);
        var hash2 = _sut.ComputeHash(config2);

        Assert.NotEqual(hash1, hash2);
    }

    [Fact]
    public void ComputeHash_StartsWithSha256Prefix()
    {
        var config = CreateConfig("Instance1", "25.0");
        var hash = _sut.ComputeHash(config);

        Assert.StartsWith("sha256:", hash);
    }

    [Fact]
    public void ComputeHash_DeterministicAcrossRuns()
    {
        // Different GeneratedAtUtc should NOT affect the hash (volatile field excluded)
        var config1 = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Attributes = [new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }],
            GeneratedAtUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)
        };
        var config2 = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Attributes = [new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }],
            GeneratedAtUtc = new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero)
        };

        Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
    }

    [Fact]
    public void ComputeHash_NullConfig_ThrowsArgumentNull()
    {
        Assert.Throws<ArgumentNullException>(() => _sut.ComputeHash(null!));
    }

    [Fact]
    public void ComputeHash_AttributeOrder_DoesNotAffectHash()
    {
        var config1 = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" },
                new ResolvedAttribute { CanonicalName = "B", Value = "2", DataType = "Int32" }
            ]
        };
        var config2 = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "B", Value = "2", DataType = "Int32" },
                new ResolvedAttribute { CanonicalName = "A", Value = "1", DataType = "Int32" }
            ]
        };

        Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
    }

    [Fact]
    public void HashableRecords_PropertiesDeclaredAlphabetically()
    {
        // TemplateEngine-011: revision-hash determinism depends entirely on the
        // private Hashable* records declaring their properties in alphabetical
        // order (System.Text.Json emits properties in CLR declaration order and
        // does not sort). This guards against a contributor silently changing
        // every revision hash by adding a property out of order.
        var nested = typeof(RevisionHashService)
            .GetNestedTypes(System.Reflection.BindingFlags.NonPublic)
            .Where(t => t.Name.StartsWith("Hashable"))
            .ToList();

        Assert.NotEmpty(nested);

        foreach (var type in nested)
        {
            var propNames = type
                .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
                .Where(p => p.Name != "EqualityContract")
                .Select(p => p.Name)
                .ToList();

            var sorted = propNames.OrderBy(n => n, StringComparer.Ordinal).ToList();

            Assert.True(
                propNames.SequenceEqual(sorted),
                $"{type.Name} properties must be declared alphabetically. " +
                $"Declared: [{string.Join(", ", propNames)}] Expected: [{string.Join(", ", sorted)}]");
        }
    }

    [Fact]
    public void ComputeHash_AttributeDescriptionEdit_ChangesHash()
    {
        // TemplateEngine-017: Description must be folded into the hash so that
        // edits to authoring-time documentation (which still travels in the
        // deployed payload) flow through the staleness indicator.
        var baseAttr = new ResolvedAttribute
        {
            CanonicalName = "Temperature",
            Value = "25",
            DataType = "Double",
            Description = "Original description"
        };
        var editedAttr = baseAttr with { Description = "Updated description" };

        var configBefore = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Attributes = [baseAttr]
        };
        var configAfter = configBefore with { Attributes = [editedAttr] };

        Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
    }

    [Fact]
    public void ComputeHash_AlarmDescriptionEdit_ChangesHash()
    {
        // TemplateEngine-017: same Description contract applies to alarms.
        var baseAlarm = new ResolvedAlarm
        {
            CanonicalName = "HighTemp",
            TriggerType = "RangeViolation",
            Description = "Original"
        };
        var editedAlarm = baseAlarm with { Description = "Updated" };

        var configBefore = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Alarms = [baseAlarm]
        };
        var configAfter = configBefore with { Alarms = [editedAlarm] };

        Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
    }

    [Fact]
    public void ComputeHash_ConnectionEndpointEdit_ChangesHash()
    {
        // TemplateEngine-017: a Deployment user editing the primary endpoint
        // JSON of a data connection bound to an instance must produce a
        // different revision hash. The connection's protocol, primary/backup
        // configuration JSON, and failover retry count are all part of the
        // deployment package and therefore part of the hash input.
        var connectionsBefore = new Dictionary<string, ConnectionConfig>
        {
            ["plc1"] = new ConnectionConfig
            {
                Protocol = "OpcUa",
                ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a:4840\"}",
                BackupConfigurationJson = null,
                FailoverRetryCount = 3
            }
        };
        var connectionsAfter = new Dictionary<string, ConnectionConfig>
        {
            ["plc1"] = connectionsBefore["plc1"] with
            {
                ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b:4840\"}"
            }
        };

        var configBefore = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Connections = connectionsBefore
        };
        var configAfter = configBefore with { Connections = connectionsAfter };

        Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
    }

    [Fact]
    public void ComputeHash_ConnectionProtocolEdit_ChangesHash()
    {
        // TemplateEngine-017: changing protocol must change the hash.
        var connectionsBefore = new Dictionary<string, ConnectionConfig>
        {
            ["plc1"] = new ConnectionConfig
            {
                Protocol = "OpcUa",
                ConfigurationJson = "{}",
                FailoverRetryCount = 3
            }
        };
        var connectionsAfter = new Dictionary<string, ConnectionConfig>
        {
            ["plc1"] = connectionsBefore["plc1"] with { Protocol = "Modbus" }
        };

        var configBefore = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Connections = connectionsBefore
        };
        var configAfter = configBefore with { Connections = connectionsAfter };

        Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
    }

    [Fact]
    public void ComputeHash_ConnectionsSameContent_SameHash()
    {
        // TemplateEngine-017: equal Connections maps must yield the same hash,
        // regardless of dictionary iteration order (the SortedDictionary
        // projection guards this).
        var connections1 = new Dictionary<string, ConnectionConfig>
        {
            ["b"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":2}", FailoverRetryCount = 3 },
            ["a"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":1}", FailoverRetryCount = 3 }
        };
        var connections2 = new Dictionary<string, ConnectionConfig>
        {
            ["a"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":1}", FailoverRetryCount = 3 },
            ["b"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{\"k\":2}", FailoverRetryCount = 3 }
        };

        var config1 = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Connections = connections1
        };
        var config2 = config1 with { Connections = connections2 };

        Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
    }

    [Fact]
    public void ComputeHash_ListElementDataTypeChange_ChangesHash()
    {
        // #290: a List attribute whose element type changes (Int32 → Double)
        // while the JSON-encoded values stay identical MUST produce a different
        // revision hash, so the deployment is flagged stale. ElementDataType is
        // folded into the hashable projection precisely for this case.
        var baseAttr = new ResolvedAttribute
        {
            CanonicalName = "Readings",
            Value = "[1,2]",
            DataType = "List",
            ElementDataType = "Int32"
        };
        var editedAttr = baseAttr with { ElementDataType = "Double" };

        var configBefore = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Attributes = [baseAttr]
        };
        var configAfter = configBefore with { Attributes = [editedAttr] };

        Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
    }

    [Fact]
    public void ComputeHash_SameListElementDataType_SameHash()
    {
        // #290: two List attributes identical in every field — including
        // ElementDataType — must hash the same.
        var attr = new ResolvedAttribute
        {
            CanonicalName = "Readings",
            Value = "[1,2]",
            DataType = "List",
            ElementDataType = "Int32"
        };

        var config1 = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Attributes = [attr]
        };
        var config2 = config1 with { Attributes = [attr with { }] };

        Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
    }

    [Fact]
    public void ComputeHash_ScalarAttribute_NullVsUnsetElementDataType_SameHash()
    {
        // #290 no-op guard: a scalar attribute has a null ElementDataType, and
        // the canonical serializer omits null properties
        // (DefaultIgnoreCondition = WhenWritingNull). Explicitly setting
        // ElementDataType = null must therefore hash identically to leaving it
        // at its default — i.e. adding the field is a no-op for scalars and does
        // not retroactively change their hashes.
        var withExplicitNull = new ResolvedAttribute
        {
            CanonicalName = "Temperature",
            Value = "25",
            DataType = "Double",
            ElementDataType = null
        };
        var withDefault = new ResolvedAttribute
        {
            CanonicalName = "Temperature",
            Value = "25",
            DataType = "Double"
        };

        var config1 = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            Attributes = [withExplicitNull]
        };
        var config2 = config1 with { Attributes = [withDefault] };

        Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
    }

    [Fact]
    public void ComputeHash_NativeAlarmSourceChange_ChangesHash()
    {
        // TemplateEngine-011: native alarm source bindings are part of the
        // deployment package, so an edit to a source's binding (here the
        // SourceReference) MUST change the revision hash and flag the instance
        // stale. Before this migration native sources were absent from the hash
        // and this edit went undetected.
        var baseSource = new ResolvedNativeAlarmSource
        {
            CanonicalName = "Boiler.Alarms",
            ConnectionName = "plc1",
            SourceReference = "ns=2;s=Boiler",
            ConditionFilter = null,
            IsLocked = false
        };
        var editedSource = baseSource with { SourceReference = "ns=2;s=Boiler2" };

        var configBefore = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            NativeAlarmSources = [baseSource]
        };
        var configAfter = configBefore with { NativeAlarmSources = [editedSource] };

        Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
    }

    [Fact]
    public void ComputeHash_NativeAlarmSourceIsLockedChange_ChangesHash()
    {
        // TemplateEngine-011: IsLocked participates in the hash too.
        var baseSource = new ResolvedNativeAlarmSource
        {
            CanonicalName = "Boiler.Alarms",
            ConnectionName = "plc1",
            SourceReference = "ns=2;s=Boiler",
            IsLocked = false
        };
        var editedSource = baseSource with { IsLocked = true };

        var configBefore = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            NativeAlarmSources = [baseSource]
        };
        var configAfter = configBefore with { NativeAlarmSources = [editedSource] };

        Assert.NotEqual(_sut.ComputeHash(configBefore), _sut.ComputeHash(configAfter));
    }

    [Fact]
    public void ComputeHash_NativeAlarmSourceOrder_DoesNotAffectHash()
    {
        // TemplateEngine-011: sources are sorted by CanonicalName before hashing,
        // mirroring attributes/alarms/scripts — authoring order is irrelevant.
        var a = new ResolvedNativeAlarmSource
        {
            CanonicalName = "A", ConnectionName = "plc1", SourceReference = "ns=2;s=A"
        };
        var b = new ResolvedNativeAlarmSource
        {
            CanonicalName = "B", ConnectionName = "plc1", SourceReference = "ns=2;s=B"
        };

        var config1 = new FlattenedConfiguration
        {
            InstanceUniqueName = "Instance1",
            TemplateId = 1,
            SiteId = 1,
            NativeAlarmSources = [a, b]
        };
        var config2 = config1 with { NativeAlarmSources = [b, a] };

        Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
    }

    [Fact]
    public void ComputeHash_NoNativeAlarmSources_HashUnchangedFromBaseline()
    {
        // TemplateEngine-011 migration surgicality: adding the (null-when-empty)
        // NativeAlarmSources property to the hashable projection must NOT change
        // the hash of any native-source-free configuration. The literal below is
        // the pre-migration hash of CreateConfig("Instance1", "25.0"); pinning it
        // proves that only instances that HAVE native sources flip stale — there
        // is no global staleness storm.
        var config = CreateConfig("Instance1", "25.0");

        Assert.Equal(
            "sha256:e937bcfa34a146ca6bdb9bda27bdceb204e440a4a6110ca646f7750f37b166cb",
            _sut.ComputeHash(config));
    }

    private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
    {
        return new FlattenedConfiguration
        {
            InstanceUniqueName = instanceName,
            TemplateId = 1,
            SiteId = 1,
            Attributes =
            [
                new ResolvedAttribute { CanonicalName = "Temperature", Value = tempValue, DataType = "Double" }
            ]
        };
    }
}
