//! Typed API client over `pinned_http` — the Rust twin of the JS reference
//! client `TauriClient/src/api/client.js` (see `CambiumServer/server.py` for
//! the exact endpoint/payload contracts). Every request builds a fresh
//! pinned/plain reqwest client (mirroring `pinned_http::pinned_fetch`, which
//! does the same) so `Authorization`/`X-Project` always reflect the client's
//! current `token`/`project_id` rather than headers baked in at construction.

use std::collections::{BTreeMap, HashMap};

use serde::{Deserialize, Serialize};

use crate::pinned_http;
use crate::transfer::build_headers;
use crate::version;

/// Errors surfaced by every `ApiClient` call. `Pin` covers the
/// `pinned_http::build_client` sentinels (`NO_PIN`, `PIN_MISMATCH`); `Auth`
/// is specifically a 401 response (bad/expired credentials); `Server` is any
/// other non-2xx, carrying the server's `{error}` message; `Connect` is a
/// transport-level failure (refused, DNS, timeout, TLS handshake failure
/// unrelated to pinning).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApiError {
    Connect(String),
    Pin(String),
    Auth(String),
    /// Client and server are on different major versions and must not talk
    /// (see `version`). Raised either by our own check of the version the
    /// server advertises, or by the server answering 426 Upgrade Required.
    Incompatible(String),
    Server { status: u16, message: String },
}

impl std::fmt::Display for ApiError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ApiError::Connect(msg) => write!(f, "connection failed: {msg}"),
            ApiError::Pin(msg) => write!(f, "server identity check failed: {msg}"),
            ApiError::Auth(msg) => write!(f, "authentication failed: {msg}"),
            ApiError::Incompatible(msg) => write!(f, "{msg}"),
            ApiError::Server { status, message } => write!(f, "server error ({status}): {message}"),
        }
    }
}

impl std::error::Error for ApiError {}

/// Maps a `pinned_http::build_client` failure string to an `ApiError`. Those
/// sentinels are `NO_PIN` (https without a trusted pin) and `PIN_MISMATCH`
/// (parse_pin failures are also folded in here since they're pin-adjacent
/// config errors, not connectivity ones); anything else (a reqwest builder
/// error) is treated as a connect failure.
fn map_build_error(err: String) -> ApiError {
    if err == "NO_PIN" || err.contains("PIN_MISMATCH") || err.starts_with("bad pin") {
        ApiError::Pin(err)
    } else {
        ApiError::Connect(err)
    }
}

/// Maps a transport-level reqwest failure the same way
/// `pinned_http::map_reqwest_error` does: the PIN_MISMATCH sentinel survives
/// nested inside rustls' error chain, everything else is a plain connect
/// failure (refused, DNS, timeout, ...).
fn map_transport_error(err: reqwest::Error) -> ApiError {
    let text = format!("{err:?}");
    if text.contains("PIN_MISMATCH") {
        ApiError::Pin("PIN_MISMATCH".to_string())
    } else {
        ApiError::Connect(err.to_string())
    }
}

/// Minimal `encodeURIComponent` twin (unreserved set: alnum, `- _ . ! ~ * ' (
/// )`), used for `file_content_url`'s `path` query param — matches
/// `client.js`'s `encodeURIComponent(path)` exactly, including escaping `/`.
fn encode_uri_component(s: &str) -> String {
    let mut out = String::new();
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'!' | b'~' | b'*'
            | b'\'' | b'(' | b')' => out.push(b as char),
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

#[derive(Debug, Clone, Deserialize)]
pub struct LoginResponse {
    pub token: String,
    #[serde(default)]
    pub user: Option<serde_json::Value>,
    #[serde(default)]
    pub org: Option<serde_json::Value>,
    #[serde(default, rename = "deviceLimitReached")]
    pub device_limit_reached: bool,
    #[serde(default, rename = "rememberToken")]
    pub remember_token: Option<String>,
    /// The server's self-report (`{name, timezone, version}`), kept as raw
    /// JSON like `user`/`org`. Only `version` is read here, to re-check
    /// compatibility from our side against a server too old to gate us.
    #[serde(default)]
    pub server: Option<serde_json::Value>,
}

impl LoginResponse {
    /// The version string the server reported, if it sent one. A server that
    /// omits it predates the field entirely.
    fn server_version(&self) -> Option<&str> {
        self.server.as_ref()?.get("version")?.as_str()
    }
}

/// One project card, as returned by `GET /api/projects` (see
/// `server.py::project_summary`).
#[derive(Debug, Clone, Deserialize)]
pub struct ProjectInfo {
    pub id: String,
    pub name: String,
    #[serde(rename = "sizeBytes", default)]
    pub size_bytes: u64,
    #[serde(rename = "memberCount", default)]
    pub member_count: u64,
    #[serde(rename = "lastCommitAt", default)]
    pub last_commit_at: Option<String>,
}

/// One entry of `GET /api/locks`' `locks` map (path -> holder).
#[derive(Debug, Clone, Deserialize)]
pub struct LockInfo {
    pub username: String,
    #[serde(rename = "deviceId")]
    pub device_id: String,
    #[serde(rename = "deviceName")]
    pub device_name: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct SubmitChange {
    pub path: String,
    /// "added" | "edited" | "deleted"
    pub change: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hash: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none", rename = "baseHash")]
    pub base_hash: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SubmitPayload {
    pub description: String,
    pub changes: Vec<SubmitChange>,
    pub folders: Vec<String>,
}

#[derive(Debug)]
pub struct ApiClient {
    pub base_url: String,
    pub host: String,
    pub token: Option<String>,
    pub project_id: Option<String>,
    pin: Option<String>,
}

impl ApiClient {
    /// Bare `host:port` defaults to `https://`; an explicit `http://` (or
    /// `https://`) prefix is honored as-is (dev servers with disable_tls) —
    /// same convention as `client.js`'s constructor. Eagerly validates the
    /// pin/TLS setup via `pinned_http::build_client` so a bad pin or a
    /// pin-less https target fails fast at construction rather than on the
    /// first request.
    pub fn new(server: &str, pin: Option<String>) -> Result<Self, ApiError> {
        let base_url = if server.starts_with("http://") || server.starts_with("https://") {
            server.to_string()
        } else {
            format!("https://{server}")
        };
        let host = base_url
            .trim_start_matches("https://")
            .trim_start_matches("http://")
            .to_string();
        pinned_http::shared_client(&base_url, pin.as_deref()).map_err(map_build_error)?;
        Ok(Self { base_url, host, token: None, project_id: None, pin })
    }

    /// Headers the native layer attaches when it streams file content/upload
    /// bodies on our behalf (those routes don't go through `send_json`) —
    /// same role as `client.js`'s `authHeaders()`.
    ///
    /// The client version rides on every request, not just the handshake: the
    /// server gates each one on it (see `version`), and the streaming
    /// transfer routes bypass `send_json` entirely, so stamping it here is
    /// what keeps those routes from being refused.
    pub fn auth_headers(&self) -> HashMap<String, String> {
        let mut headers = HashMap::new();
        headers.insert(
            version::CLIENT_VERSION_HEADER.to_string(),
            version::CLIENT_VERSION.to_string(),
        );
        if let Some(token) = &self.token {
            headers.insert("Authorization".to_string(), format!("Bearer {token}"));
        }
        if let Some(project_id) = &self.project_id {
            headers.insert("X-Project".to_string(), project_id.clone());
        }
        headers
    }

    /// Core request path shared by every endpoint method: fetches the shared
    /// pooled client for this server identity carrying the current auth
    /// headers, sends `json_body` (if any) as the request body, and
    /// classifies the response. 2xx -> `Ok(body)`; 401 -> `ApiError::Auth`;
    /// any other non-2xx -> `ApiError::Server` with the server's `{error}`
    /// message (or a synthesized one if the body isn't JSON/doesn't have
    /// that field).
    async fn send_json(
        &self,
        method: reqwest::Method,
        path: &str,
        json_body: Option<&serde_json::Value>,
    ) -> Result<serde_json::Value, ApiError> {
        let url = format!("{}{}", self.base_url, path);
        let header_map = build_headers(&self.auth_headers());
        let client = pinned_http::shared_client(&url, self.pin.as_deref())
            .map_err(map_build_error)?;
        let mut req = client.request(method, &url).headers(header_map);
        if let Some(body) = json_body {
            // reqwest's "json" feature isn't enabled (see Cargo.toml) — we
            // already depend on serde_json directly, so serialize by hand
            // rather than pull in the extra feature.
            let bytes = serde_json::to_vec(body).map_err(|e| ApiError::Server {
                status: 0,
                message: format!("invalid request body: {e}"),
            })?;
            req = req.header("Content-Type", "application/json").body(bytes);
        }
        let resp = req.send().await.map_err(map_transport_error)?;
        let status = resp.status().as_u16();
        let text = resp.text().await.map_err(map_transport_error)?;
        let value: serde_json::Value =
            serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
        if (200..300).contains(&status) {
            Ok(value)
        } else {
            let message = value
                .get("error")
                .and_then(|v| v.as_str())
                .map(str::to_string)
                .unwrap_or_else(|| format!("Server error ({status})"));
            if status == 401 {
                Err(ApiError::Auth(message))
            } else if status == 426 {
                // The server refused us on major-version grounds. Its message
                // is already the user-facing explanation — pass it through
                // rather than restating it from our side.
                Err(ApiError::Incompatible(message))
            } else {
                Err(ApiError::Server { status, message })
            }
        }
    }

    /// Refuse a session whose server is on a different major version, before
    /// the caller adopts its token.
    ///
    /// The server gates us as well (426 on every request), so in practice
    /// this only fires against a server old enough to predate that gate —
    /// which is exactly the case where our side has to be the one to stop.
    /// A response with no `server.version` at all is let through: the field
    /// is what identifies a Cambium server's generation, and absence means a
    /// payload from before it existed rather than a version we've judged and
    /// rejected. The server's own check has no such gap — it refuses a
    /// request with no `X-Cambium-Client` header outright.
    fn guard_server_version(login: &LoginResponse) -> Result<(), ApiError> {
        match login.server_version() {
            Some(reported) if !version::is_compatible(version::CLIENT_VERSION, reported) => {
                Err(ApiError::Incompatible(version::incompatibility_message(reported)))
            }
            _ => Ok(()),
        }
    }

    fn parse<T: serde::de::DeserializeOwned>(value: serde_json::Value) -> Result<T, ApiError> {
        serde_json::from_value(value).map_err(|e| ApiError::Server {
            status: 0,
            message: format!("invalid response: {e}"),
        })
    }

    pub async fn login(
        &mut self,
        username: &str,
        password: &str,
        device_id: &str,
        device_name: &str,
        remember: bool,
    ) -> Result<LoginResponse, ApiError> {
        let body = serde_json::json!({
            "username": username,
            "password": password,
            "deviceId": device_id,
            "deviceName": device_name,
            "remember": remember,
        });
        let value = self
            .send_json(reqwest::Method::POST, "/api/login", Some(&body))
            .await?;
        let login: LoginResponse = Self::parse(value)?;
        Self::guard_server_version(&login)?;
        self.token = Some(login.token.clone());
        Ok(login)
    }

    /// Exchange a stored remember-token for a fresh session token.
    ///
    /// The counterpart of `login` for a client that has been remembered:
    /// same outcome, but the caller never holds the user's password. The
    /// server does not rotate the remember-token, so the caller's stored
    /// copy stays valid and must not be rewritten from the response.
    pub async fn resume(
        &mut self,
        username: &str,
        remember_token: &str,
        device_id: &str,
    ) -> Result<LoginResponse, ApiError> {
        let body = serde_json::json!({
            "username": username,
            "rememberToken": remember_token,
            "deviceId": device_id,
        });
        let value = self
            .send_json(reqwest::Method::POST, "/api/session/resume", Some(&body))
            .await?;
        let resumed: LoginResponse = Self::parse(value)?;
        Self::guard_server_version(&resumed)?;
        self.token = Some(resumed.token.clone());
        Ok(resumed)
    }

    pub async fn logout(&self) -> Result<(), ApiError> {
        self.send_json(reqwest::Method::POST, "/api/logout", Some(&serde_json::json!({})))
            .await?;
        Ok(())
    }

    pub async fn projects(&self) -> Result<Vec<ProjectInfo>, ApiError> {
        #[derive(Deserialize)]
        struct Resp {
            projects: Vec<ProjectInfo>,
        }
        let value = self.send_json(reqwest::Method::GET, "/api/projects", None).await?;
        Ok(Self::parse::<Resp>(value)?.projects)
    }

    pub async fn depot_tree(&self) -> Result<serde_json::Value, ApiError> {
        self.send_json(reqwest::Method::GET, "/api/depot/tree", None).await
    }

    pub async fn locks(&self) -> Result<BTreeMap<String, LockInfo>, ApiError> {
        #[derive(Deserialize)]
        struct Resp {
            locks: BTreeMap<String, LockInfo>,
        }
        let value = self.send_json(reqwest::Method::GET, "/api/locks", None).await?;
        Ok(Self::parse::<Resp>(value)?.locks)
    }

    pub async fn users(&self) -> Result<serde_json::Value, ApiError> {
        self.send_json(reqwest::Method::GET, "/api/users", None).await
    }

    pub async fn ignore_text(&self) -> Result<String, ApiError> {
        #[derive(Deserialize)]
        struct Resp {
            text: String,
        }
        let value = self.send_json(reqwest::Method::GET, "/api/ignore", None).await?;
        Ok(Self::parse::<Resp>(value)?.text)
    }

    pub async fn checkout(&self, paths: &[String]) -> Result<serde_json::Value, ApiError> {
        let body = serde_json::json!({ "paths": paths });
        self.send_json(reqwest::Method::POST, "/api/files/checkout", Some(&body)).await
    }

    pub async fn release(&self, paths: &[String]) -> Result<serde_json::Value, ApiError> {
        let body = serde_json::json!({ "paths": paths });
        self.send_json(reqwest::Method::POST, "/api/files/release", Some(&body)).await
    }

    pub async fn force_unlock(&self, paths: &[String]) -> Result<serde_json::Value, ApiError> {
        let body = serde_json::json!({ "paths": paths });
        self.send_json(reqwest::Method::POST, "/api/files/unlock", Some(&body)).await
    }

    pub async fn submit(&self, payload: &SubmitPayload) -> Result<serde_json::Value, ApiError> {
        let body = serde_json::to_value(payload).map_err(|e| ApiError::Server {
            status: 0,
            message: format!("invalid payload: {e}"),
        })?;
        self.send_json(reqwest::Method::POST, "/api/submit", Some(&body)).await
    }

    pub async fn rollback(&self, path: &str, revision: u64) -> Result<serde_json::Value, ApiError> {
        let body = serde_json::json!({ "path": path, "revision": revision });
        self.send_json(reqwest::Method::POST, "/api/rollback", Some(&body)).await
    }

    // ---- admin: users, roles & memberships (Task 14 REST endpoints) ----------

    /// `POST /api/admin/users` — create a user, or reinstate a disabled handle.
    /// Response carries `created: true` OR `reinstated: true`.
    pub async fn admin_add_user(
        &self,
        handle: &str,
        password: &str,
    ) -> Result<serde_json::Value, ApiError> {
        let body = serde_json::json!({ "handle": handle, "password": password });
        self.send_json(reqwest::Method::POST, "/api/admin/users", Some(&body)).await
    }

    /// `DELETE /api/admin/users/<handle>` — disable a user, dropping their sessions.
    pub async fn admin_delete_user(&self, handle: &str) -> Result<serde_json::Value, ApiError> {
        let path = format!("/api/admin/users/{}", encode_uri_component(handle));
        self.send_json(reqwest::Method::DELETE, &path, None).await
    }

    /// `PUT /api/admin/users/<handle>/password` — reset ANOTHER user's password
    /// (the server refuses resetting the owner's password unless the caller is
    /// the owner).
    pub async fn admin_set_password(
        &self,
        handle: &str,
        password: &str,
    ) -> Result<serde_json::Value, ApiError> {
        let path = format!("/api/admin/users/{}/password", encode_uri_component(handle));
        let body = serde_json::json!({ "password": password });
        self.send_json(reqwest::Method::PUT, &path, Some(&body)).await
    }

    /// `POST /api/account/password` — change your OWN password (needs the
    /// current one). The self-service twin of `admin_set_password`.
    pub async fn account_password(
        &self,
        old_password: &str,
        new_password: &str,
    ) -> Result<serde_json::Value, ApiError> {
        let body = serde_json::json!({ "oldPassword": old_password, "newPassword": new_password });
        self.send_json(reqwest::Method::POST, "/api/account/password", Some(&body)).await
    }

    /// `PUT /api/admin/users/<handle>/role` — set a user's role ("member" | "admin").
    pub async fn admin_set_role(
        &self,
        handle: &str,
        role: &str,
    ) -> Result<serde_json::Value, ApiError> {
        let path = format!("/api/admin/users/{}/role", encode_uri_component(handle));
        let body = serde_json::json!({ "role": role });
        self.send_json(reqwest::Method::PUT, &path, Some(&body)).await
    }

    /// `POST /api/admin/projects/<id>/members` with `member: true` — add a user
    /// to a project's roster (the admin twin of the invite-code join flow).
    pub async fn admin_add_member(
        &self,
        project_id: &str,
        handle: &str,
    ) -> Result<serde_json::Value, ApiError> {
        let path = format!("/api/admin/projects/{}/members", encode_uri_component(project_id));
        let body = serde_json::json!({ "username": handle, "member": true });
        self.send_json(reqwest::Method::POST, &path, Some(&body)).await
    }

    /// `DELETE /api/admin/projects/<id>/members` — remove a user from a
    /// project's roster.
    pub async fn admin_remove_member(
        &self,
        project_id: &str,
        handle: &str,
    ) -> Result<serde_json::Value, ApiError> {
        let path = format!("/api/admin/projects/{}/members", encode_uri_component(project_id));
        let body = serde_json::json!({ "handle": handle });
        self.send_json(reqwest::Method::DELETE, &path, Some(&body)).await
    }

    /// `POST /api/admin/projects` — create an empty project. The server owns
    /// name validation (duplicates, unusable names) and reports refusals as a
    /// 400, which `map_api_error` surfaces verbatim.
    pub async fn admin_create_project(&self, name: &str) -> Result<serde_json::Value, ApiError> {
        let body = serde_json::json!({ "name": name });
        self.send_json(reqwest::Method::POST, "/api/admin/projects", Some(&body)).await
    }

    /// `DELETE /api/admin/projects/<id>` — irreversibly delete a project and
    /// its depot. Owner-only on the server (403 for a plain admin), unlike
    /// every other admin project call here.
    pub async fn admin_delete_project(&self, project_id: &str) -> Result<serde_json::Value, ApiError> {
        let path = format!("/api/admin/projects/{}", encode_uri_component(project_id));
        self.send_json(reqwest::Method::DELETE, &path, None).await
    }

    /// `GET /api/admin/projects` — every project on the server (admin-gated),
    /// the roster the admin membership verbs resolve a project NAME against
    /// (unlike `projects()`, which only lists the caller's own projects).
    pub async fn admin_projects(&self) -> Result<Vec<ProjectInfo>, ApiError> {
        #[derive(Deserialize)]
        struct Resp {
            projects: Vec<ProjectInfo>,
        }
        let value = self.send_json(reqwest::Method::GET, "/api/admin/projects", None).await?;
        Ok(Self::parse::<Resp>(value)?.projects)
    }

    /// Direct download URL for a depot file's head content — consumed by the
    /// native layer, which attaches `auth_headers()` itself.
    pub fn file_content_url(&self, path: &str) -> String {
        format!("{}/api/files/content?path={}", self.base_url, encode_uri_component(path))
    }

    /// Raw-body staging upload URL, streamed by the native layer ahead of a submit.
    pub fn file_upload_url(&self) -> String {
        format!("{}/api/files/upload", self.base_url)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Arc, Mutex};

    /// Background tiny_http server that answers a fixed sequence of routes
    /// (matched by exact request path, query string included) in order,
    /// capturing the headers and JSON request bodies seen on each so tests
    /// can assert on them.
    struct FakeServer {
        base_url: String,
        seen_headers: Arc<Mutex<Vec<HashMap<String, String>>>>,
        /// Parsed JSON body of each request, in arrival order (parallel to
        /// `seen_headers`). An empty or non-JSON body is recorded as `Null`
        /// rather than dropped, so index alignment with `seen_headers` still
        /// holds.
        seen_bodies: Arc<Mutex<Vec<serde_json::Value>>>,
    }

    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();
        let seen_headers = Arc::new(Mutex::new(Vec::new()));
        let seen_bodies = Arc::new(Mutex::new(Vec::new()));
        let seen_headers_thread = seen_headers.clone();
        let seen_bodies_thread = seen_bodies.clone();
        std::thread::spawn(move || {
            for mut req in server.incoming_requests() {
                // Header names are case-insensitive over the wire (reqwest
                // lowercases them); normalize so lookups below don't have to
                // guess the wire casing.
                let mut headers = HashMap::new();
                for h in req.headers() {
                    headers.insert(h.field.to_string().to_lowercase(), h.value.as_str().to_string());
                }
                seen_headers_thread.lock().unwrap().push(headers);

                let mut raw_body = Vec::new();
                let _ = req.as_reader().read_to_end(&mut raw_body);
                let body_value = if raw_body.is_empty() {
                    serde_json::Value::Null
                } else {
                    serde_json::from_slice(&raw_body).unwrap_or(serde_json::Value::Null)
                };
                seen_bodies_thread.lock().unwrap().push(body_value);

                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}"), seen_headers, seen_bodies }
    }

    #[tokio::test]
    async fn login_stores_token_and_subsequent_request_carries_auth_headers() {
        let server = spawn_fake_server(vec![
            ("/api/login", 200, r#"{"token":"tok-123"}"#),
            ("/api/projects", 200, r#"{"projects":[]}"#),
        ]);
        let mut client = ApiClient::new(&server.base_url, None).unwrap();
        client.project_id = Some("proj-1".to_string());

        let login = client.login("alice", "hunter2", "dev-1", "Alice's Laptop", false).await.unwrap();
        assert_eq!(login.token, "tok-123");
        assert_eq!(client.token.as_deref(), Some("tok-123"));

        let projects = client.projects().await.unwrap();
        assert!(projects.is_empty());

        let seen = server.seen_headers.lock().unwrap();
        assert_eq!(seen.len(), 2);
        // First request (login) carries no auth yet.
        assert!(!seen[0].contains_key("authorization"));
        // Second request carries the token just stored, plus X-Project.
        assert_eq!(seen[1].get("authorization"), Some(&"Bearer tok-123".to_string()));
        assert_eq!(seen[1].get("x-project"), Some(&"proj-1".to_string()));
    }

    #[tokio::test]
    async fn non_2xx_maps_to_server_error_with_servers_message() {
        let server = spawn_fake_server(vec![(
            "/api/files/checkout",
            409,
            r#"{"error":"Cannot check out: file already checked out."}"#,
        )]);
        let client = ApiClient::new(&server.base_url, None).unwrap();

        let err = client.checkout(&["a.txt".to_string()]).await.unwrap_err();
        assert_eq!(
            err,
            ApiError::Server {
                status: 409,
                message: "Cannot check out: file already checked out.".to_string(),
            }
        );
    }

    #[tokio::test]
    async fn unauthorized_maps_to_auth_error() {
        let server = spawn_fake_server(vec![(
            "/api/login",
            401,
            r#"{"error":"Invalid username or password."}"#,
        )]);
        let mut client = ApiClient::new(&server.base_url, None).unwrap();

        let err = client.login("alice", "wrong", "dev-1", "Alice's Laptop", false).await.unwrap_err();
        assert_eq!(err, ApiError::Auth("Invalid username or password.".to_string()));
        assert_eq!(client.token, None);
    }

    #[tokio::test]
    async fn connection_refused_maps_to_connect_error() {
        // Bind then immediately drop a listener to reserve a port that is
        // guaranteed to refuse the next connection attempt.
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        drop(listener);

        let client = ApiClient::new(&format!("http://127.0.0.1:{port}"), None).unwrap();
        let err = client.projects().await.unwrap_err();
        assert!(matches!(err, ApiError::Connect(_)), "expected Connect, got {err:?}");
    }

    #[test]
    fn new_defaults_bare_host_to_https_and_strips_scheme_for_host() {
        let client = ApiClient::new("example.com:9443", None).unwrap_err();
        // https without a pin is refused at construction (fail fast).
        assert!(matches!(client, ApiError::Pin(_)));

        let client = ApiClient::new("http://example.com:8666", None).unwrap();
        assert_eq!(client.base_url, "http://example.com:8666");
        assert_eq!(client.host, "example.com:8666");
    }

    #[test]
    fn file_content_url_percent_encodes_the_path() {
        let client = ApiClient::new("http://127.0.0.1:9", None).unwrap();
        assert_eq!(
            client.file_content_url("art/models/hero v2.fbx"),
            "http://127.0.0.1:9/api/files/content?path=art%2Fmodels%2Fhero%20v2.fbx"
        );
        assert_eq!(client.file_upload_url(), "http://127.0.0.1:9/api/files/upload");
    }

    #[tokio::test]
    async fn resume_exchanges_a_remember_token_for_a_session() {
        let server = spawn_fake_server(vec![(
            "/api/session/resume",
            200,
            r#"{"token":"tok-resumed","user":{"username":"@gina"}}"#,
        )]);
        let mut client = ApiClient::new(&server.base_url, None).unwrap();
        let out = client.resume("@gina", "remember-me", "dev-1").await.unwrap();
        assert_eq!(out.token, "tok-resumed");
        assert_eq!(client.token.as_deref(), Some("tok-resumed"));

        // The request body must carry exactly the three fields the server's
        // /api/session/resume expects, under the exact JSON key names it
        // reads — and must NOT carry a password, since the whole point of
        // resume() is that the caller never holds one.
        let bodies = server.seen_bodies.lock().unwrap();
        assert_eq!(bodies.len(), 1);
        assert_eq!(
            bodies[0],
            serde_json::json!({
                "username": "@gina",
                "rememberToken": "remember-me",
                "deviceId": "dev-1",
            })
        );
        assert!(bodies[0].get("password").is_none());
    }

    #[tokio::test]
    async fn login_surfaces_the_remember_token_when_the_server_sends_one() {
        let server = spawn_fake_server(vec![(
            "/api/login",
            200,
            r#"{"token":"tok","user":{"username":"@gina"},"rememberToken":"rt-1"}"#,
        )]);
        let mut client = ApiClient::new(&server.base_url, None).unwrap();
        let out = client.login("@gina", "pw", "dev-1", "box", true).await.unwrap();
        assert_eq!(out.remember_token.as_deref(), Some("rt-1"));

        // Proves the `remember: true` argument actually reaches the server
        // as `"remember": true` in the JSON body, not just that the client
        // accepts the canned response.
        let bodies = server.seen_bodies.lock().unwrap();
        assert_eq!(bodies[0].get("remember"), Some(&serde_json::Value::Bool(true)));
    }

    #[tokio::test]
    async fn login_without_remember_has_no_token() {
        let server = spawn_fake_server(vec![(
            "/api/login",
            200,
            r#"{"token":"tok","user":{"username":"@gina"}}"#,
        )]);
        let mut client = ApiClient::new(&server.base_url, None).unwrap();
        let out = client.login("@gina", "pw", "dev-1", "box", false).await.unwrap();
        assert!(out.remember_token.is_none());

        // Proves the server actually received `"remember": false` — not
        // merely that the field was omitted. A body that dropped the field
        // entirely would fail this assertion (`None != Some(Bool(false))`).
        let bodies = server.seen_bodies.lock().unwrap();
        assert_eq!(bodies[0].get("remember"), Some(&serde_json::Value::Bool(false)));
    }
}
