using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;

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

/// <summary>
///     Integration tests for the F10b production binding: boot a real <see cref="OtOpcUaSdkServer"/>
///     through <see cref="OpcUaApplicationHost"/>, attach a <see cref="SdkAddressSpaceSink"/>,
///     drive <c>WriteValue</c>/<c>WriteAlarmCondition</c>/<c>RebuildAddressSpace</c>, and verify the
///     <see cref="OtOpcUaNodeManager"/> reflects the writes.
/// </summary>
public sealed class SdkAddressSpaceSinkTests : IDisposable
{
    private static CancellationToken Ct => TestContext.Current.CancellationToken;

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

    /// <summary>Verifies that WriteValue creates and updates variables in the OPC UA node manager.</summary>
    [Fact]
    public async Task WriteValue_creates_and_updates_variable_in_node_manager()
    {
        var (host, server) = await BootAsync();
        var sink = new SdkAddressSpaceSink(server.NodeManager!);

        sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
        sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
        sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);

        server.NodeManager!.VariableCount.ShouldBe(2);

        await host.DisposeAsync();
    }

    /// <summary>Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node
    /// distinct from value writes.</summary>
    [Fact]
    public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes()
    {
        var (host, server) = await BootAsync();
        var sink = new SdkAddressSpaceSink(server.NodeManager!);

        sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
        sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);

        server.NodeManager!.VariableCount.ShouldBe(2);

        await host.DisposeAsync();
    }

    /// <summary>B4-WP2 M1: AddReference wires the cross-tree Organizes edge from a UNS reference variable
    /// (source, realm=Uns) to its backing Raw node (target, realm=Raw), bidirectionally, so browse resolves
    /// both ways — and is idempotent.</summary>
    [Fact]
    public async Task AddReference_wires_Organizes_edge_from_UNS_variable_to_Raw_node()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var sink = new SdkAddressSpaceSink(nm);

        // Backing Raw node (device tree) and the UNS reference variable (equipment tree).
        sink.EnsureVariable("MAIN/modbus/dev/grp/temp", parentFolderNodeId: null, displayName: "Temp",
            dataType: "Float", writable: false, realm: AddressSpaceRealm.Raw);
        sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature",
            dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);

        sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
                          "MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);

        var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns);
        var rawVar = nm.TryGetVariable("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
        unsVar.ShouldNotBeNull();
        rawVar.ShouldNotBeNull();

        // Forward Organizes on the UNS variable → Raw node; inverse (OrganizedBy) on the Raw node.
        unsVar.ReferenceExists(ReferenceTypeIds.Organizes, isInverse: false, rawVar.NodeId).ShouldBeTrue();
        rawVar.ReferenceExists(ReferenceTypeIds.Organizes, isInverse: true, unsVar.NodeId).ShouldBeTrue();

        // Idempotent: a second identical call does not duplicate the edge. The UNS variable's only
        // FORWARD Organizes ref is the one we added (its parent link manifests as an inverse ref).
        sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
                          "MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
        nm.GetNodeReferences("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns)
            .Count(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && !r.IsInverse).ShouldBe(1);

        await host.DisposeAsync();
    }

    /// <summary>B4-WP2 M1: a missing endpoint makes AddReference a safe no-op (never throws, no edge added).</summary>
    [Fact]
    public async Task AddReference_with_missing_endpoint_is_a_safe_no_op()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var sink = new SdkAddressSpaceSink(nm);

        sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature",
            dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);

        // Target does not exist ⇒ no-op, no throw.
        Should.NotThrow(() => sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
                                                "MAIN/does/not/exist", AddressSpaceRealm.Raw));

        var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns);
        unsVar.ShouldNotBeNull();
        nm.GetNodeReferences("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns)
            .Any(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && !r.IsInverse).ShouldBeFalse();

        await host.DisposeAsync();
    }

    /// <summary>Verifies that RebuildAddressSpace clears all registered variables.</summary>
    [Fact]
    public async Task RebuildAddressSpace_clears_all_registered_variables()
    {
        var (host, server) = await BootAsync();
        var sink = new SdkAddressSpaceSink(server.NodeManager!);

        sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
        sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
        sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
        server.NodeManager!.VariableCount.ShouldBe(3);

        sink.RebuildAddressSpace();
        server.NodeManager.VariableCount.ShouldBe(0);

        // After rebuild, subsequent writes start fresh.
        sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
        server.NodeManager.VariableCount.ShouldBe(1);

        await host.DisposeAsync();
    }

    /// <summary>Verifies that NullOpcUaAddressSpaceSink does not crash on any call.</summary>
    [Fact]
    public async Task NullOpcUaAddressSpaceSink_does_not_crash_on_any_call()
    {
        // Sanity check that the F10 fallback still works — production callers default to
        // NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
        var sink = NullOpcUaAddressSpaceSink.Instance;
        sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
        sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
        sink.RebuildAddressSpace();
        await Task.CompletedTask;
    }

    /// <summary>T14 — materialises an equipment folder + a real Part 9 AlarmConditionState under it,
    /// then projects active state through WriteAlarmCondition. Asserts the node is a real
    /// <see cref="AlarmConditionState"/>, reachable under the equipment folder, and that
    /// ActiveState/Retain reflect the write. Also inspects which optional Part 9 children
    /// <c>Create</c> auto-builds (the T13 uncertainty) and records the finding inline.</summary>
    [Fact]
    public async Task MaterialiseAlarmCondition_creates_real_condition_node_and_WriteAlarmCondition_updates_it()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var sink = new SdkAddressSpaceSink(nm);

        // Equipment folder must exist first (MaterialiseHierarchy owns this in production).
        sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", AddressSpaceRealm.Uns);

        // Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
        sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);

        nm.AlarmConditionCount.ShouldBe(1);

        var condition = nm.TryGetAlarmCondition("alm-1");
        condition.ShouldNotBeNull();
        // It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
        condition.ShouldBeOfType<OffNormalAlarmState>();
        // v3 dual-namespace: the sink's transitional default realm is UNS, so default-realm nodes live in the
        // UNS namespace (index 3), not the Raw namespace nm.NamespaceIndex (index 2) reports.
        var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
        condition.NodeId.ShouldBe(new NodeId("alm-1", unsNs));

        // Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child).
        condition.Parent.ShouldNotBeNull();
        condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", unsNs));

        // Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false.
        condition.EnabledState.Id.Value.ShouldBeTrue();
        condition.ActiveState.Id.Value.ShouldBeFalse();
        condition.Retain.Value.ShouldBeFalse();

        // --- T13 optional-children finding (RESOLVED by THIS real-server test) ---
        // AckedState is mandatory on AcknowledgeableConditionState so it is always present.
        condition.AckedState.ShouldNotBeNull();
        // FINDING (1.5.378.106): Create auto-builds the FULL optional Part 9 child set from the
        // embedded type definition WITHOUT us pre-setting any property — both ConfirmedState (Confirm
        // sub-state machine) AND ShelvingState (Shelve state machine) come back non-null. This is
        // RICHER than the SDK-notes' [SAMPLE-ONLY] caveat predicted (it suggested we'd have to
        // instantiate optional children ourselves). Net: T15/T16 can drive SetConfirmedState /
        // SetShelvingState directly — no manual child materialisation needed. Asserting both non-null
        // so a future SDK bump that changes auto-build behaviour fails loudly.
        condition.ConfirmedState.ShouldNotBeNull();
        condition.ShelvingState.ShouldNotBeNull();

        // WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
        // BaseDataVariable is minted for the alarm id.
        sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
        nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken

        condition.ActiveState.Id.Value.ShouldBeTrue();
        condition.AckedState.Id.Value.ShouldBeFalse();
        condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain

        // Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
        sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        nm.AlarmConditionCount.ShouldBe(1);

        // RebuildAddressSpace clears the alarm dict too.
        sink.RebuildAddressSpace();
        nm.AlarmConditionCount.ShouldBe(0);

        await host.DisposeAsync();
    }

    /// <summary>An unknown / limit-style AlarmType (with no script-supplied OPC limits) falls back to
    /// the base <see cref="AlarmConditionState"/> per the T13 notes.</summary>
    [Fact]
    public async Task MaterialiseAlarmCondition_unknown_type_falls_back_to_base_condition()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var sink = new SdkAddressSpaceSink(nm);

        sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", AddressSpaceRealm.Uns);
        sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500, realm: AddressSpaceRealm.Uns);

        var condition = nm.TryGetAlarmCondition("alm-x");
        condition.ShouldNotBeNull();
        // Base type exactly — NOT a LimitAlarmState (no limits to populate for a script alarm).
        condition.GetType().ShouldBe(typeof(AlarmConditionState));

        await host.DisposeAsync();
    }

    /// <summary>T15 — the full condition state bridges through WriteAlarmCondition. Materialise a
    /// condition, then push a rich snapshot (active + unacked + disabled + timed-shelved + high severity
    /// + message) and assert every projected child reflects it:
    /// ActiveState/AckedState/EnabledState/ConfirmedState/ShelvingState/Severity/Message/Retain.</summary>
    [Fact]
    public async Task WriteAlarmCondition_projects_full_state_onto_materialised_condition()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var sink = new SdkAddressSpaceSink(nm);

        sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", AddressSpaceRealm.Uns);
        sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);

        var condition = nm.TryGetAlarmCondition("alm-rich");
        condition.ShouldNotBeNull();

        // Rich snapshot: active, unacknowledged, unconfirmed, DISABLED, TIMED-shelved, severity 850, message.
        sink.WriteAlarmCondition(
            "alm-rich",
            new AlarmConditionSnapshot(
                Active: true,
                Acknowledged: false,
                Confirmed: false,
                Enabled: false,
                Shelving: AlarmShelvingKind.Timed,
                Severity: 850,
                Message: "Pressure above limit"),
            DateTime.UtcNow, AddressSpaceRealm.Uns);

        condition.ActiveState.Id.Value.ShouldBeTrue();
        condition.AckedState.Id.Value.ShouldBeFalse();
        condition.EnabledState.Id.Value.ShouldBeFalse();         // disabled
        condition.ConfirmedState!.Id.Value.ShouldBeFalse();      // unconfirmed
        // SetShelvingState(shelved:true, oneShot:false) drives the shelving sub-state machine into a
        // shelved state; CurrentState is populated (TimedShelved).
        condition.ShelvingState!.CurrentState.Id.Value.ShouldNotBeNull();
        // Severity 850 → High bucket (>= 800) → EventSeverity.High (the SDK stamps the ushort value).
        condition.Severity.Value.ShouldBe((ushort)EventSeverity.High);
        condition.Message.Value.Text.ShouldBe("Pressure above limit");
        condition.Retain.Value.ShouldBeTrue();                   // active || !acked ⇒ retain

        await host.DisposeAsync();
    }

    /// <summary>T15 null-guard — a base <see cref="AlarmConditionState"/> (AlarmType "AlarmCondition")
    /// whose optional ConfirmedState child the SDK might not materialise must not throw when a snapshot
    /// sets Confirmed/Shelving. We force the ConfirmedState child to null after materialise to simulate a
    /// leaner child set, then write a snapshot that sets Confirmed=true — the write must be a safe no-op
    /// on that child rather than an NRE, while still projecting the mandatory state.</summary>
    [Fact]
    public async Task WriteAlarmCondition_null_optional_child_does_not_throw()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var sink = new SdkAddressSpaceSink(nm);

        sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", AddressSpaceRealm.Uns);
        sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200, realm: AddressSpaceRealm.Uns);

        var condition = nm.TryGetAlarmCondition("alm-base");
        condition.ShouldNotBeNull();
        condition.GetType().ShouldBe(typeof(AlarmConditionState)); // base type

        // Simulate a build where the optional Confirm sub-state machine was NOT created.
        condition.ConfirmedState = null;

        // Setting Confirmed on a condition with no ConfirmedState child must not throw.
        Should.NotThrow(() =>
            sink.WriteAlarmCondition(
                "alm-base",
                new AlarmConditionSnapshot(
                    Active: true,
                    Acknowledged: false,
                    Confirmed: true,           // targets the (now-null) optional child
                    Enabled: true,
                    Shelving: AlarmShelvingKind.OneShot,
                    Severity: 500,
                    Message: "still works"),
                DateTime.UtcNow, AddressSpaceRealm.Uns));

        // Mandatory state still projected despite the missing optional child.
        condition.ActiveState.Id.Value.ShouldBeTrue();
        condition.AckedState.Id.Value.ShouldBeFalse();
        condition.Message.Value.Text.ShouldBe("still works");

        await host.DisposeAsync();
    }

    /// <summary>T16 — each engine-driven <see cref="OtOpcUaNodeManager.WriteAlarmCondition"/> on a
    /// materialised condition fires a real Part 9 condition event, stamping a FRESH per-event
    /// <c>EventId</c> (GUID bytes). Part 9 requires a unique EventId per event so inbound
    /// Acknowledge/Confirm (T17) can correlate back to the exact event being acked.
    /// <para>
    /// We assert EventId-freshness (non-null + changed from its materialise-time value, and a second
    /// write yields a DIFFERENT EventId) plus no-throw. The node manager exposes no in-process hook to
    /// observe <c>ReportEvent</c> delivery without standing up a full client subscription + monitored
    /// item, so actual event DELIVERY to a subscribing client is proven live in T19 (Client.CLI) rather
    /// than faked here — see the comment below.
    /// </para></summary>
    [Fact]
    public async Task WriteAlarmCondition_fires_event_with_fresh_EventId_per_transition()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var sink = new SdkAddressSpaceSink(nm);

        sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt", AddressSpaceRealm.Uns);
        sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);

        var condition = nm.TryGetAlarmCondition("alm-evt");
        condition.ShouldNotBeNull();

        // EventId at materialise time (Create stamps an initial one). Capture a COPY — the live byte[]
        // reference is reused, so we compare snapshots, not the same array instance.
        var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();

        // First engine-driven transition → fires an event with a fresh EventId.
        sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);

        var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
        firstEventId.ShouldNotBeNull();
        firstEventId!.Length.ShouldBe(16); // GUID bytes
        // Changed from the materialise-time value (a real event fired, not the create-time stamp).
        if (materialiseEventId is not null)
        {
            firstEventId.ShouldNotBe(materialiseEventId);
        }

        // Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
        sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow, AddressSpaceRealm.Uns);

        var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
        secondEventId.ShouldNotBeNull();
        secondEventId!.ShouldNotBe(firstEventId);

        // NOTE: event DELIVERY (a subscribing client actually receiving these ActiveState/AckedState
        // transitions on the equipment folder) is an integration concern proven LIVE in T19 via the
        // Client.CLI alarm subscription — not asserted here, and deliberately NOT faked. This unit-level
        // test proves the firing path is exercised (no throw) and the Part 9 EventId-freshness invariant
        // that T17's ack routing depends on.

        await host.DisposeAsync();
    }

    /// <summary>T16 — firing an event must not break the state projection even when ReportEvent could
    /// fail. We can't easily force ReportEvent to throw in-process, but we CAN prove the projection +
    /// firing path is exception-safe end-to-end across repeated transitions and that the condition's
    /// mandatory state stays correct after firing.</summary>
    [Fact]
    public async Task WriteAlarmCondition_firing_does_not_disturb_projected_state()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var sink = new SdkAddressSpaceSink(nm);

        sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2", AddressSpaceRealm.Uns);
        sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);

        var condition = nm.TryGetAlarmCondition("alm-evt2");
        condition.ShouldNotBeNull();

        // Drive several transitions; each fires an event AND projects state. State must survive firing.
        Should.NotThrow(() =>
        {
            sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
            sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow, AddressSpaceRealm.Uns);
            sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
        });

        // Final projected state is intact after the last firing.
        condition!.ActiveState.Id.Value.ShouldBeFalse();
        condition.AckedState.Id.Value.ShouldBeTrue();
        condition.Message.Value.Text.ShouldBe("cleared");
        condition.Retain.Value.ShouldBeFalse(); // inactive && acked ⇒ no retain

        await host.DisposeAsync();
    }

    /// <summary>T20 — the inbound double-emit is suppressed by the delta-gate. We simulate the REAL
    /// inbound sequence: a client Acknowledge whose T18 gate returned Good causes the SDK to apply the
    /// acked state to the node and auto-fire its OWN event (E2) WITHOUT going through WriteAlarmCondition.
    /// We reproduce that by applying the acked state directly onto the live AlarmConditionState the way
    /// the SDK would. THEN the engine re-projects the same logical transition through WriteAlarmCondition
    /// with the matching snapshot — and because that snapshot equals the node's current state, the
    /// delta-gate must fire NO event (no E3). We prove "no event fired" by asserting the condition's
    /// EventId is UNCHANGED across the WriteAlarmCondition call (ReportConditionEvent always restamps a
    /// fresh GUID EventId when it fires — same probe the T16 event test uses).</summary>
    [Fact]
    public async Task WriteAlarmCondition_suppresses_event_when_snapshot_equals_node_current_state()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var sink = new SdkAddressSpaceSink(nm);

        sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack", AddressSpaceRealm.Uns);
        sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);

        var condition = nm.TryGetAlarmCondition("alm-ack");
        condition.ShouldNotBeNull();

        // Drive the alarm active+unacked through the engine path (a genuine transition → fires).
        sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
        condition!.ActiveState.Id.Value.ShouldBeTrue();
        condition.AckedState.Id.Value.ShouldBeFalse();

        // === Simulate the SDK-applied inbound Acknowledge (E2) ===
        // After T18's gate returns Good, the SDK applies the acked state to the node and auto-fires its
        // own event — directly on the node, BYPASSING WriteAlarmCondition. Reproduce that node mutation.
        lock (nm.Lock)
        {
            condition.SetAcknowledgedState(nm.SystemContext, true);
            condition.Message.Value = new LocalizedText("active");
            condition.ClearChangeMasks(nm.SystemContext, includeChildren: true);
        }
        condition.AckedState.Id.Value.ShouldBeTrue();

        // Capture the EventId AFTER the SDK-applied ack but BEFORE the engine re-projection.
        var beforeReProject = (byte[]?)condition.EventId.Value?.Clone();

        // === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) ===
        // Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees
        // no change ⇒ NO event fires.
        sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);

        var afterReProject = (byte[]?)condition.EventId.Value?.Clone();
        // EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed.
        afterReProject.ShouldBe(beforeReProject);

        // The projection still applied (state is intact) — only the redundant event was suppressed.
        condition.ActiveState.Id.Value.ShouldBeTrue();
        condition.AckedState.Id.Value.ShouldBeTrue();

        await host.DisposeAsync();
    }

    /// <summary>T20 — genuine engine-driven transitions still fire exactly one event, and a second
    /// IDENTICAL WriteAlarmCondition (no delta vs the node's now-current state) fires zero more. We use
    /// the EventId-changed-on-fire probe: a fire restamps a fresh GUID EventId, a suppress leaves it
    /// untouched.</summary>
    [Fact]
    public async Task WriteAlarmCondition_fires_on_delta_and_suppresses_identical_reprojection()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var sink = new SdkAddressSpaceSink(nm);

        sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta", AddressSpaceRealm.Uns);
        sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);

        var condition = nm.TryGetAlarmCondition("alm-delta");
        condition.ShouldNotBeNull();

        var beforeFirst = (byte[]?)condition!.EventId.Value?.Clone();

        // Genuine transition: snapshot (active, unacked) differs from the materialise state
        // (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes).
        sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
        var afterFirst = (byte[]?)condition.EventId.Value?.Clone();
        afterFirst.ShouldNotBeNull();
        afterFirst!.ShouldNotBe(beforeFirst); // fired

        // Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more
        // events (EventId unchanged from the first fire).
        sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
        var afterSecond = (byte[]?)condition.EventId.Value?.Clone();
        afterSecond.ShouldBe(afterFirst); // suppressed

        // A FURTHER genuine transition (clear) differs again ⇒ fires once more.
        sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
        var afterThird = (byte[]?)condition.EventId.Value?.Clone();
        afterThird.ShouldNotBe(afterSecond); // fired

        await host.DisposeAsync();
    }

    /// <summary>T20 — direct unit test of the pure fire-vs-suppress decision seam
    /// (<see cref="OtOpcUaNodeManager.ShouldFireConditionEvent"/>). Equal states ⇒ suppress; any single
    /// field differing ⇒ fire. This pins the gate logic independent of the booted server.</summary>
    [Fact]
    public void ShouldFireConditionEvent_fires_only_on_a_field_delta()
    {
        var baseState = new OtOpcUaNodeManager.AlarmConditionDelta(
            Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
            Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: "m",
            Quality: StatusCodes.Good);

        // Equal ⇒ suppress (this is the inbound double-emit case in pure form).
        OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState).ShouldBeFalse();
        OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { }).ShouldBeFalse();

        // Each single-field difference ⇒ fire.
        OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Active = false }).ShouldBeTrue();
        OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Acknowledged = true }).ShouldBeTrue();
        OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Confirmed = false }).ShouldBeTrue();
        OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Enabled = false }).ShouldBeTrue();
        OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Shelving = AlarmShelvingKind.Timed }).ShouldBeTrue();
        OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { MappedSeverity = 900 }).ShouldBeTrue();
        OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Message = "other" }).ShouldBeTrue();
        // #477 — a quality-only change (e.g. source going comms-lost, no state change) is a genuine delta.
        OtOpcUaNodeManager.ShouldFireConditionEvent(baseState, baseState with { Quality = StatusCodes.Bad }).ShouldBeTrue();
    }

    /// <summary>T20 — null-vs-empty Message normalization. Both snapshot.Message = null and a live node
    /// whose Message.Value.Text = null normalize to <see cref="string.Empty"/> in
    /// <see cref="OtOpcUaNodeManager.AlarmConditionDelta"/>, so a snapshot whose Message is null does NOT
    /// produce a phantom delta against a freshly-materialised node (whose Message is the displayName but
    /// whose Text reads back as the displayName string, and a snapshot that explicitly passes an empty
    /// string equally produces no delta vs a null-Message node). This pins the normalization so a
    /// future change to the null-coalescing in ReadConditionDelta / ToConditionDelta is caught here.</summary>
    [Fact]
    public void ShouldFireConditionEvent_null_and_empty_message_normalize_identically()
    {
        // Both null and "" collapse to string.Empty in AlarmConditionDelta — they are the same delta value.
        var withNull = new OtOpcUaNodeManager.AlarmConditionDelta(
            Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
            Shelving: AlarmShelvingKind.Unshelved, MappedSeverity: 100, Message: string.Empty,
            Quality: StatusCodes.Good);

        var withEmpty = withNull with { Message = string.Empty };

        // null-message snapshot (normalised to "") vs empty-message node (normalised to "") ⇒ no delta.
        OtOpcUaNodeManager.ShouldFireConditionEvent(withNull, withEmpty).ShouldBeFalse();
        OtOpcUaNodeManager.ShouldFireConditionEvent(withEmpty, withNull).ShouldBeFalse();

        // A non-empty message IS a delta vs the empty baseline.
        OtOpcUaNodeManager.ShouldFireConditionEvent(withNull, withNull with { Message = "pressure high" }).ShouldBeTrue();
    }

    /// <summary>Builds a test <see cref="AlarmConditionSnapshot"/> with sensible defaults so each call
    /// site only specifies the fields it cares about.</summary>
    private static AlarmConditionSnapshot Snapshot(
        bool active = false,
        bool acknowledged = true,
        bool confirmed = true,
        bool enabled = true,
        AlarmShelvingKind shelving = AlarmShelvingKind.Unshelved,
        ushort severity = 500,
        string message = "test") =>
        new(active, acknowledged, confirmed, enabled, shelving, severity, message);

    private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
    {
        var host = new OpcUaApplicationHost(
            new OpcUaApplicationHostOptions
            {
                ApplicationName = "OtOpcUa.SinkTest",
                ApplicationUri = $"urn:OtOpcUa.SinkTest:{Guid.NewGuid():N}",
                OpcUaPort = AllocateFreePort(),
                PublicHostname = "localhost",
                PkiStoreRoot = _pkiRoot,
            },
            NullLogger<OpcUaApplicationHost>.Instance);

        var server = new OtOpcUaSdkServer();
        await host.StartAsync(server, Ct);
        return (host, server);
    }

    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>Cleans up the PKI root directory.</summary>
    public void Dispose()
    {
        if (Directory.Exists(_pkiRoot))
        {
            try { Directory.Delete(_pkiRoot, recursive: true); }
            catch { /* best-effort cleanup */ }
        }
    }
}
