#![recursion_limit = "256"]
#![warn(clippy::all, clippy::pedantic)]
#![allow(
    clippy::assigning_clones,
    clippy::bool_to_int_with_if,
    clippy::case_sensitive_file_extension_comparisons,
    clippy::cast_possible_wrap,
    clippy::doc_markdown,
    clippy::field_reassign_with_default,
    clippy::float_cmp,
    clippy::implicit_clone,
    clippy::items_after_statements,
    clippy::map_unwrap_or,
    clippy::manual_let_else,
    clippy::missing_errors_doc,
    clippy::missing_panics_doc,
    clippy::module_name_repetitions,
    clippy::needless_pass_by_value,
    clippy::needless_raw_string_hashes,
    clippy::redundant_closure_for_method_calls,
    clippy::similar_names,
    clippy::single_match_else,
    clippy::struct_field_names,
    clippy::too_many_lines,
    clippy::uninlined_format_args,
    clippy::unused_self,
    clippy::cast_precision_loss,
    clippy::unnecessary_cast,
    clippy::unnecessary_lazy_evaluations,
    clippy::unnecessary_literal_bound,
    clippy::unnecessary_map_or,
    clippy::unnecessary_wraps,
    // ── Temporarily allowed while CI stabilizes ──
    // See src/lib.rs for the matching block and rationale.
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss,
    clippy::collapsible_if,
    clippy::default_trait_access,
    clippy::doc_overindented_list_items,
    clippy::double_ended_iterator_last,
    clippy::double_must_use,
    clippy::empty_line_after_doc_comments,
    clippy::format_push_string,
    clippy::if_not_else,
    clippy::implicit_hasher,
    clippy::ip_constant,
    clippy::len_without_is_empty,
    clippy::manual_clamp,
    clippy::manual_contains,
    clippy::manual_string_new,
    clippy::match_same_arms,
    clippy::needless_borrow,
    clippy::needless_continue,
    clippy::needless_option_as_deref,
    clippy::redundant_closure,
    clippy::ref_option,
    clippy::result_large_err,
    clippy::struct_excessive_bools,
    clippy::trivially_copy_pass_by_ref,
    clippy::type_complexity,
    clippy::unchecked_time_subtraction,
    clippy::unnested_or_patterns,
    clippy::unused_async,
    clippy::unwrap_or_default,
    clippy::used_underscore_binding,
    clippy::vec_init_then_push,
    dead_code
)]

use anyhow::{Context, Result, bail};
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use dialoguer::Password;
use serde::{Deserialize, Serialize};
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use tracing::{info, warn};
use tracing_subscriber::{EnvFilter, fmt};

fn parse_temperature(s: &str) -> std::result::Result<f64, String> {
    let t: f64 = s.parse().map_err(|e| format!("{e}"))?;
    config::schema::validate_temperature(t)
}

fn print_no_command_help() -> Result<()> {
    // No config has been loaded yet, but $LANG / REVKA_LANG should still
    // localize this hint. Default to English if none of those are set.
    revka::i18n::init(revka::i18n::detect_lang(None, None));
    println!("{}", revka::t!("err-no-command"));
    println!("{}", revka::t!("err-try-onboard"));
    println!();

    let mut cmd = Cli::command();
    cmd.print_help()?;
    println!();

    #[cfg(windows)]
    pause_after_no_command_help();

    Ok(())
}

#[cfg(windows)]
fn pause_after_no_command_help() {
    println!();
    print!("Press Enter to exit...");
    let _ = std::io::stdout().flush();
    let mut line = String::new();
    let _ = std::io::stdin().read_line(&mut line);
}

mod agent;
mod approval;
mod auth;
mod channels;
mod cli_input;
mod commands;
mod rag {
    pub use revka::rag::*;
}
mod config;
mod cost;
mod cron;
mod daemon;
mod doctor;
mod gateway;
mod hardware;
mod health;
mod heartbeat;
mod hooks;
mod i18n;
mod identity;
mod integrations;
// MCP server runs as an in-process task inside the main daemon. The
// `pub use` re-exports inside `mcp_server/mod.rs` are consumed through the
// library crate (tests), not the bin crate — suppress the resulting
// "unused import" noise here.
#[allow(unused_imports)]
mod mcp_server;
mod memory;
mod migration;
mod multimodal;
mod observability;
mod onboard;
mod peripherals;
#[cfg(feature = "plugins-wasm")]
mod plugins;
mod providers;
mod runtime;
mod security;
mod service;
mod sidecars;
mod skills;
mod sop;
mod tools;
mod trust;
mod tunnel;
mod util;
mod verifiable_intent;

use config::Config;

// Re-export so binary modules can use crate::<CommandEnum> while keeping a single source of truth.
pub use revka::{
    ChannelCommands, CronCommands, GatewayCommands, HardwareCommands, IntegrationCommands,
    MigrateCommands, PeripheralCommands, ServiceCommands, SkillCommands, SopCommands,
    WorkflowCommands,
};

#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum CompletionShell {
    #[value(name = "bash")]
    Bash,
    #[value(name = "fish")]
    Fish,
    #[value(name = "zsh")]
    Zsh,
    #[value(name = "powershell")]
    PowerShell,
    #[value(name = "elvish")]
    Elvish,
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum EstopLevelArg {
    #[value(name = "kill-all")]
    KillAll,
    #[value(name = "network-kill")]
    NetworkKill,
    #[value(name = "domain-block")]
    DomainBlock,
    #[value(name = "tool-freeze")]
    ToolFreeze,
}

/// `Revka` - Memory-native AI agent OS. 100% Rust.
#[derive(Parser, Debug)]
#[command(name = "revka")]
#[command(author = "Kumiho Inc.")]
#[command(version)]
#[command(about = "The fastest, smallest AI assistant.", long_about = None)]
struct Cli {
    #[arg(long, global = true)]
    config_dir: Option<String>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Initialize your workspace and configuration
    Onboard {
        /// Overwrite existing config without confirmation
        #[arg(long)]
        force: bool,

        /// Reinitialize from scratch (backup and reset all configuration)
        #[arg(long)]
        reinit: bool,

        /// Reconfigure channels only (fast repair flow)
        #[arg(long)]
        channels_only: bool,

        /// API key for provider configuration
        #[arg(long)]
        api_key: Option<String>,

        /// Provider name (used in quick mode, default: openrouter)
        #[arg(long)]
        provider: Option<String>,
        /// Model ID override (used in quick mode)
        #[arg(long)]
        model: Option<String>,
        /// Memory backend (kumiho, none) - used in quick mode, default: kumiho
        #[arg(long)]
        memory: Option<String>,

        /// Skip interactive prompts and use quick setup with defaults
        #[arg(long)]
        quick: bool,

        /// UI language for the wizard. Overrides `REVKA_LANG`, `$LANG`,
        /// and the `language` field in config.toml. Supported: `en`, `ko`.
        ///
        /// Examples:
        ///   revka onboard --lang ko
        ///   REVKA_LANG=ko revka onboard
        #[arg(long, value_name = "LANG")]
        lang: Option<String>,
    },

    /// Start the AI agent loop
    #[command(long_about = "\
Start the AI agent loop.

Launches an interactive chat session with the configured AI provider. \
Use --message for single-shot queries without entering interactive mode.

Examples:
  revka agent                              # interactive session
  revka agent -m \"Summarize today's logs\"  # single message
  revka agent -p anthropic --model claude-sonnet-4-20250514
  revka agent --peripheral nucleo-f401re:/dev/ttyACM0")]
    Agent {
        /// Single message mode (don't enter interactive mode)
        #[arg(short, long)]
        message: Option<String>,

        /// Load and save interactive session state in this JSON file
        #[arg(long)]
        session_state_file: Option<PathBuf>,

        /// Provider to use (openrouter, anthropic, openai, openai-codex)
        #[arg(short, long)]
        provider: Option<String>,

        /// Model to use
        #[arg(long)]
        model: Option<String>,

        /// Temperature (0.0 - 2.0, defaults to config default_temperature)
        #[arg(short, long, value_parser = parse_temperature)]
        temperature: Option<f64>,

        /// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0)
        #[arg(long)]
        peripheral: Vec<String>,
    },

    /// Start/manage the gateway server (webhooks, websockets)
    #[command(long_about = "\
Manage the gateway server (webhooks, websockets).

Start, restart, or inspect the HTTP/WebSocket gateway that accepts \
incoming webhook events and WebSocket connections.

Examples:
  revka gateway start              # start gateway
  revka gateway restart            # restart gateway
  revka gateway get-paircode       # show pairing code")]
    Gateway {
        #[command(subcommand)]
        gateway_command: Option<revka::GatewayCommands>,
    },

    /// Mint bearer tokens from gateway pairing codes
    #[command(long_about = "\
Mint gateway bearer tokens from pairing codes.

Use this when a dashboard or operator gives you a one-time pairing code
and you need a bearer token for CLI/API access. The plaintext token is
printed once; Revka stores only its hash.

Examples:
  revka pair token --code 123456
  revka pair token --code 123456 --name \"Field phone\" --device-type mobile --hardware \"Pixel 8 / Android\"
  revka pair token --url http://revka.local:8080 --code 123456 --json")]
    Pair {
        #[command(subcommand)]
        pair_command: PairCommands,
    },

    /// Start ACP (Agent Control Protocol) server over stdio
    #[command(long_about = "\
Start the ACP server (JSON-RPC 2.0 over stdio).

Launches a JSON-RPC 2.0 server on stdin/stdout for IDE and tool \
integration. Supports session management and streaming agent \
responses as notifications.

Methods: initialize, session/new, session/prompt, session/stop.

Examples:
  revka acp                        # start ACP server
  revka acp --max-sessions 5       # limit concurrent sessions")]
    Acp {
        /// Maximum concurrent sessions (default: 10)
        #[arg(long)]
        max_sessions: Option<usize>,

        /// Session inactivity timeout in seconds (default: 3600)
        #[arg(long)]
        session_timeout: Option<u64>,
    },

    /// Start long-running autonomous runtime (gateway + channels + heartbeat + scheduler)
    #[command(long_about = "\
Start the long-running autonomous daemon.

Launches the full Revka runtime: gateway server, all configured \
channels (Telegram, Discord, Slack, etc.), heartbeat monitor, and \
the cron scheduler. This is the recommended way to run Revka in \
production or as an always-on assistant.

Use 'revka service install' to register the daemon as an OS \
service (systemd/launchd) for auto-start on boot.

Examples:
  revka daemon                   # use config defaults
  revka daemon -p 9090           # gateway on port 9090
  revka daemon --host 127.0.0.1  # localhost only")]
    Daemon {
        /// Port to listen on (use 0 for random available port); defaults to config gateway.port
        #[arg(short, long)]
        port: Option<u16>,

        /// Host to bind to; defaults to config gateway.host
        #[arg(long)]
        host: Option<String>,
    },

    /// Manage OS service lifecycle (launchd/systemd user service)
    Service {
        /// Init system to use: auto (detect), systemd, or openrc
        #[arg(long, default_value = "auto", value_parser = ["auto", "systemd", "openrc"])]
        service_init: String,

        #[command(subcommand)]
        service_command: ServiceCommands,
    },

    /// Run diagnostics for daemon/scheduler/channel freshness
    Doctor {
        #[command(subcommand)]
        doctor_command: Option<DoctorCommands>,
    },

    /// Show system status (full details)
    Status {
        /// Output format: "exit-code" exits 0 if healthy, 1 otherwise (for Docker HEALTHCHECK)
        #[arg(long)]
        format: Option<String>,
    },

    /// Engage, inspect, and resume emergency-stop states.
    ///
    /// Examples:
    /// - `revka estop`
    /// - `revka estop --level network-kill`
    /// - `revka estop --level domain-block --domain "*.chase.com"`
    /// - `revka estop --level tool-freeze --tool shell --tool browser`
    /// - `revka estop status`
    /// - `revka estop resume --network`
    /// - `revka estop resume --domain "*.chase.com"`
    /// - `revka estop resume --tool shell`
    Estop {
        #[command(subcommand)]
        estop_command: Option<EstopSubcommands>,

        /// Level used when engaging estop from `revka estop`.
        #[arg(long, value_enum)]
        level: Option<EstopLevelArg>,

        /// Domain pattern(s) for `domain-block` (repeatable).
        #[arg(long = "domain")]
        domains: Vec<String>,

        /// Tool name(s) for `tool-freeze` (repeatable).
        #[arg(long = "tool")]
        tools: Vec<String>,
    },

    /// Configure and manage scheduled tasks
    #[command(long_about = "\
Configure and manage scheduled tasks.

Schedule recurring, one-shot, or interval-based tasks using cron \
expressions, RFC 3339 timestamps, durations, or fixed intervals.

Cron expressions use the standard 5-field format: \
'min hour day month weekday'. Timezones default to UTC; \
override with --tz and an IANA timezone name.

Examples:
  revka cron list
  revka cron add '0 9 * * 1-5' 'Good morning' --tz America/New_York --agent
  revka cron add '*/30 * * * *' 'Check system health' --agent
  revka cron add '*/5 * * * *' 'echo ok'
  revka cron add-at 2025-01-15T14:00:00Z 'Send reminder' --agent
  revka cron add-every 60000 'Ping heartbeat'
  revka cron once 30m 'Run backup in 30 minutes' --agent
  revka cron pause <task-id>
  revka cron update <task-id> --expression '0 8 * * *' --tz Europe/London")]
    Cron {
        #[command(subcommand)]
        cron_command: CronCommands,
    },

    /// Manage provider model catalogs
    Models {
        #[command(subcommand)]
        model_command: ModelCommands,
    },

    /// List supported AI providers
    Providers,

    /// Manage channels (telegram, discord, slack)
    #[command(long_about = "\
Manage communication channels.

Add, remove, list, send, and health-check channels that connect Revka \
to messaging platforms. Supported channel types: telegram, discord, \
slack, whatsapp, matrix, imessage, email.

Examples:
  revka channel list
  revka channel doctor
  revka channel add telegram '{\"bot_token\":\"...\",\"name\":\"my-bot\"}'
  revka channel remove my-bot
  revka channel bind-telegram revka_user
  revka channel send 'Alert!' --channel-id telegram --recipient 123456789")]
    Channel {
        #[command(subcommand)]
        channel_command: ChannelCommands,
    },

    /// Browse 50+ integrations
    Integrations {
        #[command(subcommand)]
        integration_command: IntegrationCommands,
    },

    /// Manage skills (user-defined capabilities)
    Skills {
        #[command(subcommand)]
        skill_command: SkillCommands,
    },

    /// Manage built-in workflow templates (list, sync to workspace)
    Workflows {
        #[command(subcommand)]
        workflow_command: WorkflowCommands,
    },

    /// Migrate data from other agent runtimes
    Migrate {
        #[command(subcommand)]
        migrate_command: MigrateCommands,
    },

    /// Manage provider subscription authentication profiles
    Auth {
        #[command(subcommand)]
        auth_command: AuthCommands,
    },

    /// Discover and introspect USB hardware
    #[command(long_about = "\
Discover and introspect USB hardware.

Enumerate connected USB devices, identify known development boards \
(STM32 Nucleo, Arduino, ESP32), and retrieve chip information via \
probe-rs / ST-Link.

Examples:
  revka hardware discover
  revka hardware introspect /dev/ttyACM0
  revka hardware info --chip STM32F401RETx")]
    Hardware {
        #[command(subcommand)]
        hardware_command: revka::HardwareCommands,
    },

    /// Manage hardware peripherals (STM32, RPi GPIO, etc.)
    #[command(long_about = "\
Manage hardware peripherals.

Add, list, flash, and configure hardware boards that expose tools \
to the agent (GPIO, sensors, actuators). Supported boards: \
nucleo-f401re, rpi-gpio, esp32, arduino-uno.

Examples:
  revka peripheral list
  revka peripheral add nucleo-f401re /dev/ttyACM0
  revka peripheral add rpi-gpio native
  revka peripheral flash --port /dev/cu.usbmodem12345
  revka peripheral flash-nucleo")]
    Peripheral {
        #[command(subcommand)]
        peripheral_command: revka::PeripheralCommands,
    },

    /// Manage agent memory (list, get, stats, clear)
    #[command(long_about = "\
Manage agent memory entries.

List, inspect, and clear memory entries stored by the agent. \
Supports filtering by category and session, pagination, and \
batch clearing with confirmation.

Examples:
  revka memory stats
  revka memory list
  revka memory list --category core --limit 10
  revka memory get <key>
  revka memory clear --category conversation --yes")]
    Memory {
        #[command(subcommand)]
        memory_command: MemoryCommands,
    },

    /// Manage configuration
    #[command(long_about = "\
Manage Revka configuration.

Inspect and export configuration settings. Use 'schema' to dump \
the full JSON Schema for the config file, which documents every \
available key, type, and default value.

Examples:
  revka config schema              # print JSON Schema to stdout
  revka config schema > schema.json")]
    Config {
        #[command(subcommand)]
        config_command: ConfigCommands,
    },

    /// Install Revka components (first slice: Python MCP sidecars)
    #[command(long_about = "\
Run the unified Revka install flow.

Today only the --sidecars-only path is implemented: it installs the \
Kumiho and Operator Python MCP sidecars under ~/.revka/ using the \
bundled (embedded) install-sidecars script for your platform. You can \
call this from any Revka binary — no source checkout required.

The full install flow (prerequisite checks, build, onboard, dashboard \
launch) is still handled by ./install.sh (POSIX) and setup.bat (Windows) \
from a source checkout; that path will migrate into this subcommand over \
time.

Examples:
  revka install --sidecars-only           # install Kumiho + Operator MCP sidecars
  revka install --sidecars-only --dry-run # show planned actions (POSIX only)
  revka install --sidecars-only --skip-kumiho --skip-operator   # no-op smoke test")]
    Install {
        /// Install only the Python MCP sidecars (Kumiho + Operator).
        /// Currently required — omitting it prints the full-install pointers.
        #[arg(long)]
        sidecars_only: bool,
        /// Skip the Kumiho MCP sidecar install.
        #[arg(long)]
        skip_kumiho: bool,
        /// Skip the Operator MCP sidecar install.
        #[arg(long)]
        skip_operator: bool,
        /// Opt-in: install the Node.js Session Manager sidecar.
        ///
        /// Default OFF. Subprocess fallback (the default) uses
        /// `claude --print` + `codex exec`, both of which authenticate via
        /// each CLI's OAuth and route through the user's Claude Pro/Max +
        /// Codex CLI subscriptions — no per-call API spend on spawned
        /// agents. The Session Manager uses the Claude Agent SDK which
        /// requires `ANTHROPIC_API_KEY` (pay-per-token) — see
        /// anthropics/claude-agent-sdk-python#559. Enable only if you need
        /// the streaming timeline events the sidecar provides and accept
        /// the API-billing trade-off.
        #[arg(long)]
        with_session_manager: bool,
        /// Print planned actions without executing them (POSIX only).
        #[arg(long)]
        dry_run: bool,
        /// Explicit Python interpreter path (overrides auto-detection).
        #[arg(long)]
        python: Option<String>,
        /// Dev-mode: install operator-mcp from a local Revka checkout
        /// instead of the binary's embedded snapshot. Pass the repo root —
        /// pip installs from `<PATH>/operator-mcp/` with `--force-reinstall`
        /// and `--no-deps` for fast iteration. Skips the Rust rebuild cycle
        /// when you only changed Python.
        #[arg(long, value_name = "REPO_PATH")]
        from_source: Option<std::path::PathBuf>,
    },

    /// Check for and apply updates
    #[command(long_about = "\
Check for and apply Revka updates.

By default, downloads and installs the latest release with a \
6-phase pipeline: preflight, download, backup, validate, swap, \
and smoke test. Automatic rollback on failure.

Use --check to only check for updates without installing.
Use --force to skip the confirmation prompt.
Use --version to target a specific release instead of latest.

Examples:
  revka update                      # download and install latest
  revka update --check              # check only, don't install
  revka update --force              # install without confirmation
  revka update --version 0.6.0      # install specific version")]
    Update {
        /// Only check for updates, don't install
        #[arg(long)]
        check: bool,
        /// Skip confirmation prompt
        #[arg(long)]
        force: bool,
        /// Target version (default: latest)
        #[arg(long)]
        version: Option<String>,
    },

    /// Run diagnostic self-tests
    #[command(long_about = "\
Run diagnostic self-tests to verify the Revka installation.

By default, runs the full test suite including network checks \
(gateway health, memory round-trip). Use --quick to skip network \
checks for faster offline validation.

Examples:
  revka self-test             # full suite
  revka self-test --quick     # quick checks only (no network)")]
    SelfTest {
        /// Run quick checks only (no network)
        #[arg(long)]
        quick: bool,
    },

    /// Generate shell completion script to stdout
    #[command(long_about = "\
Generate shell completion scripts for `revka`.

The script is printed to stdout so it can be sourced directly:

Examples:
  source <(revka completions bash)
  revka completions zsh > ~/.zfunc/_revka
  revka completions fish > ~/.config/fish/completions/revka.fish")]
    Completions {
        /// Target shell
        #[arg(value_enum)]
        shell: CompletionShell,
    },

    /// Launch or install the companion desktop app
    #[command(long_about = "\
Launch the Revka companion desktop app.

The companion app is a lightweight menu bar / system tray application \
that connects to the same gateway as the CLI. It provides quick access \
to the dashboard, status monitoring, and device pairing.

Use --install to download the pre-built companion app for your platform.

Examples:
  revka desktop              # launch the companion app
  revka desktop --install    # download and install it")]
    Desktop {
        /// Download and install the companion app
        #[arg(long)]
        install: bool,
    },

    /// Manage WASM plugins
    #[cfg(feature = "plugins-wasm")]
    Plugin {
        #[command(subcommand)]
        plugin_command: PluginCommands,
    },
}

#[cfg(feature = "plugins-wasm")]
#[derive(Subcommand, Debug)]
enum PluginCommands {
    /// List installed plugins
    List,
    /// Install a plugin from a directory or URL
    Install {
        /// Path to plugin directory or manifest
        source: String,
    },
    /// Remove an installed plugin
    Remove {
        /// Plugin name
        name: String,
    },
    /// Show information about a plugin
    Info {
        /// Plugin name
        name: String,
    },
}

#[derive(Subcommand, Debug)]
enum ConfigCommands {
    /// Dump the full configuration JSON Schema to stdout
    Schema,
}

#[derive(Subcommand, Debug)]
enum PairCommands {
    /// Exchange a one-time pairing code for a bearer token
    Token {
        /// Pairing code shown by the gateway or dashboard
        #[arg(long)]
        code: String,
        /// Gateway base URL, e.g. http://127.0.0.1:8080
        #[arg(long)]
        url: Option<String>,
        /// Gateway host override when --url is omitted
        #[arg(long)]
        host: Option<String>,
        /// Gateway port override when --url is omitted
        #[arg(short, long)]
        port: Option<u16>,
        /// Device name recorded on the dashboard Pairing page
        #[arg(long)]
        name: Option<String>,
        /// Device type recorded on the dashboard Pairing page
        #[arg(long, default_value = "cli")]
        device_type: String,
        /// Hardware/platform label recorded on the dashboard Pairing page
        #[arg(long)]
        hardware: Option<String>,
        /// Print machine-readable JSON
        #[arg(long)]
        json: bool,
    },
}

#[derive(Subcommand, Debug)]
enum EstopSubcommands {
    /// Print current estop status.
    Status,
    /// Resume from an engaged estop level.
    Resume {
        /// Resume only network kill.
        #[arg(long)]
        network: bool,
        /// Resume one or more blocked domain patterns.
        #[arg(long = "domain")]
        domains: Vec<String>,
        /// Resume one or more frozen tools.
        #[arg(long = "tool")]
        tools: Vec<String>,
        /// OTP code. If omitted and OTP is required, a prompt is shown.
        #[arg(long)]
        otp: Option<String>,
    },
}

#[derive(Subcommand, Debug)]
enum AuthCommands {
    /// Login with OAuth (OpenAI Codex or Gemini)
    Login {
        /// Provider (`openai-codex` or `gemini`)
        #[arg(long)]
        provider: String,
        /// Profile name (default: default)
        #[arg(long, default_value = "default")]
        profile: String,
        /// Use OAuth device-code flow
        #[arg(long)]
        device_code: bool,
        /// Import an existing auth.json file instead of starting a new login flow.
        /// Currently supports only `openai-codex`; Codex defaults to `~/.codex/auth.json`.
        #[arg(long, value_name = "PATH", conflicts_with = "device_code")]
        import: Option<PathBuf>,
    },
    /// Complete OAuth by pasting redirect URL or auth code
    PasteRedirect {
        /// Provider (`openai-codex`)
        #[arg(long)]
        provider: String,
        /// Profile name (default: default)
        #[arg(long, default_value = "default")]
        profile: String,
        /// Full redirect URL or raw OAuth code
        #[arg(long)]
        input: Option<String>,
    },
    /// Paste setup token / auth token (for Anthropic subscription auth)
    PasteToken {
        /// Provider (`anthropic`)
        #[arg(long)]
        provider: String,
        /// Profile name (default: default)
        #[arg(long, default_value = "default")]
        profile: String,
        /// Token value (if omitted, read interactively)
        #[arg(long)]
        token: Option<String>,
        /// Auth kind override (`authorization` or `api-key`)
        #[arg(long)]
        auth_kind: Option<String>,
    },
    /// Alias for `paste-token` (interactive by default)
    SetupToken {
        /// Provider (`anthropic`)
        #[arg(long)]
        provider: String,
        /// Profile name (default: default)
        #[arg(long, default_value = "default")]
        profile: String,
    },
    /// Refresh OpenAI Codex access token using refresh token
    Refresh {
        /// Provider (`openai-codex`)
        #[arg(long)]
        provider: String,
        /// Profile name or profile id
        #[arg(long)]
        profile: Option<String>,
    },
    /// Remove auth profile
    Logout {
        /// Provider
        #[arg(long)]
        provider: String,
        /// Profile name (default: default)
        #[arg(long, default_value = "default")]
        profile: String,
    },
    /// Set active profile for a provider
    Use {
        /// Provider
        #[arg(long)]
        provider: String,
        /// Profile name or full profile id
        #[arg(long)]
        profile: String,
    },
    /// List auth profiles
    List,
    /// Show auth status with active profile and token expiry info
    Status,
}

#[derive(Subcommand, Debug)]
enum ModelCommands {
    /// Refresh and cache provider models
    Refresh {
        /// Provider name (defaults to configured default provider)
        #[arg(long)]
        provider: Option<String>,

        /// Refresh all providers that support live model discovery
        #[arg(long)]
        all: bool,

        /// Force live refresh and ignore fresh cache
        #[arg(long)]
        force: bool,
    },
    /// List cached models for a provider
    List {
        /// Provider name (defaults to configured default provider)
        #[arg(long)]
        provider: Option<String>,
    },
    /// Set the default model in config
    Set {
        /// Model name to set as default
        model: String,
    },
    /// Show current model configuration and cache status
    Status,
}

#[derive(Subcommand, Debug)]
enum DoctorCommands {
    /// Probe model catalogs across providers and report availability
    Models {
        /// Probe a specific provider only (default: all known providers)
        #[arg(long)]
        provider: Option<String>,

        /// Prefer cached catalogs when available (skip forced live refresh)
        #[arg(long)]
        use_cache: bool,
    },
    /// Query runtime trace events (tool diagnostics and model replies)
    Traces {
        /// Show a specific trace event by id
        #[arg(long)]
        id: Option<String>,
        /// Filter list output by event type
        #[arg(long)]
        event: Option<String>,
        /// Case-insensitive text match across message/payload
        #[arg(long)]
        contains: Option<String>,
        /// Maximum number of events to display
        #[arg(long, default_value = "20")]
        limit: usize,
    },
}

#[derive(Subcommand, Debug)]
enum MemoryCommands {
    /// List memory entries with optional filters
    List {
        #[arg(long)]
        category: Option<String>,
        #[arg(long)]
        session: Option<String>,
        #[arg(long, default_value = "50")]
        limit: usize,
        #[arg(long, default_value = "0")]
        offset: usize,
    },
    /// Get a specific memory entry by key
    Get { key: String },
    /// Show memory backend statistics and health
    Stats,
    /// Clear memories by category, by key, or clear all
    Clear {
        /// Delete a single entry by key (supports prefix match)
        #[arg(long)]
        key: Option<String>,
        #[arg(long)]
        category: Option<String>,
        /// Skip confirmation prompt
        #[arg(long)]
        yes: bool,
    },
}

#[tokio::main]
#[allow(clippy::too_many_lines)]
async fn main() -> Result<()> {
    // Install default crypto provider for Rustls TLS.
    // This prevents the error: "could not automatically determine the process-level CryptoProvider"
    // when both aws-lc-rs and ring features are available (or neither is explicitly selected).
    if let Err(e) = rustls::crypto::ring::default_provider().install_default() {
        eprintln!("Warning: Failed to install default crypto provider: {e:?}");
    }

    if std::env::args_os().len() <= 1 {
        return print_no_command_help();
    }

    let cli = Cli::parse();

    if let Some(config_dir) = &cli.config_dir {
        if config_dir.trim().is_empty() {
            bail!("--config-dir cannot be empty");
        }
        // SAFETY: called early in main before any threads are spawned.
        unsafe { std::env::set_var("REVKA_CONFIG_DIR", config_dir) };
    }

    // Completions must remain stdout-only and should not load config or initialize logging.
    // This avoids warnings/log lines corrupting sourced completion scripts.
    if let Commands::Completions { shell } = &cli.command {
        let mut stdout = std::io::stdout().lock();
        write_shell_completion(*shell, &mut stdout)?;
        return Ok(());
    }

    // Initialize logging - respects RUST_LOG env var, defaults to INFO
    let subscriber = fmt::Subscriber::builder()
        .with_env_filter(
            EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
        )
        .finish();

    tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");

    // Onboard auto-detects the environment: if stdin/stdout are a TTY and no
    // provider flags were given, it runs the full interactive wizard; otherwise
    // it runs the quick (scriptable) setup.  Use --quick to force quick setup,
    // or set REVKA_INTERACTIVE=1 to force interactive mode when TTY
    // detection fails.  This means `curl … | bash` and
    // `revka onboard --api-key …` both take the fast path, while a bare
    // `revka onboard` in a terminal launches the wizard.
    if let Commands::Onboard {
        force,
        reinit,
        channels_only,
        api_key,
        provider,
        model,
        memory,
        quick,
        lang,
    } = &cli.command
    {
        let force = *force;
        let reinit = *reinit;
        let channels_only = *channels_only;
        let api_key = api_key.clone();
        let provider = provider.clone();
        let model = model.clone();
        let memory = memory.clone();
        let quick = *quick;
        let lang_flag = lang.clone();

        // Initialize the wizard's UI language. No config.toml is loaded yet at
        // this point — that's deliberate, the user might be onboarding for the
        // first time. Detection priority: --lang flag → REVKA_LANG env →
        // $LC_ALL/$LANG → English. The Step 0 picker can override this once
        // the wizard runs interactively.
        let initial_lang = revka::i18n::detect_lang(lang_flag.as_deref(), None);
        revka::i18n::init(initial_lang);

        if reinit && channels_only {
            bail!("--reinit and --channels-only cannot be used together");
        }
        if channels_only
            && (api_key.is_some() || provider.is_some() || model.is_some() || memory.is_some())
        {
            bail!("--channels-only does not accept --api-key, --provider, --model, or --memory");
        }
        if channels_only && force {
            bail!("--channels-only does not accept --force");
        }
        if quick && channels_only {
            bail!("--quick and --channels-only cannot be used together");
        }

        // Handle --reinit: backup and reset configuration
        if reinit {
            let (revka_dir, _) =
                crate::config::schema::resolve_runtime_dirs_for_onboarding().await?;

            if revka_dir.exists() {
                let timestamp = chrono::Local::now().format("%Y%m%d%H%M%S");
                let backup_dir = format!("{}.backup.{}", revka_dir.display(), timestamp);

                println!("{}", revka::t!("reinit-banner"));
                println!(
                    "   {}",
                    revka::t!("reinit-current-dir", path = revka_dir.display().to_string())
                );
                println!(
                    "   {}",
                    revka::t!("reinit-backup-target", path = backup_dir.clone())
                );
                println!();
                print!("{} ", revka::t!("reinit-confirm"));
                std::io::stdout()
                    .flush()
                    .context("Failed to flush stdout")?;

                let mut answer = String::new();
                std::io::stdin().read_line(&mut answer)?;
                if !answer.trim().eq_ignore_ascii_case("y") {
                    println!("{}", revka::t!("reinit-aborted"));
                    return Ok(());
                }
                println!();

                // Rename existing directory as backup
                tokio::fs::rename(&revka_dir, &backup_dir)
                    .await
                    .with_context(|| {
                        format!("Failed to backup existing config to {}", backup_dir)
                    })?;

                println!("   {}", revka::t!("reinit-backup-ok"));
                println!("   {}\n", revka::t!("reinit-fresh-start"));
            }
        }

        // Auto-detect: run the interactive wizard when in a TTY with no
        // provider flags, quick setup otherwise (scriptable path).
        let has_provider_flags =
            api_key.is_some() || provider.is_some() || model.is_some() || memory.is_some();
        let is_tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
        let env_interactive = std::env::var("REVKA_INTERACTIVE").as_deref() == Ok("1");

        let config = if channels_only {
            Box::pin(onboard::run_channels_repair_wizard()).await
        } else if quick || has_provider_flags {
            Box::pin(onboard::run_quick_setup(
                api_key.as_deref(),
                provider.as_deref(),
                model.as_deref(),
                memory.as_deref(),
                force,
            ))
            .await
        } else if is_tty || env_interactive {
            Box::pin(onboard::run_wizard(force)).await
        } else {
            Box::pin(onboard::run_quick_setup(
                api_key.as_deref(),
                provider.as_deref(),
                model.as_deref(),
                memory.as_deref(),
                force,
            ))
            .await
        }?;

        if config.gateway.require_pairing {
            println!();
            println!("  {}", revka::t!("next-step-pairing-enabled"));
            println!(
                "  {}",
                revka::t!("next-step-dashboard", port = config.gateway.port)
            );
            println!();
        }

        // Auto-start channels if user said yes during wizard
        if std::env::var("REVKA_AUTOSTART_CHANNELS").as_deref() == Ok("1") {
            Box::pin(channels::start_channels(config)).await?;
        }
        return Ok(());
    }

    // All other commands need config loaded first
    let mut config = Box::pin(Config::load_or_init()).await?;

    // Load workspace .env file so secrets (e.g. KUMIHO_SERVICE_TOKEN) are available
    // as environment variables without requiring the user to export them manually.
    let env_path = config.workspace_dir.join(".env");
    if env_path.is_file() {
        if let Ok(contents) = std::fs::read_to_string(&env_path) {
            for line in contents.lines() {
                let line = line.trim();
                if line.is_empty() || line.starts_with('#') {
                    continue;
                }
                if let Some((key, value)) = line.split_once('=') {
                    let key = key.trim();
                    let value = value.trim();
                    // Only set if not already in the environment (explicit env takes precedence)
                    if std::env::var(key).is_err() {
                        // SAFETY: called early in main before worker threads are spawned.
                        unsafe { std::env::set_var(key, value) };
                    }
                }
            }
        }
    }

    // If KUMIHO_SERVICE_TOKEN is still missing after .env loading, fall back
    // to the Kumiho CLI's auth file (~/.kumiho/kumiho_authentication.json,
    // `control_plane_token`). This lets `kumiho login` propagate to Revka
    // without requiring the user to export the token manually — matching the
    // resolution chain that src/agent/kumiho.rs already uses for MCP auth.
    inject_kumiho_service_token_from_auth_file();

    config.apply_env_overrides();
    observability::runtime_trace::init_from_config(&config.observability, &config.workspace_dir);
    if config.security.otp.enabled {
        let config_dir = config
            .config_path
            .parent()
            .context("Config path must have a parent directory")?;
        let store = security::SecretStore::new(config_dir, config.secrets.encrypt);
        let (_validator, enrollment_uri) =
            security::OtpValidator::from_config(&config.security.otp, config_dir, &store)?;
        if let Some(uri) = enrollment_uri {
            println!("Initialized OTP secret for Revka.");
            println!("Enrollment URI: {uri}");
        }
    }

    if needs_sidecars(&cli.command) {
        sidecars::ensure_sidecars_ready(true).await?;
    }

    match cli.command {
        Commands::Onboard { .. } | Commands::Completions { .. } => unreachable!(),

        Commands::Agent {
            message,
            session_state_file,
            provider,
            model,
            temperature,
            peripheral,
        } => {
            let final_temperature = temperature.unwrap_or(config.default_temperature);

            Box::pin(agent::run(
                config,
                message,
                provider,
                model,
                final_temperature,
                peripheral,
                true,
                session_state_file,
                None,
            ))
            .await
            .map(|_| ())
        }

        Commands::Acp {
            max_sessions,
            session_timeout,
        } => {
            let mut acp_config = channels::acp_server::AcpServerConfig::default();
            if let Some(max) = max_sessions {
                acp_config.max_sessions = max;
            }
            if let Some(timeout) = session_timeout {
                acp_config.session_timeout_secs = timeout;
            }
            let server = channels::acp_server::AcpServer::new(config, acp_config);
            server.run().await
        }

        Commands::Gateway { gateway_command } => {
            match gateway_command {
                Some(revka::GatewayCommands::Restart { port, host }) => {
                    let (port, host) = resolve_gateway_addr(&config, port, host);
                    let addr = format!("{host}:{port}");
                    info!("🔄 Restarting Revka Gateway on {addr}");

                    // Try to gracefully shutdown existing gateway via admin endpoint
                    match shutdown_gateway(&host, port).await {
                        Ok(()) => {
                            info!("   ✓ Existing gateway on {addr} shut down gracefully");
                            // Poll until the port is free (connection refused) or timeout
                            let deadline =
                                tokio::time::Instant::now() + tokio::time::Duration::from_secs(5);
                            loop {
                                match tokio::net::TcpStream::connect(&addr).await {
                                    Err(_) => break, // port is free
                                    Ok(_) if tokio::time::Instant::now() >= deadline => {
                                        warn!(
                                            "   Timed out waiting for port {port} to be released"
                                        );
                                        break;
                                    }
                                    Ok(_) => {
                                        tokio::time::sleep(tokio::time::Duration::from_millis(50))
                                            .await;
                                    }
                                }
                            }
                        }
                        Err(e) => {
                            info!("   No existing gateway to shut down: {e}");
                        }
                    }

                    log_gateway_start(&host, port);
                    Box::pin(gateway::run_gateway(&host, port, config)).await
                }
                Some(revka::GatewayCommands::GetPaircode { new }) => {
                    let port = config.gateway.port;
                    let host = &config.gateway.host;

                    // Fetch live pairing code from running gateway
                    // If --new is specified, generate a fresh pairing code
                    match fetch_paircode(host, port, new).await {
                        Ok(Some(code)) => {
                            println!("🔐 Gateway pairing is enabled.");
                            println!();
                            println!("  ┌──────────────┐");
                            println!("  │  {code}  │");
                            println!("  └──────────────┘");
                            println!();
                            println!("  Use this one-time code to pair a new device:");
                            println!("    POST /pair with header X-Pairing-Code: {code}");
                        }
                        Ok(None) => {
                            if config.gateway.require_pairing {
                                println!(
                                    "🔐 Gateway pairing is enabled, but no active pairing code available."
                                );
                                println!(
                                    "   The gateway may already be paired, or the code has been used."
                                );
                                println!("   Restart the gateway to generate a new pairing code.");
                            } else {
                                println!("⚠️  Gateway pairing is disabled in config.");
                                println!(
                                    "   All requests will be accepted without authentication."
                                );
                                println!(
                                    "   To enable pairing, set [gateway] require_pairing = true"
                                );
                            }
                        }
                        Err(e) => {
                            println!(
                                "❌ Failed to fetch pairing code from gateway at {host}:{port}"
                            );
                            println!("   Error: {e}");
                            println!();
                            println!("   Is the gateway running? Start it with:");
                            println!("     revka gateway start");
                        }
                    }
                    Ok(())
                }
                Some(revka::GatewayCommands::Start { port, host }) => {
                    let (port, host) = resolve_gateway_addr(&config, port, host);
                    log_gateway_start(&host, port);
                    Box::pin(gateway::run_gateway(&host, port, config)).await
                }
                None => {
                    let port = config.gateway.port;
                    let host = config.gateway.host.clone();
                    log_gateway_start(&host, port);
                    Box::pin(gateway::run_gateway(&host, port, config)).await
                }
            }
        }

        Commands::Pair { pair_command } => handle_pair_command(pair_command, &config).await,

        Commands::Daemon { port, host } => {
            if let Ok(exe) = std::env::current_exe() {
                let exe_str = exe.to_string_lossy();
                if exe_str.contains(".cargo/bin") || exe_str.contains("/home/") {
                    tracing::warn!(
                        "Daemon running from user home directory: {}. \
                         Consider installing to /usr/local/bin for system-wide service.",
                        exe_str
                    );
                }
            }
            let port = port.unwrap_or(config.gateway.port);
            let host = host.unwrap_or_else(|| config.gateway.host.clone());
            if port == 0 {
                info!("🧠 Starting Revka Daemon on {host} (random port)");
            } else {
                info!("🧠 Starting Revka Daemon on {host}:{port}");
            }
            Box::pin(daemon::run(config, host, port)).await
        }

        Commands::Status { format } => {
            if format.as_deref() == Some("exit-code") {
                // Lightweight health probe for Docker HEALTHCHECK
                let port = config.gateway.port;
                let host = if config.gateway.host == "[::]" || config.gateway.host == "0.0.0.0" {
                    "127.0.0.1"
                } else {
                    &config.gateway.host
                };
                let url = format!("http://{}:{}/health", host, port);
                match reqwest::Client::new()
                    .get(&url)
                    .timeout(std::time::Duration::from_secs(5))
                    .send()
                    .await
                {
                    Ok(resp) if resp.status().is_success() => {
                        std::process::exit(0);
                    }
                    _ => {
                        std::process::exit(1);
                    }
                }
            }
            println!("🦀 Revka Status");
            println!();
            println!("Version:     {}", env!("CARGO_PKG_VERSION"));
            println!("Workspace:   {}", config.workspace_dir.display());
            println!("Config:      {}", config.config_path.display());
            println!();
            println!(
                "🤖 Provider:      {}",
                config.default_provider.as_deref().unwrap_or("openrouter")
            );
            println!(
                "   Model:         {}",
                config.default_model.as_deref().unwrap_or("(default)")
            );
            println!("📊 Observability:  {}", config.observability.backend);
            println!(
                "🧾 Trace storage:  {} ({})",
                config.observability.runtime_trace_mode, config.observability.runtime_trace_path
            );
            println!("🛡️  Autonomy:      {:?}", config.autonomy.level);
            println!("⚙️  Runtime:       {}", config.runtime.kind);
            if service::is_running() {
                println!("🟢 Service:       running");
            } else {
                println!("🔴 Service:       stopped");
            }
            let effective_memory_backend = memory::effective_memory_backend_name(
                &config.memory.backend,
                Some(&config.storage.provider.config),
            );
            println!(
                "💓 Heartbeat:      {}",
                if config.heartbeat.enabled {
                    format!("every {}min", config.heartbeat.interval_minutes)
                } else {
                    "disabled".into()
                }
            );
            println!(
                "🧠 Memory:         {} (auto-save: {})",
                effective_memory_backend,
                if config.memory.auto_save { "on" } else { "off" }
            );

            println!();
            println!("Security:");
            println!("  Workspace only:    {}", config.autonomy.workspace_only);
            println!(
                "  Allowed roots:     {}",
                if config.autonomy.allowed_roots.is_empty() {
                    "(none)".to_string()
                } else {
                    config.autonomy.allowed_roots.join(", ")
                }
            );
            println!(
                "  Allowed commands:  {}",
                config.autonomy.allowed_commands.join(", ")
            );
            println!(
                "  Max actions/hour:  {}",
                config.autonomy.max_actions_per_hour
            );
            println!(
                "  Cost tracking:     {}",
                if config.cost.enabled {
                    "enabled"
                } else {
                    "disabled"
                }
            );
            println!("  Max cost/day:      ${:.2}", config.cost.daily_limit_usd);
            println!("  Max cost/month:    ${:.2}", config.cost.monthly_limit_usd);
            if config.cost.enabled {
                match cost::CostTracker::new(config.cost.clone(), &config.workspace_dir) {
                    Ok(tracker) => match tracker.get_summary() {
                        Ok(summary) => {
                            println!(
                                "  Spent today:       ${:.4} / ${:.2}",
                                summary.daily_cost_usd, config.cost.daily_limit_usd
                            );
                            println!(
                                "  Spent this month:  ${:.4} / ${:.2}",
                                summary.monthly_cost_usd, config.cost.monthly_limit_usd
                            );
                        }
                        Err(e) => {
                            eprintln!("  ⚠ Could not load cost usage: {e}");
                        }
                    },
                    Err(e) => {
                        eprintln!("  ⚠ Could not init cost tracker: {e}");
                    }
                }
            }
            println!("  OTP enabled:       {}", config.security.otp.enabled);
            println!("  E-stop enabled:    {}", config.security.estop.enabled);
            println!();
            println!("Channels:");
            println!("  CLI:      ✅ always");
            for (channel, configured) in config.channels_config.channels() {
                println!(
                    "  {:9} {}",
                    channel.name(),
                    if configured {
                        "✅ configured"
                    } else {
                        "❌ not configured"
                    }
                );
            }
            println!();
            println!("Peripherals:");
            println!(
                "  Enabled:   {}",
                if config.peripherals.enabled {
                    "yes"
                } else {
                    "no"
                }
            );
            println!("  Boards:    {}", config.peripherals.boards.len());

            Ok(())
        }

        Commands::Estop {
            estop_command,
            level,
            domains,
            tools,
        } => handle_estop_command(&config, estop_command, level, domains, tools),

        Commands::Cron { cron_command } => cron::handle_command(cron_command, &config),

        Commands::Models { model_command } => match model_command {
            ModelCommands::Refresh {
                provider,
                all,
                force,
            } => {
                if all {
                    if provider.is_some() {
                        bail!("`models refresh --all` cannot be combined with --provider");
                    }
                    onboard::run_models_refresh_all(&config, force).await
                } else {
                    onboard::run_models_refresh(&config, provider.as_deref(), force).await
                }
            }
            ModelCommands::List { provider } => {
                onboard::run_models_list(&config, provider.as_deref()).await
            }
            ModelCommands::Set { model } => {
                Box::pin(onboard::run_models_set(&config, &model)).await
            }
            ModelCommands::Status => onboard::run_models_status(&config).await,
        },

        Commands::Providers => {
            let providers = providers::list_providers();
            let current = config
                .default_provider
                .as_deref()
                .unwrap_or("openrouter")
                .trim()
                .to_ascii_lowercase();
            println!("Supported providers ({} total):\n", providers.len());
            println!("  ID (use in config)  DESCRIPTION");
            println!("  ─────────────────── ───────────");
            for p in &providers {
                let is_active = p.name.eq_ignore_ascii_case(&current)
                    || p.aliases
                        .iter()
                        .any(|alias| alias.eq_ignore_ascii_case(&current));
                let marker = if is_active { " (active)" } else { "" };
                let local_tag = if p.local { " [local]" } else { "" };
                let aliases = if p.aliases.is_empty() {
                    String::new()
                } else {
                    format!("  (aliases: {})", p.aliases.join(", "))
                };
                println!(
                    "  {:<19} {}{}{}{}",
                    p.name, p.display_name, local_tag, marker, aliases
                );
            }
            println!("\n  custom:<URL>   Any OpenAI-compatible endpoint");
            println!("  anthropic-custom:<URL>  Any Anthropic-compatible endpoint");
            Ok(())
        }

        Commands::Service {
            service_command,
            service_init,
        } => {
            let init_system = service_init.parse()?;
            service::handle_command(&service_command, &config, init_system)
        }

        Commands::Doctor { doctor_command } => match doctor_command {
            Some(DoctorCommands::Models {
                provider,
                use_cache,
            }) => doctor::run_models(&config, provider.as_deref(), use_cache).await,
            Some(DoctorCommands::Traces {
                id,
                event,
                contains,
                limit,
            }) => doctor::run_traces(
                &config,
                id.as_deref(),
                event.as_deref(),
                contains.as_deref(),
                limit,
            ),
            None => doctor::run(&config),
        },

        Commands::Channel { channel_command } => match channel_command {
            ChannelCommands::Start => Box::pin(channels::start_channels(config)).await,
            ChannelCommands::Doctor => Box::pin(channels::doctor_channels(config)).await,
            other => Box::pin(channels::handle_command(other, &config)).await,
        },

        Commands::Integrations {
            integration_command,
        } => integrations::handle_command(integration_command, &config),

        Commands::Skills { skill_command } => skills::handle_command(skill_command, &config),

        Commands::Workflows { workflow_command } => match workflow_command {
            WorkflowCommands::List => {
                onboard::builtin_workflows::run_list();
                Ok(())
            }
            WorkflowCommands::Sync { force } => {
                onboard::builtin_workflows::run_sync(config.workspace_dir.clone(), force).await
            }
        },

        Commands::Migrate { migrate_command } => {
            migration::handle_command(migrate_command, &config).await
        }

        Commands::Memory { memory_command } => {
            memory::cli::handle_command(memory_command, &config).await
        }

        Commands::Auth { auth_command } => handle_auth_command(auth_command, &config).await,

        Commands::Hardware { hardware_command } => {
            hardware::handle_command(hardware_command.clone(), &config)
        }

        Commands::Peripheral { peripheral_command } => {
            Box::pin(peripherals::handle_command(
                peripheral_command.clone(),
                &config,
            ))
            .await
        }

        Commands::Desktop {
            install: do_install,
        } => {
            let download_url = "https://www.kumiho.io/download";

            if do_install {
                println!("Download the Revka companion app:");
                println!();
                #[cfg(target_os = "macos")]
                {
                    println!("  macOS:  {download_url}");
                    println!();
                    println!("Or install via Homebrew (coming soon):");
                    println!("  brew install --cask revka");
                }
                #[cfg(target_os = "linux")]
                {
                    println!("  Linux:  {download_url}");
                    println!();
                    println!("  Download the .deb or .AppImage for your architecture.");
                }
                #[cfg(not(any(target_os = "macos", target_os = "linux")))]
                {
                    println!("  {download_url}");
                }
                println!();

                // On macOS, open the download page in the browser
                #[cfg(target_os = "macos")]
                {
                    let _ = std::process::Command::new("open").arg(download_url).spawn();
                }
                #[cfg(target_os = "linux")]
                {
                    let _ = std::process::Command::new("xdg-open")
                        .arg(download_url)
                        .spawn();
                }
                return Ok(());
            }

            // Locate the companion app
            let desktop_bin = {
                let mut found = None;

                // 1. macOS: check /Applications/Revka.app
                #[cfg(target_os = "macos")]
                {
                    let app_paths = [
                        PathBuf::from("/Applications/Revka.app/Contents/MacOS/Revka"),
                        PathBuf::from(std::env::var("HOME").unwrap_or_default())
                            .join("Applications/Revka.app/Contents/MacOS/Revka"),
                    ];
                    for app in &app_paths {
                        if app.is_file() {
                            found = Some(app.clone());
                            break;
                        }
                    }
                }

                // 2. Same directory as the current executable
                if found.is_none() {
                    if let Ok(exe) = std::env::current_exe() {
                        let sibling = exe.with_file_name("revka-desktop");
                        if sibling.is_file() {
                            found = Some(sibling);
                        }
                    }
                }

                // 3. ~/.cargo/bin/revka-desktop or ~/.local/bin/revka-desktop
                if found.is_none() {
                    if let Some(home) = std::env::var_os("HOME") {
                        let home = PathBuf::from(home);
                        for dir in &[".cargo/bin", ".local/bin"] {
                            let candidate = home.join(dir).join("revka-desktop");
                            if candidate.is_file() {
                                found = Some(candidate);
                                break;
                            }
                        }
                    }
                }

                // 4. Fallback to PATH lookup
                if found.is_none() {
                    if let Ok(path) = which::which("revka-desktop") {
                        found = Some(path);
                    }
                }

                found
            };

            match desktop_bin {
                Some(bin) => {
                    println!("Launching Revka companion app...");
                    let _child = std::process::Command::new(&bin)
                        .spawn()
                        .with_context(|| format!("Failed to launch {}", bin.display()))?;
                    Ok(())
                }
                None => {
                    println!("Revka companion app is not installed.");
                    println!();
                    println!("  Download it at: {download_url}");
                    println!("  Or run: revka desktop --install");
                    println!();
                    println!("The companion app is a lightweight menu bar app that");
                    println!("connects to the same gateway as the CLI.");
                    std::process::exit(1);
                }
            }
        }

        Commands::Install {
            sidecars_only,
            skip_kumiho,
            skip_operator,
            with_session_manager,
            dry_run,
            python,
            from_source,
        } => {
            commands::install::run(commands::install::InstallOptions {
                sidecars_only,
                skip_kumiho,
                skip_operator,
                with_session_manager,
                dry_run,
                python,
                from_source,
            })
            .await
        }

        Commands::Update {
            check,
            force: _force,
            version,
        } => {
            if check {
                let info = commands::update::check(version.as_deref()).await?;
                if info.is_newer {
                    println!(
                        "Update available: v{} -> v{}",
                        info.current_version, info.latest_version
                    );
                } else {
                    println!("Already up to date (v{}).", info.current_version);
                }
                Ok(())
            } else {
                commands::update::run(version.as_deref()).await
            }
        }

        Commands::SelfTest { quick } => {
            let results = if quick {
                commands::self_test::run_quick(&config).await?
            } else {
                commands::self_test::run_full(&config).await?
            };
            commands::self_test::print_results(&results);
            let failed = results.iter().filter(|r| !r.passed).count();
            if failed > 0 {
                std::process::exit(1);
            }
            Ok(())
        }

        Commands::Config { config_command } => match config_command {
            ConfigCommands::Schema => {
                let schema = schemars::schema_for!(config::Config);
                println!(
                    "{}",
                    serde_json::to_string_pretty(&schema).expect("failed to serialize JSON Schema")
                );
                Ok(())
            }
        },

        #[cfg(feature = "plugins-wasm")]
        Commands::Plugin { plugin_command } => match plugin_command {
            PluginCommands::List => {
                let host = revka::plugins::host::PluginHost::new(&config.workspace_dir)?;
                let plugins = host.list_plugins();
                if plugins.is_empty() {
                    println!("No plugins installed.");
                } else {
                    println!("Installed plugins:");
                    for p in &plugins {
                        println!(
                            "  {} v{} — {}",
                            p.name,
                            p.version,
                            p.description.as_deref().unwrap_or("(no description)")
                        );
                    }
                }
                Ok(())
            }
            PluginCommands::Install { source } => {
                let mut host = revka::plugins::host::PluginHost::new(&config.workspace_dir)?;
                host.install(&source)?;
                println!("Plugin installed from {source}");
                Ok(())
            }
            PluginCommands::Remove { name } => {
                let mut host = revka::plugins::host::PluginHost::new(&config.workspace_dir)?;
                host.remove(&name)?;
                println!("Plugin '{name}' removed.");
                Ok(())
            }
            PluginCommands::Info { name } => {
                let host = revka::plugins::host::PluginHost::new(&config.workspace_dir)?;
                match host.get_plugin(&name) {
                    Some(info) => {
                        println!("Plugin: {} v{}", info.name, info.version);
                        if let Some(desc) = &info.description {
                            println!("Description: {desc}");
                        }
                        println!("Capabilities: {:?}", info.capabilities);
                        println!("Permissions: {:?}", info.permissions);
                        println!("WASM: {}", info.wasm_path.display());
                    }
                    None => println!("Plugin '{name}' not found."),
                }
                Ok(())
            }
        },
    }
}

fn handle_estop_command(
    config: &Config,
    estop_command: Option<EstopSubcommands>,
    level: Option<EstopLevelArg>,
    domains: Vec<String>,
    tools: Vec<String>,
) -> Result<()> {
    if !config.security.estop.enabled {
        bail!("Emergency stop is disabled. Enable [security.estop].enabled = true in config.toml");
    }

    let config_dir = config
        .config_path
        .parent()
        .context("Config path must have a parent directory")?;
    let mut manager = security::EstopManager::load(&config.security.estop, config_dir)?;

    match estop_command {
        Some(EstopSubcommands::Status) => {
            print_estop_status(&manager.status());
            Ok(())
        }
        Some(EstopSubcommands::Resume {
            network,
            domains,
            tools,
            otp,
        }) => {
            let selector = build_resume_selector(network, domains, tools)?;
            let mut otp_code = otp;
            let otp_validator = if config.security.estop.require_otp_to_resume {
                if !config.security.otp.enabled {
                    bail!(
                        "security.estop.require_otp_to_resume=true but security.otp.enabled=false"
                    );
                }
                if otp_code.is_none() {
                    let entered = Password::new()
                        .with_prompt("Enter OTP code")
                        .allow_empty_password(false)
                        .interact()?;
                    otp_code = Some(entered);
                }

                let store = security::SecretStore::new(config_dir, config.secrets.encrypt);
                let (validator, enrollment_uri) =
                    security::OtpValidator::from_config(&config.security.otp, config_dir, &store)?;
                if let Some(uri) = enrollment_uri {
                    println!("Initialized OTP secret for Revka.");
                    println!("Enrollment URI: {uri}");
                }
                Some(validator)
            } else {
                None
            };

            manager.resume(selector, otp_code.as_deref(), otp_validator.as_ref())?;
            println!("Estop resume completed.");
            print_estop_status(&manager.status());
            Ok(())
        }
        None => {
            let engage_level = build_engage_level(level, domains, tools)?;
            manager.engage(engage_level)?;
            println!("Estop engaged.");
            print_estop_status(&manager.status());
            Ok(())
        }
    }
}

fn build_engage_level(
    level: Option<EstopLevelArg>,
    domains: Vec<String>,
    tools: Vec<String>,
) -> Result<security::EstopLevel> {
    let requested = level.unwrap_or(EstopLevelArg::KillAll);
    match requested {
        EstopLevelArg::KillAll => {
            if !domains.is_empty() || !tools.is_empty() {
                bail!("--domain/--tool are only valid with --level domain-block/tool-freeze");
            }
            Ok(security::EstopLevel::KillAll)
        }
        EstopLevelArg::NetworkKill => {
            if !domains.is_empty() || !tools.is_empty() {
                bail!("--domain/--tool are not valid with --level network-kill");
            }
            Ok(security::EstopLevel::NetworkKill)
        }
        EstopLevelArg::DomainBlock => {
            if domains.is_empty() {
                bail!("--level domain-block requires at least one --domain");
            }
            if !tools.is_empty() {
                bail!("--tool is not valid with --level domain-block");
            }
            Ok(security::EstopLevel::DomainBlock(domains))
        }
        EstopLevelArg::ToolFreeze => {
            if tools.is_empty() {
                bail!("--level tool-freeze requires at least one --tool");
            }
            if !domains.is_empty() {
                bail!("--domain is not valid with --level tool-freeze");
            }
            Ok(security::EstopLevel::ToolFreeze(tools))
        }
    }
}

fn build_resume_selector(
    network: bool,
    domains: Vec<String>,
    tools: Vec<String>,
) -> Result<security::ResumeSelector> {
    let selected =
        usize::from(network) + usize::from(!domains.is_empty()) + usize::from(!tools.is_empty());
    if selected > 1 {
        bail!("Use only one of --network, --domain, or --tool for estop resume");
    }
    if network {
        return Ok(security::ResumeSelector::Network);
    }
    if !domains.is_empty() {
        return Ok(security::ResumeSelector::Domains(domains));
    }
    if !tools.is_empty() {
        return Ok(security::ResumeSelector::Tools(tools));
    }
    Ok(security::ResumeSelector::KillAll)
}

fn print_estop_status(state: &security::EstopState) {
    println!("Estop status:");
    println!(
        "  engaged:        {}",
        if state.is_engaged() { "yes" } else { "no" }
    );
    println!(
        "  kill_all:       {}",
        if state.kill_all { "active" } else { "inactive" }
    );
    println!(
        "  network_kill:   {}",
        if state.network_kill {
            "active"
        } else {
            "inactive"
        }
    );
    if state.blocked_domains.is_empty() {
        println!("  domain_blocks:  (none)");
    } else {
        println!("  domain_blocks:  {}", state.blocked_domains.join(", "));
    }
    if state.frozen_tools.is_empty() {
        println!("  tool_freeze:    (none)");
    } else {
        println!("  tool_freeze:    {}", state.frozen_tools.join(", "));
    }
    if let Some(updated_at) = &state.updated_at {
        println!("  updated_at:     {updated_at}");
    }
}

fn write_shell_completion<W: Write>(shell: CompletionShell, writer: &mut W) -> Result<()> {
    use clap_complete::generate;
    use clap_complete::shells;

    let mut cmd = Cli::command();
    let bin_name = cmd.get_name().to_string();

    match shell {
        CompletionShell::Bash => generate(shells::Bash, &mut cmd, bin_name.clone(), writer),
        CompletionShell::Fish => generate(shells::Fish, &mut cmd, bin_name.clone(), writer),
        CompletionShell::Zsh => generate(shells::Zsh, &mut cmd, bin_name.clone(), writer),
        CompletionShell::PowerShell => {
            generate(shells::PowerShell, &mut cmd, bin_name.clone(), writer);
        }
        CompletionShell::Elvish => generate(shells::Elvish, &mut cmd, bin_name, writer),
    }

    writer.flush()?;
    Ok(())
}

// ─── Gateway helper functions ───────────────────────────────────────────────

/// Resolve gateway host and port from CLI args or config.
fn resolve_gateway_addr(config: &Config, port: Option<u16>, host: Option<String>) -> (u16, String) {
    let port = port.unwrap_or(config.gateway.port);
    let host = host.unwrap_or_else(|| config.gateway.host.clone());
    (port, host)
}

/// Log gateway startup message.
fn log_gateway_start(host: &str, port: u16) {
    if port == 0 {
        info!("🚀 Starting Revka Gateway on {host} (random port)");
    } else {
        info!("🚀 Starting Revka Gateway on {host}:{port}");
    }
}

/// Gracefully shutdown a running gateway via the admin endpoint.
async fn shutdown_gateway(host: &str, port: u16) -> Result<()> {
    let url = format!("http://{host}:{port}/admin/shutdown");
    let client = reqwest::Client::new();

    match client
        .post(&url)
        .timeout(std::time::Duration::from_secs(5))
        .send()
        .await
    {
        Ok(response) if response.status().is_success() => Ok(()),
        Ok(response) => Err(anyhow::anyhow!(
            "Gateway responded with status: {}",
            response.status()
        )),
        Err(e) => Err(anyhow::anyhow!("Failed to connect to gateway: {e}")),
    }
}

/// Fetch the current pairing code from a running gateway.
/// If `new` is true, generates a fresh pairing code via POST request.
async fn fetch_paircode(host: &str, port: u16, new: bool) -> Result<Option<String>> {
    let client = reqwest::Client::new();

    let response = if new {
        // Generate a new pairing code via POST
        let url = format!("http://{host}:{port}/admin/paircode/new");
        client
            .post(&url)
            .timeout(std::time::Duration::from_secs(5))
            .send()
            .await
    } else {
        // Get existing pairing code via GET
        let url = format!("http://{host}:{port}/admin/paircode");
        client
            .get(&url)
            .timeout(std::time::Duration::from_secs(5))
            .send()
            .await
    };

    let response = response.map_err(|e| anyhow::anyhow!("Failed to connect to gateway: {e}"))?;

    if !response.status().is_success() {
        return Err(anyhow::anyhow!(
            "Gateway responded with status: {}",
            response.status()
        ));
    }

    let json: serde_json::Value = response
        .json()
        .await
        .map_err(|e| anyhow::anyhow!("Failed to parse response: {e}"))?;

    if json.get("success").and_then(|v| v.as_bool()) != Some(true) {
        return Ok(None);
    }

    Ok(json
        .get("pairing_code")
        .and_then(|v| v.as_str())
        .map(String::from))
}

#[derive(Debug, Serialize)]
struct PairTokenRequest {
    code: String,
    device_name: Option<String>,
    device_type: Option<String>,
    hardware: Option<String>,
}

#[derive(Debug, Deserialize)]
struct PairTokenResponse {
    token: Option<String>,
    persisted: Option<bool>,
    message: Option<String>,
}

async fn handle_pair_command(pair_command: PairCommands, config: &Config) -> Result<()> {
    match pair_command {
        PairCommands::Token {
            code,
            url,
            host,
            port,
            name,
            device_type,
            hardware,
            json,
        } => {
            let code = code.trim().to_string();
            if code.is_empty() {
                bail!("--code cannot be empty");
            }

            let request = PairTokenRequest {
                code,
                device_name: Some(
                    clean_optional_cli_value(name).unwrap_or_else(default_pairing_cli_device_name),
                ),
                device_type: Some(
                    clean_optional_cli_value(Some(device_type))
                        .unwrap_or_else(|| "cli".to_string()),
                ),
                hardware: Some(
                    clean_optional_cli_value(hardware).unwrap_or_else(default_pairing_cli_hardware),
                ),
            };

            let base_url = gateway_pairing_base_url(config, url.as_deref(), host, port);
            let endpoint = format!("{}/api/pair", base_url.trim_end_matches('/'));
            let client = reqwest::Client::new();
            let response = client
                .post(&endpoint)
                .header(
                    reqwest::header::USER_AGENT,
                    format!("revka-cli/{}", env!("CARGO_PKG_VERSION")),
                )
                .timeout(std::time::Duration::from_secs(10))
                .json(&request)
                .send()
                .await
                .with_context(|| format!("Failed to connect to gateway at {endpoint}"))?;

            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            if !status.is_success() {
                let detail = body.trim();
                if detail.is_empty() {
                    bail!("Pairing token mint failed ({status})");
                }
                bail!("Pairing token mint failed ({status}): {detail}");
            }

            let parsed: PairTokenResponse =
                serde_json::from_str(&body).context("Failed to parse pairing response")?;
            let token = parsed
                .token
                .context("Pairing response did not include a token")?;
            let persisted = parsed.persisted.unwrap_or(false);

            if json {
                let authorization = format!("Bearer {token}");
                let output = serde_json::json!({
                    "token": token,
                    "authorization": authorization,
                    "persisted": persisted,
                    "message": parsed.message,
                    "device_name": request.device_name,
                    "device_type": request.device_type,
                    "hardware": request.hardware,
                });
                println!("{}", serde_json::to_string_pretty(&output)?);
            } else {
                println!("Bearer token minted.");
                if let Some(device_name) = request.device_name.as_deref() {
                    println!("Device: {device_name}");
                }
                if !persisted {
                    println!("Warning: token is active for this process but was not persisted.");
                }
                println!();
                println!("Token:");
                println!("{token}");
                println!();
                println!("Authorization header:");
                println!("Authorization: Bearer {token}");
            }

            Ok(())
        }
    }
}

fn gateway_pairing_base_url(
    config: &Config,
    url: Option<&str>,
    host: Option<String>,
    port: Option<u16>,
) -> String {
    if let Some(url) = url.and_then(|value| {
        let trimmed = value.trim();
        (!trimmed.is_empty()).then_some(trimmed)
    }) {
        return url.trim_end_matches('/').to_string();
    }

    let (port, host) = resolve_gateway_addr(config, port, host);
    let host = gateway_connect_host(&host);
    let host = http_host_literal(&host);
    let mut base = format!("http://{host}:{port}");
    if let Some(prefix) = config.gateway.path_prefix.as_deref() {
        base.push_str(prefix);
    }
    base
}

fn gateway_connect_host(host: &str) -> String {
    match host.trim() {
        "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(),
        other => other.to_string(),
    }
}

fn http_host_literal(host: &str) -> String {
    if host.contains(':') && !host.starts_with('[') {
        format!("[{host}]")
    } else {
        host.to_string()
    }
}

fn clean_optional_cli_value(value: Option<String>) -> Option<String> {
    value.and_then(|value| {
        let trimmed = value.trim();
        (!trimmed.is_empty()).then(|| trimmed.to_string())
    })
}

fn default_pairing_cli_device_name() -> String {
    hostname::get()
        .ok()
        .and_then(|name| name.into_string().ok())
        .and_then(|name| {
            let trimmed = name.trim();
            (!trimmed.is_empty()).then(|| format!("Revka CLI ({trimmed})"))
        })
        .unwrap_or_else(|| "Revka CLI".to_string())
}

fn default_pairing_cli_hardware() -> String {
    format!("{} {}", std::env::consts::OS, std::env::consts::ARCH)
}

// ─── Generic Pending OAuth Login ────────────────────────────────────────────

/// Generic pending OAuth login state, shared across providers.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingOAuthLogin {
    provider: String,
    profile: String,
    code_verifier: String,
    state: String,
    created_at: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingOAuthLoginFile {
    #[serde(default)]
    provider: Option<String>,
    profile: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    code_verifier: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    encrypted_code_verifier: Option<String>,
    state: String,
    created_at: String,
}

fn pending_oauth_login_path(config: &Config, provider: &str) -> std::path::PathBuf {
    let filename = format!("auth-{}-pending.json", provider);
    auth::state_dir_from_config(config).join(filename)
}

fn pending_oauth_secret_store(config: &Config) -> security::secrets::SecretStore {
    security::secrets::SecretStore::new(
        &auth::state_dir_from_config(config),
        config.secrets.encrypt,
    )
}

#[cfg(unix)]
fn set_owner_only_permissions(path: &std::path::Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;
    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
    Ok(())
}

#[cfg(not(unix))]
fn set_owner_only_permissions(_path: &std::path::Path) -> Result<()> {
    Ok(())
}

const KUMIHO_AUTH_FILE_TOKEN_GRACE_SECONDS: i64 = 300;

/// Populate `KUMIHO_SERVICE_TOKEN` from the Kumiho CLI's auth file when it is
/// not already set in the environment.
///
/// `kumiho login` writes `~/.kumiho/kumiho_authentication.json` with a
/// `control_plane_token` and `id_token` fields. This is only a fallback for
/// CLI-login users; normal onboarding writes `KUMIHO_SERVICE_TOKEN` to the
/// workspace `.env` and takes precedence.
fn inject_kumiho_service_token_from_auth_file() {
    if std::env::var("KUMIHO_SERVICE_TOKEN")
        .map(|v| !v.trim().is_empty())
        .unwrap_or(false)
    {
        return;
    }
    let Some(user_dirs) = directories::UserDirs::new() else {
        return;
    };
    let auth_path = user_dirs
        .home_dir()
        .join(".kumiho")
        .join("kumiho_authentication.json");
    let Ok(content) = std::fs::read_to_string(&auth_path) else {
        return;
    };
    let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) else {
        return;
    };
    let Some(token) = select_kumiho_auth_file_token(&parsed, chrono::Utc::now().timestamp()) else {
        return;
    };
    // SAFETY: called once early in main before worker threads are spawned.
    unsafe { std::env::set_var("KUMIHO_SERVICE_TOKEN", token) };
}

fn select_kumiho_auth_file_token(parsed: &serde_json::Value, now_secs: i64) -> Option<String> {
    kumiho_auth_file_token_field(parsed, "control_plane_token", "cp_expires_at", now_secs)
        .or_else(|| kumiho_auth_file_token_field(parsed, "id_token", "expires_at", now_secs))
}

fn kumiho_auth_file_token_field(
    parsed: &serde_json::Value,
    token_field: &str,
    expiry_field: &str,
    now_secs: i64,
) -> Option<String> {
    let token = parsed
        .get(token_field)
        .and_then(|v| v.as_str())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())?;

    let Some(expires_at) = parsed.get(expiry_field).and_then(|v| v.as_i64()) else {
        return Some(token);
    };

    (expires_at > now_secs + KUMIHO_AUTH_FILE_TOKEN_GRACE_SECONDS).then_some(token)
}

fn save_pending_oauth_login(config: &Config, pending: &PendingOAuthLogin) -> Result<()> {
    let path = pending_oauth_login_path(config, &pending.provider);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let secret_store = pending_oauth_secret_store(config);
    let encrypted_code_verifier = secret_store.encrypt(&pending.code_verifier)?;
    let persisted = PendingOAuthLoginFile {
        provider: Some(pending.provider.clone()),
        profile: pending.profile.clone(),
        code_verifier: None,
        encrypted_code_verifier: Some(encrypted_code_verifier),
        state: pending.state.clone(),
        created_at: pending.created_at.clone(),
    };
    let tmp = path.with_extension(format!(
        "tmp.{}.{}",
        std::process::id(),
        chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
    ));
    let json = serde_json::to_vec_pretty(&persisted)?;
    std::fs::write(&tmp, json)?;
    set_owner_only_permissions(&tmp)?;
    std::fs::rename(tmp, &path)?;
    set_owner_only_permissions(&path)?;
    Ok(())
}

fn load_pending_oauth_login(config: &Config, provider: &str) -> Result<Option<PendingOAuthLogin>> {
    let path = pending_oauth_login_path(config, provider);
    if !path.exists() {
        return Ok(None);
    }
    let bytes = std::fs::read(&path)?;
    if bytes.is_empty() {
        return Ok(None);
    }
    let persisted: PendingOAuthLoginFile = serde_json::from_slice(&bytes)?;
    let secret_store = pending_oauth_secret_store(config);
    let code_verifier = if let Some(encrypted) = persisted.encrypted_code_verifier {
        secret_store.decrypt(&encrypted)?
    } else if let Some(plaintext) = persisted.code_verifier {
        plaintext
    } else {
        bail!("Pending {} login is missing code verifier", provider);
    };
    Ok(Some(PendingOAuthLogin {
        provider: persisted.provider.unwrap_or_else(|| provider.to_string()),
        profile: persisted.profile,
        code_verifier,
        state: persisted.state,
        created_at: persisted.created_at,
    }))
}

fn clear_pending_oauth_login(config: &Config, provider: &str) {
    let path = pending_oauth_login_path(config, provider);
    if let Ok(file) = std::fs::OpenOptions::new().write(true).open(&path) {
        let _ = file.set_len(0);
        let _ = file.sync_all();
    }
    let _ = std::fs::remove_file(path);
}

fn read_auth_input(prompt: &str) -> Result<String> {
    let input = Password::new()
        .with_prompt(prompt)
        .allow_empty_password(false)
        .interact()?;
    Ok(input.trim().to_string())
}

fn read_plain_input(prompt: &str) -> Result<String> {
    let input: String = cli_input::Input::new()
        .with_prompt(prompt)
        .interact_text()?;
    Ok(input.trim().to_string())
}

fn extract_openai_account_id_for_profile(access_token: &str) -> Option<String> {
    let account_id = auth::openai_oauth::extract_account_id_from_jwt(access_token);
    if account_id.is_none() {
        warn!(
            "Could not extract OpenAI account id from OAuth access token; \
             requests may fail until re-authentication."
        );
    }
    account_id
}

async fn import_openai_codex_auth_profile(
    auth_service: &auth::AuthService,
    profile: &str,
    import_path: &std::path::Path,
) -> Result<()> {
    #[derive(Deserialize)]
    struct CodexAuthTokens {
        access_token: String,
        #[serde(default)]
        refresh_token: Option<String>,
        #[serde(default)]
        id_token: Option<String>,
        #[serde(default)]
        account_id: Option<String>,
    }

    #[derive(Deserialize)]
    struct CodexAuthFile {
        tokens: CodexAuthTokens,
    }

    let raw = std::fs::read_to_string(import_path)
        .with_context(|| format!("Failed to read import file {}", import_path.display()))?;
    let imported: CodexAuthFile = serde_json::from_str(&raw)
        .with_context(|| format!("Failed to parse import file {}", import_path.display()))?;
    let expires_at = auth::openai_oauth::extract_expiry_from_jwt(&imported.tokens.access_token);

    let token_set = auth::profiles::TokenSet {
        access_token: imported.tokens.access_token,
        refresh_token: imported.tokens.refresh_token,
        id_token: imported.tokens.id_token,
        expires_at,
        token_type: Some("Bearer".to_string()),
        scope: None,
    };

    let account_id = imported
        .tokens
        .account_id
        .or_else(|| extract_openai_account_id_for_profile(&token_set.access_token));

    auth_service
        .store_openai_tokens(profile, token_set, account_id, true)
        .await?;

    Ok(())
}

fn format_expiry(profile: &auth::profiles::AuthProfile) -> String {
    match profile
        .token_set
        .as_ref()
        .and_then(|token_set| token_set.expires_at)
    {
        Some(ts) => {
            let now = chrono::Utc::now();
            if ts <= now {
                format!("expired at {}", ts.to_rfc3339())
            } else {
                let mins = (ts - now).num_minutes();
                format!("expires in {mins}m ({})", ts.to_rfc3339())
            }
        }
        None => "n/a".to_string(),
    }
}

#[allow(clippy::too_many_lines)]
async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Result<()> {
    let auth_service = auth::AuthService::from_config(config);

    match auth_command {
        AuthCommands::Login {
            provider,
            profile,
            device_code,
            import,
        } => {
            let provider = auth::normalize_provider(&provider)?;
            if import.is_some() && provider != "openai-codex" {
                bail!("`auth login --import` currently supports only --provider openai-codex");
            }
            let client = reqwest::Client::new();

            match provider.as_str() {
                "gemini" => {
                    // Gemini OAuth flow
                    if device_code {
                        match auth::gemini_oauth::start_device_code_flow(&client).await {
                            Ok(device) => {
                                println!("Google/Gemini device-code login started.");
                                println!("Visit: {}", device.verification_uri);
                                println!("Code:  {}", device.user_code);
                                if let Some(uri_complete) = &device.verification_uri_complete {
                                    println!("Fast link: {uri_complete}");
                                }

                                let token_set =
                                    auth::gemini_oauth::poll_device_code_tokens(&client, &device)
                                        .await?;
                                let account_id = token_set.id_token.as_deref().and_then(
                                    auth::gemini_oauth::extract_account_email_from_id_token,
                                );

                                auth_service
                                    .store_gemini_tokens(&profile, token_set, account_id, true)
                                    .await?;

                                println!("Saved profile {profile}");
                                println!("Active profile for gemini: {profile}");
                                return Ok(());
                            }
                            Err(e) => {
                                println!(
                                    "Device-code flow unavailable: {e}. Falling back to browser flow."
                                );
                            }
                        }
                    }

                    let pkce = auth::gemini_oauth::generate_pkce_state();
                    let authorize_url = auth::gemini_oauth::build_authorize_url(&pkce)?;

                    // Save pending login for paste-redirect fallback
                    let pending = PendingOAuthLogin {
                        provider: "gemini".to_string(),
                        profile: profile.clone(),
                        code_verifier: pkce.code_verifier.clone(),
                        state: pkce.state.clone(),
                        created_at: chrono::Utc::now().to_rfc3339(),
                    };
                    save_pending_oauth_login(config, &pending)?;

                    println!("Open this URL in your browser and authorize access:");
                    println!("{authorize_url}");
                    println!();

                    let code = match auth::gemini_oauth::receive_loopback_code(
                        &pkce.state,
                        std::time::Duration::from_secs(180),
                    )
                    .await
                    {
                        Ok(code) => {
                            clear_pending_oauth_login(config, "gemini");
                            code
                        }
                        Err(e) => {
                            println!("Callback capture failed: {e}");
                            println!(
                                "Run `revka auth paste-redirect --provider gemini --profile {profile}`"
                            );
                            return Ok(());
                        }
                    };

                    let token_set =
                        auth::gemini_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?;
                    let account_id = token_set
                        .id_token
                        .as_deref()
                        .and_then(auth::gemini_oauth::extract_account_email_from_id_token);

                    auth_service
                        .store_gemini_tokens(&profile, token_set, account_id, true)
                        .await?;

                    println!("Saved profile {profile}");
                    println!("Active profile for gemini: {profile}");
                    Ok(())
                }
                "openai-codex" => {
                    if let Some(import_path) = import.as_deref() {
                        import_openai_codex_auth_profile(&auth_service, &profile, import_path)
                            .await?;
                        println!("Imported auth profile from {}", import_path.display());
                        println!("Active profile for openai-codex: {profile}");
                        return Ok(());
                    }

                    // OpenAI Codex OAuth flow
                    if device_code {
                        match auth::openai_oauth::start_device_code_flow(&client).await {
                            Ok(device) => {
                                println!("OpenAI device-code login started.");
                                println!("Visit: {}", device.verification_uri);
                                println!("Code:  {}", device.user_code);
                                if let Some(uri_complete) = &device.verification_uri_complete {
                                    println!("Fast link: {uri_complete}");
                                }
                                if let Some(message) = &device.message {
                                    println!("{message}");
                                }

                                let token_set =
                                    auth::openai_oauth::poll_device_code_tokens(&client, &device)
                                        .await?;
                                let account_id =
                                    extract_openai_account_id_for_profile(&token_set.access_token);

                                auth_service
                                    .store_openai_tokens(&profile, token_set, account_id, true)
                                    .await?;
                                clear_pending_oauth_login(config, "openai");

                                println!("Saved profile {profile}");
                                println!("Active profile for openai-codex: {profile}");
                                return Ok(());
                            }
                            Err(e) => {
                                println!(
                                    "Device-code flow unavailable: {e}. Falling back to browser/paste flow."
                                );
                            }
                        }
                    }

                    let pkce = auth::openai_oauth::generate_pkce_state();
                    let pending = PendingOAuthLogin {
                        provider: "openai".to_string(),
                        profile: profile.clone(),
                        code_verifier: pkce.code_verifier.clone(),
                        state: pkce.state.clone(),
                        created_at: chrono::Utc::now().to_rfc3339(),
                    };
                    save_pending_oauth_login(config, &pending)?;

                    let authorize_url = auth::openai_oauth::build_authorize_url(&pkce);
                    println!("Open this URL in your browser and authorize access:");
                    println!("{authorize_url}");
                    println!();
                    println!("Waiting for callback at http://localhost:1455/auth/callback ...");

                    let code = match auth::openai_oauth::receive_loopback_code(
                        &pkce.state,
                        std::time::Duration::from_secs(180),
                    )
                    .await
                    {
                        Ok(code) => code,
                        Err(e) => {
                            println!("Callback capture failed: {e}");
                            println!(
                                "Run `revka auth paste-redirect --provider openai-codex --profile {profile}`"
                            );
                            return Ok(());
                        }
                    };

                    let token_set =
                        auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?;
                    let account_id = extract_openai_account_id_for_profile(&token_set.access_token);

                    auth_service
                        .store_openai_tokens(&profile, token_set, account_id, true)
                        .await?;
                    clear_pending_oauth_login(config, "openai");

                    println!("Saved profile {profile}");
                    println!("Active profile for openai-codex: {profile}");
                    Ok(())
                }
                _ => {
                    bail!(
                        "`auth login` supports --provider openai-codex or gemini, got: {provider}"
                    );
                }
            }
        }

        AuthCommands::PasteRedirect {
            provider,
            profile,
            input,
        } => {
            let provider = auth::normalize_provider(&provider)?;

            match provider.as_str() {
                "openai-codex" => {
                    let pending = load_pending_oauth_login(config, "openai")?.ok_or_else(|| {
                        anyhow::anyhow!(
                            "No pending OpenAI login found. Run `revka auth login --provider openai-codex` first."
                        )
                    })?;

                    if pending.profile != profile {
                        bail!(
                            "Pending login profile mismatch: pending={}, requested={}",
                            pending.profile,
                            profile
                        );
                    }

                    let redirect_input = match input {
                        Some(value) => value,
                        None => read_plain_input("Paste redirect URL or OAuth code")?,
                    };

                    let code = auth::openai_oauth::parse_code_from_redirect(
                        &redirect_input,
                        Some(&pending.state),
                    )?;

                    let pkce = auth::openai_oauth::PkceState {
                        code_verifier: pending.code_verifier.clone(),
                        code_challenge: String::new(),
                        state: pending.state.clone(),
                    };

                    let client = reqwest::Client::new();
                    let token_set =
                        auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?;
                    let account_id = extract_openai_account_id_for_profile(&token_set.access_token);

                    auth_service
                        .store_openai_tokens(&profile, token_set, account_id, true)
                        .await?;
                    clear_pending_oauth_login(config, "openai");

                    println!("Saved profile {profile}");
                    println!("Active profile for openai-codex: {profile}");
                }
                "gemini" => {
                    let pending = load_pending_oauth_login(config, "gemini")?.ok_or_else(|| {
                        anyhow::anyhow!(
                            "No pending Gemini login found. Run `revka auth login --provider gemini` first."
                        )
                    })?;

                    if pending.profile != profile {
                        bail!(
                            "Pending login profile mismatch: pending={}, requested={}",
                            pending.profile,
                            profile
                        );
                    }

                    let redirect_input = match input {
                        Some(value) => value,
                        None => read_plain_input("Paste redirect URL or OAuth code")?,
                    };

                    let code = auth::gemini_oauth::parse_code_from_redirect(
                        &redirect_input,
                        Some(&pending.state),
                    )?;

                    let pkce = auth::gemini_oauth::PkceState {
                        code_verifier: pending.code_verifier.clone(),
                        code_challenge: String::new(),
                        state: pending.state.clone(),
                    };

                    let client = reqwest::Client::new();
                    let token_set =
                        auth::gemini_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?;
                    let account_id = token_set
                        .id_token
                        .as_deref()
                        .and_then(auth::gemini_oauth::extract_account_email_from_id_token);

                    auth_service
                        .store_gemini_tokens(&profile, token_set, account_id, true)
                        .await?;
                    clear_pending_oauth_login(config, "gemini");

                    println!("Saved profile {profile}");
                    println!("Active profile for gemini: {profile}");
                }
                _ => {
                    bail!("`auth paste-redirect` supports --provider openai-codex or gemini");
                }
            }
            Ok(())
        }

        AuthCommands::PasteToken {
            provider,
            profile,
            token,
            auth_kind,
        } => {
            let provider = auth::normalize_provider(&provider)?;
            let token = match token {
                Some(token) => token.trim().to_string(),
                None => read_auth_input("Paste token")?,
            };
            if token.is_empty() {
                bail!("Token cannot be empty");
            }

            let kind = auth::anthropic_token::detect_auth_kind(&token, auth_kind.as_deref());
            let mut metadata = std::collections::HashMap::new();
            metadata.insert(
                "auth_kind".to_string(),
                kind.as_metadata_value().to_string(),
            );

            auth_service
                .store_provider_token(&provider, &profile, &token, metadata, true)
                .await?;
            println!("Saved profile {profile}");
            println!("Active profile for {provider}: {profile}");
            Ok(())
        }

        AuthCommands::SetupToken { provider, profile } => {
            let provider = auth::normalize_provider(&provider)?;
            let token = read_auth_input("Paste token")?;
            if token.is_empty() {
                bail!("Token cannot be empty");
            }

            let kind = auth::anthropic_token::detect_auth_kind(&token, Some("authorization"));
            let mut metadata = std::collections::HashMap::new();
            metadata.insert(
                "auth_kind".to_string(),
                kind.as_metadata_value().to_string(),
            );

            auth_service
                .store_provider_token(&provider, &profile, &token, metadata, true)
                .await?;
            println!("Saved profile {profile}");
            println!("Active profile for {provider}: {profile}");
            Ok(())
        }

        AuthCommands::Refresh { provider, profile } => {
            let provider = auth::normalize_provider(&provider)?;

            match provider.as_str() {
                "openai-codex" => {
                    match auth_service
                        .get_valid_openai_access_token(profile.as_deref())
                        .await?
                    {
                        Some(_) => {
                            println!("OpenAI Codex token is valid (refresh completed if needed).");
                            Ok(())
                        }
                        None => {
                            bail!(
                                "No OpenAI Codex auth profile found. Run `revka auth login --provider openai-codex`."
                            )
                        }
                    }
                }
                "gemini" => {
                    match auth_service
                        .get_valid_gemini_access_token(profile.as_deref())
                        .await?
                    {
                        Some(_) => {
                            let profile_name = profile.as_deref().unwrap_or("default");
                            println!("✓ Gemini token refreshed successfully");
                            println!("  Profile: gemini:{}", profile_name);
                            Ok(())
                        }
                        None => {
                            bail!(
                                "No Gemini auth profile found. Run `revka auth login --provider gemini`."
                            )
                        }
                    }
                }
                _ => bail!("`auth refresh` supports --provider openai-codex or gemini"),
            }
        }

        AuthCommands::Logout { provider, profile } => {
            let provider = auth::normalize_provider(&provider)?;
            let removed = auth_service.remove_profile(&provider, &profile).await?;
            if removed {
                println!("Removed auth profile {provider}:{profile}");
            } else {
                println!("Auth profile not found: {provider}:{profile}");
            }
            Ok(())
        }

        AuthCommands::Use { provider, profile } => {
            let provider = auth::normalize_provider(&provider)?;
            auth_service.set_active_profile(&provider, &profile).await?;
            println!("Active profile for {provider}: {profile}");
            Ok(())
        }

        AuthCommands::List => {
            let data = auth_service.load_profiles().await?;
            if data.profiles.is_empty() {
                println!("No auth profiles configured.");
                return Ok(());
            }

            for (id, profile) in &data.profiles {
                let active = data
                    .active_profiles
                    .get(&profile.provider)
                    .is_some_and(|active_id| active_id == id);
                let marker = if active { "*" } else { " " };
                println!("{marker} {id}");
            }

            Ok(())
        }

        AuthCommands::Status => {
            let data = auth_service.load_profiles().await?;
            if data.profiles.is_empty() {
                println!("No auth profiles configured.");
                return Ok(());
            }

            for (id, profile) in &data.profiles {
                let active = data
                    .active_profiles
                    .get(&profile.provider)
                    .is_some_and(|active_id| active_id == id);
                let marker = if active { "*" } else { " " };
                println!(
                    "{} {} kind={:?} account={} expires={}",
                    marker,
                    id,
                    profile.kind,
                    crate::security::redact(profile.account_id.as_deref().unwrap_or("unknown")),
                    format_expiry(profile)
                );
            }

            println!();
            println!("Active profiles:");
            for (provider, profile_id) in &data.active_profiles {
                println!("  {provider}: {profile_id}");
            }

            Ok(())
        }
    }
}

/// Return true if the given command launches a long-running agent/gateway/daemon
/// that talks to the Kumiho or Operator MCP sidecars. These commands trigger
/// first-run sidecar auto-provisioning.
fn needs_sidecars(cmd: &Commands) -> bool {
    match cmd {
        Commands::Agent { .. }
        | Commands::Acp { .. }
        | Commands::Gateway { .. }
        | Commands::Daemon { .. } => true,
        Commands::Channel { channel_command } => {
            matches!(channel_command, ChannelCommands::Start)
        }
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::{CommandFactory, Parser};

    #[test]
    fn cli_definition_has_no_flag_conflicts() {
        Cli::command().debug_assert();
    }

    #[test]
    fn kumiho_auth_file_token_prefers_valid_control_plane_token() {
        let parsed = serde_json::json!({
            "control_plane_token": "cp-token",
            "cp_expires_at": 10_000,
            "id_token": "id-token",
            "expires_at": 10_000
        });

        assert_eq!(
            select_kumiho_auth_file_token(&parsed, 1_000).as_deref(),
            Some("cp-token")
        );
    }

    #[test]
    fn kumiho_auth_file_token_falls_back_when_control_plane_token_expired() {
        let parsed = serde_json::json!({
            "control_plane_token": "expired-cp-token",
            "cp_expires_at": 1_100,
            "id_token": "fresh-id-token",
            "expires_at": 10_000
        });

        assert_eq!(
            select_kumiho_auth_file_token(&parsed, 1_000).as_deref(),
            Some("fresh-id-token")
        );
    }

    #[test]
    fn onboard_help_includes_model_flag() {
        let cmd = Cli::command();
        let onboard = cmd
            .get_subcommands()
            .find(|subcommand| subcommand.get_name() == "onboard")
            .expect("onboard subcommand must exist");

        let has_model_flag = onboard
            .get_arguments()
            .any(|arg| arg.get_id().as_str() == "model" && arg.get_long() == Some("model"));

        assert!(
            has_model_flag,
            "onboard help should include --model for quick setup overrides"
        );
    }

    #[test]
    fn onboard_cli_accepts_model_provider_and_api_key_in_quick_mode() {
        let cli = Cli::try_parse_from([
            "revka",
            "onboard",
            "--provider",
            "openrouter",
            "--model",
            "custom-model-946",
            "--api-key",
            "sk-issue946",
        ])
        .expect("quick onboard invocation should parse");

        match cli.command {
            Commands::Onboard {
                force,
                channels_only,
                api_key,
                provider,
                model,
                ..
            } => {
                assert!(!force);
                assert!(!channels_only);
                assert_eq!(provider.as_deref(), Some("openrouter"));
                assert_eq!(model.as_deref(), Some("custom-model-946"));
                assert_eq!(api_key.as_deref(), Some("sk-issue946"));
            }
            other => panic!("expected onboard command, got {other:?}"),
        }
    }

    #[test]
    fn completions_cli_parses_supported_shells() {
        for shell in ["bash", "fish", "zsh", "powershell", "elvish"] {
            let cli = Cli::try_parse_from(["revka", "completions", shell])
                .expect("completions invocation should parse");
            match cli.command {
                Commands::Completions { .. } => {}
                other => panic!("expected completions command, got {other:?}"),
            }
        }
    }

    #[test]
    fn completion_generation_mentions_binary_name() {
        let mut output = Vec::new();
        write_shell_completion(CompletionShell::Bash, &mut output)
            .expect("completion generation should succeed");
        let script = String::from_utf8(output).expect("completion output should be valid utf-8");
        assert!(
            script.contains("revka"),
            "completion script should reference binary name"
        );
    }

    #[test]
    fn onboard_cli_accepts_force_flag() {
        let cli = Cli::try_parse_from(["revka", "onboard", "--force"])
            .expect("onboard --force should parse");

        match cli.command {
            Commands::Onboard { force, .. } => assert!(force),
            other => panic!("expected onboard command, got {other:?}"),
        }
    }

    #[test]
    fn onboard_cli_rejects_removed_interactive_flag() {
        // --interactive was removed; onboard auto-detects TTY instead.
        assert!(Cli::try_parse_from(["revka", "onboard", "--interactive"]).is_err());
    }

    #[test]
    fn onboard_cli_parses_quick_flag() {
        let cli = Cli::try_parse_from(["revka", "onboard", "--quick"])
            .expect("onboard --quick should parse");

        match cli.command {
            Commands::Onboard { quick, .. } => assert!(quick),
            other => panic!("expected onboard command, got {other:?}"),
        }
    }

    #[test]
    fn onboard_cli_quick_and_channels_only_conflict() {
        // --quick and --channels-only should both parse at the CLI level
        // (the conflict is checked at runtime), but we verify both flags parse.
        let cli = Cli::try_parse_from(["revka", "onboard", "--quick", "--channels-only"]);
        assert!(
            cli.is_ok(),
            "--quick --channels-only should parse at CLI level"
        );
    }

    #[test]
    fn onboard_cli_bare_parses() {
        let cli = Cli::try_parse_from(["revka", "onboard"]).expect("bare onboard should parse");

        match cli.command {
            Commands::Onboard { .. } => {}
            other => panic!("expected onboard command, got {other:?}"),
        }
    }

    #[test]
    fn cli_parses_estop_default_engage() {
        let cli = Cli::try_parse_from(["revka", "estop"]).expect("estop command should parse");

        match cli.command {
            Commands::Estop {
                estop_command,
                level,
                domains,
                tools,
            } => {
                assert!(estop_command.is_none());
                assert!(level.is_none());
                assert!(domains.is_empty());
                assert!(tools.is_empty());
            }
            other => panic!("expected estop command, got {other:?}"),
        }
    }

    #[test]
    fn cli_parses_estop_resume_domain() {
        let cli = Cli::try_parse_from(["revka", "estop", "resume", "--domain", "*.chase.com"])
            .expect("estop resume command should parse");

        match cli.command {
            Commands::Estop {
                estop_command: Some(EstopSubcommands::Resume { domains, .. }),
                ..
            } => assert_eq!(domains, vec!["*.chase.com".to_string()]),
            other => panic!("expected estop resume command, got {other:?}"),
        }
    }

    #[test]
    fn pair_token_cli_accepts_device_metadata_flags() {
        let cli = Cli::try_parse_from([
            "revka",
            "pair",
            "token",
            "--code",
            "123456",
            "--name",
            "Field phone",
            "--device-type",
            "mobile",
            "--hardware",
            "Pixel 8 / Android",
            "--json",
        ])
        .expect("pair token command should parse");

        match cli.command {
            Commands::Pair {
                pair_command:
                    PairCommands::Token {
                        code,
                        name,
                        device_type,
                        hardware,
                        json,
                        ..
                    },
            } => {
                assert_eq!(code, "123456");
                assert_eq!(name.as_deref(), Some("Field phone"));
                assert_eq!(device_type, "mobile");
                assert_eq!(hardware.as_deref(), Some("Pixel 8 / Android"));
                assert!(json);
            }
            other => panic!("expected pair token command, got {other:?}"),
        }
    }

    #[test]
    fn pairing_base_url_uses_loopback_for_public_bind_and_prefix() {
        let mut config = Config::default();
        config.gateway.host = "0.0.0.0".to_string();
        config.gateway.port = 8080;
        config.gateway.path_prefix = Some("/revka".to_string());

        assert_eq!(
            gateway_pairing_base_url(&config, None, None, None),
            "http://127.0.0.1:8080/revka"
        );
    }

    #[test]
    fn pairing_base_url_uses_explicit_url_without_config_prefix() {
        let mut config = Config::default();
        config.gateway.path_prefix = Some("/revka".to_string());

        assert_eq!(
            gateway_pairing_base_url(
                &config,
                Some("http://example.test:9000/custom/"),
                None,
                None
            ),
            "http://example.test:9000/custom"
        );
    }

    #[test]
    fn agent_command_parses_with_temperature() {
        let cli = Cli::try_parse_from(["revka", "agent", "--temperature", "0.5"])
            .expect("agent command with temperature should parse");

        match cli.command {
            Commands::Agent { temperature, .. } => {
                assert_eq!(temperature, Some(0.5));
            }
            other => panic!("expected agent command, got {other:?}"),
        }
    }

    #[test]
    fn agent_command_parses_without_temperature() {
        let cli = Cli::try_parse_from(["revka", "agent", "--message", "hello"])
            .expect("agent command without temperature should parse");

        match cli.command {
            Commands::Agent { temperature, .. } => {
                assert_eq!(temperature, None);
            }
            other => panic!("expected agent command, got {other:?}"),
        }
    }

    #[test]
    fn agent_command_parses_session_state_file() {
        let cli = Cli::try_parse_from(["revka", "agent", "--session-state-file", "session.json"])
            .expect("agent command with session state file should parse");

        match cli.command {
            Commands::Agent {
                session_state_file, ..
            } => {
                assert_eq!(session_state_file, Some(PathBuf::from("session.json")));
            }
            other => panic!("expected agent command, got {other:?}"),
        }
    }

    #[test]
    fn agent_fallback_uses_config_default_temperature() {
        // Test that when user doesn't provide --temperature,
        // the fallback logic works correctly
        let mut config = Config::default(); // default_temperature = 0.7
        config.default_temperature = 1.5;

        // Simulate None temperature (user didn't provide --temperature)
        let user_temperature: Option<f64> = std::hint::black_box(None);
        let final_temperature = user_temperature.unwrap_or(config.default_temperature);

        assert!((final_temperature - 1.5).abs() < f64::EPSILON);
    }

    #[test]
    fn agent_fallback_uses_hardcoded_when_config_uses_default() {
        // Test that when config uses default value (0.7), fallback still works
        let config = Config::default(); // default_temperature = 0.7

        // Simulate None temperature (user didn't provide --temperature)
        let user_temperature: Option<f64> = std::hint::black_box(None);
        let final_temperature = user_temperature.unwrap_or(config.default_temperature);

        assert!((final_temperature - 0.7).abs() < f64::EPSILON);
    }
}
