from __future__ import annotations

import argparse
import json
import os
import signal
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any

import yaml

from flownet.config import NodeRuntimeConfig
from flownet.platform.node_runtime import bootstrap_node_runtime


_BUILTIN_RESOURCE_KEYS = {"cpu", "memory_mb", "gpu", "gpu_mem_mb"}
_STATE_DIR_ENV = "FLOWNET_STATE_DIR"
_STATE_FILE_NAME = "node-runtime.json"
_DEFAULT_LOG_FILE_NAME = "node-runtime.log"


def _split_address(address: str) -> tuple[str, int]:
    if not isinstance(address, str) or ":" not in address:
        raise ValueError(f"Invalid address format: {address!r}. Expected host:port.")
    host, port_text = address.rsplit(":", 1)
    host = host.strip()
    if not host:
        raise ValueError(f"Invalid address host in {address!r}.")
    try:
        port = int(port_text)
    except ValueError as exc:  # pragma: no cover - defensive branch
        raise ValueError(f"Invalid address port in {address!r}.") from exc
    if port <= 0:
        raise ValueError(f"Address port must be > 0 in {address!r}.")
    return host, port


def _parse_node_ref(value: str) -> tuple[str, str]:
    if not isinstance(value, str) or "@" not in value:
        raise ValueError(
            f"Invalid node reference: {value!r}. Expected node_id@host:port."
        )
    node_id, address = value.split("@", 1)
    node_id = node_id.strip()
    if not node_id:
        raise ValueError(f"Invalid node reference: {value!r}. Missing node_id.")
    _split_address(address)
    return node_id, address


def _sanitize_meta_value(value: object) -> str:
    raw = str(value)
    return raw.replace("|", "_").replace(",", "_")


def _resource_meta_key(resource_key: str) -> str:
    key = resource_key.strip()
    if not key:
        raise ValueError("Resource key cannot be empty.")
    if key in _BUILTIN_RESOURCE_KEYS:
        return key
    if key.startswith("res_"):
        return key
    return f"res_{key}"


def parse_resource_flags(flags: list[str] | None) -> dict[str, str]:
    result: dict[str, str] = {}
    if not flags:
        return result
    for item in flags:
        if "=" not in item:
            raise ValueError(
                f"Invalid --resource value: {item!r}. Expected key=value."
            )
        key, value = item.split("=", 1)
        norm_key = _resource_meta_key(key)
        norm_value = _sanitize_meta_value(value.strip())
        if not norm_value:
            raise ValueError(
                f"Invalid --resource value: {item!r}. Resource value cannot be empty."
            )
        result[norm_key] = norm_value
    return result


def _state_dir() -> Path:
    override = os.environ.get(_STATE_DIR_ENV)
    if override:
        return Path(override).expanduser()
    return Path.home() / ".local" / "state" / "flownet"


def _state_file_path() -> Path:
    return _state_dir() / _STATE_FILE_NAME


def _default_log_file_path() -> Path:
    return _state_dir() / _DEFAULT_LOG_FILE_NAME


def _ensure_state_dir() -> Path:
    path = _state_dir()
    path.mkdir(parents=True, exist_ok=True)
    return path


def _pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    try:
        os.kill(pid, 0)
        return True
    except OSError:
        return False


def _tcp_probe_address(address: str, *, timeout_seconds: float) -> tuple[bool, str]:
    try:
        host, port = _split_address(address)
    except Exception as exc:
        return False, f"invalid-address: {exc}"
    timeout_seconds = max(0.1, float(timeout_seconds))
    try:
        with socket.create_connection((host, port), timeout=timeout_seconds):
            return True, "ok"
    except Exception as exc:
        return False, str(exc)


def _read_node_state() -> dict[str, Any] | None:
    path = _state_file_path()
    if not path.exists():
        return None
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return None
    if not isinstance(payload, dict):
        return None
    return payload


def _write_node_state(state: dict[str, Any]) -> None:
    _ensure_state_dir()
    path = _state_file_path()
    path.write_text(json.dumps(state, indent=2, ensure_ascii=True), encoding="utf-8")


def _clear_node_state() -> None:
    path = _state_file_path()
    try:
        path.unlink()
    except FileNotFoundError:
        return


def _build_local_cluster_nodes_env(
    *,
    node_id: str,
    address: str,
    seeds: list[str],
    zone: str | None = None,
    region: str | None = None,
    resources: dict[str, str] | None = None,
) -> str:
    if seeds:
        return build_join_nodes_env(
            local_node_id=node_id,
            local_address=address,
            seeds=seeds,
            zone=zone,
            region=region,
            resources=resources,
        )
    return _node_entry(
        node_id=node_id,
        address=address,
        zone=zone,
        region=region,
        resources=resources,
    )


def _node_entry(
    *,
    node_id: str,
    address: str,
    zone: str | None = None,
    region: str | None = None,
    resources: dict[str, str] | None = None,
) -> str:
    _split_address(address)
    parts = [f"{node_id}@{address}"]
    if zone:
        parts.append(f"zone={_sanitize_meta_value(zone.strip())}")
    if region:
        parts.append(f"region={_sanitize_meta_value(region.strip())}")
    for key in sorted((resources or {}).keys()):
        parts.append(f"{key}={_sanitize_meta_value(resources[key])}")
    return "|".join(parts)


def load_yaml_mapping(path: str) -> dict[str, Any]:
    payload = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
    if not isinstance(payload, dict):
        raise ValueError("Config YAML must deserialize to a mapping/object.")
    return payload


def _require_mapping(obj: Any, field_name: str) -> dict[str, Any]:
    if not isinstance(obj, dict):
        raise ValueError(f"{field_name} must be a mapping/object.")
    return obj


def _require_nodes(payload: dict[str, Any]) -> list[dict[str, Any]]:
    cluster = _require_mapping(payload.get("cluster"), "cluster")
    nodes = cluster.get("nodes")
    if not isinstance(nodes, list) or not nodes:
        raise ValueError("cluster.nodes must be a non-empty list.")
    normalized: list[dict[str, Any]] = []
    for index, item in enumerate(nodes):
        if not isinstance(item, dict):
            raise ValueError(f"cluster.nodes[{index}] must be a mapping/object.")
        normalized.append(item)
    return normalized


def select_cluster_node(payload: dict[str, Any], node_id: str) -> dict[str, Any]:
    for node in _require_nodes(payload):
        if str(node.get("id", "")).strip() == node_id:
            return node
    raise ValueError(f"Node id not found in cluster config: {node_id!r}.")


def _node_zone_region(node: dict[str, Any]) -> tuple[str | None, str | None]:
    labels = node.get("labels")
    if not isinstance(labels, dict):
        return None, None
    zone = labels.get("zone")
    region = labels.get("region")
    zone_text = str(zone).strip() if isinstance(zone, str) and zone.strip() else None
    region_text = (
        str(region).strip() if isinstance(region, str) and region.strip() else None
    )
    return zone_text, region_text


def _node_resources(node: dict[str, Any]) -> dict[str, str]:
    raw = node.get("resources")
    if not isinstance(raw, dict):
        return {}
    resources: dict[str, str] = {}
    for key, value in raw.items():
        key_text = str(key).strip()
        if not key_text:
            continue
        meta_key = _resource_meta_key(key_text)
        resources[meta_key] = _sanitize_meta_value(value)
    return resources


def build_cluster_nodes_env(payload: dict[str, Any]) -> str:
    entries: list[str] = []
    for node in _require_nodes(payload):
        node_id = str(node.get("id", "")).strip()
        address = str(node.get("address", "")).strip()
        if not node_id:
            raise ValueError("Each cluster node requires non-empty id.")
        if not address:
            raise ValueError(f"cluster node {node_id!r} requires non-empty address.")
        zone, region = _node_zone_region(node)
        resources = _node_resources(node)
        entries.append(
            _node_entry(
                node_id=node_id,
                address=address,
                zone=zone,
                region=region,
                resources=resources,
            )
        )
    return ",".join(entries)


def build_join_nodes_env(
    *,
    local_node_id: str,
    local_address: str,
    seeds: list[str],
    zone: str | None = None,
    region: str | None = None,
    resources: dict[str, str] | None = None,
) -> str:
    if not seeds:
        raise ValueError("Join mode requires at least one --seed node reference.")

    entries: list[str] = [
        _node_entry(
            node_id=local_node_id,
            address=local_address,
            zone=zone,
            region=region,
            resources=resources,
        )
    ]
    seen = {entries[0]}
    for seed in seeds:
        seed_node_id, seed_address = _parse_node_ref(seed)
        seed_entry = _node_entry(node_id=seed_node_id, address=seed_address)
        if seed_entry in seen:
            continue
        seen.add(seed_entry)
        entries.append(seed_entry)
    return ",".join(entries)


def _runtime_field(node: dict[str, Any], payload: dict[str, Any], key: str) -> Any:
    node_runtime = node.get("runtime")
    if isinstance(node_runtime, dict) and key in node_runtime:
        return node_runtime[key]
    root_runtime = payload.get("runtime")
    if isinstance(root_runtime, dict):
        return root_runtime.get(key)
    return None


def _node_runtime_config_from_cluster(
    payload: dict[str, Any],
    node_id: str,
) -> tuple[NodeRuntimeConfig, str | None, str | None]:
    node = select_cluster_node(payload, node_id)
    address = str(node.get("address", "")).strip()
    host, port = _split_address(address)
    threads_raw = _runtime_field(node, payload, "threads")
    threads = int(threads_raw) if threads_raw is not None else 4
    transport_mode = _runtime_field(node, payload, "transport_mode") or "auto"
    local_ipc_dir = _runtime_field(node, payload, "local_ipc_dir") or "/tmp/flownet"
    zone, region = _node_zone_region(node)
    config = NodeRuntimeConfig(
        host=host,
        port=port,
        threads=threads,
        node_id=node_id,
        transport_mode=str(transport_mode),
        local_ipc_dir=str(local_ipc_dir),
    )
    return config, zone, region


def _run_node_runtime(config: NodeRuntimeConfig, *, node_id: str) -> int:
    os.environ.setdefault("FLOWNET_RUNTIME_ROLE", "node")
    bootstrap_node_runtime(config, node_id=node_id)
    print(
        "[flownet] node runtime started",
        f"node_id={node_id}",
        f"address={config.host}:{config.port}",
    )
    try:
        threading.Event().wait()
    except KeyboardInterrupt:
        print("[flownet] shutdown requested")
    return 0


def _cmd_init_static(args: argparse.Namespace) -> int:
    if args.node_id is None or not str(args.node_id).strip():
        raise ValueError("--node-id is required for static init mode.")
    if args.config is None or not str(args.config).strip():
        raise ValueError("--config is required for static init mode.")

    payload = load_yaml_mapping(str(args.config))
    nodes_env = build_cluster_nodes_env(payload)
    config, zone, region = _node_runtime_config_from_cluster(
        payload,
        str(args.node_id).strip(),
    )
    os.environ["FLOWNET_CLUSTER_NODES"] = nodes_env
    if zone:
        os.environ["FLOWNET_NODE_ZONE"] = zone
    if region:
        os.environ["FLOWNET_NODE_REGION"] = region
    return _run_node_runtime(config, node_id=config.node_id)


def _cmd_init_join(args: argparse.Namespace) -> int:
    if args.node_id is None or not str(args.node_id).strip():
        raise ValueError("--node-id is required for join mode.")
    if args.bind is None:
        raise ValueError("--bind is required for join mode.")

    local_node_id = str(args.node_id).strip()
    host, port = _split_address(str(args.bind).strip())
    seeds = list(args.seed or [])
    resources = parse_resource_flags(args.resource)
    nodes_env = build_join_nodes_env(
        local_node_id=local_node_id,
        local_address=f"{host}:{port}",
        seeds=seeds,
        zone=args.zone,
        region=args.region,
        resources=resources,
    )
    os.environ["FLOWNET_CLUSTER_NODES"] = nodes_env
    if args.zone:
        os.environ["FLOWNET_NODE_ZONE"] = args.zone
    if args.region:
        os.environ["FLOWNET_NODE_REGION"] = args.region

    config = NodeRuntimeConfig(
        host=host,
        port=port,
        threads=int(args.threads),
        node_id=local_node_id,
        transport_mode=str(args.transport_mode),
        local_ipc_dir=str(args.local_ipc_dir),
    )
    return _run_node_runtime(config, node_id=local_node_id)


def _build_node_runtime_config(args: argparse.Namespace) -> tuple[NodeRuntimeConfig, str]:
    local_node_id = str(args.node_id).strip()
    if not local_node_id:
        raise ValueError("--node-id must not be empty.")
    if args.bind is None:
        raise ValueError("--bind is required.")
    host, port = _split_address(str(args.bind).strip())
    config = NodeRuntimeConfig(
        host=host,
        port=port,
        threads=int(args.threads),
        node_id=local_node_id,
        transport_mode=str(args.transport_mode),
        local_ipc_dir=str(args.local_ipc_dir),
    )
    return config, local_node_id


def _node_runtime_env(
    *,
    config: NodeRuntimeConfig,
    seeds: list[str],
    zone: str | None,
    region: str | None,
    resources: dict[str, str],
) -> dict[str, str]:
    env = os.environ.copy()
    env["FLOWNET_RUNTIME_ROLE"] = "node"
    env["FLOWNET_CLUSTER_NODES"] = _build_local_cluster_nodes_env(
        node_id=config.node_id,
        address=f"{config.host}:{config.port}",
        seeds=seeds,
        zone=zone,
        region=region,
        resources=resources,
    )
    if zone:
        env["FLOWNET_NODE_ZONE"] = zone
    else:
        env.pop("FLOWNET_NODE_ZONE", None)
    if region:
        env["FLOWNET_NODE_REGION"] = region
    else:
        env.pop("FLOWNET_NODE_REGION", None)
    return env


def _cmd_node_start_common(args: argparse.Namespace, *, require_seed: bool) -> int:
    config, local_node_id = _build_node_runtime_config(args)
    seeds = list(args.seed or [])
    if require_seed and not seeds:
        raise ValueError("join requires at least one --seed node reference.")
    resources = parse_resource_flags(args.resource)

    env = _node_runtime_env(
        config=config,
        seeds=seeds,
        zone=args.zone,
        region=args.region,
        resources=resources,
    )

    if bool(getattr(args, "foreground", False)):
        os.environ.update(env)
        return _run_node_runtime(config, node_id=local_node_id)

    state = _read_node_state()
    if state is not None:
        pid = int(state.get("pid") or 0)
        if _pid_alive(pid):
            address = state.get("address") or "unknown"
            print(f"[flownet] node already running (pid={pid}, address={address})")
            return 0
        _clear_node_state()

    _ensure_state_dir()
    log_file = Path(str(args.log_file).strip()).expanduser() if args.log_file else _default_log_file_path()
    log_file.parent.mkdir(parents=True, exist_ok=True)
    log_handle = log_file.open("a", encoding="utf-8")
    cmd = [
        sys.executable,
        "-m",
        "flownet.platform.start_node_runtime",
        "--host",
        str(config.host),
        "--port",
        str(config.port),
        "--threads",
        str(config.threads),
        "--node-id",
        str(config.node_id),
        "--transport-mode",
        str(config.transport_mode),
        "--local-ipc-dir",
        str(config.local_ipc_dir),
        "--transport-shm-threshold-bytes",
        str(config.transport_shm_threshold_bytes),
        "--transport-shm-max-payload-bytes",
        str(config.transport_shm_max_payload_bytes),
        "--transport-backend-cooldown-seconds",
        str(config.transport_backend_cooldown_seconds),
        "--transport-backend-cooldown-max-entries",
        str(config.transport_backend_cooldown_max_entries),
        "--transport-backend-cooldown-cleanup-interval",
        str(config.transport_backend_cooldown_cleanup_interval),
    ]
    process = subprocess.Popen(
        cmd,
        env=env,
        stdout=log_handle,
        stderr=log_handle,
        start_new_session=True,
    )
    log_handle.close()
    time.sleep(0.2)
    if process.poll() is not None:
        raise RuntimeError(
            f"node process exited immediately (code={process.returncode}). Check log: {log_file}"
        )

    _write_node_state(
        {
            "pid": process.pid,
            "node_id": local_node_id,
            "address": f"{config.host}:{config.port}",
            "log_file": str(log_file),
            "started_at": time.time(),
            "seeds": seeds,
            "zone": args.zone,
            "region": args.region,
        }
    )
    print(
        "[flownet] node started",
        f"pid={process.pid}",
        f"node_id={local_node_id}",
        f"address={config.host}:{config.port}",
        f"log={log_file}",
    )
    return 0


def _cmd_node_start(args: argparse.Namespace) -> int:
    return _cmd_node_start_common(args, require_seed=False)


def _cmd_node_join(args: argparse.Namespace) -> int:
    return _cmd_node_start_common(args, require_seed=True)


def _cmd_node_stop(args: argparse.Namespace) -> int:
    state = _read_node_state()
    if state is None:
        print("[flownet] node not managed by CLI (state file missing).")
        return 0

    pid = int(state.get("pid") or 0)
    if pid <= 0:
        _clear_node_state()
        print("[flownet] stale node state removed.")
        return 0
    if not _pid_alive(pid):
        _clear_node_state()
        print(f"[flownet] node already stopped (stale pid={pid} removed).")
        return 0

    timeout = float(args.timeout)
    timeout = max(0.0, timeout)
    try:
        os.killpg(pid, signal.SIGTERM)
    except Exception:
        os.kill(pid, signal.SIGTERM)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if not _pid_alive(pid):
            _clear_node_state()
            print(f"[flownet] node stopped (pid={pid}).")
            return 0
        time.sleep(0.1)

    if bool(args.force):
        try:
            os.killpg(pid, signal.SIGKILL)
        except Exception:
            os.kill(pid, signal.SIGKILL)
        time.sleep(0.1)
        _clear_node_state()
        print(f"[flownet] node force-stopped (pid={pid}).")
        return 0

    raise RuntimeError(
        f"node stop timed out after {timeout:.1f}s (pid={pid}). Re-run with --force."
    )


def _cmd_node_status(args: argparse.Namespace) -> int:
    state = _read_node_state()
    if state is None:
        if bool(args.as_json):
            print(
                json.dumps(
                    {
                        "status": "stopped",
                        "managed": False,
                        "reason": "no managed state",
                    },
                    ensure_ascii=True,
                )
            )
        else:
            print("node: stopped (no managed state)")
        return 0

    pid = int(state.get("pid") or 0)
    alive = _pid_alive(pid)
    address = state.get("address") or "unknown"
    node_id = state.get("node_id") or "unknown"
    log_file = state.get("log_file") or str(_default_log_file_path())
    probe_enabled = bool(args.probe)
    reachable: bool | None = None
    probe_message: str | None = None
    if alive and probe_enabled and isinstance(address, str) and ":" in address:
        reachable, probe_message = _tcp_probe_address(
            address,
            timeout_seconds=float(args.probe_timeout_seconds),
        )

    status_payload = {
        "status": "running" if alive else "stopped",
        "managed": True,
        "pid": pid,
        "node_id": node_id,
        "address": address,
        "log_file": log_file,
        "probe_enabled": probe_enabled,
        "reachable": reachable,
        "probe_message": probe_message,
    }

    if bool(args.as_json):
        print(json.dumps(status_payload, ensure_ascii=True))
        if (not alive) and bool(args.cleanup_stale):
            _clear_node_state()
        return 0

    if alive:
        if probe_enabled and reachable is not None:
            health = "reachable" if reachable else "unreachable"
            print(
                f"node: running (pid={pid}, node_id={node_id}, address={address}, health={health})"
            )
            if probe_message and probe_message != "ok":
                print(f"probe: {probe_message}")
        else:
            print(f"node: running (pid={pid}, node_id={node_id}, address={address})")
        print(f"log: {log_file}")
        return 0

    print(f"node: stopped (stale pid={pid}, node_id={node_id}, address={address})")
    if bool(args.cleanup_stale):
        _clear_node_state()
        print("stale state cleaned")
    return 0


def _cmd_node_logs(args: argparse.Namespace) -> int:
    state = _read_node_state() or {}
    log_file = (
        Path(str(args.log_file).strip()).expanduser()
        if args.log_file
        else Path(str(state.get("log_file") or _default_log_file_path())).expanduser()
    )
    if not log_file.exists():
        raise ValueError(f"log file does not exist: {log_file}")

    tail = max(1, int(args.tail))
    lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
    for line in lines[-tail:]:
        print(line)

    if not bool(args.follow):
        return 0

    with log_file.open("r", encoding="utf-8", errors="replace") as handle:
        handle.seek(0, os.SEEK_END)
        try:
            while True:
                row = handle.readline()
                if row:
                    print(row, end="")
                else:
                    time.sleep(0.2)
        except KeyboardInterrupt:
            return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="flownet",
        description="FlowNet CLI",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    init_parser = subparsers.add_parser(
        "init",
        help="Start a node runtime from static config or join mode.",
    )
    init_parser.set_defaults(handler=_cmd_init_static)
    init_parser.add_argument(
        "--config",
        type=str,
        default=None,
        help="Cluster YAML path for static node start mode.",
    )
    init_parser.add_argument(
        "--node-id",
        type=str,
        default=None,
        help="Local node id.",
    )

    init_subparsers = init_parser.add_subparsers(dest="init_subcommand")
    join_parser = init_subparsers.add_parser(
        "join",
        help="Join an existing cluster via one or more seed nodes.",
    )
    join_parser.set_defaults(handler=_cmd_init_join)
    join_parser.add_argument(
        "--node-id",
        type=str,
        default=None,
        help="Local node id.",
    )
    join_parser.add_argument(
        "--seed",
        action="append",
        default=[],
        help="Seed node reference, repeatable: node_id@host:port",
    )
    join_parser.add_argument(
        "--bind",
        type=str,
        default=None,
        help="Local node bind address: host:port",
    )
    join_parser.add_argument(
        "--threads",
        type=int,
        default=4,
        help="Local node worker threads.",
    )
    join_parser.add_argument(
        "--transport-mode",
        type=str,
        default="auto",
        help="Transport mode: auto|tcp|uds_prefer|hetero_auto",
    )
    join_parser.add_argument(
        "--local-ipc-dir",
        type=str,
        default="/tmp/flownet",
        help="Directory for local UDS/SHM IPC paths.",
    )
    join_parser.add_argument(
        "--zone",
        type=str,
        default=None,
        help="Local node zone metadata.",
    )
    join_parser.add_argument(
        "--region",
        type=str,
        default=None,
        help="Local node region metadata.",
    )
    join_parser.add_argument(
        "--resource",
        action="append",
        default=[],
        help=(
            "Local resource metadata key=value, repeatable. "
            "Built-ins: cpu,memory_mb,gpu,gpu_mem_mb."
        ),
    )

    node_parser = subparsers.add_parser(
        "node",
        help="Manage a local node runtime process.",
    )
    node_subparsers = node_parser.add_subparsers(dest="node_subcommand", required=True)

    def _add_node_runtime_flags(parser_obj: argparse.ArgumentParser) -> None:
        parser_obj.add_argument(
            "--node-id",
            type=str,
            default="local-node",
            help="Local node id.",
        )
        parser_obj.add_argument(
            "--bind",
            type=str,
            default="127.0.0.1:8787",
            help="Local node bind address host:port.",
        )
        parser_obj.add_argument(
            "--threads",
            type=int,
            default=4,
            help="Local node worker threads.",
        )
        parser_obj.add_argument(
            "--transport-mode",
            type=str,
            default="auto",
            help="Transport mode: auto|tcp|uds_prefer|hetero_auto",
        )
        parser_obj.add_argument(
            "--local-ipc-dir",
            type=str,
            default="/tmp/flownet",
            help="Directory for local UDS/SHM IPC paths.",
        )
        parser_obj.add_argument(
            "--zone",
            type=str,
            default=None,
            help="Local node zone metadata.",
        )
        parser_obj.add_argument(
            "--region",
            type=str,
            default=None,
            help="Local node region metadata.",
        )
        parser_obj.add_argument(
            "--resource",
            action="append",
            default=[],
            help=(
                "Local resource metadata key=value, repeatable. "
                "Built-ins: cpu,memory_mb,gpu,gpu_mem_mb."
            ),
        )
        parser_obj.add_argument(
            "--seed",
            action="append",
            default=[],
            help="Seed node reference, repeatable: node_id@host:port",
        )
        parser_obj.add_argument(
            "--foreground",
            action="store_true",
            help="Run node in foreground (default starts managed background process).",
        )
        parser_obj.add_argument(
            "--log-file",
            type=str,
            default=None,
            help="Managed node log file path.",
        )

    node_start_parser = node_subparsers.add_parser(
        "start",
        help="Start a local node runtime (managed background by default).",
    )
    node_start_parser.set_defaults(handler=_cmd_node_start)
    _add_node_runtime_flags(node_start_parser)

    node_join_parser = node_subparsers.add_parser(
        "join",
        help="Start local node runtime and join an existing cluster via --seed.",
    )
    node_join_parser.set_defaults(handler=_cmd_node_join)
    _add_node_runtime_flags(node_join_parser)

    node_stop_parser = node_subparsers.add_parser(
        "stop",
        help="Stop local managed node runtime.",
    )
    node_stop_parser.set_defaults(handler=_cmd_node_stop)
    node_stop_parser.add_argument(
        "--timeout",
        type=float,
        default=8.0,
        help="Graceful stop timeout seconds before optional force kill.",
    )
    node_stop_parser.add_argument(
        "--force",
        action="store_true",
        help="Force kill when graceful stop times out.",
    )

    node_status_parser = node_subparsers.add_parser(
        "status",
        help="Show local managed node runtime status.",
    )
    node_status_parser.set_defaults(handler=_cmd_node_status)
    node_status_parser.add_argument(
        "--cleanup-stale",
        action="store_true",
        help="Remove stale state file when pid is no longer alive.",
    )
    node_status_parser.add_argument(
        "--probe",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Enable TCP reachability probe for managed node address.",
    )
    node_status_parser.add_argument(
        "--probe-timeout-seconds",
        type=float,
        default=0.8,
        help="TCP probe timeout in seconds.",
    )
    node_status_parser.add_argument(
        "--json",
        dest="as_json",
        action="store_true",
        help="Print status payload as JSON.",
    )

    node_logs_parser = node_subparsers.add_parser(
        "logs",
        help="Print managed node logs.",
    )
    node_logs_parser.set_defaults(handler=_cmd_node_logs)
    node_logs_parser.add_argument(
        "--tail",
        type=int,
        default=120,
        help="Number of lines to print from log tail.",
    )
    node_logs_parser.add_argument(
        "--follow",
        action="store_true",
        help="Follow log output until Ctrl-C.",
    )
    node_logs_parser.add_argument(
        "--log-file",
        type=str,
        default=None,
        help="Override log file path.",
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    handler = getattr(args, "handler", None)
    if handler is None:
        parser.error("No command handler configured.")
    try:
        return int(handler(args))
    except ValueError as exc:
        parser.error(str(exc))
    return 2


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())
