using System.Linq;
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;

/// <summary>
///     Byte-parity tests for the scripted-alarm deployment-artifact decode path
///     (<c>DeploymentArtifact.BuildEquipmentScriptedAlarmPlans</c>) against the live compose seam
///     (<c>AddressSpaceComposer.Compose</c>). Both sides derive <c>EquipmentScriptedAlarmPlan</c> from the
///     same ScriptedAlarm + Script data via the shared <c>EquipmentScriptPaths.ExtractAlarmDependencyRefs</c>
///     helper, so the decoded plans must equal the composer's element-wise (the record has value
///     equality including DependencyRefs order). Mirrors the existing EquipmentVirtualTags parity
///     style in <c>DeploymentArtifactEquipTokenTests</c>.
/// </summary>
public sealed class DeploymentArtifactScriptedAlarmParityTests
{
    private static byte[] BlobOf(object snapshot) => JsonSerializer.SerializeToUtf8Bytes(snapshot);

    /// <summary>The live composer's plan for the same scripted alarms + scripts the artifact carries
    /// must equal the artifact-decode plan, field-for-field including DependencyRefs order.</summary>
    [Fact]
    public void ParseComposition_scripted_alarms_are_byte_parity_with_composer()
    {
        // Two equipments, each with a scripted alarm: predicate reads a tag via ctx.GetTag("X.Y"),
        // and the message template carries a distinct {A.B} token — so DependencyRefs exercise the
        // predicate-reads-then-template-tokens merge order on both sides.
        var script1 = new Script
        {
            ScriptId = "s-1",
            Name = "hot-1",
            SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach1.Temp\").Value) > 80;",
            SourceHash = "h1",
        };
        var script2 = new Script
        {
            ScriptId = "s-2",
            Name = "hot-2",
            SourceCode = "return System.Convert.ToDouble(ctx.GetTag(\"Mach2.Temp\").Value) > 80;",
            SourceHash = "h2",
        };
        var alarm1 = new ScriptedAlarm
        {
            ScriptedAlarmId = "al-1",
            EquipmentId = "eq-1",
            Name = "Overheat-1",
            AlarmType = "LimitAlarm",
            Severity = 700,
            MessageTemplate = "Machine 1 at {Mach1.Pressure}",
            PredicateScriptId = "s-1",
            HistorizeToAveva = true,
            Retain = true,
            Enabled = true,
        };
        var alarm2 = new ScriptedAlarm
        {
            ScriptedAlarmId = "al-2",
            EquipmentId = "eq-2",
            Name = "Overheat-2",
            AlarmType = "OffNormalAlarm",
            Severity = 250,
            MessageTemplate = "Machine 2 at {Mach2.Pressure}",
            PredicateScriptId = "s-2",
            HistorizeToAveva = false,
            Retain = false,
            Enabled = false,
        };

        var composed = AddressSpaceComposer.Compose(
            Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
            Array.Empty<DriverInstance>(), new[] { alarm1, alarm2 },
            virtualTags: Array.Empty<VirtualTag>(),
            scripts: new[] { script1, script2 });

        // The artifact blob the write side (ConfigComposer) emits: the FULL ScriptedAlarm + Script
        // entities serialised Pascal-case off the EF entity.
        var blob = BlobOf(new
        {
            ScriptedAlarms = new[]
            {
                ToSnapshot(alarm1),
                ToSnapshot(alarm2),
            },
            Scripts = new[]
            {
                new { ScriptId = script1.ScriptId, SourceCode = script1.SourceCode },
                new { ScriptId = script2.ScriptId, SourceCode = script2.SourceCode },
            },
        });

        var decoded = DeploymentArtifact.ParseComposition(blob);

        // Byte-parity: element-wise value equality (record equality compares DependencyRefs order).
        decoded.EquipmentScriptedAlarms.Count.ShouldBe(2);
        decoded.EquipmentScriptedAlarms.SequenceEqual(composed.EquipmentScriptedAlarms).ShouldBeTrue();
        // And spot-check the merged DependencyRefs (predicate read first, then template token).
        var plan1 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-1");
        plan1.DependencyRefs.ShouldBe(new[] { "Mach1.Temp", "Mach1.Pressure" });
        plan1.Enabled.ShouldBeTrue();
        var plan2 = decoded.EquipmentScriptedAlarms.Single(p => p.ScriptedAlarmId == "al-2");
        plan2.DependencyRefs.ShouldBe(new[] { "Mach2.Temp", "Mach2.Pressure" });
        plan2.Enabled.ShouldBeFalse();
        plan2.HistorizeToAveva.ShouldBeFalse();
        plan2.Retain.ShouldBeFalse();
    }

    /// <summary>The cluster-scoped overload keeps only the scripted alarms whose EquipmentId belongs to
    /// an in-cluster driver (mirroring how EquipmentVirtualTags + EquipmentTags are filtered).</summary>
    [Fact]
    public void ParseComposition_scoped_keeps_only_my_clusters_scripted_alarms()
    {
        var blob = BlobOf(new
        {
            Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
            Nodes = new[]
            {
                new { NodeId = "central-1:4053", ClusterId = "MAIN" },
                new { NodeId = "site-a-1:4053",  ClusterId = "SITE-A" },
            },
            DriverInstances = new[]
            {
                new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN",   NamespaceId = "main-ns" },
                new { DriverInstanceId = "sa-modbus",   DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
            },
            // v3: equipment is attributed to a cluster via its UNS line's area cluster, not via a driver.
            UnsAreas = new[]
            {
                new { UnsAreaId = "a-main", Name = "AreaMain", ClusterId = "MAIN" },
                new { UnsAreaId = "a-sa",   Name = "AreaSa",   ClusterId = "SITE-A" },
            },
            UnsLines = new[]
            {
                new { UnsLineId = "l1", UnsAreaId = "a-main", Name = "L1" },
                new { UnsLineId = "l2", UnsAreaId = "a-sa",   Name = "L2" },
            },
            Equipment = new[]
            {
                new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1" },
                new { EquipmentId = "eq-sa",   Name = "eqs", UnsLineId = "l2" },
            },
            Scripts = new[]
            {
                new { ScriptId = "scr", SourceCode = "return ctx.GetTag(\"A.X\").Value;" },
            },
            ScriptedAlarms = new[]
            {
                NewAlarmSnapshot("al-main", "eq-main", "scr"),
                NewAlarmSnapshot("al-sa",   "eq-sa",   "scr"),
            },
        });

        var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
        main.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-main" });

        var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
        siteA.EquipmentScriptedAlarms.Select(a => a.ScriptedAlarmId).ShouldBe(new[] { "al-sa" });
    }

    /// <summary>An alarm whose PredicateScriptId matches no Script row is SKIPPED on BOTH the composer
    /// and the artifact-decode side, so parity holds for the surviving alarm.</summary>
    [Fact]
    public void ParseComposition_skips_alarm_with_missing_predicate_script_matching_composer()
    {
        var goodScript = new Script
        {
            ScriptId = "s-ok",
            Name = "ok",
            SourceCode = "return ctx.GetTag(\"A.X\").Value;",
            SourceHash = "h1",
        };
        var goodAlarm = new ScriptedAlarm
        {
            ScriptedAlarmId = "al-ok",
            EquipmentId = "eq-1",
            Name = "Ok",
            AlarmType = "AlarmCondition",
            Severity = 500,
            MessageTemplate = "ok",
            PredicateScriptId = "s-ok",
            HistorizeToAveva = true,
            Retain = true,
            Enabled = true,
        };
        var orphanAlarm = new ScriptedAlarm
        {
            ScriptedAlarmId = "al-orphan",
            EquipmentId = "eq-2",
            Name = "Orphan",
            AlarmType = "AlarmCondition",
            Severity = 500,
            MessageTemplate = "orphan",
            PredicateScriptId = "s-does-not-exist",
            HistorizeToAveva = true,
            Retain = true,
            Enabled = true,
        };

        var composed = AddressSpaceComposer.Compose(
            Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
            Array.Empty<DriverInstance>(), new[] { goodAlarm, orphanAlarm },
            scripts: new[] { goodScript });

        var blob = BlobOf(new
        {
            ScriptedAlarms = new[] { ToSnapshot(goodAlarm), ToSnapshot(orphanAlarm) },
            Scripts = new[] { new { ScriptId = goodScript.ScriptId, SourceCode = goodScript.SourceCode } },
        });

        var decoded = DeploymentArtifact.ParseComposition(blob);

        // The orphan is dropped on both sides; the survivor's plan is byte-identical.
        var composedPlan = composed.EquipmentScriptedAlarms.ShouldHaveSingleItem();
        composedPlan.ScriptedAlarmId.ShouldBe("al-ok");
        var decodedPlan = decoded.EquipmentScriptedAlarms.ShouldHaveSingleItem();
        decodedPlan.ScriptedAlarmId.ShouldBe("al-ok");
        decodedPlan.ShouldBe(composedPlan);
    }

    // The full Pascal-case snapshot a ScriptedAlarm EF entity serialises to (matches ConfigComposer).
    private static object ToSnapshot(ScriptedAlarm a) => new
    {
        a.ScriptedAlarmId,
        a.EquipmentId,
        a.Name,
        a.AlarmType,
        a.Severity,
        a.MessageTemplate,
        a.PredicateScriptId,
        a.HistorizeToAveva,
        a.Retain,
        a.Enabled,
    };

    private static object NewAlarmSnapshot(string id, string equipmentId, string scriptId) => new
    {
        ScriptedAlarmId = id,
        EquipmentId = equipmentId,
        Name = id,
        AlarmType = "AlarmCondition",
        Severity = 500,
        MessageTemplate = $"{id} alarm",
        PredicateScriptId = scriptId,
        HistorizeToAveva = true,
        Retain = true,
        Enabled = true,
    };
}
