//! Thymos CLI — interact with the Thymos server from the terminal.
//!
//! Usage:
//!     thymos run "Set greeting to hello"
//!     thymos run "Say hello" --model openai --max-steps 8
//!     thymos status <run-id>
//!     thymos stream <run-id>
//!     thymos world <run-id>
//!     thymos replay <run-id>
//!     thymos usage
//!     thymos health

use clap::{Parser, Subcommand};
use serde_json::Value;
use std::process::Command as ProcessCommand;

mod shell;

/// `<pkg-version> (<git-sha>)`, e.g. `0.5.0 (a1b2c3d)`. Lets any binary report
/// the exact build it came from — `thymos --version`.
const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("THYMOS_GIT_SHA"), ")");

#[derive(Parser)]
#[command(name = "thymos", version = VERSION, about = "Thymos governed-cognition CLI")]
struct Cli {
    /// Server URL (default: http://localhost:3001).
    #[arg(long, env = "THYMOS_URL", default_value = "http://localhost:3001")]
    url: String,

    /// API key for authenticated requests.
    #[arg(long, env = "THYMOS_API_KEY")]
    api_key: Option<String>,

    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Subcommand)]
enum Commands {
    /// Create and start a new agent run.
    Run {
        /// Task description.
        task: String,
        /// Maximum steps (default: 16).
        #[arg(long, default_value = "16")]
        max_steps: u32,
        /// Cognition provider. Default `auto` uses the server's configured
        /// provider (which resolves THYMOS_DEFAULT_PROVIDER, then the first API
        /// key, then mock). Pass anthropic / openai / local / lmstudio /
        /// huggingface / mock to override it for this run.
        #[arg(long, default_value = "auto")]
        provider: String,
        /// Model override.
        #[arg(long)]
        model: Option<String>,
        /// Base URL override for any OpenAI-compatible endpoint (used with
        /// `--provider openai` / a custom host). Presets set this for you.
        #[arg(long = "base-url")]
        base_url: Option<String>,
        /// Tool scopes (comma-separated).
        #[arg(long)]
        scopes: Option<String>,
        /// Bind a skill (name or content-id). It narrows the run's authority
        /// (tools ∩, ceiling AND, budget min) and prepends its instructions.
        #[arg(long)]
        skill: Option<String>,
        /// Skill param override `key=value` (repeatable).
        #[arg(long = "skill-param", value_parser = parse_kv)]
        skill_param: Vec<(String, String)>,
        /// After starting the run, stream cognition events until it completes.
        #[arg(long, short = 'f')]
        follow: bool,
    },
    /// Author and inspect skills (reusable, authority-narrowing templates).
    Skill {
        #[command(subcommand)]
        action: SkillAction,
    },
    /// Get run status and summary.
    Status {
        /// Run ID.
        run_id: String,
    },
    /// Stream cognition events (tokens) in real-time.
    Stream {
        /// Run ID.
        run_id: String,
    },
    /// Get world state for a run.
    World {
        /// Run ID.
        run_id: String,
    },
    /// Verify and fold a run's execution ledger.
    Replay {
        /// Run ID.
        run_id: String,
        /// Accepted for protocol-shaped scripts; replay always verifies integrity.
        #[arg(long)]
        verify: bool,
        /// Accepted for protocol-shaped scripts; replay always folds committed state.
        #[arg(long)]
        fold_world: bool,
        /// Accepted for protocol-shaped scripts; replay reports ledger-visible policy outcomes.
        #[arg(long)]
        policy_trace: bool,
        /// Require every replayed commit to match a compiler version.
        #[arg(long)]
        require_compiler: Option<String>,
        /// Print the raw JSON replay report.
        #[arg(long)]
        json: bool,
    },
    /// Print a trajectory's full governance story: the commit chain,
    /// rejections, suspensions, delegations, the policy decision behind each
    /// committed action, and a replay-verification verdict. This is the audit
    /// trail rendered for a human — the demo of "the boundary worked".
    Audit {
        /// Run ID.
        run_id: String,
        /// Print the raw JSON (ledger entries + replay report) instead.
        #[arg(long)]
        json: bool,
    },
    /// List supported cognition providers / models (presets + how to start).
    Providers {
        /// Print the preset registry as JSON.
        #[arg(long)]
        json: bool,
    },
    /// List the real-world actions (tools) the agent can take, by effect class.
    Tools {
        /// Print the live runtime's registered tools as JSON (queries the server).
        #[arg(long)]
        json: bool,
    },
    /// Show API gateway usage stats.
    Usage,
    /// Health check.
    Health,
    /// Branded local + runtime readiness dashboard.
    Doctor,
    /// Show terminal configuration, env vars, and next commands.
    Config,
    /// Connect a model: copy-paste instructions for API keys and local LLMs.
    Setup {
        /// Write a starter `.env` you can fill in (won't overwrite an existing one).
        #[arg(long)]
        init: bool,
    },
    /// Make a provider the server default (writes ./.env). e.g. `thymos use openai`.
    Use {
        /// Provider id: anthropic, openai, mock, or a preset (`thymos providers`).
        provider: String,
        /// Default model for this provider (optional).
        #[arg(long)]
        model: Option<String>,
        /// Also save the API key to .env (otherwise you're told which env to set).
        #[arg(long)]
        key: Option<String>,
    },
    /// Approve or deny a pending proposal.
    Approve {
        /// Run ID.
        run_id: String,
        /// Approval channel name.
        channel: String,
        /// Deny instead of approve.
        #[arg(long)]
        deny: bool,
    },
    /// Run history operations (list / show / diff).
    Runs {
        #[command(subcommand)]
        action: RunsAction,
    },
    /// Cancel a running agent.
    Cancel {
        /// Run ID.
        run_id: String,
    },
    /// Launch an interactive Thymos shell — programmable terminal with
    /// persistent session defaults and an `auto` autonomous loop.
    Shell,
}

#[derive(Subcommand)]
enum RunsAction {
    /// List recent runs.
    Ls {
        /// Filter by status (running, completed, failed).
        #[arg(long)]
        status: Option<String>,
        /// Page size (default: 50, max 200).
        #[arg(long, default_value = "50")]
        limit: u32,
        /// Offset for pagination.
        #[arg(long, default_value = "0")]
        offset: u32,
        /// Print the raw JSON page instead of the table.
        #[arg(long)]
        json: bool,
    },
    /// Show full record + ledger entries for one run.
    Show {
        /// Run ID.
        run_id: String,
    },
    /// Diff the ledger entries of two runs (counts + final-answer compare).
    Diff {
        /// Source run ID.
        a: String,
        /// Target run ID.
        b: String,
    },
}

#[derive(clap::Subcommand)]
enum SkillAction {
    /// List authored skills.
    List,
    /// Show a skill's full definition (by name or content-id).
    Show {
        /// Skill name or content-id.
        name: String,
    },
    /// Create a new skill.
    New {
        /// Skill name (the human handle).
        name: String,
        /// Instructions prepended to the task (use `{param}` placeholders).
        #[arg(long)]
        instructions: String,
        /// One-line title.
        #[arg(long)]
        title: Option<String>,
        /// Allowed tools (comma-separated globs, e.g. `kv_*,fs.read`). Empty =
        /// no extra tool restriction (the writ still gates).
        #[arg(long)]
        tools: Option<String>,
        /// Effect-ceiling cap (comma list from read,write,external,irreversible).
        /// Default: `read,write`. The run's ceiling is AND-ed with this.
        #[arg(long)]
        ceiling: Option<String>,
    },
    /// Tune an existing skill (bumps its version, minting a new content-id).
    Tune {
        /// Skill name to edit.
        name: String,
        #[arg(long)]
        instructions: Option<String>,
        #[arg(long)]
        tools: Option<String>,
        #[arg(long)]
        ceiling: Option<String>,
    },
}

/// Parse a `key=value` CLI argument.
fn parse_kv(s: &str) -> Result<(String, String), String> {
    s.split_once('=')
        .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
        .ok_or_else(|| format!("expected key=value, got '{s}'"))
}

#[tokio::main]
async fn main() {
    // Load .env from CWD or any parent dir so THYMOS_URL / THYMOS_API_KEY
    // and provider tokens are picked up without needing to `source` manually.
    let _ = dotenvy::dotenv();

    let cli = Cli::parse();
    let client = reqwest::Client::new();

    // Bare `thymos` (no subcommand) → branded home screen.
    let Some(command) = cli.command else {
        cmd_home(&cli.url);
        return;
    };

    let result = match command {
        Commands::Run {
            task,
            max_steps,
            provider,
            model,
            base_url,
            scopes,
            skill,
            skill_param,
            follow,
        } => {
            cmd_run(
                &client,
                &cli.url,
                cli.api_key.as_deref(),
                CmdRunOptions {
                    start: StartRunOptions {
                        task: &task,
                        max_steps,
                        provider: &provider,
                        model: model.as_deref(),
                        base_url: base_url.as_deref(),
                        scopes: scopes.as_deref(),
                        skill: skill.as_deref(),
                        skill_params: &skill_param,
                    },
                    follow,
                },
            )
            .await
        }
        Commands::Skill { action } => {
            cmd_skill(&client, &cli.url, cli.api_key.as_deref(), action).await
        }
        Commands::Status { run_id } => {
            cmd_status(&client, &cli.url, cli.api_key.as_deref(), &run_id).await
        }
        Commands::Stream { run_id } => cmd_stream(&cli.url, &run_id).await,
        Commands::World { run_id } => {
            cmd_world(&client, &cli.url, cli.api_key.as_deref(), &run_id).await
        }
        Commands::Replay {
            run_id,
            require_compiler,
            json,
            ..
        } => {
            cmd_replay(
                &client,
                &cli.url,
                cli.api_key.as_deref(),
                &run_id,
                require_compiler.as_deref(),
                json,
            )
            .await
        }
        Commands::Audit { run_id, json } => {
            cmd_audit(&client, &cli.url, cli.api_key.as_deref(), &run_id, json).await
        }
        Commands::Providers { json } => cmd_providers(json),
        Commands::Tools { json } => cmd_tools(&client, &cli.url, json).await,
        Commands::Usage => cmd_usage(&client, &cli.url, cli.api_key.as_deref()).await,
        Commands::Health => cmd_health(&client, &cli.url).await,
        Commands::Doctor => cmd_doctor(&client, &cli.url, cli.api_key.as_deref()).await,
        Commands::Config => cmd_config(&cli.url, cli.api_key.as_deref()),
        Commands::Setup { init } => cmd_setup(init),
        Commands::Use {
            provider,
            model,
            key,
        } => cmd_use(&provider, model.as_deref(), key.as_deref()),
        Commands::Approve {
            run_id,
            channel,
            deny,
        } => {
            cmd_approve(
                &client,
                &cli.url,
                cli.api_key.as_deref(),
                &run_id,
                &channel,
                !deny,
            )
            .await
        }
        Commands::Cancel { run_id } => {
            cmd_cancel(&client, &cli.url, cli.api_key.as_deref(), &run_id).await
        }
        Commands::Shell => shell::cmd_shell(&client, &cli.url, cli.api_key.as_deref()).await,
        Commands::Runs { action } => match action {
            RunsAction::Ls {
                status,
                limit,
                offset,
                json,
            } => {
                cmd_runs_ls(
                    &client,
                    &cli.url,
                    cli.api_key.as_deref(),
                    status.as_deref(),
                    limit,
                    offset,
                    json,
                )
                .await
            }
            RunsAction::Show { run_id } => {
                cmd_runs_show(&client, &cli.url, cli.api_key.as_deref(), &run_id).await
            }
            RunsAction::Diff { a, b } => {
                cmd_runs_diff(&client, &cli.url, cli.api_key.as_deref(), &a, &b).await
            }
        },
    };

    if let Err(e) = result {
        eprintln!("error: {e}");
        if is_connection_error(&e) {
            eprintln!();
            eprintln!("Could not reach the Thymos server at {}.", cli.url);
            eprintln!("Is it running?  Start it with:  cargo run -p thymos-server");
            eprintln!("Point at another server with  --url <addr>  or  THYMOS_URL=<addr>.");
        }
        std::process::exit(1);
    }
}

/// Heuristic over the stringified transport error: did we fail because nothing
/// was listening / reachable, rather than because the server returned an error?
fn is_connection_error(msg: &str) -> bool {
    let m = msg.to_ascii_lowercase();
    // reqwest's top-level Display is just "error sending request for url (…)" —
    // the connect/DNS cause lives in the source chain, which is dropped when the
    // error is stringified. For a CLI a failure to even send the request means
    // the server is unreachable (an HTTP-status error takes a different path),
    // so this phrase is a reliable signal on its own.
    m.contains("error sending request")
        || m.contains("connection refused")
        || m.contains("error trying to connect")
        || m.contains("tcp connect error")
        || m.contains("connection reset")
        || m.contains("dns error")
        || m.contains("failed to lookup")
}

fn color_enabled() -> bool {
    use std::io::IsTerminal;
    std::env::var_os("NO_COLOR").is_none()
        && std::env::var("TERM")
            .map(|term| term != "dumb")
            .unwrap_or(true)
        // Don't emit SGR codes when output is piped/redirected (scripts, tests,
        // `| grep`); only colorize a real terminal.
        && std::io::stdout().is_terminal()
}

fn paint(code: &str, text: impl AsRef<str>) -> String {
    if color_enabled() {
        format!("\x1b[{code}m{}\x1b[0m", text.as_ref())
    } else {
        text.as_ref().to_string()
    }
}

pub(crate) fn brand_banner() {
    // "OPEN THYMOS" in ANSI Shadow, painted top→bottom in the brand violet
    // gradient (#c77dff → #7c3aed) so it reads as a glowing logo lockup.
    const ART: [&str; 12] = [
        r" ██████╗ ██████╗ ███████╗███╗   ██╗",
        r"██╔═══██╗██╔══██╗██╔════╝████╗  ██║",
        r"██║   ██║██████╔╝█████╗  ██╔██╗ ██║",
        r"██║   ██║██╔═══╝ ██╔══╝  ██║╚██╗██║",
        r"╚██████╔╝██║     ███████╗██║ ╚████║",
        r" ╚═════╝ ╚═╝     ╚══════╝╚═╝  ╚═══╝",
        r"████████╗██╗  ██╗██╗   ██╗███╗   ███╗ ██████╗ ███████╗",
        r"╚══██╔══╝██║  ██║╚██╗ ██╔╝████╗ ████║██╔═══██╗██╔════╝",
        r"   ██║   ███████║ ╚████╔╝ ██╔████╔██║██║   ██║███████╗",
        r"   ██║   ██╔══██║  ╚██╔╝  ██║╚██╔╝██║██║   ██║╚════██║",
        r"   ██║   ██║  ██║   ██║   ██║ ╚═╝ ██║╚██████╔╝███████║",
        r"   ╚═╝   ╚═╝  ╚═╝   ╚═╝   ╚═╝     ╚═╝ ╚═════╝ ╚══════╝",
    ];
    let (top, bot) = ((199.0_f32, 125.0, 255.0), (124.0_f32, 58.0, 237.0));
    let span = (ART.len() - 1).max(1) as f32;
    println!();
    for (i, line) in ART.iter().enumerate() {
        let t = i as f32 / span;
        let r = (top.0 + (bot.0 - top.0) * t) as u8;
        let g = (top.1 + (bot.1 - top.1) * t) as u8;
        let b = (top.2 + (bot.2 - top.2) * t) as u8;
        println!("{}", paint(&format!("38;2;{r};{g};{b};1"), line));
    }
    println!(
        "  {}",
        paint("38;2;139;233;255;1", "governed execution runtime"),
    );
    println!(
        "  {}",
        paint(
            "38;2;109;119;137",
            "cognition proposes · the runtime governs · the ledger records",
        ),
    );
    println!();
}

// ----- brand palette (truecolor SGR codes) -----
const C_VIOLET: &str = "38;2;199;125;255";
const C_VIOLET_B: &str = "38;2;199;125;255;1";
const C_STAR: &str = "38;2;139;233;255";
const C_OK: &str = "38;2;52;211;153;1";
const C_WARN: &str = "38;2;251;191;36;1";
const C_RED: &str = "38;2;255;111;111;1";
const C_DIM: &str = "2";

/// A glyph + violet/cyan badge for an execution phase, e.g. Intent → Proposal →
/// Commit. Unknown phases fall back to a neutral dot.
fn phase_badge(phase: &str) -> String {
    let (glyph, code) = match phase.to_ascii_lowercase().as_str() {
        "intent" => ('◆', C_STAR),
        "proposal" | "propose" | "compile" => ('▸', C_VIOLET_B),
        "commit" | "committed" => ('✓', C_OK),
        "rejection" | "rejected" | "deny" => ('✕', C_RED),
        "approval" | "pending_approval" | "suspended" => ('⏸', C_WARN),
        "cognition" | "think" | "model" => ('✷', C_VIOLET),
        "tool" | "execute" => ('⚙', C_VIOLET),
        _ => ('·', C_DIM),
    };
    paint(code, format!("{glyph} {phase}"))
}

/// Color an execution-log level word.
fn level_code(level: &str) -> &'static str {
    match level.to_ascii_lowercase().as_str() {
        "ok" | "success" | "commit" => C_OK,
        "warn" | "warning" => C_WARN,
        "error" | "fail" | "failed" => C_RED,
        _ => C_DIM,
    }
}

/// Status word colored by lifecycle state.
fn status_code(status: &str) -> &'static str {
    match status.to_ascii_lowercase().as_str() {
        "completed" | "complete" | "ok" => C_OK,
        "failed" | "cancelled" | "error" => C_RED,
        _ => C_VIOLET_B,
    }
}

fn status_line(label: &str, ok: bool, detail: impl AsRef<str>) {
    let marker = if ok {
        paint(C_OK, "OK ")
    } else {
        paint(C_WARN, "CHK")
    };
    println!("{marker}  {label:<24} {}", detail.as_ref());
}

/// One aligned `command  description` row for the home screen.
fn home_row(cmd: &str, desc: &str) {
    println!(
        "    {}{}",
        paint(C_VIOLET, format!("{cmd:<34}")),
        paint(C_DIM, desc)
    );
}

fn home_section(title: &str) {
    println!();
    println!("  {}", paint(C_VIOLET_B, title));
}

/// One example task, shown as a copy-pasteable command.
fn home_example(task: &str) {
    println!(
        "      {} {}",
        paint(C_STAR, "›"),
        paint(C_DIM, format!("thymos run \"{task}\""))
    );
}

/// `thymos` with no subcommand: the branded home — banner, what it is, how to
/// start, what a task is, and the command map. Pure print; no network.
fn cmd_home(url: &str) {
    brand_banner();

    home_section("WHAT IT DOES");
    println!("    {}", paint(C_DIM, "Give it a task in plain English. A model proposes actions; the"));
    println!("    {}", paint(C_DIM, "runtime checks each against a signed capability writ + policy"));
    println!("    {}", paint(C_DIM, "BEFORE anything runs, and records every step in a replayable ledger."));

    home_section("START HERE");
    home_row("thymos setup", "connect a model (API key or local LLM) — do this first");
    home_row("thymos use <provider>", "set the default model, e.g. `thymos use openai`");
    home_row("thymos run \"<task>\" --follow", "run a task; watch Intent → Proposal → Commit live");
    home_row("thymos doctor", "check what's connected and ready");

    home_section("WHAT'S A TASK?");
    println!("    {}", paint(C_DIM, "Plain-English work for the agent — for example:"));
    home_example("Map this repo and summarize how the runtime boundary works");
    home_example("Read config.json, bump the version, and write it back");
    home_example("Find every TODO in src/ and list them with file:line");
    println!(
        "    {}",
        paint(C_DIM, "Grant tools with --scopes (e.g. --scopes kv_set,kv_get or fs_read).")
    );

    home_section("GOVERNANCE");
    home_row("thymos audit <run-id>", "the full governance trail + replay verdict");
    home_row("thymos replay <run-id>", "verify the ledger folds to its world");
    home_row("thymos world <run-id>", "current projected world state");
    home_row("thymos approve <run-id> <ch>", "clear a human-in-the-loop gate");

    home_section("MORE");
    home_row("thymos providers", "list models / presets (anthropic, openai, local…)");
    home_row("thymos tools", "real-world actions: shell, http, fs, mcp… + effect class");
    home_row("thymos health", "server liveness · live-vs-mock · ledger backend");
    home_row("thymos shell", "interactive session (type `health`, `run \"…\"`)");

    println!();
    println!("  {} {}", paint(C_DIM, "server"), paint(C_STAR, url));
    println!(
        "  {} {}",
        paint(C_DIM, "repo  "),
        paint(C_VIOLET, "https://github.com/gryszzz/open-thymos")
    );
    println!();
    println!(
        "  {}",
        paint(C_DIM, "new here?  run `thymos setup`, then `thymos run \"…\" --follow`")
    );
}

/// `thymos setup` — copy-paste instructions to connect a real model or a local
/// LLM, with `--init` to drop a starter `.env`. Pure local; no network.
fn cmd_setup(init: bool) -> Result<(), String> {
    brand_banner();
    println!("  {}", paint(C_VIOLET_B, "Connect a model"));
    println!(
        "  {}",
        paint(C_DIM, "Default is a deterministic MOCK (no key needed). Pick one below,")
    );
    println!(
        "  {}",
        paint(C_DIM, "set it in your shell or a .env file, then restart the server.")
    );

    let block = |title: &str, lines: &[&str]| {
        println!();
        println!("  {}", paint(C_VIOLET_B, title));
        for l in lines {
            println!("    {}", paint(C_DIM, l));
        }
    };

    block(
        "Anthropic — Claude (best tool use)",
        &[
            "export ANTHROPIC_API_KEY=sk-ant-...",
            "key → https://console.anthropic.com/settings/keys",
        ],
    );
    block(
        "OpenAI — and any OpenAI-compatible host",
        &[
            "export OPENAI_API_KEY=sk-...",
            "key → https://platform.openai.com/api-keys",
        ],
    );
    block(
        "Local LLM — no key, runs on your machine",
        &[
            "LM Studio: load a model, start its server (:1234), then:",
            "    thymos run \"…\" --provider lmstudio",
            "Ollama:    `ollama serve`, then start the server with:",
            "    THYMOS_DEFAULT_PROVIDER=ollama THYMOS_DEFAULT_MODEL=llama3.2 \\",
            "      cargo run -p thymos-server",
        ],
    );
    block(
        "Hugging Face Router — hosted free tier",
        &[
            "export HF_TOKEN=hf_...",
            "thymos run \"…\" --provider huggingface",
        ],
    );

    println!();
    println!("  {}", paint(C_VIOLET_B, "Where keys live"));
    println!(
        "    {}",
        paint(C_DIM, "The CLI & server auto-load a .env from the current or any parent dir.")
    );
    println!(
        "    {}",
        paint(C_DIM, "`thymos providers` lists every preset · `thymos doctor` verifies the wiring.")
    );

    if init {
        let path = std::path::Path::new(".env");
        if path.exists() {
            println!();
            println!("  {}", paint(C_WARN, ".env already exists — left untouched."));
        } else {
            let template = "# OpenThymos — uncomment ONE provider, fill it in, then restart the server.\n\
                # ANTHROPIC_API_KEY=sk-ant-...\n\
                # OPENAI_API_KEY=sk-...\n\
                # HF_TOKEN=hf_...\n\
                # Local LLM (no key):\n\
                # THYMOS_DEFAULT_PROVIDER=ollama\n\
                # THYMOS_DEFAULT_MODEL=llama3.2\n";
            std::fs::write(path, template).map_err(|e| format!("writing .env: {e}"))?;
            println!();
            println!(
                "  {} {}",
                paint(C_OK, "wrote .env template →"),
                paint(C_STAR, "./.env")
            );
            println!(
                "    {}",
                paint(C_DIM, "edit it, uncomment a provider, then restart the server.")
            );
        }
    } else {
        println!();
        println!(
            "  {}",
            paint(C_DIM, "tip: `thymos setup --init` drops a starter .env you can fill in.")
        );
    }
    println!();
    Ok(())
}

/// `thymos tools` — the catalog of real-world actions the agent can take,
/// grouped by effect class. Pure print; mirrors the built-in tool registry.
async fn cmd_tools(client: &reqwest::Client, url: &str, json: bool) -> Result<(), String> {
    if json {
        // The live registry (built-ins + manifest + MCP tools), not the static
        // overview table — query the running server.
        let resp = client
            .get(format!("{url}/tools"))
            .send()
            .await
            .map_err(|e| format!("could not reach the runtime at {url}: {e}"))?;
        let body = json_body_or_error(resp).await?;
        println!("{}", serde_json::to_string_pretty(&body).unwrap());
        return Ok(());
    }
    brand_banner();
    println!("  {}", paint(C_VIOLET_B, "Real-world actions the agent can take"));
    println!(
        "  {}",
        paint(C_DIM, "Every tool call is checked against the run's writ effect ceiling")
    );
    println!(
        "  {}",
        paint(C_DIM, "(Read ≤ Write ≤ External ≤ Irreversible) BEFORE it executes.")
    );

    let group = |title: &str, sub: &str, rows: &[(&str, &str)]| {
        println!();
        println!("  {}  {}", paint(C_VIOLET_B, title), paint(C_DIM, sub));
        for (name, desc) in rows {
            println!(
                "    {}{}",
                paint(C_VIOLET, format!("{name:<14}")),
                paint(C_DIM, desc)
            );
        }
    };

    group(
        "READ",
        "no side effects",
        &[
            ("fs_read", "read a file"),
            ("list_files", "list a directory"),
            ("repo_map", "structural map of a repo"),
            ("grep", "search file contents"),
            ("kv_get", "read run state"),
            ("memory_recall", "recall agent memory"),
        ],
    );
    group(
        "WRITE",
        "local state / files",
        &[
            ("fs_patch", "edit a file (diff-applied)"),
            ("kv_set", "set run state"),
            ("memory_store", "persist agent memory"),
            ("delegate", "spawn a sub-agent (writ ⊆ parent)"),
        ],
    );
    group(
        "EXTERNAL",
        "network / processes — the real world",
        &[
            ("http", "call any HTTP API / URL"),
            ("shell", "run a shell command"),
            ("test_run", "run the project's tests"),
            ("mcp_*", "bridge ANY MCP server (DBs, browsers, SaaS…)"),
        ],
    );

    println!();
    println!("  {}", paint(C_VIOLET_B, "Grant + extend"));
    println!(
        "    {}",
        paint(C_DIM, "Grant per run:  thymos run \"…\" --scopes fs_read,fs_patch,shell")
    );
    println!(
        "    {}",
        paint(C_DIM, "Add your own:   manifest tools + MCP servers via the marketplace")
    );
    println!(
        "    {}",
        paint(C_DIM, "Irreversible-class tools suspend for human approval before running.")
    );
    Ok(())
}

/// Insert or replace a `KEY=value` line in .env content (also replaces a
/// commented `# KEY=` line); appends if absent.
fn upsert_env(content: &str, key: &str, value: &str) -> String {
    let newline = format!("{key}={value}");
    let active = format!("{key}=");
    let commented = format!("# {key}=");
    let mut found = false;
    let mut lines: Vec<String> = content
        .lines()
        .map(|l| {
            let t = l.trim_start();
            if t.starts_with(&active) || t.starts_with(&commented) {
                found = true;
                newline.clone()
            } else {
                l.to_string()
            }
        })
        .collect();
    if !found {
        lines.push(newline);
    }
    let mut s = lines.join("\n");
    s.push('\n');
    s
}

/// `thymos use <provider>` — make a provider the server default by writing
/// THYMOS_DEFAULT_PROVIDER (+ model, + key) to ./.env. Pure local.
fn cmd_use(provider: &str, model: Option<&str>, key: Option<&str>) -> Result<(), String> {
    use thymos_cognition::presets;
    let p = provider.to_lowercase();
    let native = matches!(p.as_str(), "anthropic" | "openai" | "mock");
    let preset = presets::resolve(&p);
    if !native && preset.is_none() {
        return Err(format!(
            "unknown provider '{provider}'. Try: anthropic, openai, mock, or a preset \
             (run `thymos providers` to list all)."
        ));
    }

    let key_env: Option<String> = match p.as_str() {
        "anthropic" => Some("ANTHROPIC_API_KEY".into()),
        "openai" => Some("OPENAI_API_KEY".into()),
        _ => preset.and_then(|pr| pr.api_key_envs.first().map(|s| s.to_string())),
    };
    let is_local = p == "mock" || preset.map(|pr| pr.local).unwrap_or(false);
    let default_model = model
        .map(|m| m.to_string())
        .or_else(|| preset.map(|pr| pr.default_model.to_string()));

    let mut env = std::fs::read_to_string(".env").unwrap_or_default();
    env = upsert_env(&env, "THYMOS_DEFAULT_PROVIDER", &p);
    if let Some(m) = &default_model {
        env = upsert_env(&env, "THYMOS_DEFAULT_MODEL", m);
    }
    if let (Some(k), Some(ke)) = (key, key_env.as_deref()) {
        env = upsert_env(&env, ke, k);
    }
    std::fs::write(".env", env).map_err(|e| format!("writing .env: {e}"))?;

    brand_banner();
    println!(
        "  {} {}",
        paint(C_OK, "✓ default provider →"),
        paint(C_VIOLET_B, &p)
    );
    if let Some(m) = &default_model {
        println!("    {} {}", paint(C_DIM, "model "), paint(C_VIOLET, m));
    }
    println!("    {}", paint(C_DIM, "saved to ./.env"));
    println!();
    if is_local {
        println!(
            "  {}",
            paint(C_DIM, "local/offline provider — make sure its server is running.")
        );
    } else if key.is_some() {
        println!("  {}", paint(C_OK, "key saved to .env."));
    } else if let Some(ke) = &key_env {
        println!(
            "  {} {}",
            paint(C_WARN, "still needs a key:"),
            paint(C_DIM, format!("add {ke}=… to .env  (or `thymos use {p} --key sk-…`)"))
        );
    }
    println!();
    println!("  {}", paint(C_VIOLET_B, "Activate"));
    println!("    {}", paint(C_DIM, "restart the server, then every run uses it:"));
    println!("    {}", paint(C_DIM, "cargo run -p thymos-server"));
    println!(
        "    {}",
        paint(C_DIM, format!("verify:  thymos health   → default_provider: {p}"))
    );
    Ok(())
}

fn mask_secret(value: Option<&str>) -> String {
    match value {
        Some(v) if v.len() > 8 => format!("{}...{}", &v[..4], &v[v.len() - 4..]),
        Some(_) => "(set)".into(),
        None => "(not set)".into(),
    }
}

/// Probe the active provider's `/models` endpoint with locally-resolved
/// credentials — the same check the desktop's "Test connection" runs. Returns
/// (reachable, human detail). Never prints or returns key material.
async fn probe_provider(provider: &str) -> (bool, String) {
    use thymos_cognition::presets;
    let p = provider.trim().to_ascii_lowercase();
    if p == "mock" || p.is_empty() {
        return (true, "mock — offline, nothing to probe".into());
    }

    let (base_url, headers): (String, Vec<(String, String)>) = if p == "anthropic" {
        let base = std::env::var("ANTHROPIC_BASE_URL")
            .unwrap_or_else(|_| "https://api.anthropic.com/v1".into());
        let mut hs = vec![("anthropic-version".to_string(), "2023-06-01".to_string())];
        if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") {
            hs.push(("x-api-key".to_string(), k));
        }
        (base, hs)
    } else {
        // openai + every OpenAI-compatible preset share one shape.
        let preset = presets::resolve(&p);
        let base = std::env::var("OPENAI_BASE_URL")
            .ok()
            .filter(|s| !s.trim().is_empty())
            .or_else(|| preset.map(|pr| pr.base_url.to_string()))
            .unwrap_or_else(|| "https://api.openai.com/v1".into());
        let key = preset
            .into_iter()
            .flat_map(|pr| pr.api_key_envs.iter())
            .find_map(|e| std::env::var(e).ok())
            .or_else(|| std::env::var("OPENAI_API_KEY").ok());
        let hs = key
            .map(|k| vec![("Authorization".to_string(), format!("Bearer {k}"))])
            .unwrap_or_default();
        (base.trim_end_matches('/').to_string(), hs)
    };

    let client = match reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(10))
        .build()
    {
        Ok(c) => c,
        Err(e) => return (false, format!("probe client failed: {e}")),
    };
    let mut req = client.get(format!("{base_url}/models"));
    for (k, v) in headers {
        req = req.header(&k, &v);
    }
    match req.send().await {
        Ok(resp) if resp.status().is_success() => {
            let body: Value = resp.json().await.unwrap_or(Value::Null);
            let n = body["data"].as_array().map(|a| a.len()).unwrap_or(0);
            (true, format!("reachable — {n} models"))
        }
        Ok(resp) => (
            false,
            format!("HTTP {} — check the key / base URL", resp.status().as_u16()),
        ),
        Err(e) if e.is_timeout() => (false, "timed out after 10s".into()),
        Err(e) if e.is_connect() => (false, "not reachable (connection failed)".into()),
        Err(_) => (false, "not reachable".into()),
    }
}

fn command_version(command: &str) -> Option<String> {
    ProcessCommand::new(command)
        .arg("--version")
        .output()
        .ok()
        .and_then(|output| {
            if output.status.success() {
                Some(
                    String::from_utf8_lossy(&output.stdout)
                        .lines()
                        .next()?
                        .to_string(),
                )
            } else {
                None
            }
        })
}

pub(crate) fn auth_headers(api_key: Option<&str>) -> Vec<(String, String)> {
    let mut headers = Vec::new();
    if let Some(key) = api_key {
        headers.push(("Authorization".into(), format!("Bearer {key}")));
    }
    headers
}

pub(crate) async fn json_body_or_error(resp: reqwest::Response) -> Result<Value, String> {
    let status = resp.status();
    let body: Value = resp.json().await.map_err(|e| e.to_string())?;
    if !status.is_success() {
        return Err(format!(
            "HTTP {}: {}",
            status,
            serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string())
        ));
    }
    Ok(body)
}

/// POST /runs and return the new run id. Used by both the one-shot `run`
/// command and the interactive shell's `run` / `auto` commands.
#[derive(Clone, Copy)]
pub(crate) struct StartRunOptions<'a> {
    pub(crate) task: &'a str,
    pub(crate) max_steps: u32,
    pub(crate) provider: &'a str,
    pub(crate) model: Option<&'a str>,
    pub(crate) base_url: Option<&'a str>,
    pub(crate) scopes: Option<&'a str>,
    /// Optional skill (name or content-id) to bind — narrows authority + adds
    /// instructions. The server enforces it.
    pub(crate) skill: Option<&'a str>,
    /// Param overrides for the bound skill (`key`, `value`).
    pub(crate) skill_params: &'a [(String, String)],
}

pub(crate) async fn start_run(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    options: StartRunOptions<'_>,
) -> Result<String, String> {
    let mut body = serde_json::json!({
        "task": options.task,
        "max_steps": options.max_steps,
    });
    // `auto` means "don't override the server's configured provider" — so we omit
    // the cognition block entirely and let the server's own resolution order win
    // (THYMOS_DEFAULT_PROVIDER -> first API key -> mock). An explicit provider is
    // sent as a per-run override; `--model` pairs with an explicit provider.
    if !options.provider.eq_ignore_ascii_case("auto") {
        body["cognition"] = serde_json::json!({ "provider": options.provider });
        if let Some(m) = options.model {
            body["cognition"]["model"] = serde_json::json!(m);
        }
        if let Some(u) = options.base_url {
            body["cognition"]["base_url"] = serde_json::json!(u);
        }
    }
    if let Some(s) = options.scopes {
        let scope_list: Vec<&str> = s.split(',').collect();
        body["tool_scopes"] = serde_json::json!(scope_list);
    }
    if let Some(sk) = options.skill {
        body["skill"] = serde_json::json!(sk);
        if !options.skill_params.is_empty() {
            body["skill_params"] = serde_json::json!(options.skill_params);
        }
    }

    let mut req = client.post(format!("{url}/runs")).json(&body);
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }

    let resp = req.send().await.map_err(|e| e.to_string())?;
    let parsed = json_body_or_error(resp).await?;
    parsed["run_id"]
        .as_str()
        .map(str::to_owned)
        .ok_or_else(|| format!("server response missing run_id: {parsed}"))
}

#[derive(Clone, Copy)]
pub(crate) struct CmdRunOptions<'a> {
    pub(crate) start: StartRunOptions<'a>,
    pub(crate) follow: bool,
}

pub(crate) async fn cmd_run(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    options: CmdRunOptions<'_>,
) -> Result<(), String> {
    let run_id = start_run(client, url, api_key, options.start).await?;

    // `Run started: <id>` stays a stable, line-start-parseable contract.
    println!("Run started: {run_id}");
    println!("  {} {}", paint(C_DIM, "task:"), options.start.task);
    if options.start.provider.eq_ignore_ascii_case("auto") {
        println!("  {} auto (server default)", paint(C_DIM, "provider:"));
    } else {
        println!(
            "  {} {}",
            paint(C_DIM, "provider:"),
            paint(C_VIOLET, options.start.provider)
        );
    }
    println!();
    if options.follow {
        println!("{}", paint(C_VIOLET, "── live ── Intent → Proposal → Commit"));
        cmd_stream(url, &run_id).await?;
        // A branded outcome summary once the stream closes (not raw JSON).
        return cmd_run_summary(client, url, api_key, &run_id).await;
    }
    println!("Poll status:  thymos status {run_id}");
    println!("Stream live:  thymos stream {run_id}");
    Ok(())
}

/// Parse an effect-ceiling spec (comma list) into the runtime's bool fields.
/// Default `read,write` matches `EffectCeiling::read_write_local()`.
fn parse_ceiling(csv: Option<&str>) -> serde_json::Value {
    let spec = csv.unwrap_or("read,write");
    let has = |k: &str| spec.split(',').any(|t| t.trim().eq_ignore_ascii_case(k));
    serde_json::json!({
        "read": has("read"),
        "write": has("write"),
        "external": has("external"),
        "irreversible": has("irreversible"),
    })
}

/// Parse a comma-separated tool allow-list into `[{ "tool": … }]`.
fn parse_tools(csv: Option<&str>) -> serde_json::Value {
    match csv {
        None => serde_json::json!([]),
        Some(s) => serde_json::json!(s
            .split(',')
            .map(str::trim)
            .filter(|t| !t.is_empty())
            .map(|t| serde_json::json!({ "tool": t }))
            .collect::<Vec<_>>()),
    }
}

async fn skills_get(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    path: &str,
) -> Result<Value, String> {
    let mut req = client.get(format!("{url}{path}"));
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }
    let resp = req.send().await.map_err(|e| e.to_string())?;
    json_body_or_error(resp).await
}

async fn skills_post(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    body: Value,
) -> Result<Value, String> {
    let mut req = client.post(format!("{url}/skills")).json(&body);
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }
    let resp = req.send().await.map_err(|e| e.to_string())?;
    json_body_or_error(resp).await
}

pub(crate) async fn cmd_skill(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    action: SkillAction,
) -> Result<(), String> {
    match action {
        SkillAction::List => {
            let resp = skills_get(client, url, api_key, "/skills").await?;
            let skills = resp["skills"].as_array().cloned().unwrap_or_default();
            if skills.is_empty() {
                println!(
                    "{}",
                    paint(C_DIM, "no skills yet — thymos skill new <name> --instructions \"…\"")
                );
                return Ok(());
            }
            for s in &skills {
                println!(
                    "{}  {}  {}",
                    paint(C_VIOLET, s["name"].as_str().unwrap_or("")),
                    paint(C_DIM, format!("v{}", s["version"])),
                    s["title"].as_str().unwrap_or("")
                );
                println!("  {} {}", paint(C_DIM, "id:"), s["id"].as_str().unwrap_or(""));
            }
            Ok(())
        }
        SkillAction::Show { name } => {
            let resp = skills_get(client, url, api_key, &format!("/skills/{name}")).await?;
            println!(
                "{}",
                serde_json::to_string_pretty(&resp["skill"]).unwrap_or_default()
            );
            Ok(())
        }
        SkillAction::New {
            name,
            instructions,
            title,
            tools,
            ceiling,
        } => {
            let def = serde_json::json!({
                "name": name,
                "version": 1,
                "title": title.unwrap_or_default(),
                "instructions": instructions,
                "tools": parse_tools(tools.as_deref()),
                "ceiling": parse_ceiling(ceiling.as_deref()),
            });
            let resp = skills_post(client, url, api_key, def).await?;
            println!(
                "created {} {}",
                paint(C_VIOLET, &name),
                paint(C_DIM, resp["id"].as_str().unwrap_or(""))
            );
            Ok(())
        }
        SkillAction::Tune {
            name,
            instructions,
            tools,
            ceiling,
        } => {
            let existing = skills_get(client, url, api_key, &format!("/skills/{name}")).await?;
            let mut def = existing["skill"].clone();
            if def.is_null() {
                return Err(format!("unknown skill '{name}'"));
            }
            if let Some(i) = instructions {
                def["instructions"] = serde_json::json!(i);
            }
            if let Some(t) = tools {
                def["tools"] = parse_tools(Some(&t));
            }
            if let Some(c) = ceiling {
                def["ceiling"] = parse_ceiling(Some(&c));
            }
            let resp = skills_post(client, url, api_key, def).await?;
            println!(
                "tuned {} → v{} {}",
                paint(C_VIOLET, &name),
                resp["skill"]["version"],
                paint(C_DIM, resp["id"].as_str().unwrap_or(""))
            );
            Ok(())
        }
    }
}

/// Compact, branded outcome for a finished run (used after `run --follow`):
/// the verdict counters and final answer, plus the next governance commands.
async fn cmd_run_summary(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    run_id: &str,
) -> Result<(), String> {
    let mut req = client.get(format!("{url}/runs/{run_id}"));
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }
    let body = json_body_or_error(req.send().await.map_err(|e| e.to_string())?).await?;
    let status = body["status"].as_str().unwrap_or("?");
    let s = &body["summary"];
    let steps = s["steps_executed"].as_u64().unwrap_or(0);
    let commits = s["commits"].as_u64().unwrap_or(0);
    let rejections = s["rejections"].as_u64().unwrap_or(0);

    println!();
    println!("{}", paint(status_code(status), format!("● run {status}")));
    println!(
        "  {}   {}   {}",
        paint(C_DIM, format!("steps {steps}")),
        paint(C_OK, format!("✓ commits {commits}")),
        paint(
            if rejections > 0 { C_WARN } else { C_DIM },
            format!("✕ rejections {rejections}")
        ),
    );
    if let Some(answer) = s["final_answer"].as_str() {
        if !answer.is_empty() {
            println!("  {} {}", paint(C_DIM, "answer"), answer);
        }
    }
    println!();
    println!("  {}", paint(C_DIM, format!("audit the trail:  thymos audit {run_id}")));
    println!("  {}", paint(C_DIM, format!("inspect world:    thymos world {run_id}")));
    Ok(())
}

pub(crate) async fn cmd_cancel(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    run_id: &str,
) -> Result<(), String> {
    let mut req = client.post(format!("{url}/runs/{run_id}/cancel"));
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }
    let resp = req.send().await.map_err(|e| e.to_string())?;
    let body = json_body_or_error(resp).await?;
    println!("{}", serde_json::to_string_pretty(&body).unwrap());
    Ok(())
}

pub(crate) async fn cmd_status(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    run_id: &str,
) -> Result<(), String> {
    let mut req = client.get(format!("{url}/runs/{run_id}"));
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }

    let resp = req.send().await.map_err(|e| e.to_string())?;
    let body = json_body_or_error(resp).await?;
    println!("{}", serde_json::to_string_pretty(&body).unwrap());
    Ok(())
}

pub(crate) async fn cmd_stream(url: &str, run_id: &str) -> Result<(), String> {
    // Unified execution-session stream.
    let resp = reqwest::get(format!("{url}/runs/{run_id}/execution/stream"))
        .await
        .map_err(|e| e.to_string())?;

    if !resp.status().is_success() {
        let body: Value = resp.json().await.map_err(|e| e.to_string())?;
        println!("Error: {}", serde_json::to_string_pretty(&body).unwrap());
        return Ok(());
    }

    use futures_util::StreamExt;
    let mut stream = resp.bytes_stream();
    let mut buffer = String::new();
    let mut last_log_idx = 0u64;
    let mut last_status = String::new();
    let mut last_operator_state = String::new();
    // Channels we've already prompted (or hinted) for, so repeated snapshots
    // of the same suspension don't re-prompt.
    let mut handled_approvals: std::collections::HashSet<String> = Default::default();

    while let Some(chunk) = stream.next().await {
        let chunk = chunk.map_err(|e| e.to_string())?;
        buffer.push_str(&String::from_utf8_lossy(&chunk));

        // Parse SSE lines.
        while let Some(pos) = buffer.find("\n\n") {
            let event_block = buffer[..pos].to_string();
            buffer = buffer[pos + 2..].to_string();

            for line in event_block.lines() {
                if let Some(data) = line.strip_prefix("data:") {
                    let data = data.trim();
                    if let Ok(snapshot) = serde_json::from_str::<Value>(data) {
                        let status = snapshot["status"].as_str().unwrap_or("?");
                        let phase = snapshot["phase"].as_str().unwrap_or("?");
                        let operator_state = snapshot["operator_state"].as_str().unwrap_or("");

                        if status != last_status || operator_state != last_operator_state {
                            println!(
                                "\n{}  {}  {}",
                                paint(status_code(status), format!("● {}", status.to_uppercase())),
                                phase_badge(phase),
                                paint(C_DIM, operator_state)
                            );
                            last_status = status.to_string();
                            last_operator_state = operator_state.to_string();
                        }

                        if let Some(entries) = snapshot["log"].as_array() {
                            for entry in entries {
                                let idx = entry["idx"].as_u64().unwrap_or(0);
                                if idx <= last_log_idx {
                                    continue;
                                }
                                print_execution_entry(entry);
                                last_log_idx = idx;
                            }
                        }

                        // A suspended proposal: resolve it right here in the
                        // terminal (TTY), or print the exact command to run.
                        if status == "waiting_approval" {
                            let channel = snapshot["pending_channel"]
                                .as_str()
                                .unwrap_or("ops")
                                .to_string();
                            if handled_approvals.insert(channel.clone()) {
                                prompt_approval(url, run_id, &channel).await;
                            }
                        }

                        if matches!(status, "completed" | "failed" | "cancelled") {
                            if let Some(answer) = snapshot["final_answer"].as_str() {
                                println!("\n{}", paint(C_VIOLET_B, "── final answer ──"));
                                println!("{answer}");
                            }
                        }
                    }
                }
            }
        }
    }
    println!();
    Ok(())
}

/// Resolve a pending approval from the stream: interactive y/n on a TTY,
/// otherwise print the exact `thymos approve` command and leave it pending.
async fn prompt_approval(url: &str, run_id: &str, channel: &str) {
    use std::io::IsTerminal;
    if !std::io::stdin().is_terminal() {
        println!(
            "{}",
            paint(
                C_DIM,
                format!(
                    "resolve with:  thymos approve {run_id} {channel}   (add --deny to reject)"
                ),
            )
        );
        return;
    }
    print!(
        "{} ",
        paint("1;33", format!("⏸ approve '{channel}'? [y/N]"))
    );
    use std::io::Write as _;
    let _ = std::io::stdout().flush();
    let line = tokio::task::spawn_blocking(|| {
        let mut s = String::new();
        let _ = std::io::stdin().read_line(&mut s);
        s
    })
    .await
    .unwrap_or_default();
    let approve = matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes");
    let client = reqwest::Client::new();
    let resp = client
        .post(format!("{url}/runs/{run_id}/approvals/{channel}"))
        .json(&serde_json::json!({ "approve": approve }))
        .send()
        .await;
    match resp {
        Ok(r) if r.status().is_success() => {
            println!(
                "{}",
                paint(C_DIM, if approve { "— approved" } else { "— denied" })
            );
        }
        Ok(r) => println!(
            "{}",
            paint(C_DIM, format!("approval not accepted (HTTP {})", r.status()))
        ),
        Err(e) => println!("{}", paint(C_DIM, format!("approval failed: {e}"))),
    }
}

fn print_execution_entry(entry: &Value) {
    let phase = entry["phase"].as_str().unwrap_or("?");
    let level = entry["level"].as_str().unwrap_or("info");
    let title = entry["title"].as_str().unwrap_or("");
    let detail = entry["detail"].as_str().unwrap_or("");
    let step = entry["step_index"]
        .as_u64()
        .map(|n| format!("step {} ", n + 1))
        .unwrap_or_default();
    let tool = entry["tool"].as_str().unwrap_or("");

    let mut head = format!("  {}  ", phase_badge(phase));
    if !step.is_empty() {
        head.push_str(&paint(C_DIM, &step));
    }
    if !tool.is_empty() {
        head.push_str(&paint(C_VIOLET, format!("{tool} ")));
    }
    if !level.eq_ignore_ascii_case("info") {
        head.push_str(&paint(level_code(level), format!("{} ", level.to_uppercase())));
    }
    head.push_str(title);
    println!("{head}");
    if !detail.is_empty() {
        println!("     {}", paint(C_DIM, detail));
    }
}

pub(crate) async fn cmd_world(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    run_id: &str,
) -> Result<(), String> {
    let mut req = client.get(format!("{url}/runs/{run_id}/world"));
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }

    let resp = req.send().await.map_err(|e| e.to_string())?;
    let body = json_body_or_error(resp).await?;
    println!("{}", serde_json::to_string_pretty(&body).unwrap());
    Ok(())
}

pub(crate) async fn cmd_replay(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    run_id: &str,
    require_compiler: Option<&str>,
    raw_json: bool,
) -> Result<(), String> {
    let mut req = client.get(format!("{url}/runs/{run_id}/replay"));
    if let Some(version) = require_compiler {
        req = req.query(&[("require_compiler", version)]);
    }
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }

    let resp = req.send().await.map_err(|e| e.to_string())?;
    let body = json_body_or_error(resp).await?;
    if raw_json {
        println!("{}", serde_json::to_string_pretty(&body).unwrap());
        return Ok(());
    }

    let trajectory = body["trajectory_id"].as_str().unwrap_or("?");
    let entries_seen = body["entries_seen"].as_u64().unwrap_or(0);
    let commits = body["commits_replayed"].as_u64().unwrap_or(0);
    let head_seq = body["head_seq"].as_u64().unwrap_or(0);
    let head_commit = body["head_commit"].as_str().unwrap_or("(none)");
    let final_world_hash = body["final_world_hash"].as_str().unwrap_or("?");
    let resources = body["resources"].as_u64().unwrap_or(0);
    let rejections = body["rejected_proposals"].as_u64().unwrap_or(0);
    let approvals = body["pending_approvals"].as_u64().unwrap_or(0);

    println!("{}", paint(C_VIOLET_B, "◆ OpenThymos replay"));
    println!("  {}  {run_id}", paint(C_DIM, "run       "));
    println!("  {}  {trajectory}", paint(C_DIM, "trajectory"));
    println!();
    println!("{}", paint(C_OK, "✓ [integrity] verified"));
    println!("  entries seen:        {entries_seen}");
    println!("  commits replayed:   {commits}");
    println!("  rejected proposals: {rejections}");
    println!("  pending approvals:  {approvals}");
    println!("  head sequence:      {head_seq}");
    println!("  head commit:        {head_commit}");
    println!();
    println!("{}", paint(C_VIOLET_B, "[fold]"));
    println!("  resources:          {resources}");
    println!("  final world hash:   {}", paint(C_STAR, final_world_hash));

    if let Some(versions) = body["compiler_versions_seen"].as_array() {
        let versions = versions
            .iter()
            .filter_map(|v| v.as_str())
            .collect::<Vec<_>>();
        if !versions.is_empty() {
            println!("  compiler versions:  {}", versions.join(", "));
        }
    }

    if let Some(tool_calls) = body["tool_calls"].as_array() {
        if !tool_calls.is_empty() {
            println!();
            println!("{}", paint(C_VIOLET_B, "[tool calls]"));
            for call in tool_calls {
                let seq = call["seq"].as_u64().unwrap_or(0);
                let tool = call["tool"].as_str().unwrap_or("?");
                let latency = call["latency_ms"].as_u64().unwrap_or(0);
                let commit = call["commit_id"].as_str().unwrap_or("");
                let commit_short: String = commit.chars().take(12).collect();
                println!(
                    "  seq={seq:<4} tool={tool:<16} latency={latency}ms commit={commit_short}"
                );
            }
        }
    }

    println!();
    println!("{}", paint(C_OK, "result: replay verified ✓"));
    Ok(())
}

/// Render one short, hex-prefix label for a content-addressed id.
fn short_id(v: &Value) -> String {
    v.as_str().map(|s| s.chars().take(12).collect()).unwrap_or_default()
}

/// Human-readable rendering of a `PolicyDecision` / `RejectionReason`
/// (both serialize as `{ "kind": ..., "detail": ... }`).
fn tagged_reason(v: &Value) -> String {
    let kind = v["kind"].as_str().unwrap_or("?");
    match &v["detail"] {
        Value::Null => kind.to_string(),
        Value::String(s) => format!("{kind}: {s}"),
        Value::Object(o) => {
            let parts: Vec<String> = o
                .iter()
                .map(|(k, val)| format!("{k}={}", val.as_str().unwrap_or(&val.to_string())))
                .collect();
            format!("{kind} ({})", parts.join(", "))
        }
        other => format!("{kind}: {other}"),
    }
}

/// `thymos audit <run-id>` — compose the ledger entries and the replay report
/// into one human-readable governance trail. Pure read: it only GETs
/// `/audit/entries` and `/runs/:id/replay`.
pub(crate) async fn cmd_audit(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    run_id: &str,
    raw_json: bool,
) -> Result<(), String> {
    // 1. Ledger entries for this run's trajectory.
    let mut req = client
        .get(format!("{url}/audit/entries"))
        .query(&[("run_id", run_id)]);
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }
    let entries_body = json_body_or_error(req.send().await.map_err(|e| e.to_string())?).await?;

    // 2. Replay verdict (best-effort: a trajectory with no commits still audits).
    let mut rreq = client.get(format!("{url}/runs/{run_id}/replay"));
    for (k, v) in auth_headers(api_key) {
        rreq = rreq.header(&k, &v);
    }
    let replay = match rreq.send().await {
        Ok(resp) => json_body_or_error(resp).await.ok(),
        Err(_) => None,
    };

    if raw_json {
        println!(
            "{}",
            serde_json::to_string_pretty(&serde_json::json!({
                "entries": entries_body["entries"],
                "replay": replay,
            }))
            .unwrap()
        );
        return Ok(());
    }

    let empty = vec![];
    let entries = entries_body["entries"].as_array().unwrap_or(&empty);
    print!("{}", render_audit(run_id, entries, replay.as_ref(), color_enabled()));
    Ok(())
}

/// Pure renderer for `thymos audit`: turn the ledger entries (+ optional replay
/// report) into the human-readable governance trail. Kept side-effect-free so it
/// is unit-testable against synthetic entries covering every entry kind.
pub(crate) fn render_audit(
    run_id: &str,
    entries: &[Value],
    replay: Option<&Value>,
    color: bool,
) -> String {
    use std::fmt::Write as _;
    let mut out = String::new();
    // Paint only when color is enabled; uncolored output stays byte-identical
    // (the unit tests assert plain strings).
    let c = |code: &str, s: &str| if color { paint(code, s) } else { s.to_string() };

    let trajectory = entries
        .first()
        .and_then(|e| e["trajectory_id"].as_str())
        .or_else(|| replay.and_then(|r| r["trajectory_id"].as_str()))
        .unwrap_or("?");

    let _ = writeln!(out, "{}", c(C_VIOLET_B, "OpenThymos audit"));
    let _ = writeln!(out, "run:              {run_id}");
    let _ = writeln!(out, "trajectory:       {trajectory}");
    let _ = writeln!(out);
    let _ = writeln!(out, "{}", c(C_VIOLET_B, &format!("ledger ({} entries)", entries.len())));

    let mut commits = 0u64;
    let mut rejections = 0u64;
    for e in entries {
        let seq = e["seq"].as_u64().unwrap_or(0);
        let p = &e["payload"];
        let prefix = format!("  #{seq:<3}");
        match p["type"].as_str().unwrap_or("?") {
            "root" => {
                let note = p["note"].as_str().unwrap_or("");
                let _ = writeln!(out, "{prefix} {}        trajectory bound  {note:?}", c(C_VIOLET, "ROOT"));
            }
            "commit" => {
                commits += 1;
                let body = &p["body"];
                let tool = body["observations"]
                    .as_array()
                    .and_then(|o| o.first())
                    .and_then(|o| o["tool"].as_str())
                    .unwrap_or("?");
                let decision = tagged_reason(&body["policy_trace"]["decision"]);
                let rules = body["policy_trace"]["rules_evaluated"]
                    .as_array()
                    .map(|r| {
                        r.iter()
                            .filter_map(|x| x.as_str())
                            .collect::<Vec<_>>()
                            .join(",")
                    })
                    .unwrap_or_default();
                let writ = short_id(&body["writ_id"]);
                let signed = if body["signature"].is_string() {
                    " signed✓"
                } else {
                    ""
                };
                let _ = write!(out, "{prefix} {}      {tool:<16} policy={decision}", c(C_OK, "COMMIT"));
                if !rules.is_empty() {
                    let _ = write!(out, " [{rules}]");
                }
                let _ = write!(out, " writ={writ}{signed}");
                if body["compensates"].is_string() {
                    let _ = write!(out, " compensates={}", short_id(&body["compensates"]));
                }
                if body["routing_evidence"].is_object() {
                    let _ = write!(
                        out,
                        " route={}",
                        body["routing_evidence"]["selected"].as_str().unwrap_or("?")
                    );
                }
                let _ = writeln!(out);
            }
            "rejection" => {
                rejections += 1;
                let _ = writeln!(out, "{prefix} {}    {}", c(C_RED, "REJECTED"), tagged_reason(&p["reason"]));
            }
            "pending_approval" => {
                let channel = p["channel"].as_str().unwrap_or("?");
                let reason = p["reason"].as_str().unwrap_or("");
                let _ = writeln!(out, "{prefix} {}   channel={channel} reason={reason:?}", c(C_WARN, "SUSPENDED"));
            }
            "delegation" => {
                let child = short_id(&p["child_trajectory_id"]);
                let task = p["task"].as_str().unwrap_or("");
                let _ = writeln!(out, "{prefix} {}  child={child} task={task:?}", c(C_VIOLET, "DELEGATION"));
            }
            "branch" => {
                let src = short_id(&p["source_trajectory_id"]);
                let commit = short_id(&p["source_commit_id"]);
                let _ = writeln!(out, "{prefix} {}      from {src}@{commit}", c(C_VIOLET, "BRANCH"));
            }
            other => {
                let _ = writeln!(out, "{prefix} {other}");
            }
        }
    }

    let _ = writeln!(out);
    if let Some(r) = replay {
        let verified = r["verified"].as_bool();
        let final_hash = r["final_world_hash"].as_str().unwrap_or("?");
        let head_seq = r["head_seq"].as_u64().unwrap_or(0);
        let ok = verified != Some(false);
        let _ = writeln!(out, "{}", c(C_VIOLET_B, "replay"));
        let _ = writeln!(
            out,
            "  {}",
            c(
                if ok { C_OK } else { C_RED },
                &format!("[integrity] {}", if ok { "verified" } else { "FAILED" })
            )
        );
        let _ = writeln!(
            out,
            "  commits replayed:   {}",
            r["commits_replayed"].as_u64().unwrap_or(commits)
        );
        let _ = writeln!(
            out,
            "  rejected proposals: {}",
            r["rejected_proposals"].as_u64().unwrap_or(rejections)
        );
        let _ = writeln!(out, "  head sequence:      {head_seq}");
        let _ = writeln!(out, "  final world hash:   {final_hash}");
        let _ = writeln!(out);
        let verdict = if verified == Some(false) {
            "replay verification FAILED"
        } else {
            "replay verified"
        };
        let _ = writeln!(
            out,
            "{}",
            c(
                if ok { C_OK } else { C_RED },
                &format!("result: {commits} commits, {rejections} rejections — {verdict}")
            )
        );
    } else {
        let _ = writeln!(
            out,
            "replay: unavailable (run has no trajectory or replay endpoint errored)"
        );
        let _ = writeln!(out);
        let _ = writeln!(out, "result: {commits} commits, {rejections} rejections");
    }
    out
}

#[cfg(test)]
mod audit_tests {
    use super::render_audit;
    use serde_json::json;

    #[test]
    fn renders_every_entry_kind_and_verdict() {
        let entries = vec![
            json!({"seq":0,"trajectory_id":"deadbeefcafe0000","payload":{"type":"root","note":"demo"}}),
            json!({"seq":1,"payload":{"type":"commit","body":{
                "writ_id":"ab12ab12ab12ab12",
                "observations":[{"tool":"kv_set","latency_ms":42}],
                "policy_trace":{"rules_evaluated":["WritAuthority"],"decision":{"kind":"permit"}},
                "signature":"ff",
                "routing_evidence":{"selected":"anthropic:claude"}
            }}}),
            json!({"seq":2,"payload":{"type":"rejection","reason":{"kind":"policy_denied","detail":"writ does not authorize tool 'delete_all'"}}}),
            json!({"seq":3,"payload":{"type":"pending_approval","channel":"ops","reason":"irreversible"}}),
            json!({"seq":4,"payload":{"type":"delegation","child_trajectory_id":"cccc1111dddd2222","task":"sub-task"}}),
            json!({"seq":5,"payload":{"type":"commit","body":{
                "writ_id":"ab12ab12ab12ab12",
                "observations":[{"tool":"kv_del","latency_ms":3}],
                "policy_trace":{"rules_evaluated":[],"decision":{"kind":"permit"}},
                "compensates":"99aa99aa99aa99aa","signature":null
            }}}),
        ];
        let replay = json!({"verified":true,"commits_replayed":2,"rejected_proposals":1,"head_seq":5,"final_world_hash":"f4cfe88219"});
        let out = render_audit("run-x", &entries, Some(&replay), false);

        assert!(out.contains("trajectory:       deadbeefcafe0000"));
        assert!(out.contains("ROOT        trajectory bound  \"demo\""));
        // commit: tool, human-readable policy decision, rules, short writ, signed mark, route
        assert!(out.contains("COMMIT      kv_set"));
        assert!(out.contains("policy=permit [WritAuthority]"));
        assert!(out.contains("writ=ab12ab12ab12"));
        assert!(out.contains("signed✓"));
        assert!(out.contains("route=anthropic:claude"));
        // rejection renders the tagged reason
        assert!(out.contains("REJECTED    policy_denied: writ does not authorize tool 'delete_all'"));
        assert!(out.contains("SUSPENDED   channel=ops"));
        assert!(out.contains("DELEGATION  child=cccc1111dddd"));
        // compensation commit
        assert!(out.contains("compensates=99aa99aa99aa"));
        // verdict line
        assert!(out.contains("[integrity] verified"));
        assert!(out.contains("result: 2 commits, 1 rejections — replay verified"));
    }

    #[test]
    fn failed_integrity_and_missing_replay() {
        let entries = vec![json!({"seq":0,"trajectory_id":"aa","payload":{"type":"root","note":"x"}})];
        let failed = json!({"verified":false,"final_world_hash":"z","head_seq":0});
        assert!(render_audit("r", &entries, Some(&failed), false).contains("replay verification FAILED"));
        assert!(render_audit("r", &entries, None, false).contains("replay: unavailable"));
    }
}

pub(crate) async fn cmd_usage(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
) -> Result<(), String> {
    let mut req = client.get(format!("{url}/usage"));
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }

    let resp = req.send().await.map_err(|e| e.to_string())?;
    let body = json_body_or_error(resp).await?;
    println!("{}", serde_json::to_string_pretty(&body).unwrap());
    Ok(())
}

pub(crate) async fn cmd_health(client: &reqwest::Client, url: &str) -> Result<(), String> {
    let resp = client
        .get(format!("{url}/health"))
        .send()
        .await
        .map_err(|e| e.to_string())?;
    let body = json_body_or_error(resp).await?;
    println!("{}", serde_json::to_string_pretty(&body).unwrap());
    Ok(())
}

/// List the cognition providers you can drive — the native adapters plus every
/// OpenAI-compatible preset — and how to start each. Runs fully offline; it
/// reads the built-in registry, not the server.
// Literal args are deliberate: this is a width-aligned table, so inlining them
// into the format string would break the column layout.
#[allow(clippy::print_literal)]
pub(crate) fn cmd_providers(json: bool) -> Result<(), String> {
    use thymos_cognition::presets;

    if json {
        let entries: Vec<Value> = presets::all()
            .iter()
            .map(|p| {
                serde_json::json!({
                    "id": p.id,
                    "label": p.label,
                    "base_url": p.base_url,
                    "default_model": p.default_model,
                    "api_key_envs": p.api_key_envs,
                    "native_tools": p.native_tools,
                    "local": p.local,
                })
            })
            .collect();
        let out = serde_json::json!({
            "native": ["anthropic", "openai", "mock"],
            "presets": entries,
        });
        println!("{}", serde_json::to_string_pretty(&out).unwrap());
        return Ok(());
    }

    brand_banner();
    println!("{}", paint("1", "Cognition providers — drive (almost) any model"));
    println!("Cognition only ever proposes intents; the runtime still governs every");
    println!("effect. API keys are read server-side — a provider name never carries one.");
    println!();

    println!("{}", paint("1", "Native adapters"));
    println!("  {:<13} {:<34} {}", "anthropic", "ANTHROPIC_API_KEY", "Claude (native Messages API)");
    println!("  {:<13} {:<34} {}", "openai", "OPENAI_API_KEY", "OpenAI (OPENAI_BASE_URL for any host)");
    println!("  {:<13} {:<34} {}", "mock", "(no key)", "deterministic, offline — the default");
    println!();

    println!("{}", paint("1", "OpenAI-compatible presets — cloud (set the key, go)"));
    for p in presets::all().iter().filter(|p| !p.local) {
        println!(
            "  {:<13} {:<34} e.g. {}",
            p.id,
            p.api_key_envs.join(" / "),
            p.default_model
        );
    }
    println!();

    println!("{}", paint("1", "Local runtimes — no key, just a running server"));
    for p in presets::all().iter().filter(|p| p.local) {
        println!("  {:<13} {:<34} e.g. {}", p.id, p.base_url, p.default_model);
    }
    println!();

    println!("{}", paint("1", "Start with any of them"));
    println!("  Make it the server default (every run uses it):");
    println!("    THYMOS_DEFAULT_PROVIDER=groq GROQ_API_KEY=… cargo run -p thymos-server");
    println!("  Pick per run:");
    println!("    thymos run \"…\" --provider openrouter --model openai/gpt-4o-mini");
    println!("  Point at ANY OpenAI-compatible endpoint:");
    println!("    thymos run \"…\" --provider openai --base-url https://host/v1 --model my-model");
    Ok(())
}

pub(crate) async fn cmd_doctor(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
) -> Result<(), String> {
    brand_banner();
    println!("{}", paint("1", "Thymos Terminal Doctor"));
    println!("endpoint: {url}");
    println!();

    status_line("thymos cli", true, env!("CARGO_PKG_VERSION"));
    status_line("api key", api_key.is_some(), mask_secret(api_key));

    for command in ["cargo", "node", "npm", "git"] {
        match command_version(command) {
            Some(version) => status_line(command, true, version),
            None => status_line(command, false, "not found on PATH"),
        }
    }

    println!();
    let mut active_provider: Option<String> = None;
    match client.get(format!("{url}/health")).send().await {
        Ok(resp) => {
            let status = resp.status();
            let body: Value = resp.json().await.unwrap_or(Value::Null);
            status_line(
                "runtime health",
                status.is_success(),
                format!(
                    "{} {}",
                    status.as_u16(),
                    body["mode"].as_str().unwrap_or("unknown mode")
                ),
            );
            // The single most common first-run footgun: not knowing whether the
            // server is answering with a real model or the deterministic mock.
            let provider = body["default_provider"].as_str().unwrap_or("unknown");
            active_provider = Some(provider.to_string());
            let live = body["cognition_live"].as_bool().unwrap_or(false);
            status_line(
                "cognition",
                live,
                if live {
                    format!("{provider} (live model)")
                } else {
                    format!(
                        "{provider} — MOCK, not a real model (set ANTHROPIC_API_KEY / OPENAI_API_KEY)"
                    )
                },
            );
        }
        Err(err) => status_line("runtime health", false, format!("offline: {err}")),
    }

    match client.get(format!("{url}/ready")).send().await {
        Ok(resp) => {
            let status = resp.status();
            let body: Value = resp.json().await.unwrap_or(Value::Null);
            let checks = body["checks"]
                .as_object()
                .map(|obj| {
                    obj.iter()
                        .map(|(key, value)| {
                            format!(
                                "{key}={}",
                                if value.as_bool().unwrap_or(false) {
                                    "ok"
                                } else {
                                    "wait"
                                }
                            )
                        })
                        .collect::<Vec<_>>()
                        .join(", ")
                })
                .unwrap_or_else(|| "no checks".into());
            status_line("runtime ready", status.is_success(), checks);
        }
        Err(err) => status_line("runtime ready", false, format!("offline: {err}")),
    }

    println!();
    status_line(
        "OPENAI_API_KEY",
        std::env::var_os("OPENAI_API_KEY").is_some(),
        mask_secret(std::env::var("OPENAI_API_KEY").ok().as_deref()),
    );
    status_line(
        "ANTHROPIC_API_KEY",
        std::env::var_os("ANTHROPIC_API_KEY").is_some(),
        mask_secret(std::env::var("ANTHROPIC_API_KEY").ok().as_deref()),
    );
    status_line(
        "HF_TOKEN",
        std::env::var_os("HF_TOKEN").is_some(),
        mask_secret(std::env::var("HF_TOKEN").ok().as_deref()),
    );
    status_line(
        "OPENAI_BASE_URL",
        std::env::var_os("OPENAI_BASE_URL").is_some(),
        std::env::var("OPENAI_BASE_URL").unwrap_or_else(|_| "(not set)".into()),
    );

    // Live connection test against the active provider's /models endpoint —
    // the same probe the desktop's "Test connection" button runs. Keys stay
    // local; only reachability and a model count are printed.
    if let Some(provider) = active_provider.as_deref() {
        println!();
        let (ok, detail) = probe_provider(provider).await;
        status_line("provider connection", ok, format!("{provider}: {detail}"));
    }

    println!();
    println!("{}", paint("38;2;199;125;255;1", "Next moves"));
    println!("  thymos config");
    println!("  thymos shell");
    println!("  thymos run \"Inspect this repo and explain the runtime\" --provider mock --follow");
    Ok(())
}

pub(crate) fn cmd_config(url: &str, api_key: Option<&str>) -> Result<(), String> {
    brand_banner();
    println!("{}", paint("1", "Terminal Configuration"));
    println!();
    let env_url = std::env::var("THYMOS_URL").ok();
    let installed_thymos = command_version("thymos");
    status_line("endpoint", true, url);
    status_line(
        "THYMOS_URL",
        env_url.is_some(),
        env_url.unwrap_or_else(|| format!("defaulting to {url}")),
    );
    status_line("THYMOS_API_KEY", api_key.is_some(), mask_secret(api_key));
    status_line(
        "config file",
        thymos_config_path().is_some(),
        thymos_config_path().unwrap_or_else(|| "~/.config/thymos/thymos.env (not found)".into()),
    );
    status_line(
        "installed thymos",
        installed_thymos.is_some(),
        installed_thymos.unwrap_or_else(|| "run scripts/install.sh".into()),
    );
    println!();
    println!("{}", paint("38;2;199;125;255;1", "Clean terminal workflow"));
    println!("  1. thymos doctor");
    println!("  2. thymos shell");
    println!("  3. set preset code");
    println!("  4. set workspace .");
    println!("  5. auto \"Inspect the repo, improve one small thing, and verify it\"");
    Ok(())
}

fn thymos_config_path() -> Option<String> {
    let home = std::env::var("HOME").ok()?;
    let path = format!("{home}/.config/thymos/thymos.env");
    if std::path::Path::new(&path).exists() {
        Some(path)
    } else {
        None
    }
}

pub(crate) async fn cmd_approve(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    run_id: &str,
    channel: &str,
    approve: bool,
) -> Result<(), String> {
    let mut req = client
        .post(format!("{url}/runs/{run_id}/approvals/{channel}"))
        .json(&serde_json::json!({ "approve": approve }));
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }

    let resp = req.send().await.map_err(|e| e.to_string())?;
    let body = json_body_or_error(resp).await?;
    println!("{}", serde_json::to_string_pretty(&body).unwrap());
    Ok(())
}

pub(crate) async fn cmd_runs_ls(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    status: Option<&str>,
    limit: u32,
    offset: u32,
    json: bool,
) -> Result<(), String> {
    let mut endpoint = format!("{url}/runs?limit={limit}&offset={offset}");
    if let Some(s) = status {
        endpoint.push_str(&format!("&status={s}"));
    }
    let mut req = client.get(&endpoint);
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }
    let resp = req.send().await.map_err(|e| e.to_string())?;
    let body = json_body_or_error(resp).await?;

    if json {
        println!("{}", serde_json::to_string_pretty(&body).unwrap());
        return Ok(());
    }

    let runs = body["runs"].as_array().cloned().unwrap_or_default();
    let total = body["total"].as_u64().unwrap_or(0);
    if runs.is_empty() {
        println!("(no runs)");
        return Ok(());
    }
    println!("{:<14}  {:<10}  TASK", "RUN ID", "STATUS");
    for r in &runs {
        let id = r["run_id"].as_str().unwrap_or("?");
        let st = r["status"].as_str().unwrap_or("?");
        let task = r["task"].as_str().unwrap_or("");
        let id_short: String = id.chars().take(12).collect();
        let task_short: String = task.chars().take(56).collect();
        println!("{id_short:<14}  {st:<10}  {task_short}");
    }
    println!();
    println!("({} of {total} shown, offset {offset})", runs.len());
    Ok(())
}

pub(crate) async fn cmd_runs_show(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    run_id: &str,
) -> Result<(), String> {
    // Status block.
    let mut req = client.get(format!("{url}/runs/{run_id}"));
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }
    let resp = req.send().await.map_err(|e| e.to_string())?;
    let rec = json_body_or_error(resp).await?;
    println!("=== Run {run_id} ===");
    println!("{}", serde_json::to_string_pretty(&rec).unwrap_or_default());

    // Audit entries.
    let mut req = client.get(format!("{url}/audit/entries?run_id={run_id}&limit=200"));
    for (k, v) in auth_headers(api_key) {
        req = req.header(&k, &v);
    }
    let resp = req.send().await.map_err(|e| e.to_string())?;
    let body = json_body_or_error(resp).await?;
    let entries = body["entries"].as_array().cloned().unwrap_or_default();
    println!("\n=== Ledger ({} entries) ===", entries.len());
    for e in &entries {
        let seq = e["seq"].as_u64().unwrap_or(0);
        let kind = e["kind"].as_str().unwrap_or("?");
        let id = e["id"].as_str().unwrap_or("");
        let id_short: String = id.chars().take(12).collect();
        println!("  #{seq:<4} {kind:<18} {id_short}");
    }
    Ok(())
}

pub(crate) async fn cmd_runs_diff(
    client: &reqwest::Client,
    url: &str,
    api_key: Option<&str>,
    a: &str,
    b: &str,
) -> Result<(), String> {
    async fn summary(
        client: &reqwest::Client,
        url: &str,
        api_key: Option<&str>,
        run_id: &str,
    ) -> Result<(Value, Value), String> {
        let mut req = client.get(format!("{url}/runs/{run_id}"));
        for (k, v) in auth_headers(api_key) {
            req = req.header(&k, &v);
        }
        let rec = json_body_or_error(req.send().await.map_err(|e| e.to_string())?).await?;
        let mut req = client.get(format!("{url}/audit/entries?run_id={run_id}&limit=2000"));
        for (k, v) in auth_headers(api_key) {
            req = req.header(&k, &v);
        }
        let entries = json_body_or_error(req.send().await.map_err(|e| e.to_string())?).await?;
        Ok((rec, entries))
    }

    let (rec_a, ent_a) = summary(client, url, api_key, a).await?;
    let (rec_b, ent_b) = summary(client, url, api_key, b).await?;

    fn count_kind(entries: &Value, kind: &str) -> usize {
        entries["entries"]
            .as_array()
            .map(|arr| {
                arr.iter()
                    .filter(|e| e["kind"].as_str() == Some(kind))
                    .count()
            })
            .unwrap_or(0)
    }

    let commits_a = count_kind(&ent_a, "commit");
    let commits_b = count_kind(&ent_b, "commit");
    let rej_a = count_kind(&ent_a, "rejection");
    let rej_b = count_kind(&ent_b, "rejection");
    let final_a = rec_a["summary"]["final_answer"].as_str().unwrap_or("");
    let final_b = rec_b["summary"]["final_answer"].as_str().unwrap_or("");

    println!("           {:<22} {:<22} delta", a, b);
    println!(
        "status     {:<22} {:<22}",
        rec_a["status"].as_str().unwrap_or("?"),
        rec_b["status"].as_str().unwrap_or("?")
    );
    println!(
        "commits    {commits_a:<22} {commits_b:<22} {:+}",
        commits_b as i64 - commits_a as i64
    );
    println!(
        "rejections {rej_a:<22} {rej_b:<22} {:+}",
        rej_b as i64 - rej_a as i64
    );
    println!();
    if final_a == final_b {
        println!("final_answer: identical");
    } else {
        println!("final_answer DIFFERS:");
        println!("  a: {final_a}");
        println!("  b: {final_b}");
    }
    Ok(())
}

// Needed for the streaming SSE client.
mod futures_util {
    pub use tokio_stream::StreamExt;
}
