using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;

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

/// <summary>
///     v3 Batch 4 (B4-WP3) — the applier's dual-realm materialise passes.
///     <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> lights up the <c>ns=Raw</c> device tree
///     (containers as folders + tags as variables, each keyed by its RawPath, all in
///     <see cref="AddressSpaceRealm.Raw"/>); <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> lights
///     up the <c>ns=UNS</c> reference variables (each under its equipment folder in
///     <see cref="AddressSpaceRealm.Uns"/>) and wires an <c>Organizes</c> reference UNS→Raw. A historized raw
///     tag's UNS reference inherits the SAME historian tagname (both NodeIds → one tagname). Every call carries
///     its realm EXPLICITLY.
/// </summary>
public sealed class AddressSpaceApplierRawUnsTests
{
    private static AddressSpaceApplier NewApplier(RealmRecordingSink sink) =>
        new(sink, NullLogger<AddressSpaceApplier>.Instance);

    [Fact]
    public void MaterialiseRawSubtree_creates_raw_containers_and_tags_in_raw_realm()
    {
        var sink = new RealmRecordingSink();
        var composition = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            RawContainers = new[]
            {
                new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder),
                new RawContainerNode("Plant/Modbus", "Plant", "Modbus", RawNodeKind.Driver),
                new RawContainerNode("Plant/Modbus/dev1", "Plant/Modbus", "dev1", RawNodeKind.Device),
            },
            RawTags = new[]
            {
                new RawTagPlan("t1", "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1", "drv-1", "speed",
                    "Float", Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>()),
            },
        };

        NewApplier(sink).MaterialiseRawSubtree(composition);

        // Containers are folders in the Raw realm, parents-before-children.
        sink.Folders.Select(f => (f.NodeId, f.Realm)).ShouldBe(new[]
        {
            ("Plant", AddressSpaceRealm.Raw),
            ("Plant/Modbus", AddressSpaceRealm.Raw),
            ("Plant/Modbus/dev1", AddressSpaceRealm.Raw),
        });
        // The tag is a variable keyed by its RawPath, in the Raw realm, parented under its group/device.
        var v = sink.Variables.ShouldHaveSingleItem();
        v.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
        v.Parent.ShouldBe("Plant/Modbus/dev1");
        v.Realm.ShouldBe(AddressSpaceRealm.Raw);
        v.Writable.ShouldBeTrue();
        v.HistorianTagname.ShouldBeNull();
    }

    [Fact]
    public void MaterialiseRawSubtree_resolves_historian_tagname_default_and_override()
    {
        var sink = new RealmRecordingSink();
        var composition = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            RawTags = new[]
            {
                // Historized, no override → historian tagname defaults to the RawPath.
                new RawTagPlan("t1", "Plant/A/dev/def", "Plant/A/dev", "drv", "def", "Float",
                    Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true),
                // Historized with override → the override wins.
                new RawTagPlan("t2", "Plant/A/dev/ovr", "Plant/A/dev", "drv", "ovr", "Float",
                    Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true, HistorianTagname: "WW.Override"),
            },
        };

        NewApplier(sink).MaterialiseRawSubtree(composition);

        var byId = sink.Variables.ToDictionary(v => v.NodeId);
        byId["Plant/A/dev/def"].HistorianTagname.ShouldBe("Plant/A/dev/def"); // default = RawPath
        byId["Plant/A/dev/ovr"].HistorianTagname.ShouldBe("WW.Override");
    }

    [Fact]
    public void MaterialiseRawSubtree_alarm_tag_materialises_a_raw_condition_not_a_variable()
    {
        var sink = new RealmRecordingSink();
        var composition = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            RawTags = new[]
            {
                new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
                    Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
                    ReferencingEquipmentPaths: Array.Empty<string>()),
            },
        };

        NewApplier(sink).MaterialiseRawSubtree(composition);

        sink.Variables.ShouldBeEmpty(); // an alarm tag is a condition, not a value variable
        var c = sink.Conditions.ShouldHaveSingleItem();
        c.NodeId.ShouldBe("Plant/A/dev/OverTemp"); // ConditionId == RawPath
        c.Parent.ShouldBe("Plant/A/dev");
        c.Realm.ShouldBe(AddressSpaceRealm.Raw);
        c.IsNative.ShouldBeTrue();
        // No referencing equipment ⇒ no multi-notifier wiring.
        sink.NotifierWirings.ShouldBeEmpty();
    }

    [Fact]
    public void MaterialiseRawSubtree_alarm_tag_wires_a_notifier_per_referencing_equipment_folder()
    {
        var sink = new RealmRecordingSink();
        // UNS topology so the alarm's referencing-equipment PATHS (Area/Line/Equipment) resolve to the
        // EquipmentId the equipment folders were materialised under. Two equipment reference the one alarm tag.
        var composition = new AddressSpaceComposition(
            new[] { new UnsAreaProjection("a1", "filling") },
            new[] { new UnsLineProjection("l1", "a1", "line1"), new UnsLineProjection("l2", "a1", "line2") },
            new[]
            {
                new EquipmentNode("EQ-1", "station1", "l1"),
                new EquipmentNode("EQ-2", "station2", "l2"),
            },
            Array.Empty<DriverInstancePlan>(),
            Array.Empty<ScriptedAlarmPlan>())
        {
            RawTags = new[]
            {
                new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
                    Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
                    // The composer emits Area/Line/Equipment NAME paths here.
                    ReferencingEquipmentPaths: new[] { "filling/line1/station1", "filling/line2/station2" }),
            },
        };

        NewApplier(sink).MaterialiseRawSubtree(composition);

        // The single condition is materialised ONCE at the RawPath (Raw realm)...
        var c = sink.Conditions.ShouldHaveSingleItem();
        c.NodeId.ShouldBe("Plant/A/dev/OverTemp");
        c.Realm.ShouldBe(AddressSpaceRealm.Raw);
        // ...and wired as a notifier of BOTH referencing equipment folders — resolved from the name paths to the
        // logical EquipmentId folder NodeIds, in the Uns realm. ONE wiring call (single condition), not per-root.
        var w = sink.NotifierWirings.ShouldHaveSingleItem();
        w.AlarmNodeId.ShouldBe("Plant/A/dev/OverTemp");
        w.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
        w.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
        w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true);
    }

    /// <summary>
    ///     Wave C review M2 — composer↔applier PATH-AGREEMENT (the tripwire for the latent DisplayName==Name
    ///     coupling). The composer builds a native alarm's <c>ReferencingEquipmentPaths</c> from the
    ///     Area/Line/Equipment <c>Name</c>s; the applier's <c>BuildEquipmentIdByFolderPath</c> inverts those
    ///     paths back to the <c>EquipmentId</c> via the projected DisplayName. Feed a REAL composer output
    ///     (not a hand-built plan) through <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> and assert
    ///     the notifier wired to exactly the backing equipment's id — proving the two path constructions agree
    ///     end-to-end. If a future editable Area/Line display-name feature made <c>DisplayName != Name</c>, this
    ///     inversion (and thus the native-alarm fan-out) would break with no deploy error; this test trips first.
    /// </summary>
    [Fact]
    public void Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier()
    {
        // Real config: a raw alarm tag (Speed) referenced by one equipment (eq-1 under Area "filling" /
        // Line "line-1" / Equipment "station-1").
        var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
        var driver = new DriverInstance
        {
            DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
            Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
        };
        var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
        var speed = new Tag
        {
            TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float",
            AccessLevel = TagAccessLevel.Read,
            TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
        };
        var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
        var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
        var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
        var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" };

        var composition = AddressSpaceComposer.Compose(
            new[] { area }, new[] { line }, new[] { equip },
            new[] { driver }, Array.Empty<ScriptedAlarm>(),
            unsTagReferences: new[] { refSpeed },
            tags: new[] { speed },
            rawFolders: new[] { folder },
            devices: new[] { device },
            tagGroups: Array.Empty<TagGroup>());

        // The composer emitted the alarm tag's referencing-equipment path from the Area/Line/Equipment Names.
        var alarmTag = composition.RawTags.Single(t => t.Alarm is not null);
        alarmTag.ReferencingEquipmentPaths.ShouldBe(new[] { V3NodeIds.Uns("filling", "line-1", "station-1") });

        // The applier inverts that path back to the EquipmentId folder and wires the notifier there.
        var sink = new RealmRecordingSink();
        NewApplier(sink).MaterialiseRawSubtree(composition);

        var w = sink.NotifierWirings.ShouldHaveSingleItem();
        w.AlarmNodeId.ShouldBe(alarmTag.NodeId);
        w.FolderNodeIds.ShouldBe(new[] { "eq-1" }); // resolved path → EquipmentId
    }

    /// <summary>Wave C review M2 — when a native alarm HAS referencing equipment paths but NONE resolve to an
    /// equipment folder (broken ancestry / the latent coupling breaking), the applier surfaces it as a failed
    /// node (so the degraded-apply meter fires) instead of silently skipping — no notifier is wired, but the
    /// condition itself is still materialised.</summary>
    [Fact]
    public void MaterialiseRawSubtree_alarm_with_unresolvable_referencing_equipment_counts_as_failed()
    {
        var sink = new RealmRecordingSink();
        // No equipment nodes in the composition → the referencing path can't resolve to an EquipmentId folder.
        var composition = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            RawTags = new[]
            {
                new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
                    Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
                    ReferencingEquipmentPaths: new[] { "filling/line1/ghost" }),
            },
        };

        var failed = NewApplier(sink).MaterialiseRawSubtree(composition);

        failed.ShouldBeGreaterThanOrEqualTo(1); // resolution failure surfaced (not a silent skip)
        sink.NotifierWirings.ShouldBeEmpty();   // nothing wired
        sink.Conditions.ShouldHaveSingleItem(); // the condition itself still materialised
    }

    [Fact]
    public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian()
    {
        var sink = new RealmRecordingSink();
        var composition = new AddressSpaceComposition(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            RawTags = new[]
            {
                // Backing raw tag: historized with an override → the UNS ref must register the SAME tagname.
                new RawTagPlan("t1", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
                    Writable: true, Alarm: null, ReferencingEquipmentPaths: new[] { "filling/line1/station1" },
                    IsHistorized: true, HistorianTagname: "WW.Speed"),
            },
            UnsReferenceVariables = new[]
            {
                new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
                    "Plant/A/dev/speed", "Float", Writable: true),
            },
        };

        NewApplier(sink).MaterialiseUnsReferences(composition);

        // The UNS reference is a variable in the Uns realm, parented under its equipment folder (EquipmentId).
        var v = sink.Variables.ShouldHaveSingleItem();
        v.NodeId.ShouldBe("filling/line1/station1/speed");
        v.Parent.ShouldBe("EQ-1");
        v.Realm.ShouldBe(AddressSpaceRealm.Uns);
        v.Writable.ShouldBeTrue();
        v.HistorianTagname.ShouldBe("WW.Speed"); // inherited from the backing historized raw tag

        // An Organizes reference UNS→Raw links the two.
        var r = sink.References.ShouldHaveSingleItem();
        r.Source.ShouldBe("filling/line1/station1/speed");
        r.SourceRealm.ShouldBe(AddressSpaceRealm.Uns);
        r.Target.ShouldBe("Plant/A/dev/speed");
        r.TargetRealm.ShouldBe(AddressSpaceRealm.Raw);
        r.ReferenceType.ShouldBe("Organizes");
    }

    /// <summary>A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges.</summary>
    private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink
    {
        public List<(string NodeId, string? Parent, AddressSpaceRealm Realm)> Folders { get; } = new();
        public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> Variables { get; } = new();
        public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
        public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
        public List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> FolderNodeIds, AddressSpaceRealm FolderRealm)> NotifierWirings { get; } = new();

        public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
            => Folders.Add((folderNodeId, parentNodeId, realm));

        public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
            => Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm));

        public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
            => Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));

        public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
            => NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm));

        public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
            => References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));

        public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
        public void WriteAlarmQuality(string alarmNodeId, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
        public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
        public void RebuildAddressSpace() { }
        public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
    }
}
