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

import com.zb.mom.ww.mxgateway.client.BrowseChildrenOptions;
import com.zb.mom.ww.mxgateway.client.DeployEventStream;
import com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient;
import com.zb.mom.ww.mxgateway.client.LazyBrowseNode;
import com.zb.mom.ww.mxgateway.client.MxEventStream;
import com.zb.mom.ww.mxgateway.client.MxEventStreamItem;
import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
import com.zb.mom.ww.mxgateway.client.MxGatewayClient;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientVersion;
import com.zb.mom.ww.mxgateway.client.MxGatewaySecrets;
import com.zb.mom.ww.mxgateway.client.MxGatewaySession;
import com.zb.mom.ww.mxgateway.client.MxValues;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;
import io.grpc.stub.StreamObserver;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.AlarmProviderStatus;
import mxaccess_gateway.v1.MxaccessGateway.AlarmSnapshotStatus;
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
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.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.PingCommand;
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkEntry;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Mixin;
import picocli.CommandLine.Model.CommandSpec;
import picocli.CommandLine.Option;
import picocli.CommandLine.ParameterException;
import picocli.CommandLine.Spec;

/**
 * Picocli entry point for the {@code mxgw-java} test CLI used by the
 * cross-language smoke matrix.
 */
@Command(
        name = "mxgw-java",
        mixinStandardHelpOptions = true,
        description = "MXAccess Gateway Java test CLI.")
public final class MxGatewayCli implements Callable<Integer> {
    private final MxGatewayCliClientFactory clientFactory;

    @Spec
    private CommandSpec spec;

    /**
     * Builds a CLI bound to the default gRPC client factory.
     */
    public MxGatewayCli() {
        this(new GrpcMxGatewayCliClientFactory());
    }

    MxGatewayCli(MxGatewayCliClientFactory clientFactory) {
        this.clientFactory = clientFactory;
    }

    /**
     * Process entry point.
     *
     * @param args command-line arguments
     */
    public static void main(String[] args) {
        int exitCode = commandLine(new GrpcMxGatewayCliClientFactory()).execute(args);
        System.exit(exitCode);
    }

    /**
     * Entry point that runs the CLI against the supplied {@link PrintWriter}
     * pair instead of the system streams. This overload wires the production
     * {@link GrpcMxGatewayCliClientFactory} (a real gRPC channel), so it is
     * suitable for embedding the CLI but not for unit tests that need to stub
     * the gateway. Tests should use the package-private
     * {@link #execute(MxGatewayCliClientFactory, PrintWriter, PrintWriter, String...)}
     * / {@link #commandLine(MxGatewayCliClientFactory)} overloads, which accept
     * an injectable client factory.
     *
     * @param out writer that receives standard output
     * @param err writer that receives standard error
     * @param args command-line arguments
     * @return the picocli exit code
     */
    public static int execute(PrintWriter out, PrintWriter err, String... args) {
        return execute(new GrpcMxGatewayCliClientFactory(), out, err, args);
    }

    static int execute(MxGatewayCliClientFactory clientFactory, PrintWriter out, PrintWriter err, String... args) {
        CommandLine commandLine = commandLine(clientFactory);
        commandLine.setOut(out);
        commandLine.setErr(err);
        return commandLine.execute(args);
    }

    @Override
    public Integer call() {
        spec.commandLine().usage(spec.commandLine().getOut());
        return 0;
    }

    static CommandLine commandLine(MxGatewayCliClientFactory clientFactory) {
        return commandLine(clientFactory, new GrpcGalaxyClientFactory());
    }

    static CommandLine commandLine(
            MxGatewayCliClientFactory clientFactory, GalaxyClientFactory galaxyClientFactory) {
        CommandLine commandLine = new CommandLine(new MxGatewayCli(clientFactory));
        commandLine.addSubcommand("version", new VersionCommand());
        commandLine.addSubcommand("open-session", new OpenSessionCommand(clientFactory));
        commandLine.addSubcommand("close-session", new CloseSessionCommand(clientFactory));
        commandLine.addSubcommand("ping", new PingCommandLine(clientFactory));
        commandLine.addSubcommand("register", new RegisterCommand(clientFactory));
        commandLine.addSubcommand("add-item", new AddItemCommand(clientFactory));
        commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
        commandLine.addSubcommand(
                "advise-supervisory", new AdviseSupervisoryCommand(clientFactory));
        commandLine.addSubcommand("write-secured", new WriteSecuredCommand(clientFactory));
        commandLine.addSubcommand("authenticate-user", new AuthenticateUserCommand(clientFactory));
        commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
        commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
        commandLine.addSubcommand("read-bulk", new ReadBulkCommand(clientFactory));
        commandLine.addSubcommand("write-bulk", new WriteBulkCommand(clientFactory));
        commandLine.addSubcommand("write2-bulk", new Write2BulkCommand(clientFactory));
        commandLine.addSubcommand("write-secured-bulk", new WriteSecuredBulkCommand(clientFactory));
        commandLine.addSubcommand("write-secured2-bulk", new WriteSecured2BulkCommand(clientFactory));
        commandLine.addSubcommand("bench-read-bulk", new BenchReadBulkCommand(clientFactory));
        commandLine.addSubcommand("write", new WriteCommand(clientFactory));
        commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
        commandLine.addSubcommand("stream-alarms", new StreamAlarmsCommand(clientFactory));
        commandLine.addSubcommand("acknowledge-alarm", new AcknowledgeAlarmCommand(clientFactory));
        commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
        commandLine.addSubcommand("galaxy-test-connection", new GalaxyTestConnectionCommand(galaxyClientFactory));
        commandLine.addSubcommand("galaxy-last-deploy", new GalaxyDeployTimeCommand(galaxyClientFactory));
        commandLine.addSubcommand("galaxy-discover", new GalaxyDiscoverCommand(galaxyClientFactory));
        commandLine.addSubcommand("galaxy-browse", new GalaxyBrowseCommand(galaxyClientFactory));
        commandLine.addSubcommand("galaxy-watch", new GalaxyWatchCommand(galaxyClientFactory));
        commandLine.addSubcommand("batch", new BatchCommand(clientFactory));
        return commandLine;
    }

    /** Sentinel written to stdout after every command result in batch mode. */
    static final String BATCH_EOR = "__MXGW_BATCH_EOR__";

    /**
     * Canonical CLI credential environment variable, shared by every official
     * client CLI (CLI-45) so one exported variable drives the same operator
     * workflow in all five languages.
     */
    static final String DEFAULT_VERIFY_PASSWORD_ENV = "MXGATEWAY_VERIFY_PASSWORD";

    /** Sentinel queued by {@code stream-alarms} to mark a clean end of the alarm feed. */
    private static final Object ALARM_FEED_END = new Object();

    /**
     * Tokenises a single batch-mode stdin line into the argv that the inner
     * {@link CommandLine} should execute. Honours single-quoted, double-quoted,
     * and backslash-escaped runs so values that contain spaces (e.g.
     * {@code --comment "needs verification"}) survive intact — the old
     * implementation used {@code split("\\s+")} which shredded any quoted
     * argument mid-string (Client.Java-034).
     *
     * <p>Rules (a small POSIX-like shell tokenizer; no variable expansion,
     * command substitution, globbing, or backtick handling):
     *
     * <ul>
     *   <li>Outside quotes, runs of whitespace separate tokens.</li>
     *   <li>{@code "..."} groups a sequence into one token; the surrounding
     *       quotes are removed. Inside double quotes a backslash escapes
     *       {@code \\}, {@code "}, and a literal newline; other characters
     *       are taken literally (so {@code \n} is the two characters
     *       backslash-n).</li>
     *   <li>{@code '...'} groups a sequence into one token; the surrounding
     *       quotes are removed. Inside single quotes nothing is escaped —
     *       the run is literal until the matching single quote.</li>
     *   <li>Outside quotes, backslash escapes the next character (including
     *       whitespace, so {@code needs\ verification} is one token).</li>
     *   <li>An unterminated quote or a trailing backslash throws
     *       {@link IllegalArgumentException} so the batch loop surfaces it
     *       as a JSON error instead of silently emitting wrong args.</li>
     * </ul>
     *
     * <p>Empty input (or input that contains only whitespace) returns an
     * empty array so callers can skip the line.
     */
    static String[] tokenizeBatchLine(String line) {
        List<String> tokens = new ArrayList<>();
        StringBuilder current = new StringBuilder();
        boolean inToken = false;
        // 0 = outside, 1 = inside single quotes, 2 = inside double quotes
        int quoteMode = 0;
        int length = line.length();
        for (int i = 0; i < length; i++) {
            char c = line.charAt(i);
            if (quoteMode == 1) {
                if (c == '\'') {
                    quoteMode = 0;
                } else {
                    current.append(c);
                }
                continue;
            }
            if (quoteMode == 2) {
                if (c == '\\') {
                    if (i + 1 >= length) {
                        throw new IllegalArgumentException(
                                "batch tokenizer: trailing backslash inside double-quoted string");
                    }
                    char next = line.charAt(i + 1);
                    if (next == '\\' || next == '"' || next == '\n') {
                        current.append(next);
                        i++;
                    } else {
                        // POSIX rule: inside double quotes a backslash is
                        // literal unless it precedes \, ", $, `, or newline.
                        current.append(c);
                    }
                    continue;
                }
                if (c == '"') {
                    quoteMode = 0;
                    continue;
                }
                current.append(c);
                continue;
            }
            // Outside any quotes.
            if (c == '\'') {
                quoteMode = 1;
                inToken = true;
                continue;
            }
            if (c == '"') {
                quoteMode = 2;
                inToken = true;
                continue;
            }
            if (c == '\\') {
                if (i + 1 >= length) {
                    throw new IllegalArgumentException(
                            "batch tokenizer: trailing backslash outside quotes");
                }
                current.append(line.charAt(i + 1));
                i++;
                inToken = true;
                continue;
            }
            if (Character.isWhitespace(c)) {
                if (inToken) {
                    tokens.add(current.toString());
                    current.setLength(0);
                    inToken = false;
                }
                continue;
            }
            current.append(c);
            inToken = true;
        }
        if (quoteMode != 0) {
            throw new IllegalArgumentException(
                    "batch tokenizer: unterminated " + (quoteMode == 1 ? "single" : "double") + " quote");
        }
        if (inToken) {
            tokens.add(current.toString());
        }
        return tokens.toArray(new String[0]);
    }

    /**
     * Reads one CLI invocation per stdin line, executes each via a fresh
     * {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
     * every result. Errors are written as JSON to stdout so the harness
     * sees them in the same stream, delimited by the same sentinel. The
     * loop never terminates on command failure; only stdin EOF (or an
     * empty line) ends the session.
     */
    @Command(name = "batch", description = "Reads CLI invocations from stdin and executes them sequentially.")
    static final class BatchCommand implements Callable<Integer> {
        private final MxGatewayCliClientFactory clientFactory;

        @Spec
        private CommandSpec spec;

        BatchCommand(MxGatewayCliClientFactory clientFactory) {
            this.clientFactory = clientFactory;
        }

        @Override
        public Integer call() {
            PrintWriter out = spec.commandLine().getOut();
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    if (line.isEmpty()) {
                        break;
                    }
                    String[] args = tokenizeBatchLine(line);
                    if (args.length == 0) {
                        continue;
                    }
                    StringWriter cmdOut = new StringWriter();
                    StringWriter cmdErr = new StringWriter();
                    PrintWriter cmdOutWriter = new PrintWriter(cmdOut, true);
                    PrintWriter cmdErrWriter = new PrintWriter(cmdErr, true);
                    try {
                        CommandLine cmd = commandLine(clientFactory);
                        cmd.setOut(cmdOutWriter);
                        cmd.setErr(cmdErrWriter);
                        int exitCode = cmd.execute(args);
                        cmdOutWriter.flush();
                        cmdErrWriter.flush();
                        String cmdOutput = cmdOut.toString();
                        if (!cmdOutput.isEmpty()) {
                            out.print(cmdOutput);
                        }
                        if (exitCode != 0) {
                            // Non-zero exit: emit the stderr content (if any) as a JSON
                            // error object to stdout so the harness can parse it in the
                            // same delimited stream.
                            String errText = cmdErr.toString().trim();
                            if (errText.isEmpty()) {
                                errText = "command exited with code " + exitCode;
                            }
                            Map<String, Object> errorPayload = new LinkedHashMap<>();
                            errorPayload.put("error", errText);
                            errorPayload.put("type", "error");
                            out.println(jsonObject(errorPayload));
                        }
                    } catch (Exception ex) {
                        Map<String, Object> errorPayload = new LinkedHashMap<>();
                        errorPayload.put("error", ex.getMessage() != null ? ex.getMessage() : ex.getClass().getName());
                        errorPayload.put("type", "error");
                        out.println(jsonObject(errorPayload));
                    }
                    out.println(BATCH_EOR);
                    out.flush();
                }
            } catch (java.io.IOException ex) {
                // Stdin closed unexpectedly — treat as EOF and exit normally.
            }
            return 0;
        }
    }

    abstract static class GalaxyCommand implements Callable<Integer> {
        final GalaxyClientFactory galaxyClientFactory;

        @Mixin
        CommonOptions common = new CommonOptions();

        @Option(names = "--json", description = "Write JSON output.")
        boolean json;

        GalaxyCommand(GalaxyClientFactory galaxyClientFactory) {
            this.galaxyClientFactory = galaxyClientFactory;
        }

        GalaxyRepositoryClient connect() {
            return galaxyClientFactory.connect(common.resolved().toClientOptions());
        }
    }

    @Command(
            name = "galaxy-test-connection",
            aliases = {"galaxy-test"},
            description = "Calls GalaxyRepository.TestConnection.")
    static final class GalaxyTestConnectionCommand extends GalaxyCommand {
        GalaxyTestConnectionCommand(GalaxyClientFactory galaxyClientFactory) {
            super(galaxyClientFactory);
        }

        @Override
        public Integer call() {
            try (GalaxyRepositoryClient client = connect()) {
                boolean ok = client.testConnection();
                PrintWriter out = common.spec.commandLine().getOut();
                if (json) {
                    Map<String, Object> output = new LinkedHashMap<>();
                    output.put("command", "galaxy-test-connection");
                    output.put("options", common.redactedJsonMap());
                    output.put("ok", ok);
                    out.println(jsonObject(output));
                } else {
                    out.println(ok);
                }
            }
            return 0;
        }
    }

    @Command(
            name = "galaxy-last-deploy",
            aliases = {"galaxy-deploy-time"},
            description = "Calls GalaxyRepository.GetLastDeployTime.")
    static final class GalaxyDeployTimeCommand extends GalaxyCommand {
        GalaxyDeployTimeCommand(GalaxyClientFactory galaxyClientFactory) {
            super(galaxyClientFactory);
        }

        @Override
        public Integer call() {
            try (GalaxyRepositoryClient client = connect()) {
                Optional<Instant> result = client.getLastDeployTime();
                PrintWriter out = common.spec.commandLine().getOut();
                if (json) {
                    Map<String, Object> output = new LinkedHashMap<>();
                    output.put("command", "galaxy-last-deploy");
                    output.put("options", common.redactedJsonMap());
                    output.put("present", result.isPresent());
                    output.put("timeOfLastDeploy", result.map(Instant::toString).orElse(""));
                    out.println(jsonObject(output));
                } else if (result.isPresent()) {
                    out.println(result.get());
                } else {
                    out.println("(none)");
                }
            }
            return 0;
        }
    }

    @Command(name = "galaxy-discover", description = "Calls GalaxyRepository.DiscoverHierarchy.")
    static final class GalaxyDiscoverCommand extends GalaxyCommand {
        GalaxyDiscoverCommand(GalaxyClientFactory galaxyClientFactory) {
            super(galaxyClientFactory);
        }

        @Override
        public Integer call() {
            try (GalaxyRepositoryClient client = connect()) {
                List<GalaxyObject> objects = client.discoverHierarchy();
                PrintWriter out = common.spec.commandLine().getOut();
                if (json) {
                    Map<String, Object> output = new LinkedHashMap<>();
                    output.put("command", "galaxy-discover");
                    output.put("options", common.redactedJsonMap());
                    output.put("objects", objects.stream().map(MxGatewayCli::galaxyObjectMap).toList());
                    out.println(jsonObject(output));
                } else {
                    out.printf("count=%d%n", objects.size());
                    for (GalaxyObject obj : objects) {
                        out.printf("  %s [%s] attrs=%d%n",
                                obj.getTagName(), obj.getBrowseName(), obj.getAttributesCount());
                    }
                }
            }
            return 0;
        }
    }

    /**
     * Page size used for the raw {@code BrowseChildren} paging loop driven by
     * the {@code --parent} one-level path. Mirrors {@code BROWSE_CHILDREN_PAGE_SIZE}
     * in the client library's lazy-browse helper and the other clients' CLI page
     * size so paging behaviour is consistent across languages.
     */
    private static final int BROWSE_CHILDREN_CLI_PAGE_SIZE = 500;

    @Command(
            name = "galaxy-browse",
            description = "Browses the Galaxy hierarchy via GalaxyRepository.BrowseChildren.")
    static final class GalaxyBrowseCommand extends GalaxyCommand {
        GalaxyBrowseCommand(GalaxyClientFactory galaxyClientFactory) {
            super(galaxyClientFactory);
        }

        @Spec
        private CommandSpec spec;

        @Option(
                names = "--parent",
                defaultValue = "-1",
                description =
                        "Parent gobject id to browse one level of children for."
                                + " Use the default (omit) to walk root nodes;"
                                + " gobject id 0 is reserved by the server to mean roots.")
        int parent;

        @Option(
                names = "--depth",
                defaultValue = "0",
                description =
                        "When walking roots, eagerly expand this many further levels before printing."
                                + " Must be between 0 and 50 inclusive.")
        int depth;

        @Option(names = "--category-ids", description = "Comma-separated category ids to include.")
        String categoryIds;

        @Option(names = "--template-contains", description = "Comma-separated template names each child's chain must contain.")
        String templateContains;

        @Option(names = "--tag-name-glob", description = "SQL-LIKE-style glob applied to tag_name.")
        String tagNameGlob;

        @Option(names = "--alarm-bearing-only", description = "Restrict to alarm-bearing objects.")
        boolean alarmBearingOnly;

        @Option(names = "--historized-only", description = "Restrict to objects with at least one historized attribute.")
        boolean historizedOnly;

        @Option(names = "--include-attributes", description = "Request attribute population on each returned object.")
        boolean includeAttributes;

        @Override
        public Integer call() {
            if (depth < 0) {
                throw new CommandLine.ParameterException(spec.commandLine(), "--depth must be non-negative");
            }
            if (depth > 50) {
                throw new CommandLine.ParameterException(spec.commandLine(), "--depth must be at most 50");
            }
            BrowseChildrenOptions options = buildOptions();
            PrintWriter out = common.spec.commandLine().getOut();
            PrintWriter err = common.spec.commandLine().getErr();
            if (parent == 0) {
                err.println("warning: --parent 0 is the server sentinel for root nodes; omit --parent to walk roots instead.");
            }
            try (GalaxyRepositoryClient client = connect()) {
                if (parent >= 0) {
                    if (depth > 0) {
                        err.println("warning: --depth is ignored when --parent is specified.");
                    }
                    List<BrowseChild> children = browseOneLevel(client, parent, options);
                    if (json) {
                        List<Map<String, Object>> nodes = new ArrayList<>(children.size());
                        for (BrowseChild child : children) {
                            nodes.add(browseNodeMap(child.object(), child.hasChildrenHint(), List.of()));
                        }
                        Map<String, Object> output = new LinkedHashMap<>();
                        output.put("command", "galaxy-browse");
                        output.put("options", common.redactedJsonMap());
                        output.put("parentId", parent);
                        output.put("nodes", nodes);
                        out.println(jsonObject(output));
                    } else {
                        out.println(children.size());
                        for (BrowseChild child : children) {
                            printBrowseChild(out, child);
                        }
                    }
                    return 0;
                }

                List<LazyBrowseNode> roots = client.browse(options);
                for (LazyBrowseNode root : roots) {
                    expandToDepth(root, depth);
                }
                if (json) {
                    List<Map<String, Object>> nodes = new ArrayList<>(roots.size());
                    for (LazyBrowseNode root : roots) {
                        nodes.add(lazyNodeMap(root));
                    }
                    Map<String, Object> output = new LinkedHashMap<>();
                    output.put("command", "galaxy-browse");
                    output.put("options", common.redactedJsonMap());
                    output.put("nodes", nodes);
                    out.println(jsonObject(output));
                } else {
                    out.println(roots.size());
                    for (LazyBrowseNode root : roots) {
                        printLazyNode(out, root, 0);
                    }
                }
            }
            return 0;
        }

        private BrowseChildrenOptions buildOptions() {
            return BrowseChildrenOptions.builder()
                    .categoryIds(parseOptionalIntList(categoryIds))
                    .templateChainContains(parseOptionalStringList(templateContains))
                    .tagNameGlob(tagNameGlob == null ? "" : tagNameGlob)
                    // Tri-state: only override the server default when the flag is present.
                    .includeAttributes(includeAttributes ? Boolean.TRUE : null)
                    .alarmBearingOnly(alarmBearingOnly)
                    .historizedOnly(historizedOnly)
                    .build();
        }
    }

    /** One raw {@code BrowseChildren} child paired with its server-supplied has-children hint. */
    private record BrowseChild(GalaxyObject object, boolean hasChildrenHint) {
    }

    /**
     * Drives the raw {@code BrowseChildren} paging loop for a single parent and
     * returns the flattened one-level child list. Used by the {@code --parent}
     * path, which surfaces a single level rather than the lazy root-tree walk.
     */
    private static List<BrowseChild> browseOneLevel(
            GalaxyRepositoryClient client, int parentGobjectId, BrowseChildrenOptions options) {
        List<BrowseChild> children = new ArrayList<>();
        Set<String> seenPageTokens = new HashSet<>();
        String pageToken = "";
        while (true) {
            BrowseChildrenRequest.Builder builder = BrowseChildrenRequest.newBuilder()
                    .setPageSize(BROWSE_CHILDREN_CLI_PAGE_SIZE)
                    .setPageToken(pageToken)
                    .setParentGobjectId(parentGobjectId)
                    .setAlarmBearingOnly(options.isAlarmBearingOnly())
                    .setHistorizedOnly(options.isHistorizedOnly());
            if (!options.getCategoryIds().isEmpty()) {
                builder.addAllCategoryIds(options.getCategoryIds());
            }
            if (!options.getTemplateChainContains().isEmpty()) {
                builder.addAllTemplateChainContains(options.getTemplateChainContains());
            }
            if (!options.getTagNameGlob().isEmpty()) {
                builder.setTagNameGlob(options.getTagNameGlob());
            }
            if (options.getIncludeAttributes() != null) {
                builder.setIncludeAttributes(options.getIncludeAttributes());
            }

            BrowseChildrenReply reply = client.browseChildrenRaw(builder.build());
            for (int i = 0; i < reply.getChildrenCount(); i++) {
                boolean hint = i < reply.getChildHasChildrenCount() && reply.getChildHasChildren(i);
                children.add(new BrowseChild(reply.getChildren(i), hint));
            }

            pageToken = reply.getNextPageToken();
            if (pageToken == null || pageToken.isEmpty()) {
                return children;
            }
            if (!seenPageTokens.add(pageToken)) {
                throw new IllegalStateException(
                        "galaxy browse children returned repeated page token: " + pageToken);
            }
        }
    }

    /**
     * Recursively expands a {@link LazyBrowseNode} up to {@code depth} further
     * levels. A {@code depth} of 0 leaves the node unexpanded so callers print
     * only the requested level. Nodes the server reports as childless are not
     * expanded.
     */
    private static void expandToDepth(LazyBrowseNode node, int depth) {
        if (depth <= 0) {
            return;
        }
        if (node.hasChildrenHint()) {
            node.expand();
        }
        for (LazyBrowseNode child : node.getChildren()) {
            expandToDepth(child, depth - 1);
        }
    }

    /**
     * Renders one {@link LazyBrowseNode} (and any already-expanded descendants)
     * as a JSON map. Mirrors the {@code galaxy-discover} object shape with an
     * added {@code hasChildrenHint} flag and a nested {@code children} array,
     * matching the cross-client browse JSON surface.
     */
    private static Map<String, Object> lazyNodeMap(LazyBrowseNode node) {
        List<Map<String, Object>> children = new ArrayList<>();
        if (node.isExpanded()) {
            for (LazyBrowseNode child : node.getChildren()) {
                children.add(lazyNodeMap(child));
            }
        }
        return browseNodeMap(node.getObject(), node.hasChildrenHint(), children);
    }

    /**
     * Builds the per-node browse JSON map: the flattened Galaxy object fields,
     * the {@code hasChildrenHint} flag, and a nested {@code children} array.
     * The {@code hasChildrenHint} key is the cross-client standard (Rust /
     * Python / .NET / Go all use the same key and node shape).
     */
    static Map<String, Object> browseNodeMap(
            GalaxyObject object, boolean hasChildrenHint, List<Map<String, Object>> children) {
        Map<String, Object> values = galaxyObjectMap(object);
        values.put("hasChildrenHint", hasChildrenHint);
        values.put("children", children);
        return values;
    }

    private static void printLazyNode(PrintWriter out, LazyBrowseNode node, int level) {
        GalaxyObject obj = node.getObject();
        out.printf(
                "%s%d\t%s\t%s\t(attrs=%d, hasChildrenHint=%b)%n",
                "  ".repeat(level),
                obj.getGobjectId(),
                obj.getTagName(),
                obj.getBrowseName(),
                obj.getAttributesCount(),
                node.hasChildrenHint());
        if (node.isExpanded()) {
            for (LazyBrowseNode child : node.getChildren()) {
                printLazyNode(out, child, level + 1);
            }
        }
    }

    private static void printBrowseChild(PrintWriter out, BrowseChild child) {
        GalaxyObject obj = child.object();
        out.printf(
                "%d\t%s\t%s\t(attrs=%d, hasChildrenHint=%b)%n",
                obj.getGobjectId(),
                obj.getTagName(),
                obj.getBrowseName(),
                obj.getAttributesCount(),
                child.hasChildrenHint());
    }

    private static List<Integer> parseOptionalIntList(String value) {
        if (value == null || value.isBlank()) {
            return List.of();
        }
        return parseIntList(value);
    }

    private static List<String> parseOptionalStringList(String value) {
        if (value == null || value.isBlank()) {
            return List.of();
        }
        return parseStringList(value);
    }

    @Command(
            name = "galaxy-watch",
            description = "Streams GalaxyRepository.WatchDeployEvents until cancelled.")
    static final class GalaxyWatchCommand extends GalaxyCommand {
        GalaxyWatchCommand(GalaxyClientFactory galaxyClientFactory) {
            super(galaxyClientFactory);
        }

        @Option(
                names = "--last-seen-deploy-time",
                description =
                        "Optional ISO-8601 instant. When supplied, the bootstrap event is suppressed if the cached"
                                + " deploy time matches.")
        String lastSeenDeployTime;

        @Option(names = "--limit", defaultValue = "0", description = "Maximum events to print before exiting.")
        int limit;

        @Override
        public Integer call() {
            Instant after = parseInstant(lastSeenDeployTime);
            try (GalaxyRepositoryClient client = connect();
                    DeployEventStream events = client.watchDeployEvents(after)) {
                PrintWriter out = common.spec.commandLine().getOut();
                Thread shutdownHook = new Thread(events::close, "galaxy-watch-shutdown");
                Runtime.getRuntime().addShutdownHook(shutdownHook);
                try {
                    int count = 0;
                    while (events.hasNext()) {
                        DeployEvent event = events.next();
                        if (json) {
                            out.println(protoJson(event));
                        } else {
                            out.printf(
                                    "seq=%d observed=%s deployTime=%s objects=%d attributes=%d%n",
                                    event.getSequence(),
                                    formatTimestamp(event.getObservedAt()),
                                    event.getTimeOfLastDeployPresent()
                                            ? formatTimestamp(event.getTimeOfLastDeploy())
                                            : "(none)",
                                    event.getObjectCount(),
                                    event.getAttributeCount());
                        }
                        out.flush();
                        count++;
                        if (limit > 0 && count >= limit) {
                            events.close();
                            break;
                        }
                    }
                } finally {
                    try {
                        Runtime.getRuntime().removeShutdownHook(shutdownHook);
                    } catch (IllegalStateException ignored) {
                        // JVM is already shutting down.
                    }
                }
            }
            return 0;
        }

        private static Instant parseInstant(String value) {
            if (value == null || value.isBlank()) {
                return null;
            }
            return Instant.parse(value);
        }

        private static String formatTimestamp(com.google.protobuf.Timestamp ts) {
            return Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()).toString();
        }
    }

    private static Map<String, Object> galaxyObjectMap(GalaxyObject obj) {
        Map<String, Object> values = new LinkedHashMap<>();
        values.put("gobjectId", obj.getGobjectId());
        values.put("tagName", obj.getTagName());
        values.put("containedName", obj.getContainedName());
        values.put("browseName", obj.getBrowseName());
        values.put("parentGobjectId", obj.getParentGobjectId());
        values.put("isArea", obj.getIsArea());
        values.put("categoryId", obj.getCategoryId());
        values.put("hostedByGobjectId", obj.getHostedByGobjectId());
        values.put("templateChain", new ArrayList<>(obj.getTemplateChainList()));
        List<Map<String, Object>> attrs = new ArrayList<>();
        for (GalaxyAttribute attr : obj.getAttributesList()) {
            Map<String, Object> attrMap = new LinkedHashMap<>();
            attrMap.put("attributeName", attr.getAttributeName());
            attrMap.put("fullTagReference", attr.getFullTagReference());
            attrMap.put("mxDataType", attr.getMxDataType());
            attrMap.put("dataTypeName", attr.getDataTypeName());
            attrMap.put("isArray", attr.getIsArray());
            attrMap.put("arrayDimension", attr.getArrayDimension());
            attrMap.put("arrayDimensionPresent", attr.getArrayDimensionPresent());
            attrMap.put("mxAttributeCategory", attr.getMxAttributeCategory());
            attrMap.put("securityClassification", attr.getSecurityClassification());
            attrMap.put("isHistorized", attr.getIsHistorized());
            attrMap.put("isAlarm", attr.getIsAlarm());
            attrs.add(attrMap);
        }
        values.put("attributes", attrs);
        return values;
    }

    /**
     * Picocli subcommand that prints the client and protocol version numbers.
     */
    @Command(name = "version", description = "Prints the Java client version.")
    public static final class VersionCommand implements Callable<Integer> {
        @Spec
        private CommandSpec spec;

        @Option(names = "--json", description = "Write JSON output.")
        private boolean json;

        @Override
        public Integer call() {
            Map<String, Object> values = new LinkedHashMap<>();
            values.put("clientVersion", MxGatewayClientVersion.clientVersion());
            values.put("gatewayProtocolVersion", MxGatewayClientVersion.gatewayProtocolVersion());
            values.put("workerProtocolVersion", MxGatewayClientVersion.workerProtocolVersion());
            if (json) {
                spec.commandLine().getOut().println(jsonObject(values));
                return 0;
            }

            spec.commandLine()
                    .getOut()
                    .printf(
                            "mxgateway-java %s gatewayProtocolVersion=%d workerProtocolVersion=%d%n",
                            MxGatewayClientVersion.clientVersion(),
                            MxGatewayClientVersion.gatewayProtocolVersion(),
                            MxGatewayClientVersion.workerProtocolVersion());
            return 0;
        }
    }

    abstract static class GatewayCommand implements Callable<Integer> {
        final MxGatewayCliClientFactory clientFactory;

        @Mixin
        CommonOptions common = new CommonOptions();

        @Option(names = "--json", description = "Write JSON output.")
        boolean json;

        GatewayCommand(MxGatewayCliClientFactory clientFactory) {
            this.clientFactory = clientFactory;
        }
    }

    @Command(name = "open-session", description = "Opens a gateway session.")
    static final class OpenSessionCommand extends GatewayCommand {
        @Option(names = "--client-session-name", description = "Client session name.")
        String clientSessionName = "";

        @Option(names = "--backend", description = "Requested gateway backend.")
        String backend = "";

        OpenSessionCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                var reply = client.openSession(OpenSessionRequest.newBuilder()
                        .setClientSessionName(clientSessionName)
                        .setRequestedBackend(backend)
                        .build());
                writeOutput("open-session", common, json, reply, () -> reply.getSessionId());
            }
            return 0;
        }
    }

    @Command(name = "close-session", description = "Closes a gateway session.")
    static final class CloseSessionCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        CloseSessionCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                var reply = client.closeSession(CloseSessionRequest.newBuilder()
                        .setSessionId(sessionId)
                        .build());
                writeOutput("close-session", common, json, reply, () -> reply.getFinalState().name());
            }
            return 0;
        }
    }

    @Command(name = "ping", description = "Sends a diagnostic ping command to the session worker.")
    static final class PingCommandLine extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--message", defaultValue = "ping", description = "Message echoed back in the reply.")
        String message;

        PingCommandLine(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                MxCommandReply reply = client.session(sessionId).pingRaw(message);
                // The worker echoes the message in the diagnostic message field;
                // there is no dedicated ping reply payload, so the plain-text path
                // surfaces that field.
                writeOutput("ping", common, json, reply, reply::getDiagnosticMessage);
            }
            return 0;
        }
    }

    @Command(name = "register", description = "Invokes MXAccess Register.")
    static final class RegisterCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--client-name", required = true, description = "MXAccess client name.")
        String clientName;

        RegisterCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                MxCommandReply reply = client.session(sessionId).registerRaw(clientName);
                writeOutput("register", common, json, reply, () -> reply.getKind().name());
            }
            return 0;
        }
    }

    @Command(name = "add-item", description = "Invokes MXAccess AddItem.")
    static final class AddItemCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--item", required = true, description = "Item definition.")
        String item;

        AddItemCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                MxCommandReply reply = client.session(sessionId).addItemRaw(serverHandle, item);
                writeOutput("add-item", common, json, reply, () -> reply.getKind().name());
            }
            return 0;
        }
    }

    @Command(name = "advise", description = "Invokes MXAccess Advise.")
    static final class AdviseCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
        int itemHandle;

        AdviseCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                MxCommandReply reply = client.session(sessionId).adviseRaw(serverHandle, itemHandle);
                writeOutput("advise", common, json, reply, () -> reply.getKind().name());
            }
            return 0;
        }
    }

    @Command(
            name = "advise-supervisory",
            description = "Invokes MXAccess AdviseSupervisory.")
    static final class AdviseSupervisoryCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
        int itemHandle;

        AdviseSupervisoryCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                MxCommandReply reply =
                        client.session(sessionId).adviseSupervisoryRaw(serverHandle, itemHandle);
                writeOutput("advise-supervisory", common, json, reply, () -> reply.getKind().name());
            }
            return 0;
        }
    }

    @Command(
            name = "write-secured",
            description = "Invokes MXAccess WriteSecured (verified single-item write).")
    static final class WriteSecuredCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
        int itemHandle;

        @Option(names = "--type", defaultValue = "string", description = "Value type.")
        String type;

        @Option(names = "--value", required = true, description = "Value text (credential-sensitive; never echoed).")
        String value;

        @Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
        int currentUserId;

        @Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
        int verifierUserId;

        WriteSecuredCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                // The secured write value is credential-sensitive: it goes only
                // into the request. The reply printed below never carries it.
                MxCommandReply reply = client.session(sessionId)
                        .writeSecuredRaw(
                                serverHandle, itemHandle, currentUserId, verifierUserId, parseValue(type, value));
                writeOutput("write-secured", common, json, reply, () -> reply.getKind().name());
            }
            return 0;
        }
    }

    @Command(
            name = "authenticate-user",
            description = "Invokes MXAccess AuthenticateUser and prints the resolved user id.")
    static final class AuthenticateUserCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--verify-user", required = true, description = "Galaxy user name to authenticate.")
        String verifyUser;

        @Option(
                names = "--password",
                description = "Galaxy user password (credential; prefer --password-env). Never echoed.")
        String password = "";

        @Option(
                names = "--password-env",
                defaultValue = DEFAULT_VERIFY_PASSWORD_ENV,
                description = "Environment variable holding the password when --password is omitted.")
        String passwordEnv;

        AuthenticateUserCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            // Resolve the credential from the flag or environment. It flows only
            // into the request; it is never written to output, logs, or errors.
            String environmentName =
                    passwordEnv == null || passwordEnv.isBlank() ? DEFAULT_VERIFY_PASSWORD_ENV : passwordEnv;
            String resolvedPassword = password == null || password.isBlank()
                    ? System.getenv(environmentName)
                    : password;
            if (resolvedPassword == null || resolvedPassword.isBlank()) {
                // Fail fast instead of dialing: a misconfigured environment must not
                // become a real MXAccess authentication attempt with an empty
                // credential (CLI-45). The message names the option and the variable
                // only — never the value.
                throw new ParameterException(
                        common.spec.commandLine(),
                        "a password is required via --password or the " + environmentName
                                + " environment variable");
            }
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                int userId = client.session(sessionId)
                        .authenticateUser(serverHandle, verifyUser, resolvedPassword);
                PrintWriter out = common.spec.commandLine().getOut();
                if (json) {
                    Map<String, Object> output = new LinkedHashMap<>();
                    output.put("command", "authenticate-user");
                    output.put("options", common.redactedJsonMap());
                    output.put("verifyUser", verifyUser);
                    output.put("userId", userId);
                    out.println(jsonObject(output));
                } else {
                    out.println(userId);
                }
            }
            return 0;
        }
    }

    @Command(name = "subscribe-bulk", description = "Invokes MXAccess SubscribeBulk.")
    static final class SubscribeBulkCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--items", required = true, description = "Comma-separated item definitions.")
        String items;

        SubscribeBulkCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                List<SubscribeResult> results =
                        client.session(sessionId).subscribeBulk(serverHandle, parseStringList(items));
                writeBulkOutput("subscribe-bulk", common, json, results);
            }
            return 0;
        }
    }

    @Command(name = "unsubscribe-bulk", description = "Invokes MXAccess UnsubscribeBulk.")
    static final class UnsubscribeBulkCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
        String itemHandles;

        UnsubscribeBulkCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                List<SubscribeResult> results =
                        client.session(sessionId).unsubscribeBulk(serverHandle, parseIntList(itemHandles));
                writeBulkOutput("unsubscribe-bulk", common, json, results);
            }
            return 0;
        }
    }

    @Command(name = "read-bulk", description = "Invokes MXAccess ReadBulk (cached or snapshot per tag).")
    static final class ReadBulkCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--items", required = true, description = "Comma-separated tag addresses.")
        String items;

        @Option(
                names = "--timeout-ms",
                defaultValue = "0",
                description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
        int timeoutMs;

        ReadBulkCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                List<BulkReadResult> results = client.session(sessionId)
                        .readBulk(serverHandle, parseStringList(items), Duration.ofMillis(timeoutMs));
                writeReadBulkOutput("read-bulk", common, json, results);
            }
            return 0;
        }
    }

    @Command(name = "write-bulk", description = "Invokes MXAccess WriteBulk.")
    static final class WriteBulkCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
        String itemHandles;

        @Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
        String type;

        @Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
        String values;

        @Option(names = "--user-id", defaultValue = "0", description = "MXAccess user id.")
        int userId;

        WriteBulkCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                List<Integer> handles = parseIntList(itemHandles);
                List<String> valueTexts = parseStringList(values);
                if (handles.size() != valueTexts.size()) {
                    throw new IllegalArgumentException(
                            "item-handles count (" + handles.size() + ") does not match values count ("
                                    + valueTexts.size() + ")");
                }
                List<WriteBulkEntry> entries = new ArrayList<>(handles.size());
                for (int i = 0; i < handles.size(); i++) {
                    entries.add(WriteBulkEntry.newBuilder()
                            .setItemHandle(handles.get(i))
                            .setUserId(userId)
                            .setValue(parseValue(type, valueTexts.get(i)))
                            .build());
                }
                List<BulkWriteResult> results = client.session(sessionId).writeBulk(serverHandle, entries);
                writeWriteBulkOutput("write-bulk", common, json, results);
            }
            return 0;
        }
    }

    @Command(name = "write2-bulk", description = "Invokes MXAccess Write2Bulk (timestamped).")
    static final class Write2BulkCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
        String itemHandles;

        @Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
        String type;

        @Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
        String values;

        @Option(names = "--timestamp", required = true, description = "ISO-8601 timestamp shared across all entries.")
        String timestamp;

        @Option(names = "--user-id", defaultValue = "0", description = "MXAccess user id.")
        int userId;

        Write2BulkCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                List<Integer> handles = parseIntList(itemHandles);
                List<String> valueTexts = parseStringList(values);
                if (handles.size() != valueTexts.size()) {
                    throw new IllegalArgumentException(
                            "item-handles count (" + handles.size() + ") does not match values count ("
                                    + valueTexts.size() + ")");
                }
                MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
                List<Write2BulkEntry> entries = new ArrayList<>(handles.size());
                for (int i = 0; i < handles.size(); i++) {
                    entries.add(Write2BulkEntry.newBuilder()
                            .setItemHandle(handles.get(i))
                            .setUserId(userId)
                            .setValue(parseValue(type, valueTexts.get(i)))
                            .setTimestampValue(timestampValue)
                            .build());
                }
                List<BulkWriteResult> results = client.session(sessionId).write2Bulk(serverHandle, entries);
                writeWriteBulkOutput("write2-bulk", common, json, results);
            }
            return 0;
        }
    }

    @Command(name = "write-secured-bulk", description = "Invokes MXAccess WriteSecuredBulk.")
    static final class WriteSecuredBulkCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
        String itemHandles;

        @Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
        String type;

        @Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
        String values;

        @Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
        int currentUserId;

        @Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
        int verifierUserId;

        WriteSecuredBulkCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                List<Integer> handles = parseIntList(itemHandles);
                List<String> valueTexts = parseStringList(values);
                if (handles.size() != valueTexts.size()) {
                    throw new IllegalArgumentException(
                            "item-handles count (" + handles.size() + ") does not match values count ("
                                    + valueTexts.size() + ")");
                }
                List<WriteSecuredBulkEntry> entries = new ArrayList<>(handles.size());
                for (int i = 0; i < handles.size(); i++) {
                    entries.add(WriteSecuredBulkEntry.newBuilder()
                            .setItemHandle(handles.get(i))
                            .setCurrentUserId(currentUserId)
                            .setVerifierUserId(verifierUserId)
                            .setValue(parseValue(type, valueTexts.get(i)))
                            .build());
                }
                List<BulkWriteResult> results = client.session(sessionId).writeSecuredBulk(serverHandle, entries);
                writeWriteBulkOutput("write-secured-bulk", common, json, results);
            }
            return 0;
        }
    }

    @Command(name = "write-secured2-bulk", description = "Invokes MXAccess WriteSecured2Bulk.")
    static final class WriteSecured2BulkCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--item-handles", required = true, description = "Comma-separated item handles.")
        String itemHandles;

        @Option(names = "--type", defaultValue = "string", description = "Value type for all entries.")
        String type;

        @Option(names = "--values", required = true, description = "Comma-separated values, one per item handle.")
        String values;

        @Option(names = "--timestamp", required = true, description = "ISO-8601 timestamp shared across all entries.")
        String timestamp;

        @Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
        int currentUserId;

        @Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
        int verifierUserId;

        WriteSecured2BulkCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                List<Integer> handles = parseIntList(itemHandles);
                List<String> valueTexts = parseStringList(values);
                if (handles.size() != valueTexts.size()) {
                    throw new IllegalArgumentException(
                            "item-handles count (" + handles.size() + ") does not match values count ("
                                    + valueTexts.size() + ")");
                }
                MxValue timestampValue = MxValues.timestampValue(Instant.parse(timestamp));
                List<WriteSecured2BulkEntry> entries = new ArrayList<>(handles.size());
                for (int i = 0; i < handles.size(); i++) {
                    entries.add(WriteSecured2BulkEntry.newBuilder()
                            .setItemHandle(handles.get(i))
                            .setCurrentUserId(currentUserId)
                            .setVerifierUserId(verifierUserId)
                            .setValue(parseValue(type, valueTexts.get(i)))
                            .setTimestampValue(timestampValue)
                            .build());
                }
                List<BulkWriteResult> results = client.session(sessionId).writeSecured2Bulk(serverHandle, entries);
                writeWriteBulkOutput("write-secured2-bulk", common, json, results);
            }
            return 0;
        }
    }

    @Command(
            name = "bench-read-bulk",
            description = "Repeatedly invokes ReadBulk for benchmarking; prints aggregate timing.")
    static final class BenchReadBulkCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--items", required = true, description = "Comma-separated tag addresses.")
        String items;

        @Option(
                names = "--timeout-ms",
                defaultValue = "0",
                description = "Per-tag snapshot timeout in milliseconds (0 = worker default).")
        int timeoutMs;

        @Option(names = "--iterations", defaultValue = "10", description = "Number of ReadBulk calls to perform.")
        int iterations;

        @Option(
                names = "--warmup",
                defaultValue = "1",
                description = "Number of warmup iterations excluded from timing.")
        int warmup;

        BenchReadBulkCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            if (iterations <= 0) {
                throw new IllegalArgumentException("--iterations must be positive");
            }
            if (warmup < 0) {
                throw new IllegalArgumentException("--warmup must be non-negative");
            }
            List<String> tagAddresses = parseStringList(items);
            Duration timeout = Duration.ofMillis(timeoutMs);
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                MxGatewayCliSession session = client.session(sessionId);
                for (int i = 0; i < warmup; i++) {
                    session.readBulk(serverHandle, tagAddresses, timeout);
                }
                long totalNanos = 0L;
                long minNanos = Long.MAX_VALUE;
                long maxNanos = 0L;
                int lastResultCount = 0;
                int lastSuccessCount = 0;
                int lastCachedCount = 0;
                for (int i = 0; i < iterations; i++) {
                    long start = System.nanoTime();
                    List<BulkReadResult> results = session.readBulk(serverHandle, tagAddresses, timeout);
                    long elapsed = System.nanoTime() - start;
                    totalNanos += elapsed;
                    minNanos = Math.min(minNanos, elapsed);
                    maxNanos = Math.max(maxNanos, elapsed);
                    lastResultCount = results.size();
                    lastSuccessCount = 0;
                    lastCachedCount = 0;
                    for (BulkReadResult result : results) {
                        if (result.getWasSuccessful()) {
                            lastSuccessCount++;
                        }
                        if (result.getWasCached()) {
                            lastCachedCount++;
                        }
                    }
                }
                double avgMs = totalNanos / 1_000_000.0 / iterations;
                double minMs = minNanos / 1_000_000.0;
                double maxMs = maxNanos / 1_000_000.0;
                PrintWriter out = common.spec.commandLine().getOut();
                if (json) {
                    Map<String, Object> output = new LinkedHashMap<>();
                    output.put("command", "bench-read-bulk");
                    output.put("options", common.redactedJsonMap());
                    output.put("iterations", iterations);
                    output.put("warmup", warmup);
                    output.put("tagCount", tagAddresses.size());
                    output.put("resultCount", lastResultCount);
                    output.put("successCount", lastSuccessCount);
                    output.put("cachedCount", lastCachedCount);
                    output.put("avgMs", avgMs);
                    output.put("minMs", minMs);
                    output.put("maxMs", maxMs);
                    out.println(jsonObject(output));
                } else {
                    out.printf(
                            "iterations=%d tags=%d avg=%.3fms min=%.3fms max=%.3fms last_results=%d last_success=%d last_cached=%d%n",
                            iterations,
                            tagAddresses.size(),
                            avgMs,
                            minMs,
                            maxMs,
                            lastResultCount,
                            lastSuccessCount,
                            lastCachedCount);
                }
            }
            return 0;
        }
    }

    @Command(name = "write", description = "Invokes MXAccess Write.")
    static final class WriteCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
        int serverHandle;

        @Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
        int itemHandle;

        @Option(names = "--type", defaultValue = "string", description = "Value type.")
        String type;

        @Option(names = "--value", required = true, description = "Value text.")
        String value;

        @Option(names = "--user-id", defaultValue = "0", description = "MXAccess user id.")
        int userId;

        WriteCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                MxCommandReply reply =
                        client.session(sessionId).writeRaw(serverHandle, itemHandle, parseValue(type, value), userId);
                writeOutput("write", common, json, reply, () -> reply.getKind().name());
            }
            return 0;
        }
    }

    @Command(name = "stream-events", description = "Streams gateway events.")
    static final class StreamEventsCommand extends GatewayCommand {
        @Option(names = "--session-id", required = true, description = "Gateway session id.")
        String sessionId;

        @Option(names = "--after-worker-sequence", defaultValue = "0", description = "Starting worker sequence.")
        long afterWorkerSequence;

        @Option(names = "--limit", defaultValue = "0", description = "Maximum events to print.")
        int limit;

        StreamEventsCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved());
                    MxEventStream events = client.session(sessionId).streamEventsAfter(afterWorkerSequence)) {
                int count = 0;
                while (events.hasNext()) {
                    MxEventStreamItem item = events.nextItem();
                    if (item.isReplayGap()) {
                        // Render the ReplayGap sentinel as the typed cross-CLI row (NEXT-02,
                        // mirroring the Go/Python/Rust/.NET CLIs) instead of the raw sentinel
                        // event, whose text form printed "0 MX_EVENT_FAMILY_UNSPECIFIED".
                        ReplayGap gap = item.replayGap();
                        if (json) {
                            client.out().printf(
                                    "{\"replayGap\":{\"requestedAfterSequence\":%s,\"oldestAvailableSequence\":%s}}%n",
                                    Long.toUnsignedString(gap.getRequestedAfterSequence()),
                                    Long.toUnsignedString(gap.getOldestAvailableSequence()));
                        } else {
                            client.out().printf(
                                    "REPLAY_GAP requested_after=%s oldest_available=%s%n",
                                    Long.toUnsignedString(gap.getRequestedAfterSequence()),
                                    Long.toUnsignedString(gap.getOldestAvailableSequence()));
                        }
                    } else {
                        MxEvent event = item.event();
                        if (json) {
                            client.out().println(protoJson(event));
                        } else {
                            client.out().printf("%d %s%n", event.getWorkerSequence(), event.getFamily());
                        }
                    }
                    count++;
                    if (limit > 0 && count >= limit) {
                        events.close();
                        break;
                    }
                }
            }
            return 0;
        }
    }

    @Command(name = "stream-alarms", description = "Streams the gateway central alarm feed.")
    static final class StreamAlarmsCommand extends GatewayCommand {
        @Option(names = "--filter-prefix", description = "Alarm-reference prefix scoping the feed; empty means unscoped.")
        String filterPrefix = "";

        @Option(names = "--limit", defaultValue = "0", description = "Maximum feed messages to print.")
        int limit;

        StreamAlarmsCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                // The async alarm feed delivers on a background gRPC thread; buffer
                // messages in a bounded queue and drain them on this thread so the
                // --limit termination mirrors stream-events. 1024 absorbs the
                // gateway's initial active-alarm snapshot burst.
                BlockingQueue<Object> queue = new ArrayBlockingQueue<>(1024);
                StreamAlarmsRequest request = StreamAlarmsRequest.newBuilder()
                        .setAlarmFilterPrefix(filterPrefix)
                        .build();
                // Client.Java-033/040/042 — fail-fast on overflow and on
                // transport errors. A bare queue.offer(value) silently drops
                // messages past capacity (violating the JavaStyleGuide "do not
                // drop events" contract and letting the CLI exit 0 on a
                // truncated feed), and a bare queue.offer(error) on a full
                // queue would drop the terminal item and deadlock the drain on
                // queue.take().
                //
                // Terminal transitions (overflow, transport error, clean
                // completion) are now serialised through a single AtomicBoolean
                // guard plus a dedicated `terminal` slot rather than
                // re-clearing the shared queue. The first terminal condition
                // wins; a concurrent onNext on the gRPC I/O thread can no
                // longer displace it (Client.Java-040). The drain reads the
                // terminal slot independently of the bounded queue, so a full
                // queue can never strand the terminal item (Client.Java-042).
                AtomicReference<MxGatewayAlarmFeedSubscription> subscriptionRef = new AtomicReference<>();
                AtomicBoolean terminated = new AtomicBoolean();
                AtomicReference<Object> terminal = new AtomicReference<>();
                Consumer<Object> terminate = item -> {
                    if (terminated.compareAndSet(false, true)) {
                        terminal.set(item);
                        MxGatewayAlarmFeedSubscription sub = subscriptionRef.get();
                        if (sub != null) {
                            sub.cancel();
                        }
                    }
                };
                MxGatewayAlarmFeedSubscription subscription =
                        client.streamAlarms(request, new StreamObserver<>() {
                            @Override
                            public void onNext(AlarmFeedMessage value) {
                                if (terminated.get()) {
                                    return;
                                }
                                if (!queue.offer(value)) {
                                    terminate.accept(new IllegalStateException(
                                            "stream-alarms queue overflowed (capacity 1024); consumer too slow"));
                                }
                            }

                            @Override
                            public void onError(Throwable error) {
                                terminate.accept(error);
                            }

                            @Override
                            public void onCompleted() {
                                terminate.accept(ALARM_FEED_END);
                            }
                        });
                subscriptionRef.set(subscription);
                try {
                    int count = 0;
                    while (true) {
                        // Poll with a short timeout so the dedicated terminal
                        // slot is observed even when the bounded queue is full
                        // of normal messages the consumer has not yet drained.
                        Object item = queue.poll(50, TimeUnit.MILLISECONDS);
                        if (item == null) {
                            Object end = terminal.get();
                            if (end == null) {
                                continue;
                            }
                            if (end == ALARM_FEED_END) {
                                break;
                            }
                            Throwable error = (Throwable) end;
                            throw new IllegalStateException(
                                    "gateway stream alarms failed: " + error.getMessage(), error);
                        }
                        AlarmFeedMessage message = (AlarmFeedMessage) item;
                        if (json) {
                            client.out().println(protoJson(message));
                        } else {
                            client.out().println(formatAlarmFeedMessage(message));
                        }
                        client.out().flush();
                        count++;
                        if (limit > 0 && count >= limit) {
                            subscription.cancel();
                            break;
                        }
                    }
                } catch (InterruptedException error) {
                    Thread.currentThread().interrupt();
                    subscription.cancel();
                } finally {
                    subscription.cancel();
                }
            }
            return 0;
        }
    }

    @Command(name = "acknowledge-alarm", description = "Acknowledges an active MXAccess alarm.")
    static final class AcknowledgeAlarmCommand extends GatewayCommand {
        @Option(names = "--reference", required = true, description = "Full alarm reference to acknowledge.")
        String reference;

        @Option(names = "--comment", description = "Operator acknowledge comment.")
        String comment = "";

        @Option(names = "--operator", description = "Operator user performing the acknowledge.")
        String operator = "";

        AcknowledgeAlarmCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                AcknowledgeAlarmReply reply = client.acknowledgeAlarm(AcknowledgeAlarmRequest.newBuilder()
                        .setAlarmFullReference(reference)
                        .setComment(comment)
                        .setOperatorUser(operator)
                        .build());
                writeOutput(
                        "acknowledge-alarm",
                        common,
                        json,
                        reply,
                        () -> Integer.toString(reply.getHresult()));
            }
            return 0;
        }
    }

    @Command(name = "smoke", description = "Runs a bounded open/register/add/advise flow.")
    static final class SmokeCommand extends GatewayCommand {
        @Option(names = "--client-name", defaultValue = "mxgw-java-smoke", description = "MXAccess client name.")
        String clientName;

        @Option(names = "--item", required = true, description = "Item definition.")
        String item;

        SmokeCommand(MxGatewayCliClientFactory clientFactory) {
            super(clientFactory);
        }

        @Override
        public Integer call() {
            try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
                var session = client.openSession(OpenSessionRequest.newBuilder()
                        .setClientSessionName(clientName)
                        .build());
                try {
                    MxGatewayCliSession cliSession = client.session(session.getSessionId());
                    int serverHandle = cliSession.register(clientName);
                    int itemHandle = cliSession.addItem(serverHandle, item);
                    cliSession.advise(serverHandle, itemHandle);
                    if (json) {
                        Map<String, Object> output = new LinkedHashMap<>();
                        output.put("command", "smoke");
                        output.put("options", common.redactedJsonMap());
                        output.put("sessionId", session.getSessionId());
                        output.put("serverHandle", serverHandle);
                        output.put("itemHandle", itemHandle);
                        client.out().println(jsonObject(output));
                    } else {
                        client.out().printf(
                                "session=%s server=%d item=%d%n", session.getSessionId(), serverHandle, itemHandle);
                    }
                } finally {
                    client.closeSession(CloseSessionRequest.newBuilder()
                            .setSessionId(session.getSessionId())
                            .build());
                }
            }
            return 0;
        }
    }

    static final class CommonOptions {
        @Spec
        CommandSpec spec;

        @Option(names = "--endpoint", defaultValue = "localhost:5000", description = "Gateway endpoint.")
        String endpoint;

        @Option(names = "--api-key", description = "Gateway API key.")
        String apiKey = "";

        @Option(names = "--api-key-env", defaultValue = "MXGATEWAY_API_KEY", description = "API key environment variable.")
        String apiKeyEnv;

        @Option(names = "--plaintext", description = "Use plaintext transport.")
        boolean plaintext;

        @Option(names = "--ca-file", description = "CA certificate file.")
        Path caFile;

        @Option(names = "--server-name-override", description = "TLS server name override.")
        String serverNameOverride = "";

        @Option(
                names = "--require-certificate-validation",
                description =
                        "Verify the server certificate against the JVM trust store "
                                + "(disables the lenient default; ignored with --plaintext or --ca-file pinning).")
        boolean requireCertificateValidation;

        @Option(names = "--timeout", defaultValue = "30s", description = "Per-call timeout.")
        String timeout;

        private String resolvedApiKey = "";
        private Duration resolvedTimeout = Duration.ofSeconds(30);

        CommonOptions resolved() {
            resolvedApiKey = apiKey == null || apiKey.isBlank() ? System.getenv(apiKeyEnv) : apiKey;
            if (resolvedApiKey == null) {
                resolvedApiKey = "";
            }
            resolvedTimeout = parseDuration(timeout);
            return this;
        }

        MxGatewayClientOptions toClientOptions() {
            return MxGatewayClientOptions.builder()
                    .endpoint(endpoint)
                    .apiKey(resolvedApiKey)
                    .plaintext(plaintext)
                    .caCertificatePath(caFile)
                    .serverNameOverride(serverNameOverride)
                    .requireCertificateValidation(requireCertificateValidation)
                    .callTimeout(resolvedTimeout)
                    .build();
        }

        Map<String, Object> redactedJsonMap() {
            Map<String, Object> values = new LinkedHashMap<>();
            values.put("endpoint", endpoint);
            values.put("apiKey", MxGatewaySecrets.redactApiKey(resolvedApiKey));
            values.put("apiKeyEnv", apiKeyEnv);
            values.put("plaintext", plaintext);
            values.put("caFile", caFile == null ? "" : caFile.toString());
            values.put("serverNameOverride", serverNameOverride);
            values.put("requireCertificateValidation", requireCertificateValidation);
            values.put("timeout", timeout);
            return values;
        }
    }

    interface GalaxyClientFactory {
        GalaxyRepositoryClient connect(MxGatewayClientOptions options);
    }

    interface MxGatewayCliClientFactory {
        MxGatewayCliClient connect(CommonOptions options);
    }

    interface MxGatewayCliClient extends AutoCloseable {
        PrintWriter out();

        mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(OpenSessionRequest request);

        mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(CloseSessionRequest request);

        MxGatewayCliSession session(String sessionId);

        AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request);

        MxGatewayAlarmFeedSubscription streamAlarms(
                StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer);

        @Override
        void close();
    }

    interface MxGatewayCliSession {
        MxCommandReply pingRaw(String message);

        int register(String clientName);

        MxCommandReply registerRaw(String clientName);

        int addItem(int serverHandle, String itemDefinition);

        MxCommandReply addItemRaw(int serverHandle, String itemDefinition);

        void advise(int serverHandle, int itemHandle);

        MxCommandReply adviseRaw(int serverHandle, int itemHandle);

        MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle);

        MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId);

        MxCommandReply writeSecuredRaw(
                int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value);

        int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword);

        List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items);

        List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);

        List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout);

        List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries);

        List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries);

        List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries);

        List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries);

        MxEventStream streamEventsAfter(long afterWorkerSequence);
    }

    static final class GrpcGalaxyClientFactory implements GalaxyClientFactory {
        @Override
        public GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
            return GalaxyRepositoryClient.connect(options);
        }
    }

    static final class GrpcMxGatewayCliClientFactory implements MxGatewayCliClientFactory {
        @Override
        public MxGatewayCliClient connect(CommonOptions options) {
            return new GrpcMxGatewayCliClient(MxGatewayClient.connect(options.toClientOptions()), options.spec.commandLine().getOut());
        }
    }

    static final class GrpcMxGatewayCliClient implements MxGatewayCliClient {
        private final MxGatewayClient client;
        private final PrintWriter out;

        GrpcMxGatewayCliClient(MxGatewayClient client, PrintWriter out) {
            this.client = client;
            this.out = out;
        }

        @Override
        public PrintWriter out() {
            return out;
        }

        @Override
        public mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply openSession(OpenSessionRequest request) {
            return client.openSessionRaw(request);
        }

        @Override
        public mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply closeSession(CloseSessionRequest request) {
            return client.closeSessionRaw(request);
        }

        @Override
        public MxGatewayCliSession session(String sessionId) {
            return new GrpcMxGatewayCliSession(MxGatewaySession.forSessionId(client, sessionId));
        }

        @Override
        public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
            return client.acknowledgeAlarm(request);
        }

        @Override
        public MxGatewayAlarmFeedSubscription streamAlarms(
                StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
            return client.streamAlarms(request, observer);
        }

        @Override
        public void close() {
            client.close();
        }
    }

    record GrpcMxGatewayCliSession(MxGatewaySession session) implements MxGatewayCliSession {
        @Override
        public MxCommandReply pingRaw(String message) {
            return session.invokeCommand(MxCommand.newBuilder()
                    .setKind(MxCommandKind.MX_COMMAND_KIND_PING)
                    .setPing(PingCommand.newBuilder().setMessage(message))
                    .build());
        }

        @Override
        public int register(String clientName) {
            return session.register(clientName);
        }

        @Override
        public MxCommandReply registerRaw(String clientName) {
            return session.registerRaw(clientName);
        }

        @Override
        public int addItem(int serverHandle, String itemDefinition) {
            return session.addItem(serverHandle, itemDefinition);
        }

        @Override
        public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
            return session.addItemRaw(serverHandle, itemDefinition);
        }

        @Override
        public void advise(int serverHandle, int itemHandle) {
            session.advise(serverHandle, itemHandle);
        }

        @Override
        public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
            return session.adviseRaw(serverHandle, itemHandle);
        }

        @Override
        public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
            return session.adviseSupervisoryRaw(serverHandle, itemHandle);
        }

        @Override
        public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
            return session.writeRaw(serverHandle, itemHandle, value, userId);
        }

        @Override
        public MxCommandReply writeSecuredRaw(
                int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
            return session.writeSecuredRaw(serverHandle, itemHandle, currentUserId, verifierUserId, value);
        }

        @Override
        public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
            return session.authenticateUser(serverHandle, verifyUser, verifyUserPassword);
        }

        @Override
        public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
            return session.subscribeBulk(serverHandle, items);
        }

        @Override
        public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
            return session.unsubscribeBulk(serverHandle, itemHandles);
        }

        @Override
        public List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout) {
            return session.readBulk(serverHandle, items, timeout);
        }

        @Override
        public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
            return session.writeBulk(serverHandle, entries);
        }

        @Override
        public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
            return session.write2Bulk(serverHandle, entries);
        }

        @Override
        public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
            return session.writeSecuredBulk(serverHandle, entries);
        }

        @Override
        public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
            return session.writeSecured2Bulk(serverHandle, entries);
        }

        @Override
        public MxEventStream streamEventsAfter(long afterWorkerSequence) {
            return session.streamEventsAfter(afterWorkerSequence);
        }
    }

    interface TextSupplier {
        String get();
    }

    private static void writeOutput(
            String command, CommonOptions common, boolean json, Message reply, TextSupplier textSupplier) {
        PrintWriter out = common.spec.commandLine().getOut();
        if (json) {
            Map<String, Object> output = new LinkedHashMap<>();
            output.put("command", command);
            output.put("options", common.redactedJsonMap());
            output.put("reply", new RawJson(protoJson(reply)));
            out.println(jsonObject(output));
            return;
        }
        out.println(textSupplier.get());
    }

    private static void writeBulkOutput(
            String command, CommonOptions common, boolean json, List<SubscribeResult> results) {
        PrintWriter out = common.spec.commandLine().getOut();
        if (json) {
            Map<String, Object> output = new LinkedHashMap<>();
            output.put("command", command);
            output.put("options", common.redactedJsonMap());
            output.put("results", results.stream().map(MxGatewayCli::subscribeResultMap).toList());
            out.println(jsonObject(output));
            return;
        }
        out.println(results.size());
    }

    private static Map<String, Object> subscribeResultMap(SubscribeResult result) {
        Map<String, Object> values = new LinkedHashMap<>();
        values.put("serverHandle", result.getServerHandle());
        values.put("tagAddress", result.getTagAddress());
        values.put("itemHandle", result.getItemHandle());
        values.put("wasSuccessful", result.getWasSuccessful());
        values.put("errorMessage", result.getErrorMessage());
        return values;
    }

    private static void writeWriteBulkOutput(
            String command, CommonOptions common, boolean json, List<BulkWriteResult> results) {
        PrintWriter out = common.spec.commandLine().getOut();
        if (json) {
            Map<String, Object> output = new LinkedHashMap<>();
            output.put("command", command);
            output.put("options", common.redactedJsonMap());
            output.put("results", results.stream().map(MxGatewayCli::bulkWriteResultMap).toList());
            out.println(jsonObject(output));
            return;
        }
        out.println(results.size());
    }

    private static Map<String, Object> bulkWriteResultMap(BulkWriteResult result) {
        Map<String, Object> values = new LinkedHashMap<>();
        values.put("serverHandle", result.getServerHandle());
        values.put("itemHandle", result.getItemHandle());
        values.put("wasSuccessful", result.getWasSuccessful());
        values.put("hresult", result.hasHresult() ? (Object) result.getHresult() : null);
        values.put("errorMessage", result.getErrorMessage());
        return values;
    }

    private static void writeReadBulkOutput(
            String command, CommonOptions common, boolean json, List<BulkReadResult> results) {
        PrintWriter out = common.spec.commandLine().getOut();
        if (json) {
            Map<String, Object> output = new LinkedHashMap<>();
            output.put("command", command);
            output.put("options", common.redactedJsonMap());
            output.put("results", results.stream().map(MxGatewayCli::bulkReadResultMap).toList());
            out.println(jsonObject(output));
            return;
        }
        out.println(results.size());
    }

    private static Map<String, Object> bulkReadResultMap(BulkReadResult result) {
        Map<String, Object> values = new LinkedHashMap<>();
        values.put("serverHandle", result.getServerHandle());
        values.put("tagAddress", result.getTagAddress());
        values.put("itemHandle", result.getItemHandle());
        values.put("wasSuccessful", result.getWasSuccessful());
        values.put("wasCached", result.getWasCached());
        values.put("quality", result.getQuality());
        values.put("errorMessage", result.getErrorMessage());
        return values;
    }

    /**
     * Renders one {@link AlarmFeedMessage} in the CLI's plain-text output
     * style, distinguishing the active-alarm snapshot, snapshot-complete
     * sentinel, transition, provider-status, and snapshot-status cases of the
     * message's {@code payload} oneof.
     */
    private static String formatAlarmFeedMessage(AlarmFeedMessage message) {
        return switch (message.getPayloadCase()) {
            case ACTIVE_ALARM -> {
                ActiveAlarmSnapshot alarm = message.getActiveAlarm();
                yield String.format(
                        "active-alarm %s state=%s severity=%d",
                        alarm.getAlarmFullReference(), alarm.getCurrentState().name(), alarm.getSeverity());
            }
            case SNAPSHOT_COMPLETE -> "snapshot-complete";
            case TRANSITION -> {
                OnAlarmTransitionEvent transition = message.getTransition();
                yield String.format(
                        "transition %s kind=%s severity=%d",
                        transition.getAlarmFullReference(),
                        transition.getTransitionKind().name(),
                        transition.getSeverity());
            }
            case PROVIDER_STATUS -> {
                AlarmProviderStatus status = message.getProviderStatus();
                yield String.format(
                        "provider-status mode=%s degraded=%b reason=%s",
                        status.getMode().name(), status.getDegraded(), status.getReason());
            }
            case SNAPSHOT_STATUS -> {
                AlarmSnapshotStatus status = message.getSnapshotStatus();
                yield String.format("snapshot-status truncated=%b", status.getTruncated());
            }
            case PAYLOAD_NOT_SET -> "unknown";
        };
    }

    private static MxValue parseValue(String type, String text) {
        return switch (type) {
            case "bool" -> MxValues.boolValue(Boolean.parseBoolean(text));
            case "int32" -> MxValues.int32Value(Integer.parseInt(text));
            case "int64" -> MxValues.int64Value(Long.parseLong(text));
            case "float" -> MxValues.floatValue(Float.parseFloat(text));
            case "double" -> MxValues.doubleValue(Double.parseDouble(text));
            case "string" -> MxValues.stringValue(text);
            default -> throw new IllegalArgumentException("unsupported value type " + type);
        };
    }

    private static List<String> parseStringList(String value) {
        return Arrays.stream(value.split(","))
                .map(String::trim)
                .filter(item -> !item.isBlank())
                .toList();
    }

    private static List<Integer> parseIntList(String value) {
        return parseStringList(value).stream().map(Integer::parseInt).toList();
    }

    private static Duration parseDuration(String value) {
        if (value == null || value.isBlank()) {
            return Duration.ofSeconds(30);
        }
        if (value.startsWith("P")) {
            return Duration.parse(value);
        }
        if (value.endsWith("ms")) {
            return Duration.ofMillis(Long.parseLong(value.substring(0, value.length() - 2)));
        }
        if (value.endsWith("s")) {
            return Duration.ofSeconds(Long.parseLong(value.substring(0, value.length() - 1)));
        }
        if (value.endsWith("m")) {
            return Duration.ofMinutes(Long.parseLong(value.substring(0, value.length() - 1)));
        }
        return Duration.parse(value);
    }

    private static String protoJson(Message message) {
        try {
            return JsonFormat.printer().omittingInsignificantWhitespace().print(message);
        } catch (Exception error) {
            throw new IllegalStateException("failed to write protobuf JSON", error);
        }
    }

    private static String jsonObject(Map<String, Object> values) {
        StringBuilder builder = new StringBuilder();
        builder.append('{');
        boolean first = true;
        for (Map.Entry<String, Object> entry : values.entrySet()) {
            if (!first) {
                builder.append(',');
            }
            first = false;
            builder.append(jsonString(entry.getKey())).append(':').append(jsonValue(entry.getValue()));
        }
        builder.append('}');
        return builder.toString();
    }

    @SuppressWarnings("unchecked")
    private static String jsonValue(Object value) {
        if (value == null) {
            return "null";
        }
        if (value instanceof RawJson rawJson) {
            return rawJson.value();
        }
        if (value instanceof String string) {
            return jsonString(string);
        }
        if (value instanceof Number || value instanceof Boolean) {
            return value.toString();
        }
        if (value instanceof Map<?, ?> map) {
            return jsonObject((Map<String, Object>) map);
        }
        if (value instanceof Iterable<?> iterable) {
            StringBuilder builder = new StringBuilder();
            builder.append('[');
            boolean first = true;
            for (Object item : iterable) {
                if (!first) {
                    builder.append(',');
                }
                first = false;
                builder.append(jsonValue(item));
            }
            builder.append(']');
            return builder.toString();
        }
        return jsonString(value.toString());
    }

    // Package-private for the Client.Java-041 escaping regression test.
    static String jsonString(String value) {
        // RFC 8259 requires the two-character escapes for the named control
        // characters and six-character uXXXX escapes for the remaining
        // U+0000-U+001F (and U+007F) range. The old implementation escaped only
        // backslash, quote, CR, and LF, so a
        // value containing a tab, backspace, form-feed, or any other control
        // character produced malformed JSON (Client.Java-041).
        StringBuilder builder = new StringBuilder(value.length() + 2);
        builder.append('"');
        for (int i = 0; i < value.length(); i++) {
            char c = value.charAt(i);
            switch (c) {
                case '\\' -> builder.append("\\\\");
                case '"' -> builder.append("\\\"");
                case '\r' -> builder.append("\\r");
                case '\n' -> builder.append("\\n");
                case '\t' -> builder.append("\\t");
                case '\b' -> builder.append("\\b");
                case '\f' -> builder.append("\\f");
                default -> {
                    if (c < 0x20 || c == 0x7f) {
                        builder.append(String.format("\\u%04x", (int) c));
                    } else {
                        builder.append(c);
                    }
                }
            }
        }
        builder.append('"');
        return builder.toString();
    }

    private record RawJson(String value) {
    }
}
