using System.Collections.Concurrent;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua.Server;
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;

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

/// <summary>
/// #85 — verifies <see cref="Phase7Applier.MaterialiseHierarchy"/> builds the UNS
/// Area/Line/Equipment folder tree through <see cref="IOpcUaAddressSpaceSink.EnsureFolder"/>.
/// One pure unit test (recording sink) confirms ordering + parenting; one boot-verify test
/// drives a real <see cref="OtOpcUaNodeManager"/> and inspects the resulting predefined-node
/// count to prove the folders land in the SDK address space.
/// </summary>
public sealed class Phase7ApplierHierarchyTests : IDisposable
{
    private static CancellationToken Ct => TestContext.Current.CancellationToken;

    private readonly string _pkiRoot = Path.Combine(
        Path.GetTempPath(),
        $"otopcua-pki-{Guid.NewGuid():N}");

    /// <summary>Verifies that MaterialiseHierarchy creates areas, lines, and equipment with correct parent relationships.</summary>
    [Fact]
    public void MaterialiseHierarchy_creates_areas_then_lines_then_equipment_with_correct_parents()
    {
        var sink = new RecordingFolderSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            UnsAreas: new[] { new UnsAreaProjection("area-1", "Plant North") },
            UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
            EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
            DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
            ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
            GalaxyTags: Array.Empty<GalaxyTagPlan>());

        applier.MaterialiseHierarchy(composition);

        var calls = sink.Calls;
        calls.Count.ShouldBe(3);
        calls[0].ShouldBe(("area-1", null, "Plant North"));
        calls[1].ShouldBe(("line-1", "area-1", "Cell A"));
        calls[2].ShouldBe(("eq-1", "line-1", "Pump-1"));
    }

    /// <summary>Verifies that orphan equipment without a parent line appears under root.</summary>
    [Fact]
    public void MaterialiseHierarchy_orphan_equipment_hangs_under_root()
    {
        var sink = new RecordingFolderSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            UnsAreas: Array.Empty<UnsAreaProjection>(),
            UnsLines: Array.Empty<UnsLineProjection>(),
            EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
            DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
            ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
            GalaxyTags: Array.Empty<GalaxyTagPlan>());

        applier.MaterialiseHierarchy(composition);

        sink.Calls.Single().ShouldBe(("eq-orphan", null, "Orphan"));
    }

    /// <summary>Verifies that MaterialiseHierarchy creates folder nodes in a real SDK node manager.</summary>
    [Fact]
    public async Task MaterialiseHierarchy_against_real_SDK_node_manager_creates_folder_nodes()
    {
        await using var host = new OpcUaApplicationHost(
            new OpcUaApplicationHostOptions
            {
                ApplicationName = "OtOpcUa.Hierarchy",
                ApplicationUri = $"urn:OtOpcUa.Hierarchy:{Guid.NewGuid():N}",
                OpcUaPort = AllocateFreePort(),
                PublicHostname = "localhost",
                PkiStoreRoot = _pkiRoot,
            },
            NullLogger<OpcUaApplicationHost>.Instance);

        var sdkServer = new OtOpcUaSdkServer();
        await host.StartAsync(sdkServer, Ct);
        sdkServer.NodeManager.ShouldNotBeNull();

        var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        applier.MaterialiseHierarchy(new Phase7CompositionResult(
            UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
            UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
            EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
            DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
            ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
            GalaxyTags: Array.Empty<GalaxyTagPlan>()));

        sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment

        // Idempotent: re-applying with the same composition doesn't create duplicates.
        applier.MaterialiseHierarchy(new Phase7CompositionResult(
            UnsAreas: new[] { new UnsAreaProjection("area-A", "Area A"), new UnsAreaProjection("area-B", "Area B") },
            UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
            EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
            DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
            ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
            GalaxyTags: Array.Empty<GalaxyTagPlan>()));

        sdkServer.NodeManager!.FolderCount.ShouldBe(5);
    }

    /// <summary>Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager:
    /// applying the same composition twice yields a single Variable node (no duplicates). This is the
    /// restart-safety guarantee — HandleRebuild runs on both the apply path and the
    /// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node
    /// restart re-runs this pass and must not double-materialise.</summary>
    [Fact]
    public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent()
    {
        await using var host = new OpcUaApplicationHost(
            new OpcUaApplicationHostOptions
            {
                ApplicationName = "OtOpcUa.EquipmentTags",
                ApplicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}",
                OpcUaPort = AllocateFreePort(),
                PublicHostname = "localhost",
                PkiStoreRoot = _pkiRoot,
            },
            NullLogger<OpcUaApplicationHost>.Instance);

        var sdkServer = new OtOpcUaSdkServer();
        await host.StartAsync(sdkServer, Ct);
        sdkServer.NodeManager.ShouldNotBeNull();

        var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            UnsAreas: Array.Empty<UnsAreaProjection>(),
            UnsLines: Array.Empty<UnsLineProjection>(),
            EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") },
            DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
            ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
            GalaxyTags: Array.Empty<GalaxyTagPlan>())
        {
            EquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
            },
        };

        // Equipment folder first (the variable's parent), then the tag pass — applied twice.
        applier.MaterialiseHierarchy(composition);
        applier.MaterialiseEquipmentTags(composition);
        applier.MaterialiseEquipmentTags(composition);

        sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply
    }

    /// <summary>
    ///     Full structure-materialisation pipeline against a real SDK node manager: real Config
    ///     entities (Area / Line / Equipment + an Equipment-namespace Tag) → <see cref="Phase7Composer.Compose"/>
    ///     → MaterialiseHierarchy + MaterialiseEquipmentTags → <see cref="OtOpcUaNodeManager"/>. Proves
    ///     an Equipment namespace lands its Area/Line/Equipment folder tree + the equipment-signal
    ///     Variable in a live OPC UA address space (structure-only; live values are a later milestone).
    ///     Also covers the compose-side EquipmentTags extraction. The cluster-level deploy +
    ///     network-browse E2E (Host.IntegrationTests) needs the docker-dev fixture and is tracked
    ///     as a follow-up.
    /// </summary>
    [Fact]
    public async Task Equipment_namespace_structure_materialises_end_to_end_against_real_SDK()
    {
        await using var host = new OpcUaApplicationHost(
            new OpcUaApplicationHostOptions
            {
                ApplicationName = "OtOpcUa.EquipmentE2e",
                ApplicationUri = $"urn:OtOpcUa.EquipmentE2e:{Guid.NewGuid():N}",
                OpcUaPort = AllocateFreePort(),
                PublicHostname = "localhost",
                PkiStoreRoot = _pkiRoot,
            },
            NullLogger<OpcUaApplicationHost>.Instance);

        var sdkServer = new OtOpcUaSdkServer();
        await host.StartAsync(sdkServer, Ct);
        sdkServer.NodeManager.ShouldNotBeNull();

        // One area / line / equipment + a Modbus FK driver in an Equipment-kind namespace, with a
        // single equipment-bound Tag (the signal). Equipment.Name is the UNS browse segment.
        var ns = new Namespace { NamespaceId = "ns-eq", ClusterId = "c1", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq" };
        var driver = new DriverInstance { DriverInstanceId = "drv-modbus", ClusterId = "c1", NamespaceId = "ns-eq", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" };
        var area = new UnsArea { UnsAreaId = "nw-area-filling", ClusterId = "c1", Name = "filling" };
        var line = new UnsLine { UnsLineId = "nw-line-1", UnsAreaId = "nw-area-filling", Name = "line-1" };
        var equipment = new Equipment { EquipmentId = "eq-1", DriverInstanceId = "drv-modbus", UnsLineId = "nw-line-1", Name = "station-1", MachineCode = "STATION_001" };
        var tag = new Tag { TagId = "tag-speed", DriverInstanceId = "drv-modbus", EquipmentId = "eq-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read, TagConfig = "{\"FullName\":\"40001\"}" };

        var composition = Phase7Composer.Compose(
            new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
            Array.Empty<ScriptedAlarm>(), new[] { tag }, new[] { ns });

        // Compose-side EquipmentTags extraction (the inverse of the Galaxy filter).
        var planned = composition.EquipmentTags.ShouldHaveSingleItem();
        planned.EquipmentId.ShouldBe("eq-1");
        planned.FullName.ShouldBe("40001");

        var sink = new SdkAddressSpaceSink(sdkServer.NodeManager!);
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
        applier.MaterialiseHierarchy(composition);
        applier.MaterialiseEquipmentTags(composition);

        sdkServer.NodeManager!.FolderCount.ShouldBe(3);   // filling area + line-1 + station-1 equipment
        sdkServer.NodeManager!.VariableCount.ShouldBe(1); // the Speed signal under the equipment folder
    }

    private static int AllocateFreePort()
    {
        using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
        listener.Start();
        var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();
        return port;
    }

    /// <summary>Disposes of resources allocated by this test class.</summary>
    public void Dispose()
    {
        if (Directory.Exists(_pkiRoot))
        {
            try { Directory.Delete(_pkiRoot, recursive: true); }
            catch { /* best-effort */ }
        }
    }

    private sealed class RecordingFolderSink : IOpcUaAddressSpaceSink
    {
        private readonly ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> _calls = new();
        /// <summary>Gets the list of EnsureFolder calls recorded by this sink.</summary>
        public List<(string NodeId, string? Parent, string DisplayName)> Calls => _calls.ToList();

        /// <summary>Records a value write (stub implementation for testing).</summary>
        /// <param name="nodeId">The node ID of the variable.</param>
        /// <param name="value">The value to write.</param>
        /// <param name="quality">The OPC UA quality value.</param>
        /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
        public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
        /// <summary>Records an alarm condition write (stub implementation for testing).</summary>
        /// <param name="alarmNodeId">The node ID of the alarm condition.</param>
        /// <param name="state">The full condition state snapshot.</param>
        /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
        public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
        /// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
        /// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
        /// <param name="equipmentNodeId">The equipment folder node ID.</param>
        /// <param name="displayName">The condition display name.</param>
        /// <param name="alarmType">The domain alarm type.</param>
        /// <param name="severity">The domain severity.</param>
        public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity) { }
        /// <summary>Records a folder creation request.</summary>
        /// <param name="folderNodeId">The node ID of the folder.</param>
        /// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
        /// <param name="displayName">The display name of the folder.</param>
        public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
            => _calls.Enqueue((folderNodeId, parentNodeId, displayName));
        /// <summary>Ensures a variable exists (stub implementation for testing).</summary>
        /// <param name="variableNodeId">The node ID of the variable.</param>
        /// <param name="parentFolderNodeId">The node ID of the parent folder, or null for root.</param>
        /// <param name="displayName">The display name of the variable.</param>
        /// <param name="dataType">The OPC UA built-in type name.</param>
        public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType) { }
        /// <summary>Rebuilds the address space (stub implementation for testing).</summary>
        public void RebuildAddressSpace() { }
    }
}
