"""Command line interface for the MXAccess Gateway Python client."""

from __future__ import annotations

import asyncio
import contextlib
import io
import json
import logging
import os
import sys
import time
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import Any

import click
from google.protobuf.json_format import MessageToDict

from zb_mom_ww_mxgateway import __version__
from zb_mom_ww_mxgateway.auth import redact_secret
from zb_mom_ww_mxgateway.client import GatewayClient
from zb_mom_ww_mxgateway.errors import MxGatewayError
from zb_mom_ww_mxgateway.events import ReplayGap
from zb_mom_ww_mxgateway.galaxy import GalaxyRepositoryClient
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
from zb_mom_ww_mxgateway.options import BrowseChildrenOptions, ClientOptions
from zb_mom_ww_mxgateway.values import MxValueInput, to_mx_value

logger = logging.getLogger(__name__)

MAX_AGGREGATE_EVENTS = 10_000

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

_BATCH_EOR = "__MXGW_BATCH_EOR__"


@click.group()
def main() -> None:
    """MXAccess Gateway Python test CLI."""


@main.command()
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def version(output_json: bool) -> None:
    """Print client package version information."""

    payload = {
        "client": "mxgw-py",
        "package": "mxaccess-gateway-client",
        "version": __version__,
    }
    _emit(payload, output_json=output_json, text=f"mxgw-py {__version__}")


@main.command()
def batch() -> None:
    """Read commands from stdin and execute each, writing output + __MXGW_BATCH_EOR__ after each.

    Each non-empty line of stdin is a complete argument string (no quoting support — the
    harness never passes whitespace-containing arguments). Lines are split on runs of ASCII
    whitespace and dispatched through the normal CLI parser. On EOF or an empty line, exit 0.

    Errors do NOT terminate the loop. Each command's output (including any error JSON) is
    written to stdout followed by a line containing exactly ``__MXGW_BATCH_EOR__``, then
    stdout is flushed. Error output is formatted as ``{"error": "...", "type": "..."}``.

    Recursive ``batch`` lines are rejected (Client.Python-024) — re-entering the batch
    dispatcher would silently spawn a nested loop reading from the same exhausted stdin.
    """

    for raw_line in sys.stdin:
        line = raw_line.rstrip("\n").rstrip("\r")
        if not line:
            # Empty line signals clean exit (matches the spec and .NET behaviour).
            break

        args = line.split()

        # Reject a recursive `batch` line outright: the nested invocation would
        # read from the already-exhausted stdin (or, depending on harness, the
        # same stream the outer batch is consuming line-by-line) and silently
        # exit. Surface it as an explicit error block so callers can audit the
        # mis-routed line.
        if args and args[0] == "batch":
            _batch_write_error(
                "RecursiveBatchError",
                "nested 'batch' invocation is not allowed inside batch mode",
            )
            _batch_flush_eor()
            continue

        _dispatch_batch_line(args)


def _dispatch_batch_line(args: list[str]) -> None:
    """Run a single batch line through the Click parser directly (no CliRunner).

    Captures the subcommand's stdout via :func:`contextlib.redirect_stdout` and
    synthesises the standard ``{"error": ..., "type": ...}`` shape on failure.
    Click exceptions (`ClickException`, `UsageError`) are caught and rendered;
    `SystemExit(0)` from a Click command is treated as a clean exit, while a
    non-zero `SystemExit` is rendered as a CLI error. All other exceptions are
    captured and rendered as `{"error": str(exc), "type": exc.__class__.__name__}`
    so the loop never dies.
    """

    buffer = io.StringIO()
    exit_code = 0
    exc: BaseException | None = None
    try:
        with contextlib.redirect_stdout(buffer):
            try:
                # `standalone_mode=False` makes Click raise instead of calling
                # `sys.exit`; we still need to handle SystemExit because some
                # commands explicitly raise it (or `click.UsageError` converts
                # to a SystemExit under some entry-point paths).
                main.main(args=args, standalone_mode=False, prog_name="mxgw-py")
            except click.exceptions.Exit as click_exit:
                exit_code = click_exit.exit_code
            except click.ClickException as click_exc:
                exit_code = click_exc.exit_code
                exc = click_exc
                click.echo(f"Error: {click_exc.format_message()}", err=False)
            except SystemExit as sys_exit:
                code = sys_exit.code
                exit_code = int(code) if isinstance(code, int) else (0 if code is None else 1)
    except Exception as captured:  # noqa: BLE001 — never let batch loop die
        exc = captured
        exit_code = 1

    output = buffer.getvalue()
    if exit_code == 0 and exc is None:
        sys.stdout.write(output)
    else:
        if output.lstrip().startswith("{"):
            # Inner command already emitted JSON (e.g. a structured error) —
            # relay verbatim.
            sys.stdout.write(output)
            if output and not output.endswith("\n"):
                sys.stdout.write("\n")
        elif exc is not None:
            _batch_write_error(type(exc).__name__, str(exc))
        else:
            msg = output.strip()
            if msg.startswith("Error: "):
                msg = msg[len("Error: "):]
            _batch_write_error("CliError", msg)

    _batch_flush_eor()


def _batch_write_error(exc_type: str, message: str) -> None:
    """Write a JSON error record to stdout in the standard batch error shape."""
    sys.stdout.write(json.dumps({"error": message, "type": exc_type}) + "\n")


def _batch_flush_eor() -> None:
    """Write the end-of-record sentinel and flush stdout."""
    sys.stdout.write(_BATCH_EOR + "\n")
    sys.stdout.flush()


def gateway_options(command: Callable[..., Any]) -> Callable[..., Any]:
    """Apply the shared gateway connection options to a Click command."""
    command = click.option("--endpoint", default="localhost:5000", show_default=True)(command)
    command = click.option("--api-key", default=None, help="Gateway API key.")(command)
    command = click.option(
        "--api-key-env",
        default=None,
        help="Environment variable containing the gateway API key.",
    )(command)
    command = click.option("--plaintext", is_flag=True, help="Use plaintext gRPC.")(command)
    command = click.option("--tls", "use_tls", is_flag=True, help="Use TLS gRPC.")(command)
    command = click.option("--ca-file", default=None, help="Custom root certificate file.")(command)
    command = click.option(
        "--require-certificate-validation",
        "require_certificate_validation",
        is_flag=True,
        help="Verify the TLS certificate against the system trust store "
        "instead of the lenient trust-on-first-use default.",
    )(command)
    command = click.option(
        "--server-name-override",
        default=None,
        help="TLS server name override for test environments.",
    )(command)
    command = click.option("--call-timeout", default=30.0, type=float, show_default=True)(command)
    command = click.option("--stream-timeout", default=None, type=float)(command)
    return command


@main.command("open-session")
@gateway_options
@click.option("--client-name", default="", help="Client session name.")
@click.option("--requested-backend", default="", help="Requested backend name.")
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def open_session(**kwargs: Any) -> None:
    """Open a gateway session."""

    _run(
        _open_session(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("close-session")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def close_session(**kwargs: Any) -> None:
    """Close a gateway session."""

    _run(
        _close_session(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command()
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--message", default="ping", show_default=True, help="Ping payload.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def ping(**kwargs: Any) -> None:
    """Send a diagnostic ping command."""

    _run(_ping(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command()
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--client-name", required=True, help="MXAccess client name.")
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def register(**kwargs: Any) -> None:
    """Invoke MXAccess Register."""

    _run(
        _register(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("add-item")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item", required=True, help="MXAccess item definition.")
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def add_item(**kwargs: Any) -> None:
    """Invoke MXAccess AddItem."""

    _run(
        _add_item(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command()
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def advise(**kwargs: Any) -> None:
    """Invoke MXAccess Advise."""

    _run(_advise(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command("advise-supervisory")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def advise_supervisory(**kwargs: Any) -> None:
    """Invoke MXAccess AdviseSupervisory."""

    _run(
        _advise_supervisory(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("write-secured")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
@click.option("--type", "value_type", default="string", show_default=True)
@click.option("--value", required=True, help="Value to write (credential-sensitive; never logged).")
@click.option("--current-user-id", default=0, type=int, show_default=True)
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def write_secured(**kwargs: Any) -> None:
    """Invoke MXAccess WriteSecured — a signed/verified write (credential-sensitive)."""

    _run(
        _write_secured(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs) + [kwargs.get("value")],
    )


@main.command("authenticate-user")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--verify-user", required=True, help="MXAccess user name to authenticate.")
@click.option(
    "--password",
    default=None,
    help="User password. Prefer --password-env so the secret is not visible on the command line.",
)
@click.option(
    "--password-env",
    default=DEFAULT_VERIFY_PASSWORD_ENV,
    show_default=True,
    help="Environment variable holding the user password.",
)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def authenticate_user(**kwargs: Any) -> None:
    """Invoke MXAccess AuthenticateUser — resolve a Galaxy user id (credential-sensitive)."""

    password = _resolve_password(kwargs)
    kwargs["password"] = password
    _run(
        _authenticate_user(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs) + [password],
    )


@main.command("subscribe-bulk")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--items", required=True, help="Comma-separated MXAccess item definitions.")
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def subscribe_bulk(**kwargs: Any) -> None:
    """Invoke MXAccess SubscribeBulk."""

    _run(
        _subscribe_bulk(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("unsubscribe-bulk")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def unsubscribe_bulk(**kwargs: Any) -> None:
    """Invoke MXAccess UnsubscribeBulk."""

    _run(
        _unsubscribe_bulk(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("read-bulk")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--items", required=True, help="Comma-separated MXAccess tag addresses.")
@click.option("--timeout-ms", default=0, type=int, show_default=True,
              help="Per-tag snapshot timeout in milliseconds. 0 = worker default.")
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def read_bulk(**kwargs: Any) -> None:
    """Invoke MXAccess ReadBulk — cached value when advised, snapshot otherwise."""

    _run(_read_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command("write-bulk")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
@click.option("--type", "value_type", default="string", show_default=True)
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
@click.option("--user-id", default=0, type=int, show_default=True)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def write_bulk(**kwargs: Any) -> None:
    """Invoke MXAccess WriteBulk — sequential Write per entry."""

    _run(_write_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command("write2-bulk")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
@click.option("--type", "value_type", default="string", show_default=True)
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
@click.option("--timestamp", required=True, help="ISO-8601 timestamp shared across all entries.")
@click.option("--user-id", default=0, type=int, show_default=True)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def write2_bulk(**kwargs: Any) -> None:
    """Invoke MXAccess Write2Bulk — timestamped sequential Write2 per entry."""

    _run(_write2_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command("write-secured-bulk")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
@click.option("--type", "value_type", default="string", show_default=True)
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
@click.option("--current-user-id", default=0, type=int, show_default=True)
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def write_secured_bulk(**kwargs: Any) -> None:
    """Invoke MXAccess WriteSecuredBulk — credential-sensitive."""

    _run(_write_secured_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command("write-secured2-bulk")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handles", required=True, help="Comma-separated MXAccess item handles.")
@click.option("--type", "value_type", default="string", show_default=True)
@click.option("--values", required=True, help="Comma-separated values, one per item handle.")
@click.option("--timestamp", required=True, help="ISO-8601 timestamp shared across all entries.")
@click.option("--current-user-id", default=0, type=int, show_default=True)
@click.option("--verifier-user-id", default=0, type=int, show_default=True)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def write_secured2_bulk(**kwargs: Any) -> None:
    """Invoke MXAccess WriteSecured2Bulk — timestamped + credential-sensitive."""

    _run(_write_secured2_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command("bench-read-bulk")
@gateway_options
@click.option("--client-name", default="mxgw-python-bench", show_default=True)
@click.option("--duration-seconds", default=30, type=int, show_default=True)
@click.option("--warmup-seconds", default=3, type=int, show_default=True)
@click.option("--bulk-size", default=6, type=int, show_default=True)
@click.option("--tag-start", default=1, type=int, show_default=True)
@click.option("--tag-prefix", default="TestMachine_", show_default=True)
@click.option("--tag-attribute", default="TestChangingInt", show_default=True)
@click.option("--timeout-ms", default=1500, type=int, show_default=True)
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def bench_read_bulk(**kwargs: Any) -> None:
    """Cross-language ReadBulk stress benchmark.

    Opens its own session, subscribes to bulk-size tags so the worker value
    cache populates from real OnDataChange events, runs ReadBulk in a tight
    loop for duration-seconds, and emits the shared JSON stats schema the
    scripts/bench-read-bulk.ps1 driver collates across all five clients.
    """

    _run(_bench_read_bulk(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command("stream-events")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--after-worker-sequence", default=0, type=int, show_default=True)
@click.option("--max-events", default=1, type=int, show_default=True)
@click.option("--timeout", default=5.0, type=float, show_default=True)
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def stream_events(**kwargs: Any) -> None:
    """Stream a bounded number of events."""

    _run(
        _stream_events(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("stream-alarms")
@gateway_options
@click.option("--filter-prefix", default="", help="Alarm-reference prefix filter.")
@click.option("--max-messages", default=1, type=int, show_default=True)
@click.option("--timeout", default=5.0, type=float, show_default=True)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def stream_alarms(**kwargs: Any) -> None:
    """Stream a bounded number of messages from the gateway's central alarm feed."""

    _run(
        _stream_alarms(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("acknowledge-alarm")
@gateway_options
@click.option("--reference", required=True, help="Alarm full reference to acknowledge.")
@click.option("--comment", default="", help="Acknowledgement comment.")
@click.option("--operator", default="", help="Operator user name.")
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def acknowledge_alarm(**kwargs: Any) -> None:
    """Acknowledge an active MXAccess alarm condition (session-less)."""

    _run(
        _acknowledge_alarm(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command()
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
@click.option("--type", "value_type", default="string", show_default=True)
@click.option("--value", required=True, help="Value to write.")
@click.option("--user-id", default=0, type=int, show_default=True)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def write(**kwargs: Any) -> None:
    """Invoke MXAccess Write."""

    _run(_write(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command()
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
@click.option("--type", "value_type", default="string", show_default=True)
@click.option("--value", required=True, help="Value to write.")
@click.option("--timestamp", required=True, help="ISO-8601 timestamp value.")
@click.option("--user-id", default=0, type=int, show_default=True)
@click.option("--correlation-id", default="", help="Client correlation id.")
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def write2(**kwargs: Any) -> None:
    """Invoke MXAccess Write2."""

    _run(_write2(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command()
@gateway_options
@click.option("--client-name", default="mxgw-py-smoke", show_default=True)
@click.option("--item", required=True, help="MXAccess item definition.")
@click.option("--max-events", default=1, type=int, show_default=True)
@click.option("--timeout", default=5.0, type=float, show_default=True)
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def smoke(**kwargs: Any) -> None:
    """Run a bounded open/register/add/advise/stream/close smoke flow."""

    _run(_smoke(**kwargs), output_json=kwargs["output_json"], secrets=_secrets(kwargs))


@main.command("galaxy-test-connection")
@gateway_options
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def galaxy_test_connection(**kwargs: Any) -> None:
    """Test whether the gateway can reach the Galaxy Repository DB."""

    _run(
        _galaxy_test_connection(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("galaxy-last-deploy")
@gateway_options
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def galaxy_last_deploy(**kwargs: Any) -> None:
    """Read the last Galaxy deploy timestamp."""

    _run(
        _galaxy_last_deploy(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("galaxy-discover")
@gateway_options
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def galaxy_discover(**kwargs: Any) -> None:
    """Enumerate the deployed Galaxy object hierarchy."""

    _run(
        _galaxy_discover(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("galaxy-browse")
@gateway_options
@click.option(
    "--parent-gobject-id",
    "parent_gobject_id",
    default=None,
    type=int,
    help=(
        "Fetch one level of this parent's direct children via BrowseChildren "
        "instead of the lazy root walk. Pass a gobject id >= 1. "
        "(gobject-id 0 is the server root sentinel — omit the flag to list root objects.) "
        "--depth is ignored when this option is set."
    ),
)
@click.option(
    "--depth",
    default=0,
    type=int,
    show_default=True,
    help="Eagerly expand the root nodes this many further levels before printing.",
)
@click.option(
    "--category-id",
    "category_ids",
    multiple=True,
    type=int,
    help="Restrict to objects whose category_id matches one of these ids (repeatable).",
)
@click.option(
    "--template-chain-contains",
    "template_chain_contains",
    multiple=True,
    help="Restrict to objects whose template chain contains this entry (repeatable).",
)
@click.option(
    "--tag-name-glob",
    "tag_name_glob",
    default=None,
    help="Restrict to objects whose tag name matches this glob.",
)
@click.option(
    "--include-attributes",
    "include_attributes",
    is_flag=True,
    help="Include each object's attribute metadata in the browse results.",
)
@click.option(
    "--alarm-bearing-only",
    "alarm_bearing_only",
    is_flag=True,
    help="Only return objects that own at least one alarm-bearing attribute.",
)
@click.option(
    "--historized-only",
    "historized_only",
    is_flag=True,
    help="Only return objects that own at least one historized attribute.",
)
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def galaxy_browse(**kwargs: Any) -> None:
    """Browse the deployed Galaxy object hierarchy as a lazy-expanded tree."""

    _run(
        _galaxy_browse(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


@main.command("galaxy-watch")
@gateway_options
@click.option(
    "--last-seen-deploy-time",
    "last_seen_deploy_time",
    default=None,
    help="ISO-8601 timestamp; when it matches the current cached deploy time the "
    "bootstrap event is suppressed.",
)
@click.option(
    "--max-events",
    default=1,
    type=int,
    show_default=True,
    help="Stop after collecting this many deploy events.",
)
@click.option(
    "--timeout",
    default=5.0,
    type=float,
    show_default=True,
    help="Seconds to wait for each event before stopping.",
)
@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
def galaxy_watch(**kwargs: Any) -> None:
    """Stream a bounded number of Galaxy deploy events."""

    _run(
        _galaxy_watch(**kwargs),
        output_json=kwargs["output_json"],
        secrets=_secrets(kwargs),
    )


async def _open_session(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        reply = await client.open_session_raw(
            pb.OpenSessionRequest(
                requested_backend=kwargs["requested_backend"],
                client_session_name=kwargs["client_name"],
                client_correlation_id=kwargs["correlation_id"],
            ),
        )
        return {"sessionId": reply.session_id, "rawReply": _message_dict(reply)}


async def _close_session(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        reply = await client.close_session_raw(
            pb.CloseSessionRequest(
                session_id=kwargs["session_id"],
                client_correlation_id=kwargs["correlation_id"],
            ),
        )
        return {"sessionId": reply.session_id, "rawReply": _message_dict(reply)}


async def _ping(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        reply = await client.invoke_raw(
            pb.MxCommandRequest(
                session_id=kwargs["session_id"],
                command=pb.MxCommand(
                    kind=pb.MX_COMMAND_KIND_PING,
                    ping=pb.PingCommand(message=kwargs["message"]),
                ),
            ),
        )
        return {"kind": "ping", "rawReply": _message_dict(reply)}


async def _register(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        server_handle = await session.register(
            kwargs["client_name"],
            correlation_id=kwargs["correlation_id"],
        )
        return {"serverHandle": server_handle}


async def _add_item(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        item_handle = await session.add_item(
            kwargs["server_handle"],
            kwargs["item"],
            correlation_id=kwargs["correlation_id"],
        )
        return {"itemHandle": item_handle}


async def _advise(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        await session.advise(
            kwargs["server_handle"],
            kwargs["item_handle"],
            correlation_id=kwargs["correlation_id"],
        )
        return {"ok": True}


async def _advise_supervisory(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        await session.advise_supervisory(
            kwargs["server_handle"],
            kwargs["item_handle"],
            correlation_id=kwargs["correlation_id"],
        )
        return {"ok": True}


async def _write_secured(**kwargs: Any) -> dict[str, Any]:
    value = _parse_value(kwargs["value"], kwargs["value_type"])
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        await session.write_secured(
            kwargs["server_handle"],
            kwargs["item_handle"],
            value,
            current_user_id=kwargs["current_user_id"],
            verifier_user_id=kwargs["verifier_user_id"],
            correlation_id=kwargs["correlation_id"],
        )
        return {"ok": True}


async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        user_id = await session.authenticate_user(
            kwargs["server_handle"],
            kwargs["verify_user"],
            kwargs["password"],
            correlation_id=kwargs["correlation_id"],
        )
        return {"userId": user_id}


def _resolve_password(kwargs: dict[str, Any]) -> str:
    """Resolve the authenticate-user password from --password or --password-env.

    Prefers the explicit flag, then falls back to the environment variable named
    by ``--password-env`` (default :data:`DEFAULT_VERIFY_PASSWORD_ENV`). A missing
    *or empty* value from either source is a usage error (CLI-45): the CLI never
    sends a fabricated empty credential to the wire. The error names the option
    and the variable only — the resolved secret is never echoed, and callers pass
    it into the ``secrets`` redaction list so it cannot leak through a surfaced
    error either.
    """

    env_name = kwargs.get("password_env") or DEFAULT_VERIFY_PASSWORD_ENV
    password = kwargs.get("password")
    if not password:
        password = os.environ.get(env_name)
    if not password:
        raise click.UsageError(
            f"a password is required via --password or the {env_name} environment variable"
        )
    return password


async def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        results = await session.subscribe_bulk(
            kwargs["server_handle"],
            _parse_string_list(kwargs["items"]),
            correlation_id=kwargs["correlation_id"],
        )
        return {"results": [_message_dict(result) for result in results]}


async def _unsubscribe_bulk(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        results = await session.unsubscribe_bulk(
            kwargs["server_handle"],
            _parse_int_list(kwargs["item_handles"]),
            correlation_id=kwargs["correlation_id"],
        )
        return {"results": [_message_dict(result) for result in results]}


async def _read_bulk(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        results = await session.read_bulk(
            kwargs["server_handle"],
            _parse_string_list(kwargs["items"]),
            timeout_ms=kwargs["timeout_ms"],
            correlation_id=kwargs["correlation_id"],
        )
        return {"results": [_message_dict(result) for result in results]}


def _build_write_bulk_entries(kwargs: dict[str, Any]):
    """Build (item_handle, MxValue) pairs for the bulk-write families.

    The CLI accepts a single ``--type`` plus ``--values`` (comma-separated
    string-encoded values, one per ``--item-handles`` entry). Returns the
    parsed item-handle list and the per-entry MxValue protobuf instances —
    callers wrap these into the appropriate per-entry message type.
    """

    handles = _parse_int_list(kwargs["item_handles"])
    value_texts = _parse_string_list(kwargs["values"], param_hint="--values")
    if len(handles) != len(value_texts):
        raise click.UsageError(
            f"item-handles count ({len(handles)}) does not match values count ({len(value_texts)})",
        )
    parsed = [_parse_value(text, kwargs["value_type"]) for text in value_texts]
    values = [to_mx_value(v) for v in parsed]
    return handles, values


async def _write_bulk(**kwargs: Any) -> dict[str, Any]:
    handles, values = _build_write_bulk_entries(kwargs)
    entries = [
        pb.WriteBulkEntry(item_handle=handle, user_id=kwargs["user_id"], value=value)
        for handle, value in zip(handles, values)
    ]
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        results = await session.write_bulk(
            kwargs["server_handle"],
            entries,
            correlation_id=kwargs["correlation_id"],
        )
        return {"results": [_message_dict(result) for result in results]}


async def _write2_bulk(**kwargs: Any) -> dict[str, Any]:
    handles, values = _build_write_bulk_entries(kwargs)
    timestamp_value = to_mx_value(_parse_datetime(kwargs["timestamp"]))
    entries = [
        pb.Write2BulkEntry(
            item_handle=handle,
            user_id=kwargs["user_id"],
            value=value,
            timestamp_value=timestamp_value,
        )
        for handle, value in zip(handles, values)
    ]
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        results = await session.write2_bulk(
            kwargs["server_handle"],
            entries,
            correlation_id=kwargs["correlation_id"],
        )
        return {"results": [_message_dict(result) for result in results]}


async def _write_secured_bulk(**kwargs: Any) -> dict[str, Any]:
    handles, values = _build_write_bulk_entries(kwargs)
    entries = [
        pb.WriteSecuredBulkEntry(
            item_handle=handle,
            current_user_id=kwargs["current_user_id"],
            verifier_user_id=kwargs["verifier_user_id"],
            value=value,
        )
        for handle, value in zip(handles, values)
    ]
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        results = await session.write_secured_bulk(
            kwargs["server_handle"],
            entries,
            correlation_id=kwargs["correlation_id"],
        )
        return {"results": [_message_dict(result) for result in results]}


async def _write_secured2_bulk(**kwargs: Any) -> dict[str, Any]:
    handles, values = _build_write_bulk_entries(kwargs)
    timestamp_value = to_mx_value(_parse_datetime(kwargs["timestamp"]))
    entries = [
        pb.WriteSecured2BulkEntry(
            item_handle=handle,
            current_user_id=kwargs["current_user_id"],
            verifier_user_id=kwargs["verifier_user_id"],
            value=value,
            timestamp_value=timestamp_value,
        )
        for handle, value in zip(handles, values)
    ]
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        results = await session.write_secured2_bulk(
            kwargs["server_handle"],
            entries,
            correlation_id=kwargs["correlation_id"],
        )
        return {"results": [_message_dict(result) for result in results]}


async def _bench_read_bulk(**kwargs: Any) -> dict[str, Any]:
    """ReadBulk stress benchmark — matches the .NET / Go / Rust / Java schema."""

    bulk_size = int(kwargs["bulk_size"])
    if bulk_size < 1:
        raise click.UsageError("bulk-size must be positive")
    duration_seconds = int(kwargs["duration_seconds"])
    warmup_seconds = int(kwargs["warmup_seconds"])
    tag_start = int(kwargs["tag_start"])
    tag_prefix = kwargs["tag_prefix"]
    tag_attribute = kwargs["tag_attribute"]
    timeout_ms = int(kwargs["timeout_ms"])
    client_name = kwargs["client_name"]
    tags = [f"{tag_prefix}{i:03d}.{tag_attribute}" for i in range(tag_start, tag_start + bulk_size)]

    async with await _connect(kwargs) as client:
        session = await client.open_session(client_session_name=client_name)
        server_handle = 0
        item_handles: list[int] = []
        try:
            server_handle = await session.register(client_name)
            subscribe_results = await session.subscribe_bulk(server_handle, tags)
            item_handles = [r.item_handle for r in subscribe_results if r.was_successful]

            # Warm-up window so JIT / connection pool / first-call costs are
            # amortised before the measurement window opens.
            warmup_deadline = time.perf_counter() + warmup_seconds
            while time.perf_counter() < warmup_deadline:
                await session.read_bulk(server_handle, tags, timeout_ms=timeout_ms)

            latencies_ms: list[float] = []
            total_results = 0
            cached_results = 0
            successful = 0
            failed = 0
            steady_start = time.perf_counter()
            steady_deadline = steady_start + duration_seconds
            while time.perf_counter() < steady_deadline:
                call_start = time.perf_counter()
                try:
                    results = await session.read_bulk(server_handle, tags, timeout_ms=timeout_ms)
                except Exception:
                    failed += 1
                    latencies_ms.append((time.perf_counter() - call_start) * 1000.0)
                    continue
                latencies_ms.append((time.perf_counter() - call_start) * 1000.0)
                successful += 1
                for r in results:
                    total_results += 1
                    if r.was_cached:
                        cached_results += 1
            steady_elapsed = time.perf_counter() - steady_start
            total_calls = successful + failed
            calls_per_second = total_calls / steady_elapsed if steady_elapsed > 0 else 0.0
        finally:
            if item_handles:
                try:
                    await session.unsubscribe_bulk(server_handle, item_handles)
                except Exception as exc:  # noqa: BLE001 — bench is best-effort
                    logger.warning("bench-read-bulk: unsubscribe_bulk cleanup failed: %s", exc)
            try:
                await session.close()
            except Exception as exc:  # noqa: BLE001 — bench is best-effort
                logger.warning("bench-read-bulk: session.close cleanup failed: %s", exc)

        return {
            "language": "python",
            "command": "bench-read-bulk",
            "endpoint": kwargs.get("endpoint"),
            "clientName": client_name,
            "bulkSize": bulk_size,
            "durationSeconds": duration_seconds,
            "warmupSeconds": warmup_seconds,
            "durationMs": int(steady_elapsed * 1000),
            "tags": tags,
            "totalCalls": total_calls,
            "successfulCalls": successful,
            "failedCalls": failed,
            "totalReadResults": total_results,
            "cachedReadResults": cached_results,
            "callsPerSecond": round(calls_per_second, 2),
            "latencyMs": _percentile_summary(latencies_ms),
        }


def _percentile_summary(sample: list[float]) -> dict[str, float]:
    if not sample:
        return {"p50": 0.0, "p95": 0.0, "p99": 0.0, "max": 0.0, "mean": 0.0}
    sorted_sample = sorted(sample)
    return {
        "p50": round(_percentile(sorted_sample, 0.50), 3),
        "p95": round(_percentile(sorted_sample, 0.95), 3),
        "p99": round(_percentile(sorted_sample, 0.99), 3),
        "max": round(sorted_sample[-1], 3),
        "mean": round(sum(sample) / len(sample), 3),
    }


def _percentile(sorted_sample: list[float], quantile: float) -> float:
    """Nearest-rank with linear interpolation; matches every other client."""
    n = len(sorted_sample)
    if n == 0:
        return 0.0
    if n == 1:
        return sorted_sample[0]
    rank = quantile * (n - 1)
    lower = int(rank)
    upper = min(lower + 1, n - 1)
    fraction = rank - lower
    return sorted_sample[lower] + (sorted_sample[upper] - sorted_sample[lower]) * fraction


async def _stream_events(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        events = await _collect_events(
            session.stream_events(after_worker_sequence=kwargs["after_worker_sequence"]),
            max_events=kwargs["max_events"],
            timeout=kwargs["timeout"],
        )
        return {"events": [_event_row(event) for event in events]}


async def _stream_alarms(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        messages = await _collect_alarm_messages(
            client.stream_alarms(
                pb.StreamAlarmsRequest(
                    client_correlation_id=kwargs["correlation_id"],
                    alarm_filter_prefix=kwargs["filter_prefix"],
                ),
            ),
            max_messages=kwargs["max_messages"],
            timeout=kwargs["timeout"],
        )
        return {"messages": [_message_dict(message) for message in messages]}


async def _acknowledge_alarm(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        reply = await client.acknowledge_alarm(
            pb.AcknowledgeAlarmRequest(
                client_correlation_id=kwargs["correlation_id"],
                alarm_full_reference=kwargs["reference"],
                comment=kwargs["comment"],
                operator_user=kwargs["operator"],
            ),
        )
        return _message_dict(reply)


async def _write(**kwargs: Any) -> dict[str, Any]:
    value = _parse_value(kwargs["value"], kwargs["value_type"])
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        await session.write(
            kwargs["server_handle"],
            kwargs["item_handle"],
            value,
            user_id=kwargs["user_id"],
            correlation_id=kwargs["correlation_id"],
        )
        return {"ok": True}


async def _write2(**kwargs: Any) -> dict[str, Any]:
    value = _parse_value(kwargs["value"], kwargs["value_type"])
    timestamp = _parse_datetime(kwargs["timestamp"])
    async with await _connect(kwargs) as client:
        session = _session(client, kwargs["session_id"])
        await session.write2(
            kwargs["server_handle"],
            kwargs["item_handle"],
            value,
            timestamp,
            user_id=kwargs["user_id"],
            correlation_id=kwargs["correlation_id"],
        )
        return {"ok": True}


async def _smoke(**kwargs: Any) -> dict[str, Any]:
    async with await _connect(kwargs) as client:
        session = await client.open_session(client_session_name=kwargs["client_name"])
        async with session:
            server_handle = await session.register(kwargs["client_name"])
            item_handle = await session.add_item(server_handle, kwargs["item"])
            await session.advise(server_handle, item_handle)
            events = await _collect_events(
                session.stream_events(),
                max_events=kwargs["max_events"],
                timeout=kwargs["timeout"],
            )
            return {
                "sessionId": session.session_id,
                "serverHandle": server_handle,
                "itemHandle": item_handle,
                "events": [_message_dict(event) for event in events],
            }


async def _galaxy_test_connection(**kwargs: Any) -> dict[str, Any]:
    async with await _connect_galaxy(kwargs) as galaxy:
        ok = await galaxy.test_connection()
        return {"command": "galaxy-test-connection", "ok": ok}


async def _galaxy_last_deploy(**kwargs: Any) -> dict[str, Any]:
    async with await _connect_galaxy(kwargs) as galaxy:
        last_deploy = await galaxy.get_last_deploy_time()
        payload: dict[str, Any] = {
            "command": "galaxy-last-deploy",
            "present": last_deploy is not None,
        }
        if last_deploy is not None:
            # galaxy.py returns a timezone-NAIVE UTC datetime (protobuf ToDatetime()).
            # Stamp it as UTC so the emitted ISO-8601 carries an unambiguous offset,
            # matching the Go client's "...Z" output.
            payload["timeOfLastDeploy"] = last_deploy.replace(tzinfo=timezone.utc).isoformat()
        return payload


async def _galaxy_discover(**kwargs: Any) -> dict[str, Any]:
    async with await _connect_galaxy(kwargs) as galaxy:
        objects = await galaxy.discover_hierarchy()
        return {
            "command": "galaxy-discover",
            "objects": [_message_dict(obj) for obj in objects],
        }


async def _galaxy_browse(**kwargs: Any) -> dict[str, Any]:
    depth = int(kwargs["depth"])
    if depth < 0 or depth > 50:
        raise click.BadParameter("--depth must be between 0 and 50", param_hint="--depth")
    parent_gobject_id: int | None = kwargs.get("parent_gobject_id")
    options = BrowseChildrenOptions(
        category_ids=tuple(kwargs.get("category_ids") or ()),
        template_chain_contains=tuple(kwargs.get("template_chain_contains") or ()),
        tag_name_glob=kwargs.get("tag_name_glob"),
        include_attributes=True if kwargs.get("include_attributes") else None,
        alarm_bearing_only=bool(kwargs.get("alarm_bearing_only")),
        historized_only=bool(kwargs.get("historized_only")),
    )
    async with await _connect_galaxy(kwargs) as galaxy:
        if parent_gobject_id is not None:
            # Single-level parent drill-down: drive BrowseChildren paging by hand
            # and return the children as a flat list.  --depth is not meaningful
            # here; warn if the caller set it so they know it was ignored.
            if depth > 0:
                click.echo(
                    "warning: --depth is ignored when --parent-gobject-id is specified",
                    err=True,
                )
            children = await _browse_children_one_level(galaxy, parent_gobject_id, options)
            return {
                "command": "galaxy-browse",
                "nodes": [_browse_child_dict(obj, hint) for obj, hint in children],
                "_text": _render_browse_children(children),
            }

        roots = await galaxy.browse(options)
        for root in roots:
            await _expand_to_depth(root, depth)
        return {
            "command": "galaxy-browse",
            "nodes": [_browse_node_dict(node) for node in roots],
            "_text": _render_browse_tree(roots),
        }


async def _expand_to_depth(node: Any, depth: int) -> None:
    """Recursively expand a LazyBrowseNode up to ``depth`` further levels.

    ``depth == 0`` leaves the node unexpanded so only the requested level is
    printed; each level beyond fetches and recurses into the loaded children.
    """

    if depth <= 0:
        return
    if node.has_children_hint:
        await node.expand()
    for child in node.children:
        await _expand_to_depth(child, depth - 1)


def _browse_node_dict(node: Any) -> dict[str, Any]:
    """Render one LazyBrowseNode (and any already-expanded descendants).

    Mirrors the ``galaxy-discover`` object shape with an added
    ``hasChildrenHint`` flag and a nested ``children`` array, matching the
    cross-client browse JSON surface.
    """

    payload = _message_dict(node.object)
    payload["hasChildrenHint"] = bool(node.has_children_hint)
    payload["children"] = (
        [_browse_node_dict(child) for child in node.children] if node.is_expanded else []
    )
    return payload


def _render_browse_tree(roots: list[Any]) -> str:
    """Render the lazy-browse roots as a node count plus an indented tree."""

    lines: list[str] = [str(len(roots))]
    for root in roots:
        _append_browse_node_lines(root, 0, lines)
    return "\n".join(lines)


def _append_browse_node_lines(node: Any, indent: int, lines: list[str]) -> None:
    obj = node.object
    marker = "+" if node.has_children_hint else "-"
    pad = " " * indent
    lines.append(f"{pad}{marker} {obj.tag_name} {obj.browse_name} (gobject {obj.gobject_id})")
    if node.is_expanded:
        for child in node.children:
            _append_browse_node_lines(child, indent + 2, lines)


_BROWSE_CHILDREN_PAGE_SIZE = 500


async def _browse_children_one_level(
    galaxy: Any,
    parent_gobject_id: int,
    options: BrowseChildrenOptions,
) -> list[tuple[Any, bool]]:
    """Page through BrowseChildren for ``parent_gobject_id`` and return (object, hint) pairs.

    Uses page size 500 (matching the library constant) and guards against a
    repeated page token to prevent an infinite loop if the server misbehaves.
    """

    results: list[tuple[Any, bool]] = []
    seen_page_tokens: set[str] = set()
    page_token = ""

    while True:
        request = galaxy_pb.BrowseChildrenRequest(
            parent_gobject_id=parent_gobject_id,
            page_size=_BROWSE_CHILDREN_PAGE_SIZE,
            page_token=page_token,
            alarm_bearing_only=options.alarm_bearing_only,
            historized_only=options.historized_only,
        )
        if options.category_ids:
            request.category_ids.extend(options.category_ids)
        if options.template_chain_contains:
            request.template_chain_contains.extend(options.template_chain_contains)
        if options.tag_name_glob:
            request.tag_name_glob = options.tag_name_glob
        if options.include_attributes is not None:
            request.include_attributes = options.include_attributes

        reply = await galaxy.browse_children_raw(request)

        for index, obj in enumerate(reply.children):
            hint = index < len(reply.child_has_children) and bool(reply.child_has_children[index])
            results.append((obj, hint))

        page_token = reply.next_page_token
        if not page_token:
            return results
        if page_token in seen_page_tokens:
            raise MxGatewayError(
                f"galaxy browse children returned repeated page token {page_token!r}"
            )
        seen_page_tokens.add(page_token)


def _browse_child_dict(obj: Any, has_children_hint: bool) -> dict[str, Any]:
    """Render one raw browse child as a node dict matching the lazy-browse shape.

    The ``children`` array is always empty — the parent drill-down path returns
    a flat single-level listing without recursive expansion.
    """

    payload = _message_dict(obj)
    payload["hasChildrenHint"] = has_children_hint
    payload["children"] = []
    return payload


def _render_browse_children(children: list[tuple[Any, bool]]) -> str:
    """Render a flat one-level child list as a count line plus marker lines."""

    lines: list[str] = [str(len(children))]
    for obj, has_children_hint in children:
        marker = "+" if has_children_hint else "-"
        lines.append(f"{marker} {obj.tag_name} {obj.browse_name} (gobject {obj.gobject_id})")
    return "\n".join(lines)


async def _galaxy_watch(**kwargs: Any) -> dict[str, Any]:
    last_seen = kwargs.get("last_seen_deploy_time")
    last_seen_dt = _parse_datetime(last_seen) if last_seen else None
    async with await _connect_galaxy(kwargs) as galaxy:
        events = await _collect_deploy_events(
            galaxy.watch_deploy_events(last_seen_dt),
            max_events=kwargs["max_events"],
            timeout=kwargs["timeout"],
        )
        return {
            "command": "galaxy-watch",
            "events": [_message_dict(event) for event in events],
        }


async def _connect(kwargs: dict[str, Any]) -> GatewayClient:
    api_key = kwargs.get("api_key") or _api_key_from_env(kwargs.get("api_key_env"))
    return await GatewayClient.connect(
        ClientOptions(
            endpoint=kwargs["endpoint"],
            api_key=api_key,
            plaintext=_use_plaintext(kwargs),
            ca_file=kwargs.get("ca_file"),
            require_certificate_validation=bool(kwargs.get("require_certificate_validation")),
            server_name_override=kwargs.get("server_name_override"),
            call_timeout=kwargs.get("call_timeout"),
            stream_timeout=kwargs.get("stream_timeout"),
        ),
    )


async def _connect_galaxy(kwargs: dict[str, Any]) -> GalaxyRepositoryClient:
    api_key = kwargs.get("api_key") or _api_key_from_env(kwargs.get("api_key_env"))
    return await GalaxyRepositoryClient.connect(
        ClientOptions(
            endpoint=kwargs["endpoint"],
            api_key=api_key,
            plaintext=_use_plaintext(kwargs),
            ca_file=kwargs.get("ca_file"),
            require_certificate_validation=bool(kwargs.get("require_certificate_validation")),
            server_name_override=kwargs.get("server_name_override"),
            call_timeout=kwargs.get("call_timeout"),
            stream_timeout=kwargs.get("stream_timeout"),
        ),
    )


def _session(client: GatewayClient, session_id: str):
    from zb_mom_ww_mxgateway.session import Session

    return Session(client=client, session_id=session_id)


def _use_plaintext(kwargs: dict[str, Any]) -> bool:
    """Resolve the plaintext / TLS contract from the CLI flags.

    TLS is the default. ``--plaintext`` is the only way to opt in to an
    unencrypted channel; ``--tls`` is accepted as a redundant explicit
    affirmation. Combining the two is a usage error (regression-guarded by
    Client.Python-023 — the previous silent ``localhost:`` /
    ``127.0.0.1:`` auto-plaintext branch leaked the API-key bearer over a
    plaintext channel when a user ran the gateway behind TLS on loopback).
    """

    plaintext = bool(kwargs.get("plaintext"))
    use_tls = bool(kwargs.get("use_tls"))
    if plaintext and use_tls:
        raise click.UsageError("--plaintext and --tls are mutually exclusive")
    return plaintext


def _api_key_from_env(name: str | None) -> str | None:
    if not name:
        return None
    return os.environ.get(name)


def _secrets(kwargs: dict[str, Any]) -> list[str | None]:
    return [
        kwargs.get("api_key"),
        _api_key_from_env(kwargs.get("api_key_env")),
    ]


def _run(
    awaitable: Awaitable[dict[str, Any]],
    *,
    output_json: bool,
    secrets: list[str | None],
) -> None:
    try:
        payload = asyncio.run(awaitable)
    except MxGatewayError as error:
        raise click.ClickException(redact_secret(str(error), secrets)) from error

    _emit(payload, output_json=output_json)


def _emit(
    payload: dict[str, Any],
    *,
    output_json: bool,
    text: str | None = None,
) -> None:
    # A payload may carry a pre-rendered text representation under the private
    # "_text" key (used by commands like galaxy-browse whose text output is a
    # custom indented tree rather than the default JSON dump). Strip it so it
    # never leaks into the JSON branch.
    rendered_text = payload.pop("_text", None) if isinstance(payload, dict) else None

    if output_json:
        click.echo(json.dumps(payload, sort_keys=True))
        return

    click.echo(text or rendered_text or json.dumps(payload, sort_keys=True))


async def _collect_events(
    events: Any,
    *,
    max_events: int,
    timeout: float,
) -> list[pb.MxEvent | ReplayGap]:
    if max_events > MAX_AGGREGATE_EVENTS:
        raise click.BadParameter(
            f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
            param_hint="--max-events",
        )

    collected: list[pb.MxEvent | ReplayGap] = []
    iterator = events.__aiter__()
    try:
        while len(collected) < max_events:
            collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
    except StopAsyncIteration:
        pass
    except asyncio.TimeoutError:
        pass
    finally:
        close = getattr(iterator, "aclose", None)
        if close is not None:
            await close()
    return collected


async def _collect_alarm_messages(
    messages: Any,
    *,
    max_messages: int,
    timeout: float,
) -> list[pb.AlarmFeedMessage]:
    if max_messages > MAX_AGGREGATE_EVENTS:
        raise click.BadParameter(
            f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
            param_hint="--max-messages",
        )

    collected: list[pb.AlarmFeedMessage] = []
    iterator = messages.__aiter__()
    try:
        while len(collected) < max_messages:
            collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
    except StopAsyncIteration:
        pass
    except asyncio.TimeoutError:
        pass
    finally:
        close = getattr(iterator, "aclose", None)
        if close is not None:
            await close()
    return collected


async def _collect_deploy_events(
    events: Any,
    *,
    max_events: int,
    timeout: float,
) -> list[galaxy_pb.DeployEvent]:
    if max_events > MAX_AGGREGATE_EVENTS:
        raise click.BadParameter(
            f"must be less than or equal to {MAX_AGGREGATE_EVENTS}",
            param_hint="--max-events",
        )

    collected: list[galaxy_pb.DeployEvent] = []
    iterator = events.__aiter__()
    try:
        while len(collected) < max_events:
            collected.append(await asyncio.wait_for(iterator.__anext__(), timeout=timeout))
    except StopAsyncIteration:
        pass
    except asyncio.TimeoutError:
        pass
    finally:
        close = getattr(iterator, "aclose", None)
        if close is not None:
            await close()
    return collected


def _parse_value(raw_value: str, value_type: str) -> MxValueInput:
    normalized = value_type.lower()
    if normalized == "bool":
        return raw_value.lower() in ("1", "true", "yes", "on")
    if normalized in ("int", "int32", "int64"):
        return int(raw_value)
    if normalized in ("float", "double"):
        return float(raw_value)
    if normalized in ("time", "timestamp"):
        return _parse_datetime(raw_value)
    if normalized == "raw":
        return raw_value.encode("utf-8")
    if normalized == "string":
        return raw_value
    raise click.BadParameter(f"unsupported value type: {value_type}", param_hint="--type")


def _parse_datetime(raw_value: str) -> datetime:
    if raw_value.endswith("Z"):
        raw_value = raw_value[:-1] + "+00:00"
    parsed = datetime.fromisoformat(raw_value)
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed


def _parse_string_list(raw_value: str, param_hint: str = "--items") -> list[str]:
    values = [item.strip() for item in raw_value.split(",") if item.strip()]
    if not values:
        raise click.BadParameter("at least one item is required", param_hint=param_hint)
    return values


def _parse_int_list(raw_value: str) -> list[int]:
    values = [item.strip() for item in raw_value.split(",") if item.strip()]
    if not values:
        raise click.BadParameter("at least one item handle is required", param_hint="--item-handles")
    try:
        return [int(item) for item in values]
    except ValueError as exc:
        raise click.BadParameter(
            f"item handles must be integers: {exc}", param_hint="--item-handles"
        ) from exc


def _message_dict(message: Any) -> dict[str, Any]:
    return MessageToDict(
        message,
        preserving_proto_field_name=False,
        use_integers_for_enums=False,
    )


def _event_row(item: Any) -> dict[str, Any]:
    """Render one item of an event stream as a JSON row.

    ``Session.stream_events`` yields ``MxEvent | ReplayGap``. ``ReplayGap`` is a
    plain dataclass, so it has no protobuf descriptor and cannot go through
    ``MessageToDict`` — it gets its own distinct row instead, matching the shape
    the Rust and Go CLIs emit so the cross-language matrix can compare rows.
    Keys are camelCase for the same reason ``_message_dict`` uses
    ``preserving_proto_field_name=False``. The gap is always rendered: never
    dropped, and never re-synthesized into an event.
    """

    if isinstance(item, ReplayGap):
        return {
            "replayGap": {
                "requestedAfterSequence": item.requested_after_sequence,
                "oldestAvailableSequence": item.oldest_available_sequence,
            },
        }

    return _message_dict(item)
