//! `kranth` — premium interactive terminal client for Kranth.
//!
//! Naked invocation (no subcommand, stdin is a TTY) drops into the REPL.
//! Any subcommand or piped stdin runs in one-shot mode and exits.

use std::io;
use std::process::ExitCode;

use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;

mod auth;
mod cinema;
mod config;
mod personas;
mod repl;
mod theme;

/// Stress-test ideas against adversarial AI personas.
#[derive(Parser, Debug)]
#[command(
    name = "kranth",
    version,
    about = "Premium interactive terminal client for Kranth.",
    long_about = "Run `kranth` with no arguments to drop into the interactive shell.\n\
                  Run `kranth <subcommand>` for one-shot CI / scripting usage."
)]
struct Cli {
    /// API key override. Defaults to the OS keyring entry stored by `kranth login`,
    /// or the `KRANTH_API_KEY` env var if set.
    #[arg(long, env = "KRANTH_API_KEY", global = true, hide_env_values = true)]
    api_key: Option<String>,

    /// API base URL. Defaults to https://api.kranth.com.
    #[arg(long, env = "KRANTH_API_URL", global = true, default_value = "https://api.kranth.com")]
    api_url: String,

    /// Enable verbose logging to stderr (off by default to keep the TUI clean).
    #[arg(long, global = true)]
    verbose: bool,

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

#[derive(Subcommand, Debug)]
enum Command {
    /// Authenticate this device via the browser (device-code flow).
    Login,
    /// Forget the locally-stored API key.
    Logout,
    /// Print the signed-in account + credit balance.
    Whoami,
    /// One-shot: run an idea through the panel debate mode and stream the result.
    Run {
        /// The idea or topic to stress-test.
        idea: String,
    },
    /// One-shot: run an idea through a simulation (N personas) and print the verdict.
    Sim {
        /// The idea to stress-test.
        idea: String,
        /// How many personas to simulate.
        #[arg(long, default_value_t = 50)]
        personas: u32,
        /// Model id to run the personas on.
        #[arg(long, default_value = "claude-sonnet-4-6")]
        model: String,
    },
    /// One-shot: run an idea through Recon (web-grounded research swarm) and print the cited verdict.
    Recon {
        /// The idea to research against the live web.
        idea: String,
        /// Tier slug (defaults to the cheapest available, e.g. the entry tier).
        #[arg(long)]
        tier: Option<String>,
    },
    /// List recent debates (most recent first).
    Ls {
        /// Output as JSON (default: pretty table).
        #[arg(long)]
        json: bool,
    },
    /// Replay a past debate at 4× speed in the cinema.
    Replay {
        /// Debate UUID (from `kranth ls` or a `/d/<id>` share URL).
        debate_id: String,
    },
    /// Attach to a running debate's live stream from another shell.
    Tail {
        /// Debate UUID.
        debate_id: String,
    },
    /// Run a debate against a GitHub PR (URL or org/repo#number).
    Pr {
        /// PR URL — accepts `https://github.com/owner/repo/pull/123` or
        /// `owner/repo#123` shorthand.
        url: String,
    },
    /// Check for a newer release on GitHub and print the upgrade command.
    Update,
    /// Emit shell completion script. `kranth completions zsh > ~/.zfunc/_kranth`
    Completions {
        /// Target shell.
        #[arg(value_enum)]
        shell: Shell,
    },
}

fn main() -> ExitCode {
    let cli = Cli::parse();

    if cli.verbose {
        tracing_subscriber::fmt()
            .with_env_filter(
                tracing_subscriber::EnvFilter::try_from_default_env()
                    .unwrap_or_else(|_| "kranth_cli=debug".into()),
            )
            .with_writer(std::io::stderr)
            .init();
    }

    // Build a multi-threaded runtime so the SSE/network task can live alongside
    // the ratatui draw loop without starving either one.
    let runtime = match tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
    {
        Ok(rt) => rt,
        Err(err) => {
            eprintln!("kranth: failed to start tokio runtime: {err}");
            return ExitCode::from(2);
        }
    };

    let result = runtime.block_on(async move {
        match &cli.command {
            Some(Command::Login) => auth::login(&cli.api_url).await,
            Some(Command::Logout) => auth::logout().await,
            Some(Command::Whoami) => auth::whoami(&cli.api_url, cli.api_key.as_deref()).await,
            Some(Command::Run { idea }) => one_shot_run(&cli, idea).await,
            Some(Command::Sim { idea, personas, model }) => one_shot_sim(&cli, idea, *personas, model).await,
            Some(Command::Recon { idea, tier }) => one_shot_recon(&cli, idea, tier.as_deref()).await,
            Some(Command::Ls { json }) => one_shot_ls(&cli, *json).await,
            Some(Command::Replay { debate_id }) => one_shot_replay(&cli, debate_id).await,
            Some(Command::Tail { debate_id }) => one_shot_tail(&cli, debate_id).await,
            Some(Command::Pr { url }) => one_shot_pr(&cli, url).await,
            Some(Command::Update) => one_shot_update().await,
            Some(Command::Completions { shell }) => {
                let mut cmd = Cli::command();
                clap_complete::generate(*shell, &mut cmd, "kranth", &mut io::stdout());
                Ok(())
            }
            None => repl::run(&cli.api_url, cli.api_key.as_deref()).await,
        }
    });

    match result {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("kranth: {err:#}");
            ExitCode::FAILURE
        }
    }
}

async fn one_shot_run(_cli: &Cli, _idea: &str) -> anyhow::Result<()> {
    // TODO(cli): wire to POST /v1/debates with panel mode, then stream the
    // SSE feed into a minimal text renderer (no ratatui — this is the CI /
    // piped-stdin path).
    anyhow::bail!("`kranth run` not yet implemented — coming in v0.1.0 MVP");
}

/// Mint a short-lived signed SSE token. The `/events` endpoints reject Bearer
/// auth (built for cookieless EventSource), so every stream must POST for a
/// token first. `kind` is the URL segment: "sims" | "recon" | "debates".
async fn mint_stream_token(
    http: &reqwest::Client,
    api_url: &str,
    key: &str,
    kind: &str,
    id: &str,
) -> anyhow::Result<String> {
    #[derive(serde::Deserialize)]
    struct Tok {
        token: String,
    }
    let t: Tok = http
        .post(format!("{api_url}/v1/{kind}/{id}/stream-token"))
        .bearer_auth(key)
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;
    Ok(t.token)
}

/// Stream a sim/recon run's SSE feed until it terminates. Calls `on_progress`
/// for every non-terminal frame; returns Ok on `<kind>.complete`, Err on
/// `<kind>.failed`. Shared by `kranth sim` and `kranth recon`.
async fn stream_run_until_terminal(
    api_url: &str,
    key: &str,
    path_kind: &str,     // "sims" | "recon"
    complete_kind: &str, // "sim.complete" | "recon.complete"
    failed_kind: &str,   // "sim.failed"   | "recon.failed"
    id: &str,
    mut on_progress: impl FnMut(&serde_json::Value),
) -> anyhow::Result<()> {
    use futures::StreamExt;

    let http = reqwest::Client::builder()
        .user_agent(concat!("kranth-cli/", env!("CARGO_PKG_VERSION")))
        .connect_timeout(std::time::Duration::from_secs(10))
        .build()?;
    let token = mint_stream_token(&http, api_url, key, path_kind, id).await?;
    let resp = http
        .get(format!("{api_url}/v1/{path_kind}/{id}/events"))
        .query(&[("token", token.as_str())])
        .send()
        .await?;
    if !resp.status().is_success() {
        anyhow::bail!("SSE stream rejected ({})", resp.status());
    }
    let mut events = eventsource_stream::EventStream::new(
        resp.bytes_stream()
            .map(|r| r.map_err(eventsource_stream::EventStreamError::Transport)),
    );
    while let Some(evt) = events.next().await {
        let Ok(evt) = evt else { break };
        let Ok(val) = serde_json::from_str::<serde_json::Value>(&evt.data) else {
            continue;
        };
        let kind = val.get("kind").and_then(|v| v.as_str()).unwrap_or("");
        if kind == complete_kind {
            return Ok(());
        }
        if kind == failed_kind {
            let msg = val
                .get("data")
                .and_then(|d| d.get("error"))
                .and_then(|v| v.as_str())
                .unwrap_or("run failed");
            anyhow::bail!("{msg}");
        }
        on_progress(&val);
    }
    Ok(())
}

async fn one_shot_sim(cli: &Cli, idea: &str, personas: u32, model: &str) -> anyhow::Result<()> {
    use anyhow::Context;
    use serde::Deserialize;

    let key = auth::resolve_key(&cli.api_url, cli.api_key.as_deref())?
        .ok_or_else(|| anyhow::anyhow!("Not signed in. Run `kranth login` first."))?;

    let http = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(20))
        .build()?;

    #[derive(serde::Serialize)]
    struct CreateSim<'a> {
        idea_text: &'a str,
        persona_count: u32,
        model_id: &'a str,
    }
    #[derive(Deserialize)]
    struct CreateSimResp {
        sim_id: String,
        #[serde(default)]
        credits_charged: i64,
    }

    eprintln!("kranth: starting sim · {personas} personas · {model}…");
    let created: CreateSimResp = http
        .post(format!("{}/v1/sims", cli.api_url))
        .bearer_auth(&key)
        .json(&CreateSim { idea_text: idea, persona_count: personas, model_id: model })
        .send()
        .await?
        .error_for_status()
        .context("kranth API rejected the sim create")?
        .json()
        .await?;
    eprintln!("kranth: queued {} ({} credits)\n", created.sim_id, created.credits_charged);

    stream_run_until_terminal(
        &cli.api_url, &key, "sims", "sim.complete", "sim.failed", &created.sim_id,
        |val| {
            if let Some(done) = val.get("data").and_then(|d| d.get("personas_done")).and_then(|v| v.as_i64()) {
                eprint!("\rkranth: {done}/{personas} personas…");
                use std::io::Write;
                let _ = std::io::stderr().flush();
            }
        },
    )
    .await?;
    eprintln!();

    // Authoritative verdict from the detail endpoint.
    #[derive(Deserialize)]
    struct SimDetail {
        status: String,
        #[serde(default)]
        avg_sentiment: Option<f64>,
        #[serde(default)]
        report: Option<serde_json::Value>,
    }
    let d: SimDetail = http
        .get(format!("{}/v1/sims/{}", cli.api_url, created.sim_id))
        .bearer_auth(&key)
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    println!("─── verdict ───");
    println!("Status:    {}", d.status);
    if let Some(s) = d.avg_sentiment {
        println!("Sentiment: {s:.2}");
    }
    if let Some(r) = &d.report {
        let g = |k: &str| r.get(k).and_then(|v| v.as_i64());
        if let (Some(h), Some(a), Some(n)) = (g("hostile_count"), g("aligned_count"), g("neutral_count")) {
            println!("Room:      {a} aligned · {n} neutral · {h} hostile");
        }
        if let Some(objs) = r.get("top_objections").and_then(|v| v.as_array()) {
            if !objs.is_empty() {
                println!("Top objections:");
                for o in objs.iter().take(3).filter_map(|v| v.as_str()) {
                    println!("  · {o}");
                }
            }
        }
    }
    println!("\nDashboard: https://kranth.com/app/sims/{}", created.sim_id);
    Ok(())
}

async fn one_shot_recon(cli: &Cli, idea: &str, tier: Option<&str>) -> anyhow::Result<()> {
    use anyhow::Context;
    use serde::Deserialize;

    let key = auth::resolve_key(&cli.api_url, cli.api_key.as_deref())?
        .ok_or_else(|| anyhow::anyhow!("Not signed in. Run `kranth login` first."))?;

    let http = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(20))
        .build()?;

    // Resolve the tier: explicit flag, else the cheapest available (tiers are
    // returned price-ascending).
    let tier_slug = match tier {
        Some(t) => t.to_string(),
        None => {
            #[derive(Deserialize)]
            struct Tier {
                slug: String,
            }
            #[derive(Deserialize)]
            struct Tiers {
                tiers: Vec<Tier>,
            }
            let t: Tiers = http
                .get(format!("{}/v1/recon/tiers", cli.api_url))
                .bearer_auth(&key)
                .send()
                .await?
                .error_for_status()
                .context("fetching recon tiers")?
                .json()
                .await?;
            t.tiers
                .into_iter()
                .next()
                .ok_or_else(|| anyhow::anyhow!("no recon tiers available"))?
                .slug
        }
    };

    #[derive(serde::Serialize)]
    struct CreateRecon<'a> {
        idea_text: &'a str,
        tier: &'a str,
    }
    #[derive(Deserialize)]
    struct CreateReconResp {
        recon_id: String,
        #[serde(default)]
        credits_charged: i64,
    }

    eprintln!("kranth: starting recon · tier {tier_slug}…");
    let created: CreateReconResp = http
        .post(format!("{}/v1/recon", cli.api_url))
        .bearer_auth(&key)
        .json(&CreateRecon { idea_text: idea, tier: &tier_slug })
        .send()
        .await?
        .error_for_status()
        .context("kranth API rejected the recon create")?
        .json()
        .await?;
    eprintln!("kranth: queued {} ({} credits)", created.recon_id, created.credits_charged);
    eprintln!("kranth: agents researching the live web…\n");

    stream_run_until_terminal(
        &cli.api_url, &key, "recon", "recon.complete", "recon.failed", &created.recon_id,
        |_val| {},
    )
    .await?;

    // Authoritative cited verdict from the detail endpoint.
    #[derive(Deserialize)]
    struct Run {
        status: String,
        #[serde(default)]
        score: Option<i64>,
    }
    #[derive(Deserialize)]
    struct Finding {
        agent_role: String,
        claim: String,
        #[serde(default)]
        grounding: i64,
    }
    #[derive(Deserialize)]
    struct ReconDetail {
        run: Run,
        #[serde(default)]
        findings: Vec<Finding>,
        #[serde(default)]
        sources: Vec<serde_json::Value>,
    }
    let d: ReconDetail = http
        .get(format!("{}/v1/recon/{}", cli.api_url, created.recon_id))
        .bearer_auth(&key)
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    println!("─── verdict ───");
    println!("Status: {}", d.run.status);
    if let Some(s) = d.run.score {
        println!("Score:  {s}/100");
    }
    println!("Evidence: {} findings · {} cited sources", d.findings.len(), d.sources.len());
    if !d.findings.is_empty() {
        println!("\nTop findings:");
        for f in d.findings.iter().take(5) {
            let mark = match f.grounding {
                1 => "✓",
                -1 => "✗",
                _ => "·",
            };
            println!("  {mark} [{}] {}", f.agent_role, truncate(&f.claim, 80));
        }
    }
    println!("\nDashboard: https://kranth.com/app/recon/{}", created.recon_id);
    Ok(())
}

async fn one_shot_replay(cli: &Cli, debate_id: &str) -> anyhow::Result<()> {
    use serde::Deserialize;

    let key = auth::resolve_key(&cli.api_url, cli.api_key.as_deref())?
        .ok_or_else(|| anyhow::anyhow!("Not signed in. Run `kranth login` first."))?;

    #[derive(Deserialize)]
    struct DebateGet {
        id: String,
        topic: String,
        mode: String,
        status: String,
        #[serde(default)]
        turn_count: i64,
        #[serde(default)]
        transcript: Vec<Turn>,
        #[serde(default)]
        synthesis: Option<Synthesis>,
    }
    #[derive(Deserialize)]
    struct Turn {
        #[serde(default)]
        speaker_archetype: Option<String>,
        #[serde(default)]
        role: Option<String>,
        #[serde(default)]
        text: Option<String>,
        #[serde(default)]
        turn_seq: i32,
    }
    #[derive(Deserialize)]
    struct Synthesis {
        #[serde(default)]
        summary: Option<String>,
        #[serde(default)]
        strongest_turn: Option<String>,
    }

    let http = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .build()?;
    let d: DebateGet = http
        .get(format!("{}/v1/debates/{}", cli.api_url, debate_id))
        .bearer_auth(&key)
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    println!("Topic:   {}", d.topic);
    println!("Mode:    {}", d.mode);
    println!("Status:  {}", d.status);
    println!("Turns:   {}", d.turn_count);
    println!("ID:      {}", d.id);
    println!();

    if d.transcript.is_empty() {
        eprintln!("(no transcript rows — debate may still be queued / running, or live-attach via `kranth tail {debate_id}`.)");
    }

    for turn in &d.transcript {
        let who = turn
            .speaker_archetype
            .as_deref()
            .or(turn.role.as_deref())
            .unwrap_or("?");
        println!("─── turn {} · {who} ───", turn.turn_seq);
        if let Some(text) = &turn.text {
            println!("{text}");
        }
        println!();
    }

    if let Some(syn) = &d.synthesis {
        println!("─── synthesis ───");
        if let Some(s) = &syn.summary {
            println!("{s}");
        }
        if let Some(s) = &syn.strongest_turn {
            println!("\nStrongest turn: {s}");
        }
    }

    println!("\nShare URL: https://kranth.com/d/{debate_id}");
    Ok(())
}

async fn one_shot_tail(cli: &Cli, debate_id: &str) -> anyhow::Result<()> {
    let key = auth::resolve_key(&cli.api_url, cli.api_key.as_deref())?
        .ok_or_else(|| anyhow::anyhow!("Not signed in. Run `kranth login` first."))?;
    eprintln!("kranth: tailing {debate_id}…\n");
    stream_debate_to_stdout(&cli.api_url, &key, debate_id).await
}

/// Pipe a debate's SSE event stream into plain-text stdout (no ratatui).
/// Used by both `kranth tail` (attach to a running debate) and `kranth pr`
/// (after the create step has produced an id). CI-friendly format: one
/// `[role]` header per turn, text streams inline, terminated by a
/// `— debate complete —` line.
async fn stream_debate_to_stdout(api_url: &str, api_key: &str, debate_id: &str) -> anyhow::Result<()> {
    use futures::StreamExt;

    let http = reqwest::Client::builder()
        .user_agent(concat!("kranth-cli/", env!("CARGO_PKG_VERSION")))
        // SSE stream is long-lived; no read timeout — only fail-fast on connect.
        .connect_timeout(std::time::Duration::from_secs(10))
        .build()?;
    // The /events endpoint is built for cookieless EventSource and rejects
    // Bearer auth — mint a short-lived signed token via the authed POST first.
    let token = mint_stream_token(&http, api_url, api_key, "debates", debate_id).await?;
    let resp = http
        .get(format!("{api_url}/v1/debates/{debate_id}/events"))
        .query(&[("token", token.as_str())])
        .send()
        .await?;
    if !resp.status().is_success() {
        anyhow::bail!("SSE stream rejected ({})", resp.status());
    }
    let mut events = eventsource_stream::EventStream::new(
        resp.bytes_stream()
            .map(|r| r.map_err(eventsource_stream::EventStreamError::Transport)),
    );

    let mut current_speaker: Option<String> = None;
    while let Some(evt) = events.next().await {
        let evt = match evt {
            Ok(e) => e,
            Err(_) => break,
        };
        let val: serde_json::Value = match serde_json::from_str(&evt.data) {
            Ok(v) => v,
            Err(_) => continue,
        };
        let kind = val.get("kind").and_then(|v| v.as_str()).unwrap_or("");
        match kind {
            "text.fragment" => {
                let speaker = val.get("role").and_then(|v| v.as_str()).unwrap_or("");
                if current_speaker.as_deref() != Some(speaker) {
                    println!("\n[{speaker}]");
                    current_speaker = Some(speaker.to_string());
                }
                if let Some(t) = val.get("text").and_then(|v| v.as_str()) {
                    print!("{t}");
                    use std::io::Write;
                    let _ = std::io::stdout().flush();
                }
            }
            "turn.end" => {
                println!();
                current_speaker = None;
            }
            "debate.end" => {
                println!("\n\n— debate complete —");
                println!("https://kranth.com/d/{debate_id}");
                break;
            }
            _ => {}
        }
    }
    Ok(())
}

async fn one_shot_pr(cli: &Cli, url_or_shorthand: &str) -> anyhow::Result<()> {
    use anyhow::Context;
    use serde::Deserialize;

    let key = auth::resolve_key(&cli.api_url, cli.api_key.as_deref())?
        .ok_or_else(|| anyhow::anyhow!("Not signed in. Run `kranth login` first."))?;

    let (owner, repo, number) = parse_pr_ref(url_or_shorthand)
        .ok_or_else(|| anyhow::anyhow!("could not parse {url_or_shorthand:?} — expected `https://github.com/owner/repo/pull/N` or `owner/repo#N`"))?;

    // Anonymous GitHub REST works for public repos (~60 req/h unauth).
    #[derive(Deserialize)]
    struct PrShape {
        title: String,
        #[serde(default)]
        body: Option<String>,
    }

    let http = reqwest::Client::builder()
        .user_agent(concat!("kranth-cli/", env!("CARGO_PKG_VERSION")))
        .timeout(std::time::Duration::from_secs(15))
        .build()?;

    eprintln!("kranth: fetching {owner}/{repo}#{number}…");
    let pr: PrShape = http
        .get(format!("https://api.github.com/repos/{owner}/{repo}/pulls/{number}"))
        .send()
        .await
        .context("contacting api.github.com")?
        .error_for_status()
        .context("github rejected the PR fetch (private repo or rate-limited?)")?
        .json()
        .await?;

    let body_snip = pr.body.unwrap_or_default();
    let topic = format!(
        "GitHub {owner}/{repo}#{number}: {}\n\n{}",
        pr.title,
        truncate(&body_snip, 400)
    );

    eprintln!("kranth: starting debate…\n");

    #[derive(serde::Serialize)]
    struct CreateRequest<'a> {
        topic: &'a str,
        mode: &'a str,
        public: bool,
        training_opt_in: bool,
    }
    #[derive(Deserialize)]
    struct CreateResponse {
        id: String,
    }

    let create: CreateResponse = http
        .post(format!("{}/v1/debates", cli.api_url))
        .bearer_auth(&key)
        .json(&CreateRequest {
            topic: &topic,
            mode: "panel",
            public: false,
            training_opt_in: false,
        })
        .send()
        .await?
        .error_for_status()
        .context("kranth API rejected the debate create")?
        .json()
        .await?;

    stream_debate_to_stdout(&cli.api_url, &key, &create.id).await
}

/// Parse `https://github.com/owner/repo/pull/N`, `https://github.com/owner/repo/pull/N/files`,
/// or shorthand `owner/repo#N`. Returns `(owner, repo, number)`.
fn parse_pr_ref(s: &str) -> Option<(String, String, u64)> {
    if let Some(rest) = s.strip_prefix("https://github.com/").or_else(|| s.strip_prefix("http://github.com/")) {
        let mut parts = rest.split('/');
        let owner = parts.next()?.to_string();
        let repo = parts.next()?.to_string();
        let kind = parts.next()?;
        if kind != "pull" && kind != "pulls" {
            return None;
        }
        let number: u64 = parts.next()?.parse().ok()?;
        return Some((owner, repo, number));
    }
    // Shorthand: owner/repo#N
    let (left, num_str) = s.rsplit_once('#')?;
    let mut left_parts = left.split('/');
    let owner = left_parts.next()?.to_string();
    let repo = left_parts.next()?.to_string();
    let number: u64 = num_str.parse().ok()?;
    Some((owner, repo, number))
}

/// `kranth update` — query GitHub Releases for the latest CLI tag and tell the
/// user how to upgrade if they're behind. Doesn't auto-install (per `curl |sh`
/// security best practice: never have a binary mutate its own bytes silently).
async fn one_shot_update() -> anyhow::Result<()> {
    use serde::Deserialize;
    const CURRENT: &str = env!("CARGO_PKG_VERSION");

    #[derive(Deserialize)]
    struct Release {
        tag_name: String,
    }

    let http = reqwest::Client::builder()
        .user_agent(concat!("kranth-cli/", env!("CARGO_PKG_VERSION")))
        .timeout(std::time::Duration::from_secs(8))
        .build()?;
    let url = "https://api.github.com/repos/Kranth-ai/kranth-cli/releases/latest";
    let resp = http.get(url).send().await?;
    if !resp.status().is_success() {
        eprintln!("kranth update: could not reach GitHub ({}).", resp.status());
        return Ok(());
    }
    let release: Release = resp.json().await?;
    let latest = release.tag_name.trim_start_matches("cli-v");
    if latest == CURRENT {
        println!("kranth {CURRENT} is up to date.");
        return Ok(());
    }

    println!("kranth {CURRENT} → {latest} available.\n");
    println!("Re-install with whichever channel you used:");
    println!("  curl -fsSL https://kranth.com/install.sh | sh");
    println!("  brew upgrade kranth-ai/tap/kranth");
    println!("  npm i -g @kranth/cli@latest");
    println!("  cargo install kranth-cli --force");
    println!("\nFull release notes: https://github.com/Kranth-ai/kranth-cli/releases/latest");
    Ok(())
}

async fn one_shot_ls(cli: &Cli, json: bool) -> anyhow::Result<()> {
    use anyhow::Context;
    use serde::Deserialize;

    let key = auth::resolve_key(&cli.api_url, cli.api_key.as_deref())?
        .ok_or_else(|| anyhow::anyhow!("Not signed in. Run `kranth login` first."))?;

    #[derive(Deserialize)]
    struct ListResponse {
        debates: Vec<DebateSummary>,
    }
    #[derive(Deserialize, serde::Serialize)]
    struct DebateSummary {
        id: String,
        topic: String,
        mode: String,
        status: String,
        #[serde(default)]
        turn_count: i64,
        #[serde(default)]
        duration_secs: i64,
        #[serde(default)]
        created_at: String,
        #[serde(default)]
        public: bool,
    }

    let http = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(10))
        .build()?;
    let resp: ListResponse = http
        .get(format!("{}/v1/debates", cli.api_url))
        .bearer_auth(&key)
        .send()
        .await
        .context("calling /v1/debates")?
        .error_for_status()
        .context("debates list rejected")?
        .json()
        .await?;

    if json {
        println!("{}", serde_json::to_string_pretty(&resp.debates)?);
        return Ok(());
    }

    if resp.debates.is_empty() {
        println!("no debates yet — run `kranth` to start one");
        return Ok(());
    }

    // Plain-text table: id (8) · mode (10) · status (10) · turns (5) · age (6) · topic (truncated)
    println!(
        "{:<8}  {:<10}  {:<10}  {:>5}  {:>6}  {}",
        "ID", "MODE", "STATUS", "TURNS", "AGE", "TOPIC"
    );
    for d in &resp.debates {
        let id_short: String = d.id.chars().take(8).collect();
        let age = age_short(&d.created_at);
        let topic = truncate(&d.topic, 60);
        let pub_mark = if d.public { "·🔗" } else { "" };
        println!(
            "{:<8}  {:<10}  {:<10}  {:>5}  {:>6}  {}{}",
            id_short, d.mode, d.status, d.turn_count, age, topic, pub_mark
        );
    }
    Ok(())
}

/// Rough humanised age — "12s", "4m", "2h", "3d". Falls back to the raw
/// ISO timestamp when chrono parsing fails so the user never sees a blank.
fn age_short(iso: &str) -> String {
    use chrono::{DateTime, Utc};
    let Ok(then) = iso.parse::<DateTime<Utc>>() else {
        return iso.chars().take(6).collect();
    };
    let delta = Utc::now().signed_duration_since(then);
    let secs = delta.num_seconds().max(0);
    match secs {
        n if n < 60 => format!("{n}s"),
        n if n < 3600 => format!("{}m", n / 60),
        n if n < 86_400 => format!("{}h", n / 3600),
        n => format!("{}d", n / 86_400),
    }
}

fn truncate(s: &str, max_chars: usize) -> String {
    if s.chars().count() <= max_chars {
        s.to_string()
    } else {
        let mut out: String = s.chars().take(max_chars.saturating_sub(1)).collect();
        out.push('…');
        out
    }
}
