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

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import io.grpc.stub.StreamObserver;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmReply;
import mxaccess_gateway.v1.MxaccessGateway.AcknowledgeAlarmRequest;
import mxaccess_gateway.v1.MxaccessGateway.ActiveAlarmSnapshot;
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.MxEventFamily;
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
import mxaccess_gateway.v1.MxaccessGateway.SessionState;
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 org.junit.jupiter.api.Test;

final class MxGatewayCliTests {
    @Test
    void versionCommandPrintsProtocolVersions() {
        CliRun run = execute(new FakeClientFactory(), "version");

        assertEquals(0, run.exitCode());
        assertEquals("", run.errors());
        assertTrue(run.output().contains("mxgateway-java 0.2.1"));
        assertTrue(run.output().contains("gatewayProtocolVersion=3"));
        assertTrue(run.output().contains("workerProtocolVersion=1"));
    }

    @Test
    void jsonStringEscapesControlCharacters() {
        // Client.Java-041 — the hand-rolled jsonString escaped only backslash,
        // quote, CR, and LF, so a tab/backspace/form-feed or any other control
        // char produced malformed JSON (RFC 8259). After the fix the named control
        // chars use their two-character escapes and the rest use six-char uXXXX.
        assertEquals("\"a\\tb\"", MxGatewayCli.jsonString("a\tb"));
        assertEquals("\"a\\bb\"", MxGatewayCli.jsonString("a\bb"));
        assertEquals("\"a\\fb\"", MxGatewayCli.jsonString("a\fb"));
        assertEquals("\"a\\rb\"", MxGatewayCli.jsonString("a\rb"));
        assertEquals("\"a\\nb\"", MxGatewayCli.jsonString("a\nb"));
        // A non-named control character (U+0001) must become .
        assertEquals("\"a\\u0001b\"", MxGatewayCli.jsonString("ab"));
        // DEL (U+007F) is also escaped.
        assertEquals("\"a\\u007fb\"", MxGatewayCli.jsonString("ab"));
        // Quote and backslash still escape; ordinary printable text is verbatim.
        assertEquals("\"a\\\"\\\\b\"", MxGatewayCli.jsonString("a\"\\b"));
        assertEquals("\"plain\"", MxGatewayCli.jsonString("plain"));
    }

    @Test
    void versionCommandPrintsJson() {
        CliRun run = execute(new FakeClientFactory(), "version", "--json");

        assertEquals(0, run.exitCode());
        assertTrue(run.output().contains("\"clientVersion\":\"0.2.1\""));
        assertTrue(run.output().contains("\"gatewayProtocolVersion\":3"));
    }

    @Test
    void openSessionJsonRedactsApiKey() {
        CliRun run = execute(
                new FakeClientFactory(),
                "open-session",
                "--endpoint",
                "localhost:5000",
                "--api-key",
                "mxgw_visible_secret",
                "--plaintext",
                "--client-session-name",
                "java-cli",
                "--json");

        assertEquals(0, run.exitCode());
        assertTrue(run.output().contains("\"command\":\"open-session\""));
        assertTrue(run.output().contains("\"sessionId\":\"session-cli\""));
        assertTrue(run.output().contains("mxgw***********cret"));
        assertFalse(run.output().contains("visible_secret"));
    }

    @Test
    void writeBuildsTypedValueFromParserOptions() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "write",
                "--session-id",
                "session-cli",
                "--server-handle",
                "12",
                "--item-handle",
                "34",
                "--type",
                "int32",
                "--value",
                "123",
                "--json");

        assertEquals(0, run.exitCode());
        assertEquals(123, factory.client.session.lastWriteValue.getInt32Value());
        assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_WRITE\""));
    }

    @Test
    void smokeCommandRunsOpenRegisterAddAdviseAndClose() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(factory, "smoke", "--item", "TestObject.TestInt", "--json");

        assertEquals(0, run.exitCode());
        assertTrue(factory.client.session.registerCalled);
        assertTrue(factory.client.session.addItemCalled);
        assertTrue(factory.client.session.adviseCalled);
        assertTrue(factory.client.closeCalled);
        assertTrue(run.output().contains("\"serverHandle\":42"));
        assertTrue(run.output().contains("\"itemHandle\":7"));
    }

    @Test
    void adviseSupervisoryCommandCallsAdviseSupervisoryRaw() {
        // Client.Java-050: dedicated test for advise-supervisory, using a
        // separate adviseSupervisoryCalled flag so it cannot be masked by the
        // plain advise path that shares adviseCalled.
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "advise-supervisory",
                "--session-id", "session-cli",
                "--server-handle", "12",
                "--item-handle", "34",
                "--json");

        assertEquals(0, run.exitCode());
        assertTrue(factory.client.session.adviseSupervisoryCalled);
        assertFalse(factory.client.session.adviseCalled, "plain advise must not be called");
        assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\""));
    }

    @Test
    void writeSecuredCommandForwardsUserIdsAndValue() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "write-secured",
                "--session-id", "session-cli",
                "--server-handle", "12",
                "--item-handle", "34",
                "--type", "int32",
                "--value", "77",
                "--current-user-id", "100",
                "--verifier-user-id", "200",
                "--json");

        assertEquals(0, run.exitCode());
        assertEquals(77, factory.client.session.lastWriteSecuredValue.getInt32Value());
        assertEquals(100, factory.client.session.lastWriteSecuredCurrentUserId);
        assertEquals(200, factory.client.session.lastWriteSecuredVerifierUserId);
        assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_WRITE_SECURED\""));
    }

    @Test
    void authenticateUserCommandForwardsCredentialAndPrintsUserIdWithoutEchoingPassword() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "authenticate-user",
                "--session-id", "session-cli",
                "--server-handle", "3",
                "--verify-user", "operator",
                "--password", "super-secret-pw",
                "--json");

        assertEquals(0, run.exitCode());
        // The credential reaches the session (request) but never the output.
        assertEquals("operator", factory.client.session.lastAuthenticateUser);
        assertEquals("super-secret-pw", factory.client.session.lastAuthenticatePassword);
        assertTrue(run.output().contains("\"userId\":4242"), run.output());
        assertFalse(run.output().contains("super-secret-pw"), "password must never be echoed");
        assertFalse(run.errors().contains("super-secret-pw"), "password must never be echoed to stderr");
    }

    /**
     * CLI-45: an unresolved credential must abort with a picocli usage error
     * before the CLI dials, instead of authenticating with an empty password.
     * The message names the option and the variable, never a value.
     */
    @Test
    void authenticateUserRejectsMissingCredentialWithUsageError() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "authenticate-user",
                "--session-id", "session-cli",
                "--server-handle", "3",
                "--verify-user", "operator",
                "--password-env", "MXGW_CLI45_ABSENT_PASSWORD_VAR",
                "--json");

        assertNotEquals(0, run.exitCode(), "a missing credential must fail");
        assertTrue(run.errors().contains("--password"), run.errors());
        assertTrue(run.errors().contains("MXGW_CLI45_ABSENT_PASSWORD_VAR"), run.errors());
        assertNull(factory.client, "the CLI must not connect without a credential");
    }

    /**
     * CLI-45: a blank {@code --password} is treated as missing — the CLI never
     * sends a fabricated empty credential to the wire.
     */
    @Test
    void authenticateUserRejectsBlankPasswordValue() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "authenticate-user",
                "--session-id", "session-cli",
                "--server-handle", "3",
                "--verify-user", "operator",
                "--password", "",
                "--password-env", "MXGW_CLI45_ABSENT_PASSWORD_VAR",
                "--json");

        assertNotEquals(0, run.exitCode(), "a blank credential must fail");
        assertNull(factory.client, "the CLI must not connect without a credential");
    }

    /**
     * CLI-45: {@code --password-env} defaults to the canonical
     * {@code MXGATEWAY_VERIFY_PASSWORD}, so the usage error names it when no
     * explicit variable is given. Skipped if the canonical variable happens to be
     * exported in the running environment (which would satisfy the credential).
     */
    @Test
    void authenticateUserDefaultsToCanonicalPasswordEnvName() {
        assumeTrue(System.getenv(MxGatewayCli.DEFAULT_VERIFY_PASSWORD_ENV) == null);

        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "authenticate-user",
                "--session-id", "session-cli",
                "--server-handle", "3",
                "--verify-user", "operator",
                "--json");

        assertNotEquals(0, run.exitCode());
        assertTrue(run.errors().contains("MXGATEWAY_VERIFY_PASSWORD"), run.errors());
    }

    // ---- ping subcommand (D4) ----

    @Test
    void pingCommandForwardsMessageAndPrintsEcho() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory, "ping", "--session-id", "session-cli", "--message", "hello-mxgw");

        assertEquals(0, run.exitCode());
        assertEquals("hello-mxgw", factory.client.session.lastPingMessage);
        // The worker echoes the message in the diagnostic message field; the
        // plain-text path surfaces exactly that echoed value.
        assertEquals("hello-mxgw", run.output().trim());
    }

    @Test
    void pingCommandDefaultsMessageToPing() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(factory, "ping", "--session-id", "session-cli");

        assertEquals(0, run.exitCode());
        assertEquals("ping", factory.client.session.lastPingMessage);
    }

    @Test
    void pingCommandJsonIncludesPingKindAndDiagnosticMessage() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory, "ping", "--session-id", "session-cli", "--message", "diag-1", "--json");

        assertEquals(0, run.exitCode());
        String out = run.output();
        assertTrue(out.contains("\"command\":\"ping\""), out);
        assertTrue(out.contains("\"kind\":\"MX_COMMAND_KIND_PING\""), out);
        assertTrue(out.contains("diag-1"), out);
    }

    // ---- galaxy-browse subcommand (D8-java) ----

    @Test
    void galaxyBrowseNodeJsonUsesHasChildrenHintKeyAndFlattensObjectFields() {
        GalaxyObject object = GalaxyObject.newBuilder()
                .setGobjectId(101)
                .setTagName("Area001")
                .setBrowseName("Area001")
                .build();
        Map<String, Object> leaf = MxGatewayCli.browseNodeMap(
                GalaxyObject.newBuilder().setGobjectId(202).setTagName("Pump001").build(),
                false,
                List.of());
        Map<String, Object> node = MxGatewayCli.browseNodeMap(object, true, List.of(leaf));

        // Cross-client JSON parity: the per-node "has children" flag MUST use the
        // key hasChildrenHint (Rust / Python / .NET / Go all standardized on it).
        assertTrue(node.containsKey("hasChildrenHint"), node.toString());
        assertEquals(Boolean.TRUE, node.get("hasChildrenHint"));
        // Object fields are flattened directly into the node (matching the
        // galaxy-discover object shape), not nested under an "object" key.
        assertFalse(node.containsKey("object"), node.toString());
        assertEquals(101L, ((Number) node.get("gobjectId")).longValue());
        assertEquals("Area001", node.get("tagName"));
        // Nested children array carries the same node shape recursively.
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> children = (List<Map<String, Object>>) node.get("children");
        assertEquals(1, children.size());
        assertTrue(children.get(0).containsKey("hasChildrenHint"));
        assertEquals(Boolean.FALSE, children.get(0).get("hasChildrenHint"));
    }

    @Test
    void galaxyBrowseInvocationsParseCleanly() {
        // galaxy-browse connects via GalaxyRepositoryClient.connect (a static),
        // so the full surface is exercised only by the cross-language matrix
        // against a live gateway. Here we assert the option surface parses.
        assertReadmeExampleParses(new String[] {"galaxy-browse", "--json"});
        assertReadmeExampleParses(new String[] {"galaxy-browse", "--parent", "42", "--json"});
        assertReadmeExampleParses(new String[] {
            "galaxy-browse",
            "--depth", "2",
            "--category-ids", "1,2",
            "--template-contains", "$Pump",
            "--tag-name-glob", "Area%",
            "--alarm-bearing-only",
            "--historized-only",
            "--include-attributes",
            "--json"
        });
    }

    @Test
    void galaxyBrowseNegativeDepthYieldsNonZeroExitViaParameterException() {
        // Fix: --depth validation must surface as a picocli ParameterException
        // (clean error line on stderr) rather than an unhandled IllegalArgumentException
        // stack trace. Picocli maps ParameterException to exit code 2.
        CliRun run = execute(new FakeClientFactory(), "galaxy-browse", "--depth", "-1");

        assertFalse(run.exitCode() == 0, "expected non-zero exit for --depth -1");
        // Picocli writes ParameterException messages to the error writer.
        assertTrue(run.errors().contains("--depth"), "expected --depth in error output: " + run.errors());
    }

    @Test
    void galaxyBrowseDepthAbove50YieldsNonZeroExit() {
        CliRun run = execute(new FakeClientFactory(), "galaxy-browse", "--depth", "51");

        assertFalse(run.exitCode() == 0, "expected non-zero exit for --depth 51");
        assertTrue(run.errors().contains("--depth"), "expected --depth in error output: " + run.errors());
    }

    @Test
    void galaxyBrowseParentZeroEmitsWarningToStderr() {
        // --parent 0 is the server sentinel for roots; passing it explicitly is
        // almost certainly a mistake. The CLI must print a warning to stderr
        // (matching Go/Rust client behaviour) but must still attempt the call.
        //
        // GalaxyBrowseCommand prints the warning, then calls connect() on the
        // GalaxyClientFactory. We inject a stub factory whose connect() throws,
        // so only the warning path runs — no live Netty channel to localhost is
        // constructed (Client.Java-043). The warning is emitted before
        // connect() is reached, so it appears on stderr regardless.
        CliRun run = executeGalaxy(
                new ThrowingGalaxyClientFactory(),
                "galaxy-browse", "--parent", "0", "--depth", "1");

        assertTrue(
                run.errors().contains("--parent 0"),
                "expected '--parent 0' warning on stderr; got: " + run.errors());
    }

    // ---- galaxy command-name aliases (D9-java) ----

    @Test
    void galaxyTestConnectionCanonicalAndDeprecatedAliasResolve() {
        picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory());
        // Both the canonical dash-separated name and the deprecated short alias
        // must resolve to the same subcommand so existing scripts keep working.
        assertTrue(commandLine.getSubcommands().containsKey("galaxy-test-connection"));
        assertTrue(commandLine.getSubcommands().containsKey("galaxy-test"));
        assertEquals(
                commandLine.getSubcommands().get("galaxy-test-connection"),
                commandLine.getSubcommands().get("galaxy-test"));
    }

    @Test
    void galaxyLastDeployCanonicalAndDeprecatedAliasResolve() {
        picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory());
        assertTrue(commandLine.getSubcommands().containsKey("galaxy-last-deploy"));
        assertTrue(commandLine.getSubcommands().containsKey("galaxy-deploy-time"));
        assertEquals(
                commandLine.getSubcommands().get("galaxy-last-deploy"),
                commandLine.getSubcommands().get("galaxy-deploy-time"));
    }

    @Test
    void subscribeBulkCommandPrintsResults() {
        CliRun run = execute(
                new FakeClientFactory(),
                "subscribe-bulk",
                "--session-id",
                "session-cli",
                "--server-handle",
                "42",
                "--items",
                "TestMachine_001.TestChangingInt,TestMachine_002.TestChangingInt",
                "--json");

        assertEquals(0, run.exitCode());
        assertTrue(run.output().contains("\"command\":\"subscribe-bulk\""));
        assertTrue(run.output().contains("\"itemHandle\":100"));
        assertTrue(run.output().contains("\"tagAddress\":\"TestMachine_002.TestChangingInt\""));
    }

    // ---- read-bulk / write-bulk / write2-bulk subcommands ----

    @Test
    void readBulkCommandForwardsTimeoutAndPrintsResults() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "read-bulk",
                "--session-id", "session-cli",
                "--server-handle", "42",
                "--items", "TestMachine_001.TestInt,TestMachine_002.TestInt",
                "--timeout-ms", "750",
                "--json");

        assertEquals(0, run.exitCode());
        assertEquals(750, factory.client.session.lastReadBulkTimeoutMs);
        String out = run.output();
        assertTrue(out.contains("\"command\":\"read-bulk\""), out);
        assertTrue(out.contains("\"tagAddress\":\"TestMachine_001.TestInt\""), out);
        assertTrue(out.contains("\"tagAddress\":\"TestMachine_002.TestInt\""), out);
        assertTrue(out.contains("\"itemHandle\""), out);
        assertTrue(out.contains("\"wasCached\""), out);
        assertTrue(out.contains("\"quality\""), out);
    }

    @Test
    void writeBulkCommandParsesTypedValuesAndPrintsResults() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "write-bulk",
                "--session-id", "session-cli",
                "--server-handle", "42",
                "--item-handles", "100,101",
                "--type", "int32",
                "--values", "111,222",
                "--user-id", "5",
                "--json");

        assertEquals(0, run.exitCode());
        List<WriteBulkEntry> entries = factory.client.session.lastWriteBulkEntries;
        assertEquals(2, entries.size());
        assertEquals(111, entries.get(0).getValue().getInt32Value());
        assertEquals(222, entries.get(1).getValue().getInt32Value());
        assertEquals(5, entries.get(0).getUserId());
        assertEquals(5, entries.get(1).getUserId());
        String out = run.output();
        assertTrue(out.contains("\"command\":\"write-bulk\""), out);
        assertTrue(out.contains("\"itemHandle\":100"), out);
        assertTrue(out.contains("\"wasSuccessful\":true"), out);
    }

    @Test
    void write2BulkCommandForwardsTimestampAndPrintsResults() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "write2-bulk",
                "--session-id", "session-cli",
                "--server-handle", "42",
                "--item-handles", "100",
                "--type", "string",
                "--values", "hello",
                "--timestamp", "2026-05-20T00:00:00Z",
                "--json");

        assertEquals(0, run.exitCode());
        List<Write2BulkEntry> entries = factory.client.session.lastWrite2BulkEntries;
        assertEquals(1, entries.size());
        assertTrue(entries.get(0).hasTimestampValue(), "expected timestampValue to be set");
        String out = run.output();
        assertTrue(out.contains("\"command\":\"write2-bulk\""), out);
        assertTrue(out.contains("\"itemHandle\":100"), out);
        assertTrue(out.contains("\"wasSuccessful\":true"), out);
    }

    @Test
    void writeSecuredBulkCommandForwardsUserIdsAndPrintsResults() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "write-secured-bulk",
                "--session-id", "session-cli",
                "--server-handle", "42",
                "--item-handles", "100,101",
                "--type", "int32",
                "--values", "10,20",
                "--current-user-id", "7",
                "--verifier-user-id", "8",
                "--json");

        assertEquals(0, run.exitCode());
        List<WriteSecuredBulkEntry> entries = factory.client.session.lastWriteSecuredBulkEntries;
        assertEquals(2, entries.size());
        assertEquals(7, entries.get(0).getCurrentUserId());
        assertEquals(8, entries.get(0).getVerifierUserId());
        assertEquals(7, entries.get(1).getCurrentUserId());
        assertEquals(8, entries.get(1).getVerifierUserId());
        assertEquals(10, entries.get(0).getValue().getInt32Value());
        assertEquals(20, entries.get(1).getValue().getInt32Value());
        String out = run.output();
        assertTrue(out.contains("\"command\":\"write-secured-bulk\""), out);
        assertTrue(out.contains("\"itemHandle\":100"), out);
        assertTrue(out.contains("\"wasSuccessful\":true"), out);
    }

    @Test
    void writeSecured2BulkCommandForwardsTimestampAndUserIdsAndPrintsResults() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "write-secured2-bulk",
                "--session-id", "session-cli",
                "--server-handle", "42",
                "--item-handles", "100",
                "--type", "string",
                "--values", "hello",
                "--timestamp", "2026-05-20T00:00:00Z",
                "--current-user-id", "7",
                "--verifier-user-id", "8",
                "--json");

        assertEquals(0, run.exitCode());
        List<WriteSecured2BulkEntry> entries = factory.client.session.lastWriteSecured2BulkEntries;
        assertEquals(1, entries.size());
        assertEquals(7, entries.get(0).getCurrentUserId());
        assertEquals(8, entries.get(0).getVerifierUserId());
        assertTrue(entries.get(0).hasTimestampValue(), "expected timestampValue to be set");
        String out = run.output();
        assertTrue(out.contains("\"command\":\"write-secured2-bulk\""), out);
        assertTrue(out.contains("\"itemHandle\":100"), out);
        assertTrue(out.contains("\"wasSuccessful\":true"), out);
    }

    @Test
    void benchReadBulkCommandEmitsJsonSchemaKeys() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "bench-read-bulk",
                "--session-id", "session-cli",
                "--server-handle", "42",
                "--items", "TestMachine_001.TestInt",
                "--iterations", "3",
                "--warmup", "0",
                "--json");

        assertEquals(0, run.exitCode());
        String out = run.output();
        assertTrue(out.contains("\"command\":\"bench-read-bulk\""), out);
        assertTrue(out.contains("\"iterations\""), out);
        assertTrue(out.contains("\"warmup\""), out);
        assertTrue(out.contains("\"tagCount\""), out);
        assertTrue(out.contains("\"resultCount\""), out);
        assertTrue(out.contains("\"successCount\""), out);
        assertTrue(out.contains("\"cachedCount\""), out);
        assertTrue(out.contains("\"avgMs\""), out);
        assertTrue(out.contains("\"minMs\""), out);
        assertTrue(out.contains("\"maxMs\""), out);
    }

    @Test
    void closeSessionCommandPrintsReply() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "close-session",
                "--session-id", "session-cli",
                "--json");

        assertEquals(0, run.exitCode());
        assertTrue(factory.client.closeCalled);
        String out = run.output();
        assertTrue(out.contains("\"command\":\"close-session\""), out);
        assertTrue(out.contains("SESSION_STATE_CLOSED"), out);
    }

    @Test
    void unsubscribeBulkCommandPrintsResults() {
        CliRun run = execute(
                new FakeClientFactory(),
                "unsubscribe-bulk",
                "--session-id",
                "session-cli",
                "--server-handle",
                "42",
                "--item-handles",
                "100,101",
                "--json");

        assertEquals(0, run.exitCode());
        assertTrue(run.output().contains("\"command\":\"unsubscribe-bulk\""));
        assertTrue(run.output().contains("\"itemHandle\":101"));
        assertTrue(run.output().contains("\"wasSuccessful\":true"));
    }

    // ---- stream-alarms / acknowledge-alarm subcommands ----

    @Test
    void streamAlarmsCommandForwardsFilterPrefixAndPrintsFeedMessages() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Tank01");

        assertEquals(0, run.exitCode());
        assertEquals("Tank01", factory.client.lastStreamAlarmsRequest.getAlarmFilterPrefix());
        String out = run.output();
        assertTrue(out.contains("active-alarm Tank01.Level.HiHi"), out);
        assertTrue(out.contains("snapshot-complete"), out);
        assertTrue(out.contains("transition Tank01.Level.HiHi"), out);
    }

    @Test
    void streamAlarmsCommandHonoursLimit() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(factory, "stream-alarms", "--limit", "1");

        assertEquals(0, run.exitCode());
        long lines = run.output().lines().filter(line -> !line.isBlank()).count();
        assertEquals(1, lines, "expected exactly one feed message with --limit 1, got: " + run.output());
    }

    @Test
    void streamAlarmsCommandPrintsJson() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(factory, "stream-alarms", "--json");

        assertEquals(0, run.exitCode());
        assertTrue(run.output().contains("\"activeAlarm\""), run.output());
        assertTrue(run.output().contains("\"snapshotComplete\""), run.output());
    }

    @Test
    void acknowledgeAlarmCommandForwardsOptionsAndPrintsReply() {
        FakeClientFactory factory = new FakeClientFactory();
        CliRun run = execute(
                factory,
                "acknowledge-alarm",
                "--reference",
                "Tank01.Level.HiHi",
                "--comment",
                "checked",
                "--operator",
                "operator1",
                "--json");

        assertEquals(0, run.exitCode());
        assertEquals("Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
        assertEquals("checked", factory.client.lastAcknowledgeAlarmRequest.getComment());
        assertEquals("operator1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
        assertTrue(run.output().contains("\"command\":\"acknowledge-alarm\""), run.output());
    }

    @Test
    void acknowledgeAlarmCommandRequiresReference() {
        CliRun run = execute(new FakeClientFactory(), "acknowledge-alarm", "--comment", "checked");

        assertFalse(run.exitCode() == 0, "expected non-zero exit without --reference");
        assertTrue(run.errors().contains("--reference"), run.errors());
    }

    @Test
    void readmeDocumentedStreamAlarmsExampleParsesCleanly() {
        // Client.Java-032 regression — the README's stream-alarms example
        // (clients/java/README.md:182) must round-trip through picocli's
        // parser without a parse error. Before the fix, the example used
        // a non-existent --session-id option and picocli failed at parse
        // time. This test pins the exact tokens documented in the README.
        String[] args = {
                "stream-alarms",
                "--endpoint",
                "localhost:5000",
                "--api-key-env",
                "MXGATEWAY_API_KEY",
                "--plaintext",
                "--filter-prefix",
                "Galaxy",
                "--limit",
                "1",
                "--json"
        };
        assertReadmeExampleParses(args);
    }

    @Test
    void readmeDocumentedAcknowledgeAlarmExampleParsesCleanly() {
        // Client.Java-032 regression — the README's acknowledge-alarm
        // example (clients/java/README.md:183) must parse without error.
        // Before the fix it used --session-id (no such option) and
        // --alarm-reference (the real option is --reference), so picocli
        // rejected the invocation immediately.
        String[] args = {
                "acknowledge-alarm",
                "--endpoint",
                "localhost:5000",
                "--api-key-env",
                "MXGATEWAY_API_KEY",
                "--plaintext",
                "--reference",
                "\\Galaxy\\Area001.Pump001.PumpFault",
                "--json"
        };
        assertReadmeExampleParses(args);
    }

    /**
     * Parses the given args through the production picocli {@link CommandLine}
     * and asserts no parser error, no unknown option, and no missing required
     * option. Does not execute the command body — only the option / subcommand
     * parser is exercised, so no network call is made.
     */
    private static void assertReadmeExampleParses(String[] args) {
        picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory());
        try {
            commandLine.parseArgs(args);
        } catch (picocli.CommandLine.ParameterException ex) {
            throw new AssertionError(
                    "documented README invocation failed picocli parse: "
                            + String.join(" ", args)
                            + " -> "
                            + ex.getMessage(),
                    ex);
        }
    }

    @Test
    void requireCertificateValidationFlagPropagatesThroughToClientOptions() {
        // Client.Java-038 regression — the --require-certificate-validation
        // CLI flag must reach MxGatewayClientOptions.requireCertificateValidation
        // via CommonOptions.toClientOptions(), so CLI users can opt into strict
        // JVM-trust verification without pinning a CA.
        CapturingClientFactory factory = new CapturingClientFactory();
        CliRun run = execute(
                factory,
                "acknowledge-alarm",
                "--endpoint",
                "localhost:5000",
                "--api-key-env",
                "MXGATEWAY_API_KEY",
                "--require-certificate-validation",
                "--reference",
                "Tank01.Level.HiHi");

        assertEquals(0, run.exitCode(), "errors:\n" + run.errors());
        assertTrue(
                factory.capturedClientOptions.requireCertificateValidation(),
                "--require-certificate-validation did not propagate into MxGatewayClientOptions");
    }

    @Test
    void requireCertificateValidationDefaultsToLenientWhenFlagAbsent() {
        // Without the flag, the lenient-by-default trust posture must be
        // preserved (requireCertificateValidation == false).
        CapturingClientFactory factory = new CapturingClientFactory();
        CliRun run = execute(
                factory,
                "acknowledge-alarm",
                "--endpoint",
                "localhost:5000",
                "--api-key-env",
                "MXGATEWAY_API_KEY",
                "--reference",
                "Tank01.Level.HiHi");

        assertEquals(0, run.exitCode(), "errors:\n" + run.errors());
        assertFalse(
                factory.capturedClientOptions.requireCertificateValidation(),
                "requireCertificateValidation should default to false (lenient)");
    }

    @Test
    void streamAlarmsCommandFailsFastOnQueueOverflow() {
        // Client.Java-033/040/046 regression — the CLI's stream-alarms bounded
        // queue used queue.offer(value) which silently dropped messages past
        // capacity (1024). After the fix the CLI must surface the overflow as a
        // non-zero exit (mirroring MxEventStream's fail-fast contract).
        //
        // The OverflowingFakeClient floods the gRPC observer on a BACKGROUND
        // thread so the subscription is already published when the overflow
        // fires — exercising the terminate() cancel path with a non-null
        // subscription (Client.Java-046), not just the synchronous-flood path
        // where subscriptionRef is still null. The fix records the overflow in
        // a dedicated terminal slot (no queue.clear, Client.Java-040) and the
        // drain loop surfaces it with the overflow message text.
        OverflowingFakeClientFactory factory = new OverflowingFakeClientFactory();
        CliRun run = execute(factory, "stream-alarms", "--filter-prefix", "Flood");

        assertFalse(run.exitCode() == 0,
                "expected non-zero exit when the alarm queue overflows; got exit=" + run.exitCode()
                        + " out=\n" + run.output() + "\nerr=\n" + run.errors());
        assertTrue(
                run.errors().contains("queue overflowed") || run.output().contains("queue overflowed"),
                "expected the overflow message text to surface; out=\n" + run.output()
                        + "\nerr=\n" + run.errors());
    }

    @Test
    void streamEventsRendersScriptedEventsIncludingHighUint64Sequence() {
        // Drive the REAL MxGatewayClient / MxGatewaySession / MxEventStream
        // path over the in-process harness (Task 4), so the production
        // stream-events command exercises the real streaming wrapper instead
        // of a hand-written FakeSession seam.
        //
        // The high worker-sequence (-1L == 18446744073709551615 unsigned)
        // covers the unsigned-rendering regression: worker_sequence is a
        // proto uint64 carried as a Java long with the top bit set. The CLI's
        // --json path renders it through protobuf's JsonFormat, which prints
        // uint64 as an unsigned decimal STRING; a naive %d render would print
        // a negative number instead.
        MxEvent dataChange = MxEvent.newBuilder()
                .setFamily(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE)
                .setSessionId("session-cli")
                .setServerHandle(7)
                .setItemHandle(42)
                .setWorkerSequence(5L)
                .build();
        MxEvent highSequence = MxEvent.newBuilder()
                .setFamily(MxEventFamily.MX_EVENT_FAMILY_OPERATION_COMPLETE)
                .setSessionId("session-cli")
                .setServerHandle(9)
                .setItemHandle(99)
                // -1L unsigned == 18446744073709551615 (top bit set).
                .setWorkerSequence(-1L)
                .build();

        try (InProcessGatewayHarness harness = new InProcessGatewayHarness()) {
            harness.setScriptedEvents(List.of(dataChange, highSequence));
            CliRun run = execute(
                    new HarnessClientFactory(harness),
                    "stream-events",
                    "--session-id",
                    "session-cli",
                    "--json");

            assertEquals(0, run.exitCode(), "errors:\n" + run.errors());
            String out = run.output();
            // Scripted event fields surface in the JSON render.
            assertTrue(out.contains("\"family\":\"MX_EVENT_FAMILY_ON_DATA_CHANGE\""), out);
            assertTrue(out.contains("\"family\":\"MX_EVENT_FAMILY_OPERATION_COMPLETE\""), out);
            assertTrue(out.contains("\"serverHandle\":7"), out);
            assertTrue(out.contains("\"itemHandle\":42"), out);
            // The low sequence renders as the unsigned decimal string "5".
            assertTrue(out.contains("\"workerSequence\":\"5\""), out);
            // The high sequence renders as the FULL unsigned decimal, not -1.
            assertTrue(out.contains("\"workerSequence\":\"18446744073709551615\""), out);
            assertFalse(out.contains("\"workerSequence\":\"-1\""), out);
            assertFalse(out.contains("\"workerSequence\":-1"), out);
        }
    }

    @Test
    void streamEventsRendersReplayGapAsTypedRow() {
        // NEXT-02: the ReplayGap sentinel must render as the typed cross-CLI
        // row (numeric cursors under a replayGap key in --json, a REPLAY_GAP
        // line in text mode), never as the raw sentinel event — text mode
        // used to print "0 MX_EVENT_FAMILY_UNSPECIFIED".
        MxEvent gap = MxEvent.newBuilder()
                .setReplayGap(ReplayGap.newBuilder()
                        .setRequestedAfterSequence(7L)
                        .setOldestAvailableSequence(42L)
                        .build())
                .build();
        MxEvent dataChange = MxEvent.newBuilder()
                .setFamily(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE)
                .setSessionId("session-cli")
                .setWorkerSequence(43L)
                .build();

        try (InProcessGatewayHarness harness = new InProcessGatewayHarness()) {
            harness.setScriptedEvents(List.of(gap, dataChange));
            CliRun jsonRun = execute(
                    new HarnessClientFactory(harness),
                    "stream-events",
                    "--session-id",
                    "session-cli",
                    "--json");

            assertEquals(0, jsonRun.exitCode(), "errors:\n" + jsonRun.errors());
            String jsonOut = jsonRun.output();
            assertTrue(
                    jsonOut.contains(
                            "{\"replayGap\":{\"requestedAfterSequence\":7,\"oldestAvailableSequence\":42}}"),
                    jsonOut);
            assertTrue(jsonOut.contains("\"family\":\"MX_EVENT_FAMILY_ON_DATA_CHANGE\""), jsonOut);
            assertFalse(jsonOut.contains("MX_EVENT_FAMILY_UNSPECIFIED"), jsonOut);
        }

        try (InProcessGatewayHarness harness = new InProcessGatewayHarness()) {
            harness.setScriptedEvents(List.of(gap, dataChange));
            CliRun textRun = execute(
                    new HarnessClientFactory(harness),
                    "stream-events",
                    "--session-id",
                    "session-cli");

            assertEquals(0, textRun.exitCode(), "errors:\n" + textRun.errors());
            String textOut = textRun.output();
            assertTrue(textOut.contains("REPLAY_GAP requested_after=7 oldest_available=42"), textOut);
            assertFalse(textOut.contains("MX_EVENT_FAMILY_UNSPECIFIED"), textOut);
            assertTrue(textOut.contains("43 MX_EVENT_FAMILY_ON_DATA_CHANGE"), textOut);
        }
    }

    // ---- galaxy-discover / galaxy-watch over the in-process harness (Task 6) ----

    @Test
    void galaxyDiscoverPrintsPagedHierarchyJson() {
        // Drive the REAL GalaxyRepositoryClient.discoverHierarchy path over the
        // in-process harness (Task 6), so the production galaxy-discover command
        // exercises the real client paging loop against scripted objects instead
        // of a hand-written seam. The harness's fake discoverHierarchy returns a
        // single page carrying the scripted objects.
        GalaxyObject area = GalaxyObject.newBuilder()
                .setGobjectId(101)
                .setTagName("Area001")
                .setContainedName("Area001")
                .setBrowseName("Area001")
                .setIsArea(true)
                .build();
        GalaxyObject pump = GalaxyObject.newBuilder()
                .setGobjectId(202)
                .setTagName("Pump001")
                .setContainedName("Pump001")
                .setBrowseName("Pump001")
                .setParentGobjectId(101)
                .build();

        try (InProcessGatewayHarness harness = new InProcessGatewayHarness()) {
            harness.setScriptedObjects(List.of(area, pump));
            CliRun run = executeGalaxy(new HarnessGalaxyClientFactory(harness), "galaxy-discover", "--json");

            assertEquals(0, run.exitCode(), "errors:\n" + run.errors());
            String out = run.output();
            // galaxy-discover --json renders {"command":..,"options":..,"objects":[{galaxyObjectMap}]}.
            assertTrue(out.contains("\"command\":\"galaxy-discover\""), out);
            // Both scripted objects render with the flattened object fields.
            assertTrue(out.contains("\"tagName\":\"Area001\""), out);
            assertTrue(out.contains("\"tagName\":\"Pump001\""), out);
            assertTrue(out.contains("\"gobjectId\":101"), out);
            assertTrue(out.contains("\"gobjectId\":202"), out);
            assertTrue(out.contains("\"parentGobjectId\":101"), out);
            assertTrue(out.contains("\"isArea\":true"), out);
        }
    }

    @Test
    void galaxyWatchRendersScriptedDeployEvents() {
        // Drive the REAL GalaxyRepositoryClient.watchDeployEvents / DeployEventStream
        // path over the in-process harness. The harness's fake watchDeployEvents
        // streams the scripted deploy events then completes; the CLI's --limit
        // option caps how many it prints before closing the stream.
        DeployEvent first = DeployEvent.newBuilder()
                .setSequence(7L)
                .setObjectCount(12)
                .setAttributeCount(34)
                .build();
        DeployEvent second = DeployEvent.newBuilder()
                .setSequence(8L)
                .setObjectCount(13)
                .setAttributeCount(35)
                .build();
        DeployEvent third = DeployEvent.newBuilder()
                .setSequence(9L)
                .setObjectCount(14)
                .setAttributeCount(36)
                .build();

        try (InProcessGatewayHarness harness = new InProcessGatewayHarness()) {
            harness.setScriptedDeployEvents(List.of(first, second, third));
            // --limit 2 caps the feed at the first two scripted events.
            CliRun run = executeGalaxy(
                    new HarnessGalaxyClientFactory(harness), "galaxy-watch", "--limit", "2", "--json");

            assertEquals(0, run.exitCode(), "errors:\n" + run.errors());
            String out = run.output();
            // galaxy-watch --json prints one proto-JSON object per event; proto3
            // JSON renders uint64 sequence as a decimal string.
            assertTrue(out.contains("\"sequence\":\"7\""), out);
            assertTrue(out.contains("\"objectCount\":12"), out);
            assertTrue(out.contains("\"attributeCount\":34"), out);
            assertTrue(out.contains("\"sequence\":\"8\""), out);
            // --limit 2 must stop before printing the third scripted event.
            assertFalse(out.contains("\"sequence\":\"9\""), out);
        }
    }

    @Test
    void batchCommandExecutesVersionAndEmitsEorMarker() {
        CliRun run = executeBatch(new FakeClientFactory(), "version --json\n");

        assertEquals(0, run.exitCode());
        String out = run.output();
        assertTrue(out.contains("\"clientVersion\""), out);
        assertTrue(out.contains(MxGatewayCli.BATCH_EOR), out);
    }

    @Test
    void batchCommandTokenisesDoubleQuotedArgumentWithEmbeddedSpaces() {
        // Client.Java-034 regression — a real shell-style tokenizer must not
        // shred `"needs verification"` into two arguments. Drives
        // acknowledge-alarm through batch and asserts the captured --comment
        // is the un-quoted string with the embedded space preserved.
        FakeClientFactory factory = new FakeClientFactory();
        String line = "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"needs verification\" --operator op1\n";
        CliRun run = executeBatch(factory, line);

        assertEquals(0, run.exitCode());
        assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
        assertEquals("op1", factory.client.lastAcknowledgeAlarmRequest.getOperatorUser());
        assertEquals(
                "Tank01.Level.HiHi", factory.client.lastAcknowledgeAlarmRequest.getAlarmFullReference());
    }

    @Test
    void batchCommandTokenisesSingleQuotedArgumentWithEmbeddedSpaces() {
        FakeClientFactory factory = new FakeClientFactory();
        String line =
                "acknowledge-alarm --reference Tank01.Level.HiHi --comment 'needs verification' --operator op1\n";
        CliRun run = executeBatch(factory, line);

        assertEquals(0, run.exitCode());
        assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
    }

    @Test
    void batchCommandTokenisesBackslashEscapedSpaceOutsideQuotes() {
        FakeClientFactory factory = new FakeClientFactory();
        String line =
                "acknowledge-alarm --reference Tank01.Level.HiHi --comment needs\\ verification\n";
        CliRun run = executeBatch(factory, line);

        assertEquals(0, run.exitCode());
        assertEquals("needs verification", factory.client.lastAcknowledgeAlarmRequest.getComment());
    }

    @Test
    void batchCommandPreservesEmptyQuotedArgument() {
        FakeClientFactory factory = new FakeClientFactory();
        String line = "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"\"\n";
        CliRun run = executeBatch(factory, line);

        assertEquals(0, run.exitCode());
        assertEquals("", factory.client.lastAcknowledgeAlarmRequest.getComment());
    }

    @Test
    void batchCommandSupportsBackslashEscapedQuoteInsideDoubleQuotes() {
        // `--comment "with \"inner\" quote"` should round-trip the inner
        // double-quote into the comment string.
        FakeClientFactory factory = new FakeClientFactory();
        String line =
                "acknowledge-alarm --reference Tank01.Level.HiHi --comment \"with \\\"inner\\\" quote\"\n";
        CliRun run = executeBatch(factory, line);

        assertEquals(0, run.exitCode());
        assertEquals("with \"inner\" quote", factory.client.lastAcknowledgeAlarmRequest.getComment());
    }

    @Test
    void batchCommandEmitsEorAfterFailedCommandAndContinues() {
        // An unknown subcommand causes a picocli parse error (non-zero exit).
        // The loop must still emit BATCH_EOR for the failure and continue
        // processing the subsequent valid command.
        CliRun run = executeBatch(new FakeClientFactory(), "no-such-subcommand\nversion --json\n");

        assertEquals(0, run.exitCode());
        String out = run.output();
        long eorCount = out.lines()
                .filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
                .count();
        assertEquals(2, eorCount, "expected exactly 2 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
        assertTrue(out.contains("\"clientVersion\""), out);
    }

    /**
     * Runs the CLI with {@code batch} as the subcommand, using the provided
     * string as standard input content. Temporarily replaces {@link System#in}
     * for the duration of the call.
     */
    private static CliRun executeBatch(MxGatewayCli.MxGatewayCliClientFactory factory, String stdinContent) {
        InputStream originalIn = System.in;
        try {
            System.setIn(new ByteArrayInputStream(stdinContent.getBytes(StandardCharsets.UTF_8)));
            return execute(factory, "batch");
        } finally {
            System.setIn(originalIn);
        }
    }

    private static CliRun execute(MxGatewayCli.MxGatewayCliClientFactory factory, String... args) {
        StringWriter output = new StringWriter();
        StringWriter errors = new StringWriter();
        int exitCode = MxGatewayCli.execute(
                factory,
                new PrintWriter(output, true),
                new PrintWriter(errors, true),
                args);
        return new CliRun(exitCode, output.toString(), errors.toString());
    }

    /**
     * Runs a galaxy subcommand against the supplied {@link
     * MxGatewayCli.GalaxyClientFactory}, wiring it through the production
     * {@code commandLine(gatewayFactory, galaxyFactory)} two-arg overload (Task 3
     * seam). The gateway factory slot is unused by galaxy commands, so a plain
     * {@link FakeClientFactory} fills it. Mirrors {@link #execute} for the
     * gateway commands.
     */
    private static CliRun executeGalaxy(MxGatewayCli.GalaxyClientFactory galaxyFactory, String... args) {
        StringWriter output = new StringWriter();
        StringWriter errors = new StringWriter();
        picocli.CommandLine commandLine = MxGatewayCli.commandLine(new FakeClientFactory(), galaxyFactory);
        commandLine.setOut(new PrintWriter(output, true));
        commandLine.setErr(new PrintWriter(errors, true));
        int exitCode = commandLine.execute(args);
        return new CliRun(exitCode, output.toString(), errors.toString());
    }

    private record CliRun(int exitCode, String output, String errors) {
    }

    private static final class FakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
        private FakeClient client;

        @Override
        public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
            client = new FakeClient(options.spec.commandLine().getOut());
            return client;
        }
    }

    /**
     * Factory that records the {@link MxGatewayClientOptions} produced by
     * {@link MxGatewayCli.CommonOptions#toClientOptions()} so a test can assert
     * how CLI flags map onto the library option surface. Wraps the standard
     * {@link FakeClient} so the command body still completes. Used by the
     * Client.Java-038 option-flow regression.
     */
    private static final class CapturingClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
        private MxGatewayClientOptions capturedClientOptions;

        @Override
        public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
            capturedClientOptions = options.toClientOptions();
            return new FakeClient(options.spec.commandLine().getOut());
        }
    }

    /**
     * Factory whose fake client floods the {@code streamAlarms} observer with
     * 2000 messages synchronously, exceeding the CLI's bounded 1024-element
     * queue. Used by the Client.Java-033 fail-fast overflow regression.
     */
    private static final class OverflowingFakeClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
        @Override
        public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
            return new OverflowingFakeClient(options.spec.commandLine().getOut());
        }
    }

    /**
     * Factory that wires the production {@link MxGatewayCli.GrpcMxGatewayCliClient}
     * adapter around the harness's REAL
     * {@link com.zb.mom.ww.mxgateway.client.MxGatewayClient}, so the
     * stream-events command runs against the in-process scripted gateway over
     * a real channel (exercising the real {@code MxEventStream}). Mirrors the
     * production {@code GrpcMxGatewayCliClientFactory}, swapping only the
     * client construction for the harness-backed client.
     */
    private static final class HarnessClientFactory implements MxGatewayCli.MxGatewayCliClientFactory {
        private final InProcessGatewayHarness harness;

        private HarnessClientFactory(InProcessGatewayHarness harness) {
            this.harness = harness;
        }

        @Override
        public MxGatewayCli.MxGatewayCliClient connect(MxGatewayCli.CommonOptions options) {
            return new MxGatewayCli.GrpcMxGatewayCliClient(
                    harness.gatewayClient(), options.spec.commandLine().getOut());
        }
    }

    /**
     * Galaxy factory that returns the harness's REAL {@link
     * com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient} over the in-process
     * scripted {@code GalaxyRepository} service, so galaxy-discover / galaxy-watch
     * exercise the real client (paging loop, deploy-event stream wrapper) against
     * scripted payloads. Mirrors the production {@code GrpcGalaxyClientFactory},
     * swapping only client construction for the harness-backed client.
     */
    private static final class HarnessGalaxyClientFactory implements MxGatewayCli.GalaxyClientFactory {
        private final InProcessGatewayHarness harness;

        private HarnessGalaxyClientFactory(InProcessGatewayHarness harness) {
            this.harness = harness;
        }

        @Override
        public com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
            return harness.galaxyClient();
        }
    }

    /**
     * Galaxy client factory whose {@code connect} throws, so a test can exercise
     * a command's pre-connect path (e.g. the {@code --parent 0} warning) without
     * constructing a live Netty channel to localhost (Client.Java-043).
     */
    private static final class ThrowingGalaxyClientFactory implements MxGatewayCli.GalaxyClientFactory {
        @Override
        public com.zb.mom.ww.mxgateway.client.GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
            throw new IllegalStateException("galaxy connect not available in this test");
        }
    }

    private static final class OverflowingFakeClient implements MxGatewayCli.MxGatewayCliClient {
        private final PrintWriter out;

        OverflowingFakeClient(PrintWriter out) {
            this.out = out;
        }

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

        @Override
        public OpenSessionReply openSession(OpenSessionRequest request) {
            return OpenSessionReply.newBuilder().setSessionId("flood-session").setProtocolStatus(ok()).build();
        }

        @Override
        public CloseSessionReply closeSession(CloseSessionRequest request) {
            return CloseSessionReply.newBuilder()
                    .setSessionId(request.getSessionId())
                    .setFinalState(SessionState.SESSION_STATE_CLOSED)
                    .setProtocolStatus(ok())
                    .build();
        }

        @Override
        public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
            throw new UnsupportedOperationException();
        }

        @Override
        public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
            throw new UnsupportedOperationException();
        }

        @Override
        public MxGatewayAlarmFeedSubscription streamAlarms(
                StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
            // Push messages on a BACKGROUND thread (mirroring real gRPC, which
            // delivers onNext on a netty I/O thread) so the CLI's
            // subscriptionRef is already published when the overflow fires —
            // this exercises the terminate() cancel path with a non-null
            // subscription (Client.Java-046), unlike a synchronous flood that
            // overflows before streamAlarms even returns. Keeps pushing until
            // it observes the CLI cancelling the subscription on overflow, so
            // no fixed message count is needed and the thread always exits.
            MxGatewayAlarmFeedSubscription subscription = new MxGatewayAlarmFeedSubscription();
            Thread flood = new Thread(() -> {
                int i = 0;
                while (!Thread.currentThread().isInterrupted() && i < 100_000) {
                    observer.onNext(AlarmFeedMessage.newBuilder()
                            .setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
                                    .setAlarmFullReference("Flood." + i)
                                    .setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
                                    .setSeverity(700))
                            .build());
                    i++;
                }
                observer.onCompleted();
            }, "overflowing-fake-alarm-feed");
            flood.setDaemon(true);
            flood.start();
            return subscription;
        }

        @Override
        public void close() {
        }
    }

    private static final class FakeClient implements MxGatewayCli.MxGatewayCliClient {
        private final PrintWriter out;
        private final FakeSession session = new FakeSession();
        private boolean closeCalled;
        private AcknowledgeAlarmRequest lastAcknowledgeAlarmRequest;
        private StreamAlarmsRequest lastStreamAlarmsRequest;

        private FakeClient(PrintWriter out) {
            this.out = out;
        }

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

        @Override
        public OpenSessionReply openSession(OpenSessionRequest request) {
            return OpenSessionReply.newBuilder()
                    .setSessionId("session-cli")
                    .setProtocolStatus(ok())
                    .build();
        }

        @Override
        public CloseSessionReply closeSession(CloseSessionRequest request) {
            closeCalled = true;
            return CloseSessionReply.newBuilder()
                    .setSessionId(request.getSessionId())
                    .setFinalState(SessionState.SESSION_STATE_CLOSED)
                    .setProtocolStatus(ok())
                    .build();
        }

        @Override
        public MxGatewayCli.MxGatewayCliSession session(String sessionId) {
            return session;
        }

        @Override
        public AcknowledgeAlarmReply acknowledgeAlarm(AcknowledgeAlarmRequest request) {
            lastAcknowledgeAlarmRequest = request;
            return AcknowledgeAlarmReply.newBuilder()
                    .setCorrelationId(request.getClientCorrelationId())
                    .setProtocolStatus(ok())
                    .setHresult(0)
                    .build();
        }

        @Override
        public MxGatewayAlarmFeedSubscription streamAlarms(
                StreamAlarmsRequest request, StreamObserver<AlarmFeedMessage> observer) {
            lastStreamAlarmsRequest = request;
            // Replay a deterministic active-alarm snapshot, snapshot-complete
            // sentinel, transition, then complete the feed so the CLI command
            // drains a bounded stream without contacting a live gateway.
            observer.onNext(AlarmFeedMessage.newBuilder()
                    .setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
                            .setAlarmFullReference("Tank01.Level.HiHi")
                            .setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
                            .setSeverity(700))
                    .build());
            observer.onNext(AlarmFeedMessage.newBuilder().setSnapshotComplete(true).build());
            observer.onNext(AlarmFeedMessage.newBuilder()
                    .setTransition(OnAlarmTransitionEvent.newBuilder()
                            .setAlarmFullReference("Tank01.Level.HiHi")
                            .setTransitionKind(AlarmTransitionKind.ALARM_TRANSITION_KIND_ACKNOWLEDGE)
                            .setSeverity(700))
                    .build());
            observer.onCompleted();
            return new MxGatewayAlarmFeedSubscription();
        }

        @Override
        public void close() {
        }
    }

    private static final class FakeSession implements MxGatewayCli.MxGatewayCliSession {
        private boolean registerCalled;
        private boolean addItemCalled;
        private boolean adviseCalled;
        private boolean adviseSupervisoryCalled;
        private MxValue lastWriteValue;
        private MxValue lastWriteSecuredValue;
        private int lastWriteSecuredCurrentUserId;
        private int lastWriteSecuredVerifierUserId;
        private String lastAuthenticateUser;
        private String lastAuthenticatePassword;
        private String lastPingMessage;
        private long lastReadBulkTimeoutMs;
        private List<String> lastReadBulkItems;
        private List<WriteBulkEntry> lastWriteBulkEntries;
        private List<Write2BulkEntry> lastWrite2BulkEntries;
        private List<WriteSecuredBulkEntry> lastWriteSecuredBulkEntries;
        private List<WriteSecured2BulkEntry> lastWriteSecured2BulkEntries;

        @Override
        public MxCommandReply pingRaw(String message) {
            lastPingMessage = message;
            // The worker echoes the request message in the diagnostic message
            // field; there is no dedicated ping reply payload.
            return MxCommandReply.newBuilder()
                    .setKind(MxCommandKind.MX_COMMAND_KIND_PING)
                    .setProtocolStatus(ok())
                    .setDiagnosticMessage(message)
                    .build();
        }

        @Override
        public int register(String clientName) {
            registerCalled = true;
            return 42;
        }

        @Override
        public MxCommandReply registerRaw(String clientName) {
            registerCalled = true;
            return MxCommandReply.newBuilder()
                    .setKind(MxCommandKind.MX_COMMAND_KIND_REGISTER)
                    .setProtocolStatus(ok())
                    .setRegister(RegisterReply.newBuilder().setServerHandle(42))
                    .build();
        }

        @Override
        public int addItem(int serverHandle, String itemDefinition) {
            addItemCalled = true;
            return 7;
        }

        @Override
        public MxCommandReply addItemRaw(int serverHandle, String itemDefinition) {
            addItemCalled = true;
            return MxCommandReply.newBuilder()
                    .setKind(MxCommandKind.MX_COMMAND_KIND_ADD_ITEM)
                    .setProtocolStatus(ok())
                    .setAddItem(AddItemReply.newBuilder().setItemHandle(7))
                    .build();
        }

        @Override
        public void advise(int serverHandle, int itemHandle) {
            adviseCalled = true;
        }

        @Override
        public MxCommandReply adviseRaw(int serverHandle, int itemHandle) {
            adviseCalled = true;
            return MxCommandReply.newBuilder()
                    .setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE)
                    .setProtocolStatus(ok())
                    .build();
        }

        @Override
        public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
            adviseSupervisoryCalled = true;
            return MxCommandReply.newBuilder()
                    .setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
                    .setProtocolStatus(ok())
                    .build();
        }

        @Override
        public MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId) {
            lastWriteValue = value;
            return MxCommandReply.newBuilder()
                    .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE)
                    .setProtocolStatus(ok())
                    .build();
        }

        @Override
        public MxCommandReply writeSecuredRaw(
                int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
            lastWriteSecuredValue = value;
            lastWriteSecuredCurrentUserId = currentUserId;
            lastWriteSecuredVerifierUserId = verifierUserId;
            return MxCommandReply.newBuilder()
                    .setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED)
                    .setProtocolStatus(ok())
                    .build();
        }

        @Override
        public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
            lastAuthenticateUser = verifyUser;
            lastAuthenticatePassword = verifyUserPassword;
            return 4242;
        }

        @Override
        public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
            List<SubscribeResult> results = new ArrayList<>();
            for (int index = 0; index < items.size(); index++) {
                results.add(SubscribeResult.newBuilder()
                        .setServerHandle(serverHandle)
                        .setTagAddress(items.get(index))
                        .setItemHandle(100 + index)
                        .setWasSuccessful(true)
                        .build());
            }
            return results;
        }

        @Override
        public List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles) {
            List<SubscribeResult> results = new ArrayList<>();
            for (Integer itemHandle : itemHandles) {
                results.add(SubscribeResult.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(itemHandle)
                        .setWasSuccessful(true)
                        .build());
            }
            return results;
        }

        @Override
        public List<BulkReadResult> readBulk(int serverHandle, List<String> items, Duration timeout) {
            lastReadBulkTimeoutMs = timeout.toMillis();
            lastReadBulkItems = new ArrayList<>(items);
            List<BulkReadResult> results = new ArrayList<>();
            for (int index = 0; index < items.size(); index++) {
                results.add(BulkReadResult.newBuilder()
                        .setServerHandle(serverHandle)
                        .setTagAddress(items.get(index))
                        .setItemHandle(200 + index)
                        .setWasSuccessful(true)
                        .setWasCached(true)
                        .build());
            }
            return results;
        }

        @Override
        public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
            lastWriteBulkEntries = new ArrayList<>(entries);
            List<BulkWriteResult> results = new ArrayList<>();
            for (WriteBulkEntry entry : entries) {
                results.add(BulkWriteResult.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(entry.getItemHandle())
                        .setWasSuccessful(true)
                        .build());
            }
            return results;
        }

        @Override
        public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
            lastWrite2BulkEntries = new ArrayList<>(entries);
            List<BulkWriteResult> results = new ArrayList<>();
            for (Write2BulkEntry entry : entries) {
                results.add(BulkWriteResult.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(entry.getItemHandle())
                        .setWasSuccessful(true)
                        .build());
            }
            return results;
        }

        @Override
        public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
            lastWriteSecuredBulkEntries = new ArrayList<>(entries);
            List<BulkWriteResult> results = new ArrayList<>();
            for (WriteSecuredBulkEntry entry : entries) {
                results.add(BulkWriteResult.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(entry.getItemHandle())
                        .setWasSuccessful(true)
                        .build());
            }
            return results;
        }

        @Override
        public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
            lastWriteSecured2BulkEntries = new ArrayList<>(entries);
            List<BulkWriteResult> results = new ArrayList<>();
            for (WriteSecured2BulkEntry entry : entries) {
                results.add(BulkWriteResult.newBuilder()
                        .setServerHandle(serverHandle)
                        .setItemHandle(entry.getItemHandle())
                        .setWasSuccessful(true)
                        .build());
            }
            return results;
        }

        @Override
        public com.zb.mom.ww.mxgateway.client.MxEventStream streamEventsAfter(long afterWorkerSequence) {
            throw new UnsupportedOperationException("stream-events is covered by client tests");
        }
    }

    private static ProtocolStatus ok() {
        return ProtocolStatus.newBuilder()
                .setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
                .build();
    }
}
