using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;

namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;

[Trait("Category", "Unit")]
public sealed class EquipmentNodeWalkerTests
{
    /// <summary>
    ///     v3: raw tags no longer flow into <see cref="EquipmentNamespaceContent"/> and the walker
    ///     emits no raw-tag variable nodes. Raw tags reach the UNS namespace via
    ///     <c>UnsTagReference</c> as the dual-namespace fan-out of their backing raw nodes, which lands
    ///     with the Batch-4 address space. Tests that asserted the old equipment-tag variable
    ///     materialization are skipped with this reason until that projection is built.
    /// </summary>
    internal const string DarkBatch4 =
        "v3 dark address space: raw-tag → UNS variable materialization lands in Batch 4 " +
        "(UnsTagReference fan-out), not in EquipmentNodeWalker.";

    /// <summary>Verifies that walking empty content emits no nodes.</summary>
    [Fact]
    public void Walk_EmptyContent_EmitsNothing()
    {
        var rec = new RecordingBuilder("root");
        EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], []));

        rec.Children.ShouldBeEmpty();
    }

    /// <summary>Verifies that walking emits Area, Line, and Equipment folders in unsorted order.</summary>
    [Fact]
    public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder()
    {
        var content = new EquipmentNamespaceContent(
            Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")],
            Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")],
            Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")]);

        var rec = new RecordingBuilder("root");
        EquipmentNodeWalker.Walk(rec, content);

        rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]);   // ordered by Name
        var warsaw = rec.Children.First(c => c.BrowseName == "warsaw");
        warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]);
        warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]);
    }

    /// <summary>Verifies that walking adds five identifier properties on equipment nodes, skipping null ZTag and SAPID.</summary>
    [Fact]
    public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid()
    {
        var uuid = Guid.NewGuid();
        var eq = Eq("eq-1", "line-1", "oven-3");
        eq.EquipmentUuid = uuid;
        eq.MachineCode = "MC-42";
        eq.ZTag = null;
        eq.SAPID = null;
        var content = new EquipmentNamespaceContent(
            [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq]);

        var rec = new RecordingBuilder("root");
        EquipmentNodeWalker.Walk(rec, content);

        var equipmentNode = rec.Children[0].Children[0].Children[0];
        var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList();
        props.ShouldContain("EquipmentId");
        props.ShouldContain("EquipmentUuid");
        props.ShouldContain("MachineCode");
        props.ShouldNotContain("ZTag");
        props.ShouldNotContain("SAPID");
        equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString());
    }

    /// <summary>Verifies that walking adds ZTag and SAPID properties when present.</summary>
    [Fact]
    public void Walk_Adds_ZTag_And_SAPID_When_Present()
    {
        var eq = Eq("eq-1", "line-1", "oven-3");
        eq.ZTag = "ZT-0042";
        eq.SAPID = "10000042";
        var content = new EquipmentNamespaceContent(
            [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq]);

        var rec = new RecordingBuilder("root");
        EquipmentNodeWalker.Walk(rec, content);

        var equipmentNode = rec.Children[0].Children[0].Children[0];
        equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042");
        equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042");
    }

    /// <summary>Verifies that walking materializes an Identification subfolder when any identification field is present.</summary>
    [Fact]
    public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent()
    {
        var eq = Eq("eq-1", "line-1", "oven-3");
        eq.Manufacturer = "Trumpf";
        eq.Model = "TruLaser-3030";
        var content = new EquipmentNamespaceContent(
            [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq]);

        var rec = new RecordingBuilder("root");
        EquipmentNodeWalker.Walk(rec, content);

        var equipmentNode = rec.Children[0].Children[0].Children[0];
        var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification");
        identification.ShouldNotBeNull();
        identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer");
        identification.Properties.Select(p => p.BrowseName).ShouldContain("Model");
    }

    /// <summary>Verifies that walking omits the Identification subfolder when all identification fields are null.</summary>
    [Fact]
    public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull()
    {
        var eq = Eq("eq-1", "line-1", "oven-3");  // no identification fields
        var content = new EquipmentNamespaceContent(
            [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq]);

        var rec = new RecordingBuilder("root");
        EquipmentNodeWalker.Walk(rec, content);

        var equipmentNode = rec.Children[0].Children[0].Children[0];
        equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification");
    }

    /// <summary>
    ///     Batch-4 pending: the walker must emit a raw-tag variable per <c>UnsTagReference</c> under
    ///     equipment (dual-namespace fan-out). Preserved as a skipped placeholder — the pre-v3 test
    ///     asserted equipment-tag variable materialization directly off an equipment-bound <c>Tag</c>,
    ///     which no longer exists (tags bind to Devices, not equipment). Intent retained; unskip when
    ///     the Batch-4 UNS projection lands.
    /// </summary>
    [Fact(Skip = DarkBatch4)]
    public void Walk_Emits_RawTag_Variables_Under_Equipment_DARK_Batch4()
    {
        // When Batch 4 lands, EquipmentNamespaceContent carries UnsTagReference rows and the walker
        // emits one Variable per referenced raw node (correct DriverDataType, address, source kind).
    }

    /// <summary>Verifies that walking emits virtual tag variables with Virtual source discriminator.</summary>
    [Fact]
    public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator()
    {
        var eq = Eq("eq-1", "line-1", "oven-3");
        var vtag = new VirtualTag
        {
            VirtualTagRowId = Guid.NewGuid(),
            VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "LineRate",
            DataType = "Float32", ScriptId = "scr-1", Historize = true,
        };
        var content = new EquipmentNamespaceContent(
            [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
            [eq], VirtualTags: [vtag]);

        var rec = new RecordingBuilder("root");
        EquipmentNodeWalker.Walk(rec, content);

        var equipmentNode = rec.Children[0].Children[0].Children[0];
        var v = equipmentNode.Variables.Single(x => x.BrowseName == "LineRate");
        v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Virtual);
        v.AttributeInfo.VirtualTagId.ShouldBe("vt-1");
        v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
        v.AttributeInfo.IsHistorized.ShouldBeTrue();
        v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
    }

    /// <summary>Verifies that walking emits scripted alarm variables with ScriptedAlarm source and IsAlarm flag.</summary>
    [Fact]
    public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm()
    {
        var eq = Eq("eq-1", "line-1", "oven-3");
        var alarm = new ScriptedAlarm
        {
            ScriptedAlarmRowId = Guid.NewGuid(),
            ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "HighTemp",
            AlarmType = "LimitAlarm", MessageTemplate = "{Temp} exceeded",
            PredicateScriptId = "scr-9", Severity = 800,
        };
        var content = new EquipmentNamespaceContent(
            [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
            [eq], ScriptedAlarms: [alarm]);

        var rec = new RecordingBuilder("root");
        EquipmentNodeWalker.Walk(rec, content);

        var v = rec.Children[0].Children[0].Children[0].Variables.Single(x => x.BrowseName == "HighTemp");
        v.AttributeInfo.Source.ShouldBe(NodeSourceKind.ScriptedAlarm);
        v.AttributeInfo.ScriptedAlarmId.ShouldBe("al-1");
        v.AttributeInfo.VirtualTagId.ShouldBeNull();
        v.AttributeInfo.IsAlarm.ShouldBeTrue();
        v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean);
    }

    /// <summary>Verifies that walking skips disabled virtual tags and alarms.</summary>
    [Fact]
    public void Walk_Skips_Disabled_VirtualTags_And_Alarms()
    {
        var eq = Eq("eq-1", "line-1", "oven-3");
        var vtag = new VirtualTag
        {
            VirtualTagRowId = Guid.NewGuid(),
            VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled",
            DataType = "Float32", ScriptId = "scr-1", Enabled = false,
        };
        var alarm = new ScriptedAlarm
        {
            ScriptedAlarmRowId = Guid.NewGuid(),
            ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "DisabledAlarm",
            AlarmType = "LimitAlarm", MessageTemplate = "x",
            PredicateScriptId = "scr-9", Enabled = false,
        };
        var content = new EquipmentNamespaceContent(
            [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
            [eq], VirtualTags: [vtag], ScriptedAlarms: [alarm]);

        var rec = new RecordingBuilder("root");
        EquipmentNodeWalker.Walk(rec, content);

        rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
    }

    /// <summary>Verifies that walking with null virtual tags and scripted alarms is safe.</summary>
    [Fact]
    public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe()
    {
        // Backwards-compat — callers that don't populate the optional collections still work.
        var eq = Eq("eq-1", "line-1", "oven-3");
        var content = new EquipmentNamespaceContent(
            [Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq]);

        var rec = new RecordingBuilder("root");
        EquipmentNodeWalker.Walk(rec, content);  // must not throw

        rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
    }

    // ----- builders for test seed rows -----

    private static UnsArea Area(string id, string name) => new()
    {
        UnsAreaId = id, ClusterId = "c1", Name = name,
    };

    private static UnsLine Line(string id, string areaId, string name) => new()
    {
        UnsLineId = id, UnsAreaId = areaId, Name = name,
    };

    private static Equipment Eq(string equipmentId, string lineId, string name) => new()
    {
        EquipmentRowId = Guid.NewGuid(),
        EquipmentId = equipmentId,
        EquipmentUuid = Guid.NewGuid(),
        UnsLineId = lineId,
        Name = name,
        MachineCode = "MC-" + name,
    };

    // ----- recording IAddressSpaceBuilder -----

    /// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
    private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder
    {
        /// <summary>Gets the browse name of this node.</summary>
        public string BrowseName { get; } = browseName;
        /// <summary>Gets the list of child nodes.</summary>
        public List<RecordingBuilder> Children { get; } = new();
        /// <summary>Gets the list of variables.</summary>
        public List<RecordingVariable> Variables { get; } = new();
        /// <summary>Gets the list of properties.</summary>
        public List<RecordingProperty> Properties { get; } = new();

        /// <summary>Creates a folder child node.</summary>
        /// <param name="name">The browse name of the folder.</param>
        /// <param name="_">The display name (unused).</param>
        public IAddressSpaceBuilder Folder(string name, string _)
        {
            var child = new RecordingBuilder(name);
            Children.Add(child);
            return child;
        }

        /// <summary>Creates a variable node.</summary>
        /// <param name="name">The browse name of the variable.</param>
        /// <param name="_">The display name (unused).</param>
        /// <param name="attr">The attribute information for the variable.</param>
        public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
        {
            var v = new RecordingVariable(name, attr);
            Variables.Add(v);
            return v;
        }

        /// <summary>Adds a property to the node.</summary>
        /// <param name="name">The browse name of the property.</param>
        /// <param name="_">The data type (unused).</param>
        /// <param name="value">The value of the property.</param>
        public void AddProperty(string name, DriverDataType _, object? value) =>
            Properties.Add(new RecordingProperty(name, value));
    }

    /// <summary>Recorded property for test verification.</summary>
    private sealed record RecordingProperty(string BrowseName, object? Value);

    /// <summary>Recorded variable for test verification.</summary>
    private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle
    {
        /// <summary>Gets the full reference of the variable.</summary>
        public string FullReference => AttributeInfo.FullName;
        /// <summary>Marks the variable as an alarm condition.</summary>
        /// <param name="info">The alarm condition information.</param>
        public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
    }
}
