//! Management command logic — the implementations behind the CLI subcommands.
//! Kept in the lib so it is testable and so a future desktop front-end can
//! reuse it without dragging in the binary.

use anyhow::{Context, Result};

use bitrouter_sdk::caller::CallerContext;
use bitrouter_sdk::config::{Config, ConfigRoutingTable};
use bitrouter_sdk::language_model::{RoutingPrefs, RoutingTable};

use crate::auth::{NewApiKey, db as auth_db, generate};
use crate::daemon::RouteHop;
use crate::output::reports::skills::{
    FailedSkill, SkillAddReport, SkillEntry, SkillHit, SkillInitReport, SkillRemoveReport,
    SkillsFindReport, SkillsListReport, SkillsUpdateReport, SkippedSkill, UpdatedSkill,
};

/// The starter `bitrouter.yaml` written by `bitrouter init`. Mirrors
/// the zero-config in-memory default so a user who runs `init` and
/// then deletes nothing gets the same behaviour as running bitrouter
/// with no config at all — only the file is here to be edited.
pub const STARTER_CONFIG: &str = r#"# bitrouter.yaml — generated by `bitrouter init`.
#
# This file mirrors the zero-config in-memory default. Delete any
# section you don't want to override, or add custom providers /
# guardrails / policies / MCP servers as needed.

server:
  # Bind localhost only by default — combined with `skip_auth: true`,
  # exposing `0.0.0.0` would let any host on the network use bitrouter
  # without a credential check. Flip to `0.0.0.0:4356` only when you've
  # also set `skip_auth: false` and minted virtual keys with
  # `bitrouter key sign`.
  listen: "127.0.0.1:4356"
  # skip_auth=true admits credential-less local requests (cc-switch /
  # litellm style). Set to false and create keys with `bitrouter key
  # sign` for a multi-tenant deployment.
  skip_auth: true

database:
  # Relative paths are interpreted against the bitrouter home (the
  # directory containing this file) — the daemon chdirs there on
  # startup, so `./bitrouter.db` always lands alongside the config.
  url: "sqlite://./bitrouter.db"

# Built-in providers auto-enable when their env var is set. An entry
# with `inherit_defaults: true` (the workspace default) plus an empty
# body is enough — `api_base`, `api_protocol`, `api_key`, and the
# routable models all flow from the built-in catalog. Comment a line
# out (or set `active: false`) to disable a provider you don't want.
providers:
  openai: {}        # uses OPENAI_API_KEY
  anthropic: {}     # uses ANTHROPIC_API_KEY
  google: {}        # uses GEMINI_API_KEY
  openrouter: {}    # uses OPENROUTER_API_KEY
  # github-copilot needs `bitrouter providers login github-copilot` first:
  # github-copilot: {}
  # A provider can hold multiple accounts — e.g. two subscriptions to
  # the same upstream. `account_strategy` is `failover` (try the first,
  # drop to the next on a retryable / out-of-credits error — default)
  # or `balance` (spread load evenly across accounts):
  # opencode-go: { account_strategy: failover, accounts: [{ api_key: "${OPENCODE_GO_KEY_A}", label: primary }, { api_key: "${OPENCODE_GO_KEY_B}", label: backup }] }

# Upstream MCP (Model Context Protocol) servers — `bitrouter tools
# list/status` reads this, and `POST /mcp/<name>` proxies JSON-RPC
# against the entry. Spec:
# https://modelcontextprotocol.io/specification/2025-06-18
# mcp_servers:
#   ctx7:
#     name: ctx7
#     transport:
#       type: http
#       url: https://mcp.context7.com/mcp
#       headers:
#         Authorization: "Bearer ${CTX7_TOKEN}"
#   git:
#     name: git
#     transport:
#       type: stdio
#       command: uvx
#       args: ["mcp-server-git"]

# Upstream ACP (Agent Client Protocol) agents — manage with
# `bitrouter agents list` / `check` / `install`. Spec:
# https://agentclientprotocol.com/protocol/schema
# agents:
#   claude:
#     name: claude
#     transport:
#       type: stdio
#       command: npx
#       args: ["-y", "@zed-industries/claude-code-acp@latest"]
#   codex:
#     name: codex
#     transport:
#       type: stdio
#       command: npx
#       args: ["-y", "@zed-industries/codex-acp@latest"]

inherit_defaults: true
"#;

/// `bitrouter init` — write a starter config to `path` (refuses to overwrite).
pub async fn init(path: &std::path::Path) -> Result<()> {
    if tokio::fs::try_exists(path).await.unwrap_or(false) {
        anyhow::bail!("{} already exists — refusing to overwrite", path.display());
    }
    tokio::fs::write(path, STARTER_CONFIG)
        .await
        .with_context(|| format!("writing {}", path.display()))?;
    Ok(())
}

/// The outcome of `bitrouter key generate` — the plaintext secret is shown to
/// the user **once** and never persisted (only its SHA-256 hash is stored).
pub struct GeneratedKey {
    /// The api key id.
    pub id: String,
    /// The plaintext `brvk_…` secret — display once, then discard.
    pub secret: String,
}

/// `bitrouter key sign` — mint a `brvk_` virtual key for `user_id`, persist only
/// its hash, and return the plaintext once. v1 does **not** sign a JWT (004
/// §3.0); the command keeps the `sign` name but creates a DB-backed virtual key.
pub async fn key_sign(
    db_url: &str,
    user_id: &str,
    policy_id: Option<&str>,
) -> Result<GeneratedKey> {
    let db = crate::db::connect(db_url)
        .await
        .with_context(|| format!("connecting to database {db_url}"))?;
    crate::db::run_migrations(&db)
        .await
        .context("running database migrations")?;
    auth_db::upsert_user(&db, user_id)
        .await
        .context("creating user row")?;

    let key = generate();
    let id = format!("brvk_id_{}", &key.hash[..16]);
    auth_db::insert_api_key(
        &db,
        &NewApiKey {
            id: id.clone(),
            key_hash: key.hash.clone(),
            user_id: user_id.to_string(),
            spend_limit_micro_usd: None,
            rpm_limit: None,
            policy_id: policy_id.map(|s| s.to_string()),
        },
    )
    .await
    .context("inserting api key")?;

    Ok(GeneratedKey {
        id,
        secret: key.secret,
    })
}

/// `bitrouter models [--provider <id>]` — list routable models, optionally
/// filtered to those a given provider declares.
pub async fn list_models(
    config: &Config,
    provider_filter: Option<&str>,
) -> Result<Vec<(String, Vec<String>)>> {
    let mut resolved = config.clone();
    bitrouter_providers::apply_builtin_defaults(&mut resolved);
    // Best-effort `/models` discovery for any provider with
    // `auto_discover: true`. Zero-config built-ins set the flag, so
    // this turns `bitrouter models` from "empty" into a useful catalog
    // when the user has the API key set. Failures log and leave the
    // provider with no models — no error bubbles to the user.
    bitrouter_sdk::config::discover_models(&mut resolved).await;
    let table = ConfigRoutingTable::from_config(resolved);
    Ok(table
        .list_models()
        .into_iter()
        .filter(|m| match provider_filter {
            Some(p) => m.providers.iter().any(|x| x == p),
            None => true,
        })
        .map(|m| (m.id, m.providers))
        .collect())
}

/// `bitrouter route <model>` — resolve a model name through the routing table,
/// **standalone** (no running daemon needed). Returns the fallback chain.
pub async fn resolve_route(config: &Config, model: &str) -> Result<Vec<RouteHop>> {
    let mut resolved = config.clone();
    bitrouter_providers::apply_builtin_defaults(&mut resolved);
    let table = ConfigRoutingTable::from_config(resolved);
    let chain = table
        .route_chain(model, &RoutingPrefs::default(), &CallerContext::local())
        .await
        .with_context(|| format!("resolving model '{model}'"))?;
    Ok(chain
        .into_iter()
        .map(|t| RouteHop {
            provider: t.provider_name,
            service_id: t.service_id,
            api_protocol: format!("{:?}", t.api_protocol).to_lowercase(),
        })
        .collect())
}

/// One provider entry for `bitrouter providers list`.
pub struct ProviderListing {
    /// Provider id.
    pub id: String,
    /// Upstream API base.
    pub api_base: String,
    /// Whether the provider is active / routable.
    pub active: bool,
    /// Number of declared models.
    pub model_count: usize,
}

/// `bitrouter providers list` — list every configured provider.
/// Applies built-in catalog defaults first so a zero-config or
/// minimally-declared provider (`openai: {}`) shows its real `api_base`
/// and `active` state instead of empty placeholders.
pub fn list_providers(config: &Config) -> Vec<ProviderListing> {
    let mut resolved = config.clone();
    bitrouter_providers::apply_builtin_defaults(&mut resolved);
    let mut providers: Vec<ProviderListing> = resolved
        .providers
        .iter()
        .map(|(id, p)| ProviderListing {
            id: id.clone(),
            api_base: p.api_base.clone(),
            active: p.active,
            model_count: p.models.len(),
        })
        .collect();
    providers.sort_by(|a, b| a.id.cmp(&b.id));
    providers
}

/// Reject a policy id that would escape its directory or hide as a dotfile.
/// Allowed: ASCII letters / digits / `_` / `-` / `.` (not as the leading char).
fn validate_policy_id(id: &str) -> Result<()> {
    if id.is_empty() {
        anyhow::bail!("policy id must not be empty");
    }
    if id.starts_with('.') {
        anyhow::bail!("policy id must not start with '.' (no dotfiles / `..`)");
    }
    if id.chars().any(|c| {
        c == '/'
            || c == '\\'
            || c == '\0'
            || c.is_whitespace()
            || !(c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
    }) {
        anyhow::bail!(
            "policy id '{id}' contains invalid characters (allowed: letters, digits, `_`, `-`, `.`)"
        );
    }
    Ok(())
}

/// `bitrouter policy create` — write a starter policy file into `policy_dir`.
/// Refuses to overwrite an existing policy of the same id. The `id` must be a
/// plain filename (no path separators, no `..`, no leading dot) so the joined
/// path cannot escape `policy_dir`.
pub async fn create_policy(policy_dir: &std::path::Path, id: &str) -> Result<std::path::PathBuf> {
    validate_policy_id(id)?;
    tokio::fs::create_dir_all(policy_dir)
        .await
        .with_context(|| format!("creating policy dir {}", policy_dir.display()))?;
    let path = policy_dir.join(format!("{id}.yaml"));
    if tokio::fs::try_exists(&path).await.unwrap_or(false) {
        anyhow::bail!("policy '{id}' already exists at {}", path.display());
    }
    let body = format!(
        "# policy '{id}' — edit to taste, then bind it to a key:\n\
         #   bitrouter key sign --user <id> --policy {id}\n\
         id: {id}\n\
         # allowed_models: [gpt-5, claude-sonnet-4-6]\n\
         # denied_models: []\n\
         # max_spend_micro_usd: 1000000\n\
         # max_requests_per_minute: 60\n\
         # allowed_tools: [search]\n"
    );
    tokio::fs::write(&path, body)
        .await
        .with_context(|| format!("writing {}", path.display()))?;
    Ok(path)
}

/// One auth method offered for a provider — derived from the catalog +
/// the PKCE registry. The CLI shows users only the methods the provider
/// actually supports.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AuthMethod {
    /// Use the user's existing Claude Code session: read its OAuth credential
    /// live from `~/.claude` (Keychain / `.credentials.json`), or sign in via
    /// the `claude` CLI when not yet logged in. The preferred path for
    /// `anthropic` — no second token copy, so it can't refresh-rotate Claude
    /// Code out (RFC 6749 §6).
    ClaudeCodeSession,
    /// Browser-based PKCE Authorization Code flow — Anthropic Claude
    /// Pro/Max, OpenAI Codex / ChatGPT.
    PkceSubscription,
    /// Adopt an existing session from the provider's own vendor CLI (Claude
    /// Code, Codex) by reading its on-disk / Keychain OAuth token — no browser.
    ImportFromCli,
    /// RFC 8628 Device Authorization Grant — GitHub Copilot's flow.
    DeviceCode,
    /// User pastes a static API key (sk-…, sk-ant-…).
    ApiKey,
}

impl AuthMethod {
    fn label(self) -> &'static str {
        match self {
            AuthMethod::ClaudeCodeSession => {
                "Use your Claude Code session (read ~/.claude, or sign in via the claude CLI)"
            }
            AuthMethod::PkceSubscription => "Subscription (browser sign-in)",
            AuthMethod::ImportFromCli => "Import an existing session from the vendor CLI",
            AuthMethod::DeviceCode => "Device-code OAuth (show a code in browser)",
            AuthMethod::ApiKey => "API key (paste a static credential)",
        }
    }
}

/// The vendor CLI bitrouter can adopt an existing OAuth session from, for
/// providers that ship one — `Codex` for `openai-codex`. `None` for every
/// other provider.
///
/// `anthropic` is **not** listed: it is now the platform / pay-as-you-go
/// provider (`x-api-key` only). Adopting a Claude Code session is the
/// `claude-code` subscription provider's job, handled by
/// [`AuthMethod::ClaudeCodeSession`] (a *live* session read, not a one-shot
/// token copy), so there is no vendor-CLI import for `anthropic`.
fn import_cli_for(provider_id: &str) -> Option<&'static str> {
    match provider_id {
        bitrouter_providers::codex::PROVIDER_ID => Some("Codex"),
        bitrouter_providers::supergrok::PROVIDER_ID => Some("Grok"),
        _ => None,
    }
}

/// Decide which auth methods are wired for `entry`. Order matters — the
/// first entry is what runs when the user hits enter on the prompt.
fn available_methods(entry: &bitrouter_providers::ProviderEntry) -> Vec<AuthMethod> {
    use bitrouter_providers::AuthScheme;
    let mut methods = Vec::new();
    // The Claude subscription's preferred (and only) path: read the user's live
    // Claude Code session and let the `claude` CLI own login. It belongs to the
    // dedicated `claude-code` subscription provider — `anthropic` is now the
    // platform / pay-as-you-go provider and offers the API-key path only.
    // Listed first so it's the default on <enter> / non-interactive runs.
    let is_claude_code = entry.id == bitrouter_providers::claude_code::PROVIDER_ID;
    if is_claude_code {
        methods.push(AuthMethod::ClaudeCodeSession);
    }
    // Providers with a sibling vendor CLI can adopt its existing session as a
    // one-shot copy — currently only Codex (`openai-codex`). List that before
    // PKCE so the default path reuses an already-authenticated local CLI
    // session when one exists.
    if import_cli_for(&entry.id).is_some() {
        methods.push(AuthMethod::ImportFromCli);
    }
    // `anthropic` is platform / pay-as-you-go: offer the API-key path only. The
    // PKCE registry still carries an `anthropic` entry (the Claude subscription
    // browser flow), but that subscription path now belongs to the dedicated
    // `claude-code` provider, so it must not be offered under `anthropic`.
    let is_anthropic = entry.id == bitrouter_providers::anthropic::PROVIDER_ID;
    let has_pkce = !is_anthropic && bitrouter_providers::oauth::registry::has_pkce_flow(&entry.id);
    if has_pkce {
        methods.push(AuthMethod::PkceSubscription);
    }
    match &entry.auth {
        AuthScheme::Bearer { .. } | AuthScheme::Header { .. } => {
            methods.push(AuthMethod::ApiKey);
        }
        AuthScheme::Oauth { params, .. } => {
            // Device-code is only viable when the TOML carries the
            // device authorization endpoint — PKCE-only providers
            // (`openai-codex`) skip this even though their auth scheme
            // is `oauth`.
            let has_device = params
                .get("device_authorization_endpoint")
                .and_then(|v| v.as_str())
                .is_some();
            if has_device {
                methods.push(AuthMethod::DeviceCode);
            }
            // OAuth-only providers don't accept pasted API keys (the
            // Codex backend literally rejects sk-… credentials), so
            // there's no API-key fallback to offer.
        }
        AuthScheme::Native { .. } => {}
    }
    methods
}

/// Prompt for an integer choice between `[1, options.len()]`. Empty
/// input (or just <enter>) picks option 1. Invalid input (non-number,
/// out-of-range) prints a one-line error and re-prompts; a true EOF on
/// stdin (read_line returns 0 bytes) bails so the caller doesn't loop
/// forever in non-interactive contexts.
fn prompt_method_choice(provider: &str, options: &[AuthMethod]) -> Result<AuthMethod> {
    use std::io::BufRead;
    eprintln!();
    eprintln!("How would you like to authenticate to {provider}?");
    for (i, m) in options.iter().enumerate() {
        eprintln!("  {}) {}", i + 1, m.label());
    }
    let stdin = std::io::stdin();
    let mut handle = stdin.lock();
    loop {
        eprint!("Choose [1]: ");
        let mut line = String::new();
        let n_bytes = handle
            .read_line(&mut line)
            .context("reading method choice from stdin")?;
        if n_bytes == 0 {
            anyhow::bail!("stdin closed before a choice was made");
        }
        let trimmed = line.trim();
        if trimmed.is_empty() {
            return Ok(options[0]);
        }
        match trimmed.parse::<usize>() {
            Ok(n) if (1..=options.len()).contains(&n) => return Ok(options[n - 1]),
            Ok(n) => eprintln!("  choice must be between 1 and {}, got {n}", options.len()),
            Err(_) => eprintln!("  '{trimmed}' is not a number"),
        }
    }
}

/// `bitrouter providers login <provider> [--label <name>]` — interactive
/// credential setup.
///
/// Resolves which auth methods are wired for `provider_id` (subscription
/// PKCE / device-code / API key paste), prompts the user to pick when
/// there's more than one, runs the chosen flow, and persists the
/// resulting [`bitrouter_providers::oauth::credential_store::Credential`]
/// under `(provider_id, label)`.
/// What `login_provider` accomplished — surfaced as the CLI's JSON result while
/// the interactive prompts and confirmation go to stderr.
pub struct LoginOutcome {
    pub provider: String,
    pub label: String,
    pub method: String,
    pub path: String,
}

/// Non-interactive provider-login controls.
#[derive(Debug, Clone, Copy, Default)]
pub struct ProviderLoginOptions {
    /// Require importing an already-authenticated vendor CLI session.
    pub import_existing: bool,
    /// Refuse browser-based PKCE flows.
    pub no_browser: bool,
}

pub async fn login_provider(provider_id: &str, label: &str) -> Result<LoginOutcome> {
    login_provider_with_options(provider_id, label, ProviderLoginOptions::default()).await
}

pub async fn login_provider_with_options(
    provider_id: &str,
    label: &str,
    options: ProviderLoginOptions,
) -> Result<LoginOutcome> {
    use bitrouter_providers::builtin;

    // The `bitrouter` cloud gateway is compiled in; every other provider's
    // auth shape (handler + public OAuth params) comes from the fetched-or-
    // cached registry — the same mapper the built-ins once used.
    let entry: bitrouter_providers::ProviderEntry = match builtin::find(provider_id) {
        Some(e) => e.clone(),
        None => {
            let data = bitrouter_providers::registry::apply::load_or_cached(
                &bitrouter_sdk::config::RegistryConfig::default(),
            )
            .await
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "could not load the provider registry (offline, and nothing cached) \
                     to resolve '{provider_id}'"
                )
            })?;
            let provider = data
                .providers
                .iter()
                .find(|p| p.name == provider_id)
                .ok_or_else(|| {
                    anyhow::anyhow!(
                        "unknown provider '{provider_id}' — try `bitrouter providers list` \
                         for the catalog"
                    )
                })?;
            builtin::entry_from_registry(provider)
                .with_context(|| format!("resolving the auth shape for '{provider_id}'"))?
        }
    };
    let methods = available_methods(&entry);
    if methods.is_empty() {
        anyhow::bail!(
            "provider '{provider_id}' has no interactive login path \
             (auth scheme: {:?})",
            std::mem::discriminant(&entry.auth)
        );
    }
    let chosen = choose_auth_method(&entry, &methods, options)?;
    eprintln!();
    eprintln!(
        "  Logging in to {} via {}",
        entry.display_name,
        chosen.label()
    );

    let credential = match chosen {
        AuthMethod::ClaudeCodeSession => run_claude_code_session().await?,
        AuthMethod::PkceSubscription => run_pkce_subscription(provider_id).await?,
        AuthMethod::ImportFromCli => run_cli_import(provider_id)?,
        AuthMethod::DeviceCode => run_device_code(provider_id, &entry).await?,
        AuthMethod::ApiKey => run_api_key_paste(&entry.display_name)?,
    };

    let mut store = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()
        .context("opening credential store")?;
    let kind = credential.kind_label();
    store
        .set(provider_id, label, credential)
        .with_context(|| format!("persisting credential for {provider_id} ({label})"))?;
    eprintln!(
        "  ✓ Saved {kind} credential for {provider_id} (label: {label}) at {}",
        store.path().display()
    );
    eprintln!();
    Ok(LoginOutcome {
        provider: provider_id.to_string(),
        label: label.to_string(),
        method: kind.to_string(),
        path: store.path().display().to_string(),
    })
}

fn choose_auth_method(
    entry: &bitrouter_providers::ProviderEntry,
    methods: &[AuthMethod],
    options: ProviderLoginOptions,
) -> Result<AuthMethod> {
    if options.import_existing {
        if methods.contains(&AuthMethod::ImportFromCli) {
            return Ok(AuthMethod::ImportFromCli);
        }
        anyhow::bail!(
            "provider '{}' cannot import an existing vendor CLI session",
            entry.id
        );
    }

    let selectable: Vec<AuthMethod> = if options.no_browser {
        methods
            .iter()
            .copied()
            .filter(|m| *m != AuthMethod::PkceSubscription)
            .collect()
    } else {
        methods.to_vec()
    };
    if selectable.is_empty() {
        anyhow::bail!(
            "provider '{}' has no non-browser login path; rerun without --no-browser",
            entry.id
        );
    }
    if selectable.len() == 1 || options.no_browser {
        Ok(selectable[0])
    } else {
        prompt_method_choice(&entry.display_name, &selectable)
    }
}

/// Adopt the user's existing Claude Code session as the live credential source
/// for the `claude-code` subscription provider.
///
/// Resolution order, mirroring how Claude Code users actually authenticate:
/// 1. If a Claude Code session already exists in `~/.claude` (macOS Keychain /
///    `.credentials.json`), use it as-is — the daemon reads it live and
///    refreshes in place.
/// 2. Otherwise sign the user in with the `claude` CLI itself (`claude auth
///    login --claudeai`), installing it first (the same native installer
///    `bitrouter spawn` uses) when it's missing, then re-read.
///
/// Returns a [`Credential::ClaudeCodeCli`] marker — no token is copied into
/// bitrouter's own store, so bitrouter and Claude Code share one credential and
/// can't refresh-rotate each other out (RFC 6749 §6). `login_provider` stores
/// this marker under the `provider_id` it was invoked with, so
/// `bitrouter providers login claude-code` lands it under `claude-code`.
async fn run_claude_code_session()
-> Result<bitrouter_providers::oauth::credential_store::Credential> {
    use std::io::IsTerminal;

    use bitrouter_providers::import::claude_code::ClaudeCodeStore;
    use bitrouter_providers::oauth::credential_store::Credential;

    // Adopt a legacy #590 marker stored under `anthropic` before checking the
    // live session, so a returning user's existing sign-in is recognised under
    // the new `claude-code` provider.
    crate::claude_code::migrate_legacy_anthropic_marker_default();

    let store = ClaudeCodeStore::system().ok_or_else(|| {
        anyhow::anyhow!(
            "could not resolve your home directory (set HOME) to find the Claude Code session"
        )
    })?;

    // 1. Already signed in to Claude Code?
    if let Some(live) = store
        .read()
        .context("reading your existing Claude Code session")?
    {
        eprintln!(
            "  Found your Claude Code session ({}) — bitrouter will read it live.",
            live.source
        );
        return Ok(Credential::ClaudeCodeCli);
    }

    // 2. Not signed in — drive the `claude` CLI's own login.
    if !std::io::stdin().is_terminal() {
        anyhow::bail!(
            "not signed in to Claude Code. Run `claude auth login` (install the CLI first with \
             `curl -fsSL https://claude.ai/install.sh | bash`), then re-run \
             `bitrouter providers login claude-code`."
        );
    }

    let claude = crate::spawn::ensure_agent_installed(crate::spawn::SpawnAgent::Claude, false)
        .await
        .context("locating the claude CLI to sign you in")?;

    eprintln!("  You're not signed in to Claude Code yet — launching `claude auth login`.");
    let status = tokio::process::Command::new(&claude)
        .arg("auth")
        .arg("login")
        .arg("--claudeai")
        .status()
        .await
        .context("running `claude auth login`")?;
    if !status.success() {
        anyhow::bail!(
            "`claude auth login` didn't complete — sign in, then re-run \
             `bitrouter providers login claude-code`."
        );
    }

    match store
        .read()
        .context("re-reading your Claude Code session after sign-in")?
    {
        Some(live) => {
            eprintln!(
                "  Signed in — bitrouter will read your session live from {}.",
                live.source
            );
            Ok(Credential::ClaudeCodeCli)
        }
        None => anyhow::bail!(
            "still no Claude Code session after `claude auth login` — run `claude auth status` \
             to check, then retry."
        ),
    }
}

/// Run the browser-based PKCE Authorization Code flow against the
/// PKCE-registered provider. Errors when the provider isn't in
/// `bitrouter_providers::oauth::registry`.
async fn run_pkce_subscription(
    provider_id: &str,
) -> Result<bitrouter_providers::oauth::credential_store::Credential> {
    let registry = bitrouter_providers::oauth::registry::find(provider_id).ok_or_else(|| {
        anyhow::anyhow!(
            "provider '{provider_id}' has no PKCE login registration — \
             this should have been caught by available_methods()"
        )
    })?;
    let client = reqwest::Client::builder()
        .user_agent(concat!("bitrouter/", env!("CARGO_PKG_VERSION")))
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .context("building HTTP client for PKCE login")?;
    let ux = StderrLoginUx;
    let outcome = bitrouter_providers::oauth::login::run_login(
        &client,
        &registry,
        &ux,
        bitrouter_providers::oauth::auth_code::MANUAL_PASTE_TIMEOUT,
    )
    .await
    .with_context(|| format!("PKCE login for {provider_id}"))?;
    if outcome.manual_fallback_used {
        eprintln!("  (used manual paste fallback)");
    }
    Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(outcome.token))
}

/// Run the RFC 8628 Device Authorization Grant — the existing
/// `github-copilot` flow, lifted out of the old `login_provider`.
async fn run_device_code(
    provider_id: &str,
    entry: &bitrouter_providers::ProviderEntry,
) -> Result<bitrouter_providers::oauth::credential_store::Credential> {
    use bitrouter_providers::AuthScheme;
    let params = match &entry.auth {
        AuthScheme::Oauth { params, .. } => params,
        _ => anyhow::bail!("provider '{provider_id}' is not OAuth — refusing device flow"),
    };
    let client_id = params
        .get("client_id")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("'{provider_id}': auth.params.client_id is missing"))?
        .to_string();
    let scope = params
        .get("scope")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    let device_url = params
        .get("device_authorization_endpoint")
        .and_then(|v| v.as_str())
        .ok_or_else(|| {
            anyhow::anyhow!("'{provider_id}': auth.params.device_authorization_endpoint is missing")
        })?
        .to_string();
    let token_url = params
        .get("token_endpoint")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("'{provider_id}': auth.params.token_endpoint is missing"))?
        .to_string();

    let flow = bitrouter_providers::oauth::DeviceCodeFlow::new(
        bitrouter_providers::oauth::DeviceCodeParams {
            client_id,
            scope,
            device_authorization_endpoint: device_url,
            token_endpoint: token_url,
        },
    )
    .with_context(|| format!("building OAuth flow for {provider_id}"))?;

    let device_code = flow
        .request_device_code()
        .await
        .with_context(|| format!("requesting OAuth device code for {provider_id}"))?;

    eprintln!();
    eprintln!("  OAuth device authorization for {}", entry.display_name);
    eprintln!("  ──────────────────────────────────────────");
    if let Some(complete) = &device_code.verification_uri_complete {
        eprintln!("  Open:  {complete}");
    } else {
        eprintln!("  Open:  {}", device_code.verification_uri);
    }
    eprintln!("  Code:  {}", device_code.user_code);
    eprintln!();
    eprintln!("  Waiting for authorization…");

    let mut interval = std::time::Duration::from_secs(device_code.interval.max(1));
    let token = loop {
        tokio::time::sleep(interval).await;
        match flow.poll_once(&device_code.device_code).await? {
            bitrouter_providers::oauth::device_code::PollOutcome::Token(t) => break t,
            bitrouter_providers::oauth::device_code::PollOutcome::Pending => continue,
            bitrouter_providers::oauth::device_code::PollOutcome::SlowDown => {
                // RFC 8628 §3.5: server requested back-off — add 5s.
                interval += std::time::Duration::from_secs(5);
            }
        }
    };
    Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(token))
}

/// Adopt an existing OAuth session from the provider's sibling vendor CLI
/// (Codex for `openai-codex`) by reading its on-disk / Keychain credential.
/// Errors when no such session is found.
///
/// `anthropic` is intentionally absent: it is the platform / pay-as-you-go
/// provider now, and the Claude Code session is adopted *live* by the
/// `claude-code` provider via [`run_claude_code_session`] — not copied in here
/// (the one-shot copy is what risked the RFC 6749 §6 family-revoke).
fn run_cli_import(
    provider_id: &str,
) -> Result<bitrouter_providers::oauth::credential_store::Credential> {
    let imported = match provider_id {
        bitrouter_providers::codex::PROVIDER_ID => bitrouter_providers::import::codex::import(),
        bitrouter_providers::supergrok::PROVIDER_ID => bitrouter_providers::import::grok::import(),
        other => anyhow::bail!("no vendor-CLI import is available for provider '{other}'"),
    }
    .with_context(|| format!("importing a CLI credential for {provider_id}"))?;
    let imported = imported.ok_or_else(|| {
        anyhow::anyhow!(
            "no existing CLI session found for {provider_id} — sign in to the \
             vendor CLI first, or choose the browser sign-in instead"
        )
    })?;
    eprintln!("  Imported existing session from {}", imported.source);
    Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(imported.token))
}

/// Read a static API key from stdin. Input is **not** masked — the user
/// sees what they're pasting, which is the simplest correct UX and
/// matches `gh auth login --with-token`. Future work could wire
/// `rpassword` for a hidden prompt.
fn run_api_key_paste(
    display_name: &str,
) -> Result<bitrouter_providers::oauth::credential_store::Credential> {
    use std::io::BufRead;
    eprintln!();
    eprintln!("  Paste your {display_name} API key, then press enter:");
    eprint!("  > ");
    let stdin = std::io::stdin();
    let mut line = String::new();
    stdin
        .lock()
        .read_line(&mut line)
        .context("reading API key from stdin")?;
    let key = line.trim();
    if key.is_empty() {
        anyhow::bail!("no API key entered — aborting login");
    }
    Ok(bitrouter_providers::oauth::credential_store::Credential::api_key(key))
}

/// `LoginUx` implementation that drives stderr + stdin. Used by the
/// `bitrouter providers login` CLI; library tests provide their own scripted
/// UX.
struct StderrLoginUx;

#[async_trait::async_trait]
impl bitrouter_providers::oauth::login::LoginUx for StderrLoginUx {
    async fn show_authorize_url(&self, url: &str, hint: &str) {
        eprintln!();
        eprintln!("  Open this URL in your browser to continue:");
        eprintln!();
        eprintln!("    {url}");
        eprintln!();
        eprintln!("  {hint}");
    }

    async fn prompt_pasted_redirect(
        &self,
    ) -> std::result::Result<String, bitrouter_providers::oauth::login::LoginError> {
        // The login flow races this future against the loopback
        // listener. If the listener wins we never read stdin, so a
        // blocking read here would normally pin a thread for the whole
        // OAuth round trip. Run the read on a blocking thread so
        // cancellation (the `tokio::select!` in `run_login` dropping
        // this future when the listener finishes) actually frees it.
        let pasted = tokio::task::spawn_blocking(|| {
            use std::io::BufRead;
            eprint!("  Paste redirect URL (or just the code): ");
            let stdin = std::io::stdin();
            let mut line = String::new();
            stdin.lock().read_line(&mut line).map_err(|e| {
                bitrouter_providers::oauth::login::LoginError::UserIo(format!(
                    "reading pasted redirect: {e}"
                ))
            })?;
            Ok::<_, bitrouter_providers::oauth::login::LoginError>(line)
        })
        .await
        .map_err(|e| {
            bitrouter_providers::oauth::login::LoginError::UserIo(format!(
                "join error reading pasted redirect: {e}"
            ))
        })??;
        Ok(pasted)
    }
}

/// `bitrouter providers logout <provider>` — drop every stored credential for
/// the provider (subscription OAuth or pasted API key), if any.
pub async fn logout_provider(provider_id: &str) -> Result<usize> {
    let mut store = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()
        .context("opening credential store")?;
    let removed = store
        .remove_all_for(provider_id)
        .with_context(|| format!("removing stored credentials for {provider_id}"))?;
    match removed {
        0 => eprintln!("  No stored credentials for {provider_id}; nothing to remove."),
        n => eprintln!("  ✓ Removed {n} stored credential(s) for {provider_id}."),
    }
    Ok(removed)
}

// ===== skills (client installer) =====

/// Default BitRouter registry queried when `bitrouter skills add <name>` is
/// given a bare skill name rather than a git source.
const DEFAULT_SKILLS_REGISTRY: &str = "https://api.bitrouter.ai";

/// Build the HTTP client used to talk to a skills registry. Mirrors the
/// 30-second-timeout, user-agent-tagged client the cloud SDK uses.
fn skills_http_client() -> Result<reqwest::Client> {
    reqwest::Client::builder()
        .user_agent(concat!("bitrouter-skills/", env!("CARGO_PKG_VERSION")))
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .context("building skills HTTP client")
}

/// Resolve the install target for the `--global` flag.
fn skills_target(global: bool) -> Result<bitrouter_skills::install::InstallTarget> {
    use bitrouter_skills::install::InstallTarget;
    if global {
        Ok(InstallTarget::Global)
    } else {
        let cwd = std::env::current_dir().context("resolving the current directory")?;
        Ok(InstallTarget::Project { project_root: cwd })
    }
}

/// Whether `source` is a bare registry skill name (no path/scheme), as opposed
/// to a git source.
fn is_bare_skill_name(source: &str) -> bool {
    !source.contains('/') && !source.contains("://") && !source.starts_with("git@")
}

/// Resolve a registry name to its [`bitrouter_skills::source::SkillSource`] via
/// the namespace's marketplace hub.
async fn resolve_registry_source(
    name: &str,
    registry: Option<&str>,
    namespace: &str,
) -> Result<bitrouter_skills::source::SkillSource> {
    use bitrouter_skills::marketplace;
    use bitrouter_skills::source::SkillSource;
    let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);
    let client = skills_http_client()?;
    let market = marketplace::fetch_marketplace(&client, base, namespace).await?;
    let entry = market
        .find(name)
        .ok_or_else(|| anyhow::anyhow!("skill {name:?} not found in registry {base}"))?;
    SkillSource::try_from(&entry.source)
        .with_context(|| format!("resolving registry source for {name:?}"))
}

/// `bitrouter skills add` — install a skill from a git source or registry name.
pub async fn skills_add(
    source: &str,
    select: Option<&str>,
    global: bool,
    overwrite: bool,
    registry: Option<&str>,
    namespace: Option<&str>,
) -> Result<SkillAddReport> {
    use bitrouter_skills::{install, source as skill_source};

    if source.trim().is_empty() {
        anyhow::bail!(
            "a skill source is required (owner/repo, a git URL, a local path, or a registry name)"
        );
    }

    let target = skills_target(global)?;
    let parsed = if is_bare_skill_name(source) {
        let nsid = namespace
            .ok_or_else(|| anyhow::anyhow!("--namespace <nsid> is required to query a registry"))?;
        resolve_registry_source(source, registry, nsid).await?
    } else {
        skill_source::parse_source(source)?
    };
    let outcome = install::install(&parsed, &target, overwrite, select, None).await?;
    Ok(SkillAddReport {
        action: if outcome.was_updated {
            "updated"
        } else {
            "installed"
        },
        name: outcome.name,
        dest: outcome.dest.display().to_string(),
    })
}

/// `bitrouter skills list` — show installed skills.
pub fn skills_list(global: bool) -> Result<SkillsListReport> {
    use bitrouter_skills::install;
    let target = skills_target(global)?;
    let skills = install::list_installed(&target)?
        .into_iter()
        .map(|(name, path)| SkillEntry {
            name,
            path: path.display().to_string(),
        })
        .collect();
    Ok(SkillsListReport { skills })
}

/// `bitrouter skills remove` — uninstall a skill by name.
pub fn skills_remove(name: &str, global: bool) -> Result<SkillRemoveReport> {
    use bitrouter_skills::install;
    let target = skills_target(global)?;
    let dir = install::remove(name, &target)?;
    Ok(SkillRemoveReport {
        name: name.to_string(),
        path: dir.display().to_string(),
        removed: true,
    })
}

/// `bitrouter skills find` — search a registry.
pub async fn skills_find(
    query: &str,
    registry: Option<&str>,
    namespace: Option<&str>,
) -> Result<SkillsFindReport> {
    use bitrouter_skills::marketplace;
    let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);
    let nsid = namespace
        .ok_or_else(|| anyhow::anyhow!("--namespace <nsid> is required to query a registry"))?;
    let client = skills_http_client()?;
    let market = marketplace::fetch_marketplace(&client, base, nsid).await?;
    let results = market
        .search(query)
        .into_iter()
        .map(|entry| SkillHit {
            name: entry.name.clone(),
            version: entry.version.clone().unwrap_or_else(|| "-".to_string()),
            description: entry.description.clone().unwrap_or_default(),
        })
        .collect();
    Ok(SkillsFindReport {
        query: query.to_string(),
        registry: base.to_string(),
        results,
    })
}

/// `bitrouter skills init` — scaffold a SKILL.md.
pub fn skills_init(name: &str, output: &std::path::Path) -> Result<SkillInitReport> {
    bitrouter_skills::install::validate_skill_name(name)?;
    if output.exists() {
        anyhow::bail!("{} already exists; refusing to overwrite", output.display());
    }
    let body = format!(
        "---\nname: {name}\ndescription: TODO — one line on what this skill does and when to use it.\n---\n\n# {name}\n\nTODO: instructions for the agent.\n\n## When to use\n\n## Steps\n"
    );
    std::fs::write(output, body).with_context(|| format!("writing {}", output.display()))?;
    Ok(SkillInitReport {
        path: output.display().to_string(),
        created: true,
    })
}

/// `bitrouter skills update` — re-install installed skills from the registry.
pub async fn skills_update(
    name: Option<&str>,
    global: bool,
    registry: Option<&str>,
    namespace: Option<&str>,
) -> Result<SkillsUpdateReport> {
    use bitrouter_skills::install;
    use bitrouter_skills::marketplace;
    use bitrouter_skills::source::SkillSource;

    let target = skills_target(global)?;
    let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);

    // `update` only re-installs skills that are actually installed; an explicit
    // name must already be present (it does not double as an installer).
    let installed: Vec<String> = install::list_installed(&target)?
        .into_iter()
        .map(|(n, _)| n)
        .collect();
    let names: Vec<String> = match name {
        Some(n) => {
            if !installed.iter().any(|i| i == n) {
                anyhow::bail!("skill {n:?} is not installed; use `bitrouter skills add` first");
            }
            vec![n.to_string()]
        }
        None => installed,
    };
    if names.is_empty() {
        return Ok(SkillsUpdateReport {
            updated: Vec::new(),
            skipped: Vec::new(),
            failed: Vec::new(),
        });
    }

    let nsid = namespace
        .ok_or_else(|| anyhow::anyhow!("--namespace <nsid> is required to query a registry"))?;
    let client = skills_http_client()?;
    let market = marketplace::fetch_marketplace(&client, base, nsid).await?;

    // Update each skill independently: one failure must not abort the rest.
    // Re-install into the same directory so a skill stays put even if its
    // upstream frontmatter name has drifted. Per-skill outcomes are collected
    // into the report, which exits non-zero when any skill failed.
    let mut report = SkillsUpdateReport {
        updated: Vec::new(),
        skipped: Vec::new(),
        failed: Vec::new(),
    };
    for skill_name in names {
        match market.find(&skill_name) {
            Some(entry) => {
                let result = match SkillSource::try_from(&entry.source) {
                    Ok(parsed) => {
                        install::install(&parsed, &target, true, None, Some(&skill_name)).await
                    }
                    Err(e) => Err(e),
                };
                match result {
                    Ok(outcome) => report.updated.push(UpdatedSkill {
                        name: outcome.name,
                        dest: outcome.dest.display().to_string(),
                    }),
                    Err(e) => report.failed.push(FailedSkill {
                        name: skill_name,
                        error: e.to_string(),
                    }),
                }
            }
            None => report.skipped.push(SkippedSkill {
                name: skill_name,
                reason: format!("not in registry {base}"),
            }),
        }
    }
    Ok(report)
}

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

    #[tokio::test]
    async fn init_writes_skip_auth_true_config() {
        let dir = std::env::temp_dir().join(format!(
            "brinit-{}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        tokio::fs::create_dir_all(&dir).await.unwrap();
        let path = dir.join("bitrouter.yaml");
        init(&path).await.unwrap();
        let written = tokio::fs::read_to_string(&path).await.unwrap();
        assert!(written.contains("skip_auth: true"));
        // and it parses back into a valid Config
        let cfg = bitrouter_sdk::config::parse_with(&written, |_| Some("x".to_string())).unwrap();
        assert!(cfg.server.skip_auth);
        // refuses to overwrite
        assert!(init(&path).await.is_err());
        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn key_sign_produces_brvk_secret_and_persists_only_hash() {
        let key = key_sign("sqlite::memory:", "u1", Some("p1")).await.unwrap();
        assert!(key.secret.starts_with("brvk_"));
        assert!(key.id.starts_with("brvk_id_"));
    }

    /// Regression for the out-of-the-box `bitrouter init` → `start` failure:
    /// the starter config ships a *commented* `opencode-go` example that
    /// references `${OPENCODE_GO_KEY_A/B}`. The loader expands `${VAR}` over the
    /// raw text before YAML parsing, so it used to error on those unset vars
    /// even though they live in a comment. With comment-aware substitution the
    /// starter config must load with **no** environment variables set.
    #[test]
    fn starter_config_loads_with_all_env_vars_unset() {
        let cfg = bitrouter_sdk::config::parse_with(STARTER_CONFIG, |_| None)
            .expect("starter config must load with no env vars set");
        assert!(cfg.server.skip_auth);
    }

    /// Regression: the `mcp_servers:` and `agents:` blocks in
    /// `STARTER_CONFIG` are commented out by default, but if a user
    /// uncomments them the result must still parse against the current
    /// `Config` schema. This test strips the leading `# ` from those
    /// blocks and reparses; future schema drift fails CI here rather
    /// than fails the next user's `bitrouter init`.
    #[test]
    fn starter_config_parses_with_mcp_and_agents_examples_uncommented() {
        let uncommented: String = STARTER_CONFIG
            .lines()
            .map(|line| {
                // Uncomment any line inside the mcp_servers: / agents:
                // blocks. Those blocks are characterised by lines that
                // start with "# " followed by either the block-header
                // keyword or a key-value pair shaped like YAML.
                let trimmed = line.trim_start();
                if trimmed.starts_with("# mcp_servers:")
                    || trimmed.starts_with("# agents:")
                    || trimmed.starts_with("#   ")
                    || trimmed.starts_with("#     ")
                    || trimmed.starts_with("#       ")
                {
                    let leading_spaces = line.len() - trimmed.len();
                    let stripped = trimmed
                        .strip_prefix("# ")
                        .or_else(|| trimmed.strip_prefix("#"))
                        .unwrap_or(trimmed);
                    format!("{}{}", " ".repeat(leading_spaces), stripped)
                } else {
                    line.to_string()
                }
            })
            .collect::<Vec<_>>()
            .join("\n");
        let cfg = bitrouter_sdk::config::parse_with(&uncommented, |_| Some("x".to_string()))
            .expect("uncommented STARTER_CONFIG must parse against current schema");
        assert!(
            !cfg.mcp_servers.is_empty(),
            "mcp_servers block should land as parsed entries; got: {:?}",
            cfg.mcp_servers.keys().collect::<Vec<_>>()
        );
        assert!(
            !cfg.agents.is_empty(),
            "agents block should land as parsed entries; got: {:?}",
            cfg.agents.keys().collect::<Vec<_>>()
        );
    }

    /// Build a [`ProviderEntry`] from a registry-provider fixture — anthropic
    /// and openai-codex are no longer compiled-in built-ins, so `login` resolves
    /// their auth shape from the fetched registry via `entry_from_registry`.
    fn entry_for(json: serde_json::Value) -> bitrouter_providers::ProviderEntry {
        let p: bitrouter_providers::registry::types::RegistryProvider =
            serde_json::from_value(json).expect("valid registry-provider fixture");
        bitrouter_providers::builtin::entry_from_registry(&p).expect("maps to an entry")
    }

    #[test]
    fn claude_code_prefers_claude_code_session_first() {
        // The `claude-code` subscription provider — the live Claude Code session
        // is its default (and the marker lands under `claude-code`).
        let entry = entry_for(serde_json::json!({
            "name": "claude-code",
            "api_base": "https://api.anthropic.com/v1",
            "status": "active",
            "auth": { "kind": "oauth", "handler": "claude-code" },
            "models": []
        }));
        let methods = available_methods(&entry);
        assert_eq!(
            methods.first(),
            Some(&AuthMethod::ClaudeCodeSession),
            "the live Claude Code session must be the default for claude-code"
        );
        assert!(
            !methods.contains(&AuthMethod::ImportFromCli),
            "claude-code uses the live session, not the one-shot copy that can family-revoke"
        );
    }

    #[test]
    fn anthropic_offers_api_key_only() {
        // `anthropic` is now platform / pay-as-you-go: API-key paste only — no
        // Claude Code session, no subscription PKCE, no vendor-CLI import. The
        // subscription path moved to the `claude-code` provider.
        let entry = entry_for(serde_json::json!({
            "name": "anthropic",
            "api_base": "https://api.anthropic.com/v1",
            "status": "active",
            "auth": { "kind": "header", "header": "x-api-key", "env": "ANTHROPIC_API_KEY" },
            "models": []
        }));
        let methods = available_methods(&entry);
        assert_eq!(
            methods,
            vec![AuthMethod::ApiKey],
            "anthropic must offer the API-key path only"
        );
        assert!(!methods.contains(&AuthMethod::ClaudeCodeSession));
        assert!(!methods.contains(&AuthMethod::PkceSubscription));
        assert!(!methods.contains(&AuthMethod::ImportFromCli));
    }

    #[test]
    fn codex_keeps_oneshot_import_and_offers_no_claude_code_session() {
        let entry = entry_for(serde_json::json!({
            "name": "openai-codex",
            "api_base": "https://chatgpt.com/backend-api/codex",
            "status": "active",
            "auth": { "kind": "oauth", "handler": "openai-codex" },
            "models": []
        }));
        let methods = available_methods(&entry);
        assert_eq!(
            methods.first(),
            Some(&AuthMethod::ImportFromCli),
            "openai-codex onboarding should first reuse an existing Codex CLI session"
        );
        assert!(methods.contains(&AuthMethod::ImportFromCli));
        assert!(methods.contains(&AuthMethod::PkceSubscription));
        assert!(!methods.contains(&AuthMethod::ClaudeCodeSession));
    }

    #[test]
    fn import_existing_selects_vendor_cli_import_without_prompting() {
        let entry = entry_for(serde_json::json!({
            "name": "openai-codex",
            "api_base": "https://chatgpt.com/backend-api/codex",
            "status": "active",
            "auth": { "kind": "oauth", "handler": "openai-codex" },
            "models": []
        }));
        let methods = available_methods(&entry);
        let chosen = choose_auth_method(
            &entry,
            &methods,
            ProviderLoginOptions {
                import_existing: true,
                no_browser: true,
            },
        )
        .unwrap();
        assert_eq!(chosen, AuthMethod::ImportFromCli);
    }

    #[test]
    fn import_existing_rejects_providers_without_vendor_cli_import() {
        let entry = entry_for(serde_json::json!({
            "name": "anthropic",
            "api_base": "https://api.anthropic.com/v1",
            "status": "active",
            "auth": { "kind": "header", "header": "x-api-key", "env": "ANTHROPIC_API_KEY" },
            "models": []
        }));
        let methods = available_methods(&entry);
        let err = choose_auth_method(
            &entry,
            &methods,
            ProviderLoginOptions {
                import_existing: true,
                no_browser: true,
            },
        )
        .unwrap_err();
        assert!(format!("{err:#}").contains("cannot import"));
    }

    fn sample_config() -> Config {
        let yaml = r#"
providers:
  openai:
    api_base: https://api.openai.com/v1
    api_key: k1
    models: [{ id: gpt-5 }, { id: shared }]
  anthropic:
    api_base: https://api.anthropic.com/v1
    api_key: k2
    models: [{ id: shared }]
"#;
        bitrouter_sdk::config::parse_with(yaml, |_| None).unwrap()
    }

    #[tokio::test]
    async fn list_models_filters_by_provider() {
        let cfg = sample_config();
        let all = list_models(&cfg, None).await.unwrap();
        assert_eq!(all.len(), 2); // gpt-5, shared
        let openai_only = list_models(&cfg, Some("openai")).await.unwrap();
        assert_eq!(openai_only.len(), 2); // gpt-5 + shared (openai declares both)
        let anthropic_only = list_models(&cfg, Some("anthropic")).await.unwrap();
        assert_eq!(anthropic_only.len(), 1); // only `shared`
        assert_eq!(anthropic_only[0].0, "shared");
    }

    #[tokio::test]
    async fn resolve_route_returns_the_cascade_chain() {
        let cfg = sample_config();
        // `shared` is declared by both providers → a 2-hop alphabetical chain
        let chain = resolve_route(&cfg, "shared").await.unwrap();
        assert_eq!(chain.len(), 2);
        assert_eq!(chain[0].provider, "anthropic");
        assert_eq!(chain[1].provider, "openai");
        // an unknown model is a clean error (no DEFAULT_PROVIDER fallback)
        assert!(resolve_route(&cfg, "no-such-model").await.is_err());
    }

    #[test]
    fn list_providers_sorts_and_summarises() {
        let providers = list_providers(&sample_config());
        assert_eq!(providers.len(), 2);
        assert_eq!(providers[0].id, "anthropic");
        assert_eq!(providers[1].id, "openai");
        assert_eq!(providers[1].model_count, 2);
        assert!(providers[1].active);
    }

    #[tokio::test]
    async fn create_policy_rejects_path_traversal_ids() {
        let dir = std::env::temp_dir().join(format!(
            "brpoltrav-{}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        // Path separators, parent refs, dotfiles, control chars — all rejected
        // before any file is written.
        for bad in [
            "../etc/passwd",
            "foo/bar",
            "..",
            ".hidden",
            "",
            "a b",
            "x\0y",
        ] {
            let err = create_policy(&dir, bad).await.unwrap_err();
            let msg = format!("{err}");
            assert!(
                msg.contains("invalid characters")
                    || msg.contains("must not")
                    || msg.contains("empty"),
                "bad id '{bad}' should have been rejected; got: {msg}"
            );
        }
        // The dir was never created (because every call bailed before touching fs).
        // (We don't assert this strongly — a parallel test could create it — just clean up.)
        let _ = tokio::fs::remove_dir_all(&dir).await;
    }

    #[tokio::test]
    async fn create_policy_writes_a_starter_file() {
        let dir = std::env::temp_dir().join(format!(
            "brpol-{}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        let path = create_policy(&dir, "team-default").await.unwrap();
        let body = tokio::fs::read_to_string(&path).await.unwrap();
        assert!(body.contains("id: team-default"));
        // a re-create refuses to overwrite
        assert!(create_policy(&dir, "team-default").await.is_err());
        let _ = tokio::fs::remove_dir_all(&dir).await;
    }
}
