//! Session resolution: decides, for a given (server, username) pair, whether
//! the CLI/desktop app already has a usable credential or needs to prompt.
//!
//! Resolution order (spec contract):
//!   1. `session.json`'s cached token for the resolved host/user — validated
//!      with a cheap `GET /api/projects` probe. Valid -> `Ready`.
//!   2. Local credential store entry for `"<host:port>|<@handle>"` (see
//!      `secrets`) -> silent login; on success, session.json is REWRITTEN
//!      with the fresh token -> `Ready`.
//!   3. `CAMBIUM_PASSWORD` env var -> login -> `Ready`.
//!   4. Otherwise -> `NeedsPassword`.
//!
//! Before any of the above touches a credentialed endpoint, an unpinned
//! https host short-circuits to `NeedsTrust` (trust-on-first-use gate) — see
//! `pinned_http`. Plain `http://` dev servers skip pinning entirely.

use std::future::Future;

use crate::api::{ApiClient, ApiError, LoginResponse};
use crate::config::{CambiumDirs, DeviceIdentity, PinStore, SessionCache};
use crate::pinned_http::{self, CertInfo};
use crate::secrets;

#[derive(Debug)]
pub enum AuthOutcome {
    Ready(ApiClient),
    NeedsPassword,
    NeedsTrust { fingerprint: String, subject: String },
}

pub struct AuthRequest<'a> {
    pub dirs: &'a CambiumDirs,
    /// `--server` / `CAMBIUM_SERVER`; falls back to session.json's host.
    pub server: Option<String>,
    /// `--user` / `CAMBIUM_USER`; falls back to session.json's username.
    pub username: Option<String>,
}

/// The single definition of a credential-store account key:
/// `"<bare host:port>|<@handle>"`, both parts NORMALIZED. Every reader/writer
/// of the local credential store (`secrets::save_secret_in`/`load_secret_in`/
/// `delete_secret_in`) must derive its key through this function —
/// `executor::remember_token`'s writer, `executor::logout`'s deleter,
/// and this module's own reader below all have to agree on the exact same
/// string, or `logout` silently fails to remove what `login` stored, or a
/// stored credential silently stops being found by a later silent re-login.
///
/// Unifying the FORMAT is not enough, because the callers disagree about the
/// spelling they feed in: the reader below passes the caller's typed
/// `--user`/`CAMBIUM_USER` string, while the writer and deleter pass
/// `session.username` — which `canonical_username` has already replaced with
/// the server's stored handle. `cambium login --user @GINA --remember` would
/// therefore store under `host|@gina` and look up `host|@GINA` on the next
/// command, prompting for a password despite "remember me". The GUI is a
/// third speller again (`credentialAccountKey` in
/// TauriClient/src/data/credentialAccount.js, over the same shared config
/// dir), so a GUI logout would delete only its own spelling and leave the
/// CLI's copy live. This function therefore folds its inputs so all faces
/// land on one key:
///
///   * the handle gets exactly one leading `@` (the rule server.py's
///     `_normalize_handle`/`executor::normalize_handle` already apply), and
///   * the handle and the host are ASCII-lowercased.
///
/// Case folding is what makes the key match the server's own notion of
/// identity: `ORG.find_user` (CambiumServer/utilities/store.py) compares
/// `user["username"].lower() == username.lower()`, so `@GINA` and `@gina`
/// are ONE account server-side and must be one entry here. Hostnames are
/// case-insensitive by RFC 4343 and the port is digits, so the host folds
/// the same way.
///
/// The fold is deliberately ASCII-only, NOT `str::to_lowercase` and not
/// safenames.rs's NFC+lowercase (that one answers a different question:
/// whether two FILE names collide on a case-insensitive filesystem). This
/// key has to be reproduced byte-for-byte by the JS face, and Rust's
/// `to_lowercase`, JS's `toLowerCase` and Python's `.lower()` disagree on
/// non-ASCII edges (final sigma, dotted I) — an ASCII fold is identical in
/// all three. The server places no charset restriction on handles beyond
/// "non-empty after stripping @", so a non-ASCII handle simply keeps
/// today's case-sensitive behavior: never a mismatch BETWEEN the faces,
/// only the pre-existing miss if the same user types a different case.
pub fn credential_account_key(bare_host: &str, username: &str) -> String {
    let handle = username.replace('@', "").to_ascii_lowercase();
    let handle = if handle.is_empty() { String::new() } else { format!("@{handle}") };
    format!("{}|{}", bare_host.to_ascii_lowercase(), handle)
}

/// Splits a server string into (is_https, bare "host:port"), matching
/// `ApiClient::new`'s scheme convention: bare or explicit `https://` is
/// pinned-https; explicit `http://` is a plain dev server.
fn parse_host(server: &str) -> (bool, String) {
    if let Some(rest) = server.strip_prefix("http://") {
        (false, rest.to_string())
    } else if let Some(rest) = server.strip_prefix("https://") {
        (true, rest.to_string())
    } else {
        (true, server.to_string())
    }
}

/// The username to persist in session.json: the server-canonical handle
/// from the login response's `user.username`. server.py's login handler
/// returns `"user": public_user(user)` whose `username` is the STORED form,
/// while login itself matches case-insensitively (`ORG.find_user`) — so the
/// caller's typed handle (`--user @ALICE`) can differ by case from the
/// canonical `@alice` and still authenticate. The session's username feeds
/// the changelist/manifest state key (sha256-hashed into a filename), which
/// the GUI builds from this same canonical `user.username`
/// (ConnectionContext.jsx's `account`) — storing the typed form would
/// silently split the CLI's changelist file from the GUI's.
///
/// Fallback: the caller's string, for responses without a `user` object —
/// server.py's deviceLimitReached shape returns a token but no `user`
/// (defensive; that flow doesn't produce a working session anyway).
fn canonical_username(login: &LoginResponse, typed: &str) -> String {
    login
        .user
        .as_ref()
        .and_then(|u| u.get("username"))
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .unwrap_or_else(|| typed.to_string())
}

fn resolve_host_and_user(
    req: &AuthRequest<'_>,
    session: &Option<SessionCache>,
) -> Result<(String, String), ApiError> {
    let host = req
        .server
        .clone()
        .or_else(|| session.as_ref().map(|s| s.host.clone()))
        .ok_or_else(|| {
            ApiError::Connect(
                "no server: pass --server or run 'cambium login --server HOST:PORT'".into(),
            )
        })?;
    let username = req
        .username
        .clone()
        .or_else(|| session.as_ref().map(|s| s.username.clone()))
        .ok_or_else(|| {
            ApiError::Connect("no username: pass --user or run 'cambium login --user <name>'".into())
        })?;
    Ok((host, username))
}

/// Real entry point: TOFU cert fetch goes through `pinned_http`.
pub async fn resolve(req: AuthRequest<'_>) -> Result<AuthOutcome, ApiError> {
    resolve_inner(req, |host| pinned_http::fetch_server_certificate(host)).await
}

/// Same resolution logic as `resolve`, but with the TOFU certificate fetch
/// injected — lets tests exercise the "unpinned https -> NeedsTrust" decision
/// without a real TLS handshake (the pin-store check happens before any
/// network I/O; only the *fingerprint-for-the-prompt* step needs a fetcher).
async fn resolve_inner<F, Fut>(req: AuthRequest<'_>, fetch_cert: F) -> Result<AuthOutcome, ApiError>
where
    F: FnOnce(String) -> Fut,
    Fut: Future<Output = Result<CertInfo, String>>,
{
    let dirs = req.dirs;
    let session = SessionCache::load(dirs).map_err(ApiError::Connect)?;
    let (host, username) = resolve_host_and_user(&req, &session)?;
    let (is_https, bare_host) = parse_host(&host);

    // TOFU gate BEFORE any credentialed call.
    let pin = if is_https {
        let existing = PinStore::new(dirs).get(&bare_host).map_err(ApiError::Connect)?;
        match existing {
            Some(p) => Some(p),
            None => {
                let cert = fetch_cert(bare_host.clone()).await.map_err(ApiError::Connect)?;
                return Ok(AuthOutcome::NeedsTrust {
                    fingerprint: cert.fingerprint,
                    subject: cert.subject,
                });
            }
        }
    } else {
        None
    };

    // 1. Cached token, only if it belongs to the resolved host/user.
    if let Some(cached) = &session {
        if cached.host == host && cached.username == username {
            let mut client = ApiClient::new(&host, pin.clone())?;
            client.token = Some(cached.token.clone());
            if client.projects().await.is_ok() {
                return Ok(AuthOutcome::Ready(client));
            }
            // Invalid/expired: fall through to the credential store/env below.
        }
    }

    let device = DeviceIdentity::load_or_create(dirs, None, None).map_err(ApiError::Connect)?;

    // 2. Local credential store -> resume, rewriting session.json on success.
    //
    // The stored value is a remember-token, not a password: the server
    // minted it for THIS device and can revoke it, and a stolen config
    // directory therefore never yields the user's actual password.
    //
    // A store that cannot be READ is not a hard error. A corrupt file, a
    // missing or regenerated credkey, or a formatVersion written by a newer
    // Cambium all fall through to CAMBIUM_PASSWORD/NeedsPassword, so the
    // user gets a password prompt instead of a wall.
    let account = credential_account_key(&bare_host, &username);
    if let Some(remember_token) = secrets::load_secret_in(dirs, &account).unwrap_or(None) {
        let mut client = ApiClient::new(&host, pin.clone())?;
        match client.resume(&username, &remember_token, &device.device_id).await {
            Ok(resumed) => {
                let session_to_save = SessionCache {
                    format_version: 1,
                    host: host.clone(),
                    username: canonical_username(&resumed, &username),
                    token: resumed.token,
                };
                session_to_save.save(dirs).map_err(ApiError::Connect)?;
                return Ok(AuthOutcome::Ready(client));
            }
            // A 401 is the server saying this token is dead — expired,
            // revoked, or the device was removed. Drop it so the next run
            // prompts once rather than retrying a corpse forever. Any OTHER
            // error (unreachable host, TLS failure, 500) says nothing about
            // the token's validity, so it MUST be left alone: deleting on a
            // flaky network would turn a blip into a permanent logout.
            Err(ApiError::Auth(_)) => {
                let _ = secrets::delete_secret_in(dirs, &account);
            }
            Err(_) => {}
        }
    }

    // 3. CAMBIUM_PASSWORD env var.
    if let Ok(env_password) = std::env::var("CAMBIUM_PASSWORD") {
        let mut client = ApiClient::new(&host, pin.clone())?;
        let login = client
            .login(&username, &env_password, &device.device_id, &device.device_name, false)
            .await?;
        let session_to_save = SessionCache {
            format_version: 1,
            host: host.clone(),
            username: canonical_username(&login, &username),
            token: login.token,
        };
        session_to_save.save(dirs).map_err(ApiError::Connect)?;
        return Ok(AuthOutcome::Ready(client));
    }

    // 4. Nothing worked.
    Ok(AuthOutcome::NeedsPassword)
}

/// Completes a `NeedsPassword` (or post-`trust`, post-`NeedsTrust`) round:
/// logs in with the given password and saves session.json. `remember`
/// asks the server to mint a device-bound remember-token (returned in the
/// `LoginResponse`, alongside the client, for the caller to persist) — it
/// must reflect whether the user actually asked to be remembered; the
/// server only issues a token when `remember` is true.
pub async fn login_with(
    req: &AuthRequest<'_>,
    password: &str,
    remember: bool,
) -> Result<(ApiClient, LoginResponse), ApiError> {
    let dirs = req.dirs;
    let session = SessionCache::load(dirs).map_err(ApiError::Connect)?;
    let (host, username) = resolve_host_and_user(req, &session)?;
    let (is_https, bare_host) = parse_host(&host);

    let pin = if is_https {
        Some(
            PinStore::new(dirs)
                .get(&bare_host)
                .map_err(ApiError::Connect)?
                .ok_or_else(|| ApiError::Pin("host not trusted: call trust() first".into()))?,
        )
    } else {
        None
    };

    let mut client = ApiClient::new(&host, pin)?;
    let device = DeviceIdentity::load_or_create(dirs, None, None).map_err(ApiError::Connect)?;
    let login = client
        .login(&username, password, &device.device_id, &device.device_name, remember)
        .await?;
    let session_to_save = SessionCache {
        format_version: 1,
        host: host.clone(),
        username: canonical_username(&login, &username),
        token: login.token.clone(),
    };
    session_to_save.save(dirs).map_err(ApiError::Connect)?;
    Ok((client, login))
}

/// Pins `fingerprint` for `host`, completing a `NeedsTrust` round.
pub async fn trust(dirs: &CambiumDirs, host: &str, fingerprint: &str) -> Result<(), String> {
    let (_is_https, bare_host) = parse_host(host);
    PinStore::new(dirs).set(&bare_host, fingerprint)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::PinStore;
    use std::sync::Mutex;

    // CAMBIUM_PASSWORD is process-global, and `resolve_inner` reads it
    // unconditionally on its fall-through path — so EVERY test that calls
    // `resolve_inner`/`resolve` must hold this lock, not just the tests that
    // SET the variable. Otherwise a concurrently-set CAMBIUM_PASSWORD (from
    // a parallel test thread) shoves a "nothing available" test down the
    // env-password branch and breaks its assertions.
    static ENV_GUARD: Mutex<()> = Mutex::new(());

    /// First line of every test that reaches the env read. Poisoned-lock
    /// recovery matches config.rs's EnvGuard pattern.
    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
        ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner())
    }

    struct EnvPasswordGuard {
        _lock: std::sync::MutexGuard<'static, ()>,
    }

    impl Drop for EnvPasswordGuard {
        fn drop(&mut self) {
            std::env::remove_var("CAMBIUM_PASSWORD");
        }
    }

    /// Writer variant of `env_lock`: sets CAMBIUM_PASSWORD and holds the
    /// lock (plus RAII cleanup) for the whole test body. Tests use EITHER
    /// this or `env_lock()`, never both (double-lock would deadlock).
    fn set_env_password(value: &str) -> EnvPasswordGuard {
        let lock = env_lock();
        std::env::set_var("CAMBIUM_PASSWORD", value);
        EnvPasswordGuard { _lock: lock }
    }

    fn test_dirs() -> (tempfile::TempDir, CambiumDirs) {
        let tmp = tempfile::tempdir().unwrap();
        let dirs = CambiumDirs { root: tmp.path().to_path_buf() };
        (tmp, dirs)
    }

    /// A credentials.json entry with no `credkey` beside it — the store is
    /// present but unusable. Stands in for any unreadable-store shape; the
    /// bad-base64 and truncated-blob branches are covered directly in
    /// secrets.rs's tests.
    fn write_corrupt_store(dirs: &CambiumDirs, account: &str) {
        let body = format!(
            r#"{{"formatVersion":1,"credentials":{{"{account}":"not-valid-base64!!!"}}}}"#
        );
        std::fs::write(dirs.root.join("credentials.json"), body).unwrap();
    }

    /// A never-invoked TOFU fetcher, for tests where the resolved host is
    /// plain http:// (pinning must be skipped entirely) or already pinned.
    fn panicking_fetch(_host: String) -> std::future::Ready<Result<CertInfo, String>> {
        panic!("fetch_server_certificate should not be called for this host");
    }

    /// Background tiny_http server answering fixed routes (path -> status,
    /// body), mirroring api.rs's test helper.
    struct FakeServer {
        base_url: String,
    }

    fn spawn_fake_server(routes: Vec<(&'static str, u16, &'static str)>) -> FakeServer {
        let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
        let port = server.server_addr().to_ip().unwrap().port();
        std::thread::spawn(move || {
            for req in server.incoming_requests() {
                let hit = routes.iter().find(|(p, _, _)| req.url() == *p);
                match hit {
                    Some((_, status, body)) => {
                        let resp =
                            tiny_http::Response::from_string(body.to_string()).with_status_code(*status);
                        let _ = req.respond(resp);
                    }
                    None => {
                        let _ = req.respond(tiny_http::Response::empty(404));
                    }
                }
            }
        });
        FakeServer { base_url: format!("http://127.0.0.1:{port}") }
    }

    #[tokio::test]
    async fn cached_valid_token_is_ready() {
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![("/api/projects", 200, r#"{"projects":[]}"#)]);
        SessionCache {
            format_version: 1,
            host: server.base_url.clone(),
            username: "@alice".to_string(),
            token: "tok-cached".to_string(),
        }
        .save(&dirs)
        .unwrap();

        let req = AuthRequest { dirs: &dirs, server: None, username: None };
        let outcome = resolve_inner(req, panicking_fetch).await.unwrap();
        match outcome {
            AuthOutcome::Ready(client) => assert_eq!(client.token.as_deref(), Some("tok-cached")),
            other => panic!("expected Ready, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn stale_token_falls_back_to_stored_credential_and_rewrites_session() {
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![
            ("/api/projects", 401, r#"{"error":"expired"}"#),
            ("/api/session/resume", 200, r#"{"token":"tok-fresh"}"#),
        ]);
        let bare_host = server.base_url.trim_start_matches("http://").to_string();
        SessionCache {
            format_version: 1,
            host: server.base_url.clone(),
            username: "@alice".to_string(),
            token: "tok-stale".to_string(),
        }
        .save(&dirs)
        .unwrap();
        // The stored credential is now a remember-token, redeemed at
        // /api/session/resume rather than a password sent to /api/login.
        secrets::save_secret_in(&dirs, &format!("{bare_host}|@alice"), "rt-live").unwrap();

        let req = AuthRequest { dirs: &dirs, server: None, username: None };
        let outcome = resolve_inner(req, panicking_fetch).await.unwrap();
        match outcome {
            AuthOutcome::Ready(client) => assert_eq!(client.token.as_deref(), Some("tok-fresh")),
            other => panic!("expected Ready, got {other:?}"),
        }

        let saved = SessionCache::load(&dirs).unwrap().unwrap();
        assert_eq!(saved.token, "tok-fresh");
        assert_eq!(saved.host, server.base_url);
        assert_eq!(saved.username, "@alice");
    }

    #[tokio::test]
    async fn nothing_available_needs_password() {
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![]);

        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@bob".to_string()),
        };
        let outcome = resolve_inner(req, panicking_fetch).await.unwrap();
        assert!(matches!(outcome, AuthOutcome::NeedsPassword), "got {outcome:?}");
    }

    #[tokio::test]
    async fn cambium_password_env_logs_in_and_saves_session() {
        let _env = set_env_password("env-secret");
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![("/api/login", 200, r#"{"token":"tok-env"}"#)]);

        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@carol".to_string()),
        };
        let outcome = resolve_inner(req, panicking_fetch).await.unwrap();
        match outcome {
            AuthOutcome::Ready(client) => assert_eq!(client.token.as_deref(), Some("tok-env")),
            other => panic!("expected Ready, got {other:?}"),
        }

        let saved = SessionCache::load(&dirs).unwrap().unwrap();
        assert_eq!(saved.token, "tok-env");
        assert_eq!(saved.username, "@carol");
    }

    #[tokio::test]
    async fn https_without_pin_needs_trust() {
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let req = AuthRequest {
            dirs: &dirs,
            server: Some("example.test:9443".to_string()),
            username: Some("@dave".to_string()),
        };
        let fetch = |host: String| async move {
            assert_eq!(host, "example.test:9443");
            Ok(CertInfo {
                fingerprint: "AA:BB:CC".to_string(),
                subject: "CN=example.test".to_string(),
                not_before: "2026-01-01".to_string(),
                not_after: "2027-01-01".to_string(),
            })
        };
        let outcome = resolve_inner(req, fetch).await.unwrap();
        match outcome {
            AuthOutcome::NeedsTrust { fingerprint, subject } => {
                assert_eq!(fingerprint, "AA:BB:CC");
                assert_eq!(subject, "CN=example.test");
            }
            other => panic!("expected NeedsTrust, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn pinned_https_host_skips_trust_prompt() {
        // Once pinned, resolve() must proceed past the TOFU gate without
        // calling the fetcher again (proven by the panicking fetcher).
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        PinStore::new(&dirs).set("example.test:9443", "AA:BB:CC").unwrap();

        let req = AuthRequest {
            dirs: &dirs,
            server: Some("example.test:9443".to_string()),
            username: Some("@erin".to_string()),
        };
        // No session/stored-credential/env credential exists for this
        // account, so no step ever needs to actually reach the (unreachable)
        // host — proving both that the pin gate was skipped (the panicking
        // fetcher would have aborted the test otherwise) and that resolution
        // correctly bottoms out at NeedsPassword.
        let result = resolve_inner(req, panicking_fetch).await;
        assert!(matches!(result, Ok(AuthOutcome::NeedsPassword)), "got {result:?}");
    }

    #[tokio::test]
    async fn no_server_resolvable_is_an_actionable_connect_error() {
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let req = AuthRequest { dirs: &dirs, server: None, username: None };
        let err = resolve_inner(req, panicking_fetch).await.unwrap_err();
        match err {
            ApiError::Connect(msg) => assert!(msg.contains("no server"), "{msg}"),
            other => panic!("expected Connect, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn rejected_stored_remember_token_falls_through_to_needs_password() {
        // Failure-divergence contract, half 1: a stored remember-token the
        // server rejects (expired/revoked) must NOT abort resolution —
        // silent background auth continues to the next source and, with no
        // env password set, bottoms out at NeedsPassword. (The dedicated
        // deletion assertion for this same 401 lives in
        // `a_rejected_remember_token_is_deleted`.)
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![(
            "/api/session/resume",
            401,
            r#"{"error":"Your saved sign-in is no longer valid. Log in again."}"#,
        )]);
        let bare_host = server.base_url.trim_start_matches("http://").to_string();
        secrets::save_secret_in(&dirs, &format!("{bare_host}|@gina"), "rt-dead").unwrap();

        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@gina".to_string()),
        };
        let outcome = resolve_inner(req, panicking_fetch).await.unwrap();
        assert!(matches!(outcome, AuthOutcome::NeedsPassword), "got {outcome:?}");
        // The failed silent login must not have written a session.
        assert!(SessionCache::load(&dirs).unwrap().is_none());
    }

    #[tokio::test]
    async fn unreadable_store_falls_through_to_env_password() {
        // A credential that cannot be read must not abort resolution: the
        // env var is still a valid way in.
        let _env = set_env_password("env-past-broken-store");
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![("/api/login", 200, r#"{"token":"tok-headless"}"#)]);
        let bare_host = server.base_url.trim_start_matches("http://").to_string();
        write_corrupt_store(&dirs, &format!("{bare_host}|@ivy"));

        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@ivy".to_string()),
        };
        let outcome = resolve_inner(req, panicking_fetch).await.unwrap();
        match outcome {
            AuthOutcome::Ready(client) => assert_eq!(client.token.as_deref(), Some("tok-headless")),
            other => panic!("expected Ready via env password, got {other:?}"),
        }

        let saved = SessionCache::load(&dirs).unwrap().unwrap();
        assert_eq!(saved.token, "tok-headless");
        assert_eq!(saved.username, "@ivy");
    }

    #[tokio::test]
    async fn unreadable_store_without_env_is_needs_password() {
        // Same unreadable store, no CAMBIUM_PASSWORD — resolution must
        // bottom out at NeedsPassword (an Ok outcome the caller can prompt
        // on), never an Err.
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![]);
        let bare_host = server.base_url.trim_start_matches("http://").to_string();
        write_corrupt_store(&dirs, &format!("{bare_host}|@jack"));

        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@jack".to_string()),
        };
        let outcome = resolve_inner(req, panicking_fetch).await.unwrap();
        assert!(matches!(outcome, AuthOutcome::NeedsPassword), "got {outcome:?}");
    }

    #[tokio::test]
    async fn rejected_env_password_propagates_auth_error() {
        // Failure-divergence contract, half 2: CAMBIUM_PASSWORD is the
        // automation/CI path — a rejected credential there is a hard error,
        // not a silent fall-through to an (unavailable) interactive prompt.
        let _env = set_env_password("wrong-pw");
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![(
            "/api/login",
            401,
            r#"{"error":"Invalid username or password."}"#,
        )]);

        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@hank".to_string()),
        };
        let err = resolve_inner(req, panicking_fetch).await.unwrap_err();
        assert!(matches!(err, ApiError::Auth(_)), "expected Auth, got {err:?}");
        assert!(SessionCache::load(&dirs).unwrap().is_none());
    }

    #[tokio::test]
    async fn login_with_saves_session_and_returns_client() {
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![("/api/login", 200, r#"{"token":"tok-login-with"}"#)]);
        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@frank".to_string()),
        };
        let (client, _response) = login_with(&req, "hunter2", false).await.unwrap();
        assert_eq!(client.token.as_deref(), Some("tok-login-with"));

        let saved = SessionCache::load(&dirs).unwrap().unwrap();
        assert_eq!(saved.host, server.base_url);
        assert_eq!(saved.username, "@frank");
        assert_eq!(saved.token, "tok-login-with");
    }

    // ---- server-canonical username -----------------------------------------
    //
    // server.py's login matches usernames case-insensitively (ORG.find_user)
    // but the changelist/manifest state key is built from the SESSION's
    // username and sha256-hashed into a filename — a case-mangled login
    // (`--user @ALICE` vs stored `@alice`) would succeed yet silently split
    // the CLI's changelist from the GUI's. Every login path must therefore
    // persist the CANONICAL handle from the response's `user.username`
    // (`public_user` in server.py), not the caller's typed string.

    #[tokio::test]
    async fn login_with_stores_the_server_canonical_username() {
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![(
            "/api/login",
            200,
            r#"{"token":"tok-canon","user":{"username":"@alice","displayName":"alice"}}"#,
        )]);
        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@ALICE".to_string()), // case-mangled: server accepts it
        };
        let (client, _response) = login_with(&req, "hunter2", false).await.unwrap();
        assert_eq!(client.token.as_deref(), Some("tok-canon"));

        let saved = SessionCache::load(&dirs).unwrap().unwrap();
        assert_eq!(saved.username, "@alice", "session must store the server's canonical handle");
    }

    #[tokio::test]
    async fn env_password_login_stores_the_server_canonical_username() {
        let _env = set_env_password("env-secret");
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![(
            "/api/login",
            200,
            r#"{"token":"tok-env-canon","user":{"username":"@carol"}}"#,
        )]);

        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@CaRoL".to_string()),
        };
        let outcome = resolve_inner(req, panicking_fetch).await.unwrap();
        assert!(matches!(outcome, AuthOutcome::Ready(_)), "got {outcome:?}");

        let saved = SessionCache::load(&dirs).unwrap().unwrap();
        assert_eq!(saved.username, "@carol");
    }

    #[tokio::test]
    async fn stored_remember_token_resume_stores_the_server_canonical_username() {
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![(
            "/api/session/resume",
            200,
            r#"{"token":"tok-kc-canon","user":{"username":"@gina"}}"#,
        )]);
        let bare_host = server.base_url.trim_start_matches("http://").to_string();
        // Stored entry saved under the CANONICAL handle — which is what
        // `executor::remember_token` writes, because it keys off
        // `session.username` (already replaced by `canonical_username`).
        // The reader below resolves from the caller's TYPED `@GINA`, so this
        // credential (now a remember-token, redeemed at
        // /api/session/resume) is only found if `credential_account_key`
        // folds its inputs.
        secrets::save_secret_in(
            &dirs,
            &credential_account_key(&bare_host, "@gina"),
            "rt-live",
        )
        .unwrap();

        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@GINA".to_string()),
        };
        let outcome = resolve_inner(req, panicking_fetch).await.unwrap();
        assert!(matches!(outcome, AuthOutcome::Ready(_)), "got {outcome:?}");

        let saved = SessionCache::load(&dirs).unwrap().unwrap();
        assert_eq!(saved.username, "@gina");
    }

    #[tokio::test]
    async fn login_response_without_user_object_falls_back_to_typed_username() {
        // The deviceLimitReached response shape has a token but NO `user`
        // object (see server.py's login handler) — the defensive fallback is
        // the caller's string, never a panic or an empty username.
        let (_tmp, dirs) = test_dirs();
        let server =
            spawn_fake_server(vec![("/api/login", 200, r#"{"token":"tok-no-user"}"#)]);
        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@dave".to_string()),
        };
        login_with(&req, "hunter2", false).await.unwrap();
        let saved = SessionCache::load(&dirs).unwrap().unwrap();
        assert_eq!(saved.username, "@dave");
    }

    // ---- credential-store account key --------------------------------------
    //
    // The key's INPUTS reach it from four faces that disagree about spelling:
    // the reader (auth.rs) passes the caller's typed `--user`, the writer and
    // deleter (executor.rs) pass the server-canonical `session.username`, and
    // the GUI builds its own from the login form. The server treats all of
    // them as one account (`ORG.find_user` compares `.lower()`), so the key
    // must too — otherwise `--remember` stores under one key and the next
    // command looks up another.

    #[test]
    fn account_key_folds_handle_case() {
        assert_eq!(
            credential_account_key("host.example:8666", "@GINA"),
            credential_account_key("host.example:8666", "@gina")
        );
        assert_eq!(
            credential_account_key("host.example:8666", "@GiNa"),
            "host.example:8666|@gina"
        );
    }

    #[test]
    fn account_key_folds_a_missing_or_doubled_leading_at() {
        assert_eq!(
            credential_account_key("host.example:8666", "gina"),
            credential_account_key("host.example:8666", "@gina")
        );
        assert_eq!(
            credential_account_key("host.example:8666", "@@GINA"),
            "host.example:8666|@gina"
        );
        // An empty handle stays empty rather than becoming a bare "@".
        assert_eq!(credential_account_key("host.example:8666", "@"), "host.example:8666|");
    }

    #[test]
    fn account_key_folds_host_case() {
        assert_eq!(
            credential_account_key("Host.Example:8666", "@gina"),
            credential_account_key("host.example:8666", "@gina")
        );
        assert_eq!(
            credential_account_key("LOCALHOST:8666", "@gina"),
            "localhost:8666|@gina"
        );
    }

    #[test]
    fn account_key_folds_only_ascii_letters() {
        // Deliberate: the fold is ASCII-only so the Rust and JS faces produce
        // byte-identical keys (see `credential_account_key`'s doc). Non-ASCII
        // letters keep their case — `to_lowercase()`/JS `toLowerCase()` would
        // fold "É" here and the two languages disagree on such mappings, so
        // this pins the ASCII-only behavior in BOTH suites (the twin
        // assertion lives in data/credentialAccount.test.js).
        assert_eq!(credential_account_key("h:1", "@ÉLODIE"), "h:1|@Élodie");
    }

    /// The stored value is a remember-token now, redeemed at /api/session/resume
    /// — the password is never on disk and never sent.
    #[tokio::test]
    async fn stored_remember_token_resumes_the_session() {
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![(
            "/api/session/resume",
            200,
            r#"{"token":"tok-resumed","user":{"username":"@gina"}}"#,
        )]);
        let bare_host = server.base_url.trim_start_matches("http://").to_string();
        secrets::save_secret_in(&dirs, &format!("{bare_host}|@gina"), "rt-live").unwrap();
        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@gina".to_string()),
        };
        assert!(matches!(resolve(req).await.unwrap(), AuthOutcome::Ready(_)));
        // session.json now carries the resumed token.
        assert_eq!(SessionCache::load(&dirs).unwrap().unwrap().token, "tok-resumed");
    }

    /// A 401 means the token is definitively dead — drop it, so the next run
    /// prompts for a password instead of retrying a corpse forever.
    #[tokio::test]
    async fn a_rejected_remember_token_is_deleted() {
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![(
            "/api/session/resume",
            401,
            r#"{"error":"Your saved sign-in is no longer valid. Log in again."}"#,
        )]);
        let bare_host = server.base_url.trim_start_matches("http://").to_string();
        let account = format!("{bare_host}|@gina");
        secrets::save_secret_in(&dirs, &account, "rt-dead").unwrap();
        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@gina".to_string()),
        };
        assert!(matches!(resolve(req).await.unwrap(), AuthOutcome::NeedsPassword));
        assert_eq!(secrets::load_secret_in(&dirs, &account).unwrap(), None);
    }

    /// A non-401 error response (e.g. a 500) says nothing about the token's
    /// validity — only a 401 does. Deleting on any old error status would let
    /// a future refactor turn a server hiccup into a permanent logout.
    #[tokio::test]
    async fn a_server_error_on_resume_keeps_the_remember_token() {
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        let server = spawn_fake_server(vec![(
            "/api/session/resume",
            500,
            r#"{"error":"internal server error"}"#,
        )]);
        let bare_host = server.base_url.trim_start_matches("http://").to_string();
        let account = format!("{bare_host}|@gina");
        secrets::save_secret_in(&dirs, &account, "rt-live").unwrap();
        let req = AuthRequest {
            dirs: &dirs,
            server: Some(server.base_url.clone()),
            username: Some("@gina".to_string()),
        };
        assert!(matches!(resolve(req).await.unwrap(), AuthOutcome::NeedsPassword));
        assert_eq!(
            secrets::load_secret_in(&dirs, &account).unwrap(),
            Some("rt-live".to_string())
        );
    }

    /// A server that cannot be reached is NOT a dead token. Deleting here would
    /// turn a flaky network into a permanent logout.
    #[tokio::test]
    async fn an_unreachable_server_keeps_the_remember_token() {
        let _env = env_lock();
        let (_tmp, dirs) = test_dirs();
        // Port 1 is reserved and nothing listens on it.
        let account = "127.0.0.1:1|@gina".to_string();
        secrets::save_secret_in(&dirs, &account, "rt-live").unwrap();
        let req = AuthRequest {
            dirs: &dirs,
            server: Some("http://127.0.0.1:1".to_string()),
            username: Some("@gina".to_string()),
        };
        let outcome = resolve(req).await.unwrap();
        assert!(matches!(outcome, AuthOutcome::NeedsPassword), "got {outcome:?}");
        assert_eq!(
            secrets::load_secret_in(&dirs, &account).unwrap(),
            Some("rt-live".to_string())
        );
    }

    #[tokio::test]
    async fn trust_persists_fingerprint_in_pin_store() {
        let (_tmp, dirs) = test_dirs();
        trust(&dirs, "host.example:9443", "DE:AD:BE:EF").await.unwrap();
        assert_eq!(
            PinStore::new(&dirs).get("host.example:9443").unwrap(),
            Some("DE:AD:BE:EF".to_string())
        );
    }
}
