package com.zb.mom.ww.mxgateway.client;

import java.security.SecureRandom;
import java.time.Duration;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import mxaccess_gateway.v1.MxaccessGateway.ActivateCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddBufferedItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddItem2Command;
import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseSupervisoryCommand;
import mxaccess_gateway.v1.MxaccessGateway.ArchestrAUserToIdCommand;
import mxaccess_gateway.v1.MxaccessGateway.AuthenticateUserCommand;
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommand;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
import mxaccess_gateway.v1.MxaccessGateway.MxSparseArray;
import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.SetBufferedUpdateIntervalCommand;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import mxaccess_gateway.v1.MxaccessGateway.SuspendCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnregisterCommand;
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2Command;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkEntry;

/**
 * Typed handle for a single MXAccess gateway session.
 *
 * <p>Wraps an {@link OpenSessionReply} together with the {@link MxGatewayClient}
 * that opened it and exposes the MXAccess command surface (Register, AddItem,
 * Advise, bulk subscribe variants, Write, event streaming, and close). Each
 * command request carries a freshly generated client correlation id.
 */
public final class MxGatewaySession implements AutoCloseable {
    private static final SecureRandom RANDOM = new SecureRandom();

    private final MxGatewayClient client;
    private final OpenSessionReply openReply;
    private CloseSessionReply closeReply;

    MxGatewaySession(MxGatewayClient client, OpenSessionReply openReply) {
        this.client = Objects.requireNonNull(client, "client");
        this.openReply = Objects.requireNonNull(openReply, "openReply");
    }

    /**
     * Builds a session handle for an existing gateway session id without
     * issuing an {@code OpenSession} call. Useful for CLI tools that operate
     * against a session opened in a separate invocation.
     *
     * @param client the gateway client used for further commands
     * @param sessionId the existing gateway session id
     * @return a session handle bound to the supplied id
     */
    public static MxGatewaySession forSessionId(MxGatewayClient client, String sessionId) {
        return new MxGatewaySession(
                client, OpenSessionReply.newBuilder().setSessionId(sessionId).build());
    }

    /**
     * Returns the gateway-assigned session id.
     *
     * @return the session id
     */
    public String sessionId() {
        return openReply.getSessionId();
    }

    /**
     * Returns the original {@link OpenSessionReply} that this session was opened with.
     *
     * @return the open-session reply
     */
    public OpenSessionReply openReply() {
        return openReply;
    }

    /**
     * Sends a {@code CloseSession} RPC and caches the reply so subsequent calls
     * are idempotent.
     *
     * @return the raw close-session reply
     * @throws MxGatewayException on transport or protocol failure
     */
    public synchronized CloseSessionReply closeRaw() {
        if (closeReply == null) {
            closeReply = client.closeSessionRaw(CloseSessionRequest.newBuilder()
                    .setSessionId(sessionId())
                    .setClientCorrelationId(newCorrelationId())
                    .build());
        }
        return closeReply;
    }

    @Override
    public void close() {
        closeRaw();
    }

    /**
     * Invokes MXAccess {@code Register} and returns the server handle.
     *
     * @param clientName the MXAccess client name to register
     * @return the {@code ServerHandle} returned by MXAccess
     * @throws MxGatewayException on transport or protocol failure
     */
    public int register(String clientName) {
        MxCommandReply reply = registerRaw(clientName);
        if (reply.hasRegister()) {
            return reply.getRegister().getServerHandle();
        }
        return reply.getReturnValue().getInt32Value();
    }

    /**
     * Invokes MXAccess {@code Register} and returns the raw reply.
     *
     * @param clientName the MXAccess client name to register
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     */
    public MxCommandReply registerRaw(String clientName) {
        return invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER)
                .setRegister(RegisterCommand.newBuilder().setClientName(clientName))
                .build());
    }

    /**
     * Invokes MXAccess {@code Unregister}.
     *
     * @param serverHandle the {@code ServerHandle} returned by {@link #register(String)}
     * @throws MxGatewayException on transport or protocol failure
     */
    public void unregister(int serverHandle) {
        invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_UNREGISTER)
                .setUnregister(UnregisterCommand.newBuilder().setServerHandle(serverHandle))
                .build());
    }

    /**
     * Invokes MXAccess {@code AddItem} and returns the new item handle.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemDefinition the MXAccess item definition (tag reference)
     * @return the {@code ItemHandle} assigned by MXAccess
     * @throws MxGatewayException on transport or protocol failure
     */
    public int addItem(int serverHandle, String itemDefinition) {
        MxCommandReply reply = addItemRaw(serverHandle, itemDefinition);
        if (reply.hasAddItem()) {
            return reply.getAddItem().getItemHandle();
        }
        return reply.getReturnValue().getInt32Value();
    }

    /**
     * Invokes MXAccess {@code AddItem} and returns the raw reply.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemDefinition the MXAccess item definition
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     */
    public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
        return invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM)
                .setAddItem(AddItemCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemDefinition(itemDefinition))
                .build());
    }

    /**
     * Invokes MXAccess {@code AddItem2} and returns the new item handle.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemDefinition the MXAccess item definition
     * @param itemContext the MXAccess item context (e.g. galaxy/object scope)
     * @return the {@code ItemHandle} assigned by MXAccess
     * @throws MxGatewayException on transport or protocol failure
     */
    public int addItem2(int serverHandle, String itemDefinition, String itemContext) {
        MxCommandReply reply = addItem2Raw(serverHandle, itemDefinition, itemContext);
        if (reply.hasAddItem2()) {
            return reply.getAddItem2().getItemHandle();
        }
        return reply.getReturnValue().getInt32Value();
    }

    /**
     * Invokes MXAccess {@code AddItem2} and returns the raw reply.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemDefinition the MXAccess item definition
     * @param itemContext the MXAccess item context
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     */
    public MxCommandReply addItem2Raw(int serverHandle, String itemDefinition, String itemContext) {
        return invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM2)
                .setAddItem2(AddItem2Command.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemDefinition(itemDefinition)
                        .setItemContext(itemContext))
                .build());
    }

    /**
     * Invokes MXAccess {@code RemoveItem}.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to remove
     * @throws MxGatewayException on transport or protocol failure
     */
    public void removeItem(int serverHandle, int itemHandle) {
        removeItemRaw(serverHandle, itemHandle);
    }

    /**
     * Invokes MXAccess {@code RemoveItem} and returns the raw reply.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to remove
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     */
    public MxCommandReply removeItemRaw(int serverHandle, int itemHandle) {
        return invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_REMOVE_ITEM)
                .setRemoveItem(RemoveItemCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(itemHandle))
                .build());
    }

    /**
     * Invokes MXAccess {@code Advise} so the item starts emitting data-change events.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to advise
     * @throws MxGatewayException on transport or protocol failure
     */
    public void advise(int serverHandle, int itemHandle) {
        adviseRaw(serverHandle, itemHandle);
    }

    /**
     * Invokes MXAccess {@code Advise} and returns the raw reply.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to advise
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     */
    public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
        return invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE)
                .setAdvise(AdviseCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(itemHandle))
                .build());
    }

    /**
     * Invokes MXAccess {@code UnAdvise} so the item stops emitting data-change events.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to un-advise
     * @throws MxGatewayException on transport or protocol failure
     */
    public void unAdvise(int serverHandle, int itemHandle) {
        unAdviseRaw(serverHandle, itemHandle);
    }

    /**
     * Invokes MXAccess {@code UnAdvise} and returns the raw reply.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to un-advise
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     */
    public MxCommandReply unAdviseRaw(int serverHandle, int itemHandle) {
        return invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_UN_ADVISE)
                .setUnAdvise(UnAdviseCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(itemHandle))
                .build());
    }

    /**
     * Invokes the bulk {@code AddItem} variant.
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param tagAddresses the MXAccess tag addresses to add
     * @return a per-tag {@link SubscribeResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code tagAddresses} is {@code null}
     */
    public List<SubscribeResult> addItemBulk(int serverHandle, List<String> tagAddresses) {
        Objects.requireNonNull(tagAddresses, "tagAddresses");
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM_BULK)
                .setAddItemBulk(AddItemBulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllTagAddresses(tagAddresses))
                .build());
        return reply.getAddItemBulk().getResultsList();
    }

    /**
     * Invokes the bulk {@code Advise} variant.
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param itemHandles the {@code ItemHandle} list to advise
     * @return a per-item {@link SubscribeResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code itemHandles} is {@code null}
     */
    public List<SubscribeResult> adviseItemBulk(int serverHandle, List<Integer> itemHandles) {
        Objects.requireNonNull(itemHandles, "itemHandles");
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_ITEM_BULK)
                .setAdviseItemBulk(AdviseItemBulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllItemHandles(itemHandles))
                .build());
        return reply.getAdviseItemBulk().getResultsList();
    }

    /**
     * Invokes the bulk {@code RemoveItem} variant.
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param itemHandles the {@code ItemHandle} list to remove
     * @return a per-item {@link SubscribeResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code itemHandles} is {@code null}
     */
    public List<SubscribeResult> removeItemBulk(int serverHandle, List<Integer> itemHandles) {
        Objects.requireNonNull(itemHandles, "itemHandles");
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_REMOVE_ITEM_BULK)
                .setRemoveItemBulk(RemoveItemBulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllItemHandles(itemHandles))
                .build());
        return reply.getRemoveItemBulk().getResultsList();
    }

    /**
     * Invokes the bulk {@code UnAdvise} variant.
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param itemHandles the {@code ItemHandle} list to un-advise
     * @return a per-item {@link SubscribeResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code itemHandles} is {@code null}
     */
    public List<SubscribeResult> unAdviseItemBulk(int serverHandle, List<Integer> itemHandles) {
        Objects.requireNonNull(itemHandles, "itemHandles");
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_UN_ADVISE_ITEM_BULK)
                .setUnAdviseItemBulk(UnAdviseItemBulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllItemHandles(itemHandles))
                .build());
        return reply.getUnAdviseItemBulk().getResultsList();
    }

    /**
     * Invokes the gateway {@code SubscribeBulk} convenience that combines
     * AddItem and Advise for the supplied tag addresses.
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param tagAddresses the MXAccess tag addresses to subscribe
     * @return a per-tag {@link SubscribeResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code tagAddresses} is {@code null}
     */
    public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> tagAddresses) {
        Objects.requireNonNull(tagAddresses, "tagAddresses");
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_SUBSCRIBE_BULK)
                .setSubscribeBulk(SubscribeBulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllTagAddresses(tagAddresses))
                .build());
        return reply.getSubscribeBulk().getResultsList();
    }

    /**
     * Invokes the gateway {@code UnsubscribeBulk} convenience that combines
     * UnAdvise and RemoveItem for the supplied item handles.
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param itemHandles the {@code ItemHandle} list to unsubscribe
     * @return a per-item {@link SubscribeResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code itemHandles} is {@code null}
     */
    public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
        Objects.requireNonNull(itemHandles, "itemHandles");
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_UNSUBSCRIBE_BULK)
                .setUnsubscribeBulk(UnsubscribeBulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllItemHandles(itemHandles))
                .build());
        return reply.getUnsubscribeBulk().getResultsList();
    }

    /**
     * Bulk {@code Write} — sequential MXAccess Write per entry on the worker's STA.
     *
     * <p>Per-entry failures appear as {@link BulkWriteResult} entries with
     * {@code wasSuccessful == false}; this method does not throw for per-entry
     * MXAccess failures (it still throws {@link MxGatewayException} on transport
     * or protocol-level failures).
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param entries the per-item (handle, value, user id) tuples
     * @return a per-entry {@link BulkWriteResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code entries} is {@code null}
     */
    public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
        Objects.requireNonNull(entries, "entries");
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_BULK)
                .setWriteBulk(WriteBulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllEntries(entries))
                .build());
        return reply.getWriteBulk().getResultsList();
    }

    /**
     * Bulk {@code Write2} — sequential MXAccess Write2 (timestamped) per entry.
     *
     * <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param entries the per-item (handle, value, timestamp, user id) tuples
     * @return a per-entry {@link BulkWriteResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code entries} is {@code null}
     */
    public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
        Objects.requireNonNull(entries, "entries");
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE2_BULK)
                .setWrite2Bulk(Write2BulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllEntries(entries))
                .build());
        return reply.getWrite2Bulk().getResultsList();
    }

    /**
     * Bulk {@code WriteSecured} — credential-sensitive values must not be logged
     * by callers; mirrors the single-item write-secured redaction contract.
     *
     * <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param entries the per-item (handle, value, current+verifier user id) tuples
     * @return a per-entry {@link BulkWriteResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code entries} is {@code null}
     */
    public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
        Objects.requireNonNull(entries, "entries");
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED_BULK)
                .setWriteSecuredBulk(WriteSecuredBulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllEntries(entries))
                .build());
        return reply.getWriteSecuredBulk().getResultsList();
    }

    /**
     * Bulk {@code WriteSecured2} — sequential timestamped + verified write per entry.
     *
     * <p>Per-entry semantics mirror {@link #writeBulk(int, List)}.
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param entries the per-item (handle, value, timestamp, current+verifier user id) tuples
     * @return a per-entry {@link BulkWriteResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code entries} is {@code null}
     */
    public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
        Objects.requireNonNull(entries, "entries");
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED2_BULK)
                .setWriteSecured2Bulk(WriteSecured2BulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllEntries(entries))
                .build());
        return reply.getWriteSecured2Bulk().getResultsList();
    }

    /**
     * Bulk {@code Read} — snapshot the current value of each requested tag.
     *
     * <p>MXAccess COM has no synchronous read; the worker returns the cached
     * {@code OnDataChange} value for any tag that is already advised
     * ({@code wasCached == true}) without modifying the existing subscription,
     * and falls back to a full AddItem + Advise + wait + UnAdvise + RemoveItem
     * snapshot lifecycle otherwise. The supplied {@code timeout} bounds the
     * per-tag wait in the snapshot case; pass {@link Duration#ZERO} (or
     * {@code null}) to use the worker default (1000 ms). Per-tag failures
     * appear as {@link BulkReadResult} entries with {@code wasSuccessful == false};
     * this method does not throw for per-tag MXAccess failures.
     *
     * @param serverHandle the {@code ServerHandle} owning the items
     * @param tagAddresses the tag addresses to read
     * @param timeout per-tag snapshot timeout (zero or null = worker default)
     * @return a per-tag {@link BulkReadResult} list
     * @throws MxGatewayException on transport or protocol failure
     * @throws NullPointerException if {@code tagAddresses} is {@code null}
     * @throws IllegalArgumentException if {@code timeout} is negative or exceeds {@link Integer#MAX_VALUE} milliseconds
     */
    public List<BulkReadResult> readBulk(int serverHandle, List<String> tagAddresses, Duration timeout) {
        Objects.requireNonNull(tagAddresses, "tagAddresses");
        int timeoutMs = 0;
        if (timeout != null) {
            if (timeout.isNegative()) {
                throw new IllegalArgumentException("timeout must be non-negative");
            }
            long millis = timeout.toMillis();
            if (millis > Integer.MAX_VALUE) {
                throw new IllegalArgumentException("timeout exceeds Integer.MAX_VALUE milliseconds");
            }
            timeoutMs = (int) millis;
        }
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_READ_BULK)
                .setReadBulk(ReadBulkCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .addAllTagAddresses(tagAddresses)
                        .setTimeoutMs(timeoutMs))
                .build());
        return reply.getReadBulk().getResultsList();
    }

    /**
     * Invokes MXAccess {@code Write}.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to write
     * @param value the value to write
     * @param userId the MXAccess user id used for security checks
     * @throws MxGatewayException on transport or protocol failure
     */
    public void write(int serverHandle, int itemHandle, MxValue value, int userId) {
        writeRaw(serverHandle, itemHandle, value, userId);
    }

    /**
     * Invokes MXAccess {@code Write} and returns the raw reply.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to write
     * @param value the value to write
     * @param userId the MXAccess user id used for security checks
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     */
    public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
        return invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
                .setWrite(WriteCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(itemHandle)
                        .setValue(value)
                        .setUserId(userId))
                .build());
    }

    /**
     * Writes a subset of an array's elements using MXAccess {@code Write}, building a
     * write-only {@link MxSparseArray} value that the gateway expands into a full,
     * default-filled array before forwarding to the worker.
     *
     * <p><strong>Default-fill semantics:</strong> only the indices supplied in
     * {@code elements} are written; every unmentioned index is <em>reset</em> to the
     * element type's default (for example {@code 0}, {@code false}, or an empty string),
     * <em>not</em> preserved from the array's current contents. Use a full
     * {@link MxValue} array write when you need to keep existing element values.
     *
     * <p>{@code totalLength} is required and defines the length of the expanded array;
     * supplied indices must be within {@code [0, totalLength)}. Elements are iterated in
     * ascending index order so the produced command is deterministic.
     *
     * <p>Because the proto fields {@code MxSparseArray.total_length} and
     * {@code MxSparseElement.index} are {@code uint32}, passing a negative Java {@code int}
     * would silently sign-extend to a large unsigned value on the wire. This method
     * therefore rejects negative {@code totalLength} and negative element indices with
     * {@link IllegalArgumentException} rather than allowing a hard-to-diagnose gateway error.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to write
     * @param elementDataType the {@link MxDataType} of the array's elements
     * @param totalLength the total length of the expanded array; must be &gt; 0
     * @param elements the indices to write mapped to their scalar values; each index must
     *     be in {@code [0, totalLength)}; unmentioned indices are reset to the element
     *     type default
     * @param userId the MXAccess user id used for security checks
     * @throws IllegalArgumentException if {@code totalLength} is not positive, or if any
     *     element index is negative or &ge; {@code totalLength}
     * @throws MxGatewayException on transport or protocol failure
     */
    public void writeArrayElements(
            int serverHandle,
            int itemHandle,
            MxDataType elementDataType,
            int totalLength,
            Map<Integer, MxValue> elements,
            int userId) {
        Objects.requireNonNull(elementDataType, "elementDataType");
        Objects.requireNonNull(elements, "elements");
        if (totalLength <= 0) {
            throw new IllegalArgumentException("totalLength must be > 0, got " + totalLength);
        }
        for (Map.Entry<Integer, MxValue> entry : elements.entrySet()) {
            int idx = entry.getKey();
            if (idx < 0 || idx >= totalLength) {
                throw new IllegalArgumentException(
                        "element index " + idx + " is out of range [0, " + totalLength + ")");
            }
        }
        MxSparseArray.Builder sparse = MxSparseArray.newBuilder()
                .setElementDataType(elementDataType)
                .setTotalLength(totalLength);
        // Iterate in ascending index order so the built command is deterministic.
        for (Map.Entry<Integer, MxValue> entry : new TreeMap<>(elements).entrySet()) {
            sparse.addElements(MxSparseElement.newBuilder()
                    .setIndex(entry.getKey())
                    .setValue(Objects.requireNonNull(entry.getValue(), "elements value")));
        }
        MxValue value = MxValue.newBuilder()
                .setSparseArrayValue(sparse)
                .build();
        writeRaw(serverHandle, itemHandle, value, userId);
    }

    /**
     * Invokes MXAccess {@code Write2}, which carries an explicit timestamp.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to write
     * @param value the value to write
     * @param timestampValue the timestamp value to associate with the write
     * @param userId the MXAccess user id used for security checks
     * @throws MxGatewayException on transport or protocol failure
     */
    public void write2(int serverHandle, int itemHandle, MxValue value, MxValue timestampValue, int userId) {
        invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE2)
                .setWrite2(Write2Command.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(itemHandle)
                        .setValue(value)
                        .setTimestampValue(timestampValue)
                        .setUserId(userId))
                .build());
    }

    /**
     * Invokes MXAccess {@code AdviseSupervisory} so the item accepts
     * supervisory-attributed writes. Required before a plain {@link #write}
     * can stamp a Galaxy user id without the verified/secured path.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to advise supervisory
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public void adviseSupervisory(int serverHandle, int itemHandle) {
        adviseSupervisoryRaw(serverHandle, itemHandle);
    }

    /**
     * Invokes MXAccess {@code AdviseSupervisory} and returns the raw reply.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to advise supervisory
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
        return invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
                .setAdviseSupervisory(AdviseSupervisoryCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(itemHandle))
                .build());
    }

    /**
     * Invokes MXAccess {@code WriteSecured} — a verified write gated by a
     * previously authenticated Galaxy user.
     *
     * <p><strong>MXAccess parity:</strong> the native call fails if the caller
     * has not first {@link #authenticateUser authenticated} and
     * {@link #adviseSupervisory advised supervisory}, or if the value body is
     * absent; that native failure is surfaced (as {@link MxAccessException}),
     * not papered over.
     *
     * <p><strong>Secret handling:</strong> {@code value} may carry a
     * credential-sensitive payload. It is placed only in the request and never
     * appears in any surfaced error (exceptions carry the reply, not the
     * request), so callers must likewise avoid logging it.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to write
     * @param currentUserId the authenticated (current) Galaxy user id
     * @param verifierUserId the verifier Galaxy user id (second-signature); use
     *     the same value as {@code currentUserId} when no separate verifier applies
     * @param value the credential-sensitive value to write
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public void writeSecured(
            int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
        writeSecuredRaw(serverHandle, itemHandle, currentUserId, verifierUserId, value);
    }

    /**
     * Invokes MXAccess {@code WriteSecured} and returns the raw reply.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to write
     * @param currentUserId the authenticated (current) Galaxy user id
     * @param verifierUserId the verifier Galaxy user id
     * @param value the credential-sensitive value to write
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public MxCommandReply writeSecuredRaw(
            int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
        return invokeCommandRedacted(
                MxCommand.newBuilder()
                        .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED)
                        .setWriteSecured(WriteSecuredCommand.newBuilder()
                                .setServerHandle(serverHandle)
                                .setItemHandle(itemHandle)
                                .setCurrentUserId(currentUserId)
                                .setVerifierUserId(verifierUserId)
                                .setValue(value))
                        .build(),
                secretStringOf(value));
    }

    /**
     * Invokes MXAccess {@code WriteSecured2} — a verified, explicitly
     * timestamped write. Parity and secret-handling notes mirror
     * {@link #writeSecured}.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to write
     * @param currentUserId the authenticated (current) Galaxy user id
     * @param verifierUserId the verifier Galaxy user id
     * @param value the credential-sensitive value to write
     * @param timestampValue the timestamp value to associate with the write
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public void writeSecured2(
            int serverHandle,
            int itemHandle,
            int currentUserId,
            int verifierUserId,
            MxValue value,
            MxValue timestampValue) {
        writeSecured2Raw(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestampValue);
    }

    /**
     * Invokes MXAccess {@code WriteSecured2} and returns the raw reply.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to write
     * @param currentUserId the authenticated (current) Galaxy user id
     * @param verifierUserId the verifier Galaxy user id
     * @param value the credential-sensitive value to write
     * @param timestampValue the timestamp value to associate with the write
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public MxCommandReply writeSecured2Raw(
            int serverHandle,
            int itemHandle,
            int currentUserId,
            int verifierUserId,
            MxValue value,
            MxValue timestampValue) {
        return invokeCommandRedacted(
                MxCommand.newBuilder()
                        .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED2)
                        .setWriteSecured2(WriteSecured2Command.newBuilder()
                                .setServerHandle(serverHandle)
                                .setItemHandle(itemHandle)
                                .setCurrentUserId(currentUserId)
                                .setVerifierUserId(verifierUserId)
                                .setValue(value)
                                .setTimestampValue(timestampValue))
                        .build(),
                secretStringOf(value));
    }

    /**
     * Invokes MXAccess {@code AuthenticateUser} and returns the resolved
     * Galaxy user id used to attribute subsequent secured writes.
     *
     * <p><strong>Secret handling:</strong> {@code verifyUserPassword} is a raw
     * MXAccess credential. It is placed only in the request and never appears in
     * any surfaced error, log, or {@code toString()} (exceptions carry the
     * reply, not the request). Callers must not log it either.
     *
     * @param serverHandle the {@code ServerHandle} for the session
     * @param verifyUser the Galaxy user name to authenticate
     * @param verifyUserPassword the user's credential; never logged or surfaced
     * @return the authenticated Galaxy user id
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess rejects the credential
     */
    public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
        MxCommandReply reply = invokeCommandRedacted(
                MxCommand.newBuilder()
                        .setKind(MxCommandKind.MX_COMMAND_KIND_AUTHENTICATE_USER)
                        .setAuthenticateUser(AuthenticateUserCommand.newBuilder()
                                .setServerHandle(serverHandle)
                                .setVerifyUser(verifyUser)
                                .setVerifyUserPassword(verifyUserPassword))
                        .build(),
                verifyUserPassword);
        if (reply.hasAuthenticateUser()) {
            return reply.getAuthenticateUser().getUserId();
        }
        if (reply.hasReturnValue() && reply.getReturnValue().getKindCase() == MxValue.KindCase.INT32_VALUE) {
            return reply.getReturnValue().getInt32Value();
        }
        throw new MxGatewayMalformedReplyException(
                "AuthenticateUser returned a malformed reply: OK reply carried neither "
                        + "the typed payload nor an int32 return_value");
    }

    /**
     * Invokes MXAccess {@code ArchestrAUserToId}, resolving a Galaxy user GUID
     * to its integer user id.
     *
     * @param serverHandle the {@code ServerHandle} for the session
     * @param userIdGuid the Galaxy user GUID string
     * @return the resolved Galaxy user id
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public int archestrAUserToId(int serverHandle, String userIdGuid) {
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID)
                .setArchestraUserToId(ArchestrAUserToIdCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setUserIdGuid(userIdGuid))
                .build());
        if (reply.hasArchestraUserToId()) {
            return reply.getArchestraUserToId().getUserId();
        }
        if (reply.hasReturnValue() && reply.getReturnValue().getKindCase() == MxValue.KindCase.INT32_VALUE) {
            return reply.getReturnValue().getInt32Value();
        }
        throw new MxGatewayMalformedReplyException(
                "ArchestrAUserToId returned a malformed reply: OK reply carried neither "
                        + "the typed payload nor an int32 return_value");
    }

    /**
     * Invokes MXAccess {@code AddBufferedItem} and returns the new item handle.
     * The buffered add family delivers coalesced {@code OnBufferedDataChange}
     * updates on the interval set by {@link #setBufferedUpdateInterval}.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemDefinition the MXAccess item definition (tag reference)
     * @param itemContext the MXAccess item context (e.g. galaxy/object scope)
     * @return the {@code ItemHandle} assigned by MXAccess
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public int addBufferedItem(int serverHandle, String itemDefinition, String itemContext) {
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_ADD_BUFFERED_ITEM)
                .setAddBufferedItem(AddBufferedItemCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemDefinition(itemDefinition)
                        .setItemContext(itemContext))
                .build());
        if (reply.hasAddBufferedItem()) {
            return reply.getAddBufferedItem().getItemHandle();
        }
        if (reply.hasReturnValue() && reply.getReturnValue().getKindCase() == MxValue.KindCase.INT32_VALUE) {
            return reply.getReturnValue().getInt32Value();
        }
        throw new MxGatewayMalformedReplyException(
                "AddBufferedItem returned a malformed reply: OK reply carried neither "
                        + "the typed payload nor an int32 return_value");
    }

    /**
     * Invokes MXAccess {@code SetBufferedUpdateInterval}, controlling how often
     * the worker coalesces buffered updates for the given server handle.
     *
     * @param serverHandle the {@code ServerHandle} to configure
     * @param updateIntervalMilliseconds the buffered update interval in milliseconds
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public void setBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) {
        invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL)
                .setSetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setUpdateIntervalMilliseconds(updateIntervalMilliseconds))
                .build());
    }

    /**
     * Invokes MXAccess {@code Suspend} on an item and returns the reply's
     * {@link MxStatusProxy}.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to suspend
     * @return the {@code MxStatusProxy} carried by the suspend reply
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public MxStatusProxy suspend(int serverHandle, int itemHandle) {
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_SUSPEND)
                .setSuspend(SuspendCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(itemHandle))
                .build());
        return reply.getSuspend().getStatus();
    }

    /**
     * Invokes MXAccess {@code Activate} on a previously suspended item and
     * returns the reply's {@link MxStatusProxy}.
     *
     * @param serverHandle the {@code ServerHandle} owning the item
     * @param itemHandle the {@code ItemHandle} to activate
     * @return the {@code MxStatusProxy} carried by the activate reply
     * @throws MxGatewayException on transport or protocol failure
     * @throws MxAccessException when MXAccess reports a COM-side failure
     */
    public MxStatusProxy activate(int serverHandle, int itemHandle) {
        MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
                .setKind(MxCommandKind.MX_COMMAND_KIND_ACTIVATE)
                .setActivate(ActivateCommand.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(itemHandle))
                .build());
        return reply.getActivate().getStatus();
    }

    /**
     * Subscribes to gateway events for this session starting from the
     * beginning of the worker event log.
     *
     * @return an iterator-style stream of events
     */
    public MxEventStream streamEvents() {
        return streamEventsAfter(0);
    }

    /**
     * Subscribes to gateway events for this session starting after the
     * supplied worker sequence number.
     *
     * @param afterWorkerSequence the resume cursor; events with worker sequence
     *     greater than this value are delivered
     * @return an iterator-style stream of events
     */
    public MxEventStream streamEventsAfter(long afterWorkerSequence) {
        return client.streamEvents(StreamEventsRequest.newBuilder()
                .setSessionId(sessionId())
                .setAfterWorkerSequence(afterWorkerSequence)
                .build());
    }

    /**
     * Sends a pre-built {@link MxCommand} for this session and returns the raw
     * reply, attaching a freshly generated client correlation id.
     *
     * @param command the command to send
     * @return the raw command reply
     * @throws MxGatewayException on transport or protocol failure
     */
    public MxCommandReply invokeCommand(MxCommand command) {
        return client.invoke(MxCommandRequest.newBuilder()
                .setSessionId(sessionId())
                .setClientCorrelationId(newCorrelationId())
                .setCommand(command)
                .build());
    }

    /**
     * Invokes a credential-bearing command, scrubbing any exact secret the
     * gateway may have echoed back into a surfaced failure message. The secret
     * lives only in the request, but a non-parity gateway or provider can copy
     * it into a diagnostic; this guarantees it never survives in the exception
     * text a caller might log.
     *
     * <p>On failure both the exception's message <em>and</em> its structured
     * context (the {@link ProtocolStatus} and {@link MxCommandReply} a caller can
     * inspect and log) are scrubbed with {@link MxGatewaySecrets#redactExact}: the
     * gateway echoes the credential into {@code protocolStatus.message},
     * {@code reply.diagnosticMessage}, and each {@code statuses[i].diagnosticText}.
     * If nothing carried the secret (the common case) the original exception is
     * rethrown untouched. Otherwise it is re-thrown as the same concrete type
     * carrying the redacted message and scrubbed context; the secret-bearing
     * original is not chained as a cause, so it cannot leak through a printed
     * stack trace.
     */
    private MxCommandReply invokeCommandRedacted(MxCommand command, String... secrets) {
        try {
            return invokeCommand(command);
        } catch (MxGatewayException ex) {
            String original = ex.getMessage();
            String redactedMessage = MxGatewaySecrets.redactExact(original, secrets);
            boolean messageChanged = redactedMessage != null && !redactedMessage.equals(original);

            ProtocolStatus status = protocolStatusOf(ex);
            ProtocolStatus scrubbedStatus = scrubProtocolStatus(status, secrets);
            boolean statusChanged = status != null && !status.equals(scrubbedStatus);

            MxCommandReply reply = replyOf(ex);
            MxCommandReply scrubbedReply = scrubReply(reply, secrets);
            boolean replyChanged = reply != null && !reply.equals(scrubbedReply);

            if (!messageChanged && !statusChanged && !replyChanged) {
                throw ex;
            }

            String message = messageChanged ? redactedMessage : original;
            throw rebuildRedacted(ex, message, scrubbedStatus, scrubbedReply);
        }
    }

    /**
     * Extracts the {@link ProtocolStatus} an exception carries, if any, so it can
     * be scrubbed and re-attached to the rebuilt exception.
     */
    private static ProtocolStatus protocolStatusOf(MxGatewayException ex) {
        if (ex instanceof MxGatewayCommandException command) {
            return command.protocolStatus();
        }
        if (ex instanceof MxGatewaySessionException session) {
            return session.protocolStatus();
        }
        if (ex instanceof MxGatewayWorkerException worker) {
            return worker.protocolStatus();
        }
        return null;
    }

    /**
     * Extracts the raw {@link MxCommandReply} an exception carries, if any.
     */
    private static MxCommandReply replyOf(MxGatewayException ex) {
        if (ex instanceof MxGatewayCommandException command) {
            return command.reply();
        }
        return null;
    }

    /**
     * Rebuilds a gateway exception of the same concrete type with a redacted
     * message and already-scrubbed context, mirroring the .NET client's
     * type-switch. Only a truly-unknown subtype collapses to the base
     * {@link MxGatewayException}. The original (secret-bearing) exception is
     * deliberately not chained as a cause.
     */
    private static MxGatewayException rebuildRedacted(
            MxGatewayException ex, String message, ProtocolStatus status, MxCommandReply reply) {
        if (ex instanceof MxAccessException) {
            return new MxAccessException(message, status, reply, null);
        }
        if (ex instanceof MxGatewayCommandException) {
            return new MxGatewayCommandException(message, status, reply, null);
        }
        if (ex instanceof MxGatewaySessionException) {
            return new MxGatewaySessionException(message, status, null);
        }
        if (ex instanceof MxGatewayWorkerException) {
            return new MxGatewayWorkerException(message, status, null);
        }
        if (ex instanceof MxGatewayMalformedReplyException) {
            return new MxGatewayMalformedReplyException(message);
        }
        if (ex instanceof MxGatewayAuthenticationException) {
            return new MxGatewayAuthenticationException(message, null);
        }
        if (ex instanceof MxGatewayAuthorizationException) {
            return new MxGatewayAuthorizationException(message, null);
        }
        return new MxGatewayException(message);
    }

    /**
     * Produces a scrubbed clone of a command reply, removing any exact secret the
     * gateway echoed into {@code protocolStatus.message},
     * {@code diagnosticMessage}, or a status's {@code diagnosticText}.
     *
     * @param reply the reply to scrub, or {@code null}
     * @param secrets the exact secrets to strip
     * @return {@code null} when {@code reply} is {@code null}, otherwise a clone
     *     with every echoed secret replaced by the redaction marker
     */
    private static MxCommandReply scrubReply(MxCommandReply reply, String... secrets) {
        if (reply == null) {
            return null;
        }
        MxCommandReply.Builder builder = reply.toBuilder();
        if (builder.hasProtocolStatus()) {
            builder.setProtocolStatus(scrubProtocolStatus(builder.getProtocolStatus(), secrets));
        }
        builder.setDiagnosticMessage(MxGatewaySecrets.redactExact(builder.getDiagnosticMessage(), secrets));
        for (int index = 0; index < builder.getStatusesCount(); index++) {
            MxStatusProxy.Builder status = builder.getStatuses(index).toBuilder();
            status.setDiagnosticText(MxGatewaySecrets.redactExact(status.getDiagnosticText(), secrets));
            builder.setStatuses(index, status);
        }
        return builder.build();
    }

    /**
     * Produces a scrubbed clone of a protocol status, removing any exact secret
     * the gateway echoed into its free-form {@code message}.
     */
    private static ProtocolStatus scrubProtocolStatus(ProtocolStatus status, String... secrets) {
        if (status == null) {
            return null;
        }
        return status.toBuilder()
                .setMessage(MxGatewaySecrets.redactExact(status.getMessage(), secrets))
                .build();
    }

    /**
     * Extracts the string payload of a secured-write value so it can be scrubbed
     * from an echoed failure message. Only string-kind values carry a
     * credential-shaped secret worth redacting; other kinds return {@code null}
     * (ignored by {@link MxGatewaySecrets#redactExact}).
     */
    private static String secretStringOf(MxValue value) {
        if (value != null && value.getKindCase() == MxValue.KindCase.STRING_VALUE) {
            return value.getStringValue();
        }
        return null;
    }

    private static String newCorrelationId() {
        byte[] bytes = new byte[16];
        RANDOM.nextBytes(bytes);
        return HexFormat.of().formatHex(bytes);
    }
}
