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

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

/// <summary>
///     T18 — inbound OPC UA Part 9 alarm method handlers. Boots a real <see cref="OtOpcUaSdkServer"/>,
///     materialises a condition, sets a capturing <see cref="OtOpcUaNodeManager.AlarmCommandRouter"/>,
///     then drives the condition's public SDK handler delegates (<c>OnAcknowledge</c>/<c>OnShelve</c>)
///     directly. Verifies (a) the <c>AlarmAck</c> role gate fails closed and (b) the routed
///     <see cref="AlarmCommand"/> is mapped correctly.
/// </summary>
public sealed class AlarmCommandRouterTests : IDisposable
{
    private static CancellationToken Ct => TestContext.Current.CancellationToken;

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

    /// <summary>With the <c>AlarmAck</c> role present, OnAcknowledge returns Good and routes exactly one
    /// correctly-mapped AlarmCommand (AlarmId = ScriptedAlarmId, Operation, User, Comment).</summary>
    [Fact]
    public async Task OnAcknowledge_with_AlarmAck_returns_good_and_routes_mapped_command()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-1");
        condition.ShouldNotBeNull();
        condition!.OnAcknowledge.ShouldNotBeNull();

        var ctx = SessionContext(server, "alice", "ReadOnly", OpcUaDataPlaneRoles.AlarmAck);
        var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ack note"));

        result.ShouldBe(ServiceResult.Good);
        captured.Count.ShouldBe(1);
        var cmd = captured[0];
        cmd.AlarmId.ShouldBe("alm-1");                 // == ScriptedAlarmId (condition NodeId identifier)
        cmd.Operation.ShouldBe("Acknowledge");
        cmd.User.ShouldBe("alice");
        cmd.Comment.ShouldBe("ack note");
        cmd.UnshelveAtUtc.ShouldBeNull();

        await host.DisposeAsync();
    }

    /// <summary>Without the <c>AlarmAck</c> role, OnAcknowledge is vetoed (BadUserAccessDenied) and the
    /// router is NOT invoked — the gate fails closed and never mutates state.</summary>
    [Fact]
    public async Task OnAcknowledge_without_AlarmAck_returns_denied_and_does_not_route()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-2");
        condition.ShouldNotBeNull();

        var ctx = SessionContext(server, "bob", "ReadOnly"); // no AlarmAck
        var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope"));

        result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
        captured.ShouldBeEmpty();

        await host.DisposeAsync();
    }

    /// <summary>A null identity (anonymous / no role-carrying identity on the context) is denied and does
    /// not route — the gate fails closed on a missing principal.</summary>
    [Fact]
    public async Task OnAcknowledge_with_null_identity_returns_denied_and_does_not_route()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-3");
        condition.ShouldNotBeNull();

        // ServerSystemContext with no UserIdentity set ⇒ (context as ISessionOperationContext).UserIdentity is null.
        var ctx = new ServerSystemContext(server.CurrentInstance);
        var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x"));

        result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
        captured.ShouldBeEmpty();

        await host.DisposeAsync();
    }

    /// <summary>OnConfirm with AlarmAck maps to the Confirm operation.</summary>
    [Fact]
    public async Task OnConfirm_with_AlarmAck_routes_confirm_operation()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-c");
        condition.ShouldNotBeNull();
        condition!.OnConfirm.ShouldNotBeNull();

        var ctx = SessionContext(server, "carol", OpcUaDataPlaneRoles.AlarmAck);
        var result = condition.OnConfirm!(ctx, condition, EventIdBytes(), comment: null);

        result.ShouldBe(ServiceResult.Good);
        captured.Count.ShouldBe(1);
        captured[0].Operation.ShouldBe("Confirm");
        captured[0].Comment.ShouldBeNull(); // null LocalizedText ⇒ null Comment
        captured[0].User.ShouldBe("carol");

        await host.DisposeAsync();
    }

    /// <summary>OnAddComment with AlarmAck maps to the AddComment operation, carrying the comment text.</summary>
    [Fact]
    public async Task OnAddComment_with_AlarmAck_routes_add_comment_operation()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-ac");
        condition.ShouldNotBeNull();
        condition!.OnAddComment.ShouldNotBeNull();

        var ctx = SessionContext(server, "dave", OpcUaDataPlaneRoles.AlarmAck);
        var result = condition.OnAddComment!(ctx, condition, EventIdBytes(), new LocalizedText("look here"));

        result.ShouldBe(ServiceResult.Good);
        captured.Count.ShouldBe(1);
        captured[0].Operation.ShouldBe("AddComment");
        captured[0].Comment.ShouldBe("look here");

        await host.DisposeAsync();
    }

    /// <summary>OnShelve with oneShot=true maps to OneShotShelve with no UnshelveAtUtc.</summary>
    [Fact]
    public async Task OnShelve_oneshot_routes_one_shot_shelve_with_no_expiry()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-s1");
        condition.ShouldNotBeNull();
        condition!.OnShelve.ShouldNotBeNull();

        var ctx = SessionContext(server, "erin", OpcUaDataPlaneRoles.AlarmAck);
        // SDK OneShotShelve invokes OnShelve(ctx, alarm, shelving:true, oneShot:true, 0.0).
        var result = condition.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0);

        result.ShouldBe(ServiceResult.Good);
        captured.Count.ShouldBe(1);
        captured[0].Operation.ShouldBe("OneShotShelve");
        captured[0].UnshelveAtUtc.ShouldBeNull();

        await host.DisposeAsync();
    }

    /// <summary>OnShelve with oneShot=false and shelving=true maps to TimedShelve with UnshelveAtUtc
    /// derived from the millisecond Duration (UtcNow + ms).</summary>
    [Fact]
    public async Task OnShelve_timed_routes_timed_shelve_with_ms_derived_expiry()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-s2");
        condition.ShouldNotBeNull();

        var ctx = SessionContext(server, "frank", OpcUaDataPlaneRoles.AlarmAck);
        const double shelvingTimeMs = 60_000.0; // OPC UA Duration = milliseconds
        var before = DateTime.UtcNow;
        // SDK TimedShelve invokes OnShelve(ctx, alarm, shelving:true, oneShot:false, shelvingTime).
        var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: false, shelvingTime: shelvingTimeMs);
        var after = DateTime.UtcNow;

        result.ShouldBe(ServiceResult.Good);
        captured.Count.ShouldBe(1);
        captured[0].Operation.ShouldBe("TimedShelve");
        captured[0].UnshelveAtUtc.ShouldNotBeNull();
        // UtcNow + 60s, bracketed by the before/after capture instants.
        captured[0].UnshelveAtUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddMilliseconds(shelvingTimeMs));
        captured[0].UnshelveAtUtc!.Value.ShouldBeLessThanOrEqualTo(after.AddMilliseconds(shelvingTimeMs));

        await host.DisposeAsync();
    }

    /// <summary>OnShelve with shelving=false maps to Unshelve with no UnshelveAtUtc.</summary>
    [Fact]
    public async Task OnShelve_unshelve_routes_unshelve_operation()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-s3");
        condition.ShouldNotBeNull();

        var ctx = SessionContext(server, "grace", OpcUaDataPlaneRoles.AlarmAck);
        // SDK Unshelve invokes OnShelve(ctx, alarm, shelving:false, oneShot:false, 0.0).
        var result = condition!.OnShelve!(ctx, condition, shelving: false, oneShot: false, shelvingTime: 0.0);

        result.ShouldBe(ServiceResult.Good);
        captured.Count.ShouldBe(1);
        captured[0].Operation.ShouldBe("Unshelve");
        captured[0].UnshelveAtUtc.ShouldBeNull();

        await host.DisposeAsync();
    }

    /// <summary>OnShelve without AlarmAck is vetoed and does not route.</summary>
    [Fact]
    public async Task OnShelve_without_AlarmAck_returns_denied_and_does_not_route()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-s4");
        condition.ShouldNotBeNull();

        var ctx = SessionContext(server, "heidi", "ReadOnly"); // no AlarmAck
        var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0);

        result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
        captured.ShouldBeEmpty();

        await host.DisposeAsync();
    }

    /// <summary>OnTimedUnshelve fires with the SDK's system context (no session, no user identity) —
    /// the real SDK path when a TimedShelve duration expires. The gate must NOT veto: the result must be
    /// Good, the router must be invoked exactly once with Operation == "Unshelve", and no UnshelveAtUtc is
    /// carried.
    /// <para>
    /// Regression (OpcUaServer-007): the routed command's <see cref="AlarmCommand.User"/> must be a
    /// NON-EMPTY system user (<c>"system"</c>). The engine's <c>Part9StateMachine.ApplyUnshelve</c> rejects
    /// a null/whitespace user with <c>ArgumentException("User required.")</c>; that exception is swallowed
    /// downstream, so an empty user made the timed auto-unshelve SILENTLY no-op — a TimedShelve would never
    /// auto-expire. Asserting a non-empty user here pins the value the state machine requires.
    /// </para></summary>
    [Fact]
    public async Task OnTimedUnshelve_with_system_context_returns_good_and_routes_unshelve()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-tu");
        condition.ShouldNotBeNull();
        condition!.OnTimedUnshelve.ShouldNotBeNull();

        // Reproduce the real SDK path: system context has no session and no UserIdentity — exactly what
        // the SDK's internal timer fires the callback with when a TimedShelve duration expires.
        var ctx = new ServerSystemContext(server.CurrentInstance); // no UserIdentity set
        var result = condition.OnTimedUnshelve!(ctx, condition);

        result.ShouldBe(ServiceResult.Good);
        captured.Count.ShouldBe(1);
        var cmd = captured[0];
        cmd.AlarmId.ShouldBe("alm-tu");    // == ScriptedAlarmId / condition NodeId identifier
        cmd.Operation.ShouldBe("Unshelve");
        // System-initiated (no client principal), but the user MUST be non-empty so ApplyUnshelve accepts it.
        cmd.User.ShouldNotBeNullOrWhiteSpace();
        cmd.User.ShouldBe("system");
        cmd.UnshelveAtUtc.ShouldBeNull();

        await host.DisposeAsync();
    }

    /// <summary>H4 — OnEnableDisable with <c>enabling:false</c> and the <c>AlarmAck</c> role on a SCRIPTED
    /// condition returns Good and routes exactly one <see cref="AlarmCommand"/> with Operation == "Disable"
    /// and User == the caller's DisplayName.</summary>
    [Fact]
    public async Task OnEnableDisable_disabling_with_AlarmAck_routes_Disable()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-ed1");
        condition.ShouldNotBeNull();
        condition!.OnEnableDisable.ShouldNotBeNull();

        var ctx = SessionContext(server, "ivan", OpcUaDataPlaneRoles.AlarmAck);
        // SDK Disable invokes OnEnableDisable(ctx, condition, enabling:false).
        var result = condition.OnEnableDisable!(ctx, condition, enabling: false);

        result.ShouldBe(ServiceResult.Good);
        captured.Count.ShouldBe(1);
        captured[0].AlarmId.ShouldBe("alm-ed1");
        captured[0].Operation.ShouldBe("Disable");
        captured[0].User.ShouldBe("ivan");
        captured[0].UnshelveAtUtc.ShouldBeNull();

        await host.DisposeAsync();
    }

    /// <summary>H4 — OnEnableDisable with <c>enabling:true</c> maps to the Enable operation.</summary>
    [Fact]
    public async Task OnEnableDisable_enabling_routes_Enable()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-ed2");
        condition.ShouldNotBeNull();
        condition!.OnEnableDisable.ShouldNotBeNull();

        var ctx = SessionContext(server, "jane", OpcUaDataPlaneRoles.AlarmAck);
        // SDK Enable invokes OnEnableDisable(ctx, condition, enabling:true).
        var result = condition.OnEnableDisable!(ctx, condition, enabling: true);

        result.ShouldBe(ServiceResult.Good);
        captured.Count.ShouldBe(1);
        captured[0].Operation.ShouldBe("Enable");
        captured[0].User.ShouldBe("jane");

        await host.DisposeAsync();
    }

    /// <summary>H4 — OnEnableDisable from an anonymous / role-less identity is vetoed
    /// (BadUserAccessDenied) and the router is NOT invoked — the gate fails closed.</summary>
    [Fact]
    public async Task OnEnableDisable_anonymous_is_denied()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-ed3");
        condition.ShouldNotBeNull();

        // ServerSystemContext with no UserIdentity ⇒ anonymous / no role-carrying identity.
        var ctx = new ServerSystemContext(server.CurrentInstance);
        var result = condition!.OnEnableDisable!(ctx, condition, enabling: false);

        result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
        captured.ShouldBeEmpty();

        await host.DisposeAsync();
    }

    /// <summary>H4 — OnEnableDisable on a NATIVE (driver-fed) condition returns BadNotSupported and does
    /// NOT route — native conditions have no engine enable/disable surface (Phase 3 decision #2). The gate
    /// short-circuits on the native flag before the role check, so even an AlarmAck caller gets BadNotSupported.</summary>
    [Fact]
    public async Task OnEnableDisable_on_native_condition_is_BadNotSupported()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        var captured = new List<AlarmCommand>();
        nm.AlarmCommandRouter = captured.Add;

        nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-ed4");
        condition.ShouldNotBeNull();
        condition!.OnEnableDisable.ShouldNotBeNull();

        var ctx = SessionContext(server, "kara", OpcUaDataPlaneRoles.AlarmAck);
        var result = condition.OnEnableDisable!(ctx, condition, enabling: false);

        result.StatusCode.Code.ShouldBe(StatusCodes.BadNotSupported);
        captured.ShouldBeEmpty();

        await host.DisposeAsync();
    }

    /// <summary>H6c — a NATIVE (driver-fed) condition's Acknowledge routes to <see
    /// cref="OtOpcUaNodeManager.NativeAlarmAckRouter"/> (the driver-bound seam), NOT the scripted
    /// <see cref="OtOpcUaNodeManager.AlarmCommandRouter"/>. With the AlarmAck role present, OnAcknowledge
    /// returns Good, the captured <see cref="NativeAlarmAck"/> carries the condition NodeId, the operator
    /// DisplayName, and the comment text, and the scripted router is NOT invoked.</summary>
    [Fact]
    public async Task Native_OnAcknowledge_routes_to_NativeAlarmAckRouter_not_scripted()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        var scripted = new List<AlarmCommand>();
        var native = new List<NativeAlarmAck>();
        nm.AlarmCommandRouter = scripted.Add;
        nm.NativeAlarmAckRouter = native.Add;

        nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-nak1");
        condition.ShouldNotBeNull();
        condition!.OnAcknowledge.ShouldNotBeNull();

        var ctx = SessionContext(server, "nora", OpcUaDataPlaneRoles.AlarmAck);
        var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("native ack"));

        result.ShouldBe(ServiceResult.Good);
        native.Count.ShouldBe(1);
        native[0].ConditionNodeId.ShouldBe("alm-nak1");   // folder-scoped condition NodeId identifier
        native[0].OperatorUser.ShouldBe("nora");
        native[0].Comment.ShouldBe("native ack");
        scripted.ShouldBeEmpty();                          // scripted engine NOT involved

        await host.DisposeAsync();
    }

    /// <summary>H6c — a SCRIPTED condition's Acknowledge still routes through the scripted
    /// <see cref="OtOpcUaNodeManager.AlarmCommandRouter"/> (Operation == "Acknowledge"); the native
    /// router is NOT invoked.</summary>
    [Fact]
    public async Task Scripted_OnAcknowledge_still_uses_AlarmCommandRouter()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        var scripted = new List<AlarmCommand>();
        var native = new List<NativeAlarmAck>();
        nm.AlarmCommandRouter = scripted.Add;
        nm.NativeAlarmAckRouter = native.Add;

        nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-nak2");
        condition.ShouldNotBeNull();
        condition!.OnAcknowledge.ShouldNotBeNull();

        var ctx = SessionContext(server, "owen", OpcUaDataPlaneRoles.AlarmAck);
        var result = condition.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("scripted ack"));

        result.ShouldBe(ServiceResult.Good);
        scripted.Count.ShouldBe(1);
        scripted[0].AlarmId.ShouldBe("alm-nak2");
        scripted[0].Operation.ShouldBe("Acknowledge");
        scripted[0].User.ShouldBe("owen");
        scripted[0].Comment.ShouldBe("scripted ack");
        native.ShouldBeEmpty();                            // native seam NOT involved

        await host.DisposeAsync();
    }

    /// <summary>H6c — a NATIVE condition's Acknowledge from a non-null identity that lacks the
    /// <c>AlarmAck</c> role is vetoed (BadUserAccessDenied) and NEITHER router is invoked — the gate
    /// fails closed before any route, exactly as for scripted conditions.</summary>
    [Fact]
    public async Task Native_OnAcknowledge_without_AlarmAck_returns_denied_and_routes_nothing()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        var scripted = new List<AlarmCommand>();
        var native = new List<NativeAlarmAck>();
        nm.AlarmCommandRouter = scripted.Add;
        nm.NativeAlarmAckRouter = native.Add;

        nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-nak4");
        condition.ShouldNotBeNull();

        var ctx = SessionContext(server, "pete", "ReadOnly"); // non-null identity, but no AlarmAck
        var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope"));

        result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
        native.ShouldBeEmpty();
        scripted.ShouldBeEmpty();

        await host.DisposeAsync();
    }

    /// <summary>H6c — a NATIVE condition's Acknowledge from an anonymous / role-less identity is vetoed
    /// (BadUserAccessDenied) and NEITHER router is invoked — the gate fails closed before any route.</summary>
    [Fact]
    public async Task Native_OnAcknowledge_anonymous_is_denied()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        var scripted = new List<AlarmCommand>();
        var native = new List<NativeAlarmAck>();
        nm.AlarmCommandRouter = scripted.Add;
        nm.NativeAlarmAckRouter = native.Add;

        nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-nak3");
        condition.ShouldNotBeNull();

        // ServerSystemContext with no UserIdentity ⇒ anonymous / no role-carrying identity.
        var ctx = new ServerSystemContext(server.CurrentInstance);
        var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x"));

        result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
        native.ShouldBeEmpty();
        scripted.ShouldBeEmpty();

        await host.DisposeAsync();
    }

    /// <summary>A null router is a safe no-op: handler still gates + returns Good, just routes nowhere.</summary>
    [Fact]
    public async Task OnAcknowledge_with_null_router_is_safe_noop_and_returns_good()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;
        // No router set (default null).

        nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
        var condition = nm.TryGetAlarmCondition("alm-nr");
        condition.ShouldNotBeNull();

        var ctx = SessionContext(server, "judy", OpcUaDataPlaneRoles.AlarmAck);
        var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ok"));

        result.ShouldBe(ServiceResult.Good);

        await host.DisposeAsync();
    }

    /// <summary>H6a — a condition materialised with <c>isNative:true</c> is tracked so later inbound-ack
    /// routing can dispatch its Acknowledge to the driver rather than the scripted engine.</summary>
    [Fact]
    public async Task Native_materialise_is_tracked_as_native()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);

        nm.IsNativeAlarmNode("a1").ShouldBeTrue();

        await host.DisposeAsync();
    }

    /// <summary>H6a — a scripted condition (the default, <c>isNative:false</c>) is NOT tracked as native.</summary>
    [Fact]
    public async Task Scripted_materialise_is_not_native()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);

        nm.IsNativeAlarmNode("a2").ShouldBeFalse();

        await host.DisposeAsync();
    }

    /// <summary>H6a lifecycle — the native flag is NOT sticky across a rebuild + kind flip. Materialise an
    /// id as native (flag true), <see cref="OtOpcUaNodeManager.RebuildAddressSpace"/> (which clears the
    /// folder + condition + native-flag sets), re-ensure the equipment folder, then re-materialise the
    /// SAME id as scripted (<c>isNative:false</c>). The flag must read false — the rebuild dropped it and
    /// the scripted re-materialise did NOT re-add it. Guards against a stale-native leak that would route a
    /// now-scripted alarm's inbound ack to the driver.</summary>
    [Fact]
    public async Task Native_flag_clears_on_rebuild_then_kind_flip()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
        nm.IsNativeAlarmNode("a1").ShouldBeTrue();

        // RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured
        // before the same id can be re-materialised (ResolveParentFolder needs the parent back).
        nm.RebuildAddressSpace();
        nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);

        nm.IsNativeAlarmNode("a1").ShouldBeFalse();

        await host.DisposeAsync();
    }

    /// <summary>H6a lifecycle (converse) — a scripted condition can flip TO native across a rebuild.
    /// Materialise an id as scripted (flag false), rebuild, re-ensure the folder, re-materialise the SAME
    /// id as native (<c>isNative:true</c>). The flag must read true — the native re-materialise re-adds it
    /// cleanly after the rebuild cleared the slate.</summary>
    [Fact]
    public async Task Scripted_flag_can_flip_to_native_across_rebuild()
    {
        var (host, server) = await BootAsync();
        var nm = server.NodeManager!;

        nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
        nm.IsNativeAlarmNode("a1").ShouldBeFalse();

        nm.RebuildAddressSpace();
        nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
        nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);

        nm.IsNativeAlarmNode("a1").ShouldBeTrue();

        await host.DisposeAsync();
    }

    /// <summary>Builds a <see cref="ServerSystemContext"/> (an <see cref="ISessionOperationContext"/>)
    /// carrying a <see cref="RoleCarryingUserIdentity"/> with the given name + roles — the exact seam the
    /// gate reads via <c>(context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity</c>.</summary>
    private static ServerSystemContext SessionContext(OtOpcUaSdkServer server, string user, params string[] roles)
    {
        var identity = new RoleCarryingUserIdentity(
            new UserNameIdentityToken { UserName = user },
            roles);
        return new ServerSystemContext(server.CurrentInstance) { UserIdentity = identity };
    }

    private static byte[] EventIdBytes() => Guid.NewGuid().ToByteArray();

    private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
    {
        var host = new OpcUaApplicationHost(
            new OpcUaApplicationHostOptions
            {
                ApplicationName = "OtOpcUa.AlarmCmdTest",
                ApplicationUri = $"urn:OtOpcUa.AlarmCmdTest:{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 */ }
        }
    }
}
