using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;

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

public sealed class MxAccessSession : IDisposable
{
    private readonly object mxAccessComObject;
    private readonly IMxAccessServer mxAccessServer;
    private readonly IMxAccessEventSink eventSink;
    private readonly MxAccessHandleRegistry handleRegistry;
    private readonly MxAccessValueCache valueCache;
    private readonly MxAccessWriteCompletionCache writeCompletionCache;
    private bool disposed;

    private MxAccessSession(
        object mxAccessComObject,
        IMxAccessServer mxAccessServer,
        IMxAccessEventSink eventSink,
        MxAccessHandleRegistry handleRegistry,
        MxAccessValueCache valueCache,
        MxAccessWriteCompletionCache writeCompletionCache,
        int creationThreadId)
    {
        this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
        this.mxAccessServer = mxAccessServer ?? throw new ArgumentNullException(nameof(mxAccessServer));
        this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
        this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry));
        this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
        this.writeCompletionCache = writeCompletionCache ?? throw new ArgumentNullException(nameof(writeCompletionCache));
        CreationThreadId = creationThreadId;
    }

    /// <summary>The thread ID where this session was created.</summary>
    public int CreationThreadId { get; }

    /// <summary>The registry for tracking opened handles.</summary>
    public MxAccessHandleRegistry HandleRegistry => handleRegistry;

    /// <summary>
    ///     Per-session last-value cache populated by the event sink. ReadBulk
    ///     consults this cache before falling back to its own snapshot
    ///     lifecycle so it can serve a "current value" for an already-advised
    ///     tag without touching the existing subscription.
    /// </summary>
    public MxAccessValueCache ValueCache => valueCache;

    /// <summary>
    ///     Per-session OnWriteComplete completion cache populated by the event
    ///     sink. The write command executor consults it after a
    ///     WriteSecured/WriteSecured2 COM call so the unary reply can carry
    ///     the correlated completion outcome.
    /// </summary>
    public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache;

    /// <summary>Creates a WorkerReady message with session metadata.</summary>
    /// <param name="workerProcessId">Process ID of the worker.</param>
    /// <returns>The populated <see cref="WorkerReady"/> message.</returns>
    public WorkerReady CreateWorkerReady(int workerProcessId)
    {
        return new WorkerReady
        {
            WorkerProcessId = workerProcessId,
            MxaccessProgid = MxAccessInteropInfo.ProgId,
            MxaccessClsid = MxAccessInteropInfo.Clsid,
            ReadyTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
        };
    }

    /// <summary>
    ///     Test-only seam: constructs a session that bypasses the live COM
    ///     factory. The caller supplies the <see cref="IMxAccessServer"/> and
    ///     <see cref="IMxAccessEventSink"/> directly so tests can exercise
    ///     session methods without touching MXAccess COM. This is exposed via
    ///     <c>InternalsVisibleTo("ZB.MOM.WW.MxGateway.Worker.Tests")</c>; production code
    ///     must use the <see cref="Create"/> factory.
    ///
    ///     A runtime guard rejects an <see cref="MxAccessBaseEventSink"/> —
    ///     the production sink wired by <see cref="Create"/> — because the
    ///     <c>new object()</c> stand-in this factory uses for the COM object
    ///     would silently bypass <see cref="System.Runtime.InteropServices.Marshal.IsComObject(object)"/>
    ///     during disposal and mask lifetime regressions.
    /// </summary>
    /// <param name="mxAccessServer">The server abstraction to drive.</param>
    /// <param name="eventSink">The event sink to attach to the session.</param>
    /// <param name="handleRegistry">Optional handle registry; a fresh one is created when null.</param>
    /// <param name="valueCache">Optional value cache; a fresh one is created when null.</param>
    /// <param name="creationThreadId">Optional creation thread id; defaults to the current managed thread id.</param>
    /// <returns>A test-only <see cref="MxAccessSession"/> backed by the supplied test doubles.</returns>
    /// <exception cref="ArgumentException">
    ///     Thrown when <paramref name="eventSink"/> is the production
    ///     <see cref="MxAccessBaseEventSink"/>. Tests must pass a test
    ///     double sink — production code must use <see cref="Create"/>.
    /// </exception>
    internal static MxAccessSession CreateForTesting(
        IMxAccessServer mxAccessServer,
        IMxAccessEventSink eventSink,
        MxAccessHandleRegistry? handleRegistry = null,
        MxAccessValueCache? valueCache = null,
        int? creationThreadId = null)
    {
        if (eventSink is MxAccessBaseEventSink)
        {
            throw new ArgumentException(
                "CreateForTesting must not be used with the production MxAccessBaseEventSink. "
                + "Use MxAccessSession.Create for production code; pass a test-double IMxAccessEventSink here.",
                nameof(eventSink));
        }

        return new MxAccessSession(
            new object(),
            mxAccessServer,
            eventSink,
            handleRegistry ?? new MxAccessHandleRegistry(),
            valueCache ?? new MxAccessValueCache(),
            eventSink is IWriteCompletionCacheProvider provider
                ? provider.WriteCompletionCache
                : new MxAccessWriteCompletionCache(),
            creationThreadId ?? Environment.CurrentManagedThreadId);
    }

    /// <summary>Creates and initializes an MXAccess COM session.</summary>
    /// <param name="factory">Factory to create the MXAccess COM object.</param>
    /// <param name="eventSink">Event sink to attach to the COM object.</param>
    /// <param name="sessionId">Identifier of the session.</param>
    /// <returns>The initialized <see cref="MxAccessSession"/>.</returns>
    public static MxAccessSession Create(
        IMxAccessComObjectFactory factory,
        IMxAccessEventSink eventSink,
        string sessionId)
    {
        if (factory is null)
        {
            throw new ArgumentNullException(nameof(factory));
        }

        if (eventSink is null)
        {
            throw new ArgumentNullException(nameof(eventSink));
        }

        object? mxAccessComObject = null;

        try
        {
            mxAccessComObject = factory.Create();
            if (mxAccessComObject is null)
            {
                throw new InvalidOperationException("MXAccess COM factory returned null.");
            }

            eventSink.Attach(mxAccessComObject, sessionId);

            // Share the event sink's value cache when one is wired (the
            // production MxAccessBaseEventSink path) so OnDataChange writes and
            // ReadBulk reads both see the same instance. Fall back to a fresh
            // cache for test fakes that supply their own sink — ReadBulk simply
            // never serves cached values in that case.
            MxAccessValueCache valueCache = eventSink is MxAccessBaseEventSink baseSink
                ? baseSink.ValueCache
                : new MxAccessValueCache();

            // Share the sink's completion cache the same way (the production
            // sink and completion-aware test sinks implement the provider
            // seam); fall back to a fresh cache for other fakes — the write
            // executor then simply never observes a completion and replies
            // unconfirmed.
            MxAccessWriteCompletionCache writeCompletionCache = eventSink is IWriteCompletionCacheProvider provider
                ? provider.WriteCompletionCache
                : new MxAccessWriteCompletionCache();

            return new MxAccessSession(
                mxAccessComObject,
                new MxAccessComServer(mxAccessComObject),
                eventSink,
                new MxAccessHandleRegistry(),
                valueCache,
                writeCompletionCache,
                Environment.CurrentManagedThreadId);
        }
        catch (Exception exception)
        {
            try
            {
                eventSink.Detach();
            }
            catch
            {
                // Preserve the creation failure while still releasing the COM object below.
            }

            if (mxAccessComObject is not null && Marshal.IsComObject(mxAccessComObject))
            {
                Marshal.FinalReleaseComObject(mxAccessComObject);
            }

            throw MxAccessCreationException.From(exception);
        }
    }

    /// <summary>Registers a client with MXAccess and returns the server handle.</summary>
    /// <param name="clientName">Name of the client to register.</param>
    /// <returns>The server handle assigned to the registered client.</returns>
    public int Register(string clientName)
    {
        ThrowIfDisposed();

        int serverHandle = mxAccessServer.Register(clientName);
        handleRegistry.RegisterServerHandle(serverHandle, clientName);

        return serverHandle;
    }

    /// <summary>Unregisters a client from MXAccess.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    public void Unregister(int serverHandle)
    {
        ThrowIfDisposed();

        mxAccessServer.Unregister(serverHandle);
        handleRegistry.UnregisterServerHandle(serverHandle);
    }

    /// <summary>Adds an item to an MXAccess server and returns the item handle.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemDefinition">Definition or address of the item to add.</param>
    /// <returns>The item handle assigned to the added item.</returns>
    public int AddItem(
        int serverHandle,
        string itemDefinition)
    {
        ThrowIfDisposed();

        int itemHandle = mxAccessServer.AddItem(serverHandle, itemDefinition);
        handleRegistry.RegisterItemHandle(
            serverHandle,
            itemHandle,
            itemDefinition,
            string.Empty,
            hasItemContext: false);

        return itemHandle;
    }

    /// <summary>Adds an item with context to an MXAccess server and returns the item handle.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemDefinition">Definition or address of the item to add.</param>
    /// <param name="itemContext">Context string for the item.</param>
    /// <returns>The item handle assigned to the added item.</returns>
    public int AddItem2(
        int serverHandle,
        string itemDefinition,
        string itemContext)
    {
        ThrowIfDisposed();

        int itemHandle = mxAccessServer.AddItem2(serverHandle, itemDefinition, itemContext);
        handleRegistry.RegisterItemHandle(
            serverHandle,
            itemHandle,
            itemDefinition,
            itemContext,
            hasItemContext: true);

        return itemHandle;
    }

    /// <summary>Removes an item from an MXAccess server.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandle">Handle returned by the worker.</param>
    public void RemoveItem(
        int serverHandle,
        int itemHandle)
    {
        ThrowIfDisposed();

        mxAccessServer.RemoveItem(serverHandle, itemHandle);
        handleRegistry.RemoveItemHandle(serverHandle, itemHandle);
        // Evict the last-value entry so a future AddItem + Advise on the
        // same handle id (which MXAccess may reuse) does not serve a stale
        // OnDataChange snapshot from the previous lifetime.
        valueCache.Remove(serverHandle, itemHandle);
    }

    /// <summary>Advises on item changes with plain subscription.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandle">Handle returned by the worker.</param>
    public void Advise(
        int serverHandle,
        int itemHandle)
    {
        ThrowIfDisposed();

        mxAccessServer.Advise(serverHandle, itemHandle);
        handleRegistry.RegisterAdviceHandle(
            serverHandle,
            itemHandle,
            MxAccessAdviceKind.Plain);
    }

    /// <summary>Removes plain advice subscription from an item.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandle">Handle returned by the worker.</param>
    public void UnAdvise(
        int serverHandle,
        int itemHandle)
    {
        ThrowIfDisposed();

        mxAccessServer.UnAdvise(serverHandle, itemHandle);
        handleRegistry.RemoveAdviceHandles(serverHandle, itemHandle);
    }

    /// <summary>Advises on item changes with supervisory subscription.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandle">Handle returned by the worker.</param>
    public void AdviseSupervisory(
        int serverHandle,
        int itemHandle)
    {
        ThrowIfDisposed();

        mxAccessServer.AdviseSupervisory(serverHandle, itemHandle);
        handleRegistry.RegisterAdviceHandle(
            serverHandle,
            itemHandle,
            MxAccessAdviceKind.Supervisory);
    }

    /// <summary>Suspends data acquisition for an advised item and returns the native MXAccess status.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandle">Handle returned by the worker.</param>
    /// <returns>The boxed native MXAccess status produced by the call.</returns>
    public object Suspend(
        int serverHandle,
        int itemHandle)
    {
        ThrowIfDisposed();

        return mxAccessServer.Suspend(serverHandle, itemHandle);
    }

    /// <summary>Reactivates data acquisition for a suspended item and returns the native MXAccess status.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandle">Handle returned by the worker.</param>
    /// <returns>The boxed native MXAccess status produced by the call.</returns>
    public object Activate(
        int serverHandle,
        int itemHandle)
    {
        ThrowIfDisposed();

        return mxAccessServer.Activate(serverHandle, itemHandle);
    }

    /// <summary>Authenticates an MXAccess user and returns its user id.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="verifyUser">MXAccess user name to authenticate.</param>
    /// <param name="verifyUserPassword">Raw MXAccess credential; never logged.</param>
    /// <returns>The MXAccess user id for the authenticated user.</returns>
    public int AuthenticateUser(
        int serverHandle,
        string verifyUser,
        string verifyUserPassword)
    {
        ThrowIfDisposed();

        return mxAccessServer.AuthenticateUser(serverHandle, verifyUser, verifyUserPassword);
    }

    /// <summary>Resolves an ArchestrA user GUID to an MXAccess user id.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="userIdGuid">ArchestrA user GUID to resolve.</param>
    /// <returns>The MXAccess user id for the resolved user.</returns>
    public int ArchestrAUserToId(
        int serverHandle,
        string userIdGuid)
    {
        ThrowIfDisposed();

        return mxAccessServer.ArchestrAUserToId(serverHandle, userIdGuid);
    }

    /// <summary>Adds a buffered item to an MXAccess server and returns the item handle.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemDefinition">Definition or address of the item to add.</param>
    /// <param name="itemContext">Context string for the item.</param>
    /// <returns>The item handle assigned to the added item.</returns>
    public int AddBufferedItem(
        int serverHandle,
        string itemDefinition,
        string itemContext)
    {
        ThrowIfDisposed();

        int itemHandle = mxAccessServer.AddBufferedItem(serverHandle, itemDefinition, itemContext);
        handleRegistry.RegisterItemHandle(
            serverHandle,
            itemHandle,
            itemDefinition,
            itemContext,
            hasItemContext: true);

        return itemHandle;
    }

    /// <summary>Sets the buffered-update interval for an MXAccess server.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="updateIntervalMilliseconds">Buffered update interval in milliseconds.</param>
    public void SetBufferedUpdateInterval(
        int serverHandle,
        int updateIntervalMilliseconds)
    {
        ThrowIfDisposed();

        mxAccessServer.SetBufferedUpdateInterval(serverHandle, updateIntervalMilliseconds);
    }

    /// <summary>Writes a value to an item.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandle">Handle returned by the worker.</param>
    /// <param name="value">COM-marshalable value to write.</param>
    /// <param name="userId">MXAccess user id (security classification) for the write.</param>
    public void Write(
        int serverHandle,
        int itemHandle,
        object? value,
        int userId)
    {
        ThrowIfDisposed();

        mxAccessServer.Write(serverHandle, itemHandle, value, userId);
    }

    /// <summary>Writes a value with an explicit source timestamp to an item.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandle">Handle returned by the worker.</param>
    /// <param name="value">COM-marshalable value to write.</param>
    /// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
    /// <param name="userId">MXAccess user id (security classification) for the write.</param>
    public void Write2(
        int serverHandle,
        int itemHandle,
        object? value,
        object? timestamp,
        int userId)
    {
        ThrowIfDisposed();

        mxAccessServer.Write2(serverHandle, itemHandle, value, timestamp, userId);
    }

    /// <summary>Performs a secured/verified write to an item.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandle">Handle returned by the worker.</param>
    /// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
    /// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
    /// <param name="value">COM-marshalable value to write.</param>
    public void WriteSecured(
        int serverHandle,
        int itemHandle,
        int currentUserId,
        int verifierUserId,
        object? value)
    {
        ThrowIfDisposed();

        mxAccessServer.WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value);
    }

    /// <summary>Performs a secured/verified write with an explicit source timestamp.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandle">Handle returned by the worker.</param>
    /// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
    /// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
    /// <param name="value">COM-marshalable value to write.</param>
    /// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
    public void WriteSecured2(
        int serverHandle,
        int itemHandle,
        int currentUserId,
        int verifierUserId,
        object? value,
        object? timestamp)
    {
        ThrowIfDisposed();

        mxAccessServer.WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestamp);
    }

    /// <summary>Adds multiple items in bulk, returning success/failure results.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="tagAddresses">Enumerable of item definitions to add.</param>
    /// <returns>The per-item success/failure results, in the order the tags were provided.</returns>
    public IReadOnlyList<SubscribeResult> AddItemBulk(
        int serverHandle,
        IEnumerable<string> tagAddresses)
    {
        ThrowIfDisposed();
        if (tagAddresses is null)
        {
            throw new ArgumentNullException(nameof(tagAddresses));
        }

        List<SubscribeResult> results = new();
        foreach (string? tagAddress in tagAddresses)
        {
            if (string.IsNullOrWhiteSpace(tagAddress))
            {
                results.Add(Failed(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, "Tag address is required."));
                continue;
            }

            try
            {
                int itemHandle = AddItem(serverHandle, tagAddress);
                results.Add(Succeeded(serverHandle, tagAddress, itemHandle));
            }
            catch (Exception exception)
            {
                results.Add(Failed(serverHandle, tagAddress, itemHandle: 0, exception.Message));
            }
        }

        return results;
    }

    /// <summary>Advises on multiple items in bulk, returning success/failure results.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandles">Enumerable of item handles to advise on.</param>
    /// <returns>The per-item success/failure results, in the order the handles were provided.</returns>
    public IReadOnlyList<SubscribeResult> AdviseItemBulk(
        int serverHandle,
        IEnumerable<int> itemHandles)
    {
        ThrowIfDisposed();
        if (itemHandles is null)
        {
            throw new ArgumentNullException(nameof(itemHandles));
        }

        List<SubscribeResult> results = new();
        foreach (int itemHandle in itemHandles)
        {
            try
            {
                Advise(serverHandle, itemHandle);
                results.Add(Succeeded(serverHandle, string.Empty, itemHandle));
            }
            catch (Exception exception)
            {
                results.Add(Failed(serverHandle, string.Empty, itemHandle, exception.Message));
            }
        }

        return results;
    }

    /// <summary>Removes multiple items in bulk, returning success/failure results.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandles">Enumerable of item handles to remove.</param>
    /// <returns>The per-item success/failure results, in the order the handles were provided.</returns>
    public IReadOnlyList<SubscribeResult> RemoveItemBulk(
        int serverHandle,
        IEnumerable<int> itemHandles)
    {
        ThrowIfDisposed();
        if (itemHandles is null)
        {
            throw new ArgumentNullException(nameof(itemHandles));
        }

        List<SubscribeResult> results = new();
        foreach (int itemHandle in itemHandles)
        {
            try
            {
                RemoveItem(serverHandle, itemHandle);
                results.Add(Succeeded(serverHandle, string.Empty, itemHandle));
            }
            catch (Exception exception)
            {
                results.Add(Failed(serverHandle, string.Empty, itemHandle, exception.Message));
            }
        }

        return results;
    }

    /// <summary>Removes advice subscriptions from multiple items in bulk, returning success/failure results.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandles">Enumerable of item handles to unadvise.</param>
    /// <returns>The per-item success/failure results, in the order the handles were provided.</returns>
    public IReadOnlyList<SubscribeResult> UnAdviseItemBulk(
        int serverHandle,
        IEnumerable<int> itemHandles)
    {
        ThrowIfDisposed();
        if (itemHandles is null)
        {
            throw new ArgumentNullException(nameof(itemHandles));
        }

        List<SubscribeResult> results = new();
        foreach (int itemHandle in itemHandles)
        {
            try
            {
                UnAdvise(serverHandle, itemHandle);
                results.Add(Succeeded(serverHandle, string.Empty, itemHandle));
            }
            catch (Exception exception)
            {
                results.Add(Failed(serverHandle, string.Empty, itemHandle, exception.Message));
            }
        }

        return results;
    }

    /// <summary>Adds multiple items and subscribes to them in bulk, returning success/failure results.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="tagAddresses">Enumerable of item definitions to add and subscribe to.</param>
    /// <returns>The per-item success/failure results, in the order the tags were provided.</returns>
    public IReadOnlyList<SubscribeResult> SubscribeBulk(
        int serverHandle,
        IEnumerable<string> tagAddresses)
    {
        ThrowIfDisposed();
        if (tagAddresses is null)
        {
            throw new ArgumentNullException(nameof(tagAddresses));
        }

        List<SubscribeResult> results = new();
        foreach (string? tagAddress in tagAddresses)
        {
            if (string.IsNullOrWhiteSpace(tagAddress))
            {
                results.Add(Failed(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, "Tag address is required."));
                continue;
            }

            int itemHandle = 0;
            try
            {
                itemHandle = AddItem(serverHandle, tagAddress);
                Advise(serverHandle, itemHandle);
                results.Add(Succeeded(serverHandle, tagAddress, itemHandle));
            }
            catch (Exception exception)
            {
                string errorMessage = exception.Message;
                if (itemHandle != 0)
                {
                    errorMessage = AppendRemoveItemCleanup(serverHandle, itemHandle, errorMessage);
                }

                results.Add(Failed(serverHandle, tagAddress, itemHandle, errorMessage));
            }
        }

        return results;
    }

    /// <summary>Unsubscribes from multiple items in bulk, returning success/failure results.</summary>
    /// <param name="serverHandle">Handle returned by the worker.</param>
    /// <param name="itemHandles">Enumerable of item handles to unsubscribe from.</param>
    /// <returns>The per-item success/failure results, in the order the handles were provided.</returns>
    public IReadOnlyList<SubscribeResult> UnsubscribeBulk(
        int serverHandle,
        IEnumerable<int> itemHandles)
    {
        ThrowIfDisposed();
        if (itemHandles is null)
        {
            throw new ArgumentNullException(nameof(itemHandles));
        }

        List<SubscribeResult> results = new();
        foreach (int itemHandle in itemHandles)
        {
            List<string> errors = new();

            try
            {
                UnAdvise(serverHandle, itemHandle);
            }
            catch (Exception exception)
            {
                errors.Add($"UnAdvise failed: {exception.Message}");
            }

            try
            {
                RemoveItem(serverHandle, itemHandle);
            }
            catch (Exception exception)
            {
                errors.Add($"RemoveItem failed: {exception.Message}");
            }

            results.Add(errors.Count == 0
                ? Succeeded(serverHandle, string.Empty, itemHandle)
                : Failed(serverHandle, string.Empty, itemHandle, string.Join("; ", errors)));
        }

        return results;
    }

    /// <summary>
    ///     Bulk write — runs <see cref="Write"/> sequentially for each entry.
    ///     Each entry's <paramref name="convertValue"/> turns the protobuf
    ///     MxValue into a COM-marshalable variant. Per-item failures are
    ///     captured as <see cref="BulkWriteResult"/> entries with
    ///     <c>was_successful = false</c>; the loop never throws.
    /// </summary>
    /// <param name="serverHandle">The MXAccess server handle.</param>
    /// <param name="entries">The write entries to process.</param>
    /// <param name="convertValue">Converts protobuf MxValue to COM-compatible variant.</param>
    /// <returns>The per-entry write results, in the order the entries were provided.</returns>
    public IReadOnlyList<BulkWriteResult> WriteBulk(
        int serverHandle,
        IReadOnlyList<WriteBulkEntry> entries,
        Func<MxValue, object?> convertValue)
    {
        ThrowIfDisposed();
        if (entries is null)
        {
            throw new ArgumentNullException(nameof(entries));
        }

        if (convertValue is null)
        {
            throw new ArgumentNullException(nameof(convertValue));
        }

        List<BulkWriteResult> results = new(entries.Count);
        foreach (WriteBulkEntry entry in entries)
        {
            results.Add(ExecuteBulkWriteEntry(
                serverHandle,
                entry.ItemHandle,
                () => Write(serverHandle, entry.ItemHandle, convertValue(entry.Value), entry.UserId)));
        }

        return results;
    }

    /// <summary>Bulk Write2 — sequential MXAccess <see cref="Write2"/> per entry.</summary>
    /// <param name="serverHandle">The MXAccess server handle.</param>
    /// <param name="entries">The write2 entries to process.</param>
    /// <param name="convertValue">Converts protobuf MxValue to COM-compatible variant.</param>
    /// <returns>The per-entry write results, in the order the entries were provided.</returns>
    public IReadOnlyList<BulkWriteResult> Write2Bulk(
        int serverHandle,
        IReadOnlyList<Write2BulkEntry> entries,
        Func<MxValue, object?> convertValue)
    {
        ThrowIfDisposed();
        if (entries is null)
        {
            throw new ArgumentNullException(nameof(entries));
        }

        if (convertValue is null)
        {
            throw new ArgumentNullException(nameof(convertValue));
        }

        List<BulkWriteResult> results = new(entries.Count);
        foreach (Write2BulkEntry entry in entries)
        {
            results.Add(ExecuteBulkWriteEntry(
                serverHandle,
                entry.ItemHandle,
                () => Write2(
                    serverHandle,
                    entry.ItemHandle,
                    convertValue(entry.Value),
                    convertValue(entry.TimestampValue),
                    entry.UserId)));
        }

        return results;
    }

    /// <summary>Bulk WriteSecured — sequential MXAccess <see cref="WriteSecured"/> per entry.</summary>
    /// <param name="serverHandle">The MXAccess server handle.</param>
    /// <param name="entries">The WriteSecured entries to process.</param>
    /// <param name="convertValue">Converts protobuf MxValue to COM-compatible variant.</param>
    /// <returns>The per-entry write results, in the order the entries were provided.</returns>
    public IReadOnlyList<BulkWriteResult> WriteSecuredBulk(
        int serverHandle,
        IReadOnlyList<WriteSecuredBulkEntry> entries,
        Func<MxValue, object?> convertValue)
    {
        ThrowIfDisposed();
        if (entries is null)
        {
            throw new ArgumentNullException(nameof(entries));
        }

        if (convertValue is null)
        {
            throw new ArgumentNullException(nameof(convertValue));
        }

        List<BulkWriteResult> results = new(entries.Count);
        foreach (WriteSecuredBulkEntry entry in entries)
        {
            results.Add(ExecuteBulkWriteEntry(
                serverHandle,
                entry.ItemHandle,
                () => WriteSecured(
                    serverHandle,
                    entry.ItemHandle,
                    entry.CurrentUserId,
                    entry.VerifierUserId,
                    convertValue(entry.Value))));
        }

        return results;
    }

    /// <summary>Bulk WriteSecured2 — sequential MXAccess <see cref="WriteSecured2"/> per entry.</summary>
    /// <param name="serverHandle">The MXAccess server handle.</param>
    /// <param name="entries">The WriteSecured2 entries to process.</param>
    /// <param name="convertValue">Converts protobuf MxValue to COM-compatible variant.</param>
    /// <returns>The per-entry write results, in the order the entries were provided.</returns>
    public IReadOnlyList<BulkWriteResult> WriteSecured2Bulk(
        int serverHandle,
        IReadOnlyList<WriteSecured2BulkEntry> entries,
        Func<MxValue, object?> convertValue)
    {
        ThrowIfDisposed();
        if (entries is null)
        {
            throw new ArgumentNullException(nameof(entries));
        }

        if (convertValue is null)
        {
            throw new ArgumentNullException(nameof(convertValue));
        }

        List<BulkWriteResult> results = new(entries.Count);
        foreach (WriteSecured2BulkEntry entry in entries)
        {
            results.Add(ExecuteBulkWriteEntry(
                serverHandle,
                entry.ItemHandle,
                () => WriteSecured2(
                    serverHandle,
                    entry.ItemHandle,
                    entry.CurrentUserId,
                    entry.VerifierUserId,
                    convertValue(entry.Value),
                    convertValue(entry.TimestampValue))));
        }

        return results;
    }

    /// <summary>
    ///     Bulk read snapshot. For each requested tag, returns the most recent
    ///     OnDataChange value if the tag is already advised AND a cached value
    ///     exists (no subscription side effects); otherwise takes the AddItem
    ///     + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle itself.
    ///     <paramref name="timeout"/> bounds the wait per-tag in the snapshot
    ///     case; <paramref name="pumpStep"/> is invoked on every poll
    ///     iteration so the worker's STA can dispatch the incoming MXAccess
    ///     message that carries the value.
    /// </summary>
    /// <param name="serverHandle">The MXAccess server handle.</param>
    /// <param name="tagAddresses">The tag addresses to read.</param>
    /// <param name="timeout">The timeout per tag.</param>
    /// <param name="pumpStep">Action invoked on each poll iteration.</param>
    /// <returns>The per-tag read results, in the order the tags were provided.</returns>
    public IReadOnlyList<BulkReadResult> ReadBulk(
        int serverHandle,
        IReadOnlyList<string> tagAddresses,
        TimeSpan timeout,
        Action pumpStep)
    {
        ThrowIfDisposed();
        if (tagAddresses is null)
        {
            throw new ArgumentNullException(nameof(tagAddresses));
        }

        if (pumpStep is null)
        {
            throw new ArgumentNullException(nameof(pumpStep));
        }

        List<BulkReadResult> results = new(tagAddresses.Count);
        foreach (string? tagAddress in tagAddresses)
        {
            if (string.IsNullOrWhiteSpace(tagAddress))
            {
                results.Add(FailedRead(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, wasCached: false, "Tag address is required."));
                continue;
            }

            results.Add(ReadOneTag(serverHandle, tagAddress, timeout, pumpStep));
        }

        return results;
    }

    private BulkReadResult ReadOneTag(
        int serverHandle,
        string tagAddress,
        TimeSpan timeout,
        Action pumpStep)
    {
        // 1. Cached-and-advised fast path: scan the registry for a live item
        //    handle matching this tag and check whether the value cache has a
        //    payload for it. If so, return the cached value without touching
        //    the existing subscription — the caller didn't create it, so
        //    ReadBulk must not tear it down.
        if (TryGetCachedReadFor(serverHandle, tagAddress, out int cachedItemHandle, out MxAccessValueCache.CachedValue cachedValue))
        {
            return SucceededRead(
                serverHandle,
                tagAddress,
                cachedItemHandle,
                wasCached: true,
                cachedValue);
        }

        // 2. Snapshot lifecycle. Reserve our own item handle, advise, pump
        //    until we see a fresh OnDataChange (or the deadline elapses),
        //    then tear it down.
        int itemHandle = 0;
        bool advised = false;
        try
        {
            itemHandle = AddItem(serverHandle, tagAddress);
            ulong baseline = valueCache.CurrentVersion(serverHandle, itemHandle);
            Advise(serverHandle, itemHandle);
            advised = true;

            DateTime deadline = DateTime.UtcNow + timeout;
            bool gotValue = valueCache.TryWaitForUpdate(
                serverHandle,
                itemHandle,
                baseline,
                deadline,
                pumpStep,
                out MxAccessValueCache.CachedValue snapshot);

            return gotValue
                ? SucceededRead(serverHandle, tagAddress, itemHandle, wasCached: false, snapshot)
                : FailedRead(
                    serverHandle,
                    tagAddress,
                    itemHandle,
                    wasCached: false,
                    $"ReadBulk timed out after {timeout.TotalMilliseconds:F0} ms waiting for first OnDataChange.");
        }
        catch (Exception exception)
        {
            return FailedRead(serverHandle, tagAddress, itemHandle, wasCached: false, exception.Message);
        }
        finally
        {
            // Snapshot teardown — best-effort. Errors here are noted on the
            // diagnostic message of the original result (above) by appending
            // a cleanup suffix; we never re-throw from finally.
            if (advised)
            {
                try { UnAdvise(serverHandle, itemHandle); } catch { /* swallow — best effort */ }
            }

            if (itemHandle != 0)
            {
                try { RemoveItem(serverHandle, itemHandle); } catch { /* swallow — best effort */ }
            }
        }
    }

    private bool TryGetCachedReadFor(
        int serverHandle,
        string tagAddress,
        out int itemHandle,
        out MxAccessValueCache.CachedValue cachedValue)
    {
        // Dictionary probe, not a scan: the registry keeps a reverse
        // (server handle, item definition) → item handle index, so a bulk read
        // no longer walks the whole item table once per tag. The index matches
        // the tag ordinally and hands the handles back in ascending order — the
        // same order the previous linear scan over the sorted ItemHandles view
        // visited them — so the first advised-and-cached candidate still wins.
        IReadOnlyList<int> candidates = handleRegistry.GetItemHandlesForDefinition(serverHandle, tagAddress);

        for (int index = 0; index < candidates.Count; index++)
        {
            int candidate = candidates[index];

            if (!handleRegistry.ContainsAdviceHandle(serverHandle, candidate, MxAccessAdviceKind.Plain)
                && !handleRegistry.ContainsAdviceHandle(serverHandle, candidate, MxAccessAdviceKind.Supervisory))
            {
                // Tag is added but not advised — no fresh OnDataChange will
                // arrive without us advising. Fall through to the snapshot
                // path which advises explicitly.
                continue;
            }

            if (valueCache.TryGet(serverHandle, candidate, out cachedValue))
            {
                itemHandle = candidate;
                return true;
            }
        }

        itemHandle = 0;
        cachedValue = default;
        return false;
    }

    private BulkWriteResult ExecuteBulkWriteEntry(
        int serverHandle,
        int itemHandle,
        Action invokeWrite)
    {
        try
        {
            invokeWrite();
            return new BulkWriteResult
            {
                ServerHandle = serverHandle,
                ItemHandle = itemHandle,
                WasSuccessful = true,
                ErrorMessage = string.Empty,
            };
        }
        catch (System.Runtime.InteropServices.COMException comException)
        {
            BulkWriteResult result = new()
            {
                ServerHandle = serverHandle,
                ItemHandle = itemHandle,
                WasSuccessful = false,
                ErrorMessage = comException.Message,
            };
            result.Hresult = comException.HResult;
            return result;
        }
        catch (Exception exception)
        {
            return new BulkWriteResult
            {
                ServerHandle = serverHandle,
                ItemHandle = itemHandle,
                WasSuccessful = false,
                ErrorMessage = exception.Message,
            };
        }
    }

    private static BulkReadResult SucceededRead(
        int serverHandle,
        string tagAddress,
        int itemHandle,
        bool wasCached,
        MxAccessValueCache.CachedValue snapshot)
    {
        BulkReadResult result = new()
        {
            ServerHandle = serverHandle,
            TagAddress = tagAddress,
            ItemHandle = itemHandle,
            WasSuccessful = true,
            WasCached = wasCached,
            Quality = snapshot.Quality,
            ErrorMessage = string.Empty,
        };

        if (snapshot.Value is not null)
        {
            result.Value = snapshot.Value;
        }

        if (snapshot.SourceTimestamp is not null)
        {
            result.SourceTimestamp = snapshot.SourceTimestamp;
        }

        if (snapshot.Statuses is not null)
        {
            result.Statuses.Add(snapshot.Statuses);
        }

        return result;
    }

    private static BulkReadResult FailedRead(
        int serverHandle,
        string tagAddress,
        int itemHandle,
        bool wasCached,
        string errorMessage)
    {
        return new BulkReadResult
        {
            ServerHandle = serverHandle,
            TagAddress = tagAddress,
            ItemHandle = itemHandle,
            WasSuccessful = false,
            WasCached = wasCached,
            ErrorMessage = errorMessage,
        };
    }

    /// <summary>Gracefully shuts down the session, cleaning up all handles.</summary>
    /// <returns>The shutdown result, including any per-handle cleanup failures.</returns>
    public MxAccessShutdownResult ShutdownGracefully()
    {
        if (disposed)
        {
            return new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>());
        }

        List<MxAccessShutdownFailure> failures = new();

        CleanupAdviceHandles(failures);
        CleanupItemHandles(failures);
        CleanupServerHandles(failures);
        DisposeCore(failures);

        return new MxAccessShutdownResult(failures);
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (disposed)
        {
            return;
        }

        DisposeCore(failures: null);
    }

    private void CleanupAdviceHandles(ICollection<MxAccessShutdownFailure> failures)
    {
        HashSet<long> cleanedPairs = new();
        foreach (RegisteredAdviceHandle adviceHandle in handleRegistry.AdviceHandles)
        {
            long key = CreateItemKey(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
            if (!cleanedPairs.Add(key))
            {
                continue;
            }

            try
            {
                mxAccessServer.UnAdvise(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
                handleRegistry.RemoveAdviceHandles(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
            }
            catch (Exception exception)
            {
                failures.Add(new MxAccessShutdownFailure(
                    nameof(UnAdvise),
                    adviceHandle.ServerHandle,
                    adviceHandle.ItemHandle,
                    exception));
            }
        }
    }

    private void CleanupItemHandles(ICollection<MxAccessShutdownFailure> failures)
    {
        foreach (RegisteredItemHandle itemHandle in handleRegistry.ItemHandles)
        {
            try
            {
                mxAccessServer.RemoveItem(itemHandle.ServerHandle, itemHandle.ItemHandle);
                handleRegistry.RemoveItemHandle(itemHandle.ServerHandle, itemHandle.ItemHandle);
            }
            catch (Exception exception)
            {
                failures.Add(new MxAccessShutdownFailure(
                    nameof(RemoveItem),
                    itemHandle.ServerHandle,
                    itemHandle.ItemHandle,
                    exception));
            }
        }
    }

    private void CleanupServerHandles(ICollection<MxAccessShutdownFailure> failures)
    {
        foreach (RegisteredServerHandle serverHandle in handleRegistry.ServerHandles)
        {
            try
            {
                mxAccessServer.Unregister(serverHandle.ServerHandle);
                handleRegistry.UnregisterServerHandle(serverHandle.ServerHandle);
            }
            catch (Exception exception)
            {
                failures.Add(new MxAccessShutdownFailure(
                    nameof(Unregister),
                    serverHandle.ServerHandle,
                    itemHandle: null,
                    exception));
            }
        }
    }

    private static long CreateItemKey(
        int serverHandle,
        int itemHandle)
    {
        return ((long)serverHandle << 32) | (uint)itemHandle;
    }

    private string AppendRemoveItemCleanup(
        int serverHandle,
        int itemHandle,
        string errorMessage)
    {
        try
        {
            RemoveItem(serverHandle, itemHandle);
            return $"{errorMessage}; cleanup RemoveItem succeeded.";
        }
        catch (Exception cleanupException)
        {
            return $"{errorMessage}; cleanup RemoveItem failed: {cleanupException.Message}";
        }
    }

    private static SubscribeResult Succeeded(
        int serverHandle,
        string tagAddress,
        int itemHandle)
    {
        return new SubscribeResult
        {
            ServerHandle = serverHandle,
            TagAddress = tagAddress,
            ItemHandle = itemHandle,
            WasSuccessful = true,
            ErrorMessage = string.Empty,
        };
    }

    private static SubscribeResult Failed(
        int serverHandle,
        string tagAddress,
        int itemHandle,
        string errorMessage)
    {
        return new SubscribeResult
        {
            ServerHandle = serverHandle,
            TagAddress = tagAddress,
            ItemHandle = itemHandle,
            WasSuccessful = false,
            ErrorMessage = errorMessage,
        };
    }

    private void DisposeCore(ICollection<MxAccessShutdownFailure>? failures)
    {
        Exception? detachException = null;
        try
        {
            eventSink.Detach();
        }
        catch (Exception exception)
        {
            detachException = exception;
            failures?.Add(new MxAccessShutdownFailure(
                "DetachEvents",
                serverHandle: null,
                itemHandle: null,
                exception));
        }

        try
        {
            if (Marshal.IsComObject(mxAccessComObject))
            {
                Marshal.FinalReleaseComObject(mxAccessComObject);
            }
        }
        catch (Exception exception) when (failures is not null)
        {
            failures.Add(new MxAccessShutdownFailure(
                "ReleaseComObject",
                serverHandle: null,
                itemHandle: null,
                exception));
        }

        disposed = true;
        if (detachException is not null && failures is null)
        {
            throw detachException;
        }
    }

    private void ThrowIfDisposed()
    {
        if (disposed)
        {
            throw new ObjectDisposedException(nameof(MxAccessSession));
        }
    }
}
