using System.Text.Json;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Client.Cli;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;

namespace ZB.MOM.WW.MxGateway.Client.Tests;

/// <summary>Tests for the CLI command interface.</summary>
public sealed class MxGatewayClientCliTests
{
    /// <summary>Verifies that the version command prints compiled protocol versions.</summary>
    [Fact]
    public void Run_Version_PrintsCompiledProtocolVersions()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();

        var exitCode = MxGatewayClientCli.Run(["version"], output, error);

        Assert.Equal(0, exitCode);
        Assert.Contains("gateway-protocol=3", output.ToString());
        Assert.Contains("worker-protocol=1", output.ToString());
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();

        int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error);

        Assert.Equal(0, exitCode);
        Assert.Contains("\"gatewayProtocolVersion\":3", output.ToString());
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.InvokeReplies.Enqueue(new MxCommandReply
        {
            SessionId = "session-fixture",
            Kind = MxCommandKind.Write,
            ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "write",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--session-id",
                "session-fixture",
                "--server-handle",
                "12",
                "--item-handle",
                "34",
                "--type",
                "int32",
                "--value",
                "123",
                "--json",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
        Assert.Equal(MxCommandKind.Write, request.Command.Kind);
        Assert.Equal(123, request.Command.Write.Value.Int32Value);
        Assert.Contains("MX_COMMAND_KIND_WRITE", output.ToString());
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>
    /// <c>advise-supervisory</c> was present in the command
    /// dispatch table but absent from <see cref="MxGatewayClientCli"/>'s
    /// <c>IsKnownGatewayCommand</c> guard, so the guard intercepted it first and
    /// returned exit code 2 "Unknown command" before dispatch could run. This
    /// test asserts the command is recognized (exit ≠ 2, stderr contains no
    /// "Unknown command") and reaches the dispatch handler (exit 0, reply written
    /// to stdout).
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_AdviseSupervisory_IsRecognizedAndReachesDispatch()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.InvokeReplies.Enqueue(new MxCommandReply
        {
            SessionId = "session-fixture",
            Kind = MxCommandKind.AdviseSupervisory,
            ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "advise-supervisory",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--session-id",
                "session-fixture",
                "--server-handle",
                "12",
                "--item-handle",
                "34",
                "--json",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.DoesNotContain("Unknown command", error.ToString());
        Assert.Equal(0, exitCode);
        Assert.Contains("MX_COMMAND_KIND_ADVISE_SUPERVISORY", output.ToString());
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
    [Fact]
    public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.InvokeReplies.Enqueue(new MxCommandReply
        {
            SessionId = "session-fixture",
            Kind = MxCommandKind.WriteSecured,
            ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "write-secured",
                "--endpoint", "http://localhost:5000",
                "--api-key", "test-api-key",
                "--session-id", "session-fixture",
                "--server-handle", "12",
                "--item-handle", "34",
                "--type", "int32",
                "--value", "123",
                "--current-user-id", "5",
                "--verifier-user-id", "6",
                "--json",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
        Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind);
        Assert.Equal(123, request.Command.WriteSecured.Value.Int32Value);
        Assert.Equal(5, request.Command.WriteSecured.CurrentUserId);
        Assert.Equal(6, request.Command.WriteSecured.VerifierUserId);
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>
    /// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
    /// credential from the flag, and that the credential never appears in stdout/stderr.
    /// </summary>
    [Fact]
    public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
    {
        const string password = "cli-secret-credential-987";
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.InvokeReplies.Enqueue(new MxCommandReply
        {
            SessionId = "session-fixture",
            Kind = MxCommandKind.AuthenticateUser,
            ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
            AuthenticateUser = new AuthenticateUserReply { UserId = 4242 },
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "authenticate-user",
                "--endpoint", "http://localhost:5000",
                "--api-key", "test-api-key",
                "--session-id", "session-fixture",
                "--server-handle", "12",
                "--verify-user", "operator",
                "--verify-user-password", password,
                "--json",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
        Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind);
        Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser);
        Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
        Assert.DoesNotContain(password, output.ToString());
        Assert.DoesNotContain(password, error.ToString());
    }

    /// <summary>
    /// CLI-04: a surfaced error for authenticate-user must have the credential
    /// redacted (never echoed to stderr), mirroring the API-key redaction seam.
    /// </summary>
    [Fact]
    public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
    {
        const string password = "leaky-credential-value";
        using var output = new StringWriter();
        using var error = new StringWriter();

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "authenticate-user",
                "--endpoint", "http://localhost:5000",
                "--api-key", "test-api-key",
                "--session-id", "session-fixture",
                "--server-handle", "12",
                "--verify-user", "operator",
                "--password", password,
            ],
            output,
            error,
            _ => throw new InvalidOperationException($"boom {password}"));

        Assert.Equal(1, exitCode);
        Assert.DoesNotContain(password, error.ToString());
        Assert.Contains("[redacted]", error.ToString());
    }

    /// <summary>
    /// CLI-45: <c>--password</c> is the primary credential flag, matching the other
    /// four CLIs. The credential reaches the wire but never stdout/stderr.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_AuthenticateUser_AcceptsCanonicalPasswordFlag()
    {
        const string password = "canonical-flag-credential";
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.InvokeReplies.Enqueue(new MxCommandReply
        {
            SessionId = "session-fixture",
            Kind = MxCommandKind.AuthenticateUser,
            ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
            AuthenticateUser = new AuthenticateUserReply { UserId = 11 },
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "authenticate-user",
                "--endpoint", "http://localhost:5000",
                "--api-key", "test-api-key",
                "--session-id", "session-fixture",
                "--server-handle", "12",
                "--verify-user", "operator",
                "--password", password,
                "--json",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
        Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
        Assert.DoesNotContain(password, output.ToString(), StringComparison.Ordinal);
        Assert.DoesNotContain(password, error.ToString(), StringComparison.Ordinal);
    }

    /// <summary>
    /// CLI-45: the credential is read from the environment variable named by
    /// <c>--password-env</c>, whose default is the canonical
    /// <c>MXGATEWAY_VERIFY_PASSWORD</c> shared by all five CLIs.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Theory]
    [InlineData(null, "MXGATEWAY_VERIFY_PASSWORD")]
    [InlineData("MXGW_TEST_CLI45_ENV", "MXGW_TEST_CLI45_ENV")]
    public async Task RunAsync_AuthenticateUser_ReadsCredentialFromNamedEnvironmentVariable(
        string? passwordEnvArgument,
        string environmentName)
    {
        const string password = "env-sourced-credential";
        using EnvironmentVariableScope scope = new(environmentName, password);
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.InvokeReplies.Enqueue(new MxCommandReply
        {
            SessionId = "session-fixture",
            Kind = MxCommandKind.AuthenticateUser,
            ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
            AuthenticateUser = new AuthenticateUserReply { UserId = 12 },
        });

        List<string> args =
        [
            "authenticate-user",
            "--endpoint", "http://localhost:5000",
            "--api-key", "test-api-key",
            "--session-id", "session-fixture",
            "--server-handle", "12",
            "--verify-user", "operator",
            "--json",
        ];
        if (passwordEnvArgument is not null)
        {
            args.Add("--password-env");
            args.Add(passwordEnvArgument);
        }

        int exitCode = await MxGatewayClientCli.RunAsync([.. args], output, error, _ => fakeClient);

        Assert.Equal(0, exitCode);
        MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
        Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
        Assert.DoesNotContain(password, output.ToString(), StringComparison.Ordinal);
    }

    /// <summary>
    /// CLI-45: the pre-rename names stay usable for one release — the deprecated
    /// <c>--verify-user-password</c> flag and the deprecated
    /// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c> environment variable both still resolve.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_AuthenticateUser_HonoursDeprecatedAliases()
    {
        using var flagOutput = new StringWriter();
        using var flagError = new StringWriter();
        FakeCliClient flagClient = new();
        flagClient.InvokeReplies.Enqueue(new MxCommandReply
        {
            SessionId = "session-fixture",
            Kind = MxCommandKind.AuthenticateUser,
            ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
            AuthenticateUser = new AuthenticateUserReply { UserId = 13 },
        });

        int flagExitCode = await MxGatewayClientCli.RunAsync(
            [
                "authenticate-user",
                "--endpoint", "http://localhost:5000",
                "--api-key", "test-api-key",
                "--session-id", "session-fixture",
                "--server-handle", "12",
                "--verify-user", "operator",
                "--verify-user-password", "legacy-flag-credential",
                "--json",
            ],
            flagOutput,
            flagError,
            _ => flagClient);

        Assert.Equal(0, flagExitCode);
        Assert.Equal(
            "legacy-flag-credential",
            Assert.Single(flagClient.InvokeRequests).Command.AuthenticateUser.VerifyUserPassword);

        using EnvironmentVariableScope canonical = new("MXGATEWAY_VERIFY_PASSWORD", null);
        using EnvironmentVariableScope legacy = new("MXGATEWAY_VERIFY_USER_PASSWORD", "legacy-env-credential");
        using var envOutput = new StringWriter();
        using var envError = new StringWriter();
        FakeCliClient envClient = new();
        envClient.InvokeReplies.Enqueue(new MxCommandReply
        {
            SessionId = "session-fixture",
            Kind = MxCommandKind.AuthenticateUser,
            ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
            AuthenticateUser = new AuthenticateUserReply { UserId = 14 },
        });

        int envExitCode = await MxGatewayClientCli.RunAsync(
            [
                "authenticate-user",
                "--endpoint", "http://localhost:5000",
                "--api-key", "test-api-key",
                "--session-id", "session-fixture",
                "--server-handle", "12",
                "--verify-user", "operator",
                "--json",
            ],
            envOutput,
            envError,
            _ => envClient);

        Assert.Equal(0, envExitCode);
        Assert.Equal(
            "legacy-env-credential",
            Assert.Single(envClient.InvokeRequests).Command.AuthenticateUser.VerifyUserPassword);
    }

    /// <summary>
    /// CLI-45: a missing or empty credential fails fast before the invoke — the CLI
    /// never sends a fabricated empty password to the wire. The error names the flag
    /// and the environment variable, never a value.
    /// </summary>
    /// <param name="explicitEmptyFlag">Whether to pass an explicit empty --password.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task RunAsync_AuthenticateUser_FailsFastOnMissingOrEmptyCredential(bool explicitEmptyFlag)
    {
        using EnvironmentVariableScope canonical = new("MXGATEWAY_VERIFY_PASSWORD", explicitEmptyFlag ? string.Empty : null);
        using EnvironmentVariableScope legacy = new("MXGATEWAY_VERIFY_USER_PASSWORD", null);
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();

        List<string> args =
        [
            "authenticate-user",
            "--endpoint", "http://localhost:5000",
            "--api-key", "test-api-key",
            "--session-id", "session-fixture",
            "--server-handle", "12",
            "--verify-user", "operator",
        ];
        if (explicitEmptyFlag)
        {
            args.Add("--password");
            args.Add(string.Empty);
        }

        int exitCode = await MxGatewayClientCli.RunAsync([.. args], output, error, _ => fakeClient);

        Assert.Equal(1, exitCode);
        Assert.Empty(fakeClient.InvokeRequests);
        Assert.Contains("--password", error.ToString(), StringComparison.Ordinal);
        Assert.Contains("MXGATEWAY_VERIFY_PASSWORD", error.ToString(), StringComparison.Ordinal);
    }

    /// <summary>
    ///     Sets an environment variable for the duration of a test and restores the
    ///     previous value on dispose, so credential-resolution tests do not depend on
    ///     (or leak into) the ambient environment.
    /// </summary>
    private sealed class EnvironmentVariableScope : IDisposable
    {
        private readonly string _name;
        private readonly string? _original;

        public EnvironmentVariableScope(string name, string? value)
        {
            _name = name;
            _original = Environment.GetEnvironmentVariable(name);
            Environment.SetEnvironmentVariable(name, value);
        }

        public void Dispose()
        {
            Environment.SetEnvironmentVariable(_name, _original);
        }
    }

    /// <summary>Verifies that error output redacts sensitive API key values.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_ErrorOutput_RedactsApiKey()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "open-session",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "secret-api-key",
            ],
            output,
            error,
            _ => throw new InvalidOperationException("boom secret-api-key"));

        Assert.Equal(1, exitCode);
        Assert.DoesNotContain("secret-api-key", error.ToString());
        Assert.Contains("[redacted]", error.ToString());
    }

    /// <summary>
    /// When the API key is sourced from the env var
    /// (<c>--api-key-env</c> path, no <c>--api-key</c> flag), the error-redaction
    /// catch block must still resolve and redact the effective key. Regression
    /// guard for the catch block reverting to <c>GetOptional("api-key")</c> only,
    /// which is null on the env-var path and leaves the key unredacted.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable()
    {
        const string envName = "MXGATEWAY_TEST_API_KEY_028";
        const string secret = "env-sourced-secret-key";
        string? previousKey = Environment.GetEnvironmentVariable(envName);
        Environment.SetEnvironmentVariable(envName, secret);

        try
        {
            using var output = new StringWriter();
            using var error = new StringWriter();

            int exitCode = await MxGatewayClientCli.RunAsync(
                [
                    "open-session",
                    "--endpoint",
                    "http://localhost:5000",
                    "--api-key-env",
                    envName,
                ],
                output,
                error,
                _ => throw new InvalidOperationException($"boom {secret}"));

            Assert.Equal(1, exitCode);
            Assert.DoesNotContain(secret, error.ToString());
            Assert.Contains("[redacted]", error.ToString());
        }
        finally
        {
            Environment.SetEnvironmentVariable(envName, previousKey);
        }
    }

    /// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.Events.Add(new MxEvent
        {
            SessionId = "session-fixture",
            Family = MxEventFamily.OnDataChange,
            WorkerSequence = 1,
        });
        fakeClient.Events.Add(new MxEvent
        {
            SessionId = "session-fixture",
            Family = MxEventFamily.OnWriteComplete,
            WorkerSequence = 2,
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "stream-events",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--session-id",
                "session-fixture",
                "--max-events",
                "1",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        Assert.Contains("workerSequence", output.ToString());
        Assert.DoesNotContain("ON_WRITE_COMPLETE", output.ToString());
    }

    /// <summary>
    ///     Verifies stream-events renders the ReplayGap sentinel as the typed cross-CLI row —
    ///     numeric cursors under a replayGap key — instead of the raw sentinel MxEvent (NEXT-02).
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_StreamEvents_RendersReplayGapAsTypedRow()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.Events.Add(new MxEvent
        {
            ReplayGap = new ReplayGap
            {
                RequestedAfterSequence = 7,
                OldestAvailableSequence = 42,
            },
        });
        fakeClient.Events.Add(new MxEvent
        {
            SessionId = "session-fixture",
            Family = MxEventFamily.OnDataChange,
            WorkerSequence = 43,
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "stream-events",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--session-id",
                "session-fixture",
                "--max-events",
                "2",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        string[] rows = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
        Assert.Equal(2, rows.Length);
        using JsonDocument gapRow = JsonDocument.Parse(rows[0]);
        JsonElement gap = gapRow.RootElement.GetProperty("replayGap");
        Assert.Equal(7UL, gap.GetProperty("requestedAfterSequence").GetUInt64());
        Assert.Equal(42UL, gap.GetProperty("oldestAvailableSequence").GetUInt64());
        Assert.Equal(JsonValueKind.Number, gap.GetProperty("requestedAfterSequence").ValueKind);
        Assert.DoesNotContain("MX_EVENT_FAMILY_UNSPECIFIED", rows[0], StringComparison.Ordinal);
        Assert.Contains("workerSequence", rows[1], StringComparison.Ordinal);

        // The aggregate --json shape carries the same typed row inside the events array.
        using var aggregateOutput = new StringWriter();
        int aggregateExit = await MxGatewayClientCli.RunAsync(
            [
                "stream-events",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--session-id",
                "session-fixture",
                "--max-events",
                "2",
                "--json",
            ],
            aggregateOutput,
            error,
            _ => fakeClient);

        Assert.Equal(0, aggregateExit);
        using JsonDocument aggregate = JsonDocument.Parse(aggregateOutput.ToString());
        JsonElement firstRow = aggregate.RootElement.GetProperty("events")[0];
        Assert.Equal(42UL, firstRow.GetProperty("replayGap").GetProperty("oldestAvailableSequence").GetUInt64());
    }


    /// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
        {
            ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "Tank01.Level.HiHi" },
        });
        fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage { SnapshotComplete = true });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "stream-alarms",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--filter-prefix",
                "Tank01",
                "--max-events",
                "1",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        StreamAlarmsRequest request = Assert.Single(fakeClient.StreamAlarmsRequests);
        Assert.Equal("Tank01", request.AlarmFilterPrefix);
        string text = output.ToString();
        Assert.Contains("active-alarm", text);
        Assert.Contains("Tank01.Level.HiHi", text);
        Assert.DoesNotContain("snapshot-complete", text);
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
        {
            CorrelationId = "ack-fixture",
            ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
            Hresult = 0,
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "acknowledge-alarm",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--reference",
                "Tank01.Level.HiHi",
                "--comment",
                "ack from cli",
                "--operator",
                "operator1",
                "--json",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        AcknowledgeAlarmRequest request = Assert.Single(fakeClient.AcknowledgeAlarmRequests);
        Assert.Equal("Tank01.Level.HiHi", request.AlarmFullReference);
        Assert.Equal("ack from cli", request.Comment);
        Assert.Equal("operator1", request.OperatorUser);
        Assert.Contains("ack-fixture", output.ToString());
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new()
        {
            InvokeFailure = new InvalidOperationException("register failed"),
        };

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "smoke",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--item",
                "Area001.Pump001.Speed",
                "--json",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(1, exitCode);
        CloseSessionRequest closeRequest = Assert.Single(fakeClient.CloseSessionRequests);
        Assert.Equal("session-fixture", closeRequest.SessionId);
    }

    /// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new()
        {
            GalaxyTestConnectionReply = new TestConnectionReply { Ok = true },
        };

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "galaxy-test-connection",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--json",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        Assert.Single(fakeClient.GalaxyTestConnectionRequests);
        Assert.Contains("\"ok\": true", output.ToString());
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
        {
            NextPageToken = "7:1",
            TotalObjectCount = 2,
            Objects =
            {
                new GalaxyObject
                {
                    GobjectId = 7,
                    TagName = "DelmiaReceiver_001",
                    ContainedName = "DelmiaReceiver",
                    ParentGobjectId = 1,
                    Attributes =
                    {
                        new GalaxyAttribute
                        {
                            AttributeName = "DownloadPath",
                            FullTagReference = "DelmiaReceiver_001.DownloadPath",
                        },
                    },
                },
            },
        });
        fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
        {
            TotalObjectCount = 2,
            Objects =
            {
                new GalaxyObject
                {
                    GobjectId = 8,
                    TagName = "DelmiaReceiver_002",
                    ContainedName = "DelmiaReceiver",
                    ParentGobjectId = 1,
                },
            },
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "galaxy-discover",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        Assert.Equal(2, fakeClient.GalaxyDiscoverHierarchyRequests.Count);
        Assert.Equal(5000, fakeClient.GalaxyDiscoverHierarchyRequests[0].PageSize);
        Assert.Equal("", fakeClient.GalaxyDiscoverHierarchyRequests[0].PageToken);
        Assert.Equal("7:1", fakeClient.GalaxyDiscoverHierarchyRequests[1].PageToken);
        string text = output.ToString();
        Assert.Contains("objects=2", text);
        Assert.Contains("DelmiaReceiver_001", text);
        Assert.Contains("DelmiaReceiver_002", text);
        Assert.Contains("attributes=1", text);
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>
    /// Verifies galaxy-browse walks root objects and eagerly expands one further
    /// level when --depth 1 is passed, printing an indented tree.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_GalaxyBrowse_TextTreeExpandsToDepth()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        // Root level (parent 0): one area with a child hint.
        fakeClient.GalaxyBrowseChildrenReplies[0] = new Queue<BrowseChildrenReply>(
        [
            new BrowseChildrenReply
            {
                Children = { new GalaxyObject { GobjectId = 10, TagName = "Area_001", BrowseName = "Area" } },
                ChildHasChildren = { true },
            },
        ]);
        // Children of gobject 10.
        fakeClient.GalaxyBrowseChildrenReplies[10] = new Queue<BrowseChildrenReply>(
        [
            new BrowseChildrenReply
            {
                Children = { new GalaxyObject { GobjectId = 20, TagName = "Tank_001", BrowseName = "Tank" } },
            },
        ]);

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "galaxy-browse",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--depth",
                "1",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        string text = output.ToString();
        Assert.Contains("Area_001", text);
        Assert.Contains("Tank_001", text);
        // Children are indented beneath their parent (two-space indent per level).
        Assert.Matches(@"\n  \d+\tTank_001", text);
        // Root fetched with the parent oneof unset; child fetch used parent 10.
        Assert.Contains(
            fakeClient.GalaxyBrowseChildrenRequests,
            request => request.ParentCase == BrowseChildrenRequest.ParentOneofCase.ParentGobjectId
                && request.ParentGobjectId == 10);
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>
    /// Verifies galaxy-browse --json emits a nested JSON document and forwards
    /// the filter flags onto the BrowseChildren request.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_GalaxyBrowse_JsonForwardsFilters()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.GalaxyBrowseChildrenReplies[0] = new Queue<BrowseChildrenReply>(
        [
            new BrowseChildrenReply
            {
                Children = { new GalaxyObject { GobjectId = 10, TagName = "Area_001", BrowseName = "Area" } },
            },
        ]);

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "galaxy-browse",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--tag-name-glob",
                "Area*",
                "--alarm-bearing-only",
                "--json",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(output.ToString());
        Assert.Equal("galaxy-browse", document.RootElement.GetProperty("command").GetString());
        Assert.True(document.RootElement.GetProperty("nodes").GetArrayLength() >= 1);
        BrowseChildrenRequest request = Assert.Single(fakeClient.GalaxyBrowseChildrenRequests);
        Assert.Equal("Area*", request.TagNameGlob);
        Assert.True(request.AlarmBearingOnly);
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>
    /// Verifies galaxy-browse --parent fetches exactly one level of children for
    /// the supplied gobject id via a parent-scoped BrowseChildren request.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_GalaxyBrowse_ParentFetchesSingleLevel()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.GalaxyBrowseChildrenReplies[10] = new Queue<BrowseChildrenReply>(
        [
            new BrowseChildrenReply
            {
                Children = { new GalaxyObject { GobjectId = 20, TagName = "Tank_001", BrowseName = "Tank" } },
            },
        ]);

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "galaxy-browse",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--parent",
                "10",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        Assert.Contains("Tank_001", output.ToString());
        BrowseChildrenRequest request = Assert.Single(fakeClient.GalaxyBrowseChildrenRequests);
        Assert.Equal(BrowseChildrenRequest.ParentOneofCase.ParentGobjectId, request.ParentCase);
        Assert.Equal(10, request.ParentGobjectId);
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        DateTime deploy = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
        fakeClient.GalaxyDeployEvents.Add(new DeployEvent
        {
            Sequence = 1,
            ObservedAt = Timestamp.FromDateTime(deploy),
            TimeOfLastDeploy = Timestamp.FromDateTime(deploy),
            TimeOfLastDeployPresent = true,
            ObjectCount = 5,
            AttributeCount = 17,
        });
        fakeClient.GalaxyDeployEvents.Add(new DeployEvent
        {
            Sequence = 2,
            ObservedAt = Timestamp.FromDateTime(deploy.AddSeconds(30)),
            TimeOfLastDeploy = Timestamp.FromDateTime(deploy.AddSeconds(30)),
            TimeOfLastDeployPresent = true,
            ObjectCount = 6,
            AttributeCount = 18,
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "galaxy-watch",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--last-seen-deploy-time",
                "2026-04-28T14:00:00Z",
                "--max-events",
                "2",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        WatchDeployEventsRequest request = Assert.Single(fakeClient.GalaxyWatchDeployEventsRequests);
        Assert.NotNull(request.LastSeenDeployTime);
        string text = output.ToString();
        Assert.Contains("sequence=1", text);
        Assert.Contains("sequence=2", text);
        Assert.Contains("objects=5", text);
        Assert.Contains("attributes=18", text);
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        fakeClient.GalaxyDeployEvents.Add(new DeployEvent
        {
            Sequence = 42,
            ObjectCount = 99,
            AttributeCount = 1024,
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "galaxy-watch",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--max-events",
                "1",
                "--json",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(0, exitCode);
        string text = output.ToString();
        Assert.Contains("\"sequence\": \"42\"", text);
        Assert.Contains("\"objectCount\": 99", text);
    }

    /// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        using var input = new StringReader("version --json\n");

        int exitCode = await MxGatewayClientCli.RunAsync(
            ["batch"],
            output,
            error,
            clientFactory: null,
            standardInput: input);

        Assert.Equal(0, exitCode);
        string text = output.ToString();
        Assert.Contains("\"gatewayProtocolVersion\":3", text);
        Assert.Contains("__MXGW_BATCH_EOR__", text);
        // The EOR marker must come after the JSON output.
        int jsonIndex = text.IndexOf("\"gatewayProtocolVersion\"", StringComparison.Ordinal);
        int eorIndex = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
        Assert.True(jsonIndex >= 0 && eorIndex > jsonIndex);
        Assert.Equal(string.Empty, error.ToString());
    }

    /// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        // Unknown command should produce an error on the captured error stream,
        // which batch mode re-emits to stdout inside the same delimited block.
        using var input = new StringReader("nope-not-a-command\nversion\n");

        int exitCode = await MxGatewayClientCli.RunAsync(
            ["batch"],
            output,
            error,
            clientFactory: null,
            standardInput: input);

        Assert.Equal(0, exitCode);
        string text = output.ToString();
        // Two records → two EOR markers.
        int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
        int secondEor = text.IndexOf(
            "__MXGW_BATCH_EOR__",
            firstEor + 1,
            StringComparison.Ordinal);
        Assert.True(firstEor > 0);
        Assert.True(secondEor > firstEor);
        // The unknown-command error message must be on stdout (not on stderr).
        Assert.Contains("nope-not-a-command", text);
        Assert.DoesNotContain("nope-not-a-command", error.ToString());
        // The follow-up `version` line should still succeed.
        Assert.Contains("gateway-protocol=", text);
    }

    /// <summary>
    /// The README CLI examples for the alarm subcommands at
    /// `clients/dotnet/README.md` must drive cleanly through the production
    /// CLI argument parser. The previous text used non-existent flags
    /// (`--session-id`, `--max-messages`, `--alarm-reference`) that would
    /// fail with "Unknown command" / "Missing required option --reference".
    /// Each documented example is extracted from the README, parsed via the
    /// production <see cref="MxGatewayClientCli.RunAsync"/>, and asserted
    /// against exit code 0.
    /// </summary>
    /// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Theory]
    [InlineData("stream-alarms")]
    [InlineData("acknowledge-alarm")]
    public async Task RunAsync_ReadmeExamples_ForAlarmCommands_ParseSuccessfully(string command)
    {
        string readme = LocateClientReadme();
        string[] commandLine = ExtractReadmeCommandLine(readme, command);
        // The documented examples do not include --api-key (the README assumes
        // the env var path documented elsewhere). Inject an API key via the
        // standard env var so CreateOptions succeeds and the parser fully
        // exercises the documented flag shape.
        string? previousKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
        Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", "test-api-key");

        try
        {
            using var output = new StringWriter();
            using var error = new StringWriter();
            FakeCliClient fakeClient = new();
            fakeClient.AlarmFeedMessages.Add(new AlarmFeedMessage
            {
                ActiveAlarm = new ActiveAlarmSnapshot { AlarmFullReference = "fixture" },
            });
            fakeClient.AcknowledgeAlarmReplies.Enqueue(new AcknowledgeAlarmReply
            {
                CorrelationId = "ack-fixture",
                ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
            });

            int exitCode = await MxGatewayClientCli.RunAsync(
                commandLine,
                output,
                error,
                _ => fakeClient);

            Assert.True(
                exitCode == 0,
                $"README example for '{command}' exited {exitCode}; stderr=<<{error}>>");
            Assert.DoesNotContain("Unknown command", error.ToString());
            Assert.DoesNotContain("Missing required option", error.ToString());
        }
        finally
        {
            Environment.SetEnvironmentVariable("MXGATEWAY_API_KEY", previousKey);
        }
    }

    /// <summary>
    /// `BenchReadBulkAsync` previously fell back to
    /// <c>reply.ReturnValue.Int32Value</c> when the register reply had no
    /// typed <c>Register</c> payload, silently driving the rest of the bench
    /// against a zero server handle. The fix must fail loudly with a
    /// descriptive <see cref="MxGatewayException"/>.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();
        // Successful protocol + MX status but no typed `Register` payload.
        fakeClient.InvokeReplies.Enqueue(new MxCommandReply
        {
            SessionId = "session-fixture",
            Kind = MxCommandKind.Register,
            ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
        });

        int exitCode = await MxGatewayClientCli.RunAsync(
            [
                "bench-read-bulk",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--duration-seconds",
                "1",
                "--warmup-seconds",
                "0",
                "--bulk-size",
                "1",
            ],
            output,
            error,
            _ => fakeClient);

        Assert.Equal(1, exitCode);
        // Descriptive message that names the missing typed payload.
        string err = error.ToString();
        Assert.Contains("Register", err);
        // The bench must not produce any aggregate stats JSON.
        Assert.DoesNotContain("bench-read-bulk", output.ToString());
    }

    /// <summary>
    /// The steady-state loop in `BenchReadBulkAsync` had a
    /// bare `catch { failedCalls++; continue; }` that swallowed
    /// <see cref="OperationCanceledException"/>, so token-driven cancellation
    /// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
    /// the bench must exit promptly when the supplied token cancels.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
    {
        using var output = new StringWriter();
        using var error = new StringWriter();

        int invokeCount = 0;
        FakeCliClient fakeClient = new()
        {
            InvokeHandler = (request, ct) =>
            {
                int n = Interlocked.Increment(ref invokeCount);

                // Reply 1 = Register (success with typed payload).
                if (request.Command.Kind == MxCommandKind.Register)
                {
                    return Task.FromResult(new MxCommandReply
                    {
                        SessionId = "session-fixture",
                        Kind = MxCommandKind.Register,
                        ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
                        Register = new RegisterReply { ServerHandle = 1 },
                    });
                }

                // Reply 2 = SubscribeBulk (success).
                if (request.Command.Kind == MxCommandKind.SubscribeBulk)
                {
                    var subscribeReply = new MxCommandReply
                    {
                        SessionId = "session-fixture",
                        Kind = MxCommandKind.SubscribeBulk,
                        ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
                        SubscribeBulk = new BulkSubscribeReply(),
                    };
                    return Task.FromResult(subscribeReply);
                }

                // ReadBulk reply 1 = success (so the steady-state loop enters
                // and starts iterating). Reply 2+ = simulated cancellation.
                if (request.Command.Kind == MxCommandKind.ReadBulk && n <= 3)
                {
                    return Task.FromResult(new MxCommandReply
                    {
                        SessionId = "session-fixture",
                        Kind = MxCommandKind.ReadBulk,
                        ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
                        ReadBulk = new BulkReadReply(),
                    });
                }

                // From here on every ReadBulk throws OCE — the steady-state
                // loop must exit promptly rather than spinning until
                // --duration-seconds elapses.
                throw new OperationCanceledException();
            },
        };

        var sw = System.Diagnostics.Stopwatch.StartNew();
        await Assert.ThrowsAsync<OperationCanceledException>(async () =>
            await MxGatewayClientCli.RunAsync(
                [
                    "bench-read-bulk",
                    "--endpoint",
                    "http://localhost:5000",
                    "--api-key",
                    "test-api-key",
                    "--duration-seconds",
                    "30",
                    "--warmup-seconds",
                    "0",
                    "--bulk-size",
                    "1",
                ],
                output,
                error,
                _ => fakeClient));
        sw.Stop();

        // Without the fix the loop swallows OCE and continues until the 30 s
        // steady-state deadline expires. With the fix it exits as soon as OCE
        // surfaces. Generous 10 s ceiling to keep the test stable under load.
        Assert.True(
            sw.Elapsed < TimeSpan.FromSeconds(10),
            $"Bench did not exit promptly on cancellation; took {sw.Elapsed}.");
    }

    /// <summary>
    /// Both `ReadBulkAsync` and `BenchReadBulkAsync` cast
    /// the user-supplied <c>--timeout-ms</c> to <see cref="uint"/> without
    /// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps
    /// to ~49.7 days. The fix must reject negatives with a clear error.
    /// </summary>
    /// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Theory]
    [InlineData("read-bulk")]
    [InlineData("bench-read-bulk")]
    public async Task RunAsync_TimeoutMs_NegativeValue_RejectsWithClearError(string command)
    {
        using var output = new StringWriter();
        using var error = new StringWriter();
        FakeCliClient fakeClient = new();

        string[] args = command is "read-bulk"
            ? [
                "read-bulk",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--session-id",
                "session-fixture",
                "--server-handle",
                "1",
                "--items",
                "Area001.Pump001.Speed",
                "--timeout-ms",
                "-1",
            ]
            : [
                "bench-read-bulk",
                "--endpoint",
                "http://localhost:5000",
                "--api-key",
                "test-api-key",
                "--duration-seconds",
                "1",
                "--warmup-seconds",
                "0",
                "--bulk-size",
                "1",
                "--timeout-ms",
                "-1",
            ];

        int exitCode = await MxGatewayClientCli.RunAsync(
            args,
            output,
            error,
            _ => fakeClient);

        Assert.NotEqual(0, exitCode);
        string err = error.ToString();
        Assert.Contains("timeout-ms", err);
        Assert.Contains("non-negative", err);
    }

    /// <summary>
    /// Locates the .NET client README by walking up from the test assembly's
    /// base directory until <c>clients/dotnet/README.md</c> is found. Keeps
    /// the regression test independent of the current working directory.
    /// </summary>
    private static string LocateClientReadme()
    {
        string? directory = AppContext.BaseDirectory;
        while (!string.IsNullOrEmpty(directory))
        {
            string candidate = Path.Combine(directory, "clients", "dotnet", "README.md");
            if (File.Exists(candidate))
            {
                return candidate;
            }

            directory = Path.GetDirectoryName(directory);
        }

        throw new FileNotFoundException("clients/dotnet/README.md not found above test assembly base directory.");
    }

    /// <summary>
    /// Extracts the documented CLI invocation for the requested subcommand
    /// from the README, returning only the arguments after the
    /// <c>mxgw-dotnet</c>-equivalent prefix so they can be passed straight
    /// to <see cref="MxGatewayClientCli.RunAsync"/>.
    /// </summary>
    private static string[] ExtractReadmeCommandLine(string readmePath, string command)
    {
        string[] lines = File.ReadAllLines(readmePath);
        // Look for the documented `dotnet run ... -- <command> ...` line.
        foreach (string line in lines)
        {
            int dashes = line.IndexOf("-- " + command, StringComparison.Ordinal);
            if (dashes < 0)
            {
                continue;
            }

            string after = line[(dashes + 3)..].Trim();
            // Tokenize by whitespace, respecting "..." quoted segments.
            return TokenizeCommandLine(after);
        }

        throw new InvalidOperationException(
            $"README at '{readmePath}' has no documented example for subcommand '{command}'.");
    }

    /// <summary>
    /// Splits a single command-line string into argv tokens, honouring
    /// double-quoted segments so paths with embedded spaces survive intact.
    /// </summary>
    private static string[] TokenizeCommandLine(string input)
    {
        var tokens = new List<string>();
        var current = new System.Text.StringBuilder();
        bool inQuotes = false;

        foreach (char ch in input)
        {
            if (ch == '"')
            {
                inQuotes = !inQuotes;
                continue;
            }

            if (!inQuotes && char.IsWhiteSpace(ch))
            {
                if (current.Length > 0)
                {
                    tokens.Add(current.ToString());
                    current.Clear();
                }
                continue;
            }

            current.Append(ch);
        }

        if (current.Length > 0)
        {
            tokens.Add(current.ToString());
        }

        return tokens.ToArray();
    }

    /// <summary>Fake CLI client for testing.</summary>
    private sealed class FakeCliClient : IMxGatewayCliClient
    {
        /// <summary>Queue of invoke replies to return.</summary>
        public Queue<MxCommandReply> InvokeReplies { get; } = new();

        /// <summary>List of received invoke requests.</summary>
        public List<MxCommandRequest> InvokeRequests { get; } = [];

        /// <summary>List of received close session requests.</summary>
        public List<CloseSessionRequest> CloseSessionRequests { get; } = [];

        /// <summary>List of events to yield when streaming.</summary>
        public List<MxEvent> Events { get; } = [];

        /// <summary>Exception to throw on invoke, if any.</summary>
        public Exception? InvokeFailure { get; init; }

        /// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
        public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }

        /// <summary>Disposes the fake client. No-op; there are no unmanaged resources to release.</summary>
        /// <returns>A task that represents the asynchronous operation.</returns>
        public ValueTask DisposeAsync()
        {
            return ValueTask.CompletedTask;
        }

        /// <inheritdoc />
        public Task<OpenSessionReply> OpenSessionAsync(
            OpenSessionRequest request,
            CancellationToken cancellationToken)
        {
            return Task.FromResult(new OpenSessionReply
            {
                SessionId = "session-fixture",
                ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
                GatewayProtocolVersion = 1,
                WorkerProtocolVersion = 1,
            });
        }

        /// <inheritdoc />
        public Task<CloseSessionReply> CloseSessionAsync(
            CloseSessionRequest request,
            CancellationToken cancellationToken)
        {
            CloseSessionRequests.Add(request);
            return Task.FromResult(new CloseSessionReply
            {
                SessionId = request.SessionId,
                ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
                FinalState = SessionState.Closed,
            });
        }

        /// <inheritdoc />
        public Task<MxCommandReply> InvokeAsync(
            MxCommandRequest request,
            CancellationToken cancellationToken)
        {
            InvokeRequests.Add(request);
            if (InvokeFailure is not null)
            {
                throw InvokeFailure;
            }

            if (InvokeHandler is not null)
            {
                return InvokeHandler(request, cancellationToken);
            }

            return Task.FromResult(InvokeReplies.Dequeue());
        }

        /// <inheritdoc />
        public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
            StreamEventsRequest request,
            [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
        {
            foreach (MxEvent gatewayEvent in Events)
            {
                cancellationToken.ThrowIfCancellationRequested();
                await Task.Yield();
                yield return gatewayEvent;
            }
        }

        /// <summary>Queue of acknowledge-alarm replies to return.</summary>
        public Queue<AcknowledgeAlarmReply> AcknowledgeAlarmReplies { get; } = new();

        /// <summary>List of received acknowledge-alarm requests.</summary>
        public List<AcknowledgeAlarmRequest> AcknowledgeAlarmRequests { get; } = [];

        /// <summary>List of received stream-alarms requests.</summary>
        public List<StreamAlarmsRequest> StreamAlarmsRequests { get; } = [];

        /// <summary>List of alarm feed messages to yield when streaming alarms.</summary>
        public List<AlarmFeedMessage> AlarmFeedMessages { get; } = [];

        /// <inheritdoc />
        public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
            AcknowledgeAlarmRequest request,
            CancellationToken cancellationToken)
        {
            AcknowledgeAlarmRequests.Add(request);
            return Task.FromResult(AcknowledgeAlarmReplies.Dequeue());
        }

        /// <inheritdoc />
        public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
            StreamAlarmsRequest request,
            [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
        {
            StreamAlarmsRequests.Add(request);
            foreach (AlarmFeedMessage feedMessage in AlarmFeedMessages)
            {
                cancellationToken.ThrowIfCancellationRequested();
                await Task.Yield();
                yield return feedMessage;
            }
        }

        /// <summary>Galaxy test connection reply to return.</summary>
        public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };

        /// <summary>Galaxy get last deploy time reply to return.</summary>
        public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false };

        /// <summary>Galaxy discover hierarchy reply to return.</summary>
        public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();

        /// <summary>Queue of galaxy discover hierarchy replies to return.</summary>
        public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();

        /// <summary>List of received galaxy test connection requests.</summary>
        public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];

        /// <summary>List of received galaxy get last deploy time requests.</summary>
        public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = [];

        /// <summary>List of received galaxy discover hierarchy requests.</summary>
        public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = [];

        /// <inheritdoc />
        public Task<TestConnectionReply> GalaxyTestConnectionAsync(
            TestConnectionRequest request,
            CancellationToken cancellationToken)
        {
            GalaxyTestConnectionRequests.Add(request);
            return Task.FromResult(GalaxyTestConnectionReply);
        }

        /// <inheritdoc />
        public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
            GetLastDeployTimeRequest request,
            CancellationToken cancellationToken)
        {
            GalaxyGetLastDeployTimeRequests.Add(request);
            return Task.FromResult(GalaxyGetLastDeployTimeReply);
        }

        /// <inheritdoc />
        public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
            DiscoverHierarchyRequest request,
            CancellationToken cancellationToken)
        {
            GalaxyDiscoverHierarchyRequests.Add(request);
            return Task.FromResult(
                GalaxyDiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
                    ? reply
                    : GalaxyDiscoverHierarchyReply);
        }

        /// <summary>List of received galaxy watch deploy events requests.</summary>
        public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = [];

        /// <summary>List of deploy events to yield when watching.</summary>
        public List<DeployEvent> GalaxyDeployEvents { get; } = [];

        /// <inheritdoc />
        public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
            WatchDeployEventsRequest request,
            [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
        {
            GalaxyWatchDeployEventsRequests.Add(request);
            foreach (DeployEvent deployEvent in GalaxyDeployEvents)
            {
                cancellationToken.ThrowIfCancellationRequested();
                await Task.Yield();
                yield return deployEvent;
            }
        }

        /// <summary>List of received galaxy browse-children requests, in call order.</summary>
        public List<BrowseChildrenRequest> GalaxyBrowseChildrenRequests { get; } = [];

        /// <summary>
        /// Per-parent browse-children replies keyed by <c>parent_gobject_id</c>
        /// (0 = root). Each parent's queue is dequeued in page order; an absent
        /// or exhausted queue yields an empty reply.
        /// </summary>
        public Dictionary<int, Queue<BrowseChildrenReply>> GalaxyBrowseChildrenReplies { get; } = [];

        /// <inheritdoc />
        public Task<BrowseChildrenReply> GalaxyBrowseChildrenAsync(
            BrowseChildrenRequest request,
            CancellationToken cancellationToken)
        {
            GalaxyBrowseChildrenRequests.Add(request);
            int parentId = request.ParentCase == BrowseChildrenRequest.ParentOneofCase.ParentGobjectId
                ? request.ParentGobjectId
                : 0;
            if (GalaxyBrowseChildrenReplies.TryGetValue(parentId, out Queue<BrowseChildrenReply>? queue)
                && queue.TryDequeue(out BrowseChildrenReply? reply))
            {
                return Task.FromResult(reply);
            }

            return Task.FromResult(new BrowseChildrenReply());
        }
    }
}
