//! The subcommand handlers. Each returns an exit code; a hard error propagates
//! and `main` prints a redacted message + exits 1. Output is identity-based and
//! never prints a credential byte (the A11 egress guarantee) - the only reader
//! of a `Secret` is inside the adapters/store.

use crate::adapters::{self, Account, AuthTool};
use crate::paths::Paths;
use crate::store::Store;
use anyhow::Result;
use serde_json::Value;
use std::process::Command;

/// Is a CLI on PATH and runnable?
/// The `--tool` flag value for a tool name (claude-code -> claude).
fn pretty_tool_flag(tool: &str) -> &str {
    if tool == "claude-code" {
        "claude"
    } else {
        tool
    }
}

fn command_exists(cmd: &str) -> bool {
    Command::new(cmd)
        .arg("--version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

/// Which tool a `--tool` value targets. A clap `ValueEnum`, so an unknown or
/// miscased value (`--tool cluade`) is rejected with a did-you-mean instead of
/// silently falling through to "both" and switching a tool you meant to leave
/// alone. `None` (no `--tool`) means the default: act on whichever tools apply.
#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ToolSel {
    #[value(alias = "claude-code")]
    Claude,
    Codex,
    Gemini,
    Antigravity,
    /// Every tool (the default when --tool is omitted)
    #[value(alias = "both")]
    All,
}

impl ToolSel {
    fn wants(self, tool: &str) -> bool {
        match self {
            ToolSel::Claude => tool == "claude-code",
            ToolSel::Codex => tool == "codex",
            ToolSel::Gemini => tool == "gemini",
            ToolSel::Antigravity => tool == "antigravity",
            ToolSel::All => true,
        }
    }
}

/// The adapters a command targets. `None` and `Some(Both)` mean all; an explicit
/// single tool narrows it.
fn selected_adapters(sel: Option<ToolSel>) -> Vec<Box<dyn AuthTool>> {
    adapters::all()
        .into_iter()
        .filter(|a| sel.map(|s| s.wants(a.name())).unwrap_or(true))
        .collect()
}

/// Whether the user explicitly asked for one tool (so a missing one is an error,
/// not a silent skip). `--tool both` is treated as the lenient default.
fn is_explicit(sel: Option<ToolSel>) -> bool {
    matches!(
        sel,
        Some(ToolSel::Claude)
            | Some(ToolSel::Codex)
            | Some(ToolSel::Gemini)
            | Some(ToolSel::Antigravity)
    )
}

/// On macOS, Claude Code keeps its OAuth login in the Keychain rather than in
/// `~/.claude/.credentials.json`, so swapdex sees "not logged in" even when a
/// login exists. When the config file proves a login is present, explain that
/// instead of gaslighting the user. (`cfg!` keeps this type-checked on Linux.)
fn macos_keychain_note(paths: &Paths, tool: &str) -> Option<&'static str> {
    if !cfg!(target_os = "macos") || tool != "claude-code" {
        return None;
    }
    if paths.claude_credentials().exists() {
        return None;
    }
    let logged_in_by_config = std::fs::read(paths.claude_config_json())
        .ok()
        .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
        .map(|v| v["oauthAccount"].is_object())
        .unwrap_or(false);
    if logged_in_by_config {
        Some(
            "Claude Code on macOS keeps its login in the Keychain, which swapdex \
             cannot snapshot yet - Codex switching works; Claude-on-macOS is on \
             the roadmap",
        )
    } else {
        None
    }
}

/// The account_id inside a snapshot's blobs (works for stored profiles and
/// backups alike).
fn snapshot_account_id(snap: &crate::adapters::Snapshot, tool: &str) -> Option<String> {
    match tool {
        "codex" => {
            let v: Value = serde_json::from_slice(snap.part("auth")?.expose()).ok()?;
            v["tokens"]["account_id"].as_str().map(|s| s.to_string())
        }
        "claude-code" => {
            let v: Value = serde_json::from_slice(snap.part("oauth_account")?.expose()).ok()?;
            v["accountUuid"].as_str().map(|s| s.to_string())
        }
        "gemini" => {
            let v: Value = serde_json::from_slice(snap.part("oauth")?.expose()).ok()?;
            crate::adapters::gemini_jwt_claim(v["id_token"].as_str(), "sub")
        }
        "antigravity" => {
            let v: Value = serde_json::from_slice(snap.part("token")?.expose()).ok()?;
            let fp = crate::adapters::antigravity_fingerprint(&v);
            (!fp.is_empty()).then_some(fp)
        }
        _ => None,
    }
}

/// The account_id a stored profile's snapshot resolves to, for matching a live
/// identity back to a profile name (A2). Reads the snapshot, not `active.json`.
fn profile_account_id(store: &Store, name: &str, tool: &str) -> Option<String> {
    let snap = store.load(name, tool).ok()??;
    snapshot_account_id(&snap, tool)
}

/// Find the stored profile name whose snapshot matches this live account_id.
pub(crate) fn matched_profile_name(store: &Store, tool: &str, live_id: &str) -> Option<String> {
    if live_id.is_empty() {
        return None;
    }
    store
        .list()
        .into_iter()
        .find(|p| {
            p.tools.iter().any(|t| t == tool)
                && profile_account_id(store, &p.name, tool).as_deref() == Some(live_id)
        })
        .map(|p| p.name)
}

/// Every profile holding this tool+account - refresh targets when the live
/// login (with its freshest, possibly rotated tokens) is switched away.
fn matching_profile_names(store: &Store, tool: &str, live_id: &str) -> Vec<String> {
    if live_id.is_empty() {
        return Vec::new();
    }
    store
        .list()
        .into_iter()
        .filter(|p| {
            p.tools.iter().any(|t| t == tool)
                && profile_account_id(store, &p.name, tool).as_deref() == Some(live_id)
        })
        .map(|p| p.name)
        .collect()
}

/// Reject a profile name that could escape the store (path traversal). Returns
/// the exit code to use if invalid.
fn reject_bad_name(name: &str) -> Option<i32> {
    if crate::store::valid_profile_name(name) {
        None
    } else {
        eprintln!(
            "swapdex: invalid profile name '{name}' (1-64 bytes, not all spaces; \
             no '/', '\\', leading '.', or control chars)"
        );
        Some(2)
    }
}

/// Additionally reject "-" where a profile is CREATED (`use -` toggles, so a
/// new profile must never take that name; a legacy one stays manageable).
fn reject_reserved_name(name: &str) -> Option<i32> {
    if name == "-" {
        eprintln!("swapdex: '-' is reserved (`swapdex use -` toggles to the previous profile)");
        Some(2)
    } else {
        None
    }
}

pub fn add(paths: &Paths, name: Option<&str>, sel: Option<ToolSel>, update: bool) -> Result<i32> {
    crate::atomic::ensure_not_root()?;
    let store = Store::open(paths)?;
    // No name: on a terminal, suggest one from the live account (setup's flow);
    // non-interactively, error with the fix instead of a bare usage error.
    let asked;
    let name: &str = match name {
        Some(n) => n,
        None => {
            use std::io::IsTerminal;
            let tty =
                std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
            if !tty {
                eprintln!(
                    "swapdex: a profile name is required: swapdex add <name> \
                     (or run `swapdex setup` for the guided flow)"
                );
                return Ok(2);
            }
            let who = adapters::all()
                .iter()
                .find_map(|a| a.identity(paths).ok().flatten())
                .map(|id| id.email.unwrap_or(id.display))
                .unwrap_or_else(|| "account".into());
            let suggestion = suggest_name(&who);
            match ask_name(
                &store,
                &format!("name for this account [{suggestion}]: "),
                &suggestion,
            ) {
                Some(n) => {
                    asked = n;
                    &asked
                }
                None => {
                    println!("nothing saved.");
                    return Ok(0);
                }
            }
        }
    };
    if let Some(c) = reject_bad_name(name).or_else(|| reject_reserved_name(name)) {
        return Ok(c);
    }
    // Take the switch lock so `add --update` can't race a `use` into a torn
    // (mismatched credentials + identity) two-file Claude snapshot.
    let _lock = match store.lock() {
        Ok(g) => g,
        Err(_) => {
            eprintln!("swapdex: another swapdex is mid-switch; try again");
            return Ok(4);
        }
    };
    let mut saved = Vec::new();
    let mut skipped = Vec::new();
    let mut capture_failed: Vec<&str> = Vec::new();
    for adapter in selected_adapters(sel) {
        let tool = adapter.name();
        if !adapter.present(paths) {
            if is_explicit(sel) {
                eprintln!("swapdex: not logged in to {tool}");
                if let Some(note) = macos_keychain_note(paths, tool) {
                    eprintln!("swapdex: note - {note}");
                }
                return Ok(3);
            }
            continue;
        }
        if update {
            // Updating must not silently REPOINT the profile to a different
            // account - that changes what the name means. Same-account
            // updates (the documented stale-token refresh) pass through.
            let stored_id = profile_account_id(&store, name, tool).filter(|s| !s.is_empty());
            let live_id = adapter
                .identity(paths)
                .ok()
                .flatten()
                .map(|i| i.account_id)
                .filter(|s| !s.is_empty());
            if let (Some(stored), Some(live)) = (&stored_id, &live_id) {
                if stored != live {
                    use std::io::IsTerminal;
                    let tty = std::io::stdin().is_terminal()
                        || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
                    let msg = format!(
                        "profile '{name}' holds a different account for {tool} \
                         than the one you're logged into"
                    );
                    if !tty {
                        eprintln!("swapdex: {msg}.");
                        eprintln!(
                            "  keep both: swapdex add <new-name> --tool {}  |  really \
                             repoint: swapdex rm {name} && swapdex add {name}",
                            pretty_tool_flag(tool)
                        );
                        return Ok(7);
                    }
                    if !yes_no(
                        &format!("{msg}. Repoint '{name}' to the current login? [y/N]: "),
                        false,
                    ) {
                        println!("skipped {tool}.");
                        continue;
                    }
                }
            }
        }
        if store.load(name, tool)?.is_some() && !update {
            // Explicit --tool on an already-saved tool is an error; in the
            // default case, just skip it and still attach the missing tool(s).
            if is_explicit(sel) {
                eprintln!(
                    "swapdex: profile '{name}' already has a {tool} login; pass --update to replace"
                );
                return Ok(6);
            }
            skipped.push(tool);
            continue;
        }
        let snap = match adapter.capture(paths) {
            Ok(s) => s,
            Err(e) => {
                eprintln!("swapdex: {tool}: could not read the live login ({e:#}) - skipped");
                capture_failed.push(tool);
                continue;
            }
        };
        store.save(name, &snap)?;
        saved.push(tool);
    }
    if saved.is_empty() {
        if !skipped.is_empty() {
            eprintln!(
                "swapdex: profile '{name}' already has {}; pass --update to replace",
                skipped.join(", ")
            );
            return Ok(6);
        }
        eprintln!("swapdex: not logged in to any selected tool");
        return Ok(3);
    }
    let note = if skipped.is_empty() {
        String::new()
    } else {
        format!(
            " ({} already saved; --update to replace)",
            skipped.join(", ")
        )
    };
    println!("saved profile '{name}' ({}){note}", saved.join(", "));
    if !capture_failed.is_empty() {
        eprintln!(
            "swapdex: {} tool(s) could not be read and were NOT saved: {}",
            capture_failed.len(),
            capture_failed.join(", ")
        );
    }
    if name.contains(char::is_whitespace) {
        println!(
            "note: the name has spaces - quote it in later commands (`swapdex use \"{name}\"`)"
        );
    }
    Ok(if capture_failed.is_empty() { 0 } else { 1 })
}

pub fn use_account(paths: &Paths, name: &str, sel: Option<ToolSel>, dry_run: bool) -> Result<i32> {
    use_account_inner(paths, name, sel, dry_run, false, None)
}

/// `open`: after a successful switch, exec the tool (the --open flag; needs an
/// explicit --tool so there is never a guess about WHICH conversation opens).
pub fn use_account_open(
    paths: &Paths,
    name: &str,
    sel: Option<ToolSel>,
    dir: Option<&std::path::Path>,
) -> Result<i32> {
    if !is_explicit(sel) {
        eprintln!("swapdex: --open needs --tool <claude|codex|gemini|antigravity> so it knows what to launch");
        return Ok(2);
    }
    if let Some(d) = dir {
        if !d.is_dir() {
            eprintln!("swapdex: --dir is not a directory: {}", d.display());
            return Ok(2);
        }
    }
    use_account_inner(paths, name, sel, false, true, dir)
}

fn use_account_inner(
    paths: &Paths,
    name: &str,
    sel: Option<ToolSel>,
    dry_run: bool,
    open: bool,
    open_dir: Option<&std::path::Path>,
) -> Result<i32> {
    crate::atomic::ensure_not_root()?;
    let store = Store::open(paths)?;
    // Resolve the NAME first: `-` toggles to the previous/other profile and a
    // unique prefix expands, so the daily switch is two keystrokes.
    let name = match resolve_use_name(&store, paths, name, sel)? {
        Some(n) => n,
        None => return Ok(5),
    };
    let name = name.as_str();
    if let Some(c) = reject_bad_name(name) {
        return Ok(c);
    }
    let _lock = match store.lock() {
        Ok(g) => g,
        Err(_) => {
            eprintln!("swapdex: another swapdex is mid-switch; try again");
            return Ok(4);
        }
    };
    // A typo must be ONE line, not four "left unchanged" notes implying the
    // profile exists but lacks those tools.
    if !store.list().iter().any(|p| p.name == name) {
        eprintln!("swapdex: no profile named '{name}'");
        return Ok(5);
    }
    let mut matched = 0; // profile had a snapshot for this tool
    let mut changed = 0; // an actual switch was written
    let mut failed: Vec<&str> = Vec::new(); // tools whose switch errored

    // Snapshot running processes once (best-effort) so we can warn if a switch
    // pulls the login out from under a live session. Skipped on a dry-run.
    // Skip the scan on a dry-run, and under SWAPDEX_ROOT: an isolated root's
    // credentials are not the ones any running session uses, so the warning
    // would be a false positive there.
    let running = if dry_run || std::env::var_os("SWAPDEX_ROOT").is_some() {
        Vec::new()
    } else {
        crate::proc::running_process_names()
    };
    // One shared timestamp for every tool this invocation switches, so a later
    // bare `restore` can identify exactly this switch's tool set.
    let switch_ts = now_secs();
    for adapter in selected_adapters(sel) {
        let tool = adapter.name();
        // A Keychain-mode Claude install (macOS) cannot be switched yet. In
        // the default both-tools case SKIP it with a note so Codex still
        // switches; the adapter's own refusal stays for an explicit --tool.
        if !is_explicit(sel) && macos_keychain_note(paths, tool).is_some() {
            println!(
                "{tool}: skipped - the login lives in the macOS Keychain \
                 (github.com/youdie006/swapdex/issues/1); other tools continue"
            );
            continue;
        }
        let target = match store.load(name, tool)? {
            Some(s) => s,
            None => {
                if is_explicit(sel) {
                    eprintln!("swapdex: profile '{name}' has no {tool} login");
                    return Ok(5);
                }
                // Not an error in the default case, but if the user IS logged
                // into this tool, say so - a silent partial switch reads as a
                // full one and leaves the old account active unnoticed.
                if adapter.present(paths) {
                    println!("{tool}: profile '{name}' has no {tool} login - left unchanged");
                }
                continue;
            }
        };
        matched += 1;
        // Already-active is a no-op success. Ignore EMPTY ids: two accounts with
        // no account_id must never compare equal, or the switch would be skipped
        // and the WRONG account silently kept active. An UNREADABLE live file is
        // treated as unknown (not an abort): `use <good-profile>` is exactly the
        // command that can replace a corrupt login.
        let live = adapter.identity(paths).ok().flatten();
        let live_id = live
            .as_ref()
            .map(|i| i.account_id.clone())
            .filter(|s| !s.is_empty());
        let target_id = profile_account_id(&store, name, tool).filter(|s| !s.is_empty());
        if live_id.is_some() && live_id == target_id {
            println!("{tool}: '{name}' is already active");
            // Still a sync point: the live login IS this profile's account
            // and its tokens may have rotated since the last save. No backup
            // and no timeline event - nothing is switching.
            if !dry_run {
                if let (Ok(snap), Some(id)) = (adapter.capture(paths), &live_id) {
                    for pname in matching_profile_names(&store, tool, id) {
                        store.save(&pname, &snap)?;
                    }
                }
            }
            continue;
        }
        warn_if_expired(&target, tool);
        if dry_run {
            match profile_detail(&store, name, tool).and_then(|(email, _, _)| email) {
                Some(email) => println!("would switch {tool} -> {name} ({email})"),
                None => println!("would switch {tool} -> {name}"),
            }
            continue;
        }
        // Safe order (A6): back up the CURRENT live login first (atomic + fsync
        // inside write_secret); if the backup fails, `?` aborts BEFORE we touch
        // the live login. An unreadable live file only skips its own backup -
        // there is nothing usable to save.
        if adapter.present(paths) {
            match adapter.capture(paths) {
                Ok(live_snap) => {
                    store.backup(&live_snap)?;
                    // Refresh tokens ROTATE while an account is in use, so a
                    // profile snapshot goes stale the moment you work on that
                    // account. Write the live capture (the freshest known
                    // tokens) back into every profile holding this account -
                    // otherwise switching back later restores a refresh token
                    // the provider may have already revoked.
                    if let Some(id) = &live_id {
                        for pname in matching_profile_names(&store, tool, id) {
                            store.save(&pname, &live_snap)?;
                        }
                        if matched_profile_name(&store, tool, id).is_none() {
                            let who = live
                                .as_ref()
                                .map(identity_line)
                                .unwrap_or_else(|| "current".into());
                            eprintln!(
                                "swapdex: note - the outgoing {tool} login ({who}) is not \
                                 saved as a profile; only the last 2 backups keep it. \
                                 `swapdex restore` undoes this switch; `swapdex add <name>` \
                                 would keep it for good."
                            );
                        }
                    }
                }
                Err(e) => eprintln!(
                    "swapdex: note - the current {tool} login could not be read ({e:#}); \
                     switching without a backup of it"
                ),
            }
        }
        if let Err(e) = adapter.apply(paths, &target) {
            // Do NOT abort the whole multi-tool switch: the other tools can
            // still switch; a summary at the end says what failed.
            eprintln!(
                "swapdex: {tool}: switch failed - {:#}\n  (if the error is about the \
                 SNAPSHOT: log in to that account and re-save with `swapdex add {name} \
                 --tool {} --update`)",
                e,
                pretty_tool_flag(tool)
            );
            failed.push(tool);
            continue;
        }
        store.append_timeline_at(tool, name, "use", switch_ts)?;
        if let Some(id) = adapter.identity(paths).ok().flatten() {
            println!("switched {tool} -> {}", identity_line(&id));
        }
        if crate::proc::tool_running(tool, &running) {
            eprintln!(
                "swapdex: note - a {tool} session looks like it's running. Restart it \
                 to use '{name}'; a live session can overwrite the switched login on \
                 its next token refresh."
            );
        }
        changed += 1;
    }
    if matched == 0 {
        eprintln!("swapdex: no profile named '{name}'");
        return Ok(5);
    }
    // Only when a login was actually written - not for a no-op or a dry-run.
    if changed > 0 {
        println!("(takes effect on your next message)");
    }
    if !failed.is_empty() {
        eprintln!(
            "swapdex: {} tool(s) failed to switch ({}); the tools above did switch - \
             `swapdex restore` undoes this switch entirely",
            failed.len(),
            failed.join(", ")
        );
        return Ok(1);
    }
    if open {
        if let Some(adapter) = selected_adapters(sel).into_iter().next() {
            let tool = adapter.name();
            println!("opening {}...", pretty_tool(tool));
            return Err(exec_tool(tool, open_dir));
        }
    }
    Ok(0)
}

/// `restore` - put back the login that was live before the last switch. `use`
/// backs up the outgoing login before every switch; this is the command that
/// brings a backup back, so a bad switch is a one-command recovery even when
/// the outgoing account was never saved as a profile. It backs up the current
/// login first, so running `restore` twice toggles between the two.
pub fn restore(paths: &Paths, sel: Option<ToolSel>, dry_run: bool) -> Result<i32> {
    crate::atomic::ensure_not_root()?;
    let store = Store::open(paths)?;
    let _lock = match store.lock() {
        Ok(g) => g,
        Err(_) => {
            eprintln!("swapdex: another swapdex is mid-switch; try again");
            return Ok(4);
        }
    };
    // Skip the scan on a dry-run, and under SWAPDEX_ROOT: an isolated root's
    // credentials are not the ones any running session uses, so the warning
    // would be a false positive there.
    let running = if dry_run || std::env::var_os("SWAPDEX_ROOT").is_some() {
        Vec::new()
    } else {
        crate::proc::running_process_names()
    };
    // Bare `restore` means "undo the LAST SWITCH" - scope it to the tool(s)
    // that switch touched, or a codex-only undo would also rewind claude-code
    // to some older, unrelated backup.
    let last_switch = last_switch_tools(paths);
    let restore_ts = now_secs();
    let mut found = 0; // a backup existed for this tool
    let mut changed = 0; // an actual restore was written
    for adapter in selected_adapters(sel) {
        let tool = adapter.name();
        if !is_explicit(sel) {
            if let Some(tools) = &last_switch {
                if !tools.iter().any(|t| t == tool) {
                    continue;
                }
            }
            // Keychain-mode Claude (macOS): skip with a note, keep restoring
            // the other tool (mirror of the `use` skip).
            if macos_keychain_note(paths, tool).is_some() {
                println!(
                    "{tool}: skipped - the login lives in the macOS Keychain \
                     (github.com/youdie006/swapdex/issues/1); other tools continue"
                );
                continue;
            }
        }
        let Some((stamp, target)) = store.load_backup(tool)? else {
            if is_explicit(sel) {
                eprintln!("swapdex: no backup for {tool} (a backup is taken on every `use`)");
                return Ok(5);
            }
            continue;
        };
        found += 1;
        // Restoring the already-live account is a no-op success. An unreadable
        // live file is treated as unknown, not an abort - restore is the
        // disaster-recovery command.
        let live_id = adapter
            .identity(paths)
            .ok()
            .flatten()
            .map(|i| i.account_id)
            .filter(|s| !s.is_empty());
        let backup_id = snapshot_account_id(&target, tool).filter(|s| !s.is_empty());
        if live_id.is_some() && live_id == backup_id {
            println!("{tool}: the newest backup is already the active login");
            continue;
        }
        let age = age_line(stamp);
        if dry_run {
            println!("would restore {tool} from the backup taken {age}");
            continue;
        }
        // Same safe order as `use`: back up the CURRENT login first, so restore
        // is itself reversible (run it again to toggle back).
        if adapter.present(paths) {
            match adapter.capture(paths) {
                Ok(live_snap) => {
                    store.backup(&live_snap)?;
                    // Same rotation invariant as `use`: the outgoing live
                    // login carries this account's freshest tokens.
                    if let Some(id) = &live_id {
                        for pname in matching_profile_names(&store, tool, id) {
                            store.save(&pname, &live_snap)?;
                        }
                    }
                }
                Err(e) => eprintln!(
                    "swapdex: note - the current {tool} login could not be read ({e:#}); \
                     restoring without a backup of it"
                ),
            }
        }
        adapter.apply(paths, &target)?;
        // Attribute the timeline event to the restored account's profile name
        // when one matches, or `sessions` would blame "(backup)" forever after.
        let restored = adapter.identity(paths).ok().flatten();
        let event_name = restored
            .as_ref()
            .and_then(|id| matched_profile_name(&store, tool, &id.account_id))
            .unwrap_or_else(|| "(backup)".into());
        store.append_timeline_at(tool, &event_name, "restore", restore_ts)?;
        match restored {
            Some(id) => println!("restored {tool} -> {} (backup {age})", identity_line(&id)),
            None => println!("restored {tool} from the backup taken {age}"),
        }
        if crate::proc::tool_running(tool, &running) {
            eprintln!(
                "swapdex: note - a {tool} session looks like it's running. Restart it \
                 to pick up the restored login."
            );
        }
        changed += 1;
    }
    if found == 0 {
        eprintln!("swapdex: no backup to restore (a backup is taken on every `use`)");
        return Ok(5);
    }
    if changed > 0 {
        println!("(takes effect on your next message)");
    }
    Ok(0)
}

/// Resolve `use`'s NAME argument. `-` means "the profile I was on before":
/// with exactly two profiles it is simply the other one; otherwise the most
/// recent timeline switch to a profile that is not currently active. A unique
/// prefix expands (`use w` -> work); an ambiguous one refuses and lists the
/// candidates rather than guessing (switching is a write). `Ok(None)` means
/// "already reported, exit 5".
fn resolve_use_name(
    store: &Store,
    paths: &Paths,
    raw: &str,
    sel: Option<ToolSel>,
) -> Result<Option<String>> {
    // An empty name (an unset shell variable) must fall through to the
    // invalid-name rejection: every string starts with "", so prefix matching
    // would otherwise "uniquely" match a single-profile store and switch.
    if raw.is_empty() {
        return Ok(Some(raw.to_string()));
    }
    let profiles: Vec<String> = store.list().into_iter().map(|p| p.name).collect();
    if raw == "-" {
        // Scope "previous" to the selected tool(s): `use - --tool codex` asks
        // about codex history, not claude's.
        let mut act: Vec<String> = active_by_tool(store, paths)
            .into_iter()
            .filter(|(t, _)| sel.map(|s| s.wants(t)).unwrap_or(true))
            .map(|(_, n)| n)
            .collect();
        act.sort();
        act.dedup();
        // The overwhelmingly common case: two profiles, one active.
        if profiles.len() == 2 && act.len() == 1 {
            if let Some(other) = profiles.iter().find(|p| **p != act[0]) {
                eprintln!("swapdex: '-' -> '{other}'");
                return Ok(Some(other.clone()));
            }
        }
        // Otherwise: the most recent switch to a profile that is neither
        // active now nor the destination of the newest switch (when the live
        // identity is unreadable, that newest destination IS the current
        // profile - excluding it keeps '-' from re-picking where you already
        // are).
        if let Some(prev) = last_switch_name_excluding(paths, &act, &profiles, sel) {
            eprintln!("swapdex: '-' -> '{prev}'");
            return Ok(Some(prev));
        }
        if act.len() > 1 {
            eprintln!(
                "swapdex: both profiles are active ({}) - '-' is ambiguous here; \
                 say which: swapdex use <{}>",
                act.join(", "),
                profiles.join("|")
            );
        } else {
            eprintln!(
                "swapdex: can't tell which profile '-' means yet. \
                 Pick one: swapdex use <{}>",
                profiles.join("|")
            );
        }
        return Ok(None);
    }
    if profiles.iter().any(|p| p == raw) {
        return Ok(Some(raw.to_string()));
    }
    let cands: Vec<&String> = profiles.iter().filter(|p| p.starts_with(raw)).collect();
    match cands.len() {
        1 => {
            let n = cands[0].clone();
            eprintln!("swapdex: '{raw}' matched profile '{n}'");
            Ok(Some(n))
        }
        // No prefix match: fall through so the normal "no profile" error runs.
        0 => Ok(Some(raw.to_string())),
        _ => {
            eprintln!(
                "swapdex: '{raw}' is ambiguous: {}",
                cands
                    .iter()
                    .map(|s| s.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            );
            Ok(None)
        }
    }
}

/// The most recent `use`/`restore` timeline entry naming a profile that still
/// exists and is not in `exclude` - i.e. "the profile you were on before".
/// The destination of the NEWEST switch is also excluded (it is where you are
/// now, even when the live identity cannot be read), and `sel` scopes which
/// tools' events count.
fn last_switch_name_excluding(
    paths: &Paths,
    exclude: &[String],
    profiles: &[String],
    sel: Option<ToolSel>,
) -> Option<String> {
    let text = std::fs::read_to_string(paths.store_dir().join("timeline.jsonl")).ok()?;
    let mut events: Vec<(i64, String)> = Vec::new();
    for line in text.lines() {
        let Ok(v) = serde_json::from_str::<Value>(line) else {
            continue;
        };
        if !matches!(v["action"].as_str(), Some("use") | Some("restore")) {
            continue;
        }
        if let Some(tool) = v["tool"].as_str() {
            if !sel.map(|s| s.wants(tool)).unwrap_or(true) {
                continue;
            }
        }
        let (Some(ts), Some(name)) = (v["ts"].as_i64(), v["account"].as_str()) else {
            continue;
        };
        events.push((ts, name.to_string()));
    }
    // Where the newest switch went = where you are now; never "toggle" there.
    let newest = events
        .iter()
        .max_by_key(|(ts, _)| *ts)
        .map(|(_, n)| n.clone());
    let mut best: Option<(i64, String)> = None;
    for (ts, name) in events {
        if exclude.contains(&name)
            || newest.as_deref() == Some(name.as_str())
            || !profiles.contains(&name)
        {
            continue;
        }
        if best.as_ref().map(|(t, _)| ts >= *t).unwrap_or(true) {
            best = Some((ts, name.to_string()));
        }
    }
    best.map(|(_, n)| n)
}

/// The tool(s) the most recent switch (`use` or `restore`) touched, from the
/// timeline. Every tool of one invocation is written with the SAME ts
/// (append_timeline_at), so strict ts equality identifies the invocation.
/// None when no switch is on record - the caller falls back to every tool.
fn last_switch_tools(paths: &Paths) -> Option<Vec<String>> {
    let path = paths.store_dir().join("timeline.jsonl");
    let text = std::fs::read_to_string(path).ok()?;
    let mut events: Vec<(i64, String)> = Vec::new();
    for line in text.lines() {
        let Ok(v) = serde_json::from_str::<Value>(line) else {
            continue;
        };
        if !matches!(v["action"].as_str(), Some("use") | Some("restore")) {
            continue;
        }
        if let (Some(ts), Some(tool)) = (v["ts"].as_i64(), v["tool"].as_str()) {
            events.push((ts, tool.to_string()));
        }
    }
    let max_ts = events.iter().map(|(ts, _)| *ts).max()?;
    let mut tools: Vec<String> = events
        .into_iter()
        .filter(|(ts, _)| *ts == max_ts)
        .map(|(_, tool)| tool)
        .collect();
    tools.sort();
    tools.dedup();
    Some(tools)
}

/// "3m ago" / "2h ago" from a unix-nanos backup stamp.
fn age_line(stamp_nanos: u128) -> String {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let secs = (now.saturating_sub(stamp_nanos) / 1_000_000_000) as u64;
    if secs < 60 {
        format!("{secs}s ago")
    } else if secs < 3600 {
        format!("{}m ago", secs / 60)
    } else if secs < 86400 {
        format!("{}h ago", secs / 3600)
    } else {
        format!("{}d ago", secs / 86400)
    }
}

/// A saved snapshot ages out even before its access token expires, because the
/// refresh token can rotate; flag one that has not been refreshed in a while.
const STALE_DAYS: i64 = 30;

/// Identity extracted from a STORED snapshot (no live read, no secrets):
/// (email, tier, marker). marker is "expired" (Claude access token past expiry)
/// or "stale" (Codex login not refreshed in >STALE_DAYS - its refresh token may
/// have rotated, so re-run `add --update`), else None.
fn profile_detail(
    store: &Store,
    name: &str,
    tool: &str,
) -> Option<(Option<String>, Option<String>, Option<&'static str>)> {
    let snap = store.load(name, tool).ok()??;
    // From here the snapshot EXISTS: a missing part or unparseable blob is an
    // UNREADABLE snapshot (surfaced as a marker), not silently "no data" - a
    // corrupt profile must be visible in `ls` before `use` trips over it.
    let unreadable = (None, None, Some("unreadable"));
    match tool {
        "claude-code" => {
            let (Some(cred_part), Some(oauth_part)) =
                (snap.part("credentials"), snap.part("oauth_account"))
            else {
                return Some(unreadable);
            };
            let (Ok(creds), Ok(oauth)) = (
                serde_json::from_slice::<Value>(cred_part.expose()),
                serde_json::from_slice::<Value>(oauth_part.expose()),
            ) else {
                return Some(unreadable);
            };
            let marker = match creds["claudeAiOauth"]["expiresAt"].as_i64() {
                Some(ms) if ms < now_ms() => Some("expired"),
                _ => None,
            };
            Some((
                oauth["emailAddress"].as_str().map(String::from),
                creds["claudeAiOauth"]["subscriptionType"]
                    .as_str()
                    .map(String::from),
                marker,
            ))
        }
        "codex" => {
            let Some(auth_part) = snap.part("auth") else {
                return Some(unreadable);
            };
            let Ok(auth) = serde_json::from_slice::<Value>(auth_part.expose()) else {
                return Some(unreadable);
            };
            let email = crate::adapters::codex::decode_email_from_id_token(
                auth["tokens"]["id_token"].as_str(),
            );
            let marker = auth["last_refresh"]
                .as_str()
                .and_then(crate::session_link::rfc3339_to_secs)
                .filter(|&secs| now_ms() / 1000 - secs > STALE_DAYS * 86400)
                .map(|_| "stale");
            Some((email, auth["auth_mode"].as_str().map(String::from), marker))
        }
        "gemini" => {
            let oauth: Value = serde_json::from_slice(snap.part("oauth")?.expose()).ok()?;
            let email = snap
                .part("accounts")
                .and_then(|a| serde_json::from_slice::<Value>(a.expose()).ok())
                .and_then(|v| v["active"].as_str().map(String::from))
                .or_else(|| crate::adapters::gemini_jwt_claim(oauth["id_token"].as_str(), "email"));
            // Gemini access tokens live ~1h and the CLI refreshes them
            // silently, so "expired right now" is noise. Meaningful signal:
            // a snapshot whose expiry is ANCIENT was refreshed long ago and
            // its refresh token may be revoked - same idea as codex's stale.
            let marker = oauth["expiry_date"]
                .as_i64()
                .filter(|ms| now_ms() - ms > STALE_DAYS * 86400 * 1000)
                .map(|_| "stale");
            Some((email, None, marker))
        }
        "antigravity" => {
            let v: Value = serde_json::from_slice(snap.part("token")?.expose()).ok()?;
            // A snapshot whose token expiry is ancient was refreshed long ago;
            // its refresh token may be revoked - same idea as codex's stale.
            let marker = v["token"]["expiry"]
                .as_str()
                .and_then(crate::session_link::rfc3339_to_secs)
                .filter(|&secs| now_ms() / 1000 - secs > STALE_DAYS * 86400)
                .map(|_| "stale");
            Some((None, v["auth_method"].as_str().map(String::from), marker))
        }
        _ => None,
    }
}

/// Summarize a profile across ALL its tools (not just the first): a marker if
/// ANY tool is stale/expired, and the first non-empty email/tier. `p.tools` is
/// alphabetical, so inspecting only the first would always be "claude-code" and
/// hide Codex entirely.
fn profile_summary(
    store: &Store,
    name: &str,
    tools: &[String],
) -> (Option<String>, Option<String>, Option<&'static str>) {
    let mut email = None;
    let mut tier = None;
    let mut marker = None;
    // Adapter order (Claude first), NOT the store's alphabetical order:
    // antigravity sorts first alphabetically and its auth_method ("consumer")
    // would mask claude's real plan tier ("max") on multi-tool profiles.
    for a in adapters::all() {
        let t = a.name();
        if !tools.iter().any(|x| x == t) {
            continue;
        }
        if let Some((e, ti, m)) = profile_detail(store, name, t) {
            email = email.or(e);
            tier = tier.or(ti);
            marker = marker.or(m);
        }
    }
    (email, tier, marker)
}

/// Which profile is the LIVE account for each tool (from live identity, A2).
/// A mixed state (claude on profile X, codex on profile Y) is representable.
pub(crate) fn active_by_tool(store: &Store, paths: &Paths) -> Vec<(&'static str, String)> {
    adapters::all()
        .iter()
        .filter_map(|a| {
            a.identity(paths)
                .ok()
                .flatten()
                .and_then(|id| matched_profile_name(store, a.name(), &id.account_id))
                .map(|name| (a.name(), name))
        })
        .collect()
}

/// Pad-or-truncate to `w` DISPLAY columns (CJK chars occupy two; counting
/// chars would shear the table); a longer value ends in one '…'.
fn fit(s: &str, w: usize) -> String {
    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
    let n = UnicodeWidthStr::width(s);
    if n <= w {
        let mut out = String::from(s);
        out.extend(std::iter::repeat_n(' ', w - n));
        return out;
    }
    let mut out = String::new();
    let mut used = 0usize;
    for c in s.chars() {
        let cw = UnicodeWidthChar::width(c).unwrap_or(0);
        if used + cw > w.saturating_sub(1) {
            break;
        }
        out.push(c);
        used += cw;
    }
    out.push('…');
    // Pad if the truncation landed a column short (a wide char didn't fit).
    out.extend(std::iter::repeat_n(' ', w.saturating_sub(used + 1)));
    out
}

/// The account column: email if known, else just the tier (never a stray
/// leading-space " [tier]").
fn identity_column(email: Option<String>, tier: Option<String>) -> String {
    match (email.filter(|e| !e.is_empty()), tier) {
        (Some(e), Some(t)) => format!("{e} [{t}]"),
        (Some(e), None) => e,
        (None, Some(t)) => format!("[{t}]"),
        (None, None) => String::new(),
    }
}

pub fn ls(paths: &Paths, json: bool, names: bool) -> Result<i32> {
    let store = Store::open(paths)?;
    if names {
        // Bare names, one per line (store.list() is sorted) - for scripts and
        // the profile-name tab-completion snippet in the docs.
        for p in store.list() {
            println!("{}", p.name);
        }
        return Ok(0);
    }
    let active = active_by_tool(&store, paths);
    let active_tools_for = |name: &str| -> Vec<&'static str> {
        active
            .iter()
            .filter(|(_, n)| n == name)
            .map(|(t, _)| *t)
            .collect()
    };

    let profiles = store.list();
    if json {
        let rows: Vec<Value> = profiles
            .iter()
            .map(|p| {
                let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
                serde_json::json!({
                    "name": p.name,
                    "tools": p.tools,
                    "active_tools": active_tools_for(&p.name),
                    "email": email,
                    "tier": tier,
                    "warning": marker,
                })
            })
            .collect();
        println!("{}", serde_json::to_string(&rows)?);
        return Ok(0);
    }
    if profiles.is_empty() {
        println!("No accounts saved yet.");
        println!("  guided setup:  swapdex setup");
        println!("  or add one:    swapdex login <name>");
        return Ok(0);
    }
    // Two-pass so columns fit the actual content (with a sane cap).
    struct Row {
        name: String,
        ident: String,
        tools: String,
        warn: Option<&'static str>,
        active: bool,
    }
    let rows: Vec<Row> = profiles
        .iter()
        .map(|p| {
            let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
            let at = active_tools_for(&p.name);
            let tools = p
                .tools
                .iter()
                .map(|t| {
                    if at.contains(&t.as_str()) {
                        format!("{t}*")
                    } else {
                        t.clone()
                    }
                })
                .collect::<Vec<_>>()
                .join(", ");
            Row {
                name: p.name.clone(),
                ident: identity_column(email, tier),
                tools,
                warn: marker,
                active: !at.is_empty(),
            }
        })
        .collect();
    // Widths in CHARS (not bytes - non-ASCII names must not shear the table),
    // and content longer than the cap is truncated with '…' so one long row
    // cannot un-align every other. Full values stay available in `ls --json`.
    let name_w = rows
        .iter()
        .map(|r| unicode_width::UnicodeWidthStr::width(r.name.as_str()))
        .max()
        .unwrap_or(4)
        .clamp(4, 24);
    let ident_w = rows
        .iter()
        .map(|r| unicode_width::UnicodeWidthStr::width(r.ident.as_str()))
        .max()
        .unwrap_or(0)
        .clamp(0, 40);
    let mut saw_refreshable = false;
    let mut saw_unreadable = false;
    for r in &rows {
        let mark = if r.active { "* " } else { "  " };
        let warn = r.warn.map(|m| format!("  ({m})")).unwrap_or_default();
        saw_unreadable |= r.warn == Some("unreadable");
        saw_refreshable |= matches!(r.warn, Some("expired") | Some("stale"));
        println!(
            "{mark}{} {} [{}]{warn}",
            fit(&r.name, name_w),
            fit(&r.ident, ident_w),
            r.tools
        );
    }
    if saw_refreshable {
        println!(
            "  (expired/stale: re-run `swapdex add --update <name>` while logged in to refresh)"
        );
    }
    if saw_unreadable {
        println!(
            "  (unreadable: the saved snapshot is corrupt - log in to that account and \
             re-save it with `swapdex add <name> --update`)"
        );
    }
    if active
        .iter()
        .map(|(_, n)| n)
        .collect::<std::collections::HashSet<_>>()
        .len()
        > 1
    {
        println!("  (* marks the active account per tool)");
    }
    Ok(0)
}

/// One compact line for shell prompts / statuslines: `claude:work codex:personal`.
/// The value per tool is the matched profile name, falling back to the email.
/// None when nothing is logged in (or nothing is readable).
pub fn short_line(paths: &Paths) -> Option<String> {
    let store = Store::open(paths).ok()?;
    let parts: Vec<String> = adapters::all()
        .iter()
        .filter_map(|a| {
            let id = a.identity(paths).ok().flatten()?;
            let tool = match a.name() {
                "claude-code" => "claude",
                t => t,
            };
            let who = matched_profile_name(&store, a.name(), &id.account_id)
                .or(id.email)
                .unwrap_or_else(|| "?".into());
            Some(format!("{tool}:{who}"))
        })
        .collect();
    if parts.is_empty() {
        None
    } else {
        Some(parts.join(" "))
    }
}

pub fn status(paths: &Paths, json: bool, short: bool) -> Result<i32> {
    if short {
        println!("{}", short_line(paths).unwrap_or_default());
        return Ok(0);
    }
    let store = Store::open(paths)?;
    if json {
        let rows: Vec<Value> = adapters::all()
            .iter()
            .map(|adapter| {
                let tool = adapter.name();
                // Stable shape: every key present on every row, null when
                // unknown, so `jq .[].email` never needs guards.
                match adapter.identity(paths) {
                    Err(_) => serde_json::json!({
                        "tool": tool, "logged_in": false, "unreadable": true,
                        "email": null, "tier": null, "profile": null, "expired": null,
                    }),
                    Ok(None) => serde_json::json!({
                        "tool": tool, "logged_in": false, "unreadable": false,
                        "email": null, "tier": null, "profile": null, "expired": null,
                    }),
                    Ok(Some(id)) => serde_json::json!({
                        "tool": tool,
                        "logged_in": true,
                        "unreadable": false,
                        "email": id.email,
                        "tier": id.tier,
                        "profile": matched_profile_name(&store, tool, &id.account_id),
                        "expired": id.expires_at.map(|ms| ms < now_ms()),
                    }),
                }
            })
            .collect();
        println!("{}", serde_json::to_string(&rows)?);
        return Ok(0);
    }
    for adapter in adapters::all() {
        let tool = adapter.name();
        match adapter.identity(paths) {
            Err(_) => println!(
                "{tool}: login file unreadable - `swapdex use <profile>` can replace it \
                 (or log in again in the tool)"
            ),
            Ok(None) => match macos_keychain_note(paths, tool) {
                Some(note) => println!("{tool}: not manageable - {note}"),
                None => println!("{tool}: not logged in"),
            },
            Ok(Some(id)) => {
                let name = matched_profile_name(&store, tool, &id.account_id);
                let saved = match &name {
                    Some(n) => format!("profile '{n}'"),
                    None => "not saved - run `swapdex add <name>`".to_string(),
                };
                let exp = expiry_note(id.expires_at);
                println!("{tool}: {} ({saved}){exp}", identity_line(&id));
            }
        }
    }
    // A1: warn about the world-readable .claude.json (holds account PII).
    if let Ok(meta) = std::fs::metadata(paths.claude_config_json()) {
        use std::os::unix::fs::PermissionsExt;
        if meta.permissions().mode() & 0o077 != 0 {
            println!(
                "note: {} is group/world-readable (holds your account email/org); `chmod 600` it",
                crate::util::redact_path(&paths.claude_config_json().display().to_string())
            );
        }
    }
    // Ecosystem: best-effort session count grouped by account (session_link).
    if let Some(line) = crate::session_link::status_line(paths) {
        println!("{line}");
    }
    Ok(0)
}

/// `ui` - a numbered interactive picker: see every profile (active marked from
/// the live login), type a number, switch. Plain Enter cancels. Deliberately
/// stdin-only (no raw-mode/TUI crate pulls a socket library into the graph),
/// and the switch itself goes through the exact same `use` path - a human
/// picking a number IS the explicit `swapdex use <name>`.
pub fn ui(paths: &Paths) -> Result<i32> {
    use std::io::IsTerminal;
    let real_tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
    let tty = real_tty || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
    if !tty {
        eprintln!("swapdex: `ui` is interactive and needs a terminal (try `swapdex use <name>`)");
        return Ok(2);
    }
    // A real terminal gets the full-screen picker; the pipe-driven path (tests,
    // SWAPDEX_ASSUME_TTY) keeps the plain numbered prompt below.
    if real_tty {
        return ui_tui(paths);
    }
    let store = Store::open(paths)?;
    let profiles = store.list();
    if profiles.is_empty() {
        println!("No accounts saved yet.");
        println!("  guided setup:  swapdex setup");
        return Ok(0);
    }
    let active = active_by_tool(&store, paths);
    let color = crate::util::color_enabled();
    println!();
    for (i, p) in profiles.iter().enumerate() {
        let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
        let at: Vec<&str> = active
            .iter()
            .filter(|(_, n)| n == &p.name)
            .map(|(t, _)| *t)
            .collect();
        let star = if at.is_empty() { "  " } else { "* " };
        let ident = identity_column(email, tier);
        let warn = marker.map(|m| format!("  ({m})")).unwrap_or_default();
        let line = format!(
            "  {}) {star}{} {} [{}]{warn}",
            i + 1,
            fit(&p.name, 16),
            fit(&ident, 32),
            p.tools.join(", ")
        );
        if color && !at.is_empty() {
            println!("\x1b[1m{line}\x1b[0m");
        } else {
            println!("{line}");
        }
    }
    if let Some(line) = crate::session_link::status_line(paths) {
        println!("\n  {line}");
    }
    println!();
    loop {
        let Some(ans) = prompt(
            &format!("switch to [1-{}] (Enter cancels): ", profiles.len()),
            "",
        ) else {
            println!("cancelled - nothing switched.");
            return Ok(0);
        };
        if ans.is_empty() || ans.eq_ignore_ascii_case("q") {
            println!("cancelled - nothing switched.");
            return Ok(0);
        }
        match ans.parse::<usize>() {
            Ok(n) if (1..=profiles.len()).contains(&n) => {
                let name = profiles[n - 1].name.clone();
                println!();
                // Timeline state BEFORE the switch below appends its own
                // event - otherwise the first-ever switch would skip the
                // fallback written exactly for it.
                let first_time = crate::session_link::read_timeline(paths).is_empty();
                let rc = use_account(paths, &name, None, false)?;
                if rc == 0 {
                    ui_session_hints(paths, &name, first_time)?;
                }
                return Ok(rc);
            }
            _ => {
                println!(
                    "  pick a number between 1 and {} (Enter cancels)",
                    profiles.len()
                );
            }
        }
    }
}

/// One session row for the post-switch menu, whatever the source.
pub(crate) enum MenuSession {
    Wiki(crate::session_link::RecentSession),
    Native(crate::native_sessions::NativeSession),
}

impl MenuSession {
    pub(crate) fn describe(&self) -> (String, i64, String, String) {
        match self {
            MenuSession::Wiki(s) => (
                s.id.chars().take(6).collect(),
                s.started,
                s.tool.clone(),
                s.title.clone(),
            ),
            MenuSession::Native(s) => (
                s.id.chars().take(6).collect(),
                s.started,
                s.tool.to_string(),
                s.title.clone(),
            ),
        }
    }
}

/// Recent sessions for the just-switched profile - sessionwiki when present
/// (cross-tool, richer), the tools' own on-disk stores otherwise. Never
/// requires sessionwiki (real-use feedback).
pub(crate) fn recent_menu_sessions(
    paths: &Paths,
    name: &str,
    first_time: bool,
    n: usize,
) -> (Vec<MenuSession>, String) {
    // sessionwiki path (attributed, then honest any-account fallback).
    if let Some(r) = crate::session_link::recent_sessions_for(paths, name, n) {
        if !r.is_empty() {
            return (
                r.into_iter().map(MenuSession::Wiki).collect(),
                format!("recent sessions on '{name}' (sessionwiki):"),
            );
        }
        if first_time {
            if let Some(any) = crate::session_link::recent_sessions_any(n) {
                if !any.is_empty() {
                    return (
                        any.into_iter().map(MenuSession::Wiki).collect(),
                        "recent sessions (any account - attribution starts with your first switch):"
                            .to_string(),
                    );
                }
            }
        }
        return (Vec::new(), String::new());
    }
    // Native path: straight from ~/.claude and ~/.codex.
    let events = crate::session_link::read_timeline(paths);
    let all = crate::native_sessions::recent(paths, n * 4);
    let mine: Vec<crate::native_sessions::NativeSession> = all
        .iter()
        .filter(|s| {
            crate::session_link::attribute(&events, s.tool, s.started).as_deref() == Some(name)
        })
        .map(|s| crate::native_sessions::NativeSession {
            tool: s.tool,
            id: s.id.clone(),
            title: s.title.clone(),
            cwd: s.cwd.clone(),
            started: s.started,
        })
        .take(n)
        .collect();
    if !mine.is_empty() {
        return (
            mine.into_iter().map(MenuSession::Native).collect(),
            format!("recent sessions on '{name}':"),
        );
    }
    if first_time {
        let any: Vec<MenuSession> = all.into_iter().take(n).map(MenuSession::Native).collect();
        if !any.is_empty() {
            return (
                any,
                "recent sessions (any account - attribution starts with your first switch):"
                    .to_string(),
            );
        }
    }
    (Vec::new(), String::new())
}

/// Exec the resume for a picked menu session (never returns on success).
fn exec_menu_resume(s: &MenuSession) -> anyhow::Error {
    match s {
        MenuSession::Wiki(w) => {
            println!("opening session {} via sessionwiki...", w.id);
            exec_sessionwiki_resume(&w.id)
        }
        MenuSession::Native(nat) => {
            println!("resuming {} session {}...", pretty_tool(nat.tool), nat.id);
            crate::native_sessions::exec_resume(nat)
        }
    }
}

/// Post-switch continuity: recent sessions of the picked account + the
/// numbered resume handoff. Shared by the numbered picker and the TUI.
fn ui_session_hints(paths: &Paths, name: &str, first_time: bool) -> Result<()> {
    // `first_time` is captured by the CALLER before the switch writes its own
    // timeline event.
    let (recent, label) = recent_menu_sessions(paths, name, first_time, 3);
    if !recent.is_empty() {
        println!("\n{label}");
        for (i, s) in recent.iter().enumerate() {
            let (id6, started, tool, title) = s.describe();
            let age = age_line((started.max(0) as u128) * 1_000_000_000);
            let line = format!(
                "  {}) {id6}  {:>7}  {}  {}",
                i + 1,
                age,
                fit(&format!("[{tool}]"), 13),
                fit(&title, 44)
            );
            println!("{}", line.trim_end());
        }
        if let Some(ans) = prompt(
            &format!(
                "open: [1-{}] resume that session, c/x/g/a new claude/codex/gemini/agy, Enter skips: ",
                recent.len()
            ),
            "",
        ) {
            if let Ok(k) = ans.parse::<usize>() {
                if (1..=recent.len()).contains(&k) {
                    return Err(exec_menu_resume(&recent[k - 1]));
                }
            }
            if let Some(tool) = launch_letter(&ans) {
                return Err(launch_in_folder(tool));
            }
        }
    } else if let Some(ans) = prompt(
        "open now? c/x/g/a = new claude/codex/gemini/agy (Enter skips): ",
        "",
    ) {
        if let Some(tool) = launch_letter(&ans) {
            return Err(launch_in_folder(tool));
        }
    }
    Ok(())
}

/// Ask for the project folder (conversations are per-directory), then exec.
/// Enter keeps the current directory.
fn launch_in_folder(tool: &str) -> anyhow::Error {
    let dir = prompt("folder to open in [current dir]: ", "")
        .filter(|d| !d.is_empty())
        .map(|d| {
            if d == "~" {
                if let Some(home) = dirs::home_dir() {
                    return home;
                }
            }
            if let Some(rest) = d.strip_prefix("~/") {
                if let Some(home) = dirs::home_dir() {
                    return home.join(rest);
                }
            }
            std::path::PathBuf::from(d)
        });
    if let Some(d) = &dir {
        if !d.is_dir() {
            return anyhow::anyhow!("not a directory: {}", d.display());
        }
    }
    println!("opening {}...", pretty_tool(tool));
    exec_tool(tool, dir.as_deref())
}

/// c/x/g/a -> the tool a post-switch launch letter means.
fn launch_letter(ans: &str) -> Option<&'static str> {
    match ans.to_ascii_lowercase().as_str() {
        "c" => Some("claude-code"),
        "x" => Some("codex"),
        "g" => Some("gemini"),
        "a" => Some("antigravity"),
        _ => None,
    }
}

/// The persistent full-screen ui: one alternate-screen session, everything
/// inside it. Switch/restore run this same binary as a subprocess (output
/// condensed into the status line - no second switching implementation);
/// opening a conversation is the one action that leaves.
fn ui_tui(paths: &Paths) -> Result<i32> {
    struct Ctx<'a> {
        paths: &'a Paths,
        last_sessions: Vec<MenuSession>,
        /// Timeline emptiness CAPTURED BEFORE the last switch wrote its own
        /// events - the only correct "first time" signal (audit).
        pre_switch_first: bool,
    }
    fn run_self(args: &[&str]) -> (bool, String) {
        let exe = match std::env::current_exe() {
            Ok(e) => e,
            Err(e) => return (false, format!("cannot find own binary: {e}")),
        };
        match Command::new(exe)
            .args(args)
            .stdin(std::process::Stdio::null())
            .output()
        {
            Ok(out) => {
                let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
                text.push_str(&String::from_utf8_lossy(&out.stderr));
                let mut msg = text
                    .lines()
                    .filter(|l| !l.trim().is_empty())
                    .collect::<Vec<_>>()
                    .join("  |  ");
                if msg.chars().count() > 160 {
                    msg = msg.chars().take(159).collect::<String>() + "…";
                }
                (out.status.success(), msg)
            }
            Err(e) => (false, format!("failed: {e}")),
        }
    }
    impl crate::tui::TuiCtx for Ctx<'_> {
        fn rows(&mut self) -> Vec<crate::tui::Row> {
            let Ok(store) = Store::open(self.paths) else {
                return Vec::new();
            };
            let active = active_by_tool(&store, self.paths);
            store
                .list()
                .iter()
                .map(|p| {
                    let (email, tier, marker) = profile_summary(&store, &p.name, &p.tools);
                    let at: Vec<&str> = active
                        .iter()
                        .filter(|(_, n)| n == &p.name)
                        .map(|(t, _)| *t)
                        .collect();
                    crate::tui::Row {
                        name: p.name.clone(),
                        ident: identity_column(email, tier),
                        tools: p
                            .tools
                            .iter()
                            .map(|t| {
                                if at.contains(&t.as_str()) {
                                    format!("{t}*")
                                } else {
                                    t.clone()
                                }
                            })
                            .collect::<Vec<_>>()
                            .join(", "),
                        active: !at.is_empty(),
                        warn: marker,
                    }
                })
                .collect()
        }
        fn switch(&mut self, name: &str) -> (bool, String) {
            self.pre_switch_first = crate::session_link::read_timeline(self.paths).is_empty();
            run_self(&["use", name])
        }
        fn restore(&mut self) -> String {
            run_self(&["restore"]).1
        }
        fn delete(&mut self, name: &str) -> String {
            match Store::open(self.paths).and_then(|s| s.remove(name)) {
                Ok(true) => format!("removed profile '{name}' (the live login stays)"),
                Ok(false) => format!("no profile named '{name}'"),
                Err(e) => format!("delete failed: {e}"),
            }
        }
        fn sessions(&mut self, name: &str) -> (String, Vec<crate::tui::SessionEntry>) {
            let first_time = self.pre_switch_first;
            let (sessions, label) = recent_menu_sessions(self.paths, name, first_time, 5);
            let entries = sessions
                .iter()
                .map(|s| {
                    let (id6, started, tool, title) = s.describe();
                    let age = age_line((started.max(0) as u128) * 1_000_000_000);
                    crate::tui::SessionEntry {
                        line: format!(
                            "{id6}  {:>7}  {}  {}",
                            age,
                            fit(&format!("[{tool}]"), 13),
                            fit(&title, 44)
                        )
                        .trim_end()
                        .to_string(),
                    }
                })
                .collect();
            self.last_sessions = sessions;
            let label = if label.is_empty() {
                format!("open a conversation on '{name}'")
            } else {
                label.trim_end_matches(':').to_string()
            };
            (label, entries)
        }
    }

    let mut ctx = Ctx {
        paths,
        last_sessions: Vec::new(),
        pre_switch_first: crate::session_link::read_timeline(paths).is_empty(),
    };
    loop {
        // Empty store: fall back to the guided path rather than an empty box.
        if Store::open(paths)?.list().is_empty() {
            println!("No accounts saved yet.");
            println!("  guided setup:  swapdex setup");
            return Ok(0);
        }
        match crate::tui::run(&mut ctx)? {
            crate::tui::Outcome::Quit => return Ok(0),
            crate::tui::Outcome::OpenSession(i) => {
                let Some(sess) = ctx.last_sessions.get(i) else {
                    return Ok(0);
                };
                return Err(exec_menu_resume(sess));
            }
            crate::tui::Outcome::NewConv { tool, dir } => {
                println!("opening {}...", pretty_tool(tool));
                return Err(exec_tool(tool, dir.as_deref()));
            }
            crate::tui::Outcome::AddAccount(tool) => {
                let sel = match tool {
                    "claude-code" => Some(ToolSel::Claude),
                    "codex" => Some(ToolSel::Codex),
                    "gemini" => Some(ToolSel::Gemini),
                    _ => Some(ToolSel::Antigravity),
                };
                let who = adapters::by_name(tool)
                    .and_then(|a| a.identity(paths).ok().flatten())
                    .and_then(|id| id.email)
                    .unwrap_or_else(|| "account".into());
                let store = Store::open(paths)?;
                let Some(name) = ask_name(
                    &store,
                    &format!("name for the new account [{}]: ", suggest_name(&who)),
                    &suggest_name(&who),
                ) else {
                    continue;
                };
                drop(store);
                let rc = login(paths, &name, sel)?;
                if rc != 0 {
                    return Ok(rc);
                }
                println!("(press Enter to go back to the picker)");
                let _ = prompt("", "");
            }
        }
    }
}

/// Replace this process with the official tool - the "switch and land in a
/// conversation" handoff. Only returns on exec failure.
fn exec_tool(tool: &str, dir: Option<&std::path::Path>) -> anyhow::Error {
    use std::os::unix::process::CommandExt;
    let bin = match tool {
        "claude-code" => "claude",
        "codex" => "codex",
        "gemini" => "gemini",
        "antigravity" => "agy",
        other => return anyhow::anyhow!("unknown tool '{other}'"),
    };
    let mut cmd = Command::new(bin);
    if let Some(d) = dir {
        cmd.current_dir(d);
    }
    let err = cmd.exec();
    anyhow::anyhow!("could not launch `{bin}`: {err}")
}

/// Replace this process with `sessionwiki resume <id>` - a one-shot handoff to
/// the official reopen flow (sessionwiki launches the session's own tool).
/// exec(2) only returns on failure, so this returns the error to propagate.
fn exec_sessionwiki_resume(id: &str) -> anyhow::Error {
    use std::os::unix::process::CommandExt;
    let err = Command::new("sessionwiki")
        .args(["resume", "--no-sync", "--", id])
        .exec();
    anyhow::anyhow!("could not launch `sessionwiki resume {id}`: {err}")
}

/// `doctor` - local-only health check with a remedy per finding. Exit 0 when
/// healthy, 9 when any problem was found (scripts can gate on it). Checks the
/// store, every saved snapshot, both live logins, backups, and the CLIs on
/// PATH - and never touches the network.
pub fn doctor(paths: &Paths) -> Result<i32> {
    use std::os::unix::fs::PermissionsExt;
    // Stat BEFORE Store::open, which self-heals the mode to 0700 - otherwise
    // the permission check below could never observe a problem.
    let pre_mode = std::fs::metadata(paths.store_dir())
        .ok()
        .map(|m| m.permissions().mode() & 0o777);
    let store = Store::open(paths)?;
    let mut problems = 0u32;
    let color = crate::util::color_enabled();
    let mut report = |label: &str, ok: bool, msg: String| {
        let verdict = match (ok, color) {
            (true, true) => "\x1b[32mok\x1b[0m".to_string(),
            (false, true) => "\x1b[31mproblem\x1b[0m".to_string(),
            (true, false) => "ok".to_string(),
            (false, false) => "problem".to_string(),
        };
        println!("{label:<13} {verdict} - {msg}");
        if !ok {
            problems += 1;
        }
    };

    // Store directory. Store::open already tightened it to 0700; report what
    // it FOUND (pre_mode), or "ok" would paper over a store that sat
    // group-readable until this very run.
    let sd = paths.store_dir();
    let profiles = store.list();
    let count = format!(
        "{} profile{}",
        profiles.len(),
        if profiles.len() == 1 { "" } else { "s" }
    );
    match (pre_mode, std::fs::metadata(&sd)) {
        (Some(m), Ok(now)) if m & 0o077 != 0 && now.permissions().mode() & 0o077 == 0 => report(
            "store",
            true,
            format!("was mode {m:03o} - tightened to 0700 just now; {count}"),
        ),
        (_, Ok(now)) if now.permissions().mode() & 0o077 != 0 => report(
            "store",
            false,
            format!(
                "directory is group/world-accessible; run `chmod 700 {}`",
                crate::util::redact_path(&sd.display().to_string())
            ),
        ),
        (_, Ok(_)) => report("store", true, format!("0700, {count}")),
        (_, Err(e)) => report("store", false, format!("cannot stat store dir: {e}")),
    }

    // Live logins per tool.
    for adapter in adapters::all() {
        let tool = adapter.name();
        match adapter.identity(paths) {
            Ok(Some(id)) => {
                let saved = matched_profile_name(&store, tool, &id.account_id)
                    .map(|n| format!("profile '{n}'"))
                    .unwrap_or_else(|| "not saved - `swapdex add <name>` keeps it".into());
                report(
                    tool,
                    true,
                    format!("live login {} ({saved})", identity_line(&id)),
                );
            }
            Ok(None) => match macos_keychain_note(paths, tool) {
                Some(note) => report(tool, true, format!("not manageable - {note}")),
                None => report(tool, true, "not logged in".into()),
            },
            Err(_) => report(
                tool,
                false,
                "live login file unreadable; `swapdex use <profile>` can replace it, \
                 or log in again in the tool"
                    .into(),
            ),
        }
    }

    // Live credential files hold refresh tokens - flag loose modes on ALL of
    // them, not just .claude.json (the store already self-tightens; the live
    // files are each tool's own, so we can only warn).
    for f in [
        paths.claude_credentials(),
        paths.codex_auth(),
        paths.gemini_oauth(),
        paths.antigravity_token(),
    ] {
        if let Ok(meta) = std::fs::metadata(&f) {
            use std::os::unix::fs::PermissionsExt;
            if meta.permissions().mode() & 0o077 != 0 {
                report(
                    "perms",
                    false,
                    format!(
                        "{} is group/world-readable (holds tokens); run `chmod 600` on it",
                        crate::util::redact_path(&f.display().to_string())
                    ),
                );
            }
        }
    }
    // A corrupt live .claude.json breaks every claude switch with an error
    // users misread as a snapshot problem - diagnose it here by name.
    if paths.claude_config_json().exists() {
        if let Ok(bytes) = std::fs::read(paths.claude_config_json()) {
            if serde_json::from_slice::<Value>(&bytes).is_err() {
                report(
                    "claude-config",
                    false,
                    format!(
                        "{} is not valid JSON - claude switches will fail until it is \
                         repaired or removed (removing loses local settings like \
                         project trust)",
                        crate::util::redact_path(&paths.claude_config_json().display().to_string())
                    ),
                );
            }
        }
    }
    // .claude.json permissions (holds account PII).
    if let Ok(meta) = std::fs::metadata(paths.claude_config_json()) {
        if meta.permissions().mode() & 0o077 != 0 {
            report(
                "claude-config",
                false,
                format!(
                    "{} is group/world-readable; run `chmod 600` on it",
                    crate::util::redact_path(&paths.claude_config_json().display().to_string())
                ),
            );
        }
    }

    // Every saved snapshot must parse.
    for p in &profiles {
        for tool in &p.tools {
            match profile_detail(&store, &p.name, tool) {
                Some((_, _, Some("unreadable"))) => report(
                    &format!("profile:{}", p.name),
                    false,
                    format!(
                        "{tool} snapshot unreadable; log in to that account and run \
                         `swapdex add {} --tool {tool} --update`",
                        p.name
                    ),
                ),
                // The precondition matters: `add --update` snapshots whatever
                // is LIVE, so without "log in to that account first" the
                // remedy would overwrite this profile with the wrong account.
                Some((_, _, Some(m))) => report(
                    &format!("profile:{}", p.name),
                    true,
                    format!(
                        "{tool} snapshot {m} - log in to that account and run \
                         `swapdex add {} --tool {tool} --update`",
                        p.name
                    ),
                ),
                _ => {}
            }
        }
    }

    // Backups: newest intact per tool (load_backup already skips torn ones).
    let mut kept = Vec::new();
    for tool in ["claude-code", "codex", "gemini", "antigravity"] {
        if let Ok(Some((stamp, _))) = store.load_backup(tool) {
            kept.push(format!("{tool} (newest {})", age_line(stamp)));
        }
    }
    if kept.is_empty() {
        report(
            "backups",
            true,
            "none yet (one is taken on every `use`; `swapdex restore` brings it back)".into(),
        );
    } else {
        report("backups", true, format!("intact - {}", kept.join(", ")));
    }

    // CLIs on PATH - informational (a codex-only user is not "broken").
    let mut found = Vec::new();
    for cli in ["claude", "codex", "gemini", "agy"] {
        if command_exists(cli) {
            found.push(cli);
        }
    }
    report(
        "tools",
        true,
        if found.is_empty() {
            "none of `claude`, `codex`, `gemini`, `agy` found on PATH".into()
        } else {
            format!("on PATH: {}", found.join(", "))
        },
    );

    if problems > 0 {
        println!(
            "\n{problems} problem{} found - each line above ends with its fix.",
            if problems == 1 { "" } else { "s" }
        );
        return Ok(9);
    }
    println!("\neverything looks healthy.");
    Ok(0)
}

pub fn rm(paths: &Paths, name: &str, yes: bool) -> Result<i32> {
    if let Some(c) = reject_bad_name(name) {
        return Ok(c);
    }
    let store = Store::open(paths)?;
    // Existence first - never ask "delete 'ghost'?" about a profile that
    // does not exist.
    if !store.list().iter().any(|p| p.name == name) {
        eprintln!("swapdex: no profile named '{name}'");
        return Ok(5);
    }
    if !yes {
        // On a terminal, just ask; --yes stays for scripts (and remains the
        // only path when stdin is not a tty, exit 7 as documented).
        use std::io::IsTerminal;
        let tty =
            std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
        if !tty {
            eprintln!(
                "swapdex: `rm {name}` deletes the saved profile. Re-run with --yes to confirm."
            );
            return Ok(7);
        }
        if !yes_no(
            &format!("delete saved profile '{name}'? The live login stays. [y/N]: "),
            false,
        ) {
            println!("kept '{name}'.");
            return Ok(0);
        }
    }
    let _lock = match store.lock() {
        Ok(g) => g,
        Err(_) => {
            eprintln!("swapdex: another swapdex is mid-switch; try again");
            return Ok(4);
        }
    };
    if !store.remove(name)? {
        eprintln!("swapdex: no profile named '{name}'");
        return Ok(5);
    }
    println!("removed profile '{name}' (any live login it matched keeps running, now unsaved)");
    Ok(0)
}

pub fn rename(paths: &Paths, old: &str, new: &str) -> Result<i32> {
    if let Some(c) = reject_bad_name(old) {
        return Ok(c);
    }
    if let Some(c) = reject_bad_name(new).or_else(|| reject_reserved_name(new)) {
        return Ok(c);
    }
    let store = Store::open(paths)?;
    // Take the switch lock like every other store mutation, and make the
    // collision a first-class "already exists" (6) rather than a hard error -
    // a script must be able to tell "pick another name" from "disk broke".
    let _lock = match store.lock() {
        Ok(g) => g,
        Err(_) => {
            eprintln!("swapdex: another swapdex is mid-switch; try again");
            return Ok(4);
        }
    };
    // Source must be a REAL profile (ghost dirs with no known tools are
    // hidden from ls - acting on them here would contradict it)...
    if !store.list().iter().any(|p| p.name == old) {
        eprintln!("swapdex: no profile named '{old}'");
        return Ok(5);
    }
    // ...while the TARGET collision checks the directory itself: colliding
    // with a hidden ghost dir must still be a clean "exists" (6), not a
    // hard error from the rename syscall.
    if store.profile_dir_exists(new) {
        eprintln!("swapdex: a profile named '{new}' already exists");
        return Ok(6);
    }
    if store.rename(old, new)? {
        println!("renamed profile '{old}' -> '{new}'");
        Ok(0)
    } else {
        eprintln!("swapdex: no profile named '{old}'");
        Ok(5)
    }
}

/// Onboarding in one step: run a tool's login flow, then save the result as a
/// named profile. Codex has a driveable CLI login; Claude Code signs in inside
/// the app, so for it swapdex guides the two-step manual path.
pub fn login(paths: &Paths, name: &str, sel: Option<ToolSel>) -> Result<i32> {
    crate::atomic::ensure_not_root()?;
    if let Some(c) = reject_bad_name(name).or_else(|| reject_reserved_name(name)) {
        return Ok(c);
    }
    let tool = match sel {
        Some(ToolSel::Claude) => "claude-code",
        Some(ToolSel::Codex) => "codex",
        Some(ToolSel::Gemini) => "gemini",
        Some(ToolSel::Antigravity) => "antigravity",
        _ => {
            // Never guess which tool the user means (real-use feedback: the
            // old codex-if-installed default kept asking about the wrong
            // tool). On a terminal, ask; otherwise require --tool.
            use std::io::IsTerminal;
            let tty =
                std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
            if !tty {
                eprintln!("swapdex: say which tool: swapdex login {name} --tool <claude|codex|gemini|antigravity>");
                return Ok(2);
            }
            println!("Which tool do you want to log '{name}' into?");
            println!("  1) Claude Code   2) Codex   3) Gemini CLI   4) Antigravity");
            loop {
                match prompt("pick [1-4] (Enter cancels): ", "").as_deref() {
                    Some("1") => break "claude-code",
                    Some("2") => break "codex",
                    Some("3") => break "gemini",
                    Some("4") => break "antigravity",
                    Some("") | None => {
                        println!("cancelled.");
                        return Ok(0);
                    }
                    _ => println!("pick a number between 1 and 4 (Enter cancels)"),
                }
            }
        }
    };

    // One flow for all four tools from here on.
    let bin = match tool {
        "claude-code" => "claude",
        "codex" => "codex",
        "gemini" => "gemini",
        _ => "agy",
    };
    if !command_exists(bin) {
        // stderr + exit 3: `login x && ...` in a script must not proceed as
        // if a login was saved.
        eprintln!("swapdex: `{bin}` isn't on your PATH. Install it, then retry.");
        return Ok(3);
    }
    let adapter = adapters::by_name(tool).expect("known tool");
    let flag = pretty_tool_flag(tool);

    let Some(cur) = adapter.identity(paths).ok().flatten() else {
        // Not logged in at all: run the tool's own sign-in, then capture.
        // (codex has a real `login` subcommand; the others sign in on first
        // run of the app itself.)
        println!(
            "Opening {} to sign in. Complete the login{}",
            pretty_tool(tool),
            if tool == "codex" {
                " in your browser.".to_string()
            } else {
                ", then exit it.".to_string()
            }
        );
        spawn_tool_login(bin, tool)?;
        if adapter.identity(paths).ok().flatten().is_none() {
            eprintln!(
                "swapdex: no {} login was completed - nothing saved.",
                pretty_tool(tool)
            );
            return Ok(8);
        }
        println!();
        // update=true so re-running `login <name>` refreshes an existing profile.
        return add(paths, Some(name), sel_for_tool(tool), true);
    };

    // Already logged in - the user wants to ADD a different account. Do the
    // whole thing: save the current login, sign out locally, run the tool's
    // sign-in, capture the new account. The original login is stashed in the
    // store and restored on any failure, so this can never lose an account.
    use std::io::IsTerminal;
    let tty = std::io::stdin().is_terminal() || std::env::var_os("SWAPDEX_ASSUME_TTY").is_some();
    if !tty {
        // Scripts get guidance - this flow is interactive.
        println!(
            "You're already logged into {} ({}).",
            pretty_tool(tool),
            identity_line(&cur)
        );
        println!("  save the current account:  swapdex add {name} --tool {flag}");
        println!(
            "  add a DIFFERENT account:   swapdex login {name} --tool {flag}  (on a terminal)"
        );
        // Exit 3, not 0: `login x && use x` in a script must not proceed as
        // if a login was saved (nothing was).
        return Ok(3);
    }
    println!("Currently logged in as {}.", identity_line(&cur));
    if !yes_no(
        &format!(
            "Sign in to a DIFFERENT account as '{name}'? swapdex will save the \
             current login, sign you out locally, and open {} for the \
             new sign-in. [Y/n]: ",
            pretty_tool(tool)
        ),
        true,
    ) {
        println!("cancelled - nothing changed.");
        return Ok(0);
    }
    let store = Store::open(paths)?;
    let _lock = match store.lock() {
        Ok(g) => g,
        Err(_) => {
            eprintln!("swapdex: another swapdex is mid-switch; try again");
            return Ok(4);
        }
    };
    // 1) The current login, saved twice over: a store backup (restore can
    //    always bring it back) plus a refresh of every profile holding it -
    //    and, if unmatched, an offer to keep it under a name.
    let stash = adapter.capture(paths)?;
    store.backup(&stash)?;
    for pname in matching_profile_names(&store, tool, &cur.account_id) {
        store.save(&pname, &stash)?;
    }
    if matched_profile_name(&store, tool, &cur.account_id).is_none() {
        // No email on disk (antigravity): suggest a plain name instead of a
        // sanitized display string like "GoogleaccountAntigravity".
        let suggestion = match &cur.email {
            Some(e) => suggest_name(e),
            None => "main".to_string(),
        };
        while let Some(keep) = ask_name(
            &store,
            &format!("name to keep the CURRENT account under [{suggestion}]: "),
            &suggestion,
        ) {
            if keep == name {
                // '{name}' is reserved for the NEW account - accepting it here
                // would let the new sign-in silently overwrite the current one.
                println!("'{name}' is the name for the NEW account - pick another.");
                continue;
            }
            store.save(&keep, &stash)?;
            println!("saved current login as '{keep}'.");
            break;
        }
    }
    // 2) Local sign-out, so the tool's own flow prompts a FRESH sign-in.
    sign_out_locally(paths, tool);
    // 3) Fresh sign-in inside the official app.
    println!(
        "Opening {} - sign in with the OTHER account{}",
        pretty_tool(tool),
        if tool == "codex" {
            " in your browser.".to_string()
        } else {
            ", then exit it.".to_string()
        }
    );
    let spawn = spawn_tool_login(bin, tool);
    // 4) Capture, or restore the stash on any failure.
    let new_id = adapter.identity(paths).ok().flatten();
    match (spawn, new_id) {
        (Ok(status), Some(new)) if !new.account_id.is_empty() => {
            if new.account_id == cur.account_id {
                println!("note: you signed back into the same account.");
            }
            // Same repoint rule as `add --update`: if '{name}' already has a
            // snapshot for this tool, changing what the name means must be
            // explicit. An UNREADABLE snapshot counts as "different" - corrupt
            // and absent must not be conflated, or the guard is bypassable.
            let has_tool_snapshot = store
                .list()
                .iter()
                .any(|p| p.name == name && p.tools.iter().any(|t| t == tool));
            let same_account = profile_account_id(&store, name, tool)
                .filter(|s| !s.is_empty())
                .as_deref()
                == Some(new.account_id.as_str());
            if has_tool_snapshot
                && !same_account
                && !yes_no(
                    &format!(
                        "profile '{name}' already holds a different (or unreadable) \
                         {tool} account. Repoint it to this new login? [y/N]: "
                    ),
                    false,
                )
            {
                // The user completed a REAL sign-in - never discard it
                // silently. Offer a different name; skipping restores the
                // stash and honestly says the new sign-in is gone.
                if let Some(rescue) = ask_name(
                    &store,
                    "save the NEW account under a different name instead (Enter discards it): ",
                    "",
                ) {
                    if rescue != name {
                        let snap = adapter.capture(paths)?;
                        store.save(&rescue, &snap)?;
                        println!(
                            "saved profile '{rescue}' ({}). '{name}' is untouched.",
                            identity_line(&new)
                        );
                        println!("switch back any time:  swapdex use <name>  (or `swapdex ui`)");
                        return Ok(0);
                    }
                }
                adapter.apply(paths, &stash)?;
                println!(
                    "the new sign-in was DISCARDED and your previous login restored - \
                     '{name}' is untouched. Re-run `swapdex login <other-name>` to \
                     redo it under another name."
                );
                return Ok(0);
            }
            let snap = adapter.capture(paths)?;
            store.save(name, &snap)?;
            println!("saved profile '{name}' ({}).", identity_line(&new));
            if tool == "antigravity" {
                // Honesty over silence: the token file stores no email or
                // account id, so the same-account check above can never fire
                // here and ls cannot show WHO this is.
                println!(
                    "note: Antigravity stores no account identity on disk - swapdex \
                     cannot confirm WHICH Google account this is; verify inside agy."
                );
            }
            if !status.success() {
                println!(
                    "note: {} exited with an error after signing in - if anything \
                     looks off, `swapdex restore --tool {flag}` undoes this.",
                    pretty_tool(tool)
                );
            }
            println!("switch back any time:  swapdex use <name>  (or `swapdex ui`)");
            Ok(0)
        }
        _ => {
            adapter.apply(paths, &stash)?;
            eprintln!(
                "swapdex: no new {} login was completed - your previous \
                 login ({}) was restored.",
                pretty_tool(tool),
                identity_line(&cur)
            );
            Ok(8)
        }
    }
}

/// The ToolSel a canonical tool name maps back to.
fn sel_for_tool(tool: &str) -> Option<ToolSel> {
    match tool {
        "claude-code" => Some(ToolSel::Claude),
        "codex" => Some(ToolSel::Codex),
        "gemini" => Some(ToolSel::Gemini),
        "antigravity" => Some(ToolSel::Antigravity),
        _ => None,
    }
}

/// Run the tool's own sign-in command, terminal inherited. codex has a real
/// `login` subcommand; the other three sign in on first run of the app.
fn spawn_tool_login(bin: &str, tool: &str) -> Result<std::process::ExitStatus> {
    // A shell Ctrl+C during the interactive sign-in hits the whole foreground
    // process group. With the default disposition it would kill swapdex before
    // the restore-stash branch runs - leaving the user locally signed out of
    // everything. A no-op HANDLER (not SIG_IGN: handlers reset to default
    // across exec, SIG_IGN would be inherited by the child) makes swapdex
    // ride it out; the child keeps normal Ctrl+C behavior.
    unsafe extern "C" fn ride_out(_: libc::c_int) {}
    #[allow(function_casts_as_integer)]
    let prev_int = unsafe { libc::signal(libc::SIGINT, ride_out as libc::sighandler_t) };
    #[allow(function_casts_as_integer)]
    let prev_quit = unsafe { libc::signal(libc::SIGQUIT, ride_out as libc::sighandler_t) };
    let mut cmd = Command::new(bin);
    if tool == "codex" {
        cmd.arg("login");
    }
    let status = cmd.status();
    unsafe {
        libc::signal(libc::SIGINT, prev_int);
        libc::signal(libc::SIGQUIT, prev_quit);
    }
    status.map_err(|e| anyhow::anyhow!("could not run {bin}: {e}"))
}

/// Remove the live credential files so the tool's next run prompts a fresh
/// sign-in. Claude keeps the rest of .claude.json (projects, settings) - only
/// the oauthAccount block goes.
fn sign_out_locally(paths: &Paths, tool: &str) {
    match tool {
        "claude-code" => {
            std::fs::remove_file(paths.claude_credentials()).ok();
            if let Ok(bytes) = std::fs::read(paths.claude_config_json()) {
                if let Ok(mut cfg) = serde_json::from_slice::<Value>(&bytes) {
                    if let Some(obj) = cfg.as_object_mut() {
                        obj.remove("oauthAccount");
                        if let Ok(out) = serde_json::to_vec(&cfg) {
                            let _ = crate::atomic::write_secret(&paths.claude_config_json(), &out);
                        }
                    }
                }
            }
        }
        "codex" => {
            std::fs::remove_file(paths.codex_auth()).ok();
        }
        "gemini" => {
            std::fs::remove_file(paths.gemini_oauth()).ok();
            std::fs::remove_file(paths.gemini_accounts()).ok();
        }
        _ => {
            std::fs::remove_file(paths.antigravity_token()).ok();
        }
    }
}

/// Ask a question and read a trimmed line; empty input yields `default`.
/// Ask on stdout, read a line. `None` means the input stream ENDED (Ctrl-D or
/// a closed pipe) - callers must stop asking, or the wizard would spin forever
/// re-prompting into a stream that can never answer.
fn prompt(question: &str, default: &str) -> Option<String> {
    use std::io::Write;
    print!("{question}");
    let _ = std::io::stdout().flush();
    let mut line = String::new();
    match std::io::stdin().read_line(&mut line) {
        Ok(0) | Err(_) => return None, // EOF or broken stream
        Ok(_) => {}
    }
    let t = line.trim();
    Some(if t.is_empty() {
        default.to_string()
    } else {
        t.to_string()
    })
}

/// A default profile name from an email/display (its local part, sanitized).
fn suggest_name(who: &str) -> String {
    let base = who.split('@').next().unwrap_or(who);
    let clean: String = base
        .chars()
        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
        .collect();
    if crate::store::valid_profile_name(&clean) {
        clean
    } else {
        "account".to_string()
    }
}

/// A friendly tool label for prompts.
fn pretty_tool(tool: &str) -> &str {
    match tool {
        "claude-code" => "Claude Code",
        "codex" => "Codex",
        "gemini" => "Gemini CLI",
        "antigravity" => "Antigravity",
        other => other,
    }
}

/// A yes/no prompt; empty input takes `default_yes`.
fn yes_no(question: &str, default_yes: bool) -> bool {
    // EOF answers "no": never take an irreversible step on a dead stream.
    let Some(a) = prompt(question, if default_yes { "y" } else { "n" }) else {
        return false;
    };
    matches!(a.to_ascii_lowercase().as_str(), "y" | "yes")
}

/// Ask for a profile name, re-prompting until it is valid (or the user skips).
/// An existing name asks whether to replace it. Returns None on skip.
fn ask_name(store: &Store, question: &str, default: &str) -> Option<String> {
    loop {
        let ans = prompt(question, default)?; // EOF -> skip, never loop
        if ans.eq_ignore_ascii_case("skip") || ans.is_empty() {
            return None;
        }
        if !crate::store::valid_profile_name(&ans) {
            println!("  '{ans}' can't be a name (1-64 bytes, not all spaces; no '/', '\\\\', leading '.', or control chars). Try again.");
            continue;
        }
        if store.list().iter().any(|p| p.name == ans)
            && !yes_no(
                &format!("  '{ans}' already exists - replace it? [y/N]: "),
                false,
            )
        {
            continue;
        }
        return Some(ans);
    }
}

/// Guided first-run onboarding: save the accounts you're logged into, offer to
/// add more, and show how to switch. Interactive (needs a TTY).
pub fn setup(paths: &Paths) -> Result<i32> {
    use std::io::IsTerminal;
    crate::atomic::ensure_not_root()?;
    // SWAPDEX_ASSUME_TTY lets the test suite drive the prompts over a pipe.
    if !std::io::stdin().is_terminal() && std::env::var_os("SWAPDEX_ASSUME_TTY").is_none() {
        eprintln!(
            "swapdex setup is interactive - run it in a terminal, or use `swapdex login <name>`."
        );
        return Ok(1);
    }
    let store = Store::open(paths)?;
    println!(
        "swapdex keeps several Claude Code / Codex / Gemini / Antigravity logins and switches between them."
    );
    println!(
        "Let's save the accounts you use. Press Enter to accept a [default], Ctrl-C to quit.\n"
    );

    // 1) Save the accounts you're currently logged into.
    for adapter in adapters::all() {
        let tool = adapter.name();
        let id = match adapter.identity(paths)? {
            Some(id) => id,
            None => {
                println!("{}: not logged in - skipping.\n", pretty_tool(tool));
                continue;
            }
        };
        if let Some(existing) = matched_profile_name(&store, tool, &id.account_id) {
            println!("{}: already saved as '{existing}'.\n", pretty_tool(tool));
            continue;
        }
        let who = id.email.clone().unwrap_or_else(|| id.display.clone());
        let default = suggest_name(&who);
        println!("{}: you're logged in as {who}.", pretty_tool(tool));
        // Attaching this tool to an existing profile of the same suggested
        // name is the NORMAL multi-tool case (`swapdex add <name>` semantics)
        // - never scare with "replace it?" for it, and never skip it.
        if let Some(p) = store.list().into_iter().find(|p| p.name == default) {
            if !p.tools.iter().any(|t| t == tool) {
                // One unreadable tool must not abort the whole wizard.
                match adapter.capture(paths) {
                    Ok(snap) => {
                        store.save(&default, &snap)?;
                        println!("  attached {} to '{default}'.\n", pretty_tool(tool));
                    }
                    Err(e) => {
                        eprintln!("  could not read this login ({e:#}) - skipped.\n");
                    }
                }
                continue;
            }
        }
        match ask_name(
            &store,
            &format!("  save it as [{default}] (Enter to accept, 'skip' to skip): "),
            &default,
        ) {
            Some(name) => match adapter.capture(paths) {
                Ok(snap) => {
                    store.save(&name, &snap)?;
                    println!("  saved as '{name}'.\n");
                }
                Err(e) => {
                    eprintln!("  could not read this login ({e:#}) - skipped.\n");
                }
            },
            None => println!("  skipped.\n"),
        }
    }

    // 2) Offer to add more accounts - ANY tool, through the same one-flow
    //    login (save current, sign out locally, fresh sign-in, capture).
    println!("You can keep several accounts per tool (e.g. work and personal).");
    loop {
        if !yes_no("  add another account now? [y/N]: ", false) {
            break;
        }
        println!("  which tool?  1) Claude Code   2) Codex   3) Gemini CLI   4) Antigravity");
        let sel = loop {
            match prompt("  pick [1-4] (Enter cancels): ", "").as_deref() {
                Some("1") => break Some(ToolSel::Claude),
                Some("2") => break Some(ToolSel::Codex),
                Some("3") => break Some(ToolSel::Gemini),
                Some("4") => break Some(ToolSel::Antigravity),
                Some("") | None => break None,
                _ => println!("  pick a number between 1 and 4 (Enter cancels)"),
            }
        };
        let Some(sel) = sel else {
            println!("  skipped.\n");
            continue;
        };
        let name = match ask_name(&store, "  name for it (e.g. personal): ", "") {
            Some(n) => n,
            None => {
                println!("  skipped.\n");
                continue;
            }
        };
        let _ = login(paths, &name, Some(sel))?;
        println!();
    }

    // 3) Summary.
    let names: Vec<String> = store.list().into_iter().map(|p| p.name).collect();
    println!();
    if names.is_empty() {
        println!(
            "No accounts saved yet. Log into Claude Code or Codex, then run `swapdex setup` again."
        );
    } else {
        println!("You're set - saved: {}.", names.join(", "));
        println!("  switch:   swapdex use <name>");
        println!("  see all:  swapdex ls");
        if names.len() > 1 {
            println!("Switching takes effect on your next message - no restart needed.");
        }
    }
    Ok(0)
}

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}

fn now_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

pub fn usage(paths: &Paths, json: bool) -> Result<i32> {
    let rows = crate::usage::tool_usage(paths);
    if json {
        let out: Vec<Value> = rows
            .iter()
            .map(|r| {
                let accounts: serde_json::Map<String, Value> = r
                    .accounts
                    .iter()
                    .map(|(name, (t5, t7))| {
                        (
                            name.clone(),
                            serde_json::json!({"last_5h_tokens": t5, "last_7d_tokens": t7}),
                        )
                    })
                    .collect();
                serde_json::json!({
                    "tool": r.tool,
                    "last_5h": {"sessions": r.w5h.sessions, "tokens": r.w5h.tokens},
                    "last_7d": {"sessions": r.w7d.sessions, "tokens": r.w7d.tokens},
                    "accounts": accounts,
                })
            })
            .collect();
        println!("{}", serde_json::to_string(&out)?);
        return Ok(0);
    }
    if rows.iter().all(|r| r.w7d.sessions == 0) {
        println!("No recent session activity found (reads ~/.claude and ~/.codex, locally).");
        return Ok(0);
    }
    println!("Local usage - this machine, approximate (not the billed quota):");
    for r in &rows {
        if r.w7d.sessions == 0 {
            continue;
        }
        println!(
            "  {:<12} 5h: {:>7} tok / {} sess    7d: {:>8} tok / {} sess",
            r.tool,
            crate::usage::human(r.w5h.tokens),
            r.w5h.sessions,
            crate::usage::human(r.w7d.tokens),
            r.w7d.sessions,
        );
        // Per-account rows via the switch timeline; the untagged remainder is
        // whatever predates the first switch.
        for (name, (t5, t7)) in &r.accounts {
            println!(
                "    @{:<11} 5h: {:>7} tok           7d: {:>8} tok",
                name,
                crate::usage::human(*t5),
                crate::usage::human(*t7),
            );
        }
        let attributed7: u64 = r.accounts.values().map(|(_, t7)| *t7).sum();
        let rest = r.w7d.tokens.saturating_sub(attributed7);
        if !r.accounts.is_empty() && rest > 0 {
            println!(
                "    {:<12} 5h:                       7d: {:>8} tok (before your first switch)",
                "(untagged)",
                crate::usage::human(rest),
            );
        }
    }
    // Honesty for the two tools usage CANNOT cover: they keep no token
    // transcripts on disk, so a gemini/antigravity-heavy user must not read
    // the silence as "no usage".
    let uncovered: Vec<&str> = ["gemini", "antigravity"]
        .into_iter()
        .filter(|t| {
            adapters::by_name(t)
                .map(|a| a.present(paths))
                .unwrap_or(false)
        })
        .collect();
    if !uncovered.is_empty() {
        println!(
            "note: {} not shown - those CLIs keep no local token transcripts to read",
            uncovered.join(" and ")
        );
    }
    println!("(summed locally from session transcripts; accounts via the switch timeline)");
    Ok(0)
}

pub fn sessions(paths: &Paths, json: bool) -> Result<i32> {
    if json {
        // Scripting parity with the human view: {"accounts": {...}, "total": N}.
        // available=false distinguishes "no sessionwiki" from "zero sessions".
        let out = match crate::session_link::sessions_by_account(paths) {
            None => serde_json::json!({"available": false, "accounts": {}, "total": 0}),
            Some(counts) => {
                let total: usize = counts.values().sum();
                serde_json::json!({"available": true, "accounts": counts, "total": total})
            }
        };
        println!("{}", serde_json::to_string(&out)?);
        return Ok(0);
    }
    match crate::session_link::sessions_by_account(paths) {
        None => {
            println!(
                "session data unavailable (install sessionwiki to see sessions grouped by account)"
            );
        }
        Some(counts) if counts.is_empty() => {
            // sessionwiki responded but its index is empty - the fresh-install
            // landmine. Say the one command that fixes it.
            println!("no sessions found (sessionwiki index empty - run `sessionwiki sync` once)");
        }
        Some(counts) => {
            for (account, n) in &counts {
                println!("{:<20} {n}", account);
            }
        }
    }
    Ok(0)
}

fn identity_line(id: &Account) -> String {
    let who = id.email.clone().unwrap_or_else(|| id.display.clone());
    match &id.tier {
        Some(t) => format!("{who} [{t}]"),
        None => who,
    }
}

fn expiry_note(expires_at: Option<i64>) -> String {
    // expiresAt is epoch millis. Just flag if already past; no live clock math
    // needed for a coarse warning.
    match expires_at {
        Some(ms) if ms < now_ms() => " - access token expired, may re-prompt".to_string(),
        _ => String::new(),
    }
}

fn warn_if_expired(target: &crate::adapters::Snapshot, tool: &str) {
    if tool != "claude-code" {
        return;
    }
    if let Some(cred) = target.part("credentials") {
        if let Ok(v) = serde_json::from_slice::<Value>(cred.expose()) {
            if let Some(ms) = v["claudeAiOauth"]["expiresAt"].as_i64() {
                if ms < now_ms() {
                    eprintln!("swapdex: note - this saved login's access token expired; the tool may re-prompt for login");
                }
            }
        }
    }
}
