using System.Collections.Concurrent;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;

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

public sealed class Phase7ApplierTests
{
    /// <summary>Verifies that an empty plan does not call the sink or trigger a rebuild.</summary>
    [Fact]
    public void Empty_plan_does_not_call_sink_and_does_not_trigger_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var outcome = applier.Apply(EmptyPlan);

        outcome.RebuildCalled.ShouldBeFalse();
        outcome.AddedNodes.ShouldBe(0);
        outcome.RemovedNodes.ShouldBe(0);
        outcome.ChangedNodes.ShouldBe(0);
        sink.RebuildCalls.ShouldBe(0);
        sink.AlarmWrites.ShouldBeEmpty();
    }

    /// <summary>Verifies that removed equipment writes inactive alarm state and triggers rebuild.</summary>
    [Fact]
    public void Removed_equipment_writes_inactive_alarm_state_per_id_and_triggers_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var plan = WithEquipmentRemoval("eq-1", "eq-2");
        var outcome = applier.Apply(plan);

        outcome.RemovedNodes.ShouldBe(2);
        outcome.RebuildCalled.ShouldBeTrue();
        sink.AlarmWrites.Select(a => a.NodeId).OrderBy(x => x).ShouldBe(new[] { "eq-1", "eq-2" });
        // Removed nodes are reset to the "no-event" state: inactive + acked + confirmed + enabled.
        sink.AlarmWrites.All(a => !a.State.Active && a.State.Acknowledged && a.State.Confirmed).ShouldBeTrue();
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>Verifies that added equipment triggers rebuild without writing alarm state.</summary>
    [Fact]
    public void Added_equipment_triggers_rebuild_without_alarm_writes()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var plan = new Phase7Plan(
            AddedEquipment: new[] { new EquipmentNode("new", "New", "line-1") },
            RemovedEquipment: Array.Empty<EquipmentNode>(),
            ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
            AddedDrivers: Array.Empty<DriverInstancePlan>(),
            RemovedDrivers: Array.Empty<DriverInstancePlan>(),
            ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
            AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
            RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
            ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
            AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
            RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
            ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        outcome.AddedNodes.ShouldBe(1);
        sink.AlarmWrites.ShouldBeEmpty();
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>Verifies that driver-only changes do not trigger address space rebuild.</summary>
    [Fact]
    public void Driver_only_changes_do_not_trigger_address_space_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var plan = new Phase7Plan(
            AddedEquipment: Array.Empty<EquipmentNode>(),
            RemovedEquipment: Array.Empty<EquipmentNode>(),
            ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
            AddedDrivers: new[] { new DriverInstancePlan("d-new", "Modbus", "{}") },
            RemovedDrivers: Array.Empty<DriverInstancePlan>(),
            ChangedDrivers: new[]
            {
                new Phase7Plan.DriverDelta(
                    new DriverInstancePlan("d-1", "Modbus", "{\"v\":1}"),
                    new DriverInstancePlan("d-1", "Modbus", "{\"v\":2}")),
            },
            AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
            RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
            ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
            AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
            RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
            ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeFalse();
        sink.RebuildCalls.ShouldBe(0);
    }

    /// <summary>Verifies that sink exceptions in WriteAlarmCondition do not propagate and rebuild still fires.</summary>
    [Fact]
    public void Sink_exception_in_WriteAlarmCondition_does_not_propagate_and_rebuild_still_fires()
    {
        var sink = new ThrowingSink(throwOnAlarmWrite: true);
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var plan = WithEquipmentRemoval("eq-1");

        var outcome = applier.Apply(plan); // should not throw
        outcome.RemovedNodes.ShouldBe(1);
        outcome.RebuildCalled.ShouldBeTrue();
    }

    /// <summary>Verifies MaterialiseGalaxyTags creates one folder per distinct FolderPath and one
    /// variable per tag, with root-level tags hung directly under the namespace root.</summary>
    [Fact]
    public void MaterialiseGalaxyTags_creates_folder_per_distinct_path_and_variable_per_tag()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            UnsAreas: Array.Empty<UnsAreaProjection>(),
            UnsLines: Array.Empty<UnsLineProjection>(),
            EquipmentNodes: Array.Empty<EquipmentNode>(),
            DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
            ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
            GalaxyTags: new[]
            {
                new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"),
                new GalaxyTagPlan("t2", "drv", "", "Pressure", "Int32", "Pressure"),
            });

        applier.MaterialiseGalaxyTags(composition);

        // One folder for the single distinct non-empty FolderPath; the root-level tag adds none.
        sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("section.area", null, "section.area"));

        // Foldered tag → NodeId is its MxAccessRef under the FolderPath parent.
        // Root-level tag → NodeId is its DisplayName under the root (null parent).
        sink.VariableCalls.ShouldContain(("section.area.Temperature", "section.area", "Temperature", "Float"));
        sink.VariableCalls.ShouldContain(("Pressure", (string?)null, "Pressure", "Int32"));
        sink.VariableCalls.Count.ShouldBe(2);
    }

    /// <summary>Verifies that two tags sharing a FolderPath produce a single EnsureFolder call
    /// (deduped) but one EnsureVariable per tag.</summary>
    [Fact]
    public void MaterialiseGalaxyTags_dedupes_folders_for_tags_sharing_a_path()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            UnsAreas: Array.Empty<UnsAreaProjection>(),
            UnsLines: Array.Empty<UnsLineProjection>(),
            EquipmentNodes: Array.Empty<EquipmentNode>(),
            DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
            ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
            GalaxyTags: new[]
            {
                new GalaxyTagPlan("t1", "drv", "line.cell", "Speed", "Float", "line.cell.Speed"),
                new GalaxyTagPlan("t2", "drv", "line.cell", "Torque", "Float", "line.cell.Torque"),
            });

        applier.MaterialiseGalaxyTags(composition);

        sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("line.cell", null, "line.cell"));
        sink.VariableCalls.Count.ShouldBe(2);
        sink.VariableCalls.ShouldContain(("line.cell.Speed", "line.cell", "Speed", "Float"));
        sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float"));
    }

    /// <summary>Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
    /// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw
    /// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment
    /// folder (decision #4).</summary>
    [Fact]
    public void MaterialiseEquipmentTags_creates_variable_under_equipment_folder()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            UnsAreas: Array.Empty<UnsAreaProjection>(),
            UnsLines: Array.Empty<UnsLineProjection>(),
            EquipmentNodes: Array.Empty<EquipmentNode>(),
            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"),
            },
        };

        applier.MaterialiseEquipmentTags(composition);

        sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
        sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float"));
    }

    /// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
    /// folder (not the namespace root), with the variable parented to that sub-folder and a
    /// folder-scoped NodeId.</summary>
    [Fact]
    public void MaterialiseEquipmentTags_nests_FolderPath_subfolder_under_equipment()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "Diagnostics", Name: "Temp", DataType: "Float", FullName: "40002"),
            },
        };

        applier.MaterialiseEquipmentTags(composition);

        sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
        sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float"));
    }

    /// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
    /// SAME driver FullName (e.g. Modbus register 40001) must produce TWO distinct variables — one
    /// under each equipment folder — because the NodeId is folder-scoped, not the raw FullName.</summary>
    [Fact]
    public void MaterialiseEquipmentTags_identical_FullName_across_two_equipments_does_not_collide()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-a", "eq-1", "drv-1", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
                new EquipmentTagPlan("tag-b", "eq-2", "drv-2", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
            },
        };

        applier.MaterialiseEquipmentTags(composition);

        sink.VariableCalls.Count.ShouldBe(2);
        sink.VariableCalls.ShouldContain(("eq-1/Speed", "eq-1", "Speed", "Float"));
        sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float"));
    }

    /// <summary>Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
    /// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the
    /// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create
    /// the equipment folder (no sub-folder when FolderPath is empty).</summary>
    [Fact]
    public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentVirtualTags = new[]
            {
                new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
                    Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }),
            },
        };

        applier.MaterialiseEquipmentVirtualTags(composition);

        sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
        sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
    }

    /// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
    /// (one EnsureVariable each, no NodeId collision), parented to the equipment folder.</summary>
    [Fact]
    public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentVirtualTags = new[]
            {
                new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
                    Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
                new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64",
                    Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
            },
        };

        applier.MaterialiseEquipmentVirtualTags(composition);

        sink.FolderCalls.ShouldBeEmpty();
        sink.VariableCalls.Count.ShouldBe(2);
        sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
        sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64"));
    }

    /// <summary>T14 — MaterialiseScriptedAlarms materialises one condition per ENABLED alarm (keyed by
    /// ScriptedAlarmId, parented to its EquipmentId, carrying Name/AlarmType/Severity) and SKIPS
    /// disabled alarms.</summary>
    [Fact]
    public void MaterialiseScriptedAlarms_materialises_enabled_and_skips_disabled()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var composition = new Phase7CompositionResult(
            Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
        {
            EquipmentScriptedAlarms = new[]
            {
                new EquipmentScriptedAlarmPlan(
                    ScriptedAlarmId: "alm-1", EquipmentId: "eq-1", Name: "HighTemp", AlarmType: "OffNormalAlarm",
                    Severity: 700, MessageTemplate: "Temp high", PredicateScriptId: "scr-1", PredicateSource: "return true;",
                    DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true),
                new EquipmentScriptedAlarmPlan(
                    ScriptedAlarmId: "alm-2", EquipmentId: "eq-2", Name: "LowFlow", AlarmType: "AlarmCondition",
                    Severity: 300, MessageTemplate: "Flow low", PredicateScriptId: "scr-2", PredicateSource: "return false;",
                    DependencyRefs: Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: false),
            },
        };

        applier.MaterialiseScriptedAlarms(composition);

        // Only the enabled alarm is materialised; the disabled one is skipped entirely.
        sink.AlarmConditionCalls.ShouldHaveSingleItem()
            .ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700));
    }

    /// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
    /// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment
    /// tags, so a tags-only deploy is no longer a silent no-op).</summary>
    [Fact]
    public void Added_equipment_tags_trigger_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipmentTags = new[]
            {
                new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001"),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        outcome.AddedNodes.ShouldBe(1);
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>Verifies that added Equipment VirtualTags in an otherwise-empty plan trigger an
    /// address-space rebuild (parity with the equipment-tag path — the planner now diffs VirtualTags,
    /// so a VirtualTag-only deploy is no longer a silent no-op).</summary>
    [Fact]
    public void Added_equipment_virtual_tags_trigger_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var plan = EmptyPlan with
        {
            AddedEquipmentVirtualTags = new[]
            {
                new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
                    Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
            },
        };

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        outcome.AddedNodes.ShouldBe(1);
        sink.RebuildCalls.ShouldBe(1);
    }

    /// <summary>Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild.</summary>
    [Fact]
    public void Added_galaxy_tags_trigger_rebuild()
    {
        var sink = new RecordingSink();
        var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);

        var plan = new Phase7Plan(
            AddedEquipment: Array.Empty<EquipmentNode>(),
            RemovedEquipment: Array.Empty<EquipmentNode>(),
            ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
            AddedDrivers: Array.Empty<DriverInstancePlan>(),
            RemovedDrivers: Array.Empty<DriverInstancePlan>(),
            ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
            AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
            RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
            ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
            AddedGalaxyTags: new[]
            {
                new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"),
            },
            RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
            ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());

        var outcome = applier.Apply(plan);

        outcome.RebuildCalled.ShouldBeTrue();
        outcome.AddedNodes.ShouldBe(1);
        sink.RebuildCalls.ShouldBe(1);
    }

    private static Phase7Plan EmptyPlan => new(
        Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
        Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),
        Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<Phase7Plan.AlarmDelta>(),
        Array.Empty<GalaxyTagPlan>(), Array.Empty<GalaxyTagPlan>(), Array.Empty<Phase7Plan.GalaxyTagDelta>());

    private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new(
        AddedEquipment: Array.Empty<EquipmentNode>(),
        RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
        ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
        AddedDrivers: Array.Empty<DriverInstancePlan>(),
        RemovedDrivers: Array.Empty<DriverInstancePlan>(),
        ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
        AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
        RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
        ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
        AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
        RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
        ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());

    private sealed class RecordingSink : IOpcUaAddressSpaceSink
    {
        /// <summary>Gets the queue of alarm condition write calls.</summary>
        public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new();
        /// <summary>Gets the queue of folder creation calls.</summary>
        public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName)> FolderQueue { get; } = new();
        /// <summary>Gets the queue of variable creation calls.</summary>
        public ConcurrentQueue<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableQueue { get; } = new();
        /// <summary>Gets the queue of alarm-condition materialise calls.</summary>
        public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new();
        /// <summary>Gets the number of rebuild calls made on this sink.</summary>
        public int RebuildCalls;

        /// <summary>Gets the list of recorded alarm writes.</summary>
        public List<(string NodeId, AlarmConditionSnapshot State)> AlarmWrites => AlarmQueue.ToList();
        /// <summary>Gets the list of recorded folder creation calls.</summary>
        public List<(string NodeId, string? Parent, string DisplayName)> FolderCalls => FolderQueue.ToList();
        /// <summary>Gets the list of recorded variable creation calls.</summary>
        public List<(string NodeId, string? Parent, string DisplayName, string DataType)> VariableCalls => VariableQueue.ToList();
        /// <summary>Gets the list of recorded alarm-condition materialise calls.</summary>
        public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList();

        /// <summary>Records a value write (no-op in this recording sink).</summary>
        /// <param name="nodeId">The node ID.</param>
        /// <param name="value">The value to write.</param>
        /// <param name="quality">The OPC UA quality.</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 call.</summary>
        /// <param name="alarmNodeId">The alarm node ID.</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)
            => AlarmQueue.Enqueue((alarmNodeId, state));
        /// <summary>Records an alarm-condition materialise call.</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)
            => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity));
        /// <summary>Records a folder creation call.</summary>
        /// <param name="folderNodeId">The folder node ID.</param>
        /// <param name="parentNodeId">The parent folder node ID, if any.</param>
        /// <param name="displayName">The display name for the folder.</param>
        public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
            => FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
        /// <summary>Records a variable creation call.</summary>
        /// <param name="variableNodeId">The variable node ID.</param>
        /// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
        /// <param name="displayName">The display name for 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)
            => VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType));
        /// <summary>Records a rebuild address space call.</summary>
        public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
    }

    private sealed class ThrowingSink : IOpcUaAddressSpaceSink
    {
        private readonly bool _throwOnAlarmWrite;
        /// <summary>Initializes a new instance of the ThrowingSink class.</summary>
        /// <param name="throwOnAlarmWrite">Whether to throw on alarm state writes.</param>
        public ThrowingSink(bool throwOnAlarmWrite) { _throwOnAlarmWrite = throwOnAlarmWrite; }

        /// <summary>Records a value write (no-op in this sink).</summary>
        /// <param name="nodeId">The node ID.</param>
        /// <param name="value">The value to write.</param>
        /// <param name="quality">The OPC UA quality.</param>
        /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
        public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
        /// <summary>Throws an exception if configured to do so.</summary>
        /// <param name="alarmNodeId">The alarm node ID.</param>
        /// <param name="state">The full condition state snapshot.</param>
        /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
        /// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
        public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
        {
            if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
        }
        /// <summary>No-op alarm-condition materialise call.</summary>
        /// <param name="alarmNodeId">The alarm node ID.</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>No-op folder creation call.</summary>
        /// <param name="folderNodeId">The folder node ID.</param>
        /// <param name="parentNodeId">The parent folder node ID, if any.</param>
        /// <param name="displayName">The display name for the folder.</param>
        public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
        /// <summary>No-op variable creation call.</summary>
        /// <param name="variableNodeId">The variable node ID.</param>
        /// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
        /// <param name="displayName">The display name for 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>No-op rebuild address space call.</summary>
        public void RebuildAddressSpace() { }
    }
}
