using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
using ZB.MOM.WW.MxGateway.Worker.Sta;
using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;

namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;

public sealed class MxAccessCommandExecutorTests
{
    /// <summary>Verifies that Register command calls MXAccess on the STA thread and preserves the server handle.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Register_CallsMxAccessOnStaAndPreservesServerHandle()
    {
        FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 42));
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateRegisterCommand("correlation-1", "client-a"));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(0, reply.Hresult);
        Assert.Equal(42, reply.Register.ServerHandle);
        Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType);
        Assert.Equal(42, reply.ReturnValue.Int32Value);
        Assert.Equal(runtime.StaThreadId, factory.FakeComObject.RegisterThreadId);
        Assert.Equal("client-a", factory.FakeComObject.RegisteredClientName);

        RegisteredServerHandle registeredServerHandle = Assert.Single(
            await session.GetRegisteredServerHandlesAsync());
        Assert.Equal(42, registeredServerHandle.ServerHandle);
        Assert.Equal("client-a", registeredServerHandle.ClientName);
    }

    /// <summary>Verifies that Unregister command calls MXAccess on the STA thread and removes the tracked server handle.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Unregister_CallsMxAccessOnStaAndRemovesTrackedServerHandle()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 43);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(CreateUnregisterCommand("unregister", 43));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(43, fakeComObject.UnregisteredServerHandle);
        Assert.Equal(runtime.StaThreadId, fakeComObject.UnregisterThreadId);
        Assert.Empty(await session.GetRegisteredServerHandlesAsync());
    }

    /// <summary>Verifies that Unregister preserves the HResult when MXAccess throws and does not rewrite the failure.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_UnregisterWhenMxAccessThrows_PreservesHResultAndDoesNotRewriteFailure()
    {
        const int hresult = unchecked((int)0x80070057);
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 44,
            unregisterException: new COMException("Invalid handle.", hresult));
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-failure", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(CreateUnregisterCommand("invalid-unregister", 44));

        Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(hresult, reply.Hresult);
        Assert.Contains("0x80070057", reply.DiagnosticMessage);
        Assert.Equal(44, fakeComObject.UnregisteredServerHandle);

        RegisteredServerHandle registeredServerHandle = Assert.Single(
            await session.GetRegisteredServerHandlesAsync());
        Assert.Equal(44, registeredServerHandle.ServerHandle);
    }

    /// <summary>Verifies that AddItem command calls MXAccess on the STA thread and tracks the item handle.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_AddItem_CallsMxAccessOnStaAndTracksItemHandle()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 46,
            addItemHandle: 501);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-add", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(CreateAddItemCommand(
            "add-item",
            46,
            "Galaxy.Tag.Value"));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(0, reply.Hresult);
        Assert.Equal(501, reply.AddItem.ItemHandle);
        Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType);
        Assert.Equal(501, reply.ReturnValue.Int32Value);
        Assert.Equal(46, fakeComObject.AddItemServerHandle);
        Assert.Equal("Galaxy.Tag.Value", fakeComObject.AddItemDefinition);
        Assert.Equal(runtime.StaThreadId, fakeComObject.AddItemThreadId);

        RegisteredItemHandle registeredItemHandle = Assert.Single(
            await session.GetRegisteredItemHandlesAsync());
        Assert.Equal(46, registeredItemHandle.ServerHandle);
        Assert.Equal(501, registeredItemHandle.ItemHandle);
        Assert.Equal("Galaxy.Tag.Value", registeredItemHandle.ItemDefinition);
        Assert.Equal(string.Empty, registeredItemHandle.ItemContext);
        Assert.False(registeredItemHandle.HasItemContext);
    }

    /// <summary>Verifies that AddItem2 command passes the context exactly and tracks the item handle.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_AddItem2_PassesContextExactlyAndTracksItemHandle()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 47,
            addItem2Handle: 502);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-add2", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(CreateAddItem2Command(
            "add-item2",
            47,
            "TestInt",
            "TestChildObject"));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(502, reply.AddItem2.ItemHandle);
        Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType);
        Assert.Equal(502, reply.ReturnValue.Int32Value);
        Assert.Equal(47, fakeComObject.AddItem2ServerHandle);
        Assert.Equal("TestInt", fakeComObject.AddItem2Definition);
        Assert.Equal("TestChildObject", fakeComObject.AddItem2Context);
        Assert.Equal(runtime.StaThreadId, fakeComObject.AddItem2ThreadId);

        RegisteredItemHandle registeredItemHandle = Assert.Single(
            await session.GetRegisteredItemHandlesAsync());
        Assert.Equal(47, registeredItemHandle.ServerHandle);
        Assert.Equal(502, registeredItemHandle.ItemHandle);
        Assert.Equal("TestInt", registeredItemHandle.ItemDefinition);
        Assert.Equal("TestChildObject", registeredItemHandle.ItemContext);
        Assert.True(registeredItemHandle.HasItemContext);
    }

    /// <summary>Verifies that RemoveItem command calls MXAccess on the STA thread and removes the tracked item handle.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_RemoveItem_CallsMxAccessOnStaAndRemovesTrackedItemHandle()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 48,
            addItemHandle: 503);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-remove", "client-a"));
        await session.DispatchAsync(CreateAddItemCommand("add-before-remove", 48, "Galaxy.Tag.Value"));

        MxCommandReply reply = await session.DispatchAsync(CreateRemoveItemCommand(
            "remove-item",
            48,
            503));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(0, reply.Hresult);
        Assert.Equal(48, fakeComObject.RemoveItemServerHandle);
        Assert.Equal(503, fakeComObject.RemovedItemHandle);
        Assert.Equal(runtime.StaThreadId, fakeComObject.RemoveItemThreadId);
        Assert.Empty(await session.GetRegisteredItemHandlesAsync());
    }

    /// <summary>Verifies that RemoveItem removes tracked advice after MXAccess succeeds on an advised handle.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_RemoveItemWithAdvisedHandle_RemovesTrackedAdviceAfterMxAccessSucceeds()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 148,
            addItemHandle: 603);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-advised-remove", "client-a"));
        await session.DispatchAsync(CreateAddItemCommand("add-before-advised-remove", 148, "Galaxy.Tag.Value"));
        await session.DispatchAsync(CreateAdviseCommand("advise-before-remove", 148, 603));

        MxCommandReply reply = await session.DispatchAsync(CreateRemoveItemCommand(
            "remove-advised-item",
            148,
            603));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Empty(await session.GetRegisteredItemHandlesAsync());
        Assert.Empty(await session.GetRegisteredAdviceHandlesAsync());
    }

    /// <summary>Verifies that RemoveItem preserves the HResult and keeps the tracked item handle when using a cross-server handle.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_RemoveItemWithCrossServerHandle_PreservesHResultAndKeepsTrackedItemHandle()
    {
        const int hresult = unchecked((int)0x80070057);
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 49,
            addItemHandle: 504,
            removeItemException: new COMException("Invalid item handle.", hresult));
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-remove-failure", "client-a"));
        await session.DispatchAsync(CreateAddItemCommand("add-before-remove-failure", 49, "Galaxy.Tag.Value"));

        MxCommandReply reply = await session.DispatchAsync(CreateRemoveItemCommand(
            "remove-item-failure",
            999,
            504));

        Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(hresult, reply.Hresult);
        Assert.Contains("0x80070057", reply.DiagnosticMessage);
        Assert.Equal(999, fakeComObject.RemoveItemServerHandle);
        Assert.Equal(504, fakeComObject.RemovedItemHandle);

        RegisteredItemHandle registeredItemHandle = Assert.Single(
            await session.GetRegisteredItemHandlesAsync());
        Assert.Equal(49, registeredItemHandle.ServerHandle);
        Assert.Equal(504, registeredItemHandle.ItemHandle);
    }

    /// <summary>Verifies that AddItem2 preserves the HResult when MXAccess throws and does not track the item handle.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_AddItem2WhenMxAccessThrows_PreservesHResultAndDoesNotTrackItemHandle()
    {
        const int hresult = unchecked((int)0x80070057);
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 50,
            addItem2Exception: new COMException("Invalid server handle.", hresult));
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateAddItem2Command(
            "add-item2-failure",
            9001,
            "TestInt",
            "TestChildObject"));

        Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(hresult, reply.Hresult);
        Assert.Contains("0x80070057", reply.DiagnosticMessage);
        Assert.Equal(9001, fakeComObject.AddItem2ServerHandle);
        Assert.Equal("TestInt", fakeComObject.AddItem2Definition);
        Assert.Equal("TestChildObject", fakeComObject.AddItem2Context);
        Assert.Empty(await session.GetRegisteredItemHandlesAsync());
    }

    /// <summary>Verifies that Advise command calls MXAccess on the STA thread and tracks plain advice.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Advise_CallsMxAccessOnStaAndTracksPlainAdvice()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 52,
            addItemHandle: 505);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-advise", "client-a"));
        await session.DispatchAsync(CreateAddItemCommand("add-before-advise", 52, "Galaxy.Tag.Value"));

        MxCommandReply reply = await session.DispatchAsync(CreateAdviseCommand(
            "advise",
            52,
            505));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(0, reply.Hresult);
        Assert.Equal(52, fakeComObject.AdviseServerHandle);
        Assert.Equal(505, fakeComObject.AdvisedItemHandle);
        Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseThreadId);

        RegisteredAdviceHandle adviceHandle = Assert.Single(
            await session.GetRegisteredAdviceHandlesAsync());
        Assert.Equal(52, adviceHandle.ServerHandle);
        Assert.Equal(505, adviceHandle.ItemHandle);
        Assert.Equal(MxAccessAdviceKind.Plain, adviceHandle.AdviceKind);
    }

    /// <summary>Verifies that AdviseSupervisory calls a distinct MXAccess method and tracks supervisory advice.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_AdviseSupervisory_CallsDistinctMxAccessMethodAndTracksSupervisoryAdvice()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 53,
            addItemHandle: 506);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-supervisory", "client-a"));
        await session.DispatchAsync(CreateAddItemCommand("add-before-supervisory", 53, "Galaxy.Tag.Value"));

        MxCommandReply reply = await session.DispatchAsync(CreateAdviseSupervisoryCommand(
            "advise-supervisory",
            53,
            506));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(53, fakeComObject.AdviseSupervisoryServerHandle);
        Assert.Equal(506, fakeComObject.AdviseSupervisoryItemHandle);
        Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseSupervisoryThreadId);
        Assert.Null(fakeComObject.AdviseServerHandle);

        RegisteredAdviceHandle adviceHandle = Assert.Single(
            await session.GetRegisteredAdviceHandlesAsync());
        Assert.Equal(53, adviceHandle.ServerHandle);
        Assert.Equal(506, adviceHandle.ItemHandle);
        Assert.Equal(MxAccessAdviceKind.Supervisory, adviceHandle.AdviceKind);
    }

    /// <summary>Verifies that UnAdvise command calls MXAccess on the STA thread and removes the tracked advice.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_UnAdvise_CallsMxAccessOnStaAndRemovesTrackedAdvice()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 54,
            addItemHandle: 507);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-unadvise", "client-a"));
        await session.DispatchAsync(CreateAddItemCommand("add-before-unadvise", 54, "Galaxy.Tag.Value"));
        await session.DispatchAsync(CreateAdviseCommand("advise-before-unadvise", 54, 507));
        await session.DispatchAsync(CreateAdviseSupervisoryCommand("supervisory-before-unadvise", 54, 507));

        MxCommandReply reply = await session.DispatchAsync(CreateUnAdviseCommand(
            "unadvise",
            54,
            507));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(54, fakeComObject.UnAdviseServerHandle);
        Assert.Equal(507, fakeComObject.UnAdvisedItemHandle);
        Assert.Equal(runtime.StaThreadId, fakeComObject.UnAdviseThreadId);
        Assert.Empty(await session.GetRegisteredAdviceHandlesAsync());
    }

    /// <summary>Verifies that Advise preserves the HResult when MXAccess throws and does not track the advice.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_AdviseWhenMxAccessThrows_PreservesHResultAndDoesNotTrackAdvice()
    {
        const int hresult = unchecked((int)0x80070057);
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 55,
            addItemHandle: 508,
            adviseException: new COMException("Invalid item handle.", hresult));
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-advise-failure", "client-a"));
        await session.DispatchAsync(CreateAddItemCommand("add-before-advise-failure", 55, "Galaxy.Tag.Value"));

        MxCommandReply reply = await session.DispatchAsync(CreateAdviseCommand(
            "advise-failure",
            55,
            999));

        Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(hresult, reply.Hresult);
        Assert.Contains("0x80070057", reply.DiagnosticMessage);
        Assert.Equal(55, fakeComObject.AdviseServerHandle);
        Assert.Equal(999, fakeComObject.AdvisedItemHandle);
        Assert.Empty(await session.GetRegisteredAdviceHandlesAsync());
    }

    /// <summary>Verifies that UnAdvise preserves the HResult when MXAccess throws and keeps the tracked advice.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_UnAdviseWhenMxAccessThrows_PreservesHResultAndKeepsTrackedAdvice()
    {
        const int hresult = unchecked((int)0x80070057);
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 56,
            addItemHandle: 509,
            unAdviseException: new COMException("Invalid item handle.", hresult));
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-unadvise-failure", "client-a"));
        await session.DispatchAsync(CreateAddItemCommand("add-before-unadvise-failure", 56, "Galaxy.Tag.Value"));
        await session.DispatchAsync(CreateAdviseCommand("advise-before-unadvise-failure", 56, 509));

        MxCommandReply reply = await session.DispatchAsync(CreateUnAdviseCommand(
            "unadvise-failure",
            56,
            509));

        Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(hresult, reply.Hresult);
        Assert.Contains("0x80070057", reply.DiagnosticMessage);
        Assert.Equal(56, fakeComObject.UnAdviseServerHandle);
        Assert.Equal(509, fakeComObject.UnAdvisedItemHandle);

        RegisteredAdviceHandle adviceHandle = Assert.Single(
            await session.GetRegisteredAdviceHandlesAsync());
        Assert.Equal(MxAccessAdviceKind.Plain, adviceHandle.AdviceKind);
    }

    /// <summary>Verifies that SubscribeBulk runs sequential MXAccess calls and returns per-item results.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_SubscribeBulk_RunsSequentialMxAccessCallsAndReturnsPerItemResults()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 60,
            addItemHandle: 512);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateSubscribeBulkCommand(
            "subscribe-bulk",
            60,
            ["", "Galaxy.Tag.Value"]));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(MxCommandKind.SubscribeBulk, reply.Kind);
        Assert.Collection(
            reply.SubscribeBulk.Results,
            result =>
            {
                Assert.False(result.WasSuccessful);
                Assert.Equal(string.Empty, result.TagAddress);
                Assert.Equal(0, result.ItemHandle);
                Assert.Contains("required", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
            },
            result =>
            {
                Assert.True(result.WasSuccessful);
                Assert.Equal("Galaxy.Tag.Value", result.TagAddress);
                Assert.Equal(512, result.ItemHandle);
            });
        Assert.Equal(
            ["AddItem:60:Galaxy.Tag.Value", "Advise:60:512"],
            fakeComObject.OperationNames);
        Assert.Equal(runtime.StaThreadId, fakeComObject.AddItemThreadId);
        Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseThreadId);
    }

    /// <summary>
    /// Verifies that WriteBulk runs MXAccess Write per entry on the STA and returns
    /// one BulkWriteResult per entry in input order, including a per-entry COM
    /// failure surfaced as <c>WasSuccessful=false</c> with the underlying HRESULT.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteBulk_RunsSequentialWritesAndReturnsPerEntryResults()
    {
        const int hresult = unchecked((int)0x80070057);
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 80,
            writeExceptionByItemHandle: new Dictionary<int, Exception>
            {
                [802] = new COMException("Invalid item handle.", hresult),
            });
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteBulkCommand(
            "write-bulk",
            serverHandle: 80,
            new[]
            {
                (itemHandle: 801, value: 11, userId: 5),
                (itemHandle: 802, value: 22, userId: 5),
                (itemHandle: 803, value: 33, userId: 5),
            }));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(MxCommandKind.WriteBulk, reply.Kind);
        Assert.Equal(3, reply.WriteBulk.Results.Count);

        BulkWriteResult success1 = reply.WriteBulk.Results[0];
        Assert.True(success1.WasSuccessful);
        Assert.Equal(801, success1.ItemHandle);
        Assert.Equal(string.Empty, success1.ErrorMessage);

        BulkWriteResult failure = reply.WriteBulk.Results[1];
        Assert.False(failure.WasSuccessful);
        Assert.Equal(802, failure.ItemHandle);
        Assert.True(failure.HasHresult);
        Assert.Equal(hresult, failure.Hresult);

        BulkWriteResult success3 = reply.WriteBulk.Results[2];
        Assert.True(success3.WasSuccessful);
        Assert.Equal(803, success3.ItemHandle);

        // Each Write hit the fake COM object on the STA thread.
        Assert.Equal(runtime.StaThreadId, fakeComObject.WriteThreadId);
        Assert.Contains("Write:80:801", fakeComObject.OperationNames);
        Assert.Contains("Write:80:802", fakeComObject.OperationNames);
        Assert.Contains("Write:80:803", fakeComObject.OperationNames);
    }

    /// <summary>Verifies that Write2Bulk forwards value AND timestamp to each per-entry Write2.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Write2Bulk_ForwardsValueAndTimestampPerEntry()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 81);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc);

        MxCommandReply reply = await session.DispatchAsync(CreateWrite2BulkCommand(
            "write2-bulk",
            serverHandle: 81,
            new[] { (itemHandle: 811, value: 100, timestamp, userId: 7) }));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        BulkWriteResult result = Assert.Single(reply.Write2Bulk.Results);
        Assert.True(result.WasSuccessful);
        Assert.Equal(811, result.ItemHandle);
        Assert.Equal(100, fakeComObject.WriteValue);
        Assert.Equal(timestamp, fakeComObject.WriteTimestamp);
        Assert.Equal(7, fakeComObject.WriteUserId);
        Assert.Contains("Write2:81:811", fakeComObject.OperationNames);
    }

    /// <summary>Verifies that WriteSecuredBulk forwards both user ids per entry.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteSecuredBulk_ForwardsUserIdsPerEntry()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 82);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredBulkCommand(
            "write-secured-bulk",
            serverHandle: 82,
            new[] { (itemHandle: 821, currentUserId: 11, verifierUserId: 22, value: 555) }));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        BulkWriteResult result = Assert.Single(reply.WriteSecuredBulk.Results);
        Assert.True(result.WasSuccessful);
        Assert.Equal(11, fakeComObject.WriteCurrentUserId);
        Assert.Equal(22, fakeComObject.WriteVerifierUserId);
        Assert.Equal(555, fakeComObject.WriteValue);
        Assert.Contains("WriteSecured:82:821", fakeComObject.OperationNames);
    }

    /// <summary>Verifies that WriteSecured2Bulk forwards user ids, value, and timestamp per entry.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteSecured2Bulk_ForwardsUserIdsValueAndTimestampPerEntry()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 83);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2BulkCommand(
            "write-secured2-bulk",
            serverHandle: 83,
            new[] { (itemHandle: 831, currentUserId: 33, verifierUserId: 44, value: 999, timestamp) }));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        BulkWriteResult result = Assert.Single(reply.WriteSecured2Bulk.Results);
        Assert.True(result.WasSuccessful);
        Assert.Equal(33, fakeComObject.WriteCurrentUserId);
        Assert.Equal(44, fakeComObject.WriteVerifierUserId);
        Assert.Equal(999, fakeComObject.WriteValue);
        Assert.Equal(timestamp, fakeComObject.WriteTimestamp);
        Assert.Contains("WriteSecured2:83:831", fakeComObject.OperationNames);
    }

    /// <summary>
    /// Verifies ReadBulk's snapshot path: with no cached value, the worker takes
    /// the AddItem + Advise + wait + UnAdvise + RemoveItem lifecycle itself, and
    /// surfaces a timeout as a per-tag failure when no OnDataChange arrives.
    /// The fake COM object never fires events so the wait always times out — but
    /// the lifecycle calls must still happen, in order, on the STA.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_ReadBulk_WhenTagNotCached_TakesSnapshotLifecycleAndTimesOut()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 90,
            addItemHandle: 900);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-read-bulk", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(CreateReadBulkCommand(
            "read-bulk-snapshot",
            serverHandle: 90,
            tagAddresses: new[] { "Galaxy.Tag.Value" },
            timeoutMs: 80));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(MxCommandKind.ReadBulk, reply.Kind);
        BulkReadResult result = Assert.Single(reply.ReadBulk.Results);
        Assert.False(result.WasSuccessful);
        Assert.False(result.WasCached);
        Assert.Equal("Galaxy.Tag.Value", result.TagAddress);
        Assert.Equal(900, result.ItemHandle);
        Assert.Contains("timed out", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);

        // The snapshot lifecycle must call AddItem → Advise → UnAdvise → RemoveItem
        // in order on the STA. We don't assert exact ordering of UnAdvise vs.
        // RemoveItem here because both are best-effort cleanup in a finally
        // block; the operation list confirms both happened.
        Assert.Contains("AddItem:90:Galaxy.Tag.Value", fakeComObject.OperationNames);
        Assert.Contains("Advise:90:900", fakeComObject.OperationNames);
        Assert.Contains("UnAdvise:90:900", fakeComObject.OperationNames);
        Assert.Contains("RemoveItem:90:900", fakeComObject.OperationNames);
    }

    /// <summary>
    ///     Verifies ReadBulk's cached fast path end to end — the
    ///     <c>was_cached = true</c> half of the command, which nothing else in
    ///     the worker suite exercises. With the tag already added AND advised
    ///     and a value in the per-session cache, the executor answers from the
    ///     cache: the result is successful and flagged cached, and no COM call
    ///     is made for the read at all, so the subscription the caller did not
    ///     create is left exactly as it was.
    ///
    ///     It also pins the borrow contract through the whole read path: the
    ///     <c>Value</c>, <c>SourceTimestamp</c>, and status row on the
    ///     <c>BulkReadResult</c> are the cached event's own instances, not
    ///     copies (see <see cref="MxAccessValueCache.Set"/>). The worker only
    ///     serializes them onto the IPC pipe, so the alias never escapes the
    ///     process.
    ///
    ///     Driven through <see cref="MxAccessCommandExecutor"/> directly rather
    ///     than <see cref="MxAccessStaSession"/> because seeding the cache needs
    ///     a handle on it: <see cref="MxAccessSession.Create"/> only shares the
    ///     sink's cache for the production <c>MxAccessBaseEventSink</c>, which
    ///     casts the COM object to <c>LMXProxyServerClass</c> and so cannot take
    ///     a fake, while <c>CreateForTesting</c> accepts the cache directly. The
    ///     cached path neither waits nor pumps, so it needs no STA.
    /// </summary>
    [Fact]
    public void Execute_ReadBulk_WhenTagIsAdvisedAndCached_ServesTheCachedInstancesWithoutTouchingTheSubscription()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 92,
            addItemHandle: 920);
        MxAccessValueCache valueCache = new();
        using MxAccessSession session = MxAccessSession.CreateForTesting(
            mxAccessServer: fakeComObject,
            eventSink: new NoopEventSink(),
            valueCache: valueCache);
        MxAccessCommandExecutor executor = new(
            session,
            new ZB.MOM.WW.MxGateway.Worker.Conversion.VariantConverter());

        // Registry half of the fast path: the tag must resolve to a live item
        // handle on this server AND carry an advice, or TryGetCachedReadFor
        // falls through to the AddItem/Advise snapshot lifecycle.
        MxCommandReply registerReply = executor.Execute(
            CreateRegisterCommand("register-before-cached-read", "client-a"));
        MxCommandReply addItemReply = executor.Execute(
            CreateAddItemCommand("add-before-cached-read", 92, "Galaxy.Tag.Value"));
        MxCommandReply adviseReply = executor.Execute(
            CreateAdviseCommand("advise-before-cached-read", 92, 920));
        Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
        Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
        Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code);

        // Cache half: stand in for the event sink's post-publish hook, which
        // records the event it just enqueued.
        MxEvent cachedEvent = new()
        {
            Family = MxEventFamily.OnDataChange,
            ServerHandle = 92,
            ItemHandle = 920,
            Quality = 192,
            SourceTimestamp = Timestamp.FromDateTime(new(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc)),
            Value = new MxValue
            {
                DataType = MxDataType.Integer,
                VariantType = "VT_I4",
                Int32Value = 7788,
            },
            OnDataChange = new OnDataChangeEvent(),
        };
        cachedEvent.Statuses.Add(new MxStatusProxy { Category = MxStatusCategory.Ok });
        valueCache.Set(92, 920, cachedEvent);

        MxCommandReply reply = executor.Execute(CreateReadBulkCommand(
            "read-bulk-cached",
            serverHandle: 92,
            tagAddresses: new[] { "Galaxy.Tag.Value" },
            timeoutMs: 80));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(MxCommandKind.ReadBulk, reply.Kind);
        BulkReadResult result = Assert.Single(reply.ReadBulk.Results);
        Assert.True(result.WasSuccessful);
        Assert.True(result.WasCached);
        Assert.Equal("Galaxy.Tag.Value", result.TagAddress);
        Assert.Equal(920, result.ItemHandle);
        Assert.Equal(192, result.Quality);
        Assert.Equal(7788, result.Value.Int32Value);

        // Borrowed, not copied — all the way from the event handed to
        // MxAccessValueCache.Set out to the reply the worker serializes.
        Assert.Same(cachedEvent.Value, result.Value);
        Assert.Same(cachedEvent.SourceTimestamp, result.SourceTimestamp);
        Assert.Same(cachedEvent.Statuses[0], Assert.Single(result.Statuses));

        // No second AddItem, and above all no UnAdvise/RemoveItem: only the
        // three setup calls ever reached MXAccess.
        Assert.Equal(
            new[] { "Register:client-a", "AddItem:92:Galaxy.Tag.Value", "Advise:92:920" },
            fakeComObject.OperationNames);
    }

    /// <summary>Verifies that ReadBulk with no payload returns an invalid request error.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_ReadBulkWithoutPayload_ReturnsInvalidRequest()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 91);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(new StaCommand(
            "session-1",
            "missing-read-bulk-payload",
            new MxCommand
            {
                Kind = MxCommandKind.ReadBulk,
            }));

        Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
    }

    /// <summary>Verifies that UnsubscribeBulk removes items after UnAdvise failure.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_UnsubscribeBulk_RemovesItemAfterUnAdviseFailure()
    {
        const int hresult = unchecked((int)0x80070057);
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 61,
            unAdviseException: new COMException("Invalid item handle.", hresult));
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateUnsubscribeBulkCommand(
            "unsubscribe-bulk",
            61,
            [513]));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        SubscribeResult result = Assert.Single(reply.UnsubscribeBulk.Results);
        Assert.False(result.WasSuccessful);
        Assert.Equal(513, result.ItemHandle);
        Assert.Equal(string.Empty, result.TagAddress);
        Assert.Contains("UnAdvise failed", result.ErrorMessage);
        Assert.Equal(
            ["UnAdvise:61:513", "RemoveItem:61:513"],
            fakeComObject.OperationNames);
    }

    /// <summary>Verifies that ShutdownGracefullyAsync cleans up handles in advice, item, server order.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ShutdownGracefullyAsync_CleansHandlesInAdviceItemServerOrder()
    {
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 58,
            addItemHandle: 510);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-shutdown", "client-a"));
        await session.DispatchAsync(CreateAddItemCommand("add-before-shutdown", 58, "Galaxy.Tag.Value"));
        await session.DispatchAsync(CreateAdviseCommand("advise-before-shutdown", 58, 510));
        await session.DispatchAsync(CreateAdviseSupervisoryCommand("supervisory-before-shutdown", 58, 510));

        MxAccessShutdownResult result = await session.ShutdownGracefullyAsync(TimeSpan.FromSeconds(2));

        Assert.True(result.Succeeded);
        Assert.Equal(
            new[] { "UnAdvise:58:510", "RemoveItem:58:510", "Unregister:58" },
            fakeComObject.OperationNames.Where(name => name.StartsWith("Un", StringComparison.Ordinal)
                || name.StartsWith("Remove", StringComparison.Ordinal)));
    }

    /// <summary>Verifies that ShutdownGracefullyAsync records cleanup failures and continues.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ShutdownGracefullyAsync_RecordsCleanupFailuresAndContinues()
    {
        const int hresult = unchecked((int)0x80070057);
        COMException cleanupException = new("Invalid handle.", hresult);
        FakeMxAccessComObject fakeComObject = new(
            registerHandle: 59,
            addItemHandle: 511,
            unregisterException: cleanupException,
            removeItemException: cleanupException,
            unAdviseException: cleanupException);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-shutdown-failure", "client-a"));
        await session.DispatchAsync(CreateAddItemCommand("add-before-shutdown-failure", 59, "Galaxy.Tag.Value"));
        await session.DispatchAsync(CreateAdviseCommand("advise-before-shutdown-failure", 59, 511));

        MxAccessShutdownResult result = await session.ShutdownGracefullyAsync(TimeSpan.FromSeconds(2));

        Assert.False(result.Succeeded);
        Assert.Equal(new[] { "UnAdvise", "RemoveItem", "Unregister" }, result.Failures.Select(failure => failure.Operation));
        Assert.All(result.Failures, failure => Assert.Equal(hresult, failure.HResult));
        Assert.Contains("Unregister:59", fakeComObject.OperationNames);
    }

    /// <summary>Verifies that Register without payload returns an invalid request error.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_RegisterWithoutPayload_ReturnsInvalidRequest()
    {
        FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 45));
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(new StaCommand(
            "session-1",
            "missing-payload",
            new MxCommand
            {
                Kind = MxCommandKind.Register,
            }));

        Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
        Assert.Null(factory.FakeComObject.RegisteredClientName);
    }

    /// <summary>Verifies that AddItem without payload returns an invalid request error.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_AddItemWithoutPayload_ReturnsInvalidRequest()
    {
        FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 51));
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(new StaCommand(
            "session-1",
            "missing-add-payload",
            new MxCommand
            {
                Kind = MxCommandKind.AddItem,
            }));

        Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
        Assert.Null(factory.FakeComObject.AddItemDefinition);
    }

    /// <summary>Verifies that Advise without payload returns an invalid request error.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_AdviseWithoutPayload_ReturnsInvalidRequest()
    {
        FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 57));
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(new StaCommand(
            "session-1",
            "missing-advise-payload",
            new MxCommand
            {
                Kind = MxCommandKind.Advise,
            }));

        Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
        Assert.Null(factory.FakeComObject.AdviseServerHandle);
    }

    /// <summary>Verifies that Write dispatches the converted value to MXAccess on the STA thread.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Write_CallsMxAccessOnStaWithConvertedValue()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 70);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        // No completion source in this test — disable the bounded reply wait
        // so the forwarding assertions don't pay the default 1.5 s timeout.
        session.WriteCompletionTimeout = TimeSpan.Zero;
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
            "write", serverHandle: 70, itemHandle: 700, value: 123, userId: 5));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(MxCommandKind.Write, reply.Kind);
        Assert.Equal(70, fakeComObject.WriteServerHandle);
        Assert.Equal(700, fakeComObject.WriteItemHandle);
        Assert.Equal(123, fakeComObject.WriteValue);
        Assert.Equal(5, fakeComObject.WriteUserId);
        Assert.Equal(runtime.StaThreadId, fakeComObject.WriteThreadId);
    }

    /// <summary>Verifies that Write2 forwards the converted value and timestamp to MXAccess.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Write2_ForwardsValueAndTimestamp()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 71);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        // Same rationale as the Write forwarding test above.
        session.WriteCompletionTimeout = TimeSpan.Zero;
        await session.StartAsync(workerProcessId: 1234);
        DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc);

        MxCommandReply reply = await session.DispatchAsync(CreateWrite2Command(
            "write2", serverHandle: 71, itemHandle: 710, value: 456, timestamp: timestamp, userId: 6));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(MxCommandKind.Write2, reply.Kind);
        Assert.Equal(710, fakeComObject.WriteItemHandle);
        Assert.Equal(456, fakeComObject.WriteValue);
        Assert.Equal(timestamp, fakeComObject.WriteTimestamp);
        Assert.Equal(6, fakeComObject.WriteUserId);
    }

    /// <summary>Verifies that WriteSecured forwards the operator and verifier user ids to MXAccess.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteSecured_ForwardsUserIds()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 72);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        // No completion source in this test — disable the bounded reply wait
        // so the forwarding assertions don't pay the default 1.5 s timeout.
        session.WriteCompletionTimeout = TimeSpan.Zero;
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
            "write-secured", serverHandle: 72, itemHandle: 720, value: 789, currentUserId: 11, verifierUserId: 22));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(MxCommandKind.WriteSecured, reply.Kind);
        Assert.Equal(720, fakeComObject.WriteItemHandle);
        Assert.Equal(789, fakeComObject.WriteValue);
        Assert.Equal(11, fakeComObject.WriteCurrentUserId);
        Assert.Equal(22, fakeComObject.WriteVerifierUserId);
    }

    /// <summary>Verifies that WriteSecured2 forwards user ids, value, and timestamp to MXAccess.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteSecured2_ForwardsUserIdsValueAndTimestamp()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 73);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        // Same rationale as the WriteSecured forwarding test above.
        session.WriteCompletionTimeout = TimeSpan.Zero;
        await session.StartAsync(workerProcessId: 1234);
        DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2Command(
            "write-secured2", serverHandle: 73, itemHandle: 730, value: 1011,
            timestamp: timestamp, currentUserId: 33, verifierUserId: 44));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(MxCommandKind.WriteSecured2, reply.Kind);
        Assert.Equal(1011, fakeComObject.WriteValue);
        Assert.Equal(timestamp, fakeComObject.WriteTimestamp);
        Assert.Equal(33, fakeComObject.WriteCurrentUserId);
        Assert.Equal(44, fakeComObject.WriteVerifierUserId);
    }

    /// <summary>
    ///     Verifies the fast-completion ordering edge: a completion recorded while
    ///     the WriteSecured COM call is still on the stack (MXAccess committing
    ///     synchronously) is newer than the pre-call baseline and lands on the
    ///     reply — the wait never misses a callback that beat it.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 82);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        CompletionCacheEventSink sink = new();
        fakeComObject.OnWriteCallback = () =>
            sink.WriteCompletionCache.Record(82, 820, CreateCompletionRows(detail: 4321));
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, sink);
        // Hermetic: don't inherit MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS
        // from the test runner's environment.
        session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
            "write-secured-fast", serverHandle: 82, itemHandle: 820, value: 1, currentUserId: 11, verifierUserId: 22));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(0, reply.Hresult);
        MxStatusProxy row = Assert.Single(reply.Statuses);
        Assert.Equal(4321, row.Detail);
        Assert.Equal(MxStatusCategory.Ok, row.Category);
    }

    /// <summary>
    ///     Verifies the pump-wait path: the completion arrives after the COM call
    ///     returned, while the executor is pump-waiting, and still lands on the
    ///     reply.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesWhileWaiting_ReturnsStatuses()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 83);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        CompletionCacheEventSink sink = new();
        // Deterministic ordering: the executor captures its version baseline
        // BEFORE the COM call, so once the fake's WriteSecured has run the
        // baseline is committed and a Record from the test thread is
        // guaranteed to be "newer" — no fixed sleep racing the STA thread.
        using System.Threading.ManualResetEventSlim comCallReached = new(initialState: false);
        fakeComObject.OnWriteCallback = () => comCallReached.Set();
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, sink);
        session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
        await session.StartAsync(workerProcessId: 1234);

        Task<MxCommandReply> pending = session.DispatchAsync(CreateWriteSecuredCommand(
            "write-secured-waiting", serverHandle: 83, itemHandle: 830, value: 1, currentUserId: 11, verifierUserId: 22));
        Assert.True(comCallReached.Wait(TimeSpan.FromSeconds(5)));
        sink.WriteCompletionCache.Record(83, 830, CreateCompletionRows(detail: 99));

        MxCommandReply reply = await pending;

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(99, Assert.Single(reply.Statuses).Detail);
    }

    /// <summary>
    ///     Verifies the timeout fallback: no completion within the bounded wait
    ///     returns today's reply shape — protocol OK with EMPTY statuses (the
    ///     consumer's honest-unconfirmed path), never a synthesized failure row.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteSecured_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 84);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        CompletionCacheEventSink sink = new();
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, sink);
        session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
            "write-secured-timeout", serverHandle: 84, itemHandle: 840, value: 1, currentUserId: 11, verifierUserId: 22));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(0, reply.Hresult);
        Assert.Empty(reply.Statuses);
    }

    /// <summary>
    ///     Verifies the version-baseline rule end to end: a completion recorded
    ///     BEFORE the write was dispatched is stale and must not be misattributed
    ///     to this write — the reply times out empty instead.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteSecured_IgnoresStaleCompletionFromBeforeTheCall()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 85);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        CompletionCacheEventSink sink = new();
        sink.WriteCompletionCache.Record(85, 850, CreateCompletionRows(detail: 1111));
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, sink);
        session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
            "write-secured-stale", serverHandle: 85, itemHandle: 850, value: 1, currentUserId: 11, verifierUserId: 22));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Empty(reply.Statuses);
    }

    /// <summary>
    ///     Verifies that WriteSecured2 correlates the same way as WriteSecured
    ///     (fast-completion edge).
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteSecured2_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 86);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        CompletionCacheEventSink sink = new();
        fakeComObject.OnWriteCallback = () =>
            sink.WriteCompletionCache.Record(86, 860, CreateCompletionRows(detail: 2222));
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, sink);
        // Hermetic: same rationale as the WriteSecured fast-completion test.
        session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2Command(
            "write-secured2-fast", serverHandle: 86, itemHandle: 860, value: 1,
            timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), currentUserId: 33, verifierUserId: 44));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(2222, Assert.Single(reply.Statuses).Detail);
    }

    /// <summary>
    ///     Verifies plain Write correlates the same way as WriteSecured
    ///     (fast-completion edge): OtOpcUa's dominant FreeAccess path goes out
    ///     as MX_COMMAND_KIND_WRITE, so its reply must carry the OnWriteComplete
    ///     rows too.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Write_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 87);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        CompletionCacheEventSink sink = new();
        fakeComObject.OnWriteCallback = () =>
            sink.WriteCompletionCache.Record(87, 870, CreateCompletionRows(detail: 5555));
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, sink);
        // Hermetic: don't inherit MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS
        // from the test runner's environment.
        session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
            "plain-write-fast", serverHandle: 87, itemHandle: 870, value: 1, userId: 5));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(0, reply.Hresult);
        MxStatusProxy row = Assert.Single(reply.Statuses);
        Assert.Equal(5555, row.Detail);
        Assert.Equal(MxStatusCategory.Ok, row.Category);
    }

    /// <summary>
    ///     Verifies the plain-Write timeout fallback mirrors the secured one:
    ///     no completion within the bounded wait returns protocol OK with EMPTY
    ///     statuses — unconfirmed, never a synthesized failure row.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Write_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 88);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        CompletionCacheEventSink sink = new();
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, sink);
        session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
            "plain-write-timeout", serverHandle: 88, itemHandle: 880, value: 1, userId: 5));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.True(reply.HasHresult);
        Assert.Equal(0, reply.Hresult);
        Assert.Empty(reply.Statuses);
    }

    /// <summary>
    ///     Verifies Write2 correlates the same way as Write (fast-completion
    ///     edge).
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Write2_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 89);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        CompletionCacheEventSink sink = new();
        fakeComObject.OnWriteCallback = () =>
            sink.WriteCompletionCache.Record(89, 890, CreateCompletionRows(detail: 6666));
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, sink);
        session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(CreateWrite2Command(
            "plain-write2-fast", serverHandle: 89, itemHandle: 890, value: 1,
            timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), userId: 6));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(6666, Assert.Single(reply.Statuses).Detail);
    }

    /// <summary>
    ///     Verifies WriteBulk stays fire-and-forget: even with a huge completion
    ///     timeout configured and no completion source, the reply returns
    ///     immediately with per-entry results — bulk writes never enter the
    ///     bounded wait (latency for high-rate loops).
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteBulk_DoesNotWaitForCompletion()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 90);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        CompletionCacheEventSink sink = new();
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, sink);
        session.WriteCompletionTimeout = TimeSpan.FromSeconds(30);
        await session.StartAsync(workerProcessId: 1234);

        Task<MxCommandReply> pending = session.DispatchAsync(CreateWriteBulkCommand(
            "bulk-write-no-wait", serverHandle: 90, entries: new[] { (itemHandle: 900, value: 1, userId: 5) }));
        Task completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(5)));

        Assert.Same(pending, completed);
        MxCommandReply reply = await pending;
        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Empty(reply.Statuses);
    }

    private static Google.Protobuf.Collections.RepeatedField<MxStatusProxy> CreateCompletionRows(int detail)
    {
        return new Google.Protobuf.Collections.RepeatedField<MxStatusProxy>
        {
            new MxStatusProxy
            {
                Success = 1,
                Category = MxStatusCategory.Ok,
                Detail = detail,
            },
        };
    }

    /// <summary>Verifies that Write without a payload returns an invalid request error.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteWithoutPayload_ReturnsInvalidRequest()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 74);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(new StaCommand(
            "session-1",
            "missing-write-payload",
            new MxCommand
            {
                Kind = MxCommandKind.Write,
            }));

        Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
        Assert.Null(fakeComObject.WriteServerHandle);
    }

    /// <summary>Verifies that Write without a value returns an invalid request error.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WriteWithoutValue_ReturnsInvalidRequest()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 75);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        MxCommandReply reply = await session.DispatchAsync(new StaCommand(
            "session-1",
            "missing-write-value",
            new MxCommand
            {
                Kind = MxCommandKind.Write,
                Write = new WriteCommand
                {
                    ServerHandle = 75,
                    ItemHandle = 750,
                },
            }));

        Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
        Assert.Null(fakeComObject.WriteServerHandle);
    }

    /// <summary>Verifies Suspend calls MXAccess on the STA and maps the native status to MxStatusProxy.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Suspend_CallsMxAccessOnStaAndMapsStatus()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 200);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-suspend", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(CreateSuspendCommand("suspend-1", 200, 21));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(0, reply.Hresult);
        Assert.NotNull(reply.Suspend);
        Assert.NotNull(reply.Suspend.Status);
        Assert.Equal(1, reply.Suspend.Status.Success);
        Assert.Equal(MxStatusCategory.Ok, reply.Suspend.Status.Category);
        Assert.Equal(200, fakeComObject.SuspendServerHandle);
        Assert.Equal(21, fakeComObject.SuspendItemHandle);
        Assert.Equal(runtime.StaThreadId, fakeComObject.SuspendThreadId);
    }

    /// <summary>Verifies Activate calls MXAccess on the STA and maps the native status to MxStatusProxy.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_Activate_CallsMxAccessOnStaAndMapsStatus()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 201);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-activate", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(CreateActivateCommand("activate-1", 201, 22));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.NotNull(reply.Activate);
        Assert.NotNull(reply.Activate.Status);
        Assert.Equal(1, reply.Activate.Status.Success);
        Assert.Equal(MxStatusCategory.Ok, reply.Activate.Status.Category);
        Assert.Equal(201, fakeComObject.ActivateServerHandle);
        Assert.Equal(22, fakeComObject.ActivateItemHandle);
        Assert.Equal(runtime.StaThreadId, fakeComObject.ActivateThreadId);
    }

    /// <summary>Verifies AuthenticateUser passes credentials to MXAccess on the STA and returns the user id.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_AuthenticateUser_CallsMxAccessOnStaAndReturnsUserId()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 202);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-auth", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(
            CreateAuthenticateUserCommand("auth-1", 202, "Administrator", string.Empty));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.NotNull(reply.AuthenticateUser);
        Assert.Equal(1, reply.AuthenticateUser.UserId);
        Assert.Equal(202, fakeComObject.AuthenticateServerHandle);
        Assert.Equal("Administrator", fakeComObject.AuthenticateUserName);
        Assert.Equal(runtime.StaThreadId, fakeComObject.AuthenticateThreadId);
    }

    /// <summary>
    /// Verifies the AuthenticateUser path never surfaces the credential into the
    /// command reply or any recorded diagnostic — the password is only ever
    /// handed straight to the MXAccess wrapper.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_AuthenticateUser_DoesNotLeakPassword()
    {
        const string secret = "sup3r-secret-pw";
        FakeMxAccessComObject fakeComObject = new(registerHandle: 203);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-auth-leak", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(
            CreateAuthenticateUserCommand("auth-leak", 203, "Administrator", secret));

        // The wrapper still receives the credential verbatim...
        Assert.Equal(secret, fakeComObject.AuthenticatePassword);

        // ...but the reply (diagnostics, status text) and the fake's operation
        // log must never contain it.
        Assert.DoesNotContain(secret, reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal);
        Assert.DoesNotContain(secret, reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal);
        Assert.DoesNotContain(fakeComObject.OperationNames, name => name.Contains(secret, StringComparison.Ordinal));
    }

    /// <summary>Verifies ArchestrAUserToId calls MXAccess on the STA and returns the resolved user id.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_ArchestrAUserToId_CallsMxAccessOnStaAndReturnsUserId()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 204);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-user-to-id", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(
            CreateArchestrAUserToIdCommand("user-to-id-1", 204, "11112222-3333-4444-5555-666677778888"));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.NotNull(reply.ArchestraUserToId);
        Assert.Equal(7, reply.ArchestraUserToId.UserId);
        Assert.Equal(204, fakeComObject.ArchestrAUserToIdServerHandle);
        Assert.Equal("11112222-3333-4444-5555-666677778888", fakeComObject.ArchestrAUserToIdGuid);
    }

    /// <summary>Verifies AddBufferedItem calls MXAccess on the STA and tracks the buffered item handle.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_AddBufferedItem_CallsMxAccessOnStaAndTracksItemHandle()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 205);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-buffered", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(
            CreateAddBufferedItemCommand("buffered-1", 205, "TestInt", "TestChildObject"));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.NotNull(reply.AddBufferedItem);
        Assert.Equal(1, reply.AddBufferedItem.ItemHandle);
        Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType);
        Assert.Equal(1, reply.ReturnValue.Int32Value);
        Assert.Equal(205, fakeComObject.AddBufferedItemServerHandle);
        Assert.Equal("TestInt", fakeComObject.AddBufferedItemDefinition);
        Assert.Equal("TestChildObject", fakeComObject.AddBufferedItemContext);

        RegisteredItemHandle registeredItemHandle = Assert.Single(
            await session.GetRegisteredItemHandlesAsync());
        Assert.Equal(205, registeredItemHandle.ServerHandle);
        Assert.Equal(1, registeredItemHandle.ItemHandle);
        Assert.Equal("TestInt", registeredItemHandle.ItemDefinition);
        Assert.Equal("TestChildObject", registeredItemHandle.ItemContext);
        Assert.True(registeredItemHandle.HasItemContext);
    }

    /// <summary>Verifies SetBufferedUpdateInterval calls MXAccess on the STA and returns a base OK reply.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_SetBufferedUpdateInterval_CallsMxAccessOnStaAndReturnsOk()
    {
        FakeMxAccessComObject fakeComObject = new(registerHandle: 206);
        FakeMxAccessComObjectFactory factory = new(fakeComObject);
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);
        await session.DispatchAsync(CreateRegisterCommand("register-before-interval", "client-a"));

        MxCommandReply reply = await session.DispatchAsync(
            CreateSetBufferedUpdateIntervalCommand("interval-1", 206, 500));

        Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
        Assert.Equal(0, reply.Hresult);
        Assert.Equal(206, fakeComObject.SetBufferedUpdateIntervalServerHandle);
        Assert.Equal(500, fakeComObject.SetBufferedUpdateIntervalValue);
    }

    /// <summary>
    ///     Verifies that a command with an unknown <see cref="MxCommandKind"/> value returns an
    ///     <see cref="ProtocolStatusCode.InvalidRequest"/> reply whose diagnostic contains "Unsupported".
    ///     This pins the <c>_ =&gt; CreateInvalidRequestReply(...)</c> discard arm in
    ///     <c>MxAccessCommandExecutor.Execute</c>: a regression that changed the arm to
    ///     <c>throw</c> would propagate an unhandled exception through <c>WorkerPipeSession</c>
    ///     and no other test would catch it.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task DispatchAsync_WithUnknownCommandKind_ReturnsInvalidRequestWithUnsupportedDiagnostic()
    {
        FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 999));
        using StaRuntime runtime = CreateRuntime();
        using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
        await session.StartAsync(workerProcessId: 1234);

        // Cast an integer outside the defined MxCommandKind range to an unknown kind value.
        MxCommandKind unknownKind = (MxCommandKind)int.MaxValue;
        MxCommandReply reply = await session.DispatchAsync(new StaCommand(
            "session-1",
            "unknown-kind-correlation",
            new MxCommand
            {
                Kind = unknownKind,
            }));

        Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
        Assert.Contains("Unsupported", reply.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
    }

    private static StaCommand CreateSuspendCommand(
        string correlationId,
        int serverHandle,
        int itemHandle)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.Suspend,
                Suspend = new SuspendCommand
                {
                    ServerHandle = serverHandle,
                    ItemHandle = itemHandle,
                },
            });
    }

    private static StaCommand CreateActivateCommand(
        string correlationId,
        int serverHandle,
        int itemHandle)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.Activate,
                Activate = new ActivateCommand
                {
                    ServerHandle = serverHandle,
                    ItemHandle = itemHandle,
                },
            });
    }

    private static StaCommand CreateAuthenticateUserCommand(
        string correlationId,
        int serverHandle,
        string verifyUser,
        string verifyUserPassword)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.AuthenticateUser,
                AuthenticateUser = new AuthenticateUserCommand
                {
                    ServerHandle = serverHandle,
                    VerifyUser = verifyUser,
                    VerifyUserPassword = verifyUserPassword,
                },
            });
    }

    private static StaCommand CreateArchestrAUserToIdCommand(
        string correlationId,
        int serverHandle,
        string userIdGuid)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.ArchestraUserToId,
                ArchestraUserToId = new ArchestrAUserToIdCommand
                {
                    ServerHandle = serverHandle,
                    UserIdGuid = userIdGuid,
                },
            });
    }

    private static StaCommand CreateAddBufferedItemCommand(
        string correlationId,
        int serverHandle,
        string itemDefinition,
        string itemContext)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.AddBufferedItem,
                AddBufferedItem = new AddBufferedItemCommand
                {
                    ServerHandle = serverHandle,
                    ItemDefinition = itemDefinition,
                    ItemContext = itemContext,
                },
            });
    }

    private static StaCommand CreateSetBufferedUpdateIntervalCommand(
        string correlationId,
        int serverHandle,
        int updateIntervalMilliseconds)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.SetBufferedUpdateInterval,
                SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand
                {
                    ServerHandle = serverHandle,
                    UpdateIntervalMilliseconds = updateIntervalMilliseconds,
                },
            });
    }

    private static StaCommand CreateRegisterCommand(
        string correlationId,
        string clientName)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.Register,
                Register = new RegisterCommand
                {
                    ClientName = clientName,
                },
            });
    }

    private static StaCommand CreateUnregisterCommand(
        string correlationId,
        int serverHandle)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.Unregister,
                Unregister = new UnregisterCommand
                {
                    ServerHandle = serverHandle,
                },
            });
    }

    private static StaCommand CreateAddItemCommand(
        string correlationId,
        int serverHandle,
        string itemDefinition)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.AddItem,
                AddItem = new AddItemCommand
                {
                    ServerHandle = serverHandle,
                    ItemDefinition = itemDefinition,
                },
            });
    }

    private static StaCommand CreateAddItem2Command(
        string correlationId,
        int serverHandle,
        string itemDefinition,
        string itemContext)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.AddItem2,
                AddItem2 = new AddItem2Command
                {
                    ServerHandle = serverHandle,
                    ItemDefinition = itemDefinition,
                    ItemContext = itemContext,
                },
            });
    }

    private static StaCommand CreateRemoveItemCommand(
        string correlationId,
        int serverHandle,
        int itemHandle)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.RemoveItem,
                RemoveItem = new RemoveItemCommand
                {
                    ServerHandle = serverHandle,
                    ItemHandle = itemHandle,
                },
            });
    }

    private static StaCommand CreateAdviseCommand(
        string correlationId,
        int serverHandle,
        int itemHandle)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.Advise,
                Advise = new AdviseCommand
                {
                    ServerHandle = serverHandle,
                    ItemHandle = itemHandle,
                },
            });
    }

    private static MxValue CreateIntegerValue(int value)
    {
        return new MxValue
        {
            DataType = MxDataType.Integer,
            VariantType = "VT_I4",
            Int32Value = value,
        };
    }

    private static MxValue CreateTimestampValue(DateTime timestamp)
    {
        return new MxValue
        {
            DataType = MxDataType.Time,
            VariantType = "VT_DATE",
            TimestampValue = Timestamp.FromDateTime(timestamp),
        };
    }

    private static StaCommand CreateWriteCommand(
        string correlationId,
        int serverHandle,
        int itemHandle,
        int value,
        int userId)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.Write,
                Write = new WriteCommand
                {
                    ServerHandle = serverHandle,
                    ItemHandle = itemHandle,
                    Value = CreateIntegerValue(value),
                    UserId = userId,
                },
            });
    }

    private static StaCommand CreateWrite2Command(
        string correlationId,
        int serverHandle,
        int itemHandle,
        int value,
        DateTime timestamp,
        int userId)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.Write2,
                Write2 = new Write2Command
                {
                    ServerHandle = serverHandle,
                    ItemHandle = itemHandle,
                    Value = CreateIntegerValue(value),
                    TimestampValue = CreateTimestampValue(timestamp),
                    UserId = userId,
                },
            });
    }

    private static StaCommand CreateWriteSecuredCommand(
        string correlationId,
        int serverHandle,
        int itemHandle,
        int value,
        int currentUserId,
        int verifierUserId)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.WriteSecured,
                WriteSecured = new WriteSecuredCommand
                {
                    ServerHandle = serverHandle,
                    ItemHandle = itemHandle,
                    CurrentUserId = currentUserId,
                    VerifierUserId = verifierUserId,
                    Value = CreateIntegerValue(value),
                },
            });
    }

    private static StaCommand CreateWriteSecured2Command(
        string correlationId,
        int serverHandle,
        int itemHandle,
        int value,
        DateTime timestamp,
        int currentUserId,
        int verifierUserId)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.WriteSecured2,
                WriteSecured2 = new WriteSecured2Command
                {
                    ServerHandle = serverHandle,
                    ItemHandle = itemHandle,
                    CurrentUserId = currentUserId,
                    VerifierUserId = verifierUserId,
                    Value = CreateIntegerValue(value),
                    TimestampValue = CreateTimestampValue(timestamp),
                },
            });
    }

    private static StaCommand CreateUnAdviseCommand(
        string correlationId,
        int serverHandle,
        int itemHandle)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.UnAdvise,
                UnAdvise = new UnAdviseCommand
                {
                    ServerHandle = serverHandle,
                    ItemHandle = itemHandle,
                },
            });
    }

    private static StaCommand CreateSubscribeBulkCommand(
        string correlationId,
        int serverHandle,
        IEnumerable<string> tagAddresses)
    {
        SubscribeBulkCommand command = new()
        {
            ServerHandle = serverHandle,
        };
        command.TagAddresses.Add(tagAddresses);

        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.SubscribeBulk,
                SubscribeBulk = command,
            });
    }

    private static StaCommand CreateUnsubscribeBulkCommand(
        string correlationId,
        int serverHandle,
        IEnumerable<int> itemHandles)
    {
        UnsubscribeBulkCommand command = new()
        {
            ServerHandle = serverHandle,
        };
        command.ItemHandles.Add(itemHandles);

        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.UnsubscribeBulk,
                UnsubscribeBulk = command,
            });
    }

    private static StaCommand CreateWriteBulkCommand(
        string correlationId,
        int serverHandle,
        IEnumerable<(int itemHandle, int value, int userId)> entries)
    {
        WriteBulkCommand command = new()
        {
            ServerHandle = serverHandle,
        };
        foreach ((int itemHandle, int value, int userId) in entries)
        {
            command.Entries.Add(new WriteBulkEntry
            {
                ItemHandle = itemHandle,
                Value = CreateIntegerValue(value),
                UserId = userId,
            });
        }

        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.WriteBulk,
                WriteBulk = command,
            });
    }

    private static StaCommand CreateWrite2BulkCommand(
        string correlationId,
        int serverHandle,
        IEnumerable<(int itemHandle, int value, DateTime timestamp, int userId)> entries)
    {
        Write2BulkCommand command = new()
        {
            ServerHandle = serverHandle,
        };
        foreach ((int itemHandle, int value, DateTime timestamp, int userId) in entries)
        {
            command.Entries.Add(new Write2BulkEntry
            {
                ItemHandle = itemHandle,
                Value = CreateIntegerValue(value),
                TimestampValue = CreateTimestampValue(timestamp),
                UserId = userId,
            });
        }

        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.Write2Bulk,
                Write2Bulk = command,
            });
    }

    private static StaCommand CreateWriteSecuredBulkCommand(
        string correlationId,
        int serverHandle,
        IEnumerable<(int itemHandle, int currentUserId, int verifierUserId, int value)> entries)
    {
        WriteSecuredBulkCommand command = new()
        {
            ServerHandle = serverHandle,
        };
        foreach ((int itemHandle, int currentUserId, int verifierUserId, int value) in entries)
        {
            command.Entries.Add(new WriteSecuredBulkEntry
            {
                ItemHandle = itemHandle,
                CurrentUserId = currentUserId,
                VerifierUserId = verifierUserId,
                Value = CreateIntegerValue(value),
            });
        }

        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.WriteSecuredBulk,
                WriteSecuredBulk = command,
            });
    }

    private static StaCommand CreateWriteSecured2BulkCommand(
        string correlationId,
        int serverHandle,
        IEnumerable<(int itemHandle, int currentUserId, int verifierUserId, int value, DateTime timestamp)> entries)
    {
        WriteSecured2BulkCommand command = new()
        {
            ServerHandle = serverHandle,
        };
        foreach ((int itemHandle, int currentUserId, int verifierUserId, int value, DateTime timestamp) in entries)
        {
            command.Entries.Add(new WriteSecured2BulkEntry
            {
                ItemHandle = itemHandle,
                CurrentUserId = currentUserId,
                VerifierUserId = verifierUserId,
                Value = CreateIntegerValue(value),
                TimestampValue = CreateTimestampValue(timestamp),
            });
        }

        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.WriteSecured2Bulk,
                WriteSecured2Bulk = command,
            });
    }

    private static StaCommand CreateReadBulkCommand(
        string correlationId,
        int serverHandle,
        IEnumerable<string> tagAddresses,
        uint timeoutMs)
    {
        ReadBulkCommand command = new()
        {
            ServerHandle = serverHandle,
            TimeoutMs = timeoutMs,
        };
        command.TagAddresses.Add(tagAddresses);

        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.ReadBulk,
                ReadBulk = command,
            });
    }

    private static StaCommand CreateAdviseSupervisoryCommand(
        string correlationId,
        int serverHandle,
        int itemHandle)
    {
        return new StaCommand(
            "session-1",
            correlationId,
            new MxCommand
            {
                Kind = MxCommandKind.AdviseSupervisory,
                AdviseSupervisory = new AdviseSupervisoryCommand
                {
                    ServerHandle = serverHandle,
                    ItemHandle = itemHandle,
                },
            });
    }

    private static StaRuntime CreateRuntime()
    {
        return new StaRuntime(
            new NoopComApartmentInitializer(),
            new StaMessagePump(),
            TimeSpan.FromMilliseconds(25));
    }

    /// <summary>
    ///     Test sink that owns a real write-completion cache without touching the
    ///     MXAccess COM RCW (Attach is a no-op). Implements the provider seam so
    ///     <see cref="MxAccessSession.Create"/> shares this cache with the write
    ///     executor, letting tests record completions the executor's bounded
    ///     wait then observes.
    /// </summary>
    private sealed class CompletionCacheEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider
    {
        public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new MxAccessWriteCompletionCache();

        public void Attach(
            object mxAccessComObject,
            string sessionId)
        {
        }

        public void Detach()
        {
        }
    }

    private sealed class FakeMxAccessComObject : IMxAccessServer
    {
        private readonly int registerHandle;
        private readonly int addItemHandle;
        private readonly int addItem2Handle;
        private readonly Exception? unregisterException;
        private readonly Exception? addItemException;
        private readonly Exception? addItem2Exception;
        private readonly Exception? removeItemException;
        private readonly Exception? adviseException;
        private readonly Exception? unAdviseException;
        private readonly Exception? adviseSupervisoryException;
        private readonly IReadOnlyDictionary<int, Exception> writeExceptionByItemHandle;
        private readonly List<string> operationNames = new();

        /// <summary>
        ///     Invoked at the end of a successful Write/Write2/WriteSecured/
        ///     WriteSecured2 — stands in for MXAccess committing synchronously
        ///     and delivering OnWriteComplete while the COM call is still on
        ///     the stack, so tests can exercise the fast-completion ordering
        ///     edge on every correlated write kind.
        /// </summary>
        public Action? OnWriteCallback { get; set; }

        /// <summary>Initializes a fake MXAccess COM object with the given handles and optional exceptions.</summary>
        /// <param name="registerHandle">Return value for Register method.</param>
        /// <param name="addItemHandle">Return value for AddItem method.</param>
        /// <param name="addItem2Handle">Return value for AddItem2 method.</param>
        /// <param name="unregisterException">Exception to throw from Unregister, if any.</param>
        /// <param name="addItemException">Exception to throw from AddItem, if any.</param>
        /// <param name="addItem2Exception">Exception to throw from AddItem2, if any.</param>
        /// <param name="removeItemException">Exception to throw from RemoveItem, if any.</param>
        /// <param name="adviseException">Exception to throw from Advise, if any.</param>
        /// <param name="unAdviseException">Exception to throw from UnAdvise, if any.</param>
        /// <param name="adviseSupervisoryException">Exception to throw from AdviseSupervisory, if any.</param>
        /// <param name="writeExceptionByItemHandle">Map of item handles to exceptions thrown on write.</param>
        public FakeMxAccessComObject(
            int registerHandle,
            int addItemHandle = 0,
            int addItem2Handle = 0,
            Exception? unregisterException = null,
            Exception? addItemException = null,
            Exception? addItem2Exception = null,
            Exception? removeItemException = null,
            Exception? adviseException = null,
            Exception? unAdviseException = null,
            Exception? adviseSupervisoryException = null,
            IReadOnlyDictionary<int, Exception>? writeExceptionByItemHandle = null)
        {
            this.registerHandle = registerHandle;
            this.addItemHandle = addItemHandle;
            this.addItem2Handle = addItem2Handle;
            this.unregisterException = unregisterException;
            this.addItemException = addItemException;
            this.addItem2Exception = addItem2Exception;
            this.removeItemException = removeItemException;
            this.adviseException = adviseException;
            this.unAdviseException = unAdviseException;
            this.adviseSupervisoryException = adviseSupervisoryException;
            this.writeExceptionByItemHandle = writeExceptionByItemHandle
                ?? new Dictionary<int, Exception>();
        }

        /// <summary>Gets the client name passed to Register, if called.</summary>
        public string? RegisteredClientName { get; private set; }

        /// <summary>Gets the thread ID on which Register was called.</summary>
        public int? RegisterThreadId { get; private set; }

        /// <summary>Gets the server handle passed to Unregister, if called.</summary>
        public int? UnregisteredServerHandle { get; private set; }

        /// <summary>Gets the thread ID on which Unregister was called.</summary>
        public int? UnregisterThreadId { get; private set; }

        /// <summary>Gets the server handle passed to AddItem, if called.</summary>
        public int? AddItemServerHandle { get; private set; }

        /// <summary>Gets the item definition passed to AddItem, if called.</summary>
        public string? AddItemDefinition { get; private set; }

        /// <summary>Gets the thread ID on which AddItem was called.</summary>
        public int? AddItemThreadId { get; private set; }

        /// <summary>Gets the server handle passed to AddItem2, if called.</summary>
        public int? AddItem2ServerHandle { get; private set; }

        /// <summary>Gets the item definition passed to AddItem2, if called.</summary>
        public string? AddItem2Definition { get; private set; }

        /// <summary>Gets the item context passed to AddItem2, if called.</summary>
        public string? AddItem2Context { get; private set; }

        /// <summary>Gets the thread ID on which AddItem2 was called.</summary>
        public int? AddItem2ThreadId { get; private set; }

        /// <summary>Gets the server handle passed to RemoveItem, if called.</summary>
        public int? RemoveItemServerHandle { get; private set; }

        /// <summary>Gets the item handle passed to RemoveItem, if called.</summary>
        public int? RemovedItemHandle { get; private set; }

        /// <summary>Gets the thread ID on which RemoveItem was called.</summary>
        public int? RemoveItemThreadId { get; private set; }

        /// <summary>Gets the server handle passed to Advise, if called.</summary>
        public int? AdviseServerHandle { get; private set; }

        /// <summary>Gets the item handle passed to Advise, if called.</summary>
        public int? AdvisedItemHandle { get; private set; }

        /// <summary>Gets the thread ID on which Advise was called.</summary>
        public int? AdviseThreadId { get; private set; }

        /// <summary>Gets the server handle passed to UnAdvise, if called.</summary>
        public int? UnAdviseServerHandle { get; private set; }

        /// <summary>Gets the item handle passed to UnAdvise, if called.</summary>
        public int? UnAdvisedItemHandle { get; private set; }

        /// <summary>Gets the thread ID on which UnAdvise was called.</summary>
        public int? UnAdviseThreadId { get; private set; }

        /// <summary>Gets the server handle passed to AdviseSupervisory, if called.</summary>
        public int? AdviseSupervisoryServerHandle { get; private set; }

        /// <summary>Gets the item handle passed to AdviseSupervisory, if called.</summary>
        public int? AdviseSupervisoryItemHandle { get; private set; }

        /// <summary>Gets the thread ID on which AdviseSupervisory was called.</summary>
        public int? AdviseSupervisoryThreadId { get; private set; }

        /// <summary>Gets the list of operations performed on this fake object.</summary>
        public IReadOnlyList<string> OperationNames => operationNames.ToArray();

        /// <inheritdoc />
        public int Register(string clientName)
        {
            operationNames.Add($"Register:{clientName}");
            RegisteredClientName = clientName;
            RegisterThreadId = Environment.CurrentManagedThreadId;

            return registerHandle;
        }

        /// <inheritdoc />
        public void Unregister(int serverHandle)
        {
            operationNames.Add($"Unregister:{serverHandle}");
            UnregisteredServerHandle = serverHandle;
            UnregisterThreadId = Environment.CurrentManagedThreadId;

            if (unregisterException is not null)
            {
                throw unregisterException;
            }
        }

        /// <inheritdoc />
        public int AddItem(
            int serverHandle,
            string itemDefinition)
        {
            operationNames.Add($"AddItem:{serverHandle}:{itemDefinition}");
            AddItemServerHandle = serverHandle;
            AddItemDefinition = itemDefinition;
            AddItemThreadId = Environment.CurrentManagedThreadId;

            if (addItemException is not null)
            {
                throw addItemException;
            }

            return addItemHandle;
        }

        /// <inheritdoc />
        public int AddItem2(
            int serverHandle,
            string itemDefinition,
            string itemContext)
        {
            operationNames.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}");
            AddItem2ServerHandle = serverHandle;
            AddItem2Definition = itemDefinition;
            AddItem2Context = itemContext;
            AddItem2ThreadId = Environment.CurrentManagedThreadId;

            if (addItem2Exception is not null)
            {
                throw addItem2Exception;
            }

            return addItem2Handle;
        }

        /// <inheritdoc />
        public void RemoveItem(
            int serverHandle,
            int itemHandle)
        {
            operationNames.Add($"RemoveItem:{serverHandle}:{itemHandle}");
            RemoveItemServerHandle = serverHandle;
            RemovedItemHandle = itemHandle;
            RemoveItemThreadId = Environment.CurrentManagedThreadId;

            if (removeItemException is not null)
            {
                throw removeItemException;
            }
        }

        /// <inheritdoc />
        public void Advise(
            int serverHandle,
            int itemHandle)
        {
            operationNames.Add($"Advise:{serverHandle}:{itemHandle}");
            AdviseServerHandle = serverHandle;
            AdvisedItemHandle = itemHandle;
            AdviseThreadId = Environment.CurrentManagedThreadId;

            if (adviseException is not null)
            {
                throw adviseException;
            }
        }

        /// <inheritdoc />
        public void UnAdvise(
            int serverHandle,
            int itemHandle)
        {
            operationNames.Add($"UnAdvise:{serverHandle}:{itemHandle}");
            UnAdviseServerHandle = serverHandle;
            UnAdvisedItemHandle = itemHandle;
            UnAdviseThreadId = Environment.CurrentManagedThreadId;

            if (unAdviseException is not null)
            {
                throw unAdviseException;
            }
        }

        /// <inheritdoc />
        public void AdviseSupervisory(
            int serverHandle,
            int itemHandle)
        {
            operationNames.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}");
            AdviseSupervisoryServerHandle = serverHandle;
            AdviseSupervisoryItemHandle = itemHandle;
            AdviseSupervisoryThreadId = Environment.CurrentManagedThreadId;

            if (adviseSupervisoryException is not null)
            {
                throw adviseSupervisoryException;
            }
        }

        /// <summary>Gets the server handle passed to the most recent write, if called.</summary>
        public int? WriteServerHandle { get; private set; }

        /// <summary>Gets the item handle passed to the most recent write, if called.</summary>
        public int? WriteItemHandle { get; private set; }

        /// <summary>Gets the value passed to the most recent write, if called.</summary>
        public object? WriteValue { get; private set; }

        /// <summary>Gets the timestamp passed to the most recent timestamped write, if called.</summary>
        public object? WriteTimestamp { get; private set; }

        /// <summary>Gets the user id passed to the most recent Write/Write2, if called.</summary>
        public int? WriteUserId { get; private set; }

        /// <summary>Gets the current user id passed to the most recent secured write, if called.</summary>
        public int? WriteCurrentUserId { get; private set; }

        /// <summary>Gets the verifier user id passed to the most recent secured write, if called.</summary>
        public int? WriteVerifierUserId { get; private set; }

        /// <summary>Gets the thread ID on which the most recent write was called.</summary>
        public int? WriteThreadId { get; private set; }

        /// <inheritdoc />
        public void Write(
            int serverHandle,
            int itemHandle,
            object? value,
            int userId)
        {
            operationNames.Add($"Write:{serverHandle}:{itemHandle}");
            WriteServerHandle = serverHandle;
            WriteItemHandle = itemHandle;
            WriteValue = value;
            WriteUserId = userId;
            WriteThreadId = Environment.CurrentManagedThreadId;
            ThrowIfWriteFailureConfigured(itemHandle);
            OnWriteCallback?.Invoke();
        }

        /// <inheritdoc />
        public void Write2(
            int serverHandle,
            int itemHandle,
            object? value,
            object? timestamp,
            int userId)
        {
            operationNames.Add($"Write2:{serverHandle}:{itemHandle}");
            WriteServerHandle = serverHandle;
            WriteItemHandle = itemHandle;
            WriteValue = value;
            WriteTimestamp = timestamp;
            WriteUserId = userId;
            WriteThreadId = Environment.CurrentManagedThreadId;
            ThrowIfWriteFailureConfigured(itemHandle);
            OnWriteCallback?.Invoke();
        }

        /// <inheritdoc />
        public void WriteSecured(
            int serverHandle,
            int itemHandle,
            int currentUserId,
            int verifierUserId,
            object? value)
        {
            operationNames.Add($"WriteSecured:{serverHandle}:{itemHandle}");
            WriteServerHandle = serverHandle;
            WriteItemHandle = itemHandle;
            WriteCurrentUserId = currentUserId;
            WriteVerifierUserId = verifierUserId;
            WriteValue = value;
            WriteThreadId = Environment.CurrentManagedThreadId;
            ThrowIfWriteFailureConfigured(itemHandle);
            OnWriteCallback?.Invoke();
        }

        /// <inheritdoc />
        public void WriteSecured2(
            int serverHandle,
            int itemHandle,
            int currentUserId,
            int verifierUserId,
            object? value,
            object? timestamp)
        {
            operationNames.Add($"WriteSecured2:{serverHandle}:{itemHandle}");
            WriteServerHandle = serverHandle;
            WriteItemHandle = itemHandle;
            WriteCurrentUserId = currentUserId;
            WriteVerifierUserId = verifierUserId;
            WriteValue = value;
            WriteTimestamp = timestamp;
            WriteThreadId = Environment.CurrentManagedThreadId;
            ThrowIfWriteFailureConfigured(itemHandle);
            OnWriteCallback?.Invoke();
        }

        private void ThrowIfWriteFailureConfigured(int itemHandle)
        {
            // Per-item write-failure injection — used by the bulk-write tests to
            // exercise the "one bad entry surfaces as was_successful=false but
            // the loop keeps going" contract on BulkWriteResult.
            if (writeExceptionByItemHandle.TryGetValue(itemHandle, out Exception? exception))
            {
                throw exception;
            }
        }

        /// <summary>Gets the server handle passed to Suspend, if called.</summary>
        public int? SuspendServerHandle { get; private set; }

        /// <summary>Gets the item handle passed to Suspend, if called.</summary>
        public int? SuspendItemHandle { get; private set; }

        /// <summary>Gets the thread ID on which Suspend was called.</summary>
        public int? SuspendThreadId { get; private set; }

        /// <summary>Gets the server handle passed to Activate, if called.</summary>
        public int? ActivateServerHandle { get; private set; }

        /// <summary>Gets the item handle passed to Activate, if called.</summary>
        public int? ActivateItemHandle { get; private set; }

        /// <summary>Gets the thread ID on which Activate was called.</summary>
        public int? ActivateThreadId { get; private set; }

        /// <summary>Gets the server handle passed to AuthenticateUser, if called.</summary>
        public int? AuthenticateServerHandle { get; private set; }

        /// <summary>Gets the user name passed to AuthenticateUser, if called.</summary>
        public string? AuthenticateUserName { get; private set; }

        /// <summary>Gets the credential passed to AuthenticateUser, if called. Used only to prove non-logging.</summary>
        public string? AuthenticatePassword { get; private set; }

        /// <summary>Gets the thread ID on which AuthenticateUser was called.</summary>
        public int? AuthenticateThreadId { get; private set; }

        /// <summary>Gets the server handle passed to ArchestrAUserToId, if called.</summary>
        public int? ArchestrAUserToIdServerHandle { get; private set; }

        /// <summary>Gets the GUID passed to ArchestrAUserToId, if called.</summary>
        public string? ArchestrAUserToIdGuid { get; private set; }

        /// <summary>Gets the server handle passed to AddBufferedItem, if called.</summary>
        public int? AddBufferedItemServerHandle { get; private set; }

        /// <summary>Gets the item definition passed to AddBufferedItem, if called.</summary>
        public string? AddBufferedItemDefinition { get; private set; }

        /// <summary>Gets the item context passed to AddBufferedItem, if called.</summary>
        public string? AddBufferedItemContext { get; private set; }

        /// <summary>Gets the server handle passed to SetBufferedUpdateInterval, if called.</summary>
        public int? SetBufferedUpdateIntervalServerHandle { get; private set; }

        /// <summary>Gets the interval passed to SetBufferedUpdateInterval, if called.</summary>
        public int? SetBufferedUpdateIntervalValue { get; private set; }

        /// <inheritdoc />
        public object Suspend(int serverHandle, int itemHandle)
        {
            operationNames.Add($"Suspend:{serverHandle}:{itemHandle}");
            SuspendServerHandle = serverHandle;
            SuspendItemHandle = itemHandle;
            SuspendThreadId = Environment.CurrentManagedThreadId;
            return new FakeMxStatus { success = 1, category = 0, detectedBy = 0, detail = 0 };
        }

        /// <inheritdoc />
        public object Activate(int serverHandle, int itemHandle)
        {
            operationNames.Add($"Activate:{serverHandle}:{itemHandle}");
            ActivateServerHandle = serverHandle;
            ActivateItemHandle = itemHandle;
            ActivateThreadId = Environment.CurrentManagedThreadId;
            return new FakeMxStatus { success = 1, category = 0, detectedBy = 0, detail = 0 };
        }

        /// <inheritdoc />
        public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword)
        {
            // Deliberately does NOT include the password in the operation log.
            operationNames.Add($"AuthenticateUser:{serverHandle}:{verifyUser}");
            AuthenticateServerHandle = serverHandle;
            AuthenticateUserName = verifyUser;
            AuthenticatePassword = verifyUserPassword;
            AuthenticateThreadId = Environment.CurrentManagedThreadId;
            return 1;
        }

        /// <inheritdoc />
        public int ArchestrAUserToId(int serverHandle, string userIdGuid)
        {
            operationNames.Add($"ArchestrAUserToId:{serverHandle}:{userIdGuid}");
            ArchestrAUserToIdServerHandle = serverHandle;
            ArchestrAUserToIdGuid = userIdGuid;
            return 7;
        }

        /// <inheritdoc />
        public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext)
        {
            operationNames.Add($"AddBufferedItem:{serverHandle}:{itemDefinition}:{itemContext}");
            AddBufferedItemServerHandle = serverHandle;
            AddBufferedItemDefinition = itemDefinition;
            AddBufferedItemContext = itemContext;
            return 1;
        }

        /// <inheritdoc />
        public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds)
        {
            operationNames.Add($"SetBufferedUpdateInterval:{serverHandle}:{updateIntervalMilliseconds}");
            SetBufferedUpdateIntervalServerHandle = serverHandle;
            SetBufferedUpdateIntervalValue = updateIntervalMilliseconds;
        }

    }

    /// <summary>Factory for creating fake MXAccess COM objects in tests.</summary>
    private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory
    {
        /// <summary>Initializes a new instance of the FakeMxAccessComObjectFactory class.</summary>
        /// <param name="fakeComObject">The fake COM object to return from Create.</param>
        public FakeMxAccessComObjectFactory(FakeMxAccessComObject fakeComObject)
        {
            FakeComObject = fakeComObject;
        }

        /// <summary>Gets the fake COM object.</summary>
        public FakeMxAccessComObject FakeComObject { get; }

        /// <inheritdoc />
        public object Create()
        {
            return FakeComObject;
        }
    }

}
