use std::path::Path;

use clap::{CommandFactory, Parser};

use roboticus_core::config::{RoboticusConfig, resolve_config_path};
use roboticus_server::cli;

mod banner;
mod cli_args;
mod legacy_proxy;
mod serve;

use banner::print_banner;
use cli_args::*;
use serve::FALLBACK_CONFIG;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Install a raw SIGINT handler that force-exits after a 2s grace period.
    // The tokio signal handler inside axum's graceful_shutdown is unreliable
    // when background tasks (Telegram poller, heartbeat) hold the runtime.
    #[cfg(unix)]
    {
        use std::sync::atomic::{AtomicBool, Ordering};
        static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
        // SAFETY: signal_hook::register is safe for async-signal-safe operations.
        // AtomicBool::store is async-signal-safe.
        unsafe {
            signal_hook::low_level::register(signal_hook::consts::SIGINT, || {
                if SIGINT_RECEIVED.swap(true, Ordering::SeqCst) {
                    // Second SIGINT — immediate hard exit
                    std::process::exit(1);
                }
                eprintln!("\n  Shutting down... (press Ctrl+C again to force)");
            })
            .ok();
        }
        // Spawn a watcher that exits after the grace period
        tokio::spawn(async move {
            loop {
                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
                if SIGINT_RECEIVED.load(Ordering::SeqCst) {
                    tokio::time::sleep(std::time::Duration::from_secs(2)).await;
                    std::process::exit(0);
                }
            }
        });
    }

    let parsed = Cli::parse();
    cli::init_theme(
        &parsed.color,
        &parsed.theme,
        parsed.no_draw,
        parsed.nerdmode,
    );
    cli::init_api_key(parsed.api_key.clone());
    let t = cli::theme();
    eprint!("{}", t.reset());
    let url = if parsed.url == "http://localhost:18789" || parsed.url == "http://127.0.0.1:18789" {
        // Default URL — try to derive server address from resolved config
        resolve_config_path(parsed.config.as_deref())
            .and_then(|p| {
                std::fs::read_to_string(p)
                    .inspect_err(|e| {
                        tracing::warn!("failed to read config for URL resolution: {e}")
                    })
                    .ok()
            })
            .and_then(|contents| {
                RoboticusConfig::from_str(&contents)
                    .inspect_err(|e| {
                        tracing::warn!("failed to parse config for URL resolution: {e}")
                    })
                    .ok()
            })
            .map(|cfg| format!("http://{}:{}", cfg.server.bind, cfg.server.port))
            .unwrap_or_else(|| parsed.url.clone())
    } else {
        parsed.url.clone()
    };
    let url = &url;
    let config_flag = parsed.config.clone();

    let result = match parsed.command {
        // ── Lifecycle ───────────────────────────────────────
        Some(Commands::Serve { port, bind }) => {
            serve::cmd_serve(config_flag.clone(), parsed.profile.clone(), port, bind).await
        }
        Some(Commands::Init { path }) => cmd_init(&path),
        Some(Commands::Setup) => cli::cmd_setup(),
        Some(Commands::Check { json }) => match resolve_config_path(config_flag.as_deref()) {
            Some(p) => cmd_check(&p.to_string_lossy(), json || parsed.json),
            None => {
                let t = cli::theme();
                print_banner(t);
                eprintln!("  {} No configuration file found.", t.icon_warn());
                eprintln!("    Searched: ~/.roboticus/roboticus.toml, ./roboticus.toml");
                eprintln!(
                    "    Specify a path with {}--config <path>{} or create one with {}roboticus init{}",
                    t.bold(),
                    t.reset(),
                    t.bold(),
                    t.reset()
                );
                eprintln!();
                Err("no configuration file found".into())
            }
        },
        Some(Commands::Version) => {
            cmd_version(parsed.json);
            Ok(())
        }
        Some(Commands::Update(subcmd)) => {
            let resolved = resolve_config_path(parsed.config.as_deref());
            let config_path = resolved
                .as_ref()
                .map(|p| p.to_string_lossy().into_owned())
                .unwrap_or_else(|| "roboticus.toml".into());
            let config_path = config_path.as_str();
            match subcmd {
                UpdateCmd::Check {
                    channel,
                    registry_url,
                } => cli::cmd_update_check(&channel, registry_url.as_deref(), config_path).await,
                UpdateCmd::All {
                    channel,
                    yes,
                    no_restart,
                    registry_url,
                    force,
                } => {
                    let hygiene = make_hygiene_fn();
                    let daemon = make_daemon_callbacks();
                    cli::cmd_update_all(
                        &channel,
                        yes,
                        no_restart,
                        force,
                        registry_url.as_deref(),
                        config_path,
                        Some(&hygiene),
                        Some(&daemon),
                    )
                    .await
                }
                UpdateCmd::Binary {
                    channel,
                    yes,
                    method,
                } => {
                    let hygiene = make_hygiene_fn();
                    cli::cmd_update_binary(&channel, yes, &method, Some(&hygiene)).await
                }
                UpdateCmd::Providers { yes, registry_url } => {
                    let hygiene = make_hygiene_fn();
                    cli::cmd_update_providers(
                        yes,
                        registry_url.as_deref(),
                        config_path,
                        Some(&hygiene),
                    )
                    .await
                }
                UpdateCmd::Skills { yes, registry_url } => {
                    let hygiene = make_hygiene_fn();
                    cli::cmd_update_skills(
                        yes,
                        registry_url.as_deref(),
                        config_path,
                        Some(&hygiene),
                    )
                    .await
                }
            }
        }

        // ── Operations ──────────────────────────────────────
        Some(Commands::Status) => cli::cmd_status(url, parsed.json).await,
        Some(Commands::Mechanic {
            repair,
            json,
            allow_job,
        }) => cli::cmd_mechanic(url, repair, json, &allow_job).await,
        Some(Commands::Defrag { fix, yes }) => {
            let workspace = roboticus_core::default_workspace_path();
            cli::cmd_defrag(&workspace, fix, yes, parsed.json).map_err(|e| e.into())
        }
        Some(Commands::Logs {
            lines,
            follow,
            level,
        }) => cli::cmd_logs(url, lines, follow, &level, parsed.json).await,
        Some(Commands::Circuit(sub)) => match sub {
            CircuitCmd::Status => cli::cmd_circuit_status(url, parsed.json).await,
            CircuitCmd::Reset { provider } => {
                cli::cmd_circuit_reset(url, provider.as_deref()).await
            }
        },

        // ── Data ────────────────────────────────────────────
        Some(Commands::Sessions(sub)) => match sub {
            SessionsCmd::List => cli::cmd_sessions_list(url, parsed.json).await,
            SessionsCmd::Show { id } => cli::cmd_session_detail(url, &id, parsed.json).await,
            SessionsCmd::Create { agent_id } => cli::cmd_session_create(url, &agent_id).await,
            SessionsCmd::Export { id, format, output } => {
                cli::cmd_session_export(url, &id, &format, output.as_deref()).await
            }
            SessionsCmd::BackfillNicknames => cli::cmd_sessions_backfill_nicknames(url).await,
        },
        Some(Commands::Memory(sub)) => match sub {
            MemoryCmd::List {
                tier,
                session,
                limit,
            } => cli::cmd_memory(url, &tier, session.as_deref(), None, limit, parsed.json).await,
            MemoryCmd::Search { query, limit } => {
                cli::cmd_memory(
                    url,
                    "search",
                    None,
                    Some(query.as_str()),
                    limit,
                    parsed.json,
                )
                .await
            }
        },
        Some(Commands::Mcp(sub)) => match sub {
            McpCmd::List => cli::cmd_mcp_list(url, parsed.json).await,
            McpCmd::Add {
                name,
                stdio,
                sse,
                args,
            } => cli::cmd_mcp_add(&name, stdio.as_deref(), sse.as_deref(), &args),
            McpCmd::Remove { name } => cli::cmd_mcp_remove(&name),
            McpCmd::Test { name } => cli::cmd_mcp_test(url, &name).await,
        },
        Some(Commands::Ingest { path, json }) => cmd_ingest(&path, json, config_flag.as_deref()),
        Some(Commands::Skills(sub)) => match sub {
            SkillsCmd::List => cli::cmd_skills_list(url, parsed.json).await,
            SkillsCmd::Show { id } => cli::cmd_skill_detail(url, &id, parsed.json).await,
            SkillsCmd::Reload => cli::cmd_skills_reload(url).await,
            SkillsCmd::CatalogList { query } => {
                cli::cmd_skills_catalog_list(url, query.as_deref(), parsed.json).await
            }
            SkillsCmd::CatalogInstall { skills, activate } => {
                cli::cmd_skills_catalog_install(url, &skills, activate).await
            }
            SkillsCmd::CatalogActivate { skills } => {
                cli::cmd_skills_catalog_activate(url, &skills).await
            }
            SkillsCmd::Import {
                source,
                no_safety_check,
                accept_warnings,
            } => roboticus_server::migrate::cmd_skill_import(
                &source,
                no_safety_check,
                accept_warnings,
            ),
            SkillsCmd::Export { output, ids } => {
                roboticus_server::migrate::cmd_skill_export(&output, &ids)
            }
        },
        Some(Commands::Schedule(sub)) => match sub {
            ScheduleCmd::List => cli::cmd_schedule_list(url, parsed.json).await,
            ScheduleCmd::Run { job } => cli::cmd_schedule_run(url, &job, parsed.json).await,
            ScheduleCmd::Recover {
                all,
                names,
                dry_run,
            } => cli::cmd_schedule_recover(url, &names, all, dry_run, parsed.json).await,
        },
        Some(Commands::Metrics(sub)) => match sub {
            MetricsCmd::Costs => cli::cmd_metrics(url, "costs", None, parsed.json).await,
            MetricsCmd::Transactions { hours } => {
                cli::cmd_metrics(url, "transactions", hours, parsed.json).await
            }
            MetricsCmd::Cache => cli::cmd_metrics(url, "cache", None, parsed.json).await,
        },
        Some(Commands::Wallet(sub)) => match sub {
            WalletCmd::Show => cli::cmd_wallet(url, parsed.json).await,
            WalletCmd::Address => cli::cmd_wallet_address(url, parsed.json).await,
            WalletCmd::Balance => cli::cmd_wallet_balance(url, parsed.json).await,
        },

        // ── Authentication ──────────────────────────────────
        Some(Commands::Auth(sub)) => match sub {
            AuthCmd::Login {
                provider,
                client_id,
            } => cmd_auth_login(&provider, client_id.as_deref()).await,
            AuthCmd::Status => cmd_auth_status().await,
            AuthCmd::Logout { provider } => cmd_auth_logout(&provider).await,
        },

        // ── Configuration ───────────────────────────────────
        Some(Commands::Config(sub)) => match sub {
            ConfigCmd::Show => cli::cmd_config(url, parsed.json).await,
            ConfigCmd::Get { path } => cli::cmd_config_get(url, &path).await,
            ConfigCmd::Set {
                path,
                value,
                file,
                no_apply,
            } => {
                cli::cmd_config_set(&path, &value, &file)?;
                if !no_apply {
                    cli::cmd_config_apply(url, &file).await?;
                }
                Ok(())
            }
            ConfigCmd::Unset {
                path,
                file,
                no_apply,
            } => {
                cli::cmd_config_unset(&path, &file)?;
                if !no_apply {
                    cli::cmd_config_apply(url, &file).await?;
                }
                Ok(())
            }
            ConfigCmd::Lint { file } => cli::cmd_config_lint(&file),
            ConfigCmd::Backup { file } => cli::cmd_config_backup(&file),
        },
        Some(Commands::Models(sub)) => match sub {
            ModelsCmd::List => cli::cmd_models_list(url, parsed.json).await,
            ModelsCmd::Scan { provider } => cli::cmd_models_scan(url, provider.as_deref()).await,
        },
        Some(Commands::Plugins(sub)) => match sub {
            PluginsCmd::List => cli::cmd_plugins_list(url, parsed.json).await,
            PluginsCmd::Info { name } => cli::cmd_plugin_info(url, &name, parsed.json).await,
            PluginsCmd::Install { source } => cli::cmd_plugin_install(&source).await,
            PluginsCmd::Uninstall { name } => cli::cmd_plugin_uninstall(&name),
            PluginsCmd::Enable { name } => cli::cmd_plugin_toggle(url, &name, true).await,
            PluginsCmd::Disable { name } => cli::cmd_plugin_toggle(url, &name, false).await,
            PluginsCmd::Search { query } => cli::cmd_plugin_search(&query).await,
            PluginsCmd::Pack { dir, output } => cli::cmd_plugin_pack(&dir, output.as_deref()),
        },
        Some(Commands::Apps(sub)) => match sub {
            AppsCmd::Install { source } => cli::cmd_apps_install(&source),
            AppsCmd::List => cli::cmd_apps_list(),
            AppsCmd::Uninstall { name, delete_data } => cli::cmd_apps_uninstall(&name, delete_data),
        },
        Some(Commands::Profile(sub)) => match sub {
            ProfileCmd::List => cli::cmd_profile_list(),
            ProfileCmd::Create { name, display_name } => {
                cli::cmd_profile_create(&name, display_name.as_deref())
            }
            ProfileCmd::Switch { name } => cli::cmd_profile_switch(&name),
            ProfileCmd::Delete { name, keep_data } => cli::cmd_profile_delete(&name, keep_data),
        },
        Some(Commands::Agents(sub)) => match sub {
            AgentsCmd::List => cli::cmd_agents_list(url, parsed.json).await,
            AgentsCmd::Start { id } => cli::cmd_agent_start(url, &id).await,
            AgentsCmd::Stop { id } => cli::cmd_agent_stop(url, &id).await,
        },
        Some(Commands::Channels(sub)) => match sub {
            ChannelsCmd::List => cli::cmd_channels_status(url, parsed.json).await,
            ChannelsCmd::DeadLetter { limit } => {
                cli::cmd_channels_dead_letter(url, limit, parsed.json).await
            }
            ChannelsCmd::Replay { id } => cli::cmd_channels_replay(url, &id).await,
        },
        Some(Commands::Integrations(sub)) => match sub {
            IntegrationsCmd::List => cli::cmd_channels_status(url, parsed.json).await,
            IntegrationsCmd::Test { platform } => cli::cmd_integrations_test(url, &platform).await,
            IntegrationsCmd::Health => cli::cmd_channels_status(url, parsed.json).await,
            IntegrationsCmd::Connect { platform } => {
                cli::cmd_integrations_connect(url, &platform).await
            }
            IntegrationsCmd::Disconnect { platform } => {
                cli::cmd_integrations_disconnect(url, &platform).await
            }
        },
        Some(Commands::Security(sub)) => match sub {
            SecurityCmd::Audit { config } => cli::cmd_security_audit(&config, parsed.json),
        },

        // ── Credentials ──────────────────────────────────────
        Some(Commands::Keystore(sub)) => cmd_keystore(sub).await,

        // ── Migration ────────────────────────────────────────
        Some(Commands::Migrate(sub)) => match sub {
            MigrateCmd::Import {
                source,
                areas,
                yes,
                no_safety_check,
            } => {
                roboticus_server::migrate::cmd_migrate_import(&source, &areas, yes, no_safety_check)
            }
            MigrateCmd::Export { target, areas } => {
                roboticus_server::migrate::cmd_migrate_export(&target, &areas)
            }
            MigrateCmd::Ironclad { source, yes } => {
                roboticus_server::migrate::cmd_migrate_ironclad(source.as_deref(), yes)
            }
        },

        // ── System ──────────────────────────────────────────
        Some(Commands::Daemon(sub)) => match sub {
            DaemonCmd::Install { config, start } => {
                let binary = std::env::current_exe()?.to_string_lossy().to_string();
                let abs_config = std::path::Path::new(&config)
                    .canonicalize()
                    .or_else(|_| {
                        let home_cfg = roboticus_core::home_dir().join(".roboticus").join(&config);
                        home_cfg.canonicalize()
                    })
                    .map_err(|_| {
                        roboticus_core::RoboticusError::Config(format!(
                            "config file not found: {config}"
                        ))
                    })?;
                let path = roboticus_server::daemon::install_daemon(
                    &binary,
                    &abs_config.to_string_lossy(),
                    18789,
                )?;
                eprintln!("  Daemon installed: {}", path.display());

                let should_start =
                    start || prompt_yes_no("Would you like to start the daemon now?");
                if should_start {
                    roboticus_server::daemon::start_daemon()?;
                    eprintln!("  Daemon started");
                } else {
                    eprintln!("  Run `roboticus daemon start` when you're ready");
                }
                Ok(())
            }
            DaemonCmd::Start => {
                if !roboticus_server::daemon::is_installed() {
                    eprintln!("  Daemon is not installed. Run `roboticus daemon install` first.");
                    std::process::exit(1);
                }
                roboticus_server::daemon::start_daemon()?;
                eprintln!("  Daemon started");
                Ok(())
            }
            DaemonCmd::Stop => {
                roboticus_server::daemon::stop_daemon()?;
                eprintln!("  Daemon stopped");
                Ok(())
            }
            DaemonCmd::Restart => {
                if !roboticus_server::daemon::is_installed() {
                    eprintln!("  Daemon is not installed. Run `roboticus daemon install` first.");
                    std::process::exit(1);
                }
                roboticus_server::daemon::restart_daemon()?;
                eprintln!("  Daemon restarted");
                Ok(())
            }
            DaemonCmd::Status => {
                let status = roboticus_server::daemon::daemon_status()?;
                eprintln!("  {status}");
                Ok(())
            }
            DaemonCmd::Uninstall => {
                roboticus_server::daemon::uninstall_daemon()?;
                eprintln!("  Daemon uninstalled");
                Ok(())
            }
        },
        Some(Commands::Web) => cmd_web(config_flag.as_deref(), url),
        Some(Commands::Tui {
            url: tui_url,
            session,
        }) => roboticus_tui::run_tui(&tui_url, session.as_deref(), parsed.api_key.clone())
            .await
            .map_err(|e| -> Box<dyn std::error::Error> { e }),
        Some(Commands::Uninstall { purge }) => cli::cmd_uninstall(
            purge,
            Some(&|| {
                roboticus_server::daemon::uninstall_daemon()
                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
            }),
        ),
        Some(Commands::Reset { yes }) => cli::cmd_reset(yes),
        Some(Commands::Completion { shell }) => cli::cmd_completion(&shell),

        // No subcommand: show help
        None => {
            let mut cmd = Cli::command();
            cmd.print_help()?;
            eprintln!();
            Ok(())
        }
    };
    eprint!("{}", t.hard_reset());
    result
}

// ── OAuth commands ─────────────────────────────────────────────

async fn cmd_auth_login(
    provider: &str,
    client_id_override: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
    let t = cli::theme();
    let (a, d, r) = (t.accent(), t.dim(), t.reset());

    let client_id = client_id_override
        .map(String::from)
        .or_else(|| {
            let path = roboticus_core::home_dir()
                .join(".roboticus")
                .join("roboticus.toml");
            let cfg = RoboticusConfig::from_file(&path).ok()?;
            cfg.providers
                .get(provider)
                .and_then(|p| p.oauth_client_id.clone())
        })
        .unwrap_or_else(|| {
            std::env::var("ROBOTICUS_OAUTH_CLIENT_ID").unwrap_or_else(|_| "roboticus-cli".into())
        });

    let verifier = roboticus_llm::oauth::generate_code_verifier();
    let challenge = roboticus_llm::oauth::compute_code_challenge(&verifier);
    let state_param = roboticus_llm::oauth::generate_code_verifier();
    let redirect_uri = roboticus_llm::oauth::default_redirect_uri();
    let auth_url = roboticus_llm::oauth::build_authorization_url(
        &client_id,
        &redirect_uri,
        &challenge,
        &state_param,
    );

    eprintln!("\n  {a}OAuth Login — {provider}{r}\n");
    eprintln!("  {d}Opening browser for authorization...{r}");
    eprintln!("  {d}If the browser doesn't open, visit:{r}");
    eprintln!("  {a}{auth_url}{r}\n");

    let _ = open::that(&auth_url);

    eprintln!(
        "  {d}Waiting for callback on port {}...{r}",
        roboticus_llm::oauth::callback_port()
    );

    let (code, returned_state) = listen_for_callback().await?;

    if returned_state != state_param {
        return Err("OAuth state mismatch — possible CSRF attack".into());
    }

    eprintln!("  {a}Authorization code received, exchanging for tokens...{r}");

    let http = reqwest::Client::new();
    let mut params = std::collections::HashMap::new();
    params.insert("grant_type", "authorization_code");
    params.insert("code", &code);
    params.insert("redirect_uri", &redirect_uri);
    params.insert("client_id", &client_id);
    params.insert("code_verifier", &verifier);

    let resp = http
        .post(roboticus_llm::oauth::token_url())
        .form(&params)
        .send()
        .await?;

    if !resp.status().is_success() {
        let body = resp
            .text()
            .await
            .inspect_err(|e| tracing::warn!(error = %e, "CLI response parse failed"))
            .unwrap_or_default();
        return Err(format!("Token exchange failed: {body}").into());
    }

    #[derive(serde::Deserialize)]
    struct TokenResp {
        access_token: String,
        refresh_token: Option<String>,
        expires_in: Option<i64>,
    }

    let token_resp: TokenResp = resp.json().await?;
    let expires_at = token_resp
        .expires_in
        .map(|secs| chrono::Utc::now().timestamp() + secs);

    let manager = roboticus_llm::OAuthManager::new()?;
    manager
        .store_tokens(roboticus_llm::oauth::StoredTokens {
            provider: provider.to_string(),
            access_token: token_resp.access_token,
            refresh_token: token_resp.refresh_token,
            expires_at,
            client_id: None,
        })
        .await;

    let ok = t.icon_ok();
    eprintln!("\n  {ok} {a}Successfully authenticated with {provider}{r}");
    eprintln!("  {d}Tokens stored in the encrypted keystore{r}\n");
    eprintln!("  {d}To use OAuth auth, set auth_mode = \"oauth\" in your provider config:{r}");
    eprintln!("  {d}  [providers.{provider}]{r}");
    eprintln!("  {d}  auth_mode = \"oauth\"{r}\n");

    Ok(())
}

async fn listen_for_callback() -> Result<(String, String), Box<dyn std::error::Error>> {
    use tokio::io::AsyncReadExt;
    use tokio::io::AsyncWriteExt;
    use tokio::net::TcpListener;

    let addr = format!("127.0.0.1:{}", roboticus_llm::oauth::callback_port());
    let listener = TcpListener::bind(&addr).await?;

    let (mut stream, _) = listener.accept().await?;

    let mut buf = vec![0u8; 4096];
    let n = stream.read(&mut buf).await?;
    let request = String::from_utf8_lossy(&buf[..n]);
    let (code, state) = parse_oauth_callback_request(&request);

    let html = "<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the terminal.</p></body></html>";
    let response = format!(
        "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
        html.len(),
        html
    );
    stream.write_all(response.as_bytes()).await?;

    if code.is_empty() {
        return Err("No authorization code received in callback".into());
    }

    Ok((code, state))
}

fn parse_oauth_callback_request(request: &str) -> (String, String) {
    let first_line = request.lines().next().unwrap_or("");
    let path = first_line.split_whitespace().nth(1).unwrap_or("/");

    let mut code = String::new();
    let mut state = String::new();

    if let Some(query_start) = path.find('?') {
        let query = &path[query_start + 1..];
        for pair in query.split('&') {
            if let Some((k, v)) = pair.split_once('=') {
                match k {
                    "code" => code = v.to_string(),
                    "state" => state = v.to_string(),
                    _ => {}
                }
            }
        }
    }

    (code, state)
}

async fn cmd_auth_status() -> Result<(), Box<dyn std::error::Error>> {
    let t = cli::theme();
    let (a, d, r) = (t.accent(), t.dim(), t.reset());

    let manager = roboticus_llm::OAuthManager::new()?;
    let statuses = manager.status().await;

    eprintln!("\n  {a}OAuth Token Status{r}\n");

    if statuses.is_empty() {
        eprintln!("  {d}No OAuth tokens stored.{r}");
        eprintln!("  {d}Run `roboticus auth login --provider <name>` to authenticate.{r}\n");
        return Ok(());
    }

    for s in &statuses {
        let status_icon = if s.expired {
            t.icon_warn()
        } else {
            t.icon_ok()
        };
        let status_text = if s.expired { "EXPIRED" } else { "active" };
        eprintln!("  {status_icon} {a}{}{r}  {d}{status_text}{r}", s.provider);
        if let Some(exp) = s.expires_at {
            let dt = chrono::DateTime::from_timestamp(exp, 0)
                .map(|d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string())
                .unwrap_or_else(|| "unknown".into());
            eprintln!("       {d}expires: {dt}{r}");
        }
        let refresh = if s.has_refresh_token { "yes" } else { "no" };
        eprintln!("       {d}refresh token: {refresh}{r}");
    }
    eprintln!();

    Ok(())
}

async fn cmd_auth_logout(provider: &str) -> Result<(), Box<dyn std::error::Error>> {
    let t = cli::theme();
    let (a, d, r) = (t.accent(), t.dim(), t.reset());

    let manager = roboticus_llm::OAuthManager::new()?;
    let removed = manager.remove_tokens(provider).await;

    if removed {
        let ok = t.icon_ok();
        eprintln!("\n  {ok} {a}Removed OAuth tokens for {provider}{r}\n");
    } else {
        eprintln!("\n  {d}No tokens found for provider '{provider}'{r}\n");
    }

    Ok(())
}

// ── Keystore commands ──────────────────────────────────────────

fn open_keystore(
    password: &Option<String>,
) -> Result<roboticus_core::keystore::Keystore, Box<dyn std::error::Error>> {
    let ks =
        roboticus_core::keystore::Keystore::new(roboticus_core::keystore::Keystore::default_path());
    match password {
        Some(p) => ks.unlock(p)?,
        None => ks.unlock_machine()?,
    }
    Ok(ks)
}

async fn cmd_keystore(sub: KeystoreCmd) -> Result<(), Box<dyn std::error::Error>> {
    let t = cli::theme();
    let (a, d, r) = (t.accent(), t.dim(), t.reset());
    let ok = t.icon_ok();

    match sub {
        KeystoreCmd::Set {
            key,
            value,
            password,
        } => {
            let ks = open_keystore(&password)?;

            let secret = match value {
                Some(v) => v,
                None => dialoguer::Password::new()
                    .with_prompt("Secret value")
                    .interact()?,
            };
            ks.set(&key, &secret)?;
            eprintln!("  {ok} {a}Stored secret '{key}'{r}");
        }
        KeystoreCmd::Get { key, password } => {
            let ks = open_keystore(&password)?;

            match ks.get(&key) {
                Some(val) => println!("{val}"),
                None => {
                    eprintln!("  {d}Key '{key}' not found{r}");
                    std::process::exit(1);
                }
            }
        }
        KeystoreCmd::List { password } => {
            let ks = open_keystore(&password)?;

            let mut keys = ks.list_keys();
            keys.sort();
            if keys.is_empty() {
                eprintln!("  {d}Keystore is empty{r}");
            } else {
                for k in &keys {
                    eprintln!("  {a}{k}{r}");
                }
                eprintln!("\n  {d}{} secret(s){r}", keys.len());
            }
        }
        KeystoreCmd::Remove { key, password } => {
            let ks = open_keystore(&password)?;

            if ks.remove(&key)? {
                eprintln!("  {ok} {a}Removed '{key}'{r}");
            } else {
                eprintln!("  {d}Key '{key}' not found{r}");
            }
        }
        KeystoreCmd::Import { path, password } => {
            let ks = open_keystore(&password)?;

            let contents = std::fs::read_to_string(&path)?;
            let entries: std::collections::HashMap<String, String> =
                serde_json::from_str(&contents)?;
            let count = ks.import(entries)?;
            eprintln!("  {ok} {a}Imported {count} secret(s){r}");
        }
        KeystoreCmd::Rekey { password } => {
            let ks = open_keystore(&password)?;

            let new_pass = dialoguer::Password::new()
                .with_prompt("New passphrase")
                .with_confirmation("Confirm new passphrase", "Passphrases do not match")
                .interact()?;
            ks.rekey(&new_pass)?;
            eprintln!("  {ok} {a}Keystore re-encrypted with new passphrase{r}");
        }
    }

    Ok(())
}

// ── Workspace commands ─────────────────────────────────────────

fn cmd_init(path: &str) -> Result<(), Box<dyn std::error::Error>> {
    let t = cli::theme();
    print_banner(t);
    let dir = std::path::Path::new(path);
    let (b, r) = (t.bold(), t.reset());
    let (ok, action, warn) = (t.icon_ok(), t.icon_action(), t.icon_warn());

    // ── Legacy binary cleanup ──────────────────────────────────────────────
    // If the old "ironclad" binary exists alongside the new "roboticus" binary,
    // remove it to avoid confusion.
    if let Ok(current_exe) = std::env::current_exe()
        && let Some(bin_dir) = current_exe.parent()
    {
        let legacy = bin_dir.join(if cfg!(windows) {
            "ironclad.exe"
        } else {
            "ironclad"
        });
        if legacy.exists() && std::fs::remove_file(&legacy).is_ok() {
            eprintln!("  {action} Removed legacy binary: {}", legacy.display());
        }
    }

    t.typewrite_line(
        &format!(
            "  {b}Initializing Roboticus workspace{r} at {}\n",
            dir.display()
        ),
        4,
    );

    // ── Legacy config migration ─────────────────────────────────────────────
    // If ironclad.toml exists but roboticus.toml does not, rename and rewrite paths.
    let legacy_config = dir.join("ironclad.toml");
    let config_path = dir.join("roboticus.toml");
    if legacy_config.exists() && !config_path.exists() {
        std::fs::rename(&legacy_config, &config_path)?;
        // Rewrite internal paths (e.g. ~/.ironclad/ → ~/.roboticus/).
        if let Ok(content) = std::fs::read_to_string(&config_path) {
            let rewritten = content.replace("/.ironclad/", "/.roboticus/");
            if rewritten != content {
                std::fs::write(&config_path, &rewritten)?;
            }
        }
        t.typewrite_line(
            &format!("  {action} Migrated ironclad.toml → roboticus.toml (paths updated)"),
            4,
        );
    } else if config_path.exists() {
        t.typewrite_line(
            &format!("  {warn} roboticus.toml already exists, skipping"),
            4,
        );
    } else {
        std::fs::write(&config_path, workspace_init_toml())?;
        t.typewrite_line(&format!("  {action} Created roboticus.toml"), 4);
        t.typewrite_line(
            "  (API key generated — see [server] api_key in roboticus.toml for REST / MCP clients)",
            4,
        );
    }

    let skills_dir = dir.join("skills");
    if skills_dir.exists() {
        t.typewrite_line(
            &format!("  {warn} skills/ directory already exists, skipping"),
            4,
        );
    } else {
        std::fs::create_dir_all(&skills_dir)?;
        let count = cli::write_starter_skills(&skills_dir)?;
        t.typewrite_line(
            &format!("  {action} Created skills/ with {count} starter skills"),
            4,
        );
    }

    eprintln!();
    t.typewrite_line(
        &format!("  {ok} Done. Run {b}roboticus serve -c roboticus.toml{r} to start."),
        4,
    );
    eprintln!();

    Ok(())
}

fn cmd_check(config_path: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
    let config = match RoboticusConfig::from_file(Path::new(config_path)) {
        Ok(c) => c,
        Err(e) => {
            if json {
                let msg = format!("{e}");
                println!(
                    "{}",
                    serde_json::json!({
                        "valid": false,
                        "config_path": config_path,
                        "error": msg,
                    })
                );
                return Err(Box::new(e));
            }
            let t = cli::theme();
            print_banner(t);
            let (b, r) = (t.bold(), t.reset());
            let warn = t.icon_warn();
            let msg = format!("{e}");
            if msg.contains("No such file") || msg.contains("not found") || msg.contains("NotFound")
            {
                eprintln!("  {warn} Config file not found: {config_path}");
                eprintln!(
                    "    Specify a path with {b}--config <path>{r} or create one with {b}roboticus init{r}"
                );
                eprintln!();
            }
            return Err(Box::new(e));
        }
    };

    if let Err(e) = config.validate() {
        if json {
            println!(
                "{}",
                serde_json::json!({
                    "valid": false,
                    "config_path": config_path,
                    "error": format!("{e}"),
                })
            );
        }
        return Err(Box::new(e));
    }

    let mem_sum = config.memory.working_budget_pct
        + config.memory.episodic_budget_pct
        + config.memory.semantic_budget_pct
        + config.memory.procedural_budget_pct
        + config.memory.relationship_budget_pct;

    let skills_dir_exists = config.skills.skills_dir.exists();

    let mut warnings: Vec<String> = Vec::new();

    if !skills_dir_exists {
        warnings.push(format!(
            "Skills dir missing: {}",
            config.skills.skills_dir.display()
        ));
    }

    // Per-channel allow-list warnings
    {
        if let Some(ref tg) = config.channels.telegram
            && tg.allowed_chat_ids.is_empty()
            && config.security.deny_on_empty_allowlist
        {
            warnings.push(
                "Telegram: allowed_chat_ids is empty (all messages will be rejected)".to_string(),
            );
        }
        if let Some(ref dc) = config.channels.discord
            && dc.allowed_guild_ids.is_empty()
            && config.security.deny_on_empty_allowlist
        {
            warnings.push(
                "Discord: allowed_guild_ids is empty (all messages will be rejected)".to_string(),
            );
        }
        if let Some(ref wa) = config.channels.whatsapp
            && wa.allowed_numbers.is_empty()
            && config.security.deny_on_empty_allowlist
        {
            warnings.push(
                "WhatsApp: allowed_numbers is empty (all messages will be rejected)".to_string(),
            );
        }
        if let Some(ref sig) = config.channels.signal
            && sig.allowed_numbers.is_empty()
            && config.security.deny_on_empty_allowlist
        {
            warnings.push(
                "Signal: allowed_numbers is empty (all messages will be rejected)".to_string(),
            );
        }
        if config.channels.email.enabled
            && config.channels.email.allowed_senders.is_empty()
            && config.security.deny_on_empty_allowlist
        {
            warnings.push(
                "Email: allowed_senders is empty (all messages will be rejected)".to_string(),
            );
        }
        if config.channels.trusted_sender_ids.is_empty() {
            warnings.push("No trusted senders — no user can reach Creator authority".to_string());
        }
    }

    if json {
        println!(
            "{}",
            serde_json::to_string_pretty(&serde_json::json!({
                "valid": true,
                "config_path": config_path,
                "agent_name": config.agent.name,
                "agent_id": config.agent.id,
                "server": format!("{}:{}", config.server.bind, config.server.port),
                "primary_model": config.models.primary,
                "database": config.database.path.display().to_string(),
                "memory_budget_sum_pct": mem_sum,
                "treasury_per_payment_cap": config.treasury.per_payment_cap,
                "treasury_minimum_reserve": config.treasury.minimum_reserve,
                "skills_dir": config.skills.skills_dir.display().to_string(),
                "skills_dir_exists": skills_dir_exists,
                "a2a_enabled": config.a2a.enabled,
                "trusted_senders": config.channels.trusted_sender_ids.len(),
                "warnings": warnings,
            }))?
        );
        return Ok(());
    }

    // ── Human-readable output ──────────────────────────────────
    let t = cli::theme();
    print_banner(t);
    let (s, b, r) = (t.success(), t.bold(), t.reset());
    let (ok, warn) = (t.icon_ok(), t.icon_warn());
    let tw = |text: &str| t.typewrite_line(text, 4);

    tw(&format!("  {b}Validating{r} {config_path}\n"));
    tw(&format!("  {ok} TOML syntax valid"));
    tw(&format!("  {ok} Configuration semantics valid"));

    tw(&format!(
        "  {ok} Agent: {} ({})",
        config.agent.name, config.agent.id
    ));
    tw(&format!(
        "  {ok} Server: {}:{}",
        config.server.bind, config.server.port
    ));
    tw(&format!("  {ok} Primary model: {}", config.models.primary));
    tw(&format!(
        "  {ok} Database: {}",
        config.database.path.display()
    ));
    tw(&format!("  {ok} Memory budgets sum to {mem_sum}%"));
    tw(&format!(
        "  {ok} Treasury: cap=${:.2}/payment, reserve=${:.2}",
        config.treasury.per_payment_cap, config.treasury.minimum_reserve
    ));

    if skills_dir_exists {
        tw(&format!(
            "  {ok} Skills dir exists: {}",
            config.skills.skills_dir.display()
        ));
    } else {
        tw(&format!(
            "  {warn} Skills dir missing: {}",
            config.skills.skills_dir.display()
        ));
    }

    if config.a2a.enabled {
        tw(&format!(
            "  {ok} A2A enabled (rate limit: {}/peer)",
            config.a2a.rate_limit_per_peer
        ));
    }

    // ── Security (RBAC) ──────────────────────────────────────
    tw(&format!(
        "  {ok} Security: deny_on_empty_allowlist={}, allowlist={:?}, trusted={:?}, api={:?}, threat_ceiling={:?}",
        config.security.deny_on_empty_allowlist,
        config.security.allowlist_authority,
        config.security.trusted_authority,
        config.security.api_authority,
        config.security.threat_caution_ceiling,
    ));
    tw(&format!(
        "  {ok} Trusted senders: {} configured",
        config.channels.trusted_sender_ids.len()
    ));

    // Per-channel allow-list warnings (human-readable)
    {
        let mut any_channel_warn = false;
        if let Some(ref tg) = config.channels.telegram {
            if tg.allowed_chat_ids.is_empty() && config.security.deny_on_empty_allowlist {
                tw(&format!(
                    "  {warn} Telegram: allowed_chat_ids is empty (all messages will be rejected)"
                ));
                tw("         Hint: find your chat ID by messaging @userinfobot on Telegram");
                any_channel_warn = true;
            } else if !tg.allowed_chat_ids.is_empty() {
                tw(&format!(
                    "  {ok} Telegram: {} chat ID(s) configured",
                    tg.allowed_chat_ids.len()
                ));
            }
        }
        if let Some(ref dc) = config.channels.discord {
            if dc.allowed_guild_ids.is_empty() && config.security.deny_on_empty_allowlist {
                tw(&format!(
                    "  {warn} Discord: allowed_guild_ids is empty (all messages will be rejected)"
                ));
                any_channel_warn = true;
            } else if !dc.allowed_guild_ids.is_empty() {
                tw(&format!(
                    "  {ok} Discord: {} guild ID(s) configured",
                    dc.allowed_guild_ids.len()
                ));
            }
        }
        if let Some(ref wa) = config.channels.whatsapp {
            if wa.allowed_numbers.is_empty() && config.security.deny_on_empty_allowlist {
                tw(&format!(
                    "  {warn} WhatsApp: allowed_numbers is empty (all messages will be rejected)"
                ));
                any_channel_warn = true;
            } else if !wa.allowed_numbers.is_empty() {
                tw(&format!(
                    "  {ok} WhatsApp: {} number(s) configured",
                    wa.allowed_numbers.len()
                ));
            }
        }
        if let Some(ref sig) = config.channels.signal {
            if sig.allowed_numbers.is_empty() && config.security.deny_on_empty_allowlist {
                tw(&format!(
                    "  {warn} Signal: allowed_numbers is empty (all messages will be rejected)"
                ));
                any_channel_warn = true;
            } else if !sig.allowed_numbers.is_empty() {
                tw(&format!(
                    "  {ok} Signal: {} number(s) configured",
                    sig.allowed_numbers.len()
                ));
            }
        }
        if config.channels.email.enabled {
            if config.channels.email.allowed_senders.is_empty()
                && config.security.deny_on_empty_allowlist
            {
                tw(&format!(
                    "  {warn} Email: allowed_senders is empty (all messages will be rejected)"
                ));
                any_channel_warn = true;
            } else if !config.channels.email.allowed_senders.is_empty() {
                tw(&format!(
                    "  {ok} Email: {} sender(s) configured",
                    config.channels.email.allowed_senders.len()
                ));
            }
        }
        if config.channels.trusted_sender_ids.is_empty() && !any_channel_warn {
            tw(&format!(
                "  {warn} No trusted senders — no user can reach Creator authority"
            ));
        }
    }

    eprintln!();
    tw(&format!("  {ok} {s}All checks passed.{r}"));
    eprintln!();

    Ok(())
}

fn cmd_version(json: bool) {
    if json {
        let out = serde_json::json!({
            "version": env!("CARGO_PKG_VERSION"),
            "edition": "Rust 2024",
            "target": std::env::consts::ARCH,
            "os": std::env::consts::OS,
        });
        println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
        return;
    }
    let t = cli::theme();
    print_banner(t);
    let tw = |text: &str| t.typewrite_line(text, 4);
    tw(&format!("  version:    {}", env!("CARGO_PKG_VERSION")));
    tw("  edition:    Rust 2024");
    tw(&format!("  target:     {}", std::env::consts::ARCH));
    tw(&format!("  os:         {}", std::env::consts::OS));
    eprintln!();
}

fn cmd_ingest(
    path: &str,
    json: bool,
    config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
    use roboticus_agent::ingest::{ingest_directory, ingest_file};

    let cfg = match resolve_config_path(config_path) {
        Some(p) => RoboticusConfig::from_file(&p)?,
        None => RoboticusConfig::from_str(FALLBACK_CONFIG)?,
    };

    let db_path = cfg.database.path.to_string_lossy();
    let db = roboticus_db::Database::new(&db_path)?;

    let target = std::path::Path::new(path);

    let results = if target.is_dir() {
        ingest_directory(&db, target)?
    } else if target.is_file() {
        vec![ingest_file(&db, target)?]
    } else {
        return Err(format!("{path} does not exist or is not accessible").into());
    };

    if json {
        let out = serde_json::to_string_pretty(&results)?;
        std::io::Write::write_all(&mut std::io::stdout(), out.as_bytes())?;
        std::io::Write::write_all(&mut std::io::stdout(), b"\n")?;
    } else {
        if results.is_empty() {
            eprintln!("No supported files found.");
            return Ok(());
        }
        for r in &results {
            eprintln!(
                "  ✓ {} — {} ({} chunks, {} chars)",
                r.file_path,
                r.file_type.label(),
                r.chunks_stored,
                r.total_chars
            );
        }
        let total_chunks: usize = results.iter().map(|r| r.chunks_stored).sum();
        eprintln!(
            "\nIngested {} file(s), {} total chunks.",
            results.len(),
            total_chunks
        );
    }

    Ok(())
}

fn resolve_web_url(
    config_path: Option<&str>,
    cli_url: &str,
) -> Result<String, Box<dyn std::error::Error>> {
    let url = if let Some(path) = config_path {
        let raw = std::fs::read_to_string(path)?;
        let cfg: roboticus_core::config::RoboticusConfig = toml::from_str(&raw)?;
        let host = if cfg.server.bind == "0.0.0.0" {
            "127.0.0.1"
        } else {
            &cfg.server.bind
        };
        format!("http://{}:{}", host, cfg.server.port)
    } else {
        cli_url.to_string()
    };
    Ok(url)
}

fn cmd_web(config_path: Option<&str>, cli_url: &str) -> Result<(), Box<dyn std::error::Error>> {
    let url = resolve_web_url(config_path, cli_url)?;
    eprintln!("  Opening {url}");
    open::that(&url)?;
    Ok(())
}

/// Default `roboticus.toml` written by `roboticus init` (includes a fresh `server.api_key`).
fn workspace_init_toml() -> String {
    let api_key = roboticus_core::config_utils::generate_server_api_key();
    format!(
        r#"# Roboticus Configuration
# See: https://roboticus.ai/docs/configuration
#
# [server] api_key — required by clients for authenticated REST/MCP routes (x-api-key or Bearer).

[agent]
name = "Roboticus"
id = "roboticus"
workspace = "~/.roboticus/workspace"
log_level = "info"

[server]
port = 18789
bind = "127.0.0.1"
api_key = "{api_key}"

[database]
path = "~/.roboticus/state.db"

[models]
primary = "ollama/qwen3:8b"
fallbacks = []

[models.routing]
mode = "metascore"
confidence_threshold = 0.9
local_first = true

[memory]
working_budget_pct = 30.0
episodic_budget_pct = 25.0
semantic_budget_pct = 20.0
procedural_budget_pct = 15.0
relationship_budget_pct = 10.0

[cache]
enabled = true
exact_match_ttl_seconds = 3600
semantic_threshold = 0.95
max_entries = 10000

[treasury]
per_payment_cap = 100.0
hourly_transfer_limit = 500.0
daily_transfer_limit = 2000.0
minimum_reserve = 5.0
daily_inference_budget = 50.0

[skills]
skills_dir = "~/.roboticus/skills"
script_timeout_seconds = 30
script_max_output_bytes = 1048576
allowed_interpreters = ["bash", "python3", "node"]
sandbox_env = true
hot_reload = true

[a2a]
enabled = true
max_message_size = 65536
rate_limit_per_peer = 10
session_timeout_seconds = 3600
require_on_chain_identity = true
"#
    )
}

// ── Tests ──────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cmd_init_then_cmd_check_succeeds_for_generated_workspace() {
        let dir = tempfile::tempdir().unwrap();
        cmd_init(dir.path().to_str().unwrap()).expect("init should succeed");
        let cfg_path = dir.path().join("roboticus.toml");
        assert!(cfg_path.exists());
        cmd_check(cfg_path.to_str().unwrap(), false).expect("check should succeed");
    }

    #[test]
    fn cmd_init_is_idempotent_when_config_exists() {
        let dir = tempfile::tempdir().unwrap();
        let cfg_path = dir.path().join("roboticus.toml");
        std::fs::write(&cfg_path, "sentinel").unwrap();
        cmd_init(dir.path().to_str().unwrap()).unwrap();
        let after = std::fs::read_to_string(&cfg_path).unwrap();
        assert_eq!(after, "sentinel");
    }

    #[test]
    fn workspace_init_toml_contains_expected_core_sections() {
        let toml = workspace_init_toml();
        assert!(toml.contains("[server]"));
        assert!(toml.contains("api_key = \"rk_"));
        assert!(toml.contains("[memory]"));
        assert!(toml.contains("relationship_budget_pct = 10.0"));
        assert!(toml.contains("[skills]"));
        assert!(toml.contains("[a2a]"));
    }

    #[test]
    fn workspace_init_toml_generates_nonempty_api_key() {
        let toml = workspace_init_toml();
        let api_key_line = toml
            .lines()
            .find(|line| line.trim_start().starts_with("api_key = "))
            .expect("api key line present");
        let api_key = api_key_line.split('"').nth(1).expect("quoted api key");
        assert!(api_key.starts_with("rk_"));
        assert!(api_key.len() > 10);
    }

    #[test]
    fn parse_oauth_callback_request_extracts_code_and_state() {
        let request = "GET /callback?code=abc123&state=xyz789 HTTP/1.1\r\nHost: localhost\r\n\r\n";
        let (code, state) = parse_oauth_callback_request(request);
        assert_eq!(code, "abc123");
        assert_eq!(state, "xyz789");
    }

    #[test]
    fn parse_oauth_callback_request_handles_missing_query_values() {
        let request = "GET /callback HTTP/1.1\r\nHost: localhost\r\n\r\n";
        let (code, state) = parse_oauth_callback_request(request);
        assert!(code.is_empty());
        assert!(state.is_empty());
    }

    #[test]
    fn resolve_web_url_uses_cli_url_without_config() {
        let url = resolve_web_url(None, "http://localhost:18789").unwrap();
        assert_eq!(url, "http://localhost:18789");
    }

    #[test]
    fn resolve_web_url_rewrites_zero_bind_to_loopback() {
        let dir = tempfile::tempdir().unwrap();
        let config_path = dir.path().join("roboticus.toml");
        std::fs::write(
            &config_path,
            r#"
[agent]
name = "T"
id = "t"
[server]
bind = "0.0.0.0"
port = 18789
[database]
path = ":memory:"
[models]
primary = "ollama/qwen3:8b"
"#,
        )
        .unwrap();
        let url = resolve_web_url(config_path.to_str(), "http://ignored").unwrap();
        assert_eq!(url, "http://127.0.0.1:18789");
    }

    #[test]
    fn resolve_web_url_uses_explicit_bind_from_config() {
        let dir = tempfile::tempdir().unwrap();
        let config_path = dir.path().join("roboticus.toml");
        std::fs::write(
            &config_path,
            r#"
[agent]
name = "T"
id = "t"
[server]
bind = "192.168.1.8"
port = 19000
[database]
path = ":memory:"
[models]
primary = "ollama/qwen3:8b"
"#,
        )
        .unwrap();
        let url = resolve_web_url(config_path.to_str(), "http://ignored").unwrap();
        assert_eq!(url, "http://192.168.1.8:19000");
    }

    #[test]
    #[cfg(unix)]
    fn find_listeners_returns_empty_for_closed_ephemeral_port() {
        let pids = serve::find_roboticus_listeners(0).unwrap();
        assert!(pids.is_empty());
    }

    #[test]
    #[cfg(unix)]
    fn find_roboticus_listeners_filters_by_process_name() {
        let pids = serve::find_roboticus_listeners(0);
        assert!(pids.unwrap().is_empty());
    }
}
