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.assertTrue;

import com.zb.mom.ww.mxgateway.client.MxGatewayAlarmFeedSubscription;
import com.zb.mom.ww.mxgateway.client.MxGatewayClientOptions;
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.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.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.1.0"));
        assertTrue(run.output().contains("gatewayProtocolVersion=3"));
        assertTrue(run.output().contains("workerProtocolVersion=1"));
    }

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

        assertEquals(0, run.exitCode());
        assertTrue(run.output().contains("\"clientVersion\":\"0.1.0\""));
        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"));
    }

    // ---- 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
        // (exit behaviour depends on gateway reachability, not tested here;
        // we only assert the warning path is triggered by checking the error
        // writer before any gRPC connection is attempted).
        //
        // GalaxyBrowseCommand connects to a real GalaxyRepositoryClient, so the
        // call() body will throw after printing the warning when no gateway is
        // reachable. We only assert the warning appears on stderr.
        StringWriter output = new StringWriter();
        StringWriter errors = new StringWriter();
        // Non-zero exit is expected (no live gateway), but the warning must
        // appear on stderr regardless of what happens next.
        MxGatewayCli.execute(
                new FakeClientFactory(),
                new PrintWriter(output, true),
                new PrintWriter(errors, true),
                "galaxy-browse", "--parent", "0", "--depth", "1");

        assertTrue(
                errors.toString().contains("--parent 0"),
                "expected '--parent 0' warning on stderr; got: " + 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\""));
    }

    @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 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 with 2000
        // messages synchronously, which exceeds the bounded 1024-element
        // queue. The fix detects the failed offer, cancels the subscription,
        // queues an overflow exception, and the drain loop surfaces it.
        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());
    }

    @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());
    }

    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());
        }
    }

    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) {
            // Synchronously push 2000 messages to overflow the CLI's bounded
            // 1024-element queue. The CLI must surface the overflow rather
            // than silently dropping the trailing ~976 messages.
            for (int i = 0; i < 2000; i++) {
                observer.onNext(AlarmFeedMessage.newBuilder()
                        .setActiveAlarm(ActiveAlarmSnapshot.newBuilder()
                                .setAlarmFullReference("Flood." + i)
                                .setCurrentState(AlarmConditionState.ALARM_CONDITION_STATE_ACTIVE)
                                .setSeverity(700))
                        .build());
            }
            observer.onCompleted();
            return new MxGatewayAlarmFeedSubscription();
        }

        @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 MxValue lastWriteValue;
        private String lastPingMessage;

        @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 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 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) {
            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) {
            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) {
            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) {
            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) {
            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();
    }
}
