using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;

public sealed class AddressSpaceComposerPurityTests
{
    /// <summary>Verifies empty inputs produce empty result.</summary>
    [Fact]
    public void Empty_inputs_produce_empty_result()
    {
        var result = AddressSpaceComposer.Compose(
            equipment: Array.Empty<Equipment>(),
            driverInstances: Array.Empty<DriverInstance>(),
            scriptedAlarms: Array.Empty<ScriptedAlarm>());

        result.EquipmentNodes.ShouldBeEmpty();
        result.DriverInstancePlans.ShouldBeEmpty();
        result.ScriptedAlarmPlans.ShouldBeEmpty();
    }

    /// <summary>Verifies same inputs in different order produce structurally equal results.</summary>
    [Fact]
    public void Same_inputs_in_different_order_produce_structurally_equal_results()
    {
        var e1 = NewEquipment("eq-1");
        var e2 = NewEquipment("eq-2");
        var d1 = NewDriver("drv-1");
        var d2 = NewDriver("drv-2");
        var a1 = NewAlarm("a-1", "eq-1");
        var a2 = NewAlarm("a-2", "eq-2");

        var r1 = AddressSpaceComposer.Compose(
            equipment: new[] { e1, e2 },
            driverInstances: new[] { d1, d2 },
            scriptedAlarms: new[] { a1, a2 });

        var r2 = AddressSpaceComposer.Compose(
            equipment: new[] { e2, e1 },
            driverInstances: new[] { d2, d1 },
            scriptedAlarms: new[] { a2, a1 });

        r1.EquipmentNodes.ShouldBe(r2.EquipmentNodes);
        r1.DriverInstancePlans.ShouldBe(r2.DriverInstancePlans);
        r1.ScriptedAlarmPlans.ShouldBe(r2.ScriptedAlarmPlans);
    }

    /// <summary>Verifies Compose is pure with identical repeated calls.</summary>
    [Fact]
    public void Compose_is_pure_repeated_call_returns_element_identical_output()
    {
        var equipment = new[] { NewEquipment("eq-a"), NewEquipment("eq-b") };
        var drivers = new[] { NewDriver("drv-x") };
        var alarms = new[] { NewAlarm("alarm-1", "eq-a") };

        var r1 = AddressSpaceComposer.Compose(equipment, drivers, alarms);
        var r2 = AddressSpaceComposer.Compose(equipment, drivers, alarms);

        // Record equality won't help here — IReadOnlyList<T> uses reference equality. Compare
        // element-wise to verify the pure-function contract.
        r1.EquipmentNodes.ShouldBe(r2.EquipmentNodes);
        r1.DriverInstancePlans.ShouldBe(r2.DriverInstancePlans);
        r1.ScriptedAlarmPlans.ShouldBe(r2.ScriptedAlarmPlans);
    }

    /// <summary>Verifies output is sorted by natural key.</summary>
    [Fact]
    public void Output_is_sorted_by_natural_key()
    {
        var equipment = new[] { NewEquipment("z"), NewEquipment("a"), NewEquipment("m") };
        var result = AddressSpaceComposer.Compose(equipment, Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());

        result.EquipmentNodes.Select(e => e.EquipmentId)
            .ShouldBe(new[] { "a", "m", "z" });
    }

    /// <summary>Verifies UNS equipment folders browse by the friendly UNS <c>Name</c> segment
    /// (not the colloquial MachineCode, not the logical EquipmentId) while the NodeId stays the
    /// logical EquipmentId — so browse-path resolution + ACLs are unaffected (decision #3).</summary>
    [Fact]
    public void Equipment_node_DisplayName_is_the_UNS_Name_not_MachineCode()
    {
        var equipment = new[] { NewEquipment("filling-eq") }; // Name="filling-eq", MachineCode="FILLING-EQ"

        var node = AddressSpaceComposer.Compose(equipment, Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>())
            .EquipmentNodes.ShouldHaveSingleItem();

        node.EquipmentId.ShouldBe("filling-eq");    // NodeId stays the logical Id
        node.DisplayName.ShouldBe("filling-eq");    // browse name = UNS Name segment
        node.DisplayName.ShouldNotBe("FILLING-EQ"); // not the colloquial MachineCode
    }

    [Fact]
    public void Composition_carries_empty_equipment_virtualtags_by_default()
    {
        var r = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
        r.EquipmentVirtualTags.ShouldBeEmpty();
    }

    [Fact]
    public void EquipmentVirtualTagPlan_holds_id_equipment_name_datatype_expression_and_deps()
    {
        var p = new EquipmentVirtualTagPlan("vt-1", "eq-1", "", "speed-rpm", "Float64",
            "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
            new[] { "TestMachine_001.TestDouble" });
        p.VirtualTagId.ShouldBe("vt-1");
        p.EquipmentId.ShouldBe("eq-1");
        p.Name.ShouldBe("speed-rpm");
        p.DependencyRefs.ShouldHaveSingleItem();
    }

    /// <summary>Compose joins a <see cref="VirtualTag"/> to its <see cref="Script"/> by ScriptId,
    /// emitting one <see cref="EquipmentVirtualTagPlan"/> carrying the script source as the
    /// Expression and the parsed <c>ctx.GetTag("…")</c> literals as DependencyRefs.</summary>
    [Fact]
    public void Compose_emits_equipment_virtualtag_plan_joined_to_script()
    {
        var vt = new VirtualTag
        {
            VirtualTagId = "vt-1",
            EquipmentId = "eq-1",
            Name = "speed-rpm",
            DataType = "Float64",
            ScriptId = "s-1",
        };
        var script = new Script
        {
            ScriptId = "s-1",
            Name = "speed-script",
            SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
            SourceHash = "hash-1",
        };

        var result = AddressSpaceComposer.Compose(
            Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
            Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
            virtualTags: new[] { vt },
            scripts: new[] { script });

        var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
        plan.VirtualTagId.ShouldBe("vt-1");
        plan.EquipmentId.ShouldBe("eq-1");
        plan.FolderPath.ShouldBe("");
        plan.Name.ShouldBe("speed-rpm");
        plan.DataType.ShouldBe("Float64");
        plan.Expression.ShouldBe("return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;");
        plan.DependencyRefs.ShouldBe(new[] { "TestMachine_001.TestDouble" });
    }

    /// <summary>DependencyRefs are the distinct <c>ctx.GetTag("…")</c> literals in first-seen
    /// order — a repeated ref collapses to one.</summary>
    [Fact]
    public void Compose_extracts_distinct_dependency_refs()
    {
        var vt = new VirtualTag
        {
            VirtualTagId = "vt-1",
            EquipmentId = "eq-1",
            Name = "sum",
            DataType = "Float64",
            ScriptId = "s-1",
        };
        var script = new Script
        {
            ScriptId = "s-1",
            Name = "sum-script",
            SourceCode = "return ctx.GetTag(\"A.X\").Value + ctx.GetTag(\"B.Y\").Value + ctx.GetTag(\"A.X\").Value;",
            SourceHash = "hash-1",
        };

        var result = AddressSpaceComposer.Compose(
            Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
            Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
            virtualTags: new[] { vt },
            scripts: new[] { script });

        var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
        plan.DependencyRefs.ShouldBe(new[] { "A.X", "B.Y" });
    }

    /// <summary>A <see cref="VirtualTag"/> whose <c>ScriptId</c> has no matching <see cref="Script"/>
    /// in the supplied list falls back to an empty Expression and an empty DependencyRefs —
    /// the plan is always emitted (never dropped) and never carries a null Expression.</summary>
    [Fact]
    public void Compose_virtualtag_with_missing_script_yields_empty_expression_and_deps()
    {
        var vt = new VirtualTag
        {
            VirtualTagId = "vt-missing",
            EquipmentId = "eq-1",
            Name = "mystery-tag",
            DataType = "Float64",
            ScriptId = "s-does-not-exist",
        };

        var result = AddressSpaceComposer.Compose(
            Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
            Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
            virtualTags: new[] { vt },
            scripts: Array.Empty<Script>());

        var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
        plan.VirtualTagId.ShouldBe("vt-missing");
        plan.Expression.ShouldBe(string.Empty);
        plan.DependencyRefs.ShouldBeEmpty();
    }

    private static Equipment NewEquipment(string id) => new()
    {
        EquipmentId = id,
        UnsLineId = "line-1",
        Name = id,
        MachineCode = id.ToUpperInvariant(),
    };

    private static DriverInstance NewDriver(string id) => new()
    {
        DriverInstanceId = id,
        ClusterId = "cluster-1",
        Name = id,
        DriverType = "Stub",
        DriverConfig = "{\"k\":\"v\"}",
    };

    private static ScriptedAlarm NewAlarm(string id, string equipmentId) => new()
    {
        ScriptedAlarmId = id,
        EquipmentId = equipmentId,
        Name = id,
        AlarmType = "AlarmCondition",
        MessageTemplate = "{TagPath} alarm",
        PredicateScriptId = "script-1",
    };
}
