//! `mxgw` — the Rust test CLI for the MXAccess Gateway.
//!
//! The binary wraps [`zb_mom_ww_mxgateway_client`] in a `clap`-driven command surface
//! used by the cross-language smoke matrix and by developers exercising the
//! gateway by hand. Every subcommand mirrors a single gateway/Galaxy RPC,
//! prints either a terse line or a JSON document with `--json`, and exits
//! non-zero on any failure.

#![warn(missing_docs)]

use std::env;
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::{Duration, Instant};

use clap::{Args, Parser, Subcommand, ValueEnum};
use futures_util::StreamExt;
use serde_json::json;
use serde_json::Value;
use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode};
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
    alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
    MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxValue as ProtoMxValue,
    OpenSessionRequest, PingCommand, StreamAlarmsRequest, StreamEventsRequest, Write2BulkEntry,
    WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
};
use zb_mom_ww_mxgateway_client::{
    next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient,
    MxValue, MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
};

const MAX_AGGREGATE_EVENTS: usize = 10_000;

#[derive(Debug, Parser)]
#[command(name = "mxgw")]
#[command(about = "MXAccess Gateway Rust test CLI")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    Version {
        #[arg(long)]
        json: bool,
    },
    Ping {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long, default_value = "ping")]
        message: String,
        #[arg(long)]
        json: bool,
    },
    OpenSession {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long, default_value = "mxgw-rust-cli")]
        client_name: String,
        #[arg(long)]
        json: bool,
    },
    CloseSession {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        json: bool,
    },
    Register {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long, default_value = "mxgw-rust-cli")]
        client_name: String,
        #[arg(long)]
        json: bool,
    },
    AddItem {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long)]
        item: String,
        #[arg(long)]
        json: bool,
    },
    Advise {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long)]
        item_handle: i32,
        #[arg(long)]
        json: bool,
    },
    AdviseSupervisory {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long)]
        item_handle: i32,
        #[arg(long)]
        json: bool,
    },
    /// Release a `ServerHandle` (and the items advised under it) via
    /// MXAccess `Unregister`.
    Unregister {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long)]
        json: bool,
    },
    /// Resolve an MXAccess user id from a credential via `AuthenticateUser`.
    /// The password is read from `--password` or, if omitted, from the
    /// environment variable named by `--password-env`; it is never echoed to
    /// stdout/stderr.
    AuthenticateUser {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long)]
        verify_user: String,
        /// Verifier password. Prefer `--password-env` so the secret never
        /// appears in the process command line.
        #[arg(long)]
        password: Option<String>,
        /// Name of the environment variable holding the verifier password.
        /// Used only when `--password` is not supplied.
        #[arg(long, default_value = "MXGATEWAY_VERIFY_PASSWORD")]
        password_env: String,
        #[arg(long)]
        json: bool,
    },
    /// Single credential-verified write via MXAccess `WriteSecured`.
    ///
    /// Parity note: this fails natively unless the session has first run
    /// `authenticate-user` + `advise-supervisory` and the item carries a
    /// value-bearing body — the native failure is surfaced, not hidden.
    WriteSecured {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long)]
        item_handle: i32,
        #[arg(long, value_enum)]
        value_type: CliValueType,
        #[arg(long)]
        value: String,
        #[arg(long, default_value_t = 0)]
        current_user_id: i32,
        #[arg(long, default_value_t = 0)]
        verifier_user_id: i32,
        #[arg(long)]
        json: bool,
    },
    SubscribeBulk {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long, value_delimiter = ',')]
        items: Vec<String>,
        #[arg(long)]
        json: bool,
    },
    UnsubscribeBulk {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long, value_delimiter = ',')]
        item_handles: Vec<i32>,
        #[arg(long)]
        json: bool,
    },
    /// Snapshot the current value for each requested tag. Cached
    /// OnDataChange values are returned for tags that are already advised
    /// without touching the existing subscription; otherwise the worker
    /// takes a one-shot AddItem + Advise + UnAdvise + RemoveItem lifecycle.
    ReadBulk {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long, value_delimiter = ',')]
        items: Vec<String>,
        /// Per-tag snapshot timeout in milliseconds. `0` uses the worker default (1000 ms).
        #[arg(long, default_value_t = 0)]
        timeout_ms: u32,
        #[arg(long)]
        json: bool,
    },
    /// Bulk Write — one MXAccess Write per (item_handle, value) pair.
    WriteBulk {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long, value_delimiter = ',')]
        item_handles: Vec<i32>,
        #[arg(long, value_enum)]
        value_type: CliValueType,
        #[arg(long, value_delimiter = ',')]
        values: Vec<String>,
        #[arg(long, default_value_t = 0)]
        user_id: i32,
        #[arg(long)]
        json: bool,
    },
    /// Bulk Write2 — timestamped variant; the timestamp applies to all entries.
    Write2Bulk {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long, value_delimiter = ',')]
        item_handles: Vec<i32>,
        #[arg(long, value_enum)]
        value_type: CliValueType,
        #[arg(long, value_delimiter = ',')]
        values: Vec<String>,
        #[arg(long)]
        timestamp: String,
        #[arg(long, default_value_t = 0)]
        user_id: i32,
        #[arg(long)]
        json: bool,
    },
    /// Bulk WriteSecured.
    WriteSecuredBulk {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long, value_delimiter = ',')]
        item_handles: Vec<i32>,
        #[arg(long, value_enum)]
        value_type: CliValueType,
        #[arg(long, value_delimiter = ',')]
        values: Vec<String>,
        #[arg(long, default_value_t = 0)]
        current_user_id: i32,
        #[arg(long, default_value_t = 0)]
        verifier_user_id: i32,
        #[arg(long)]
        json: bool,
    },
    /// Bulk WriteSecured2 — timestamped + verified.
    WriteSecured2Bulk {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long, value_delimiter = ',')]
        item_handles: Vec<i32>,
        #[arg(long, value_enum)]
        value_type: CliValueType,
        #[arg(long, value_delimiter = ',')]
        values: Vec<String>,
        #[arg(long)]
        timestamp: String,
        #[arg(long, default_value_t = 0)]
        current_user_id: i32,
        #[arg(long, default_value_t = 0)]
        verifier_user_id: i32,
        #[arg(long)]
        json: bool,
    },
    /// Cross-language stress benchmark for ReadBulk: opens its own session,
    /// subscribes to `--bulk-size` tags so the worker's per-session value cache
    /// populates from real OnDataChange events, then hammers ReadBulk in a
    /// tight loop for `--duration-seconds` with per-call latency capture. Emits
    /// a single JSON object on stdout matching the schema the
    /// `scripts/bench-read-bulk.ps1` driver collates across all five clients.
    BenchReadBulk {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long, default_value = "mxgw-rust-bench")]
        client_name: String,
        #[arg(long, default_value_t = 30)]
        duration_seconds: u64,
        #[arg(long, default_value_t = 3)]
        warmup_seconds: u64,
        #[arg(long, default_value_t = 6)]
        bulk_size: usize,
        #[arg(long, default_value_t = 1)]
        tag_start: i32,
        #[arg(long, default_value = "TestMachine_")]
        tag_prefix: String,
        #[arg(long, default_value = "TestChangingInt")]
        tag_attribute: String,
        #[arg(long, default_value_t = 1500)]
        timeout_ms: u32,
        #[arg(long)]
        json: bool,
    },
    StreamEvents {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long, default_value_t = 0)]
        after_worker_sequence: u64,
        #[arg(long, default_value_t = 1)]
        max_events: usize,
        #[arg(long)]
        json: bool,
        #[arg(long)]
        jsonl: bool,
    },
    /// Attach to the gateway's session-less central alarm feed. The stream
    /// opens with one `active_alarm` per currently-active alarm, then a
    /// single `snapshot_complete`, then a `transition` for every subsequent
    /// raise / acknowledge / clear.
    StreamAlarms {
        #[command(flatten)]
        connection: ConnectionArgs,
        /// Optional alarm-reference prefix scoping the feed to an equipment
        /// sub-tree. Omit to stream every active alarm.
        #[arg(long)]
        filter_prefix: Option<String>,
        #[arg(long, default_value_t = 1)]
        max_events: usize,
        #[arg(long)]
        json: bool,
        #[arg(long)]
        jsonl: bool,
    },
    /// Acknowledge an active MXAccess alarm condition through the gateway's
    /// session-less AcknowledgeAlarm RPC.
    AcknowledgeAlarm {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        reference: String,
        #[arg(long, default_value = "")]
        comment: String,
        #[arg(long, default_value = "")]
        operator: String,
        #[arg(long)]
        json: bool,
    },
    Write {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long)]
        item_handle: i32,
        #[arg(long, value_enum)]
        value_type: CliValueType,
        #[arg(long)]
        value: String,
        #[arg(long, default_value_t = 0)]
        user_id: i32,
        #[arg(long)]
        json: bool,
    },
    Write2 {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        session_id: String,
        #[arg(long)]
        server_handle: i32,
        #[arg(long)]
        item_handle: i32,
        #[arg(long, value_enum)]
        value_type: CliValueType,
        #[arg(long)]
        value: String,
        #[arg(long)]
        timestamp: String,
        #[arg(long, default_value_t = 0)]
        user_id: i32,
        #[arg(long)]
        json: bool,
    },
    Smoke {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        item: String,
        #[arg(long, default_value = "mxgw-rust-smoke")]
        client_name: String,
        #[arg(long)]
        json: bool,
    },
    /// Read commands from stdin, one per line, execute each in sequence, and
    /// write `__MXGW_BATCH_EOR__` to stdout after every result.  Errors are
    /// written as `{"error":"…","type":"error"}` JSON to stdout (not stderr)
    /// so the harness can parse them without interleaving stderr.  The loop
    /// never terminates on command error or accidental blank lines; only
    /// stdin EOF ends the session — empty lines log an empty-EOR-bracketed
    /// result and continue, matching the other four language CLIs.
    Batch,
    #[command(subcommand)]
    Galaxy(GalaxyCommand),
}

#[derive(Debug, Subcommand)]
enum GalaxyCommand {
    TestConnection {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        json: bool,
    },
    LastDeployTime {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        json: bool,
    },
    DiscoverHierarchy {
        #[command(flatten)]
        connection: ConnectionArgs,
        #[arg(long)]
        json: bool,
    },
    /// Lazily browse the Galaxy hierarchy through `BrowseChildren`.
    ///
    /// With no `--parent-gobject-id` the root objects are listed; pass a
    /// parent id to list that object's direct children. `--depth` controls
    /// how many further levels are eagerly expanded (0 = the requested level
    /// only). The filter flags map onto `BrowseChildrenOptions` and are reused
    /// at every expanded level, mirroring the lazy-browse library helper.
    Browse {
        #[command(flatten)]
        connection: ConnectionArgs,
        /// Parent gobject id whose children to browse. Omit for root objects.
        #[arg(long)]
        parent_gobject_id: Option<i32>,
        /// Restrict to objects whose `category_id` matches one of these ids.
        /// Repeatable.
        #[arg(long = "category-id")]
        category_ids: Vec<i32>,
        /// Restrict to objects whose template chain contains this entry.
        /// Repeatable (combined with AND).
        #[arg(long = "template-contains")]
        template_chain_contains: Vec<String>,
        /// Restrict to objects whose tag name matches this SQL `LIKE`-style glob.
        #[arg(long)]
        tag_name_glob: Option<String>,
        /// Populate `attributes` on the returned objects.
        #[arg(long)]
        include_attributes: bool,
        /// Only return objects that own at least one alarm-bearing attribute.
        #[arg(long)]
        alarm_bearing_only: bool,
        /// Only return objects that own at least one historized attribute.
        #[arg(long)]
        historized_only: bool,
        /// Number of additional levels to eagerly expand beneath each returned
        /// node. 0 (the default) prints only the requested level.
        /// Ignored when --parent-gobject-id is specified.
        #[arg(long, default_value_t = 0)]
        depth: usize,
        #[arg(long)]
        json: bool,
    },
    /// Subscribe to the WatchDeployEvents server stream.
    ///
    /// Prints one line per received event (or one JSON object with `--json`).
    /// Runs until the stream ends, the server fails the call, or the
    /// process is interrupted (Ctrl+C).
    #[command(alias = "watch-deploy-events")]
    Watch {
        #[command(flatten)]
        connection: ConnectionArgs,
        /// Optional RFC3339 timestamp (e.g. `2026-04-28T15:30:00Z`). When
        /// supplied, the server suppresses the bootstrap event if its
        /// cached deploy time matches this value.
        #[arg(long)]
        last_seen_deploy_time: Option<String>,
        /// Optional cap on the number of events to print before exiting.
        /// 0 (the default) means run until cancelled or the stream ends.
        #[arg(long, default_value_t = 0)]
        max_events: usize,
        #[arg(long)]
        json: bool,
    },
}

#[derive(Debug, Args, Clone)]
struct ConnectionArgs {
    #[arg(long, default_value = "http://127.0.0.1:5000")]
    endpoint: String,
    #[arg(long)]
    api_key: Option<String>,
    /// Name of the environment variable holding the gateway API key. The
    /// variable's value must be a full gateway key of the form
    /// `mxgw_<key-id>_<secret>`; it is forwarded verbatim as the Bearer
    /// token, so do not point this at an unrelated credential.
    #[arg(long, default_value = "MXGATEWAY_API_KEY")]
    api_key_env: String,
    /// Use an unencrypted (plaintext h2c) channel. Mutually exclusive with
    /// `--tls`; supplying both is rejected so an explicit `--tls` cannot be
    /// silently downgraded.
    #[arg(long, conflicts_with = "tls")]
    plaintext: bool,
    #[arg(long)]
    tls: bool,
    #[arg(long)]
    ca_file: Option<PathBuf>,
    #[arg(long)]
    server_name_override: Option<String>,
    /// Verify the server certificate against the system trust roots even
    /// without a pinned CA. The Rust client's default is to require a CA
    /// file (see `--ca-file`); set this flag to use system roots instead.
    #[arg(long)]
    require_certificate_validation: bool,
    #[arg(long, default_value_t = 10)]
    connect_timeout_seconds: u64,
    #[arg(long, default_value_t = 30)]
    call_timeout_seconds: u64,
}

impl ConnectionArgs {
    fn options(&self) -> ClientOptions {
        let mut options = ClientOptions::new(self.endpoint.clone())
            .with_plaintext(!self.tls || self.plaintext)
            .with_connect_timeout(Duration::from_secs(self.connect_timeout_seconds))
            .with_call_timeout(Duration::from_secs(self.call_timeout_seconds));

        if let Some(api_key) = self
            .api_key
            .clone()
            .or_else(|| env::var(&self.api_key_env).ok())
            .filter(|value| !value.is_empty())
        {
            options = options.with_api_key(ApiKey::new(api_key));
        }
        if let Some(ca_file) = &self.ca_file {
            options = options.with_ca_file(ca_file);
        }
        if let Some(server_name_override) = &self.server_name_override {
            options = options.with_server_name_override(server_name_override);
        }
        if self.require_certificate_validation {
            options = options.with_require_certificate_validation(true);
        }

        options
    }
}

#[derive(Clone, Copy, Debug, ValueEnum)]
enum CliValueType {
    Bool,
    Int32,
    Int64,
    Float,
    Double,
    String,
}

#[tokio::main]
async fn main() -> ExitCode {
    let cli = Cli::parse();
    let result = match cli.command {
        Command::Batch => run_batch().await,
        command => dispatch(command).await,
    };
    match result {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("{error}");
            ExitCode::FAILURE
        }
    }
}

/// Dispatch a parsed [`Command`] to its handler.  All subcommands except
/// [`Command::Batch`] are handled here; `Batch` is handled separately in
/// `main` to avoid mutual recursion between `dispatch` and `run_batch`.
async fn dispatch(command: Command) -> Result<(), Error> {
    match command {
        Command::Batch => {
            return Err(Error::InvalidArgument {
                name: "batch".to_owned(),
                detail: "batch cannot be nested inside another batch session".to_owned(),
            });
        }
        Command::Version { json } => print_version(json),
        Command::Ping {
            connection,
            message,
            json,
        } => {
            let client = connect(connection).await?;
            let reply = client
                .invoke(MxCommandRequest {
                    client_correlation_id: next_correlation_id("cli-ping"),
                    command: Some(MxCommand {
                        kind: MxCommandKind::Ping as i32,
                        payload: Some(zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::Ping(
                            PingCommand { message },
                        )),
                    }),
                    ..MxCommandRequest::default()
                })
                .await?;
            print_command_reply("ping", &reply, json);
        }
        Command::OpenSession {
            connection,
            client_name,
            json,
        } => {
            let client = connect(connection).await?;
            let reply = client
                .open_session_raw(OpenSessionRequest {
                    client_session_name: client_name,
                    ..OpenSessionRequest::default()
                })
                .await?;
            if json {
                println!(
                    "{}",
                    json!({
                        "sessionId": reply.session_id,
                        "backendName": reply.backend_name,
                        "gatewayProtocolVersion": reply.gateway_protocol_version,
                        "workerProtocolVersion": reply.worker_protocol_version,
                    })
                );
            } else {
                println!("{}", reply.session_id);
            }
        }
        Command::CloseSession {
            connection,
            session_id,
            json,
        } => {
            let client = connect(connection).await?;
            let reply = client
                .close_session_raw(CloseSessionRequest {
                    session_id,
                    client_correlation_id: next_correlation_id("cli-close-session"),
                })
                .await?;
            if json {
                println!("{}", json!({ "sessionId": reply.session_id }));
            } else {
                println!("closed {}", reply.session_id);
            }
        }
        Command::Register {
            connection,
            session_id,
            client_name,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            let server_handle = session.register(&client_name).await?;
            print_handle("serverHandle", server_handle, json);
        }
        Command::AddItem {
            connection,
            session_id,
            server_handle,
            item,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            let item_handle = session.add_item(server_handle, &item).await?;
            print_handle("itemHandle", item_handle, json);
        }
        Command::Advise {
            connection,
            session_id,
            server_handle,
            item_handle,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            session.advise(server_handle, item_handle).await?;
            print_ok("advise", json);
        }
        Command::AdviseSupervisory {
            connection,
            session_id,
            server_handle,
            item_handle,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            session
                .advise_supervisory(server_handle, item_handle)
                .await?;
            print_ok("advise-supervisory", json);
        }
        Command::Unregister {
            connection,
            session_id,
            server_handle,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            session.unregister(server_handle).await?;
            print_ok("unregister", json);
        }
        Command::AuthenticateUser {
            connection,
            session_id,
            server_handle,
            verify_user,
            password,
            password_env,
            json,
        } => {
            let verify_user_password = resolve_verify_user_password(password, &password_env)?;
            let session = session_for(connection, session_id).await?;
            let user_id = session
                .authenticate_user(server_handle, &verify_user, &verify_user_password)
                .await?;
            print_handle("userId", user_id, json);
        }
        Command::WriteSecured {
            connection,
            session_id,
            server_handle,
            item_handle,
            value_type,
            value,
            current_user_id,
            verifier_user_id,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            session
                .write_secured(
                    server_handle,
                    item_handle,
                    current_user_id,
                    verifier_user_id,
                    parse_value(value_type, &value)?,
                )
                .await?;
            print_ok("write-secured", json);
        }
        Command::SubscribeBulk {
            connection,
            session_id,
            server_handle,
            items,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            let results = session.subscribe_bulk(server_handle, items).await?;
            print_bulk_results("subscribe-bulk", &results, json);
        }
        Command::UnsubscribeBulk {
            connection,
            session_id,
            server_handle,
            item_handles,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            let results = session
                .unsubscribe_bulk(server_handle, item_handles)
                .await?;
            print_bulk_results("unsubscribe-bulk", &results, json);
        }
        Command::ReadBulk {
            connection,
            session_id,
            server_handle,
            items,
            timeout_ms,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            let results = session.read_bulk(server_handle, &items, timeout_ms).await?;
            print_read_bulk_results("read-bulk", &results, json);
        }
        Command::WriteBulk {
            connection,
            session_id,
            server_handle,
            item_handles,
            value_type,
            values,
            user_id,
            json,
        } => {
            let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
            let session = session_for(connection, session_id).await?;
            let results = session
                .write_bulk(
                    server_handle,
                    entries
                        .into_iter()
                        .map(|(item_handle, value)| WriteBulkEntry {
                            item_handle,
                            value: Some(value),
                            user_id,
                        })
                        .collect(),
                )
                .await?;
            print_write_bulk_results("write-bulk", &results, json);
        }
        Command::Write2Bulk {
            connection,
            session_id,
            server_handle,
            item_handles,
            value_type,
            values,
            timestamp,
            user_id,
            json,
        } => {
            let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
            let timestamp_value: ProtoMxValue = MxValue::string(timestamp).into_proto();
            let session = session_for(connection, session_id).await?;
            let results = session
                .write2_bulk(
                    server_handle,
                    entries
                        .into_iter()
                        .map(|(item_handle, value)| Write2BulkEntry {
                            item_handle,
                            value: Some(value),
                            timestamp_value: Some(timestamp_value.clone()),
                            user_id,
                        })
                        .collect(),
                )
                .await?;
            print_write_bulk_results("write2-bulk", &results, json);
        }
        Command::WriteSecuredBulk {
            connection,
            session_id,
            server_handle,
            item_handles,
            value_type,
            values,
            current_user_id,
            verifier_user_id,
            json,
        } => {
            let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
            let session = session_for(connection, session_id).await?;
            let results = session
                .write_secured_bulk(
                    server_handle,
                    entries
                        .into_iter()
                        .map(|(item_handle, value)| WriteSecuredBulkEntry {
                            item_handle,
                            value: Some(value),
                            current_user_id,
                            verifier_user_id,
                        })
                        .collect(),
                )
                .await?;
            print_write_bulk_results("write-secured-bulk", &results, json);
        }
        Command::WriteSecured2Bulk {
            connection,
            session_id,
            server_handle,
            item_handles,
            value_type,
            values,
            timestamp,
            current_user_id,
            verifier_user_id,
            json,
        } => {
            let entries = build_write_bulk_entries(&item_handles, value_type, &values)?;
            let timestamp_value: ProtoMxValue = MxValue::string(timestamp).into_proto();
            let session = session_for(connection, session_id).await?;
            let results = session
                .write_secured2_bulk(
                    server_handle,
                    entries
                        .into_iter()
                        .map(|(item_handle, value)| WriteSecured2BulkEntry {
                            item_handle,
                            value: Some(value),
                            timestamp_value: Some(timestamp_value.clone()),
                            current_user_id,
                            verifier_user_id,
                        })
                        .collect(),
                )
                .await?;
            print_write_bulk_results("write-secured2-bulk", &results, json);
        }
        Command::BenchReadBulk {
            connection,
            client_name,
            duration_seconds,
            warmup_seconds,
            bulk_size,
            tag_start,
            tag_prefix,
            tag_attribute,
            timeout_ms,
            json,
        } => {
            run_bench_read_bulk(
                connection,
                client_name,
                duration_seconds,
                warmup_seconds,
                bulk_size,
                tag_start,
                tag_prefix,
                tag_attribute,
                timeout_ms,
                json,
            )
            .await?;
        }
        Command::StreamEvents {
            connection,
            session_id,
            after_worker_sequence,
            max_events,
            json,
            jsonl,
        } => {
            if max_events > MAX_AGGREGATE_EVENTS {
                return Err(Error::InvalidArgument {
                    name: "max-events".to_owned(),
                    detail: format!("must be less than or equal to {MAX_AGGREGATE_EVENTS}"),
                });
            }

            let client = connect(connection).await?;
            let mut stream = client
                .stream_events(StreamEventsRequest {
                    session_id,
                    after_worker_sequence,
                })
                .await?;
            let mut events: Vec<Value> = Vec::new();
            let mut event_count = 0usize;
            while event_count < max_events {
                let Some(item) = stream.next().await else {
                    break;
                };
                let item = item?;
                event_count += 1;
                match item {
                    EventItem::Event(event) => {
                        if jsonl {
                            println!("{}", event_to_json(&event));
                        } else if json {
                            events.push(event_to_json(&event));
                        } else {
                            println!("{} {}", event.worker_sequence, event.family);
                        }
                    }
                    // Reconnect-replay gap sentinel: recent history was evicted
                    // before this resumed stream could replay it. Render it as a
                    // distinct row so the caller can re-snapshot and resume with
                    // `oldest_available_sequence - 1`.
                    EventItem::ReplayGap(gap) => {
                        let value = json!({
                            "replayGap": {
                                "requestedAfterSequence": gap.requested_after_sequence,
                                "oldestAvailableSequence": gap.oldest_available_sequence,
                            }
                        });
                        if jsonl {
                            println!("{value}");
                        } else if json {
                            events.push(value);
                        } else {
                            println!(
                                "REPLAY_GAP requested_after={} oldest_available={}",
                                gap.requested_after_sequence, gap.oldest_available_sequence
                            );
                        }
                    }
                }
            }
            if json {
                // `eventCount` is preserved for back-compat; `events` carries
                // the per-event detail the cross-language e2e matrix compares.
                println!("{}", json!({ "eventCount": event_count, "events": events }));
            }
        }
        Command::StreamAlarms {
            connection,
            filter_prefix,
            max_events,
            json,
            jsonl,
        } => {
            if max_events > MAX_AGGREGATE_EVENTS {
                return Err(Error::InvalidArgument {
                    name: "max-events".to_owned(),
                    detail: format!("must be less than or equal to {MAX_AGGREGATE_EVENTS}"),
                });
            }

            let client = connect(connection).await?;
            let mut stream = client
                .stream_alarms(StreamAlarmsRequest {
                    client_correlation_id: next_correlation_id("cli-stream-alarms"),
                    alarm_filter_prefix: filter_prefix.unwrap_or_default(),
                })
                .await?;
            let mut messages: Vec<Value> = Vec::new();
            let mut message_count = 0usize;
            while message_count < max_events {
                let Some(message) = stream.next().await else {
                    break;
                };
                let message = message?;
                message_count += 1;
                if jsonl {
                    println!("{}", alarm_feed_message_to_json(&message));
                } else if json {
                    messages.push(alarm_feed_message_to_json(&message));
                } else {
                    println!("{}", alarm_feed_message_summary(&message));
                }
            }
            if json {
                println!(
                    "{}",
                    json!({ "messageCount": message_count, "messages": messages })
                );
            }
        }
        Command::AcknowledgeAlarm {
            connection,
            reference,
            comment,
            operator,
            json,
        } => {
            let client = connect(connection).await?;
            let reply = client
                .acknowledge_alarm(AcknowledgeAlarmRequest {
                    client_correlation_id: next_correlation_id("cli-acknowledge-alarm"),
                    alarm_full_reference: reference,
                    comment,
                    operator_user: operator,
                })
                .await?;
            print_acknowledge_alarm_reply(&reply, json);
        }
        Command::Write {
            connection,
            session_id,
            server_handle,
            item_handle,
            value_type,
            value,
            user_id,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            session
                .write(
                    server_handle,
                    item_handle,
                    parse_value(value_type, &value)?,
                    user_id,
                )
                .await?;
            print_ok("write", json);
        }
        Command::Write2 {
            connection,
            session_id,
            server_handle,
            item_handle,
            value_type,
            value,
            timestamp,
            user_id,
            json,
        } => {
            let session = session_for(connection, session_id).await?;
            session
                .write2(
                    server_handle,
                    item_handle,
                    parse_value(value_type, &value)?,
                    MxValue::string(timestamp),
                    user_id,
                )
                .await?;
            print_ok("write2", json);
        }
        Command::Galaxy(galaxy_command) => run_galaxy(galaxy_command).await?,
        Command::Smoke {
            connection,
            item,
            client_name,
            json,
        } => {
            let client = connect(connection).await?;
            let session = client
                .open_session(OpenSessionRequest {
                    client_session_name: client_name.clone(),
                    ..OpenSessionRequest::default()
                })
                .await?;
            let result = async {
                let server_handle = session.register(&client_name).await?;
                let item_handle = session.add_item(server_handle, &item).await?;
                session.advise(server_handle, item_handle).await?;
                Ok::<_, Error>((server_handle, item_handle))
            }
            .await;
            let close_result = session.close().await;
            let (server_handle, item_handle) = result?;
            close_result?;
            if json {
                println!(
                    "{}",
                    json!({
                        "sessionId": session.id(),
                        "serverHandle": server_handle,
                        "itemHandle": item_handle,
                        "closed": true,
                    })
                );
            } else {
                println!(
                    "session {} registered server {server_handle}, item {item_handle}, closed",
                    session.id()
                );
            }
        }
    }

    Ok(())
}

async fn connect(connection: ConnectionArgs) -> Result<GatewayClient, Error> {
    GatewayClient::connect(connection.options()).await
}

async fn connect_galaxy(connection: ConnectionArgs) -> Result<GalaxyClient, Error> {
    GalaxyClient::connect(connection.options()).await
}

async fn run_galaxy(command: GalaxyCommand) -> Result<(), Error> {
    match command {
        GalaxyCommand::TestConnection { connection, json } => {
            let mut client = connect_galaxy(connection).await?;
            let ok = client.test_connection().await?;
            if json {
                println!("{}", json!({ "ok": ok }));
            } else if ok {
                println!("ok");
            } else {
                println!("not ok");
            }
        }
        GalaxyCommand::LastDeployTime { connection, json } => {
            let mut client = connect_galaxy(connection).await?;
            let timestamp = client.get_last_deploy_time().await?;
            match (json, timestamp) {
                (true, Some(ts)) => {
                    println!(
                        "{}",
                        json!({
                            "present": true,
                            "seconds": ts.seconds,
                            "nanos": ts.nanos,
                        })
                    );
                }
                (true, None) => {
                    println!("{}", json!({ "present": false }));
                }
                (false, Some(ts)) => println!("{}.{:09}", ts.seconds, ts.nanos),
                (false, None) => println!("(absent)"),
            }
        }
        GalaxyCommand::Watch {
            connection,
            last_seen_deploy_time,
            max_events,
            json,
        } => {
            let mut client = connect_galaxy(connection).await?;
            let last_seen = last_seen_deploy_time
                .as_deref()
                .map(parse_rfc3339_timestamp)
                .transpose()?;
            let mut stream = client.watch_deploy_events(last_seen).await?;

            let mut count = 0usize;
            loop {
                tokio::select! {
                    biased;
                    signal = tokio::signal::ctrl_c() => {
                        signal.map_err(|err| Error::InvalidArgument {
                            name: "ctrl_c".to_owned(),
                            detail: err.to_string(),
                        })?;
                        // Drop the stream below by breaking; tonic tears the
                        // gRPC call down cooperatively.
                        break;
                    }
                    next = stream.next() => {
                        let Some(event) = next else { break; };
                        let event = event?;
                        count += 1;
                        print_deploy_event(&event, json);
                        if max_events != 0 && count >= max_events {
                            break;
                        }
                    }
                }
            }
        }
        GalaxyCommand::DiscoverHierarchy { connection, json } => {
            let mut client = connect_galaxy(connection).await?;
            let objects = client.discover_hierarchy().await?;
            if json {
                let payload: Vec<_> = objects
                    .iter()
                    .map(|object| {
                        json!({
                            "gobjectId": object.gobject_id,
                            "tagName": object.tag_name,
                            "containedName": object.contained_name,
                            "browseName": object.browse_name,
                            "parentGobjectId": object.parent_gobject_id,
                            "isArea": object.is_area,
                            "categoryId": object.category_id,
                            "hostedByGobjectId": object.hosted_by_gobject_id,
                            "templateChain": object.template_chain,
                            "attributes": object.attributes.iter().map(|attribute| json!({
                                "attributeName": attribute.attribute_name,
                                "fullTagReference": attribute.full_tag_reference,
                                "mxDataType": attribute.mx_data_type,
                                "dataTypeName": attribute.data_type_name,
                                "isArray": attribute.is_array,
                                "arrayDimension": attribute.array_dimension,
                                "arrayDimensionPresent": attribute.array_dimension_present,
                                "mxAttributeCategory": attribute.mx_attribute_category,
                                "securityClassification": attribute.security_classification,
                                "isHistorized": attribute.is_historized,
                                "isAlarm": attribute.is_alarm,
                            })).collect::<Vec<_>>(),
                        })
                    })
                    .collect();
                println!("{}", json!({ "objects": payload }));
            } else {
                println!("{}", objects.len());
                for object in &objects {
                    println!(
                        "{} {} {} ({} attribute(s))",
                        object.gobject_id,
                        object.tag_name,
                        object.browse_name,
                        object.attributes.len()
                    );
                }
            }
        }
        GalaxyCommand::Browse {
            connection,
            parent_gobject_id,
            category_ids,
            template_chain_contains,
            tag_name_glob,
            include_attributes,
            alarm_bearing_only,
            historized_only,
            depth,
            json,
        } => {
            if parent_gobject_id.is_some() && depth > 0 {
                eprintln!("warning: --depth is ignored when --parent-gobject-id is specified");
            }

            let mut client = connect_galaxy(connection).await?;
            let options = BrowseChildrenOptions {
                category_ids,
                template_chain_contains,
                tag_name_glob,
                include_attributes: include_attributes.then_some(true),
                alarm_bearing_only,
                historized_only,
            };

            match parent_gobject_id {
                // No parent → walk the lazy-browse tree from the root objects,
                // eagerly expanding `depth` further levels so the print walks
                // cached children without re-issuing RPCs.
                None => {
                    let nodes = client.browse(Some(options)).await?;
                    for node in &nodes {
                        expand_to_depth(node, depth).await?;
                    }
                    if json {
                        let mut payload = Vec::with_capacity(nodes.len());
                        for node in &nodes {
                            payload.push(lazy_node_to_json(node).await);
                        }
                        println!("{}", json!({ "nodes": payload }));
                    } else {
                        println!("{}", nodes.len());
                        for node in &nodes {
                            print_lazy_node(node, 0).await;
                        }
                    }
                }
                // A specific parent → fetch exactly one level of children via
                // the raw paged RPC. `--depth` is not meaningful here; the
                // single-level children are returned as-is.
                Some(parent) => {
                    let children = browse_children_one_level(&mut client, parent, &options).await?;
                    print_browse_children(&children, json);
                }
            }
        }
    }
    Ok(())
}

/// Page size used for the raw `BrowseChildren` RPC when fetching a single
/// level via `--parent-gobject-id`. Mirrors `BROWSE_CHILDREN_PAGE_SIZE` in
/// `galaxy.rs` (the library's lazy-browse helper uses the same value).
const BROWSE_PAGE_SIZE: i32 = 500;

/// Drive `BrowseChildren` paging by hand for a single parent and return the
/// flattened child list. Used by the `browse --parent-gobject-id` path, which
/// surfaces one level of children rather than the lazy root-tree walk.
/// Record a non-empty `next_page_token` in `seen` and reject a repeat. A
/// server that returns the same continuation token twice would loop forever,
/// so the second sighting is converted to an `InvalidArgument` error. Extracted
/// from [`browse_children_one_level`] so the guard can be unit-tested without a
/// network client.
fn register_page_token(
    seen: &mut std::collections::HashSet<String>,
    token: &str,
) -> Result<(), Error> {
    if !seen.insert(token.to_owned()) {
        return Err(Error::InvalidArgument {
            name: "page_token".to_owned(),
            detail: format!("galaxy browse children returned repeated page token `{token}`"),
        });
    }
    Ok(())
}

async fn browse_children_one_level(
    client: &mut GalaxyClient,
    parent_gobject_id: i32,
    options: &BrowseChildrenOptions,
) -> Result<Vec<GalaxyBrowseChild>, Error> {
    use std::collections::HashSet;
    use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::{
        browse_children_request, BrowseChildrenRequest,
    };

    let mut children = Vec::new();
    let mut page_token = String::new();
    let mut seen: HashSet<String> = HashSet::new();
    loop {
        let request = BrowseChildrenRequest {
            page_size: BROWSE_PAGE_SIZE,
            page_token: page_token.clone(),
            category_ids: options.category_ids.clone(),
            template_chain_contains: options.template_chain_contains.clone(),
            tag_name_glob: options.tag_name_glob.clone().unwrap_or_default(),
            include_attributes: options.include_attributes,
            alarm_bearing_only: options.alarm_bearing_only,
            historized_only: options.historized_only,
            parent: Some(browse_children_request::Parent::ParentGobjectId(
                parent_gobject_id,
            )),
        };
        let reply = client.browse_children_raw(request).await?;
        let hints = reply.child_has_children;
        for (index, object) in reply.children.into_iter().enumerate() {
            let has_children_hint = hints.get(index).copied().unwrap_or(false);
            children.push(GalaxyBrowseChild {
                object,
                has_children_hint,
            });
        }
        page_token = reply.next_page_token;
        if page_token.is_empty() {
            return Ok(children);
        }
        register_page_token(&mut seen, &page_token)?;
    }
}

/// A single child returned by the raw `BrowseChildren` paging path, paired
/// with its server-supplied `child_has_children` hint.
struct GalaxyBrowseChild {
    object: zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::GalaxyObject,
    has_children_hint: bool,
}

/// Print the one-level children of a browsed parent, mirroring the JSON node
/// shape used by the root-tree walk (minus the recursive `children` array).
fn print_browse_children(children: &[GalaxyBrowseChild], use_json: bool) {
    if use_json {
        let payload: Vec<_> = children.iter().map(browse_child_to_json).collect();
        println!("{}", json!({ "nodes": payload }));
    } else {
        println!("{}", children.len());
        for child in children {
            let object = &child.object;
            let marker = if child.has_children_hint { "+" } else { "-" };
            println!(
                "{marker} {} {} (gobject {})",
                object.tag_name, object.browse_name, object.gobject_id,
            );
        }
    }
}

/// Render one raw browse child as a JSON object whose key set matches the
/// lazy-node renderer (with an empty `children` array).
fn browse_child_to_json(child: &GalaxyBrowseChild) -> Value {
    let object = &child.object;
    json!({
        "gobjectId": object.gobject_id,
        "tagName": object.tag_name,
        "containedName": object.contained_name,
        "browseName": object.browse_name,
        "parentGobjectId": object.parent_gobject_id,
        "isArea": object.is_area,
        "categoryId": object.category_id,
        "hostedByGobjectId": object.hosted_by_gobject_id,
        "templateChain": object.template_chain,
        "hasChildrenHint": child.has_children_hint,
        "attributes": object.attributes.iter().map(|attribute| json!({
            "attributeName": attribute.attribute_name,
            "fullTagReference": attribute.full_tag_reference,
            "mxDataType": attribute.mx_data_type,
            "dataTypeName": attribute.data_type_name,
            "isArray": attribute.is_array,
            "arrayDimension": attribute.array_dimension,
            "arrayDimensionPresent": attribute.array_dimension_present,
            "mxAttributeCategory": attribute.mx_attribute_category,
            "securityClassification": attribute.security_classification,
            "isHistorized": attribute.is_historized,
            "isAlarm": attribute.is_alarm,
        })).collect::<Vec<_>>(),
        "children": Vec::<Value>::new(),
    })
}

/// Recursively expand a [`LazyBrowseNode`] up to `depth` further levels. A
/// `depth` of 0 leaves the node unexpanded so the caller prints only the
/// requested level.
fn expand_to_depth(
    node: &LazyBrowseNode,
    depth: usize,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Error>> + Send + '_>> {
    Box::pin(async move {
        if depth == 0 {
            return Ok(());
        }
        node.expand().await?;
        for child in node.children().await {
            expand_to_depth(&child, depth - 1).await?;
        }
        Ok(())
    })
}

/// Print a [`LazyBrowseNode`] and any already-expanded descendants as an
/// indented tree. Indentation is two spaces per level.
fn print_lazy_node(
    node: &LazyBrowseNode,
    indent: usize,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>> {
    Box::pin(async move {
        let object = node.object();
        let marker = if node.has_children_hint() { "+" } else { "-" };
        println!(
            "{:indent$}{marker} {} {} (gobject {})",
            "",
            object.tag_name,
            object.browse_name,
            object.gobject_id,
            indent = indent,
        );
        if node.is_expanded().await {
            for child in node.children().await {
                print_lazy_node(&child, indent + 2).await;
            }
        }
    })
}

/// Render a [`LazyBrowseNode`] (and its already-expanded children) as a JSON
/// object. Mirrors the `discover-hierarchy` object shape with an added
/// `hasChildrenHint` flag and a nested `children` array.
fn lazy_node_to_json(
    node: &LazyBrowseNode,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Value> + Send + '_>> {
    Box::pin(async move {
        let object = node.object();
        let mut children = Vec::new();
        if node.is_expanded().await {
            for child in node.children().await {
                children.push(lazy_node_to_json(&child).await);
            }
        }
        json!({
            "gobjectId": object.gobject_id,
            "tagName": object.tag_name,
            "containedName": object.contained_name,
            "browseName": object.browse_name,
            "parentGobjectId": object.parent_gobject_id,
            "isArea": object.is_area,
            "categoryId": object.category_id,
            "hostedByGobjectId": object.hosted_by_gobject_id,
            "templateChain": object.template_chain,
            "hasChildrenHint": node.has_children_hint(),
            "attributes": object.attributes.iter().map(|attribute| json!({
                "attributeName": attribute.attribute_name,
                "fullTagReference": attribute.full_tag_reference,
                "mxDataType": attribute.mx_data_type,
                "dataTypeName": attribute.data_type_name,
                "isArray": attribute.is_array,
                "arrayDimension": attribute.array_dimension,
                "arrayDimensionPresent": attribute.array_dimension_present,
                "mxAttributeCategory": attribute.mx_attribute_category,
                "securityClassification": attribute.security_classification,
                "isHistorized": attribute.is_historized,
                "isAlarm": attribute.is_alarm,
            })).collect::<Vec<_>>(),
            "children": children,
        })
    })
}

async fn session_for(
    connection: ConnectionArgs,
    session_id: String,
) -> Result<zb_mom_ww_mxgateway_client::Session, Error> {
    let client = connect(connection).await?;
    Ok(client.session(session_id))
}

/// End-of-result sentinel written to stdout after every batch command.
const BATCH_EOR: &str = "__MXGW_BATCH_EOR__";

/// Run the batch loop: read one command line at a time from stdin, dispatch
/// each through the normal [`dispatch`] path, and write [`BATCH_EOR`] to
/// stdout after every result.  Errors are serialised as JSON to stdout so
/// the harness can parse them without interleaving stderr.  The loop never
/// terminates on command error or accidental blank lines; only stdin EOF
/// ends the session — empty lines log an empty-EOR-bracketed result and
/// continue.
///
/// `std::io::Stdin::lock().lines()` is a blocking iterator and the dispatch
/// future is spawned on a separate tokio task so the runtime's main worker
/// stays free.  When the runtime is multi-threaded the blocking read keeps
/// one worker parked on `ReadFile`; that is acceptable here because no other
/// future on the main task needs to run while we wait for the next command.
async fn run_batch() -> Result<(), Error> {
    let stdin = io::stdin();
    let stdout = io::stdout();

    for line in stdin.lock().lines() {
        let line = line.map_err(|e| Error::InvalidArgument {
            name: "stdin".to_owned(),
            detail: e.to_string(),
        })?;

        let parts: Vec<&str> = line.split_ascii_whitespace().collect();
        if parts.is_empty() {
            // Empty / whitespace-only line: log an empty-EOR-bracketed
            // result and continue so accidental blank lines from the
            // PowerShell e2e harness do not silently end the session.
            println!("{BATCH_EOR}");
            stdout.lock().flush().ok();
            continue;
        }

        // Re-parse the split arguments under a fresh Cli, prepending the
        // program-name placeholder so clap sees a complete argv[].
        let parse_result =
            Cli::try_parse_from(std::iter::once("mxgw-cli").chain(parts.iter().copied()));

        let outcome: Result<(), Error> = match parse_result {
            Ok(cli) => {
                // Spawn on a new tokio task so each command runs with a fresh
                // stack, avoiding stack overflow from the large dispatch future.
                tokio::task::spawn(dispatch(cli.command))
                    .await
                    .unwrap_or_else(|join_err| {
                        Err(Error::InvalidArgument {
                            name: "task".to_owned(),
                            detail: join_err.to_string(),
                        })
                    })
            }
            Err(clap_err) => Err(Error::InvalidArgument {
                name: "args".to_owned(),
                detail: clap_err.to_string(),
            }),
        };

        if let Err(err) = outcome {
            // Write error as JSON to stdout so the harness sees it in the
            // same stream as normal output, framed by the EOR sentinel.
            println!(
                "{}",
                serde_json::json!({ "error": err.to_string(), "type": "error" })
            );
        }

        println!("{BATCH_EOR}");
        stdout.lock().flush().ok();
    }

    Ok(())
}

fn print_version(use_json: bool) {
    if use_json {
        println!("{}", version_json());
        return;
    }

    println!("mxgw {CLIENT_VERSION}");
    println!("gateway protocol {GATEWAY_PROTOCOL_VERSION}");
    println!("worker protocol {WORKER_PROTOCOL_VERSION}");
}

fn version_json() -> Value {
    json!({
        "clientVersion": CLIENT_VERSION,
        "gatewayProtocolVersion": GATEWAY_PROTOCOL_VERSION,
        "workerProtocolVersion": WORKER_PROTOCOL_VERSION,
    })
}

fn print_command_reply(
    operation: &str,
    reply: &zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::MxCommandReply,
    use_json: bool,
) {
    if use_json {
        println!(
            "{}",
            json!({
                "operation": operation,
                "sessionId": reply.session_id,
                "correlationId": reply.correlation_id,
                "kind": reply.kind,
            })
        );
    } else {
        println!("{operation} completed");
    }
}

fn print_handle(name: &str, handle: i32, use_json: bool) {
    if use_json {
        println!("{}", json!({ name: handle }));
    } else {
        println!("{handle}");
    }
}

fn print_ok(operation: &str, use_json: bool) {
    if use_json {
        println!("{}", json!({ "operation": operation, "ok": true }));
    } else {
        println!("{operation} completed");
    }
}

/// Resolves the `authenticate-user` credential from `--password`, falling back to
/// the environment variable named by `--password-env` (default
/// `MXGATEWAY_VERIFY_PASSWORD`).
///
/// An empty value from either source counts as missing (CLI-45): the CLI fails
/// fast with a usage error rather than sending a fabricated empty credential to
/// the wire. The error names the flag and the variable only — never the value,
/// which is never echoed to stdout/stderr or embedded in an error message.
fn resolve_verify_user_password(
    password: Option<String>,
    password_env: &str,
) -> Result<String, Error> {
    password
        .filter(|value| !value.is_empty())
        .or_else(|| {
            env::var(password_env)
                .ok()
                .filter(|value| !value.is_empty())
        })
        .ok_or_else(|| Error::InvalidArgument {
            name: "password".to_owned(),
            detail: format!("supply --password or set the environment variable `{password_env}`"),
        })
}

fn print_bulk_results(
    operation: &str,
    results: &[zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::SubscribeResult],
    use_json: bool,
) {
    if use_json {
        let results_json: Vec<_> = results
            .iter()
            .map(|result| {
                json!({
                    "serverHandle": result.server_handle,
                    "tagAddress": result.tag_address,
                    "itemHandle": result.item_handle,
                    "wasSuccessful": result.was_successful,
                    "errorMessage": result.error_message,
                })
            })
            .collect();
        println!(
            "{}",
            json!({ "operation": operation, "results": results_json })
        );
    } else {
        println!("{}", results.len());
    }
}

fn parse_value(value_type: CliValueType, value: &str) -> Result<MxValue, Error> {
    let parsed = match value_type {
        CliValueType::Bool => MxValue::bool(parse_cli_value(value)?),
        CliValueType::Int32 => MxValue::int32(parse_cli_value(value)?),
        CliValueType::Int64 => MxValue::int64(parse_cli_value(value)?),
        CliValueType::Float => MxValue::float(parse_cli_value(value)?),
        CliValueType::Double => MxValue::double(parse_cli_value(value)?),
        CliValueType::String => MxValue::string(value),
    };
    Ok(parsed)
}

fn print_write_bulk_results(
    operation: &str,
    results: &[zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::BulkWriteResult],
    use_json: bool,
) {
    if use_json {
        let results_json: Vec<_> = results
            .iter()
            .map(|result| {
                json!({
                    "serverHandle": result.server_handle,
                    "itemHandle": result.item_handle,
                    "wasSuccessful": result.was_successful,
                    "hresult": result.hresult,
                    "errorMessage": result.error_message,
                })
            })
            .collect();
        println!(
            "{}",
            json!({ "operation": operation, "results": results_json })
        );
    } else {
        println!("{}", results.len());
    }
}

fn print_read_bulk_results(
    operation: &str,
    results: &[zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::BulkReadResult],
    use_json: bool,
) {
    if use_json {
        let results_json: Vec<_> = results
            .iter()
            .map(|result| {
                json!({
                    "serverHandle": result.server_handle,
                    "tagAddress": result.tag_address,
                    "itemHandle": result.item_handle,
                    "wasSuccessful": result.was_successful,
                    "wasCached": result.was_cached,
                    "quality": result.quality,
                    "errorMessage": result.error_message,
                })
            })
            .collect();
        println!(
            "{}",
            json!({ "operation": operation, "results": results_json })
        );
    } else {
        println!("{}", results.len());
    }
}

/// Drive the cross-language ReadBulk stress benchmark from Rust: opens its
/// own session, subscribes to `bulk_size` tags so the worker's per-session
/// value cache populates from real OnDataChange events, hammers ReadBulk in
/// a tight loop for `duration_seconds` with per-call latency capture, and
/// emits the shared JSON stats schema the `scripts/bench-read-bulk.ps1`
/// driver collates across all five clients.
#[allow(clippy::too_many_arguments)]
async fn run_bench_read_bulk(
    connection: ConnectionArgs,
    client_name: String,
    duration_seconds: u64,
    warmup_seconds: u64,
    bulk_size: usize,
    tag_start: i32,
    tag_prefix: String,
    tag_attribute: String,
    timeout_ms: u32,
    json: bool,
) -> Result<(), Error> {
    if bulk_size == 0 {
        return Err(Error::InvalidArgument {
            name: "bulk-size".to_owned(),
            detail: "bulk-size must be positive".to_owned(),
        });
    }
    if duration_seconds == 0 {
        return Err(Error::InvalidArgument {
            name: "duration-seconds".to_owned(),
            detail: "duration-seconds must be positive".to_owned(),
        });
    }

    // Build TestMachine_NNN.<attribute> tags with three-digit machine numbers
    // matching the existing cross-language tag-discovery convention.
    let tags: Vec<String> = (0..bulk_size)
        .map(|index| {
            format!(
                "{prefix}{number:03}.{attr}",
                prefix = tag_prefix,
                number = tag_start + index as i32,
                attr = tag_attribute,
            )
        })
        .collect();

    let endpoint = connection.endpoint.clone();
    let client = connect(connection).await?;
    let session = client
        .open_session(OpenSessionRequest {
            client_session_name: client_name.clone(),
            ..OpenSessionRequest::default()
        })
        .await?;
    let session_id = session.id().to_owned();

    // Subscribe so the worker's MxAccessValueCache populates from real
    // OnDataChange events before the measurement window opens. Any per-tag
    // failures fall through silently; the bench is still meaningful for the
    // successfully-subscribed subset.
    let bench_outcome = async {
        let server_handle = session.register(&client_name).await?;
        let subscribe_results = session.subscribe_bulk(server_handle, tags.clone()).await?;
        let tags_ref: &[String] = &tags;
        let item_handles: Vec<i32> = subscribe_results
            .iter()
            .filter(|r| r.was_successful)
            .map(|r| r.item_handle)
            .collect();

        let timeout_ms_param = timeout_ms;

        // Warm-up: drive identical calls so any connection-pool / channel
        // setup is amortised before the measurement window opens.
        let warmup_deadline = Instant::now() + Duration::from_secs(warmup_seconds);
        while Instant::now() < warmup_deadline {
            let _ = session
                .read_bulk(server_handle, tags_ref, timeout_ms_param)
                .await;
        }

        // Steady-state measurement window: capture per-call latency as
        // sub-millisecond f64 deltas from Instant::now() so the histogram
        // resolution matches the .NET Stopwatch / Go time.Now path.
        let mut latencies_ms: Vec<f64> = Vec::with_capacity(65_536);
        let mut total_read_results: i64 = 0;
        let mut cached_read_results: i64 = 0;
        let mut successful_calls: u64 = 0;
        let mut failed_calls: u64 = 0;
        let steady_start = Instant::now();
        let steady_deadline = steady_start + Duration::from_secs(duration_seconds);

        while Instant::now() < steady_deadline {
            let call_start = Instant::now();
            let result = session
                .read_bulk(server_handle, tags_ref, timeout_ms_param)
                .await;
            let elapsed = call_start.elapsed();
            latencies_ms.push(elapsed.as_secs_f64() * 1000.0);
            match result {
                Ok(results) => {
                    successful_calls += 1;
                    for r in &results {
                        total_read_results += 1;
                        if r.was_cached {
                            cached_read_results += 1;
                        }
                    }
                }
                Err(_) => failed_calls += 1,
            }
        }
        let steady_elapsed = steady_start.elapsed();

        // Best-effort cleanup: unsubscribe so the worker can release cache slots.
        if !item_handles.is_empty() {
            let _ = session.unsubscribe_bulk(server_handle, item_handles).await;
        }

        let total_calls = successful_calls + failed_calls;
        let calls_per_second = if steady_elapsed.as_secs_f64() > 0.0 {
            total_calls as f64 / steady_elapsed.as_secs_f64()
        } else {
            0.0
        };

        Ok::<_, Error>(BenchStats {
            endpoint,
            client_name,
            bulk_size,
            duration_seconds,
            warmup_seconds,
            duration_ms: steady_elapsed.as_millis() as u64,
            tags: tags.clone(),
            total_calls,
            successful_calls,
            failed_calls,
            total_read_results,
            cached_read_results,
            calls_per_second,
            latencies_ms,
        })
    }
    .await;

    // Always close the session, even if the bench loop returned an error.
    let close_result = client
        .close_session_raw(CloseSessionRequest {
            session_id: session_id.clone(),
            client_correlation_id: next_correlation_id("cli-bench-read-bulk-close"),
        })
        .await;

    let stats = bench_outcome?;
    // Closing the session is best-effort; never let it mask a real bench error.
    let _ = close_result;

    if json {
        let latency = percentile_summary(&stats.latencies_ms);
        let payload = json!({
            "language": "rust",
            "command": "bench-read-bulk",
            "endpoint": stats.endpoint,
            "clientName": stats.client_name,
            "bulkSize": stats.bulk_size,
            "durationSeconds": stats.duration_seconds,
            "warmupSeconds": stats.warmup_seconds,
            "durationMs": stats.duration_ms,
            "tags": stats.tags,
            "totalCalls": stats.total_calls,
            "successfulCalls": stats.successful_calls,
            "failedCalls": stats.failed_calls,
            "totalReadResults": stats.total_read_results,
            "cachedReadResults": stats.cached_read_results,
            "callsPerSecond": round_to(stats.calls_per_second, 2),
            "latencyMs": {
                "p50": round_to(latency.p50, 3),
                "p95": round_to(latency.p95, 3),
                "p99": round_to(latency.p99, 3),
                "max": round_to(latency.max, 3),
                "mean": round_to(latency.mean, 3),
            },
        });
        println!("{payload}");
    } else {
        println!("{}", stats.calls_per_second);
    }
    Ok(())
}

/// Collected bench-read-bulk measurements; carried in one struct so the
/// async block can finish cleanup (unsubscribe, close-session) before the
/// caller renders the JSON / plain output.
struct BenchStats {
    endpoint: String,
    client_name: String,
    bulk_size: usize,
    duration_seconds: u64,
    warmup_seconds: u64,
    duration_ms: u64,
    tags: Vec<String>,
    total_calls: u64,
    successful_calls: u64,
    failed_calls: u64,
    total_read_results: i64,
    cached_read_results: i64,
    calls_per_second: f64,
    latencies_ms: Vec<f64>,
}

/// The same `{ p50, p95, p99, max, mean }` shape every language bench emits.
/// `p50`/`p95`/`p99` use nearest-rank with linear interpolation, matching the
/// .NET / Go implementations so cross-language comparisons are apples-to-apples.
struct LatencySummary {
    p50: f64,
    p95: f64,
    p99: f64,
    max: f64,
    mean: f64,
}

fn percentile_summary(sample: &[f64]) -> LatencySummary {
    if sample.is_empty() {
        return LatencySummary {
            p50: 0.0,
            p95: 0.0,
            p99: 0.0,
            max: 0.0,
            mean: 0.0,
        };
    }
    let mut sorted: Vec<f64> = sample.to_vec();
    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    let max = sorted[sorted.len() - 1];
    let mean: f64 = sample.iter().copied().sum::<f64>() / sample.len() as f64;
    LatencySummary {
        p50: percentile(&sorted, 0.50),
        p95: percentile(&sorted, 0.95),
        p99: percentile(&sorted, 0.99),
        max,
        mean,
    }
}

fn percentile(sorted: &[f64], quantile: f64) -> f64 {
    if sorted.is_empty() {
        return 0.0;
    }
    if sorted.len() == 1 {
        return sorted[0];
    }
    let rank = quantile * (sorted.len() - 1) as f64;
    let lower = rank.floor() as usize;
    let upper = lower + 1;
    if upper >= sorted.len() {
        return sorted[lower];
    }
    let fraction = rank - lower as f64;
    sorted[lower] + (sorted[upper] - sorted[lower]) * fraction
}

fn round_to(value: f64, digits: u32) -> f64 {
    let shift = 10f64.powi(digits as i32);
    (value * shift).round() / shift
}

/// Pairs each parsed item handle with its parsed MxValue (proto form) so a
/// single helper can build the four bulk-write families without each branch
/// repeating the length check and per-value parsing.
fn build_write_bulk_entries(
    item_handles: &[i32],
    value_type: CliValueType,
    values: &[String],
) -> Result<
    Vec<(
        i32,
        zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::MxValue,
    )>,
    Error,
> {
    if item_handles.len() != values.len() {
        return Err(Error::InvalidArgument {
            name: "values".to_owned(),
            detail: format!(
                "item-handles count ({}) does not match values count ({})",
                item_handles.len(),
                values.len()
            ),
        });
    }
    item_handles
        .iter()
        .zip(values.iter())
        .map(|(handle, value)| {
            parse_value(value_type, value).map(|wrapper| (*handle, wrapper.into_proto()))
        })
        .collect()
}

fn print_deploy_event(event: &DeployEvent, use_json: bool) {
    if use_json {
        println!(
            "{}",
            json!({
                "sequence": event.sequence,
                "observedAt": event.observed_at.as_ref().map(|ts| json!({
                    "seconds": ts.seconds,
                    "nanos": ts.nanos,
                })),
                "timeOfLastDeploy": event.time_of_last_deploy.as_ref().map(|ts| json!({
                    "seconds": ts.seconds,
                    "nanos": ts.nanos,
                })),
                "timeOfLastDeployPresent": event.time_of_last_deploy_present,
                "objectCount": event.object_count,
                "attributeCount": event.attribute_count,
            })
        );
    } else {
        let observed = event
            .observed_at
            .as_ref()
            .map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
            .unwrap_or_else(|| "(absent)".to_owned());
        let last_deploy = if event.time_of_last_deploy_present {
            event
                .time_of_last_deploy
                .as_ref()
                .map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
                .unwrap_or_else(|| "(absent)".to_owned())
        } else {
            "(absent)".to_owned()
        };
        println!(
            "seq={} observed={} lastDeploy={} objects={} attributes={}",
            event.sequence, observed, last_deploy, event.object_count, event.attribute_count,
        );
    }
}

/// Render a streamed [`MxEvent`] as a JSON object. The scalar value is
/// projected into protojson-style `*Value` keys so the cross-language e2e
/// matrix can extract and compare event values uniformly across all five
/// client CLIs.
fn event_to_json(event: &MxEvent) -> Value {
    // Match the other four CLIs by rendering the family as its protobuf enum
    // name (e.g. MX_EVENT_FAMILY_ON_WRITE_COMPLETE). The e2e write round-trip
    // looks up this name to confirm the OnWriteComplete echo arrived; emitting
    // the raw i32 leaves it unable to recognise any event.
    let family = MxEventFamily::try_from(event.family)
        .map(|f| f.as_str_name())
        .unwrap_or("MX_EVENT_FAMILY_UNSPECIFIED");
    json!({
        "family": family,
        "sessionId": event.session_id,
        "serverHandle": event.server_handle,
        "itemHandle": event.item_handle,
        "quality": event.quality,
        "workerSequence": event.worker_sequence,
        "value": event.value.as_ref().map(event_value_to_json),
    })
}

/// Project an [`MxValue`] into a protojson-shaped JSON object whose single
/// key names the scalar kind (`int32Value`, `stringValue`, ...), matching
/// the protobuf-JSON the .NET/Go/Java CLIs emit.
fn event_value_to_json(value: &ProtoMxValue) -> Value {
    match MxValue::from_proto(value.clone()).projection() {
        MxValueProjection::Bool(inner) => json!({ "boolValue": inner }),
        MxValueProjection::Int32(inner) => json!({ "int32Value": inner }),
        // protojson renders 64-bit integers as strings; mirror that here.
        MxValueProjection::Int64(inner) => json!({ "int64Value": inner.to_string() }),
        MxValueProjection::Float(inner) => json!({ "floatValue": inner }),
        MxValueProjection::Double(inner) => json!({ "doubleValue": inner }),
        MxValueProjection::String(inner) => json!({ "stringValue": inner }),
        MxValueProjection::Timestamp(ts) => {
            json!({ "timestampValue": { "seconds": ts.seconds, "nanos": ts.nanos } })
        }
        MxValueProjection::Array(_) => json!({ "arrayValue": {} }),
        MxValueProjection::Raw(bytes) => json!({ "rawValue": { "byteCount": bytes.len() } }),
        MxValueProjection::Null => json!({ "isNull": true }),
        MxValueProjection::Unset => Value::Null,
    }
}

/// Render a streamed [`AlarmFeedMessage`] as a terse one-line summary that
/// distinguishes the five `payload` oneof cases.
fn alarm_feed_message_summary(message: &AlarmFeedMessage) -> String {
    match &message.payload {
        Some(alarm_feed_message::Payload::ActiveAlarm(snapshot)) => {
            format!(
                "active-alarm {} state={}",
                snapshot.alarm_full_reference,
                AlarmEnumName::condition_state(snapshot.current_state)
            )
        }
        Some(alarm_feed_message::Payload::SnapshotComplete(complete)) => {
            format!("snapshot-complete {complete}")
        }
        Some(alarm_feed_message::Payload::Transition(transition)) => {
            format!(
                "transition {} kind={}",
                transition.alarm_full_reference,
                AlarmEnumName::transition_kind(transition.transition_kind)
            )
        }
        Some(alarm_feed_message::Payload::ProviderStatus(status)) => {
            format!(
                "provider-status mode={} degraded={} reason={:?}",
                AlarmEnumName::provider_mode(status.mode),
                status.degraded,
                status.reason
            )
        }
        Some(alarm_feed_message::Payload::SnapshotStatus(status)) => {
            format!("snapshot-status truncated={}", status.truncated)
        }
        None => "(empty)".to_owned(),
    }
}

/// Render a streamed [`AlarmFeedMessage`] as a JSON object whose single
/// top-level key names the active `payload` oneof case, mirroring the
/// protobuf-JSON the .NET/Go/Java/Python CLIs emit.
fn alarm_feed_message_to_json(message: &AlarmFeedMessage) -> Value {
    match &message.payload {
        Some(alarm_feed_message::Payload::ActiveAlarm(snapshot)) => json!({
            "activeAlarm": {
                "alarmFullReference": snapshot.alarm_full_reference,
                "sourceObjectReference": snapshot.source_object_reference,
                "alarmTypeName": snapshot.alarm_type_name,
                "severity": snapshot.severity,
                "currentState": AlarmEnumName::condition_state(snapshot.current_state),
                "category": snapshot.category,
                "description": snapshot.description,
                "operatorUser": snapshot.operator_user,
                "operatorComment": snapshot.operator_comment,
            }
        }),
        Some(alarm_feed_message::Payload::SnapshotComplete(complete)) => json!({
            "snapshotComplete": complete,
        }),
        Some(alarm_feed_message::Payload::Transition(transition)) => json!({
            "transition": {
                "alarmFullReference": transition.alarm_full_reference,
                "sourceObjectReference": transition.source_object_reference,
                "alarmTypeName": transition.alarm_type_name,
                "transitionKind": AlarmEnumName::transition_kind(transition.transition_kind),
                "severity": transition.severity,
                "operatorUser": transition.operator_user,
                "operatorComment": transition.operator_comment,
                "category": transition.category,
                "description": transition.description,
            }
        }),
        Some(alarm_feed_message::Payload::ProviderStatus(status)) => json!({
            "providerStatus": {
                "mode": AlarmEnumName::provider_mode(status.mode),
                "degraded": status.degraded,
                "reason": status.reason,
                "since": status.since.as_ref().map(|ts| json!({
                    "seconds": ts.seconds,
                    "nanos": ts.nanos,
                })),
            }
        }),
        Some(alarm_feed_message::Payload::SnapshotStatus(status)) => json!({
            "snapshotStatus": {
                "truncated": status.truncated,
            }
        }),
        None => Value::Null,
    }
}

/// Tiny namespace for alarm-enum name lookups used by the alarm-feed
/// renderers; keeps the proto-enum imports off the `main.rs` top level.
struct AlarmEnumName;

impl AlarmEnumName {
    fn condition_state(value: i32) -> String {
        use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::AlarmConditionState;
        AlarmConditionState::try_from(value)
            .map(|state| state.as_str_name().to_owned())
            .unwrap_or_else(|_| value.to_string())
    }

    fn transition_kind(value: i32) -> String {
        use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::AlarmTransitionKind;
        AlarmTransitionKind::try_from(value)
            .map(|kind| kind.as_str_name().to_owned())
            .unwrap_or_else(|_| value.to_string())
    }

    fn provider_mode(value: i32) -> String {
        use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::AlarmProviderMode;
        AlarmProviderMode::try_from(value)
            .map(|mode| mode.as_str_name().to_owned())
            .unwrap_or_else(|_| value.to_string())
    }
}

/// Render an [`AcknowledgeAlarmReply`] as a terse line or a JSON document.
fn print_acknowledge_alarm_reply(
    reply: &zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::AcknowledgeAlarmReply,
    use_json: bool,
) {
    if use_json {
        println!(
            "{}",
            json!({
                "operation": "acknowledge-alarm",
                "correlationId": reply.correlation_id,
                "protocolStatus": reply.protocol_status.as_ref().map(|status| json!({
                    "code": status.code,
                    "message": status.message,
                })),
                "hresult": reply.hresult,
                "diagnosticMessage": reply.diagnostic_message,
            })
        );
    } else {
        println!("acknowledge-alarm completed");
    }
}

/// Parse a small but practically-complete subset of RFC3339:
/// `YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|+HH:MM|-HH:MM]`. Returns the
/// corresponding `prost_types::Timestamp` (Unix seconds + nanoseconds).
fn parse_rfc3339_timestamp(input: &str) -> Result<prost_types::Timestamp, Error> {
    fn invalid(detail: impl Into<String>) -> Error {
        Error::InvalidArgument {
            name: "last-seen-deploy-time".to_owned(),
            detail: detail.into(),
        }
    }

    let bytes = input.as_bytes();
    if bytes.len() < 20 || (bytes[10] != b'T' && bytes[10] != b't') {
        return Err(invalid(format!(
            "expected RFC3339 timestamp like 2026-04-28T15:30:00Z, got {input:?}"
        )));
    }

    let read_u32 = |start: usize, len: usize| -> Result<u32, Error> {
        std::str::from_utf8(&bytes[start..start + len])
            .ok()
            .and_then(|slice| slice.parse::<u32>().ok())
            .ok_or_else(|| invalid(format!("non-numeric digits at byte {start}")))
    };

    let year = read_u32(0, 4)? as i32;
    if bytes[4] != b'-' {
        return Err(invalid("expected '-' after year"));
    }
    let month = read_u32(5, 2)?;
    if bytes[7] != b'-' {
        return Err(invalid("expected '-' after month"));
    }
    let day = read_u32(8, 2)?;
    let hour = read_u32(11, 2)?;
    if bytes[13] != b':' {
        return Err(invalid("expected ':' after hour"));
    }
    let minute = read_u32(14, 2)?;
    if bytes[16] != b':' {
        return Err(invalid("expected ':' after minute"));
    }
    let second = read_u32(17, 2)?;

    let mut cursor = 19usize;
    let mut nanos: u32 = 0;
    if cursor < bytes.len() && bytes[cursor] == b'.' {
        cursor += 1;
        let frac_start = cursor;
        while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
            cursor += 1;
        }
        let frac_len = cursor - frac_start;
        if frac_len == 0 {
            return Err(invalid("expected fractional digits after '.'"));
        }
        let take = frac_len.min(9);
        let frac = std::str::from_utf8(&bytes[frac_start..frac_start + take])
            .ok()
            .and_then(|slice| slice.parse::<u32>().ok())
            .ok_or_else(|| invalid("invalid fractional digits"))?;
        nanos = frac * 10u32.pow(9u32.saturating_sub(take as u32));
    }

    let mut offset_seconds: i64 = 0;
    if cursor >= bytes.len() {
        return Err(invalid("missing timezone designator (Z or +HH:MM)"));
    }
    match bytes[cursor] {
        b'Z' | b'z' => cursor += 1,
        sign @ (b'+' | b'-') => {
            cursor += 1;
            if cursor + 5 > bytes.len() {
                return Err(invalid("offset must be ±HH:MM"));
            }
            let oh = std::str::from_utf8(&bytes[cursor..cursor + 2])
                .ok()
                .and_then(|slice| slice.parse::<i64>().ok())
                .ok_or_else(|| invalid("invalid offset hour"))?;
            if bytes[cursor + 2] != b':' {
                return Err(invalid("offset must contain ':' between HH and MM"));
            }
            let om = std::str::from_utf8(&bytes[cursor + 3..cursor + 5])
                .ok()
                .and_then(|slice| slice.parse::<i64>().ok())
                .ok_or_else(|| invalid("invalid offset minute"))?;
            cursor += 5;
            let signed = if sign == b'-' { -1 } else { 1 };
            offset_seconds = signed * (oh * 3600 + om * 60);
        }
        other => {
            return Err(invalid(format!(
                "unexpected timezone designator byte {other:?}"
            )));
        }
    }
    if cursor != bytes.len() {
        return Err(invalid("trailing characters after timezone"));
    }

    let unix = ymdhms_to_unix(year, month, day, hour, minute, second)?;
    let seconds = unix - offset_seconds;

    Ok(prost_types::Timestamp {
        seconds,
        nanos: nanos as i32,
    })
}

fn ymdhms_to_unix(
    year: i32,
    month: u32,
    day: u32,
    hour: u32,
    minute: u32,
    second: u32,
) -> Result<i64, Error> {
    if !(1..=12).contains(&month) || day < 1 || hour > 23 || minute > 59 || second > 60 {
        return Err(Error::InvalidArgument {
            name: "last-seen-deploy-time".to_owned(),
            detail: "calendar component out of range".to_owned(),
        });
    }
    fn is_leap(year: i32) -> bool {
        (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
    }
    const DAYS: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    let mut max = DAYS[(month - 1) as usize];
    if month == 2 && is_leap(year) {
        max = 29;
    }
    if day > max {
        return Err(Error::InvalidArgument {
            name: "last-seen-deploy-time".to_owned(),
            detail: format!("day {day} out of range for month {month}/{year}"),
        });
    }

    // Days from 1970-01-01 to year-month-day.
    let mut total_days: i64 = 0;
    if year >= 1970 {
        for y in 1970..year {
            total_days += if is_leap(y) { 366 } else { 365 };
        }
    } else {
        for y in year..1970 {
            total_days -= if is_leap(y) { 366 } else { 365 };
        }
    }
    for m in 1..month {
        let mut dim = DAYS[(m - 1) as usize];
        if m == 2 && is_leap(year) {
            dim = 29;
        }
        total_days += dim as i64;
    }
    total_days += (day - 1) as i64;

    Ok(total_days * 86_400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64)
}

fn parse_cli_value<T>(value: &str) -> Result<T, Error>
where
    T: std::str::FromStr,
    T::Err: std::fmt::Display,
{
    value.parse::<T>().map_err(|source| Error::InvalidArgument {
        name: "value".to_owned(),
        detail: source.to_string(),
    })
}

#[cfg(test)]
mod tests {
    use clap::Parser;

    use super::{Cli, Command};

    /// Pull the flattened `ConnectionArgs` out of a parsed `ping` command so
    /// `ConnectionArgs::options()` can be exercised directly.
    fn connection_from_ping(args: &[&str]) -> super::ConnectionArgs {
        let mut full = vec!["mxgw", "ping"];
        full.extend_from_slice(args);
        match Cli::try_parse_from(full).expect("ping parse").command {
            Command::Ping { connection, .. } => connection,
            other => panic!("expected ping command, got {other:?}"),
        }
    }

    #[test]
    fn parses_version_json_command() {
        let parsed = Cli::try_parse_from(["mxgw", "version", "--json"]);
        assert!(parsed.is_ok());
    }

    #[test]
    fn connection_defaults_to_plaintext() {
        let options = connection_from_ping(&[]).options();
        assert!(options.plaintext(), "default channel should be plaintext");
    }

    #[test]
    fn connection_tls_flag_disables_plaintext() {
        let options = connection_from_ping(&["--tls"]).options();
        assert!(
            !options.plaintext(),
            "--tls must select an encrypted channel"
        );
    }

    #[test]
    fn connection_plaintext_flag_selects_plaintext() {
        let options = connection_from_ping(&["--plaintext"]).options();
        assert!(options.plaintext());
    }

    #[test]
    fn connection_rejects_tls_and_plaintext_together() {
        let parsed = Cli::try_parse_from(["mxgw", "ping", "--tls", "--plaintext"]);
        assert!(
            parsed.is_err(),
            "--tls and --plaintext must conflict so TLS cannot be silently downgraded"
        );
    }

    #[test]
    fn parses_write_command() {
        let parsed = Cli::try_parse_from([
            "mxgw",
            "write",
            "--session-id",
            "session-1",
            "--server-handle",
            "12",
            "--item-handle",
            "34",
            "--value-type",
            "int32",
            "--value",
            "123",
        ]);
        assert!(parsed.is_ok());
    }

    #[test]
    fn version_json_output_has_protocol_versions() {
        let value = super::version_json();

        assert_eq!(value["gatewayProtocolVersion"], 3);
        assert_eq!(value["workerProtocolVersion"], 1);
    }

    #[test]
    fn parses_authenticate_user_command_with_password_env() {
        let parsed = Cli::try_parse_from([
            "mxgw",
            "authenticate-user",
            "--session-id",
            "session-1",
            "--server-handle",
            "7",
            "--verify-user",
            "verifier",
            "--password-env",
            "MY_PW_VAR",
        ]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    /// CLI-45: `--password-env` must default to the canonical
    /// `MXGATEWAY_VERIFY_PASSWORD` shared by every official client CLI.
    #[test]
    fn authenticate_user_password_env_defaults_to_canonical_name() {
        let parsed = Cli::try_parse_from([
            "mxgw",
            "authenticate-user",
            "--session-id",
            "session-1",
            "--server-handle",
            "7",
            "--verify-user",
            "verifier",
        ])
        .expect("parse");
        match parsed.command {
            Command::AuthenticateUser { password_env, .. } => {
                assert_eq!(password_env, "MXGATEWAY_VERIFY_PASSWORD");
            }
            other => panic!("expected authenticate-user, got {other:?}"),
        }
    }

    /// CLI-45: a credential that resolves to an empty string — whether from the
    /// flag or from the named environment variable — is treated as missing, so
    /// the CLI never sends a fabricated empty password to the wire. The usage
    /// error names the flag and the variable, never a value.
    #[test]
    fn resolve_verify_user_password_rejects_missing_and_empty_values() {
        const ABSENT: &str = "MXGW_CLI45_ABSENT_PASSWORD_VAR";
        const EMPTY: &str = "MXGW_CLI45_EMPTY_PASSWORD_VAR";
        const PRESENT: &str = "MXGW_CLI45_PRESENT_PASSWORD_VAR";
        std::env::remove_var(ABSENT);
        std::env::set_var(EMPTY, "");
        std::env::set_var(PRESENT, "env-sourced-credential");

        for (password, env_name) in [
            (None, ABSENT),
            (Some(String::new()), ABSENT),
            (None, EMPTY),
            (Some(String::new()), EMPTY),
        ] {
            let error = super::resolve_verify_user_password(password, env_name)
                .expect_err("empty or missing credential must be a usage error");
            let rendered = error.to_string();
            assert!(rendered.contains("--password"), "{rendered}");
            assert!(rendered.contains(env_name), "{rendered}");
        }

        assert_eq!(
            super::resolve_verify_user_password(None, PRESENT).expect("env credential"),
            "env-sourced-credential"
        );
        assert_eq!(
            super::resolve_verify_user_password(Some("flag-credential".to_owned()), EMPTY)
                .expect("flag credential"),
            "flag-credential"
        );
    }

    #[test]
    fn parses_write_secured_command() {
        let parsed = Cli::try_parse_from([
            "mxgw",
            "write-secured",
            "--session-id",
            "session-1",
            "--server-handle",
            "12",
            "--item-handle",
            "34",
            "--value-type",
            "int32",
            "--value",
            "5",
            "--current-user-id",
            "1",
            "--verifier-user-id",
            "2",
        ]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    #[test]
    fn parses_unregister_command() {
        let parsed = Cli::try_parse_from([
            "mxgw",
            "unregister",
            "--session-id",
            "session-1",
            "--server-handle",
            "12",
        ]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    #[test]
    fn parses_stream_alarms_command() {
        let parsed = Cli::try_parse_from([
            "mxgw",
            "stream-alarms",
            "--filter-prefix",
            "Tank01",
            "--max-events",
            "3",
            "--json",
        ]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    #[test]
    fn parses_stream_alarms_command_without_filter_prefix() {
        let parsed = Cli::try_parse_from(["mxgw", "stream-alarms"]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    #[test]
    fn parses_acknowledge_alarm_command() {
        let parsed = Cli::try_parse_from([
            "mxgw",
            "acknowledge-alarm",
            "--reference",
            "Tank01.Level.HiHi",
            "--comment",
            "ack from cli",
            "--operator",
            "operator1",
        ]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    #[test]
    fn acknowledge_alarm_requires_reference() {
        let parsed = Cli::try_parse_from(["mxgw", "acknowledge-alarm"]);
        assert!(parsed.is_err());
    }

    #[test]
    fn parses_galaxy_watch_command_with_last_seen_and_max_events() {
        let parsed = Cli::try_parse_from([
            "mxgw",
            "galaxy",
            "watch",
            "--last-seen-deploy-time",
            "2026-04-28T15:30:00Z",
            "--max-events",
            "5",
            "--json",
        ]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    #[test]
    fn parses_galaxy_watch_deploy_events_alias() {
        let parsed = Cli::try_parse_from(["mxgw", "galaxy", "watch-deploy-events"]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    #[test]
    fn parses_galaxy_browse_command_with_filters_and_depth() {
        let parsed = Cli::try_parse_from([
            "mxgw",
            "galaxy",
            "browse",
            "--parent-gobject-id",
            "42",
            "--category-id",
            "3",
            "--category-id",
            "5",
            "--template-contains",
            "$DelmiaReceiver",
            "--tag-name-glob",
            "Recv_*",
            "--include-attributes",
            "--alarm-bearing-only",
            "--depth",
            "2",
            "--json",
        ]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    #[test]
    fn parses_galaxy_browse_command_with_defaults() {
        let parsed = Cli::try_parse_from(["mxgw", "galaxy", "browse"]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    #[test]
    fn parses_batch_command() {
        let parsed = Cli::try_parse_from(["mxgw", "batch"]);
        assert!(parsed.is_ok(), "parse failed: {parsed:?}");
    }

    #[test]
    fn batch_eor_marker_is_stable() {
        assert_eq!(super::BATCH_EOR, "__MXGW_BATCH_EOR__");
    }

    #[test]
    fn bench_percentile_summary_matches_hand_built_sample() {
        // Hand-built sample with 5 values: 1, 2, 3, 4, 5.
        let sample: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0];
        let summary = super::percentile_summary(&sample);

        assert_eq!(summary.max, 5.0);
        // Mean = 15/5 = 3.0
        assert!((summary.mean - 3.0).abs() < f64::EPSILON);
        // p50: rank = 0.5 * 4 = 2 -> sorted[2] = 3.0
        assert!((summary.p50 - 3.0).abs() < f64::EPSILON);
        // p95: rank = 0.95 * 4 = 3.8 -> 4.0 + 0.8 * (5.0 - 4.0) = 4.8
        assert!((summary.p95 - 4.8).abs() < f64::EPSILON);
        // p99: rank = 0.99 * 4 = 3.96 -> 4.0 + 0.96 * 1.0 = 4.96
        assert!((summary.p99 - 4.96).abs() < f64::EPSILON);
    }

    #[test]
    fn bench_percentile_summary_handles_empty_sample() {
        let summary = super::percentile_summary(&[]);
        assert_eq!(summary.p50, 0.0);
        assert_eq!(summary.p95, 0.0);
        assert_eq!(summary.p99, 0.0);
        assert_eq!(summary.max, 0.0);
        assert_eq!(summary.mean, 0.0);
    }

    #[test]
    fn bench_percentile_summary_handles_single_value_sample() {
        let summary = super::percentile_summary(&[42.0]);
        assert_eq!(summary.p50, 42.0);
        assert_eq!(summary.p95, 42.0);
        assert_eq!(summary.p99, 42.0);
        assert_eq!(summary.max, 42.0);
        assert_eq!(summary.mean, 42.0);
    }

    #[test]
    fn register_page_token_accepts_distinct_tokens_and_rejects_repeats() {
        let mut seen = std::collections::HashSet::new();
        assert!(super::register_page_token(&mut seen, "tok-1").is_ok());
        assert!(super::register_page_token(&mut seen, "tok-2").is_ok());

        let repeated = super::register_page_token(&mut seen, "tok-1");
        match repeated {
            Err(super::Error::InvalidArgument { name, detail }) => {
                assert_eq!(name, "page_token");
                assert!(detail.contains("tok-1"), "detail: {detail}");
            }
            other => panic!("expected InvalidArgument on repeated token, got {other:?}"),
        }
    }

    #[test]
    fn rfc3339_parser_rejects_trailing_characters() {
        let err = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Zextra");
        assert!(err.is_err(), "trailing chars after Z must be rejected");
    }

    #[test]
    fn rfc3339_parser_rejects_day_zero() {
        let err = super::parse_rfc3339_timestamp("2026-04-00T15:30:00Z");
        assert!(err.is_err(), "day 0 must be rejected");
    }

    #[test]
    fn rfc3339_parser_rejects_month_thirteen() {
        let err = super::parse_rfc3339_timestamp("2026-13-01T15:30:00Z");
        assert!(err.is_err(), "month 13 must be rejected");
    }

    #[test]
    fn rfc3339_parser_rejects_day_out_of_range_for_month() {
        // April has 30 days.
        let err = super::parse_rfc3339_timestamp("2026-04-31T15:30:00Z");
        assert!(err.is_err(), "April 31 must be rejected");
        // February 29 in a non-leap year.
        let feb = super::parse_rfc3339_timestamp("2025-02-29T00:00:00Z");
        assert!(feb.is_err(), "Feb 29 in a non-leap year must be rejected");
    }

    #[test]
    fn rfc3339_parser_round_trips_z_and_offset_inputs() {
        // 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)
        let utc = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Z").unwrap();
        let plus = super::parse_rfc3339_timestamp("2026-04-28T16:30:00+01:00").unwrap();
        let frac = super::parse_rfc3339_timestamp("2026-04-28T15:30:00.250Z").unwrap();

        assert_eq!(utc.seconds, plus.seconds);
        assert_eq!(utc.nanos, 0);
        assert_eq!(frac.seconds, utc.seconds);
        assert_eq!(frac.nanos, 250_000_000);
    }

    #[test]
    fn alarm_feed_provider_status_renders_in_summary_and_json() {
        use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
            alarm_feed_message, AlarmFeedMessage, AlarmProviderMode, AlarmProviderStatus,
        };

        let message = AlarmFeedMessage {
            payload: Some(alarm_feed_message::Payload::ProviderStatus(
                AlarmProviderStatus {
                    mode: AlarmProviderMode::Subtag as i32,
                    degraded: true,
                    reason: "alarmmgr unavailable".to_owned(),
                    since: Some(prost_types::Timestamp {
                        seconds: 1_777_995_000,
                        nanos: 0,
                    }),
                },
            )),
        };

        let summary = super::alarm_feed_message_summary(&message);
        assert!(summary.contains("provider-status"), "summary: {summary}");
        assert!(
            summary.contains("ALARM_PROVIDER_MODE_SUBTAG"),
            "summary: {summary}"
        );
        assert!(summary.contains("degraded=true"), "summary: {summary}");

        let value = super::alarm_feed_message_to_json(&message);
        let provider = &value["providerStatus"];
        assert_eq!(provider["mode"], "ALARM_PROVIDER_MODE_SUBTAG");
        assert_eq!(provider["degraded"], true);
        assert_eq!(provider["reason"], "alarmmgr unavailable");
        assert_eq!(provider["since"]["seconds"], 1_777_995_000_i64);
    }

    #[test]
    fn alarm_feed_snapshot_status_renders_in_summary_and_json() {
        use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
            alarm_feed_message, AlarmFeedMessage, AlarmSnapshotStatus,
        };

        let message = AlarmFeedMessage {
            payload: Some(alarm_feed_message::Payload::SnapshotStatus(
                AlarmSnapshotStatus { truncated: true },
            )),
        };

        let summary = super::alarm_feed_message_summary(&message);
        assert_eq!(summary, "snapshot-status truncated=true");

        let value = super::alarm_feed_message_to_json(&message);
        assert_eq!(value["snapshotStatus"]["truncated"], true);
    }
}
