//! Task 10: session & context verbs (`login`, `logout`, `trust`, `whoami`,
//! `projects`, `link`, `use`) — the first slice of the real command
//! executor. `run` is the single dispatch point both the `cambium` CLI
//! binary and (eventually) the desktop console panel call; it replaces
//! `commands::run_not_wired` for the seven verbs above and falls back to it
//! for everything Tasks 11-15 haven't wired up yet.
//!
//! This module never touches stdin/stdout directly (no `rpassword`, no
//! `read_line`): it is linked into the desktop app too, which has no
//! console to prompt on. Interactive prompting (the trust-on-first-use
//! y/N prompt, the password prompt) lives in `cambium-cli`'s `main.rs`,
//! which calls the lower-level `auth` functions directly for those two
//! cases *instead of* `run` when `ExecContext::interactive` is true. Every
//! other case — every verb besides `login`/`trust`, and any CLI run that
//! isn't attached to a terminal (piped, CI, or the desktop console) — goes
//! through `run`, which resolves auth non-interactively: `NeedsTrust`
//! becomes an actionable code-4 error naming `trust --fingerprint`,
//! `NeedsPassword` becomes an actionable code-3 error naming
//! `CAMBIUM_PASSWORD`.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use crate::api::{ApiClient, ApiError, SubmitChange, SubmitPayload};
use crate::auth::{self, AuthOutcome, AuthRequest};
use crate::commands::{Cli, Cmd, CommandOutput};
use crate::config::{
    self, CambiumDirs, Changelist, DeviceIdentity, PinStore, SessionCache, WorkspaceEntry,
    WorkspacesFile,
};
use crate::hashcache;
use crate::ignore::IgnoreMatcher;
use crate::paths;
use crate::pinned_http;
use crate::safenames;
use crate::status::{self as status_engine, FileStatus, LocalFile};
use crate::transfer;
use crate::workspace::{self, TreeNode};

pub struct ExecContext {
    pub dirs: CambiumDirs,
    /// `None` until a session resolves (either this run, or never — most
    /// verbs that don't need the network leave this `None` the whole time).
    pub api: Option<ApiClient>,
    pub me: Option<DeviceIdentity>,
    /// (id, name) of the resolved project, once a verb has resolved one.
    pub project: Option<(String, String)>,
    pub workspace: Option<PathBuf>,
    /// True only for a real CLI invocation attached to a terminal on both
    /// ends. False for the desktop console panel and for any non-TTY CLI
    /// run (piped, scripted, CI) — those never prompt, they fail actionably.
    pub interactive: bool,
    /// Where `pull`/`submit` report transfer progress. `None` for the `cambium`
    /// CLI (no progress UI yet) and for most tests; the desktop console wires
    /// this to the Tauri IPC channel so the GUI's toolbar progress strip can
    /// track a console-driven pull/submit the same as a native one.
    pub progress: transfer::ProgressSink,
}

// --------------------------------------------------------------- dispatch

/// Single entry point for both front ends. Handles the seven verbs of Task
/// 10; every other verb still routes through `commands::run_not_wired`
/// until Tasks 11-15 land.
pub async fn run(cli: Cli, ctx: &mut ExecContext) -> CommandOutput {
    match &cli.cmd {
        Cmd::Login { .. } => login(&cli, ctx).await,
        Cmd::Logout => logout(ctx).await,
        Cmd::Trust { .. } => trust(&cli, ctx).await,
        Cmd::Whoami => whoami(&cli, ctx),
        Cmd::Projects => projects(ctx).await,
        Cmd::Link { .. } => link(&cli, ctx).await,
        Cmd::Use { .. } => use_project(&cli, ctx).await,
        Cmd::Status { .. } => status(&cli, ctx).await,
        Cmd::Locks { .. } => locks(&cli, ctx).await,
        Cmd::Checkout { .. } => checkout(&cli, ctx).await,
        Cmd::Add { .. } => add(&cli, ctx).await,
        Cmd::Delete { .. } => delete(&cli, ctx).await,
        Cmd::Revert { .. } => revert(&cli, ctx).await,
        Cmd::Submit { .. } => submit(&cli, ctx).await,
        Cmd::Pull { .. } => pull(&cli, ctx).await,
        Cmd::Rollback { .. } => rollback(&cli, ctx).await,
        Cmd::Unlock { .. } => unlock(&cli, ctx).await,
        Cmd::Adduser { .. } => adduser(&cli, ctx).await,
        Cmd::Deluser { .. } => deluser(&cli, ctx).await,
        Cmd::Password { .. } => password(&cli, ctx).await,
        Cmd::Setrole { .. } => setrole(&cli, ctx).await,
        Cmd::Assignmember { .. } => assignmember(&cli, ctx).await,
        Cmd::Removemember { .. } => removemember(&cli, ctx).await,
    }
}

// ------------------------------------------------------------- resolve_project

/// Resolves the (project id, project name, workspace folder) a path-taking
/// verb should act against: `-p/--project <name>` if given, else the linked
/// workspace `cwd` sits inside, else the active project from
/// `workspaces.json` — in that order. A code-5 context error, naming both
/// fixes, if none of those apply.
pub fn resolve_project(
    ctx: &ExecContext,
    flag: &Option<String>,
    cwd: &Path,
) -> Result<(String, String, PathBuf), CommandOutput> {
    let not_linked = || {
        output_error(
            5,
            "not inside a linked workspace; run 'link <project> <folder>' or 'use <project>'."
                .to_string(),
        )
    };

    // Pre-seeded context (the desktop console passes the GUI's current
    // project+workspace on ExecContext): trust it directly, so console
    // commands act on exactly what the app is showing rather than re-deriving
    // it from workspaces.json/cwd. An explicit `-p` override still goes through
    // the workspaces.json lookup below. The CLI leaves both `None`, so this is
    // inert there.
    if flag.is_none() {
        if let (Some((id, name)), Some(ws)) = (&ctx.project, &ctx.workspace) {
            return Ok((id.clone(), name.clone(), ws.clone()));
        }
    }

    let workspaces = WorkspacesFile::load(&ctx.dirs)
        .map_err(|e| output_error(5, format!("failed to read workspaces: {e}")))?;

    if let Some(name) = flag {
        return workspaces
            .workspaces
            .iter()
            .find(|(_, entry)| &entry.name == name)
            .map(|(id, entry)| (id.clone(), entry.name.clone(), PathBuf::from(&entry.folder)))
            .ok_or_else(|| {
                output_error(
                    5,
                    format!(
                        "'{name}' is not a linked workspace; run 'link {name} <folder>' first."
                    ),
                )
            });
    }

    if let Some((id, entry)) = workspaces
        .workspaces
        .iter()
        .find(|(_, entry)| cwd.starts_with(&entry.folder))
    {
        return Ok((id.clone(), entry.name.clone(), PathBuf::from(&entry.folder)));
    }

    if let Some(active_id) = &workspaces.active_project {
        if let Some(entry) = workspaces.workspaces.get(active_id) {
            return Ok((
                active_id.clone(),
                entry.name.clone(),
                PathBuf::from(&entry.folder),
            ));
        }
    }

    Err(not_linked())
}

// ----------------------------------------------------------------- helpers

pub fn output_ok(human: String, data: serde_json::Value) -> CommandOutput {
    CommandOutput { ok: true, code: 0, human, data, changed: Vec::new() }
}

pub fn output_error(code: i32, human: String) -> CommandOutput {
    CommandOutput { ok: false, code, human, data: serde_json::Value::Null, changed: Vec::new() }
}

/// Maps a live-request `ApiError` to (exit code, message). `Auth` (401) is a
/// credential problem (3); `Pin`/`Connect`/`Incompatible` are
/// connectivity/identity problems (4) — an incompatible server is one this
/// build cannot talk to at all, which is the same class of "this connection
/// is not going to happen" as a refused socket; a non-401 `Server` error is
/// the server refusing the operation for a business reason (1), same bucket
/// as the `-f` class.
pub fn map_api_error(err: &ApiError) -> (i32, String) {
    match err {
        ApiError::Auth(msg) => (3, format!("authentication failed: {msg}")),
        ApiError::Pin(msg) => (4, format!("server identity check failed: {msg}")),
        ApiError::Connect(msg) => (4, format!("connection failed: {msg}")),
        // Already a complete, user-facing sentence naming which side is
        // behind (see version::incompatibility_message) — don't re-wrap it.
        ApiError::Incompatible(msg) => (4, msg.clone()),
        ApiError::Server { status, message } => (1, format!("server error ({status}): {message}")),
    }
}

fn env_server() -> Option<String> {
    std::env::var("CAMBIUM_SERVER").ok()
}

fn env_user() -> Option<String> {
    std::env::var("CAMBIUM_USER").ok()
}

/// `--server`/`--user` on `login`, falling back to `CAMBIUM_SERVER`/
/// `CAMBIUM_USER`. Exposed so the CLI's interactive login loop (which
/// bypasses `run` to do its own prompting) resolves the same target `run`
/// would have.
pub fn login_target(cli: &Cli) -> (Option<String>, Option<String>) {
    match &cli.cmd {
        Cmd::Login { server, user, .. } => {
            (server.clone().or_else(env_server), user.clone().or_else(env_user))
        }
        _ => (env_server(), env_user()),
    }
}

/// Whether `login` was asked to remember this device (issue a remember-token).
pub fn login_remember(cli: &Cli) -> bool {
    matches!(&cli.cmd, Cmd::Login { remember: true, .. })
}

/// Store the server-issued remember-token for `session.json`'s (host, user)
/// so later commands resume without prompting.
///
/// This is deliberately not the user's password: the token is bound to this
/// device, expires on its own, and can be revoked server-side by removing
/// the device or changing the password.
///
/// Returns the write outcome instead of swallowing it: a failure here must
/// never fail a login that already succeeded (the caller still reports the
/// login as OK), but the server has already REPLACED whatever token was
/// stored before with this new one (see store.py's `issue_remember_token`)
/// — so a failed write here doesn't just lose the new token, it strands the
/// user with no working credential at all, and no way to know why unless the
/// caller surfaces this `Err` to them.
pub fn remember_token(ctx: &ExecContext, token: &str) -> Result<(), String> {
    match SessionCache::load(&ctx.dirs) {
        Ok(Some(session)) => {
            let account = auth::credential_account_key(&bare_host(&session.host), &session.username);
            crate::secrets::save_secret_in(&ctx.dirs, &account, token)
        }
        Ok(None) => Err("no session to bind the remember-token to".to_string()),
        Err(e) => Err(format!("failed to read session: {e}")),
    }
}

/// What happened to `--remember` this login, once the login request itself
/// has already succeeded. `login_success` reports this honestly instead of
/// assuming the request became the outcome — the bug this type exists to
/// close: `remembered` was previously derived from whether `--remember` was
/// PASSED, not from whether a token was actually stored, so a login could
/// print "remembered" while storing nothing (e.g. the server's
/// device-limit-reached response carries a session but no `rememberToken`).
///
/// Distinguishes "wasn't asked for" from the two ways an explicit request
/// can end up not remembered — the server declining to mint a token this run
/// (`NoToken`) and a local write failure after the server DID mint one
/// (`StoreFailed`) — because they need different messages: `NoToken` is
/// nothing to retry locally, `StoreFailed` is a local problem worth naming.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RememberOutcome {
    /// `--remember` wasn't passed.
    NotRequested,
    /// Requested; the server issued a token and this run saved it locally.
    Stored,
    /// Requested, but the server issued no token this run (a
    /// device-limit-reached login, `issue_remember_token` returning `None`,
    /// or an older server that doesn't know the field). Nothing to save.
    NoToken,
    /// Requested, the server issued a token, but saving it locally failed
    /// (the message from `remember_token`/`secrets::save_secret_in`).
    StoreFailed(String),
}

/// The single place all three login call sites (this module's `login()`, and
/// `cambium-cli`'s `main.rs` interactive `Ready`/`NeedsPassword` arms) decide
/// what to report for `--remember`, so none of them can independently
/// under- or over-report success the way unconditional `remembered = true`
/// did before. `requested` is whatever the call site already knows about
/// whether the user asked for `--remember` (typically `login_remember(cli)`,
/// which every UseEnvPassword-reaching arm already established as `true`);
/// `response_token` is straight from the `LoginResponse` the server sent
/// back for THIS run.
pub fn record_remember_outcome(
    ctx: &ExecContext,
    requested: bool,
    response_token: Option<&str>,
) -> RememberOutcome {
    if !requested {
        return RememberOutcome::NotRequested;
    }
    match response_token {
        None => RememberOutcome::NoToken,
        Some(token) => match remember_token(ctx, token) {
            Ok(()) => RememberOutcome::Stored,
            Err(e) => RememberOutcome::StoreFailed(e),
        },
    }
}

/// Host to run `trust` against: the explicit flag/positional, else
/// `CAMBIUM_SERVER`, else the host already saved in `session.json`. Shared
/// by the non-interactive `trust` verb and the CLI's interactive trust
/// prompt.
pub fn resolve_trust_host(ctx: &ExecContext, host_flag: &Option<String>) -> Option<String> {
    host_flag
        .clone()
        .or_else(env_server)
        .or_else(|| SessionCache::load(&ctx.dirs).ok().flatten().map(|s| s.host))
}

/// Strips a `http://`/`https://` prefix — `pinned_http::fetch_server_certificate`
/// wants a bare `host:port`.
pub fn bare_host(host: &str) -> String {
    host.trim_start_matches("https://").trim_start_matches("http://").to_string()
}

/// `'@robin'` / `'robin'` / `'@@robin'` -> `'@robin'`; `''` / `'@'` -> `''`.
/// The Rust twin of server.py's `_normalize_handle`, applied CLI-side so
/// `adduser robin ...` works as well as `adduser @robin ...`.
pub fn normalize_handle(raw: &str) -> String {
    let stripped = raw.replace('@', "");
    if stripped.is_empty() {
        String::new()
    } else {
        format!("@{stripped}")
    }
}

/// Whether `handle` names the currently-logged-in user (case-insensitive on
/// the session's canonical `@handle`). Used by the CLI's `main.rs` to decide
/// whether `password <handle>` is a self-service change (needs the current
/// password) versus an admin reset of someone else's password.
pub fn is_own_handle(ctx: &ExecContext, handle: &str) -> bool {
    SessionCache::load(&ctx.dirs)
        .ok()
        .flatten()
        .map(|s| s.username.eq_ignore_ascii_case(&normalize_handle(handle)))
        .unwrap_or(false)
}

/// The `-f` refusal for a `rollback` that would clobber real local edits to
/// the target file — same shape as `pull`'s refusal, with "roll back" as the
/// verb. Pure, unit-tested.
fn rollback_overwrite_refusal(path: &str) -> String {
    format!("refusing to roll back: this would overwrite local changes to {path}; pass -f to overwrite.")
}

/// `rollback`'s success line: names the target revision the user asked for and
/// the new revision the forward-commit landed as. Pure, unit-tested.
fn rollback_success_message(path: &str, requested: u64, committed: Option<u64>) -> String {
    match committed {
        Some(rev) => format!("Rolled back {path} to revision {requested} (now revision {rev})."),
        None => format!("Rolled back {path} to revision {requested}."),
    }
}

/// The grammar-level refusal for `unlock` invoked without `-f`. Pure so the
/// exact spec wording is asserted without a server.
fn unlock_needs_force_message() -> String {
    "unlock force-releases another user's lock; pass -f to confirm.".to_string()
}

/// Human/JSON success output for a completed login — shared by `run`'s
/// non-interactive path and the CLI's interactive loop, both of which set
/// `ctx.api`/`ctx.me` and then call this to render the same message.
///
/// `remember` is what THIS run actually did with `--remember` (see
/// `RememberOutcome`) — not merely whether the flag was passed. A caller
/// that stored nothing must not pass `Stored`, so the human line and the
/// `remembered` JSON field never claim a remembering that didn't happen.
/// Login itself is unconditionally a success here regardless of `remember`:
/// a `NoToken`/`StoreFailed` outcome means a convenience feature didn't
/// engage, not that authentication failed — the session this call reports is
/// real and already usable.
pub fn login_success(ctx: &ExecContext, remember: RememberOutcome) -> CommandOutput {
    let session = SessionCache::load(&ctx.dirs).ok().flatten();
    let device_name = ctx.me.as_ref().map(|d| d.device_name.clone()).unwrap_or_default();
    let (host, username) = match &session {
        Some(s) => (s.host.clone(), s.username.clone()),
        None => ("-".to_string(), "-".to_string()),
    };
    let mut human = format!("Logged in to {host} as {username} ({device_name}).");
    let (remembered, remember_error) = match &remember {
        RememberOutcome::NotRequested => (false, None),
        RememberOutcome::Stored => {
            human.push_str(" This device won't need a password next time.");
            (true, None)
        }
        RememberOutcome::NoToken => {
            human.push_str(
                " Could not remember this device: the server issued no token this time; \
                 you'll need to log in again next time.",
            );
            (false, None)
        }
        RememberOutcome::StoreFailed(e) => {
            human.push_str(&format!(
                " Could not remember this device: failed to save the token locally ({e}); \
                 you'll need to log in again next time."
            ));
            (false, Some(e.clone()))
        }
    };
    let data = serde_json::json!({
        "host": host,
        "username": username,
        "device": ctx.me.as_ref().map(|d| serde_json::json!({
            "id": d.device_id,
            "name": d.device_name,
        })),
        "remembered": remembered,
        "rememberError": remember_error,
    });
    CommandOutput { ok: true, code: 0, human, data, changed: vec!["projects"] }
}

/// Ensures `ctx.api`/`ctx.me` are populated, resolving auth non-interactively
/// via `auth::resolve`. A no-op if `ctx.api` is already set (so a verb that
/// needs a session doesn't re-resolve one already established this run).
/// `NeedsTrust`/`NeedsPassword` and any `ApiError` become actionable
/// `CommandOutput` errors rather than a prompt — this function never blocks
/// on stdin.
async fn ensure_session(
    ctx: &mut ExecContext,
    server: Option<String>,
    username: Option<String>,
) -> Result<(), CommandOutput> {
    if ctx.api.is_some() {
        return Ok(());
    }

    // Captured before `server` moves into the request: the host the
    // NeedsTrust error should name in its 'trust <host> --fingerprint'
    // remedy (mirrors the trust verb's own message).
    let trust_host = resolve_trust_host(ctx, &server);

    let outcome = {
        let req = AuthRequest { dirs: &ctx.dirs, server, username };
        auth::resolve(req).await
    };

    match outcome {
        Ok(AuthOutcome::Ready(client)) => {
            let device = DeviceIdentity::load_or_create(&ctx.dirs, None, None)
                .map_err(|e| output_error(1, format!("failed to load device identity: {e}")))?;
            ctx.me = Some(device);
            ctx.api = Some(client);
            Ok(())
        }
        Ok(AuthOutcome::NeedsTrust { fingerprint, subject }) => {
            let host = trust_host.unwrap_or_else(|| "HOST:PORT".to_string());
            Err(output_error(
                4,
                format!(
                    "server identity not trusted (subject {subject}, fingerprint {fingerprint}); \
                     verify it out-of-band, then run 'trust {host} --fingerprint {fingerprint}'."
                ),
            ))
        }
        Ok(AuthOutcome::NeedsPassword) => Err(output_error(
            3,
            "not logged in; set CAMBIUM_PASSWORD or run 'cambium login' from a terminal.".to_string(),
        )),
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            Err(output_error(code, msg))
        }
    }
}

/// What `--remember` should do, decided once auth has already resolved this
/// run (a `Ready` outcome in the CLI's interactive loop, or a successful
/// `ensure_session` in the non-interactive `login` verb — there is nothing
/// to decide about remembering *before* that). Pure and I/O-free: takes the
/// three facts that matter and nothing else, so it is unit-testable without
/// a terminal or a pty, and both entry points below call it so they can't
/// drift on this decision the way they did before (the interactive path
/// used to always prompt, ignoring `CAMBIUM_PASSWORD`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RememberDecision {
    /// `--remember` wasn't passed. Nothing to do.
    Skip,
    /// A password is already available (`CAMBIUM_PASSWORD`): validate-then-
    /// store it, no prompt needed.
    UseEnvPassword,
    /// No password available in the environment, but this run has a
    /// terminal to prompt on.
    Prompt,
    /// No password available and no terminal to prompt on: `--remember`
    /// cannot be satisfied this run. Callers must fail loudly here — never
    /// report success while storing nothing.
    Fail,
}

/// Decides what `--remember` should do, given whether it was requested at
/// all (`remember`), whether a password is already sitting in
/// `CAMBIUM_PASSWORD` (`env_password_present`), and whether this run can
/// prompt on a terminal (`interactive`). See `RememberDecision` for what
/// each outcome means.
pub fn decide_remember(remember: bool, env_password_present: bool, interactive: bool) -> RememberDecision {
    if !remember {
        RememberDecision::Skip
    } else if env_password_present {
        RememberDecision::UseEnvPassword
    } else if interactive {
        RememberDecision::Prompt
    } else {
        RememberDecision::Fail
    }
}

// --------------------------------------------------------------------- login

async fn login(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (server, user) = login_target(cli);
    match ensure_session(ctx, server.clone(), user.clone()).await {
        Ok(()) => {
            // `ensure_session`'s Ok(()) says nothing about HOW it resolved:
            // it can come from a cached session token (re-validating only
            // that token) or a stored remember-token (resuming the session
            // via /api/session/resume) — neither path ever looks at
            // CAMBIUM_PASSWORD. So a bare env var can be stale (rotated
            // server-side) and must never be written over a good stored
            // credential on the strength of an unrelated success.
            //
            // Instead, run a real validating login against THIS run's env
            // password via auth::login_with (same call the interactive path
            // uses right after a password prompt) and only persist it once
            // that succeeds. A rejected password fails the whole command
            // with the usual auth-error mapping and stores nothing, leaving
            // any existing good credential untouched.
            let mut remember_outcome = RememberOutcome::NotRequested;
            let env_password = std::env::var("CAMBIUM_PASSWORD").ok();
            match decide_remember(login_remember(cli), env_password.is_some(), ctx.interactive) {
                RememberDecision::Skip => {}
                RememberDecision::UseEnvPassword => {
                    let password = env_password.expect("UseEnvPassword implies env_password.is_some()");
                    let req = AuthRequest { dirs: &ctx.dirs, server, username: user };
                    // `login_remember(cli)` is true here — `decide_remember`
                    // only reaches `UseEnvPassword` when its `remember` input
                    // was true (see the match above) — so the server is only
                    // asked to mint a token because this run actually
                    // requested one.
                    match auth::login_with(&req, &password, login_remember(cli)).await {
                        Ok((client, response)) => {
                            ctx.api = Some(client);
                            // Whether a token actually landed in the store —
                            // not merely whether `--remember` was passed.
                            // `login_remember(cli)` is true here (see the
                            // comment above), never a hardcoded literal, so
                            // this can't drift from the request that got us
                            // into this arm.
                            remember_outcome = record_remember_outcome(
                                ctx,
                                login_remember(cli),
                                response.remember_token.as_deref(),
                            );
                        }
                        Err(e) => {
                            let (code, msg) = map_api_error(&e);
                            return output_error(code, msg);
                        }
                    }
                }
                // `run` is only ever reached non-interactively (a `login` on
                // a real terminal takes `main.rs`'s `interactive_login` path
                // instead, see the module doc), so `Prompt` cannot occur
                // here — but there is no terminal to honor it on even if it
                // did, so it folds into the same actionable failure as
                // `Fail`: no password THIS run to validate-and-store,
                // and `--remember` must never claim success while storing
                // nothing. This is a DIFFERENT case from `NoToken`/
                // `StoreFailed` below: here, login itself never even ran, so
                // there is no session to hand back — failing the whole
                // command loudly is correct precisely because it costs
                // nothing (nothing succeeded to misreport). Once login HAS
                // succeeded (the arm above), the same loud failure would
                // instead misreport a working, already-persisted session as
                // a failed command — so that case reports honestly instead
                // (see `RememberOutcome`/`login_success`).
                RememberDecision::Prompt | RememberDecision::Fail => {
                    return output_error(
                        3,
                        "--remember needs a password to store; set CAMBIUM_PASSWORD, or run \
                         'cambium login --remember' from a terminal."
                            .to_string(),
                    );
                }
            }
            login_success(ctx, remember_outcome)
        }
        Err(out) => out,
    }
}

// -------------------------------------------------------------------- logout

/// Best-effort server-side invalidation of the cached token (the server
/// drops it via `POST /api/logout` — see server.py's `SESSIONS.drop`).
/// Returns `true` iff the server confirmed the drop. Any failure — no pin
/// for an https host, client construction, network, a server error — is
/// swallowed: server-side revocation must never block *local* logout, and
/// an unreachable server's token still dies of session expiry eventually.
async fn revoke_server_token(ctx: &ExecContext, session: &SessionCache) -> bool {
    // Same scheme convention as auth::parse_host / ApiClient::new: an
    // explicit http:// prefix is a plain dev server; anything else
    // (bare host:port or https://) is pinned https.
    let pin = if session.host.starts_with("http://") {
        None
    } else {
        match PinStore::new(&ctx.dirs).get(&bare_host(&session.host)) {
            Ok(Some(p)) => Some(p),
            // No pin / pin-store error: an https client can't be built, so
            // the token can only expire server-side. Still a local logout.
            _ => return false,
        }
    };
    let mut client = match ApiClient::new(&session.host, pin) {
        Ok(c) => c,
        Err(_) => return false,
    };
    client.token = Some(session.token.clone());
    client.logout().await.is_ok()
}

async fn logout(ctx: &mut ExecContext) -> CommandOutput {
    let session = match SessionCache::load(&ctx.dirs) {
        Ok(s) => s,
        Err(e) => return output_error(1, format!("failed to read session: {e}")),
    };

    // Invalidate the token on the server BEFORE clearing it locally (once
    // session.json is gone the token would be unrecoverable). Best-effort.
    // `revoke_server_token` already hits POST /api/logout, and the server's
    // logout handler clears the device's remember-token too — so no
    // separate client call is needed to revoke it server-side.
    let server_note = match &session {
        Some(s) if revoke_server_token(ctx, s).await => "",
        Some(_) => " (server unreachable; token will expire server-side)",
        None => "",
    };

    // Forget the local remember-token too. A logout that leaves a credential
    // behind is nearly a no-op: the very next command silently
    // re-authenticates from it.
    if let Some(s) = &session {
        let account = auth::credential_account_key(&bare_host(&s.host), &s.username);
        // Swallowed deliberately: delete_secret_in only errors on a corrupt
        // or newer-formatVersion credentials.json, and an entry stuck in a
        // store that broken is already unusable (load_secret_in errors too,
        // so auth.rs falls through to a password prompt) — inert, not a
        // security hole. Local logout (the token drop below) must still
        // succeed even when the credential file itself can't be touched.
        let _ = crate::secrets::delete_secret_in(&ctx.dirs, &account);
    }

    match SessionCache::clear(&ctx.dirs) {
        Ok(()) => {
            ctx.api = None;
            let human = if session.is_some() {
                format!("Logged out.{server_note}")
            } else {
                "Already logged out.".to_string()
            };
            output_ok(human, serde_json::Value::Null)
        }
        Err(e) => output_error(1, format!("failed to clear session: {e}")),
    }
}

// --------------------------------------------------------------------- trust

async fn trust(cli: &Cli, ctx: &ExecContext) -> CommandOutput {
    let (host_flag, fingerprint) = match &cli.cmd {
        Cmd::Trust { host, fingerprint } => (host, fingerprint),
        _ => unreachable!("trust() only called for Cmd::Trust"),
    };

    let host = match resolve_trust_host(ctx, host_flag) {
        Some(h) => h,
        None => {
            return output_error(
                2,
                "specify a host, e.g. 'trust HOST:PORT --fingerprint <fp>', or run 'login' first."
                    .to_string(),
            )
        }
    };

    if let Some(fp) = fingerprint {
        return match auth::trust(&ctx.dirs, &host, fp).await {
            Ok(()) => output_ok(
                format!("Trusted {host} (fingerprint {fp})."),
                serde_json::json!({"host": host, "fingerprint": fp}),
            ),
            Err(e) => output_error(4, format!("failed to save trust: {e}")),
        };
    }

    // No --fingerprint: an interactive terminal never reaches here (main.rs
    // intercepts Trust{fingerprint: None, ..} + interactive before calling
    // `run`); this is the non-interactive path, which can only report what
    // it would trust, not accept it.
    match pinned_http::fetch_server_certificate(bare_host(&host)).await {
        Ok(cert) => output_error(
            4,
            format!(
                "server identity not yet trusted (subject {}, fingerprint {}); \
                 verify it out-of-band, then run 'trust {host} --fingerprint {}'.",
                cert.subject, cert.fingerprint, cert.fingerprint
            ),
        ),
        Err(e) => output_error(4, format!("could not fetch server certificate: {e}")),
    }
}

// -------------------------------------------------------------------- whoami

fn whoami(cli: &Cli, ctx: &ExecContext) -> CommandOutput {
    let session = match SessionCache::load(&ctx.dirs) {
        Ok(s) => s,
        Err(e) => return output_error(1, format!("failed to read session: {e}")),
    };
    let device = match DeviceIdentity::load_or_create(&ctx.dirs, None, None) {
        Ok(d) => d,
        Err(e) => return output_error(1, format!("failed to load device identity: {e}")),
    };
    let cwd = std::env::current_dir().unwrap_or_default();
    // Lenient: whoami shows "-" for an unresolved project rather than
    // failing the whole command the way a path-taking verb would.
    let project = resolve_project(ctx, &cli.project, &cwd).ok();

    let host = session.as_ref().map(|s| s.host.as_str()).unwrap_or("-");
    let user = session.as_ref().map(|s| s.username.as_str()).unwrap_or("-");
    let project_name = project.as_ref().map(|(_, name, _)| name.as_str()).unwrap_or("-");
    let workspace = project
        .as_ref()
        .map(|(_, _, folder)| folder.display().to_string())
        .unwrap_or_else(|| "-".to_string());

    let human = format!(
        "Server {host} · {user} · device {} ({}) · project {project_name} · workspace {workspace}",
        device.device_name, device.device_id,
    );
    let data = serde_json::json!({
        "host": session.as_ref().map(|s| s.host.clone()),
        "username": session.as_ref().map(|s| s.username.clone()),
        "device": {"id": device.device_id, "name": device.device_name},
        "project": project.as_ref().map(|(id, name, _)| serde_json::json!({"id": id, "name": name})),
        "workspace": project.as_ref().map(|(_, _, folder)| folder.display().to_string()),
    });
    output_ok(human, data)
}

// ------------------------------------------------------------------ projects

async fn projects(ctx: &mut ExecContext) -> CommandOutput {
    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    match client.projects().await {
        Ok(list) => {
            let human = if list.is_empty() {
                "No projects.".to_string()
            } else {
                list.iter()
                    .map(|p| {
                        let members = if p.member_count == 1 { "member" } else { "members" };
                        format!("{} ({}) — {} {members}", p.name, p.id, p.member_count)
                    })
                    .collect::<Vec<_>>()
                    .join("\n")
            };
            let data = serde_json::json!({
                "projects": list.iter().map(|p| serde_json::json!({
                    "id": p.id,
                    "name": p.name,
                    "sizeBytes": p.size_bytes,
                    "memberCount": p.member_count,
                    "lastCommitAt": p.last_commit_at,
                })).collect::<Vec<_>>(),
            });
            output_ok(human, data)
        }
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            output_error(code, msg)
        }
    }
}

// ---------------------------------------------------------------------- link

async fn link(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (project_name, folder) = match &cli.cmd {
        Cmd::Link { project, folder } => (project.clone(), folder.clone()),
        _ => unreachable!("link() only called for Cmd::Link"),
    };

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    let list = match client.projects().await {
        Ok(l) => l,
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            return output_error(code, msg);
        }
    };
    let found = match list.iter().find(|p| p.name == project_name) {
        Some(p) => p,
        None => {
            let names: Vec<&str> = list.iter().map(|p| p.name.as_str()).collect();
            return output_error(
                5,
                format!("no project named '{project_name}'; available: {}.", names.join(", ")),
            );
        }
    };

    let folder_path = PathBuf::from(&folder);
    if !folder_path.is_dir() {
        return output_error(5, format!("folder '{folder}' does not exist; create it first."));
    }
    let resolved_folder = std::fs::canonicalize(&folder_path).unwrap_or(folder_path);

    let mut workspaces = match WorkspacesFile::load(&ctx.dirs) {
        Ok(w) => w,
        Err(e) => return output_error(1, format!("failed to read workspaces: {e}")),
    };
    workspaces.workspaces.insert(
        found.id.clone(),
        WorkspaceEntry { name: found.name.clone(), folder: resolved_folder.display().to_string() },
    );
    workspaces.active_project = Some(found.id.clone());
    if let Err(e) = workspaces.save(&ctx.dirs) {
        return output_error(1, format!("failed to save workspaces: {e}"));
    }

    ctx.project = Some((found.id.clone(), found.name.clone()));
    ctx.workspace = Some(resolved_folder.clone());

    output_ok(
        format!("Linked '{}' to {}.", found.name, resolved_folder.display()),
        serde_json::json!({
            "project": {"id": found.id, "name": found.name},
            "folder": resolved_folder.display().to_string(),
        }),
    )
}

// ----------------------------------------------------------------------- use

async fn use_project(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let project_name = match &cli.cmd {
        Cmd::Use { project } => project.clone(),
        _ => unreachable!("use_project() only called for Cmd::Use"),
    };

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    let list = match client.projects().await {
        Ok(l) => l,
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            return output_error(code, msg);
        }
    };
    let found = match list.iter().find(|p| p.name == project_name) {
        Some(p) => p,
        None => {
            let names: Vec<&str> = list.iter().map(|p| p.name.as_str()).collect();
            return output_error(
                5,
                format!("no project named '{project_name}'; available: {}.", names.join(", ")),
            );
        }
    };

    let mut workspaces = match WorkspacesFile::load(&ctx.dirs) {
        Ok(w) => w,
        Err(e) => return output_error(1, format!("failed to read workspaces: {e}")),
    };
    workspaces.active_project = Some(found.id.clone());
    if let Err(e) = workspaces.save(&ctx.dirs) {
        return output_error(1, format!("failed to save workspaces: {e}"));
    }

    ctx.project = Some((found.id.clone(), found.name.clone()));
    ctx.workspace = workspaces.workspaces.get(&found.id).map(|entry| PathBuf::from(&entry.folder));

    output_ok(
        format!("Active project set to '{}'.", found.name),
        serde_json::json!({"project": {"id": found.id, "name": found.name}}),
    )
}

// -------------------------------------------------------------------- status
// --------------------------------------------------------------------- locks

/// The state-store key for a project+workspace's changelist/manifest,
/// mirroring the GUI's `changelistKey` construction (see
/// `TauriClient/src/hooks/useFileControl.js`):
/// `` `${activeProjectId ?? ''}::${account?.username ?? ''}::${workspacePath}` ``.
///
/// Segment-by-segment, what is actually guaranteed:
///
///   - `project_id` — the project id, same string on both sides. Exact.
///   - `username` — the SESSION's username, which `auth.rs` stores
///     server-canonically: every login path persists the login response's
///     `user.username` (`public_user` in server.py — the stored `@handle`
///     form), never the caller's typed string, whose case can differ
///     because server login matches case-insensitively (`ORG.find_user`).
///     The GUI's `account.username` is that same response field
///     (ConnectionContext.jsx), so this segment matches exactly.
///   - `workspace` — INVARIANT: this must be `workspaces.json`'s STORED
///     folder string, used verbatim at key time (callers pass the
///     `PathBuf::from(&entry.folder)` that `resolve_project` returns;
///     `.display()` round-trips it — no canonicalization, symlink
///     resolution, or trailing-slash editing happens HERE). The one-time
///     canonicalize lives in `link()` at store time; that stored string is
///     then the single source every subsequent CLI run keys from, so the
///     CLI's own key is stable across runs. GUI parity for this segment
///     holds when the GUI's `workspacePath` (its raw folder-picker string)
///     equals the stored canonical form — the common case; a picker string
///     containing unresolved symlinks would diverge, which is a
///     pre-existing GUI-vs-CLI representation gap, not something this key
///     function can close.
///
/// The key is sha256-hashed into the state filename (`state_store`), so any
/// byte difference silently selects a different file — which is why the
/// username segment had to be canonical, not merely case-equal.
fn changelist_key(project_id: &str, username: &str, workspace: &Path) -> String {
    format!("{project_id}::{username}::{}", workspace.display())
}

/// Flattens `workspace::scan_workspace`'s `TreeNode` tree into a
/// depot-relative path -> `LocalFile` map, the shape `compute_statuses`
/// wants. Sibling of `status::flatten_depot_tree`, which does the same job
/// for the server's tree shape.
fn flatten_local_files(nodes: &[TreeNode], prefix: &str, out: &mut BTreeMap<String, LocalFile>) {
    for node in nodes {
        let rel = if prefix.is_empty() { node.name.clone() } else { format!("{prefix}/{}", node.name) };
        match node.node_type {
            "file" => {
                out.insert(
                    rel,
                    LocalFile {
                        hash: node.hash.clone(),
                        writable: node.writable.unwrap_or(false),
                        size_bytes: node.size_bytes.unwrap_or(0),
                    },
                );
            }
            "folder" => {
                if let Some(children) = &node.children {
                    flatten_local_files(children, &rel, out);
                }
            }
            _ => {}
        }
    }
}

/// Looks up `handle`'s display name in `GET /api/users`' `{users: [...]}`
/// payload (case-insensitive on `username`, matching the GUI's `getUser`);
/// falls back to the raw `@handle` when the roster is missing that user (or
/// wasn't fetchable at all — `users_json` is `Value::Null` in that case,
/// which simply matches nothing below).
pub(crate) fn holder_display_name(users_json: &serde_json::Value, handle: &str) -> String {
    users_json
        .get("users")
        .and_then(|v| v.as_array())
        .and_then(|arr| {
            arr.iter().find(|u| {
                u.get("username")
                    .and_then(|v| v.as_str())
                    .map(|u| u.eq_ignore_ascii_case(handle))
                    .unwrap_or(false)
            })
        })
        .and_then(|u| u.get("displayName").and_then(|v| v.as_str()))
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .unwrap_or_else(|| handle.to_string())
}

/// (human label, stable JSON `status` key, JSON `detail`) for one file's
/// `FileStatus` — labels are the GUI's `STATUS_KINDS[...].label` verbatim
/// (`TauriClient/src/data/fileStatus.js`), and the JSON keys are that same
/// file's object keys, so `--json` output speaks the same vocabulary the
/// desktop app already does. Holder/device names are resolved against
/// `users_json` at render time, per the brief (never cached on the status
/// engine's pure result).
pub(crate) fn render_status(
    status: &FileStatus,
    users_json: &serde_json::Value,
) -> (String, &'static str, serde_json::Value) {
    match status {
        FileStatus::Modified => ("Modified".to_string(), "modified", serde_json::Value::Null),
        FileStatus::Outdated { head_revision } => (
            "Outdated".to_string(),
            "outdated",
            serde_json::json!({"headRevision": head_revision}),
        ),
        FileStatus::CheckedOutByMe => {
            ("Checked out".to_string(), "checkedOutByMe", serde_json::Value::Null)
        }
        FileStatus::LockedByOther { holder } => {
            let display = holder_display_name(users_json, holder);
            (
                format!("Checked out by {display}"),
                "lockedByOther",
                serde_json::json!({"holder": holder, "holderDisplayName": display}),
            )
        }
        FileStatus::LockedByMeElsewhere { device_name } => (
            format!("Checked out by you on {device_name}"),
            "lockedByMeElsewhere",
            serde_json::json!({"deviceName": device_name}),
        ),
        FileStatus::MarkedForAdd => {
            ("Marked for add".to_string(), "markedForAdd", serde_json::Value::Null)
        }
        FileStatus::MarkedForDelete => {
            ("Marked for delete".to_string(), "markedForDelete", serde_json::Value::Null)
        }
        FileStatus::NotAdded => ("Not added".to_string(), "notAdded", serde_json::Value::Null),
        FileStatus::NamingIssue { reason } => (
            "Naming issue".to_string(),
            "namingIssue",
            serde_json::json!({"reason": reason}),
        ),
    }
}

/// `status [<path>...]`: whole workspace by default. Gathers the depot tree,
/// a fresh (hashed) workspace scan, locks, the ignore rules, and this
/// workspace's manifest+changelist; runs `compute_statuses`; filters to the
/// resolved targets; renders one `<label>  <path>` line per file plus a
/// summary, or `Workspace up to date.` when nothing to show.
async fn status(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let path_args = match &cli.cmd {
        Cmd::Status { paths } => paths.clone(),
        _ => unreachable!("status() only called for Cmd::Status"),
    };

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }

    let cwd = std::env::current_dir().unwrap_or_default();
    let (project_id, _name, workspace_folder) = match resolve_project(ctx, &cli.project, &cwd) {
        Ok(v) => v,
        Err(out) => return out,
    };
    let targets = match paths::resolve_targets(&path_args, &cwd, &workspace_folder) {
        Ok(t) => t,
        Err(e) => return output_error(2, e),
    };

    let g = match gather(ctx, &project_id, &workspace_folder).await {
        Ok(g) => g,
        Err(out) => return out,
    };

    let filtered: Vec<(&String, &FileStatus)> = g
        .result
        .statuses
        .iter()
        .filter(|(p, _)| targets.is_empty() || targets.iter().any(|t| paths::matches_target(p, t)))
        .collect();

    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    let users_json = client.users().await.unwrap_or(serde_json::Value::Null);

    let mut lines: Vec<String> = g.vanished_note.iter().cloned().collect();
    let mut json_entries = Vec::with_capacity(filtered.len());
    let mut in_changelist = 0u32;
    for (path, st) in &filtered {
        let (label, status_key, detail) = render_status(st, &users_json);
        lines.push(format!("{label}  {path}"));
        if matches!(st, FileStatus::MarkedForAdd | FileStatus::MarkedForDelete) {
            in_changelist += 1;
        }
        json_entries.push(serde_json::json!({"path": path, "status": status_key, "detail": detail}));
    }

    let human = if filtered.is_empty() {
        match &g.vanished_note {
            None => "Workspace up to date.".to_string(),
            Some(note) => note.clone(),
        }
    } else {
        lines.push(format!("{} files, {} in changelist", filtered.len(), in_changelist));
        lines.join("\n")
    };

    let mut out = output_ok(human, serde_json::Value::Array(json_entries));
    if g.vanished_note.is_some() {
        out.changed = vec!["files"];
    }
    out
}

/// `locks [<path>...]`: whole project by default. `<path> — <holder> on
/// <deviceName>` lines, holder resolved to a display name the same way
/// `status` does; a clear message when nothing is locked (or nothing under
/// the given targets is).
async fn locks(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let path_args = match &cli.cmd {
        Cmd::Locks { paths } => paths.clone(),
        _ => unreachable!("locks() only called for Cmd::Locks"),
    };

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }

    let cwd = std::env::current_dir().unwrap_or_default();
    let (project_id, _name, workspace_folder) = match resolve_project(ctx, &cli.project, &cwd) {
        Ok(v) => v,
        Err(out) => return out,
    };
    let targets = match paths::resolve_targets(&path_args, &cwd, &workspace_folder) {
        Ok(t) => t,
        Err(e) => return output_error(2, e),
    };

    // locks()/users() are project-scoped (X-Project) — see the identical
    // comment in status().
    if let Some(api) = ctx.api.as_mut() {
        api.project_id = Some(project_id.clone());
    }
    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    let all_locks = match client.locks().await {
        Ok(l) => l,
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            return output_error(code, msg);
        }
    };

    let filtered: Vec<(&String, &crate::api::LockInfo)> = all_locks
        .iter()
        .filter(|(p, _)| targets.is_empty() || targets.iter().any(|t| paths::matches_target(p, t)))
        .collect();

    if filtered.is_empty() {
        return output_ok("No files are checked out.".to_string(), serde_json::Value::Array(Vec::new()));
    }

    let users_json = client.users().await.unwrap_or(serde_json::Value::Null);

    let mut lines = Vec::with_capacity(filtered.len());
    let mut json_entries = Vec::with_capacity(filtered.len());
    for (path, lock) in &filtered {
        let display = holder_display_name(&users_json, &lock.username);
        lines.push(format!("{path} \u{2014} {display} on {}", lock.device_name));
        json_entries.push(serde_json::json!({
            "path": path,
            "holder": lock.username,
            "holderDisplayName": display,
            "deviceName": lock.device_name,
        }));
    }

    output_ok(lines.join("\n"), serde_json::Value::Array(json_entries))
}

// --------------------------------------------------------------- gather

/// Everything a changelist-aware verb (`status`, `checkout`, `add`,
/// `delete`, `revert`) needs about the current state of the world: the
/// depot tree, a fresh hashed workspace scan, locks, the ignore rules, this
/// workspace's changelist+manifest, and the computed per-file statuses.
/// Assumes the caller already ran `ensure_session`/`resolve_project` and is
/// passing the results — this preserves each verb's original ordering
/// (auth, then project/workspace resolution, then — for verbs that take
/// path args — cheap offline target resolution, all BEFORE this function's
/// network calls run), rather than baking targets into `gather` itself,
/// which would force fetching the whole world before ever validating a bad
/// path argument.
struct Gathered {
    session: SessionCache,
    /// The state-store key this project+user+workspace's changelist/manifest
    /// live under (see `changelist_key`) — callers save back through it.
    key: String,
    server_files: BTreeMap<String, status_engine::ServerFile>,
    local_files: BTreeMap<String, LocalFile>,
    changelist: Changelist,
    manifest: BTreeMap<String, Option<String>>,
    result: status_engine::StatusResult,
    /// The depot's compiled ignore rules — carried so a verb building its own
    /// candidate set from the changelist (rather than reading it off the
    /// status map) can still apply the same "never touch an ignored path"
    /// filter the status engine does. `submit` needs this to mirror
    /// `buildSubmitItems`'s add-set filter (`localFiles.has && !isIgnored`).
    ignore: IgnoreMatcher,
    /// Set iff the Task 8 vanished-file rule (rule 5) fired this call — the
    /// human note `status` prepends, and the signal that `changed: ["files"]`
    /// belongs on that verb's output.
    vanished_note: Option<String>,
}

fn api_error_output(e: &ApiError) -> CommandOutput {
    let (code, msg) = map_api_error(e);
    output_error(code, msg)
}

/// Runs the shared gathering pipeline (see `Gathered`'s doc) against an
/// already-resolved project/workspace. Sets `ctx.api`'s `project_id` as a
/// side effect (every call below is project-scoped: depot/locks/ignore/users
/// all read `X-Project` server-side).
///
/// Persists the Task 8 vanished-file rule into the changelist immediately —
/// a manifest-tracked, still-on-server file that vanished from disk gets
/// folded into `changelist.del` and saved back right away, same as the
/// GUI's `useFileControl.js` external-deletion effect — because the engine
/// itself stays pure (see status.rs's module doc) and every verb that calls
/// `gather` should see and act on the same up-to-date changelist, not just
/// `status`.
async fn gather(
    ctx: &mut ExecContext,
    project_id: &str,
    workspace_folder: &Path,
) -> Result<Gathered, CommandOutput> {
    let session = match SessionCache::load(&ctx.dirs) {
        Ok(Some(s)) => s,
        Ok(None) => {
            return Err(output_error(3, "not logged in; run 'cambium login' first.".to_string()))
        }
        Err(e) => return Err(output_error(1, format!("failed to read session: {e}"))),
    };

    if let Some(api) = ctx.api.as_mut() {
        api.project_id = Some(project_id.to_string());
    }
    let client = ctx.api.as_ref().expect("caller ensures ctx.api is set before calling gather");

    let depot_tree = client.depot_tree().await.map_err(|e| api_error_output(&e))?;
    let server_files = status_engine::flatten_depot_tree(&depot_tree);

    let locks_map = client.locks().await.map_err(|e| api_error_output(&e))?;

    let ignore_text = client.ignore_text().await.map_err(|e| api_error_output(&e))?;
    let ignore = IgnoreMatcher::compile(&ignore_text);

    // Disk-persisted hash cache (see hashcache.rs), keyed under this
    // program's own config root — separate from the desktop app's Tauri
    // app-cache-dir copy, but the same scheme, so repeat calls don't re-hash
    // an unchanged workspace from scratch.
    let cache_path = hashcache::cache_file_path(&ctx.dirs.root);
    let mut hash_entries = hashcache::load_entries(&cache_path);
    let (nodes, live_keys, _new_hashes) =
        workspace::scan_workspace(workspace_folder, true, &mut hash_entries).await;
    hashcache::prune_stale(&mut hash_entries, workspace_folder, &live_keys);
    let _ = hashcache::save_entries(&cache_path, &hash_entries); // best-effort; correctness never depends on it
    let mut local_files = BTreeMap::new();
    flatten_local_files(&nodes, "", &mut local_files);

    let me_device = ctx.me.as_ref().expect("caller ensures ctx.me is set before calling gather");
    let key = changelist_key(project_id, &session.username, workspace_folder);
    let mut changelist = config::load_changelist(&ctx.dirs, &key)
        .map_err(|e| output_error(1, format!("failed to read changelist: {e}")))?;
    let manifest = config::load_manifest(&ctx.dirs, &key)
        .map_err(|e| output_error(1, format!("failed to read manifest: {e}")))?;

    let result = status_engine::compute_statuses(&status_engine::StatusInputs {
        server_files: &server_files,
        local_files: &local_files,
        locks: &locks_map,
        changelist: &changelist,
        base_hashes: &manifest,
        me: (&session.username, &me_device.device_id),
        ignore: &ignore,
    });

    let mut vanished_note = None;
    if !result.vanished.is_empty() {
        for p in &result.vanished {
            changelist.del.insert(p.clone());
        }
        config::save_changelist(&ctx.dirs, &key, &changelist).map_err(|e| {
            output_error(1, format!("failed to persist vanished-file changelist update: {e}"))
        })?;
        let mut vanished_sorted = result.vanished.clone();
        vanished_sorted.sort();
        vanished_note = Some(format!(
            "Note: detected files deleted outside the app, marked for delete: {}",
            vanished_sorted.join(", ")
        ));
    }

    Ok(Gathered {
        session,
        key,
        server_files,
        local_files,
        changelist,
        manifest,
        result,
        ignore,
        vanished_note,
    })
}

/// What a `restore_via_pull` call actually accomplished, per file. Both
/// consumers (`checkout`, `revert`) must survive PARTIAL failure without
/// lying about state: they persist changelist/manifest updates and release
/// locks only for the paths that really landed, so this reports both halves
/// instead of collapsing to a first-error `Err`.
#[derive(Debug, Default, PartialEq, Eq)]
struct RestoreOutcome {
    /// path -> depot head hash, for every file whose content actually
    /// downloaded (the caller's manifest merge). A pull candidate that was
    /// skipped as already-at-head appears in neither map — nothing changed
    /// and nothing failed.
    updates: BTreeMap<String, Option<String>>,
    /// path -> error, for every file whose download failed.
    failures: BTreeMap<String, String>,
}

/// Maps a `PullResult`'s error list onto the requested candidate set.
/// Per-file errors mark just their own path; an error with an EMPTY path is
/// `pull_files_impl`'s whole-request failure shape (client construction,
/// `PULL_IN_PROGRESS`) and therefore fails every candidate. Pure — the unit
/// tests inject failing `PullResult`s here, standing in for a dead server.
fn restore_failures(
    candidates: &[String],
    result: &transfer::PullResult,
) -> BTreeMap<String, String> {
    let mut failed = BTreeMap::new();
    for e in &result.errors {
        if e.path.is_empty() {
            for p in candidates {
                failed.entry(p.clone()).or_insert_with(|| e.error.clone());
            }
        } else {
            failed.insert(e.path.clone(), e.error.clone());
        }
    }
    failed
}

/// Downloads depot head content for `paths` (server-tracked ones only — a
/// path with no `server_files` entry is silently skipped) into the
/// workspace via `transfer::pull_files_impl`, discarding progress events (no
/// TTY to draw a bar on for a non-interactive CLI verb — a `pull`-specific
/// progress UI is Task 13's job). Never short-circuits on a per-file
/// failure: the returned outcome carries what landed AND what failed, so
/// callers can persist exactly the successful subset.
async fn restore_via_pull(
    ctx: &ExecContext,
    session: &SessionCache,
    workspace_folder: &Path,
    server_files: &BTreeMap<String, status_engine::ServerFile>,
    paths: &[String],
    writable: bool,
) -> RestoreOutcome {
    let Some(client) = ctx.api.as_ref() else {
        return RestoreOutcome {
            updates: BTreeMap::new(),
            failures: paths
                .iter()
                .filter(|p| server_files.contains_key(*p))
                .map(|p| (p.clone(), "no active session".to_string()))
                .collect(),
        };
    };
    let files: Vec<transfer::PullFile> = paths
        .iter()
        .filter_map(|p| {
            let sf = server_files.get(p)?;
            Some(transfer::PullFile {
                path: p.clone(),
                url: client.file_content_url(p),
                hash: sf.hash.clone(),
                size_bytes: Some(sf.size_bytes),
            })
        })
        .collect();
    if files.is_empty() {
        return RestoreOutcome::default();
    }
    let candidates: Vec<String> = files.iter().map(|f| f.path.clone()).collect();
    let headers = client.auth_headers();
    let pin = PinStore::new(&ctx.dirs).get(&bare_host(&session.host)).ok().flatten();
    let cache = workspace::HashCache::default();
    let result = transfer::pull_files_impl(
        &workspace_folder.display().to_string(),
        &files,
        &headers,
        writable,
        None,
        &cache,
        ctx.progress.clone(),
        pin.as_deref(),
    )
    .await;
    let failures = restore_failures(&candidates, &result);
    let mut updates = BTreeMap::new();
    for p in &result.changed {
        if let Some(sf) = server_files.get(p) {
            updates.insert(p.clone(), sf.hash.clone());
        }
    }
    RestoreOutcome { updates, failures }
}

/// Whether reverting `path` would discard real local work: a local copy
/// exists, its hash differs from the current depot head (there IS something
/// that would change), AND that hash isn't the base the copy was last synced
/// at (an Outdated-but-never-edited file has nothing of the user's to lose —
/// see status.rs rule 3, Modified vs Outdated). This is the `-f` gate's
/// per-path predicate for `revert`.
fn has_real_local_changes(
    path: &str,
    local_files: &BTreeMap<String, LocalFile>,
    server_files: &BTreeMap<String, status_engine::ServerFile>,
    manifest: &BTreeMap<String, Option<String>>,
) -> bool {
    let Some(local_hash) = local_files.get(path).and_then(|l| l.hash.as_ref()) else {
        return false;
    };
    let server_hash = server_files.get(path).and_then(|s| s.hash.as_ref());
    if Some(local_hash) == server_hash {
        return false; // already matches head: nothing to lose
    }
    let base = manifest.get(path).and_then(|b| b.as_ref());
    base != Some(local_hash)
}

/// The error a `checkout` reports when a step AFTER the server lock was
/// acquired failed. `released == true` means the best-effort rollback
/// release succeeded (the world is back to pre-checkout state); `false`
/// means even the release failed, so the message must say the locks are
/// still held — either way, re-running `checkout` is safe (a re-run either
/// re-acquires the released locks or no-ops onto the still-held ones).
/// Pure, so the wording is unit-tested without a server.
fn checkout_failure_message(reason: &str, released: bool, paths: &[String]) -> String {
    if released {
        format!(
            "checkout failed: {reason}; the just-acquired lock(s) were released — \
             re-running 'checkout' is safe."
        )
    } else {
        format!(
            "checkout failed: {reason}; the lock(s) on {} are still held by you \
             (releasing them also failed) — re-running 'checkout' is safe.",
            paths.join(", ")
        )
    }
}

/// Applies `revert`'s changelist un-marks for what actually succeeded.
/// Un-marking an add never depends on a download (the file stays on disk
/// untouched), so adds un-mark unconditionally. A marked-for-delete path
/// whose content restore FAILED stays marked — the changelist keeps telling
/// the truth ("this delete is still pending, its local copy is still gone")
/// and a re-run of `revert` picks it up again. Folder delete-marks are only
/// cancelled by a successfully restored member. Pure, for the same
/// injected-failure unit tests as `restore_failures`.
fn apply_revert_unmarks(
    changelist: &mut Changelist,
    unmark_add: &[String],
    unmark_del: &[String],
    failed: &BTreeMap<String, String>,
) {
    for path in unmark_add {
        changelist.add.remove(path);
    }
    for path in unmark_del {
        if failed.contains_key(path) {
            continue;
        }
        changelist.del.remove(path);
        // Reverting anything inside a marked folder cancels the folder's own
        // delete mark too — the user clearly wants (part of) it back, same
        // as the GUI's performRevert.
        changelist
            .del_folders
            .retain(|folder| !(path == folder || path.starts_with(&format!("{folder}/"))));
    }
}

/// The exit-1 error for a `revert` whose content restore partially (or
/// wholly) failed: names every failed path, keeps the promise made by
/// `apply_revert_unmarks` (failed paths stay marked, their locks stay held)
/// and says a re-run retries exactly them. Pure, unit-tested.
fn revert_pull_failure_message(failures: &BTreeMap<String, String>) -> String {
    let paths: Vec<&str> = failures.keys().map(String::as_str).collect();
    let first_err = failures.values().next().map(String::as_str).unwrap_or("download failed");
    format!(
        "revert: failed to restore {} ({first_err}); these paths stay marked in the \
         changelist and any locks on them are kept — re-running 'revert' will retry them.",
        paths.join(", ")
    )
}

// ------------------------------------------------------------------ checkout

/// `checkout <path>...` (default: every Modified-without-checkout file):
/// locks the resolved paths for (user, device) editing, downloads any that
/// aren't on disk yet at head (mirrors the GUI's `doCheckout`, which syncs
/// missing files right after the lock lands), then flips the writable bit.
/// Refuses up front — naming the holder — if a resolved path is locked by
/// someone else OR by this same user on a different device (the server
/// would reject either case too, but composing the message from the
/// already-gathered lock data avoids a round trip and keeps the wording in
/// this program's voice rather than the server's business-error text).
async fn checkout(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let path_args = match &cli.cmd {
        Cmd::Checkout { paths } => paths.clone(),
        _ => unreachable!("checkout() only called for Cmd::Checkout"),
    };

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let cwd = std::env::current_dir().unwrap_or_default();
    let (project_id, _name, workspace_folder) = match resolve_project(ctx, &cli.project, &cwd) {
        Ok(v) => v,
        Err(out) => return out,
    };
    let targets = match paths::resolve_targets(&path_args, &cwd, &workspace_folder) {
        Ok(t) => t,
        Err(e) => return output_error(2, e),
    };

    let g = match gather(ctx, &project_id, &workspace_folder).await {
        Ok(g) => g,
        Err(out) => return out,
    };

    // Default: every Modified-without-checkout file. Explicit targets: any
    // server-tracked path under them, regardless of current status —
    // checkout's job includes pulling a not-yet-synced file, which never
    // carries a Modified status (nothing to diff locally yet).
    let mut paths_to_checkout: Vec<String> = if targets.is_empty() {
        g.result
            .statuses
            .iter()
            .filter(|(_, st)| matches!(st, FileStatus::Modified))
            .map(|(p, _)| p.clone())
            .collect()
    } else {
        g.server_files
            .keys()
            .filter(|p| targets.iter().any(|t| paths::matches_target(p, t)))
            .cloned()
            .collect()
    };
    paths_to_checkout.sort();
    paths_to_checkout.dedup();

    if paths_to_checkout.is_empty() {
        return output_ok("Nothing to check out.".to_string(), serde_json::Value::Array(Vec::new()));
    }

    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    let users_json = client.users().await.unwrap_or(serde_json::Value::Null);
    for path in &paths_to_checkout {
        if let Some(status) = g.result.statuses.get(path) {
            if matches!(status, FileStatus::LockedByOther { .. } | FileStatus::LockedByMeElsewhere { .. })
            {
                let (label, _, _) = render_status(status, &users_json);
                return output_error(1, format!("cannot check out '{path}': {label}."));
            }
        }
    }

    if let Err(e) = client.checkout(&paths_to_checkout).await {
        let (code, msg) = map_api_error(&e);
        return output_error(code, msg);
    }

    // The server lock is acquired from here on. Every downstream step
    // (content download, manifest save, writable flip) that fails must NOT
    // leave the lock silently held: collect the first failure, fall through
    // to a best-effort release of the just-acquired locks, and report which
    // state the locks ended up in (see `checkout_failure_message`).
    let mut failure: Option<String> = None;

    let missing: Vec<String> =
        paths_to_checkout.iter().filter(|p| !g.local_files.contains_key(*p)).cloned().collect();
    let outcome =
        restore_via_pull(ctx, &g.session, &workspace_folder, &g.server_files, &missing, false).await;
    if let Some((path, err)) = outcome.failures.iter().next() {
        failure = Some(format!("failed to download content ({path}: {err})"));
    }

    if failure.is_none() {
        let mut manifest = g.manifest;
        for (p, h) in &outcome.updates {
            manifest.insert(p.clone(), h.clone());
        }
        // Every checked-out file (pre-existing or freshly pulled) is now
        // based on head, whether or not it needed downloading.
        for p in &paths_to_checkout {
            if let Some(sf) = g.server_files.get(p) {
                manifest.insert(p.clone(), sf.hash.clone());
            }
        }
        if let Err(e) = config::save_manifest(&ctx.dirs, &g.key, &manifest) {
            failure = Some(format!("failed to save manifest: {e}"));
        }
    }

    if failure.is_none() {
        let ws = workspace_folder.display().to_string();
        let set_res = workspace::set_writable_impl(&ws, &paths_to_checkout, true).await;
        if !set_res.ok {
            failure = Some(format!("failed to set writable on: {}", set_res.failed.join(", ")));
        }
    }

    if let Some(reason) = failure {
        // Roll the just-acquired locks back so a half-done checkout doesn't
        // squat on files nobody is editing. Best-effort: if the release
        // itself fails too (server just went away?), say so explicitly —
        // the caller must know the locks are still theirs.
        let released = client.release(&paths_to_checkout).await.is_ok();
        return output_error(1, checkout_failure_message(&reason, released, &paths_to_checkout));
    }

    let mut out = output_ok(
        format!("Checked out: {}", paths_to_checkout.join(", ")),
        serde_json::json!({"paths": paths_to_checkout}),
    );
    out.changed = vec!["files", "locks"];
    out
}

// ----------------------------------------------------------------------- add

/// `add [<path>...]` (default: every "not added" file): marks local-only
/// files to be created on the server on the next submit. Validates every
/// candidate against the same cross-platform naming rules and folded-index
/// collision check the status engine uses (`safenames::unsafe_reason` /
/// `collision_in_index`, over the SAME server-files ∪ local-files folded
/// index status.rs's rule 4 builds) before touching the changelist: the
/// first bad name refuses the whole call, naming the file and the rule it
/// broke, rather than partially adding and leaving the rest unexplained.
async fn add(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let path_args = match &cli.cmd {
        Cmd::Add { paths } => paths.clone(),
        _ => unreachable!("add() only called for Cmd::Add"),
    };

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let cwd = std::env::current_dir().unwrap_or_default();
    let (project_id, _name, workspace_folder) = match resolve_project(ctx, &cli.project, &cwd) {
        Ok(v) => v,
        Err(out) => return out,
    };
    let targets = match paths::resolve_targets(&path_args, &cwd, &workspace_folder) {
        Ok(t) => t,
        Err(e) => return output_error(2, e),
    };

    let g = match gather(ctx, &project_id, &workspace_folder).await {
        Ok(g) => g,
        Err(out) => return out,
    };

    // Default set is exactly "Not added" (status.rs rule 4). An explicit
    // target also pulls in that path's NamingIssue status if it has one —
    // the user asked for that file by name, so it earns a real "here's
    // what's wrong" error below instead of being silently skipped the way
    // the no-args default skips it.
    let mut to_add: Vec<String> = g
        .result
        .statuses
        .iter()
        .filter(|(p, st)| {
            let in_scope = targets.is_empty() || targets.iter().any(|t| paths::matches_target(p, t));
            in_scope
                && (matches!(st, FileStatus::NotAdded)
                    || (!targets.is_empty() && matches!(st, FileStatus::NamingIssue { .. })))
        })
        .map(|(p, _)| p.clone())
        .collect();
    to_add.sort();

    if to_add.is_empty() {
        return output_ok("Nothing to add.".to_string(), serde_json::Value::Array(Vec::new()));
    }

    let folded_index = safenames::build_folded_index(
        g.server_files.keys().chain(g.local_files.keys()).map(String::as_str),
    );
    for path in &to_add {
        if let Some(reason) =
            safenames::unsafe_reason(path).or_else(|| safenames::collision_in_index(path, &folded_index))
        {
            return output_error(1, format!("cannot add '{path}': {reason}."));
        }
    }

    let mut changelist = g.changelist;
    for path in &to_add {
        changelist.add.insert(path.clone());
    }
    if let Err(e) = config::save_changelist(&ctx.dirs, &g.key, &changelist) {
        return output_error(1, format!("failed to save changelist: {e}"));
    }

    let mut out = output_ok(
        format!("Marked for add: {}", to_add.join(", ")),
        serde_json::json!({"paths": to_add}),
    );
    out.changed = vec!["files"];
    out
}

// -------------------------------------------------------------------- delete

/// `delete <path>...` (no default — an explicit target is always required):
/// removes the local copy of every server-tracked file/folder named (or
/// nested under a named folder) and stages the removal in
/// `changelist.del`/`del_folders`; a folder target's member files land in
/// `del` too, exactly like the GUI marking a folder (selecting a folder in
/// `useFileControl.js` counts every server file under it as "in scope" via
/// `underScopes`, so `candidates.del` already includes them — `delFolders`
/// just additionally marks the folder itself). A target naming a file never
/// added to the server refuses — there's no server copy to revert to, so
/// this non-interactive command won't silently make that unrecoverable the
/// way clicking through the GUI's confirm dialog would; delete it with `rm`
/// (or similar) instead. A currently-checked-out-by-me file may still be
/// deleted — the lock is left in place, exactly like `useFileControl.js`'s
/// `doDelete` (no release call there either): it clears at submit time along
/// with everything else in the changelist.
async fn delete(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let path_args = match &cli.cmd {
        Cmd::Delete { paths } => paths.clone(),
        _ => unreachable!("delete() only called for Cmd::Delete"),
    };
    if path_args.is_empty() {
        return output_error(1, "specify a file or folder to delete.".to_string());
    }

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let cwd = std::env::current_dir().unwrap_or_default();
    let (project_id, _name, workspace_folder) = match resolve_project(ctx, &cli.project, &cwd) {
        Ok(v) => v,
        Err(out) => return out,
    };
    let targets = match paths::resolve_targets(&path_args, &cwd, &workspace_folder) {
        Ok(t) => t,
        Err(e) => return output_error(2, e),
    };

    let g = match gather(ctx, &project_id, &workspace_folder).await {
        Ok(g) => g,
        Err(out) => return out,
    };

    let mut del_files: Vec<String> = Vec::new();
    let mut server_folders: Vec<String> = Vec::new();
    let mut local_only_folders: Vec<String> = Vec::new();

    for target in &targets {
        if g.server_files.contains_key(target) {
            del_files.push(target.clone());
            continue;
        }
        let prefix = format!("{target}/");
        let members: Vec<String> =
            g.server_files.keys().filter(|p| p.starts_with(&prefix)).cloned().collect();
        if !members.is_empty() {
            server_folders.push(target.clone());
            del_files.extend(members);
            continue;
        }
        if g.local_files.contains_key(target) {
            return output_error(
                1,
                format!(
                    "'{target}' was never added to the server; delete it directly (e.g. 'rm') instead."
                ),
            );
        }
        if g.local_files.keys().any(|p| p.starts_with(&prefix)) {
            // Purely local folder — nothing on the server to stage a delete
            // for; remove it from disk directly, same as the GUI's
            // delLocalOnlyFolders (no changelist entry, nothing to submit).
            local_only_folders.push(target.clone());
            continue;
        }
        return output_error(
            1,
            format!("'{target}' does not exist on the server or locally; nothing to delete."),
        );
    }
    del_files.sort();
    del_files.dedup();
    server_folders.sort();
    server_folders.dedup();
    local_only_folders.sort();
    local_only_folders.dedup();

    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    let users_json = client.users().await.unwrap_or(serde_json::Value::Null);
    for path in &del_files {
        if let Some(status) = g.result.statuses.get(path) {
            if matches!(status, FileStatus::LockedByOther { .. } | FileStatus::LockedByMeElsewhere { .. })
            {
                let (label, _, _) = render_status(status, &users_json);
                return output_error(1, format!("cannot delete '{path}': {label}."));
            }
        }
    }

    let ws = workspace_folder.display().to_string();
    let mut all_folders = server_folders.clone();
    all_folders.extend(local_only_folders.iter().cloned());
    let del_res = workspace::delete_files_impl(&ws, &del_files, &all_folders).await;
    if !del_res.ok {
        return output_error(1, format!("failed to delete: {}", del_res.failed.join(", ")));
    }

    let mut changelist = g.changelist;
    for path in &del_files {
        changelist.del.insert(path.clone());
    }
    for folder in &server_folders {
        changelist.del_folders.insert(folder.clone());
    }
    for folder in &local_only_folders {
        // A purely-local folder never enters the changelist (nothing on the
        // server to submit), but any marked-for-add file that lived inside
        // it is gone from disk now — drop those changelist entries too,
        // same as the GUI's doDelete.
        let prefix = format!("{folder}/");
        changelist.add.retain(|p| p != folder && !p.starts_with(&prefix));
    }
    if let Err(e) = config::save_changelist(&ctx.dirs, &g.key, &changelist) {
        return output_error(1, format!("failed to save changelist: {e}"));
    }

    let mut parts = Vec::new();
    if !del_files.is_empty() {
        parts.push(format!("deleted: {}", del_files.join(", ")));
    }
    if !all_folders.is_empty() {
        parts.push(format!("deleted folder(s): {}", all_folders.join(", ")));
    }
    let mut out = output_ok(
        parts.join("; "),
        serde_json::json!({"files": del_files, "folders": all_folders}),
    );
    out.changed = vec!["files"];
    out
}

// -------------------------------------------------------------------- revert

/// `revert [<path>...] [-f]` (default: whole changelist): "undo my pending
/// change → back to the server's version" — un-marks adds and deletes,
/// releases my checkouts, and restores modified/deleted files to depot head
/// content, all in one pass. Un-marking an add never
/// needs `-f` (the file sits on disk untouched either way — nothing is
/// lost); un-marking a delete, restoring a modified file, or releasing a
/// checkout DOES need `-f` when the on-disk copy carries real local edits
/// (`has_real_local_changes`), and refuses with the spec's exact message
/// naming the first such path in sorted order (deterministic, since the
/// candidate list is de-duplicated and sorted before the gate runs).
///
/// Partial-failure contract: content restoration runs BEFORE any state is
/// persisted, and un-marks/manifest-entries/lock-releases are applied only
/// to the paths whose restore succeeded (`apply_revert_unmarks`); the rest
/// stay marked and locked, and the exit-1 error names them and says a
/// re-run of `revert` retries them.
async fn revert(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (path_args, force) = match &cli.cmd {
        Cmd::Revert { paths, force } => (paths.clone(), *force),
        _ => unreachable!("revert() only called for Cmd::Revert"),
    };

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let cwd = std::env::current_dir().unwrap_or_default();
    let (project_id, _name, workspace_folder) = match resolve_project(ctx, &cli.project, &cwd) {
        Ok(v) => v,
        Err(out) => return out,
    };
    let targets = match paths::resolve_targets(&path_args, &cwd, &workspace_folder) {
        Ok(t) => t,
        Err(e) => return output_error(2, e),
    };

    let g = match gather(ctx, &project_id, &workspace_folder).await {
        Ok(g) => g,
        Err(out) => return out,
    };

    let in_scope = |p: &str| targets.is_empty() || targets.iter().any(|t| paths::matches_target(p, t));

    let mut unmark_add: Vec<String> = Vec::new();
    let mut unmark_del: Vec<String> = Vec::new();
    let mut release: Vec<String> = Vec::new();
    let mut restore_modified: Vec<String> = Vec::new();
    for (path, status) in &g.result.statuses {
        if !in_scope(path) {
            continue;
        }
        match status {
            FileStatus::MarkedForAdd => unmark_add.push(path.clone()),
            FileStatus::MarkedForDelete => unmark_del.push(path.clone()),
            FileStatus::CheckedOutByMe => release.push(path.clone()),
            FileStatus::Modified => restore_modified.push(path.clone()),
            _ => {}
        }
    }

    if unmark_add.is_empty() && unmark_del.is_empty() && release.is_empty() && restore_modified.is_empty()
    {
        return output_ok("Nothing to revert.".to_string(), serde_json::Value::Array(Vec::new()));
    }

    let mut restore_candidates: Vec<String> =
        unmark_del.iter().chain(restore_modified.iter()).chain(release.iter()).cloned().collect();
    restore_candidates.sort();
    restore_candidates.dedup();

    if !force {
        for path in &restore_candidates {
            if has_real_local_changes(path, &g.local_files, &g.server_files, &g.manifest) {
                return output_error(
                    1,
                    format!(
                        "refusing to revert: this would discard local changes to {path}; pass -f to overwrite."
                    ),
                );
            }
        }
    }

    // Content restoration runs FIRST; everything that claims success —
    // changelist un-marks, manifest entries, lock releases — is then
    // persisted only for the paths that actually restored, so a failed (or
    // partially failed) pull can never leave the changelist saying
    // "reverted" while the disk isn't. Failed paths stay marked / stay
    // locked, and the exit-1 error tells the user a re-run retries them.
    let outcome = restore_via_pull(
        ctx,
        &g.session,
        &workspace_folder,
        &g.server_files,
        &restore_candidates,
        false,
    )
    .await;
    let failed = outcome.failures;

    let mut changelist = g.changelist;
    apply_revert_unmarks(&mut changelist, &unmark_add, &unmark_del, &failed);
    if let Err(e) = config::save_changelist(&ctx.dirs, &g.key, &changelist) {
        return output_error(1, format!("failed to save changelist: {e}"));
    }

    let mut manifest = g.manifest;
    for (p, h) in &outcome.updates {
        manifest.insert(p.clone(), h.clone());
    }
    if let Err(e) = config::save_manifest(&ctx.dirs, &g.key, &manifest) {
        return output_error(1, format!("failed to save manifest: {e}"));
    }

    // Restored/reverted files go back to read-only — pull_files_impl only
    // re-chmods a file it actually downloaded, so this explicit pass is what
    // covers a released-but-never-edited checkout (skipped by pull's
    // already-matches-head short circuit) too. Failed paths are left alone.
    let restored: Vec<String> =
        restore_candidates.iter().filter(|p| !failed.contains_key(*p)).cloned().collect();
    let ws = workspace_folder.display().to_string();
    workspace::set_writable_impl(&ws, &restored, false).await;

    // Only release locks whose content restore succeeded: a checkout whose
    // restore failed keeps its lock (the file on disk may still carry the
    // edit the lock was protecting), and the re-run picks it up again.
    let releasable: Vec<String> =
        release.iter().filter(|p| !failed.contains_key(*p)).cloned().collect();
    if !releasable.is_empty() {
        let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
        if let Err(e) = client.release(&releasable).await {
            let (code, msg) = map_api_error(&e);
            return output_error(code, format!("reverted, but failed to release lock(s): {msg}"));
        }
    }

    if !failed.is_empty() {
        let mut out = output_error(1, revert_pull_failure_message(&failed));
        // Partial work DID land (un-marked adds, restored subset, released
        // subset) — tell the console panels to refresh anyway.
        out.changed = vec!["files", "locks"];
        return out;
    }

    let mut summary = Vec::new();
    if !unmark_add.is_empty() {
        summary.push(format!("un-marked for add: {}", unmark_add.join(", ")));
    }
    if !unmark_del.is_empty() {
        summary.push(format!("restored: {}", unmark_del.join(", ")));
    }
    if !restore_modified.is_empty() {
        summary.push(format!("reverted: {}", restore_modified.join(", ")));
    }
    if !release.is_empty() {
        summary.push(format!("released: {}", release.join(", ")));
    }
    let mut out = output_ok(
        summary.join("; "),
        serde_json::json!({
            "unmarkedAdd": unmark_add,
            "restored": unmark_del,
            "reverted": restore_modified,
            "released": release,
        }),
    );
    out.changed = vec!["files", "locks"];
    out
}

// -------------------------------------------------------------------- submit

/// The naming-issue block over the ADD set only (the files actually being
/// submitted), mirroring `performSubmit`'s first client-side pre-check: build
/// a folded index over the depot ∪ the add paths, then flag the first added
/// path (sorted) that breaks a cross-platform naming rule or case-collides.
/// A marked-for-add file with a bad name shows as `NamingIssue` in the status
/// engine (naming beats `MarkedForAdd`), so it would be MISSING from a
/// status-derived add set — which is exactly why the add set (and this check)
/// derive from `changelist.add` directly, not from the status map. Pure —
/// the exact wording is unit-tested without a server.
fn submit_naming_block(
    adds: &[String],
    server_files: &BTreeMap<String, status_engine::ServerFile>,
) -> Option<String> {
    let folded = safenames::build_folded_index(
        server_files.keys().map(String::as_str).chain(adds.iter().map(String::as_str)),
    );
    for path in adds {
        if let Some(reason) = safenames::unsafe_reason(path)
            .or_else(|| safenames::collision_in_index(path, &folded))
        {
            return Some(format!(
                "cannot submit: {path} has a naming issue ({reason}); rename it first."
            ));
        }
    }
    None
}

/// The staleness block over the EDIT set only, mirroring `performSubmit`'s
/// second client-side pre-check (`stale = items.filter(edited && base && head
/// && base !== head)`): flag the first edited path (sorted) whose manifest
/// base no longer matches the depot head. A checked-out file that's behind
/// head shows as `CheckedOutByMe` (the lock rule beats the Outdated rule), so
/// "outdated" can't be read off the status directly — it's derived here from
/// manifest-base-vs-head, exactly as the GUI does. Only files actually in the
/// submit's edit set are considered, so a clean Outdated file elsewhere in
/// the workspace never blocks the submit. Pure, unit-tested.
fn submit_stale_block(
    edits: &[String],
    manifest: &BTreeMap<String, Option<String>>,
    server_files: &BTreeMap<String, status_engine::ServerFile>,
) -> Option<String> {
    for path in edits {
        let base = manifest.get(path).and_then(|b| b.as_ref());
        let head = server_files.get(path).and_then(|s| s.hash.as_ref());
        if let (Some(base), Some(head)) = (base, head) {
            if base != head {
                return Some(format!(
                    "cannot submit: {path} is outdated; run 'pull {path}' first."
                ));
            }
        }
    }
    None
}

/// Applies a submit response's ACCEPTED set to the local changelist and
/// manifest — clearing only the paths/folders the server said it accepted
/// (`submitted`/`submittedFolders`), never the locally-computed request — so
/// a partial accept (a race with another submitter between `gather()` and the
/// POST landing) leaves every rejected change exactly as it was: still
/// marked, ready to resolve and resubmit. An accepted add/edit records its
/// freshly-uploaded hash as the new manifest base; an accepted delete drops
/// the manifest entry entirely. Pure, so the persistence rule is unit-tested
/// with an injected `submitted` subset rather than a live server.
fn apply_submit_persistence(
    changelist: &mut Changelist,
    manifest: &mut BTreeMap<String, Option<String>>,
    submitted: &[String],
    submitted_folders: &[String],
    hash_by_path: &BTreeMap<String, String>,
) {
    for path in submitted {
        changelist.add.remove(path);
        changelist.del.remove(path);
    }
    for folder in submitted_folders {
        changelist.del_folders.remove(folder);
    }
    for path in submitted {
        if let Some(hash) = hash_by_path.get(path) {
            manifest.insert(path.clone(), Some(hash.clone()));
        } else {
            manifest.remove(path);
        }
    }
}

/// Whether a `CheckedOutByMe` path actually carries content worth
/// submitting as an edit: `status.rs`'s lock rule (rule 2) short-circuits
/// before the Modified/Outdated rule (rule 3), so a checked-out file's
/// `FileStatus` alone can't tell "edited" from "checked out, unchanged" —
/// this compares the local and server hashes directly, the same content
/// check `isModified` makes in `useFileControl.js`. A path missing its local
/// hash (no local copy, or not yet hashed) has nothing to upload, so it
/// reads as "not an edit" rather than a submit candidate.
fn checked_out_is_modified(
    path: &str,
    local_files: &BTreeMap<String, LocalFile>,
    server_files: &BTreeMap<String, status_engine::ServerFile>,
) -> bool {
    let Some(local_hash) = local_files.get(path).and_then(|l| l.hash.as_ref()) else {
        return false;
    };
    let server_hash = server_files.get(path).and_then(|s| s.hash.as_ref());
    Some(local_hash) != server_hash
}

/// Whether a path belongs in a submit's EDIT set, from its status.
///
/// The server (`apply_submit` in server.py) accepts an `edited` change when the
/// submitter effectively holds the lock: held on THIS device, **or** the file is
/// unlocked and their `baseHash` still matches the depot head — its documented
/// auto-checkout path. So both of these are candidates:
///
///   - `CheckedOutByMe`, but only when the content actually differs from head
///     (a lock alone is not an edit — see `checked_out_is_modified`).
///   - `Modified`, which `status.rs` assigns precisely when the content differs
///     from head, nobody holds a lock, and the base still matches head.
///     `Outdated` is the mutually-exclusive base != head case, and the server
///     would refuse it ("changed on the server since you last synced"), so it
///     is deliberately NOT a candidate — that conflict is reported rather than
///     uploaded.
///
/// Requiring a manual `checkout` before an unlocked edit could be submitted was
/// a regression against the pre-core GUI, and because the empty submit set then
/// returned success it failed silently.
fn submit_edit_candidate(
    path: &str,
    status: &FileStatus,
    local_files: &BTreeMap<String, LocalFile>,
    server_files: &BTreeMap<String, status_engine::ServerFile>,
) -> bool {
    match status {
        FileStatus::CheckedOutByMe => checked_out_is_modified(path, local_files, server_files),
        FileStatus::Modified => true,
        _ => false,
    }
}

/// The `baseHash` submit sends for an `edited`/`deleted` change: the
/// manifest's recorded base when there is one, else the current server hash
/// (mirrors `useFileControl.js`'s `syncedHashes.get(path) ??
/// serverFiles.get(path)?.hash` fallback). The server only consults this
/// when the submitter doesn't hold the lock, to catch "changed on the
/// server since you last synced" — see `apply_submit` in server.py.
fn submit_base_hash(
    path: &str,
    manifest: &BTreeMap<String, Option<String>>,
    server_files: &BTreeMap<String, status_engine::ServerFile>,
) -> Option<String> {
    manifest
        .get(path)
        .and_then(|b| b.clone())
        .or_else(|| server_files.get(path).and_then(|s| s.hash.clone()))
}

fn json_string_array(v: Option<&serde_json::Value>) -> Vec<String> {
    v.and_then(|v| v.as_array())
        .map(|arr| arr.iter().filter_map(|x| x.as_str().map(String::from)).collect())
        .unwrap_or_default()
}

/// Parses `/api/submit`'s response shape (see `apply_submit` in
/// server.py): `revision` (null when every change conflicted),
/// `submitted`/`submittedFolders` (exactly what landed — the ground truth
/// this module persists into the changelist/manifest, NOT the locally
/// computed request, so a partial accept only clears what the server says it
/// accepted), and `conflicts` (`{path, reason}` pairs). Pure — the response
/// shape is unit-tested against a hand-built fixture rather than a live
/// server.
fn parse_submit_response(
    v: &serde_json::Value,
) -> (Option<u64>, Vec<String>, Vec<String>, Vec<(String, String)>) {
    let revision = v.get("revision").and_then(|r| r.as_u64());
    let submitted = json_string_array(v.get("submitted"));
    let submitted_folders = json_string_array(v.get("submittedFolders"));
    let conflicts = v
        .get("conflicts")
        .and_then(|c| c.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|c| {
                    let path = c.get("path")?.as_str()?.to_string();
                    let reason =
                        c.get("reason").and_then(|r| r.as_str()).unwrap_or("").to_string();
                    Some((path, reason))
                })
                .collect()
        })
        .unwrap_or_default();
    (revision, submitted, submitted_folders, conflicts)
}

/// `Submitted revision <N>: <M> file(s).` — the brief's exact success line
/// — with an appended note when the server accepted the revision but still
/// rejected some of the requested changes (a race with another submitter
/// between `gather()` and this submit landing). Pure, unit-tested.
fn submit_success_message(revision: u64, submitted_count: usize, conflicts: &[(String, String)]) -> String {
    let mut msg = format!("Submitted revision {revision}: {submitted_count} file(s).");
    if !conflicts.is_empty() {
        let listed: Vec<String> = conflicts.iter().map(|(p, r)| format!("{p} ({r})")).collect();
        msg.push_str(&format!(
            " {} file(s) could not be submitted: {}.",
            conflicts.len(),
            listed.join(", ")
        ));
    }
    msg
}

/// The message for a submit whose upload step failed (a subset, or all, of
/// the add/edit uploads errored) — named `<path>: <error>` pairs, joined.
/// Pure, unit-tested.
fn submit_upload_failure_message(failures: &BTreeMap<String, String>) -> String {
    let failed: Vec<String> = failures.iter().map(|(p, e)| format!("{p}: {e}")).collect();
    format!("submit failed: could not upload {}", failed.join(", "))
}

/// `submit`'s upload-failure `CommandOutput`: the existing joined human
/// message, plus the additive `data.failures` rows (mirrors
/// `pull_failure_output`'s shape so the GUI's submit/pull failure dialogs
/// read the same `data.failures` field). Pure, unit-tested.
fn submit_upload_failure_output(failures: &BTreeMap<String, String>) -> CommandOutput {
    let mut out = output_error(1, submit_upload_failure_message(failures));
    out.data = serde_json::json!({"failures": failures_json(failures)});
    out
}

/// The message for a submit the server accepted zero changes from
/// (`revision: null` — every item conflicted). Pure, unit-tested.
fn submit_rejected_message(conflicts: &[(String, String)]) -> String {
    if conflicts.is_empty() {
        return "submit rejected: no changes were accepted.".to_string();
    }
    let listed: Vec<String> = conflicts.iter().map(|(p, r)| format!("{p} ({r})")).collect();
    format!("submit rejected: {} conflict(s): {}.", conflicts.len(), listed.join(", "))
}

/// `submit [<path>...] -m "msg"` (default: whole changelist; with paths,
/// only changes localized under them — same `in_scope` filtering as
/// `revert`). Blocks (exit 1) before any network call if any in-scope path
/// is `NamingIssue` or `Outdated` (the first such path in sorted order,
/// since `g.result.statuses` is a `BTreeMap`). Otherwise builds the change
/// set — `MarkedForAdd` -> `added`, `MarkedForDelete` -> `deleted`,
/// `CheckedOutByMe` with content that actually differs from head ->
/// `edited` (see `checked_out_is_modified`) — uploads add/edit content via
/// `transfer::upload_files_impl` (aborting the whole submit, nothing
/// persisted, if any upload fails — mirrors `performSubmit`'s all-or-nothing
/// upload gate), then POSTs `/api/submit`. Persists ONLY what the response's
/// `submitted`/`submittedFolders` say actually landed — never the locally
/// computed request — so a partial accept (a race with another submitter)
/// leaves the rejected paths exactly as they were: still marked, still
/// locked, ready for the user to resolve and re-submit.
/// Explains why explicitly-targeted paths weren't submitted when the submit set
/// came out empty. Turns each modified-but-unsubmittable status into an
/// actionable hint (sorted for determinism); returns empty when nothing in
/// scope is a modified/locked file (a genuinely clean workspace). [trial A6]
fn unsubmittable_target_hints<'a>(
    scoped_statuses: impl Iterator<Item = (&'a String, &'a FileStatus)>,
) -> Vec<String> {
    let mut hints: Vec<String> = scoped_statuses
        .filter_map(|(p, st)| match st {
            // NOTE: `Modified` is deliberately absent — it is a submit candidate
            // now (see `submit_edit_candidate`), so a scope containing one never
            // reaches this "nothing to submit" path.
            FileStatus::Outdated { .. } => Some(format!(
                "{p} changed on the server since you last synced — run 'pull {p}' first"
            )),
            FileStatus::LockedByOther { holder } => {
                Some(format!("{p} is checked out by {holder} — you can't submit it"))
            }
            FileStatus::LockedByMeElsewhere { device_name } => Some(format!(
                "{p} is checked out by you on {device_name} — submit from that device or force-unlock it"
            )),
            _ => None,
        })
        .collect();
    hints.sort();
    hints
}

async fn submit(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (path_args, message) = match &cli.cmd {
        Cmd::Submit { paths, message } => (paths.clone(), message.clone()),
        _ => unreachable!("submit() only called for Cmd::Submit"),
    };

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let cwd = std::env::current_dir().unwrap_or_default();
    let (project_id, _name, workspace_folder) = match resolve_project(ctx, &cli.project, &cwd) {
        Ok(v) => v,
        Err(out) => return out,
    };
    let targets = match paths::resolve_targets(&path_args, &cwd, &workspace_folder) {
        Ok(t) => t,
        Err(e) => return output_error(2, e),
    };

    let g = match gather(ctx, &project_id, &workspace_folder).await {
        Ok(g) => g,
        Err(out) => return out,
    };

    let in_scope = |p: &str| targets.is_empty() || targets.iter().any(|t| paths::matches_target(p, t));

    // The submit set mirrors `useFileControl.js`'s `buildSubmitItems`: it is
    // built from the CHANGELIST + checked-out-modified files intersected with
    // the path scope — NOT from a whole-workspace status sweep. Deriving both
    // the change set AND the block set from this same submit-relevant set is
    // the fix for the whole-workspace over-block: a clean-but-Outdated or
    // bad-named file that ISN'T being submitted must never refuse the submit
    // (the status engine's rule order makes Outdated mutually exclusive with
    // every real submit candidate, so a status-map sweep could only ever trip
    // on a non-candidate).
    //
    //   - adds: `changelist.add` ∩ scope, present locally, not ignored — from
    //     the changelist directly (like the GUI), so a marked-for-add file
    //     with a bad name (which the status engine reports as NamingIssue, not
    //     MarkedForAdd) is still in the set and still gets blocked below.
    //   - deletes: `changelist.del` ∩ scope.
    //   - edits: `∩ scope` of the statuses `submit_edit_candidate` accepts —
    //     `CheckedOutByMe` whose content differs from head, plus `Modified`
    //     (unlocked, base still matches head), which the server accepts via its
    //     documented auto-checkout path. `Outdated` stays excluded so its
    //     conflict is reported instead of uploaded.
    let mut adds: Vec<String> = g
        .changelist
        .add
        .iter()
        .filter(|p| in_scope(p) && g.local_files.contains_key(*p) && !g.ignore.is_ignored(p))
        .cloned()
        .collect();
    adds.sort();
    let mut deletes: Vec<String> =
        g.changelist.del.iter().filter(|p| in_scope(p)).cloned().collect();
    deletes.sort();
    let mut edits: Vec<String> = g
        .result
        .statuses
        .iter()
        .filter(|(p, st)| {
            in_scope(p) && submit_edit_candidate(p, st, &g.local_files, &g.server_files)
        })
        .map(|(p, _)| p.clone())
        .collect();
    edits.sort();

    let folders: Vec<String> =
        g.changelist.del_folders.iter().filter(|f| in_scope(f.as_str())).cloned().collect();

    if adds.is_empty() && deletes.is_empty() && edits.is_empty() && folders.is_empty() {
        // If the user named specific paths, explain WHY nothing was submitted
        // instead of a bare "Nothing to submit." — a modified-but-not-checked-out
        // or locked-by-another file is otherwise silently dropped, and the user
        // could believe their edit was submitted when it was not. [trial A6]
        if !targets.is_empty() {
            let scoped = g.result.statuses.iter().filter(|(p, _)| in_scope(p)).map(|(p, st)| (p, st));
            let hints = unsubmittable_target_hints(scoped);
            if !hints.is_empty() {
                // An ERROR, not ok: the caller named paths it believed were
                // submittable and none were. Returning ok here is what made the
                // GUI report success and show nothing — `submitDialogs` only
                // surfaces a "Submit failed" dialog on !ok.
                return output_error(1, format!("Nothing to submit. {}", hints.join("; ")));
            }
        }
        return output_ok("Nothing to submit.".to_string(), serde_json::Value::Array(Vec::new()));
    }

    // Block BEFORE any upload, over ONLY the files being submitted: naming
    // issues on the add set, then stale edits (manifest base != depot head) —
    // the two client-side pre-checks `performSubmit` makes, in the same order,
    // with byte-for-byte the spec's wording (first offending path in sorted
    // order, since `adds`/`edits` are sorted above).
    if let Some(msg) = submit_naming_block(&adds, &g.server_files) {
        return output_error(1, msg);
    }
    if let Some(msg) = submit_stale_block(&edits, &g.manifest, &g.server_files) {
        return output_error(1, msg);
    }

    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");

    let upload_paths: Vec<String> = adds.iter().chain(edits.iter()).cloned().collect();
    let hash_by_path: BTreeMap<String, String> = if upload_paths.is_empty() {
        BTreeMap::new()
    } else {
        let upload_files: Vec<transfer::UploadFile> = upload_paths
            .iter()
            .map(|p| transfer::UploadFile {
                path: p.clone(),
                url: client.file_upload_url(),
                size_bytes: g.local_files.get(p).map(|l| l.size_bytes),
            })
            .collect();
        let headers = client.auth_headers();
        let pin = PinStore::new(&ctx.dirs).get(&bare_host(&g.session.host)).ok().flatten();
        let ws = workspace_folder.display().to_string();
        let result = transfer::upload_files_impl(
            &ws,
            &upload_files,
            &headers,
            ctx.progress.clone(),
            pin.as_deref(),
        )
        .await;
        if !result.ok {
            let mut failures = BTreeMap::new();
            for r in &result.results {
                if let Some(e) = &r.error {
                    failures.insert(r.path.clone(), e.clone());
                }
            }
            return submit_upload_failure_output(&failures);
        }
        result.results.into_iter().filter_map(|r| r.hash.map(|h| (r.path, h))).collect()
    };

    let mut changes: Vec<SubmitChange> = Vec::new();
    for path in &adds {
        changes.push(SubmitChange {
            path: path.clone(),
            change: "added".to_string(),
            hash: hash_by_path.get(path).cloned(),
            base_hash: None,
        });
    }
    for path in &edits {
        changes.push(SubmitChange {
            path: path.clone(),
            change: "edited".to_string(),
            hash: hash_by_path.get(path).cloned(),
            base_hash: submit_base_hash(path, &g.manifest, &g.server_files),
        });
    }
    for path in &deletes {
        changes.push(SubmitChange {
            path: path.clone(),
            change: "deleted".to_string(),
            hash: None,
            base_hash: submit_base_hash(path, &g.manifest, &g.server_files),
        });
    }

    let payload = SubmitPayload { description: message, changes, folders };
    let response = match client.submit(&payload).await {
        Ok(v) => v,
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            return output_error(code, msg);
        }
    };

    let (revision, submitted, submitted_folders, conflicts) = parse_submit_response(&response);

    let Some(revision) = revision else {
        return output_error(1, submit_rejected_message(&conflicts));
    };

    // Persist ONLY what the server accepted (`submitted`/`submittedFolders`),
    // never the locally-computed request — see `apply_submit_persistence`.
    let mut changelist = g.changelist;
    let mut manifest = g.manifest;
    apply_submit_persistence(
        &mut changelist,
        &mut manifest,
        &submitted,
        &submitted_folders,
        &hash_by_path,
    );
    if let Err(e) = config::save_changelist(&ctx.dirs, &g.key, &changelist) {
        return output_error(1, format!("failed to save changelist: {e}"));
    }
    if let Err(e) = config::save_manifest(&ctx.dirs, &g.key, &manifest) {
        return output_error(1, format!("failed to save manifest: {e}"));
    }

    // Best-effort: the revision is already committed server-side regardless
    // of whether this local chmod succeeds, so a failure here doesn't merit
    // failing the whole (already-landed) submit — same reasoning as
    // `revert`'s equivalent pass.
    let ro_targets: Vec<String> =
        submitted.iter().filter(|p| hash_by_path.contains_key(*p)).cloned().collect();
    if !ro_targets.is_empty() {
        let ws = workspace_folder.display().to_string();
        workspace::set_writable_impl(&ws, &ro_targets, false).await;
    }

    let human = submit_success_message(revision, submitted.len(), &conflicts);
    let mut out = output_ok(
        human,
        serde_json::json!({
            "revision": revision,
            "submitted": submitted,
            "submittedFolders": submitted_folders,
            "conflicts": conflicts.iter()
                .map(|(p, r)| serde_json::json!({"path": p, "reason": r}))
                .collect::<Vec<_>>(),
        }),
    );
    out.changed = vec!["files", "locks"];
    out
}

// ---------------------------------------------------------------------- pull

/// One `pull` invocation's candidate selection: which depot paths (in scope)
/// need downloading, which `CheckedOutByMe` paths were skipped instead of
/// silently overwritten, and how many in-scope depot paths were already at
/// head (for the summary line). A path is a candidate when it's missing
/// locally OR the manifest's recorded base no longer matches the server head
/// hash (see the task-13 brief) — a delete-marked path is never a candidate
/// (nothing to sync down, the user is about to remove it) regardless of
/// staleness. Pure — everything it reads is already gathered — so the
/// selection rules are unit-tested without a server.
struct PullTargets {
    candidates: Vec<String>,
    skipped_checked_out: Vec<String>,
    up_to_date: usize,
}

fn select_pull_targets(
    server_files: &BTreeMap<String, status_engine::ServerFile>,
    local_files: &BTreeMap<String, LocalFile>,
    manifest: &BTreeMap<String, Option<String>>,
    changelist: &Changelist,
    statuses: &BTreeMap<String, FileStatus>,
    in_scope: impl Fn(&str) -> bool,
) -> PullTargets {
    let mut candidates = Vec::new();
    let mut skipped_checked_out = Vec::new();
    let mut up_to_date = 0usize;
    for (path, server) in server_files {
        if !in_scope(path) || changelist.del.contains(path) {
            continue;
        }
        if matches!(statuses.get(path), Some(FileStatus::CheckedOutByMe)) {
            skipped_checked_out.push(path.clone());
            continue;
        }
        let missing = !local_files.contains_key(path);
        let base = manifest.get(path).and_then(|b| b.as_ref());
        let stale = base != server.hash.as_ref();
        if !missing && !stale {
            up_to_date += 1;
            continue;
        }
        candidates.push(path.clone());
    }
    PullTargets { candidates, skipped_checked_out, up_to_date }
}

/// The human summary line for a completed (or no-op) pull. `"Workspace up to
/// date."` only when nothing was pulled AND nothing was skipped — otherwise
/// a breakdown naming every skipped path, so a `CheckedOutByMe` file's
/// absence from the pull is never silent. Pure, unit-tested.
fn pull_summary_message(pulled: usize, up_to_date: usize, skipped_checked_out: &[String]) -> String {
    if pulled == 0 && skipped_checked_out.is_empty() {
        return "Workspace up to date.".to_string();
    }
    let mut parts = vec![format!("pulled {pulled} file(s)"), format!("{up_to_date} up to date")];
    if !skipped_checked_out.is_empty() {
        parts.push(format!("skipped (checked out): {}", skipped_checked_out.join(", ")));
    }
    format!("{}.", parts.join("; "))
}

/// The exit-1 error for a pull whose download partially (or wholly) failed.
/// The manifest is (by construction) only updated for paths that succeeded
/// — see `pull`'s use of `outcome.updates` — so every failed path here stays
/// exactly as stale as it was, and a re-run of `pull` will pick it up again.
/// Pure, unit-tested, mirrors `revert_pull_failure_message`.
fn pull_failure_message(failures: &BTreeMap<String, String>) -> String {
    let paths: Vec<&str> = failures.keys().map(String::as_str).collect();
    let first_err = failures.values().next().map(String::as_str).unwrap_or("download failed");
    format!(
        "pull: failed to download {} ({first_err}); their manifest base was left unchanged \
         — re-running 'pull' will retry them.",
        paths.join(", ")
    )
}

/// Serializes a path->error failure map into the `[{path, error}]` rows both
/// pull's and submit's per-file failure outputs additively carry in `data`
/// (Task 18d) — the GUI's `submitDialogs`/pull failure mapper reads this
/// instead of having to re-parse the joined human summary. `BTreeMap`
/// iteration order (sorted by path) makes the output deterministic, so it's
/// unit-tested directly rather than as a set.
fn failures_json(failures: &BTreeMap<String, String>) -> Vec<serde_json::Value> {
    failures.iter().map(|(path, error)| serde_json::json!({"path": path, "error": error})).collect()
}

/// `pull`'s download-failure `CommandOutput`: the existing human message and
/// `changed: ["files"]` (a partial download can still have changed files on
/// disk for the paths that DID land), plus the additive `data.failures` rows.
/// Pure, unit-tested.
fn pull_failure_output(failures: &BTreeMap<String, String>) -> CommandOutput {
    let mut out = output_error(1, pull_failure_message(failures));
    out.data = serde_json::json!({"failures": failures_json(failures)});
    out.changed = vec!["files"];
    out
}

/// `pull [<path>...] [-f]` (default: whole workspace). Targets are depot
/// paths missing locally or stale against the manifest base
/// (`select_pull_targets`); a `CheckedOutByMe` path is always skipped rather
/// than overwritten. Without `-f`, any candidate carrying real local edits
/// (`has_real_local_changes` — the same predicate `revert`'s `-f` gate
/// uses) refuses the WHOLE pull up front, naming the first such path in
/// sorted order, before anything downloads. Downloads land read-only
/// (`restore_via_pull(..., writable: false)`, which also handles the
/// chmod — no separate pass needed); the manifest is updated only for paths
/// that actually landed, so a partial download failure leaves the rest
/// exactly as stale as before and a re-run retries just them.
async fn pull(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (path_args, force) = match &cli.cmd {
        Cmd::Pull { paths, force } => (paths.clone(), *force),
        _ => unreachable!("pull() only called for Cmd::Pull"),
    };

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let cwd = std::env::current_dir().unwrap_or_default();
    let (project_id, _name, workspace_folder) = match resolve_project(ctx, &cli.project, &cwd) {
        Ok(v) => v,
        Err(out) => return out,
    };
    let targets = match paths::resolve_targets(&path_args, &cwd, &workspace_folder) {
        Ok(t) => t,
        Err(e) => return output_error(2, e),
    };

    let g = match gather(ctx, &project_id, &workspace_folder).await {
        Ok(g) => g,
        Err(out) => return out,
    };

    let in_scope = |p: &str| targets.is_empty() || targets.iter().any(|t| paths::matches_target(p, t));

    let picked = select_pull_targets(
        &g.server_files,
        &g.local_files,
        &g.manifest,
        &g.changelist,
        &g.result.statuses,
        in_scope,
    );

    if !force {
        for path in &picked.candidates {
            if has_real_local_changes(path, &g.local_files, &g.server_files, &g.manifest) {
                return output_error(
                    1,
                    format!(
                        "refusing to pull: this would overwrite local changes to {path}; \
                         pass -f to overwrite."
                    ),
                );
            }
        }
    }

    if picked.candidates.is_empty() {
        let human = pull_summary_message(0, picked.up_to_date, &picked.skipped_checked_out);
        return output_ok(
            human,
            serde_json::json!({
                "pulled": Vec::<String>::new(),
                "upToDate": picked.up_to_date,
                "skippedCheckedOut": picked.skipped_checked_out,
            }),
        );
    }

    let outcome = restore_via_pull(
        ctx,
        &g.session,
        &workspace_folder,
        &g.server_files,
        &picked.candidates,
        false,
    )
    .await;

    let mut manifest = g.manifest;
    for (p, h) in &outcome.updates {
        manifest.insert(p.clone(), h.clone());
    }
    if let Err(e) = config::save_manifest(&ctx.dirs, &g.key, &manifest) {
        return output_error(1, format!("failed to save manifest: {e}"));
    }

    let pulled: Vec<String> = outcome.updates.keys().cloned().collect();

    if !outcome.failures.is_empty() {
        return pull_failure_output(&outcome.failures);
    }

    let human = pull_summary_message(pulled.len(), picked.up_to_date, &picked.skipped_checked_out);
    let mut out = output_ok(
        human,
        serde_json::json!({
            "pulled": pulled,
            "upToDate": picked.up_to_date,
            "skippedCheckedOut": picked.skipped_checked_out,
        }),
    );
    out.changed = vec!["files"];
    out
}

// -------------------------------------------------------------------- rollback

fn env_current_password() -> Option<String> {
    std::env::var("CAMBIUM_CURRENT_PASSWORD").ok()
}

/// `rollback <path> <revision> [-f]`: roll one depot path forward to the
/// content it had at `revision`, then update the local copy to match. The
/// server call is a normal forward commit (see `apply_rollback` in
/// server.py); its business refusals (`nothing to roll back`, `already at
/// that content`, a lock held by someone else) come back as a 200 with
/// `{ok:false, error}`, which this surfaces verbatim as exit 1 — while an
/// invalid revision (predating the oldest snapshot) is a 400 the API client
/// already maps to `ApiError::Server`. The `-f` gate protects real local
/// edits to the target from the post-rollback local overwrite, refusing up
/// front (before moving the server head) with `pull`'s wording.
async fn rollback(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (path_arg, revision, force) = match &cli.cmd {
        Cmd::Rollback { path, revision, force } => (path.clone(), *revision, *force),
        _ => unreachable!("rollback() only called for Cmd::Rollback"),
    };

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let cwd = std::env::current_dir().unwrap_or_default();
    let (project_id, _name, workspace_folder) = match resolve_project(ctx, &cli.project, &cwd) {
        Ok(v) => v,
        Err(out) => return out,
    };
    let targets = match paths::resolve_targets(&[path_arg], &cwd, &workspace_folder) {
        Ok(t) => t,
        Err(e) => return output_error(2, e),
    };
    let Some(depot_path) = targets.into_iter().next() else {
        return output_error(2, "specify a file to roll back.".to_string());
    };

    let g = match gather(ctx, &project_id, &workspace_folder).await {
        Ok(g) => g,
        Err(out) => return out,
    };

    // Refuse BEFORE moving the server head if the local copy carries real
    // edits — the same predicate and up-front discipline as pull/revert.
    if !force && has_real_local_changes(&depot_path, &g.local_files, &g.server_files, &g.manifest) {
        return output_error(1, rollback_overwrite_refusal(&depot_path));
    }

    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    let response = match client.rollback(&depot_path, revision).await {
        Ok(v) => v,
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            return output_error(code, msg);
        }
    };

    // A business refusal is a 200 with ok:false — surface the server's own
    // message (missing file / already at head / lock held) verbatim.
    if response.get("ok").and_then(|v| v.as_bool()) != Some(true) {
        let msg = response
            .get("error")
            .and_then(|v| v.as_str())
            .unwrap_or("rollback refused")
            .to_string();
        return output_error(1, format!("cannot roll back {depot_path}: {msg}."));
    }

    let committed = response.get("revision").and_then(|v| v.as_u64());
    let change = response.get("change").cloned().unwrap_or(serde_json::Value::Null);
    let change_type = change.get("change").and_then(|v| v.as_str()).unwrap_or("");

    // Reflect the new head locally. A rollback-to-delete removes the local
    // copy; a rollback-to-content downloads the new head bytes (read-only),
    // using the hash the response reported so the transfer engine verifies
    // against the right target.
    let ws = workspace_folder.display().to_string();
    let mut manifest = g.manifest;
    match change_type {
        "deleted" => {
            let del_res = workspace::delete_files_impl(&ws, &[depot_path.clone()], &[]).await;
            if !del_res.ok {
                return output_error(
                    1,
                    format!(
                        "rolled back {depot_path} on the server, but failed to remove the local copy \
                         — delete it by hand."
                    ),
                );
            }
            manifest.remove(&depot_path);
        }
        "edited" | "added" => {
            let new_hash = change.get("hash").and_then(|v| v.as_str()).map(String::from);
            let size = change.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
            let mut sfmap: BTreeMap<String, status_engine::ServerFile> = BTreeMap::new();
            sfmap.insert(
                depot_path.clone(),
                status_engine::ServerFile { hash: new_hash.clone(), size_bytes: size, head_revision: committed },
            );
            let outcome =
                restore_via_pull(ctx, &g.session, &workspace_folder, &sfmap, &[depot_path.clone()], false).await;
            if let Some((p, err)) = outcome.failures.iter().next() {
                return output_error(
                    1,
                    format!(
                        "rolled back {depot_path} on the server, but failed to update the local copy \
                         ({p}: {err}); run 'pull -f {depot_path}'."
                    ),
                );
            }
            manifest.insert(depot_path.clone(), new_hash);
        }
        _ => {}
    }
    if let Err(e) = config::save_manifest(&ctx.dirs, &g.key, &manifest) {
        return output_error(1, format!("failed to save manifest: {e}"));
    }

    let mut out = output_ok(
        rollback_success_message(&depot_path, revision, committed),
        serde_json::json!({
            "path": depot_path,
            "requestedRevision": revision,
            "revision": committed,
            "change": change_type,
        }),
    );
    out.changed = vec!["files", "locks"];
    out
}

// ---------------------------------------------------------------------- unlock

/// `unlock -f <path>...`: force-release another user's lock (admin-only,
/// enforced server-side). `-f` is mandatory — without it the command refuses
/// up front (exit 1) rather than silently doing something destructive.
async fn unlock(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (force, path_args) = match &cli.cmd {
        Cmd::Unlock { force, paths } => (*force, paths.clone()),
        _ => unreachable!("unlock() only called for Cmd::Unlock"),
    };

    if !force {
        return output_error(1, unlock_needs_force_message());
    }
    if path_args.is_empty() {
        return output_error(2, "specify a file to unlock.".to_string());
    }

    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return out;
    }
    let cwd = std::env::current_dir().unwrap_or_default();
    let (project_id, _name, workspace_folder) = match resolve_project(ctx, &cli.project, &cwd) {
        Ok(v) => v,
        Err(out) => return out,
    };
    let targets = match paths::resolve_targets(&path_args, &cwd, &workspace_folder) {
        Ok(t) => t,
        Err(e) => return output_error(2, e),
    };
    if targets.is_empty() {
        return output_error(2, "specify a file to unlock.".to_string());
    }

    if let Some(api) = ctx.api.as_mut() {
        api.project_id = Some(project_id.clone());
    }
    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    match client.force_unlock(&targets).await {
        Ok(_) => {
            let mut out = output_ok(
                format!("Force-released: {}", targets.join(", ")),
                serde_json::json!({"paths": targets}),
            );
            out.changed = vec!["locks"];
            out
        }
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            output_error(code, msg)
        }
    }
}

// ------------------------------------------------------------- admin: users

/// Shared front half of every admin user/role verb: resolve a session (env
/// or cached), returning the normalized `@handle` and holding no project
/// scope (these endpoints key off the path/body, not `X-Project`).
async fn admin_session(ctx: &mut ExecContext, raw_handle: &str) -> Result<String, CommandOutput> {
    if let Err(out) = ensure_session(ctx, env_server(), env_user()).await {
        return Err(out);
    }
    let handle = normalize_handle(raw_handle);
    if handle.len() < 2 {
        return Err(output_error(2, "specify a user @handle.".to_string()));
    }
    Ok(handle)
}

/// `adduser @handle <password>`: create a user, or reinstate a disabled
/// handle (the server decides which and reports it back).
async fn adduser(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (raw_handle, password) = match &cli.cmd {
        Cmd::Adduser { handle, password } => (handle.clone(), password.clone()),
        _ => unreachable!("adduser() only called for Cmd::Adduser"),
    };
    let handle = match admin_session(ctx, &raw_handle).await {
        Ok(h) => h,
        Err(out) => return out,
    };
    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    match client.admin_add_user(&handle, &password).await {
        Ok(v) => {
            let reinstated = v.get("reinstated").and_then(|b| b.as_bool()).unwrap_or(false);
            let human = if reinstated {
                format!("Reinstated user {handle}.")
            } else {
                format!("Created user {handle}.")
            };
            let mut out = output_ok(human, v);
            out.changed = vec!["users"];
            out
        }
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            output_error(code, msg)
        }
    }
}

/// `deluser @handle`: disable a user (their sessions are dropped server-side).
async fn deluser(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let raw_handle = match &cli.cmd {
        Cmd::Deluser { handle } => handle.clone(),
        _ => unreachable!("deluser() only called for Cmd::Deluser"),
    };
    let handle = match admin_session(ctx, &raw_handle).await {
        Ok(h) => h,
        Err(out) => return out,
    };
    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    match client.admin_delete_user(&handle).await {
        Ok(v) => {
            let mut out = output_ok(format!("Removed user {handle}."), v);
            out.changed = vec!["users"];
            out
        }
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            output_error(code, msg)
        }
    }
}

/// `password @handle <password>`: reset a password. Your OWN handle goes
/// through `/api/account/password` (needs the current password, read from
/// `CAMBIUM_CURRENT_PASSWORD` — the CLI's `main.rs` prompts for it and sets
/// that var when interactive); any other handle is an admin reset via
/// `/api/admin/users/<handle>/password`.
async fn password(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (raw_handle, new_password) = match &cli.cmd {
        Cmd::Password { handle, password } => (handle.clone(), password.clone()),
        _ => unreachable!("password() only called for Cmd::Password"),
    };
    let handle = match admin_session(ctx, &raw_handle).await {
        Ok(h) => h,
        Err(out) => return out,
    };

    // "own" is decided against the session's canonical username, not the typed
    // string — the same source `is_own_handle` uses in main.rs.
    let is_own = SessionCache::load(&ctx.dirs)
        .ok()
        .flatten()
        .map(|s| s.username.eq_ignore_ascii_case(&handle))
        .unwrap_or(false);

    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    let result = if is_own {
        let current = match env_current_password() {
            Some(p) => p,
            None => {
                return output_error(
                    3,
                    "changing your own password needs the current one; set \
                     CAMBIUM_CURRENT_PASSWORD or run 'password' from a terminal."
                        .to_string(),
                )
            }
        };
        client.account_password(&current, &new_password).await
    } else {
        client.admin_set_password(&handle, &new_password).await
    };

    match result {
        Ok(_) => {
            let mut out = output_ok(
                format!("Password changed for {handle}."),
                serde_json::json!({"handle": handle}),
            );
            if !is_own {
                out.changed = vec!["users"];
            }
            out
        }
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            output_error(code, msg)
        }
    }
}

/// `setrole @handle <role>`: set a user's role ("member" | "admin"). Role
/// validation and the owner-immutable / no-self-change rules are enforced
/// server-side; a refusal surfaces as exit 1 with the server's message.
async fn setrole(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (raw_handle, role) = match &cli.cmd {
        Cmd::Setrole { handle, role } => (handle.clone(), role.clone()),
        _ => unreachable!("setrole() only called for Cmd::Setrole"),
    };
    let handle = match admin_session(ctx, &raw_handle).await {
        Ok(h) => h,
        Err(out) => return out,
    };
    let role = role.to_lowercase();
    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    match client.admin_set_role(&handle, &role).await {
        Ok(v) => {
            let mut out = output_ok(format!("Set {handle}'s role to {role}."), v);
            out.changed = vec!["users"];
            out
        }
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            output_error(code, msg)
        }
    }
}

// -------------------------------------------------------- admin: memberships

/// Resolves a project NAME to its id against the admin roster (every project
/// on the server, not just the caller's), so an admin can manage membership
/// of a project they're not on. A code-5 error listing available names when
/// the name doesn't match — same shape as `link`/`use`.
async fn resolve_admin_project(
    ctx: &ExecContext,
    project_name: &str,
) -> Result<String, CommandOutput> {
    let client = ctx.api.as_ref().expect("caller ensures ctx.api is set");
    let list = match client.admin_projects().await {
        Ok(l) => l,
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            return Err(output_error(code, msg));
        }
    };
    match list.iter().find(|p| p.name == project_name) {
        Some(p) => Ok(p.id.clone()),
        None => {
            let names: Vec<&str> = list.iter().map(|p| p.name.as_str()).collect();
            Err(output_error(
                5,
                format!("no project named '{project_name}'; available: {}.", names.join(", ")),
            ))
        }
    }
}

/// `assignmember @handle <project>`: add a user to a project's roster.
async fn assignmember(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (raw_handle, project_name) = match &cli.cmd {
        Cmd::Assignmember { handle, project } => (handle.clone(), project.clone()),
        _ => unreachable!("assignmember() only called for Cmd::Assignmember"),
    };
    let handle = match admin_session(ctx, &raw_handle).await {
        Ok(h) => h,
        Err(out) => return out,
    };
    let project_id = match resolve_admin_project(ctx, &project_name).await {
        Ok(id) => id,
        Err(out) => return out,
    };
    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    match client.admin_add_member(&project_id, &handle).await {
        Ok(v) => {
            let mut out = output_ok(format!("Added {handle} to {project_name}."), v);
            out.changed = vec!["projects", "users"];
            out
        }
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            output_error(code, msg)
        }
    }
}

/// `removemember @handle <project>`: remove a user from a project's roster.
async fn removemember(cli: &Cli, ctx: &mut ExecContext) -> CommandOutput {
    let (raw_handle, project_name) = match &cli.cmd {
        Cmd::Removemember { handle, project } => (handle.clone(), project.clone()),
        _ => unreachable!("removemember() only called for Cmd::Removemember"),
    };
    let handle = match admin_session(ctx, &raw_handle).await {
        Ok(h) => h,
        Err(out) => return out,
    };
    let project_id = match resolve_admin_project(ctx, &project_name).await {
        Ok(id) => id,
        Err(out) => return out,
    };
    let client = ctx.api.as_ref().expect("ensure_session sets ctx.api on success");
    match client.admin_remove_member(&project_id, &handle).await {
        Ok(v) => {
            let mut out = output_ok(format!("Removed {handle} from {project_name}."), v);
            out.changed = vec!["projects", "users"];
            out
        }
        Err(e) => {
            let (code, msg) = map_api_error(&e);
            output_error(code, msg)
        }
    }
}

// ---------------------------------------------------------------------- tests

#[cfg(test)]
mod tests {
    use super::*;
    use crate::transfer::{FileError, PullResult};

    #[test]
    fn unsubmittable_hints_explain_outdated_and_locked_targets() {
        let outdated = "a.txt".to_string();
        let locked = "b.txt".to_string();
        let elsewhere = "c.txt".to_string();
        let clean = "d.txt".to_string();
        let statuses = vec![
            (&outdated, FileStatus::Outdated { head_revision: Some(9) }),
            (&locked, FileStatus::LockedByOther { holder: "@buddy".to_string() }),
            (&elsewhere, FileStatus::LockedByMeElsewhere { device_name: "laptop".to_string() }),
            (&clean, FileStatus::CheckedOutByMe),
        ];
        let hints = unsubmittable_target_hints(statuses.iter().map(|(p, st)| (*p, st)));
        // One hint each for outdated / locked-by-other / locked-elsewhere; the
        // CheckedOutByMe file (a real candidate) produces none.
        assert_eq!(hints.len(), 3);
        assert!(hints.iter().any(|h| h.contains("a.txt") && h.contains("changed on the server")));
        assert!(hints.iter().any(|h| h.contains("b.txt") && h.contains("@buddy")));
        assert!(hints.iter().any(|h| h.contains("c.txt") && h.contains("laptop")));
    }

    #[test]
    fn unsubmittable_hints_never_mention_a_modified_file() {
        // Modified is submittable now (the server auto-checks-out an unlocked
        // file whose base matches head), so it must never appear as a reason
        // nothing could be submitted.
        let modified = "a.txt".to_string();
        let statuses = vec![(&modified, FileStatus::Modified)];
        let hints = unsubmittable_target_hints(statuses.iter().map(|(p, st)| (*p, st)));
        assert!(hints.is_empty());
    }

    #[test]
    fn unsubmittable_hints_empty_for_a_clean_scope() {
        let clean = "d.txt".to_string();
        let up_to_date = "e.txt".to_string();
        let statuses = vec![
            (&clean, FileStatus::CheckedOutByMe),
            (&up_to_date, FileStatus::NotAdded),
        ];
        let hints = unsubmittable_target_hints(statuses.iter().map(|(p, st)| (*p, st)));
        assert!(hints.is_empty());
    }

    /// A PullResult carrying exactly these per-file errors (empty path =
    /// pull_files_impl's whole-request failure shape) — the injected stand-in
    /// for a dead/flaky server that the partial-failure helpers are tested
    /// against, per the task-12 review findings.
    fn pull_result_with_errors(errors: &[(&str, &str)]) -> PullResult {
        PullResult {
            ok: errors.is_empty(),
            changed: vec![],
            created: vec![],
            dirs_created: vec![],
            errors: errors
                .iter()
                .map(|(p, e)| FileError { path: (*p).to_string(), error: (*e).to_string() })
                .collect(),
        }
    }

    fn paths(list: &[&str]) -> Vec<String> {
        list.iter().map(|s| s.to_string()).collect()
    }

    // ---- restore_failures (finding 1: per-file outcome handling) ----------

    #[test]
    fn restore_failures_marks_only_the_erroring_paths() {
        let candidates = paths(&["a.txt", "b.txt", "c.txt"]);
        let result = pull_result_with_errors(&[("b.txt", "Server error (500).")]);
        let failed = restore_failures(&candidates, &result);
        assert_eq!(failed.len(), 1);
        assert_eq!(failed.get("b.txt").map(String::as_str), Some("Server error (500)."));
        assert!(!failed.contains_key("a.txt"));
        assert!(!failed.contains_key("c.txt"));
    }

    #[test]
    fn restore_failures_blank_path_error_fails_every_candidate() {
        // Client-construction / PULL_IN_PROGRESS failures come back with an
        // empty path — nothing was attempted, so every candidate failed.
        let candidates = paths(&["a.txt", "b.txt"]);
        let result = pull_result_with_errors(&[("", "A pull is already running for this workspace.")]);
        let failed = restore_failures(&candidates, &result);
        assert_eq!(failed.len(), 2);
        for p in ["a.txt", "b.txt"] {
            assert!(failed.contains_key(p), "{p} should be marked failed");
        }
    }

    #[test]
    fn restore_failures_empty_errors_means_nothing_failed() {
        let candidates = paths(&["a.txt"]);
        let failed = restore_failures(&candidates, &pull_result_with_errors(&[]));
        assert!(failed.is_empty());
    }

    // ---- apply_revert_unmarks (finding 1: persist only what succeeded) ----

    #[test]
    fn revert_unmarks_keep_failed_deletes_marked_and_clear_restored_ones() {
        let mut cl = Changelist::default();
        cl.del.insert("ok.txt".into());
        cl.del.insert("bad.txt".into());
        cl.add.insert("new.txt".into());

        let mut failed = BTreeMap::new();
        failed.insert("bad.txt".to_string(), "download failed".to_string());

        apply_revert_unmarks(
            &mut cl,
            &paths(&["new.txt"]),
            &paths(&["ok.txt", "bad.txt"]),
            &failed,
        );

        // The restored delete un-marked; the failed one still marked (a
        // re-run of revert picks it up again).
        assert!(!cl.del.contains("ok.txt"));
        assert!(cl.del.contains("bad.txt"), "failed restore must stay marked for delete");
        // Un-marking an add never depends on a download.
        assert!(!cl.add.contains("new.txt"));
    }

    #[test]
    fn revert_unmarks_cancel_folder_marks_only_via_successfully_restored_members() {
        let mut cl = Changelist::default();
        cl.del.insert("Art/ok.txt".into());
        cl.del.insert("Fx/bad.txt".into());
        cl.del_folders.insert("Art".into());
        cl.del_folders.insert("Fx".into());

        let mut failed = BTreeMap::new();
        failed.insert("Fx/bad.txt".to_string(), "download failed".to_string());

        apply_revert_unmarks(&mut cl, &[], &paths(&["Art/ok.txt", "Fx/bad.txt"]), &failed);

        assert!(!cl.del_folders.contains("Art"), "restored member cancels its folder mark");
        assert!(cl.del_folders.contains("Fx"), "failed member must NOT cancel its folder mark");
        assert!(cl.del.contains("Fx/bad.txt"));
    }

    #[test]
    fn revert_failure_message_names_paths_and_promises_a_retry() {
        let mut failed = BTreeMap::new();
        failed.insert("a.txt".to_string(), "Server error (500).".to_string());
        failed.insert("b.txt".to_string(), "connection failed".to_string());
        let msg = revert_pull_failure_message(&failed);
        assert!(msg.contains("a.txt"), "must name every failed path: {msg}");
        assert!(msg.contains("b.txt"), "must name every failed path: {msg}");
        assert!(msg.contains("stay marked"), "must state the changelist keeps them: {msg}");
        assert!(
            msg.contains("re-running 'revert' will retry them"),
            "must say a re-run retries: {msg}"
        );
    }

    // ---- checkout_failure_message (finding 2: lock-rollback wording) ------

    #[test]
    fn checkout_failure_message_after_successful_rollback_release() {
        let msg = checkout_failure_message(
            "failed to save manifest: disk full",
            true,
            &paths(&["a.txt"]),
        );
        assert!(msg.contains("failed to save manifest"), "must carry the root cause: {msg}");
        assert!(msg.contains("released"), "must say the locks were rolled back: {msg}");
        assert!(msg.contains("re-running 'checkout' is safe"), "must say a re-run is safe: {msg}");
        assert!(!msg.contains("still held"), "released locks must not read as held: {msg}");
    }

    #[test]
    fn checkout_failure_message_when_rollback_release_also_failed() {
        let msg = checkout_failure_message(
            "failed to download content (a.txt: connection failed)",
            false,
            &paths(&["a.txt", "b.txt"]),
        );
        assert!(msg.contains("still held"), "must state the locks are still held: {msg}");
        assert!(msg.contains("a.txt, b.txt"), "must name the held paths: {msg}");
        assert!(msg.contains("re-running 'checkout' is safe"), "must say a re-run is safe: {msg}");
    }

    // ---- submit_naming_block (task 13 fix: block only the add set) --------

    #[test]
    fn submit_naming_block_flags_a_bad_named_add_with_exact_wording() {
        // "CON" is a reserved Windows device name -> unsafe_reason fires.
        let server = BTreeMap::new();
        let adds = paths(&["New/CON"]);
        let msg = submit_naming_block(&adds, &server).expect("a bad-named add must block");
        assert!(
            msg.starts_with("cannot submit: New/CON has a naming issue ("),
            "wrong prefix: {msg}"
        );
        assert!(msg.ends_with("); rename it first."), "wrong suffix: {msg}");
    }

    #[test]
    fn submit_naming_block_flags_a_case_collision_against_the_depot() {
        let mut server = BTreeMap::new();
        server.insert("Art/Old.txt".to_string(), server_file("h0"));
        let adds = paths(&["art/old.txt"]); // folds onto the existing depot path
        let msg = submit_naming_block(&adds, &server).expect("a case collision must block");
        assert!(msg.contains("art/old.txt has a naming issue"), "got: {msg}");
    }

    #[test]
    fn submit_naming_block_passes_clean_adds() {
        let mut server = BTreeMap::new();
        server.insert("existing.txt".to_string(), server_file("h0"));
        let adds = paths(&["New/thing.txt", "Other/file.bin"]);
        assert_eq!(submit_naming_block(&adds, &server), None);
    }

    // ---- submit_stale_block (task 13 fix: block only the edit set) --------

    #[test]
    fn submit_stale_block_flags_an_edit_behind_head_with_exact_wording() {
        let mut manifest = BTreeMap::new();
        manifest.insert("a.txt".to_string(), Some("base-hash".to_string()));
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("new-head-hash"));
        let edits = paths(&["a.txt"]);
        let msg = submit_stale_block(&edits, &manifest, &server).expect("a stale edit must block");
        assert_eq!(msg, "cannot submit: a.txt is outdated; run 'pull a.txt' first.");
    }

    #[test]
    fn submit_stale_block_passes_an_edit_at_head() {
        let mut manifest = BTreeMap::new();
        manifest.insert("a.txt".to_string(), Some("h1".to_string()));
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("h1"));
        assert_eq!(submit_stale_block(&paths(&["a.txt"]), &manifest, &server), None);
    }

    #[test]
    fn submit_stale_block_ignores_edit_with_no_recorded_base() {
        // No manifest base (e.g. an add-then-edit) -> the server's own baseHash
        // check is authoritative; nothing to block on client-side.
        let manifest = BTreeMap::new();
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("h1"));
        assert_eq!(submit_stale_block(&paths(&["a.txt"]), &manifest, &server), None);
    }

    // ---- apply_submit_persistence (task 13 fix: persist only accepted set) -

    #[test]
    fn apply_submit_persistence_clears_only_the_accepted_subset() {
        let mut cl = Changelist::default();
        cl.add.insert("subA/a.txt".into());
        cl.add.insert("subB/b.txt".into());
        let mut manifest = BTreeMap::new();
        let mut hash_by_path = BTreeMap::new();
        hash_by_path.insert("subA/a.txt".to_string(), "hA".to_string());

        // Server accepted only subA (a scoped submit, or a partial accept).
        apply_submit_persistence(
            &mut cl,
            &mut manifest,
            &paths(&["subA/a.txt"]),
            &[],
            &hash_by_path,
        );

        assert!(!cl.add.contains("subA/a.txt"), "the accepted add must clear");
        assert!(
            cl.add.contains("subB/b.txt"),
            "the unaccepted add must stay pending in the changelist"
        );
        assert_eq!(
            manifest.get("subA/a.txt"),
            Some(&Some("hA".to_string())),
            "the accepted add records its uploaded hash as the new base"
        );
        assert!(!manifest.contains_key("subB/b.txt"), "the unaccepted add gets no manifest base");
    }

    #[test]
    fn apply_submit_persistence_delete_drops_manifest_entry_and_clears_folder() {
        let mut cl = Changelist::default();
        cl.del.insert("gone.txt".into());
        cl.del_folders.insert("OldDir".into());
        let mut manifest = BTreeMap::new();
        manifest.insert("gone.txt".to_string(), Some("h1".to_string()));
        let hash_by_path = BTreeMap::new(); // deletes carry no upload hash

        apply_submit_persistence(
            &mut cl,
            &mut manifest,
            &paths(&["gone.txt"]),
            &paths(&["OldDir"]),
            &hash_by_path,
        );

        assert!(!cl.del.contains("gone.txt"));
        assert!(!cl.del_folders.contains("OldDir"), "accepted folder delete clears its mark");
        assert!(
            !manifest.contains_key("gone.txt"),
            "a submitted delete drops the manifest base entirely"
        );
    }

    // ---- checked_out_is_modified (task 13: edit-vs-unchanged detection) ---

    #[test]
    fn checked_out_is_modified_when_local_hash_differs_from_server() {
        let mut local = BTreeMap::new();
        local.insert("a.txt".to_string(), local_file("edited-hash"));
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("orig-hash"));
        assert!(checked_out_is_modified("a.txt", &local, &server));
    }

    #[test]
    fn checked_out_is_modified_false_when_hashes_match() {
        let mut local = BTreeMap::new();
        local.insert("a.txt".to_string(), local_file("same-hash"));
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("same-hash"));
        assert!(!checked_out_is_modified("a.txt", &local, &server));
    }

    #[test]
    fn checked_out_is_modified_false_when_local_copy_missing() {
        let local = BTreeMap::new();
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("orig-hash"));
        assert!(!checked_out_is_modified("a.txt", &local, &server));
    }

    // ---- submit_edit_candidate ---------------------------------------------
    //
    // Which statuses belong in a submit's EDIT set. The server (apply_submit in
    // server.py) accepts an `edited` file when the submitter holds the lock OR
    // the file is unlocked and their base hash still matches the depot head —
    // its documented "auto-checkout" path. `FileStatus::Modified` is exactly
    // that second case (Outdated is the mutually-exclusive base != head one),
    // so it must be a candidate: requiring a manual checkout first was a
    // regression against the pre-core GUI, and it failed silently.

    #[test]
    fn modified_but_unlocked_file_is_an_edit_candidate() {
        let mut local = BTreeMap::new();
        local.insert("a.txt".to_string(), local_file("edited-hash"));
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("orig-hash"));
        assert!(submit_edit_candidate("a.txt", &FileStatus::Modified, &local, &server));
    }

    #[test]
    fn checked_out_and_modified_is_an_edit_candidate() {
        let mut local = BTreeMap::new();
        local.insert("a.txt".to_string(), local_file("edited-hash"));
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("orig-hash"));
        assert!(submit_edit_candidate("a.txt", &FileStatus::CheckedOutByMe, &local, &server));
    }

    #[test]
    fn checked_out_but_unchanged_is_not_a_candidate() {
        // Holding a lock is not an edit — submitting it would create an empty
        // revision.
        let mut local = BTreeMap::new();
        local.insert("a.txt".to_string(), local_file("same-hash"));
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("same-hash"));
        assert!(!submit_edit_candidate("a.txt", &FileStatus::CheckedOutByMe, &local, &server));
    }

    #[test]
    fn outdated_is_not_a_candidate() {
        // base != head: the server would refuse this with "changed on the
        // server since you last synced", so it must not reach an upload.
        let mut local = BTreeMap::new();
        local.insert("a.txt".to_string(), local_file("edited-hash"));
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("newer-hash"));
        assert!(!submit_edit_candidate(
            "a.txt",
            &FileStatus::Outdated { head_revision: Some(9) },
            &local,
            &server
        ));
    }

    #[test]
    fn files_locked_by_someone_else_are_not_candidates() {
        let mut local = BTreeMap::new();
        local.insert("a.txt".to_string(), local_file("edited-hash"));
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("orig-hash"));
        assert!(!submit_edit_candidate(
            "a.txt",
            &FileStatus::LockedByOther { holder: "@buddy".to_string() },
            &local,
            &server
        ));
        assert!(!submit_edit_candidate(
            "a.txt",
            &FileStatus::LockedByMeElsewhere { device_name: "laptop".to_string() },
            &local,
            &server
        ));
    }

    #[test]
    fn changelist_only_statuses_are_not_edit_candidates() {
        // Adds and deletes come from the changelist, not the status sweep.
        let local = BTreeMap::new();
        let server = BTreeMap::new();
        for st in [FileStatus::MarkedForAdd, FileStatus::MarkedForDelete, FileStatus::NotAdded] {
            assert!(!submit_edit_candidate("a.txt", &st, &local, &server));
        }
    }

    // ---- submit_base_hash (task 13: baseHash fallback) ---------------------

    #[test]
    fn submit_base_hash_uses_manifest_when_present() {
        let mut manifest = BTreeMap::new();
        manifest.insert("a.txt".to_string(), Some("base-hash".to_string()));
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("head-hash"));
        assert_eq!(submit_base_hash("a.txt", &manifest, &server), Some("base-hash".to_string()));
    }

    #[test]
    fn submit_base_hash_falls_back_to_server_hash_when_manifest_unknown() {
        let manifest: BTreeMap<String, Option<String>> = BTreeMap::new();
        let mut server = BTreeMap::new();
        server.insert("a.txt".to_string(), server_file("head-hash"));
        assert_eq!(submit_base_hash("a.txt", &manifest, &server), Some("head-hash".to_string()));
    }

    // ---- parse_submit_response (task 13: server response shape) -----------

    #[test]
    fn parse_submit_response_success_shape() {
        let body = serde_json::json!({
            "ok": true,
            "revision": 7,
            "submitted": ["a.txt", "b.txt"],
            "submittedFolders": ["Art"],
            "conflicts": [],
            "failedFolders": [],
        });
        let (revision, submitted, submitted_folders, conflicts) = parse_submit_response(&body);
        assert_eq!(revision, Some(7));
        assert_eq!(submitted, vec!["a.txt".to_string(), "b.txt".to_string()]);
        assert_eq!(submitted_folders, vec!["Art".to_string()]);
        assert!(conflicts.is_empty());
    }

    #[test]
    fn parse_submit_response_all_conflicted_shape() {
        let body = serde_json::json!({
            "ok": true,
            "revision": serde_json::Value::Null,
            "submitted": [],
            "submittedFolders": [],
            "conflicts": [{"path": "a.txt", "reason": "checked out by dana (Dana's PC)"}],
            "failedFolders": [],
        });
        let (revision, submitted, _folders, conflicts) = parse_submit_response(&body);
        assert_eq!(revision, None);
        assert!(submitted.is_empty());
        assert_eq!(
            conflicts,
            vec![("a.txt".to_string(), "checked out by dana (Dana's PC)".to_string())]
        );
    }

    // ---- submit_success_message / submit_rejected_message -----------------

    #[test]
    fn submit_success_message_matches_the_spec_line_exactly() {
        let msg = submit_success_message(3, 2, &[]);
        assert_eq!(msg, "Submitted revision 3: 2 file(s).");
    }

    #[test]
    fn submit_success_message_appends_conflicts_note() {
        let msg = submit_success_message(3, 1, &[("b.txt".to_string(), "already exists".to_string())]);
        assert!(msg.starts_with("Submitted revision 3: 1 file(s)."));
        assert!(msg.contains("b.txt (already exists)"));
    }

    #[test]
    fn submit_rejected_message_names_conflicts() {
        let msg = submit_rejected_message(&[("a.txt".to_string(), "changed on the server".to_string())]);
        assert!(msg.starts_with("submit rejected:"));
        assert!(msg.contains("a.txt (changed on the server)"));
    }

    // ---- select_pull_targets (task 13: pull candidate selection) ----------

    fn local_file(hash: &str) -> LocalFile {
        LocalFile { hash: Some(hash.to_string()), writable: false, size_bytes: 4 }
    }

    fn server_file(hash: &str) -> status_engine::ServerFile {
        status_engine::ServerFile {
            hash: Some(hash.to_string()),
            size_bytes: 4,
            head_revision: Some(1),
        }
    }

    fn always_in_scope(_: &str) -> bool {
        true
    }

    #[test]
    fn select_pull_targets_includes_missing_files() {
        let mut server = BTreeMap::new();
        server.insert("new.txt".to_string(), server_file("h1"));
        let local = BTreeMap::new();
        let manifest = BTreeMap::new();
        let changelist = Changelist::default();
        let statuses = BTreeMap::new();
        let picked =
            select_pull_targets(&server, &local, &manifest, &changelist, &statuses, always_in_scope);
        assert_eq!(picked.candidates, vec!["new.txt".to_string()]);
        assert_eq!(picked.up_to_date, 0);
        assert!(picked.skipped_checked_out.is_empty());
    }

    #[test]
    fn select_pull_targets_includes_stale_base_and_counts_up_to_date() {
        let mut server = BTreeMap::new();
        server.insert("stale.txt".to_string(), server_file("h2"));
        server.insert("fresh.txt".to_string(), server_file("h1"));
        let mut local = BTreeMap::new();
        local.insert("stale.txt".to_string(), local_file("h1")); // unmodified, base=h1, head=h2
        local.insert("fresh.txt".to_string(), local_file("h1"));
        let mut manifest = BTreeMap::new();
        manifest.insert("stale.txt".to_string(), Some("h1".to_string()));
        manifest.insert("fresh.txt".to_string(), Some("h1".to_string()));
        let changelist = Changelist::default();
        let statuses = BTreeMap::new();
        let picked =
            select_pull_targets(&server, &local, &manifest, &changelist, &statuses, always_in_scope);
        assert_eq!(picked.candidates, vec!["stale.txt".to_string()]);
        assert_eq!(picked.up_to_date, 1);
    }

    #[test]
    fn select_pull_targets_skips_checked_out_by_me_and_reports_it() {
        let mut server = BTreeMap::new();
        server.insert("locked.txt".to_string(), server_file("h2"));
        let mut local = BTreeMap::new();
        local.insert("locked.txt".to_string(), local_file("h1"));
        let mut manifest = BTreeMap::new();
        manifest.insert("locked.txt".to_string(), Some("h1".to_string()));
        let changelist = Changelist::default();
        let mut statuses = BTreeMap::new();
        statuses.insert("locked.txt".to_string(), FileStatus::CheckedOutByMe);
        let picked =
            select_pull_targets(&server, &local, &manifest, &changelist, &statuses, always_in_scope);
        assert!(picked.candidates.is_empty());
        assert_eq!(picked.skipped_checked_out, vec!["locked.txt".to_string()]);
    }

    #[test]
    fn select_pull_targets_excludes_delete_marked_paths() {
        let mut server = BTreeMap::new();
        server.insert("gone.txt".to_string(), server_file("h1"));
        let local = BTreeMap::new(); // missing locally too -- would otherwise be a candidate
        let manifest = BTreeMap::new();
        let mut changelist = Changelist::default();
        changelist.del.insert("gone.txt".to_string());
        let statuses = BTreeMap::new();
        let picked =
            select_pull_targets(&server, &local, &manifest, &changelist, &statuses, always_in_scope);
        assert!(picked.candidates.is_empty());
        assert_eq!(picked.up_to_date, 0);
        assert!(picked.skipped_checked_out.is_empty());
    }

    #[test]
    fn select_pull_targets_respects_in_scope_filter() {
        let mut server = BTreeMap::new();
        server.insert("Inside/a.txt".to_string(), server_file("h1"));
        server.insert("Outside/b.txt".to_string(), server_file("h1"));
        let local = BTreeMap::new();
        let manifest = BTreeMap::new();
        let changelist = Changelist::default();
        let statuses = BTreeMap::new();
        let picked = select_pull_targets(&server, &local, &manifest, &changelist, &statuses, |p| {
            p.starts_with("Inside/")
        });
        assert_eq!(picked.candidates, vec!["Inside/a.txt".to_string()]);
    }

    // ---- pull_summary_message / pull_failure_message -----------------------

    #[test]
    fn pull_summary_message_up_to_date_when_nothing_happened() {
        assert_eq!(pull_summary_message(0, 3, &[]), "Workspace up to date.");
    }

    #[test]
    fn pull_summary_message_reports_counts_and_skips() {
        let msg = pull_summary_message(2, 1, &["locked.txt".to_string()]);
        assert!(msg.contains("pulled 2 file(s)"));
        assert!(msg.contains("1 up to date"));
        assert!(msg.contains("skipped (checked out): locked.txt"));
    }

    #[test]
    fn pull_failure_message_names_paths_and_promises_a_retry() {
        let mut failed = BTreeMap::new();
        failed.insert("a.txt".to_string(), "Server error (500).".to_string());
        let msg = pull_failure_message(&failed);
        assert!(msg.contains("a.txt"));
        assert!(msg.contains("re-running 'pull' will retry them"));
    }

    // ---- Task 18d: additive data.failures on pull/submit failure outputs --

    #[test]
    fn failures_json_pairs_path_with_error_in_sorted_order() {
        let mut failed = BTreeMap::new();
        failed.insert("b.txt".to_string(), "timed out".to_string());
        failed.insert("a.txt".to_string(), "Server error (500).".to_string());
        let rows = failures_json(&failed);
        assert_eq!(
            rows,
            vec![
                serde_json::json!({"path": "a.txt", "error": "Server error (500)."}),
                serde_json::json!({"path": "b.txt", "error": "timed out"}),
            ]
        );
    }

    #[test]
    fn pull_failure_output_carries_data_failures_and_changed_files() {
        let mut failed = BTreeMap::new();
        failed.insert("a.txt".to_string(), "Server error (500).".to_string());
        let out = pull_failure_output(&failed);
        assert!(!out.ok);
        assert_eq!(out.code, 1);
        assert_eq!(out.changed, vec!["files"]);
        assert_eq!(
            out.data,
            serde_json::json!({"failures": [{"path": "a.txt", "error": "Server error (500)."}]})
        );
    }

    #[test]
    fn submit_upload_failure_output_carries_data_failures() {
        let mut failed = BTreeMap::new();
        failed.insert("new.txt".to_string(), "connection reset".to_string());
        let out = submit_upload_failure_output(&failed);
        assert!(!out.ok);
        assert_eq!(out.code, 1);
        assert!(out.human.contains("new.txt: connection reset"));
        assert_eq!(
            out.data,
            serde_json::json!({"failures": [{"path": "new.txt", "error": "connection reset"}]})
        );
    }

    // ---- Task 15: handle normalization + rollback/unlock wording -----------

    #[test]
    fn normalize_handle_forces_a_single_leading_at() {
        assert_eq!(normalize_handle("robin"), "@robin");
        assert_eq!(normalize_handle("@robin"), "@robin");
        assert_eq!(normalize_handle("@@robin"), "@robin");
        assert_eq!(normalize_handle(""), "");
        assert_eq!(normalize_handle("@"), "");
    }

    #[test]
    fn rollback_overwrite_refusal_matches_the_pull_shaped_wording() {
        let msg = rollback_overwrite_refusal("art/rig.blend");
        assert_eq!(
            msg,
            "refusing to roll back: this would overwrite local changes to art/rig.blend; pass -f to overwrite."
        );
    }

    #[test]
    fn rollback_success_message_names_requested_and_committed_revisions() {
        assert_eq!(
            rollback_success_message("a.txt", 3, Some(9)),
            "Rolled back a.txt to revision 3 (now revision 9)."
        );
        assert_eq!(
            rollback_success_message("a.txt", 3, None),
            "Rolled back a.txt to revision 3."
        );
    }

    #[test]
    fn unlock_needs_force_message_is_the_exact_spec_string() {
        assert_eq!(
            unlock_needs_force_message(),
            "unlock force-releases another user's lock; pass -f to confirm."
        );
    }

    // ---- final review fix: shared --remember decision -----------------------
    //
    // Full matrix over the three inputs `decide_remember` takes: whether
    // `--remember` was requested at all, whether a password is already
    // available from the environment (CAMBIUM_PASSWORD), and whether this
    // run is interactive (has a terminal to prompt on). This is the pure
    // logic behind both the CLI's interactive `Ready` arm (main.rs) and the
    // non-interactive `login` verb's `Ok(())` arm above — neither has any
    // other place left to drift, since both consult this one function.

    #[test]
    fn decide_remember_skips_when_not_requested() {
        // remember=false: env/interactive shouldn't matter at all.
        assert_eq!(decide_remember(false, false, false), RememberDecision::Skip);
        assert_eq!(decide_remember(false, false, true), RememberDecision::Skip);
        assert_eq!(decide_remember(false, true, false), RememberDecision::Skip);
        assert_eq!(decide_remember(false, true, true), RememberDecision::Skip);
    }

    #[test]
    fn decide_remember_uses_env_password_when_present_regardless_of_interactivity() {
        // This is exactly FIX 1's case: interactive=true, a password is
        // already sitting in CAMBIUM_PASSWORD — must NOT fall through to a
        // prompt.
        assert_eq!(decide_remember(true, true, true), RememberDecision::UseEnvPassword);
        // And the existing non-interactive path: same env password, no tty.
        assert_eq!(decide_remember(true, true, false), RememberDecision::UseEnvPassword);
    }

    #[test]
    fn decide_remember_prompts_only_when_interactive_and_no_env_password() {
        assert_eq!(decide_remember(true, false, true), RememberDecision::Prompt);
    }

    #[test]
    fn decide_remember_fails_when_no_env_password_and_not_interactive() {
        // The property FIX in executor::login()'s Err(_) branch relies on:
        // --remember must never claim success while storing nothing, and the
        // only way to detect "nothing to store" here is this exact case.
        assert_eq!(decide_remember(true, false, false), RememberDecision::Fail);
    }

    // ---- Task 18b: ctx.progress forwarded to the transfer sink -------------

    fn sha_hex(data: &[u8]) -> String {
        use sha2::{Digest, Sha256};
        hex::encode(Sha256::digest(data))
    }

    /// A counting `ProgressSink`: the events it captured, plus the sink
    /// itself to hand to `ctx.progress`.
    fn counting_sink(
    ) -> (std::sync::Arc<std::sync::Mutex<Vec<transfer::TransferProgress>>>, transfer::ProgressSink)
    {
        let events: std::sync::Arc<std::sync::Mutex<Vec<transfer::TransferProgress>>> =
            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
        let sink = {
            let events = events.clone();
            Some(std::sync::Arc::new(move |p: transfer::TransferProgress| {
                events.lock().unwrap().push(p);
            }) as std::sync::Arc<dyn Fn(transfer::TransferProgress) + Send + Sync>)
        };
        (events, sink)
    }

    /// Background tiny_http server answering every request with the same
    /// fixed body — enough for a single-file pull/upload round trip.
    fn spawn_single_response_server(body: Vec<u8>) -> String {
        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 _ = req.respond(tiny_http::Response::from_data(body.clone()));
            }
        });
        format!("http://127.0.0.1:{port}")
    }

    #[tokio::test]
    async fn restore_via_pull_forwards_ctx_progress_to_the_pull_sink() {
        let content = b"hello world".to_vec();
        let hash = sha_hex(&content);
        let base = spawn_single_response_server(content.clone());

        let tmp = tempfile::tempdir().unwrap();
        let dirs = CambiumDirs { root: tmp.path().to_path_buf() };
        let (events, sink) = counting_sink();
        let mut ctx = ExecContext {
            dirs,
            api: Some(ApiClient::new(&base, None).unwrap()),
            me: None,
            project: None,
            workspace: None,
            interactive: false,
            progress: sink,
        };
        ctx.api.as_mut().unwrap().token = Some("tok".into());

        let session = SessionCache {
            format_version: 1,
            host: base.clone(),
            username: "@robin".into(),
            token: "tok".into(),
        };

        let mut server_files = BTreeMap::new();
        server_files.insert(
            "a.txt".to_string(),
            status_engine::ServerFile { hash: Some(hash), size_bytes: content.len() as u64, head_revision: Some(1) },
        );

        let workspace_folder = tmp.path().join("ws");
        std::fs::create_dir_all(&workspace_folder).unwrap();

        let outcome = restore_via_pull(
            &ctx,
            &session,
            &workspace_folder,
            &server_files,
            &["a.txt".to_string()],
            false,
        )
        .await;

        assert!(outcome.failures.is_empty(), "pull should succeed: {:?}", outcome.failures);
        assert!(!events.lock().unwrap().is_empty(), "ctx.progress sink was never invoked");
    }

    #[tokio::test]
    async fn submit_forwards_ctx_progress_to_the_upload_sink() {
        // Serve every route (depot tree, locks, ignore, upload, submit) with a
        // response good enough to reach the upload call: an empty depot/locks
        // list, no ignore rules, and a successful-looking upload response —
        // the sink fires from inside upload_files_impl regardless of what
        // /api/submit itself answers afterwards.
        let base = spawn_route_server(vec![
            ("/api/depot/tree", b"{}".to_vec()),
            ("/api/locks", br#"{"locks":{}}"#.to_vec()),
            ("/api/ignore", br#"{"text":""}"#.to_vec()),
            ("/api/files/upload", br#"{"hash":"deadbeef"}"#.to_vec()),
        ]);

        let tmp = tempfile::tempdir().unwrap();
        let dirs = CambiumDirs { root: tmp.path().to_path_buf() };

        let workspace_folder = tmp.path().join("ws");
        std::fs::create_dir_all(&workspace_folder).unwrap();
        std::fs::write(workspace_folder.join("new.txt"), b"new file content").unwrap();

        let session = SessionCache {
            format_version: 1,
            host: base.clone(),
            username: "@robin".into(),
            token: "tok".into(),
        };
        session.save(&dirs).unwrap();

        let mut api = ApiClient::new(&base, None).unwrap();
        api.token = Some("tok".into());
        api.project_id = Some("proj-1".into());

        let key = changelist_key("proj-1", "@robin", &workspace_folder);
        let mut changelist = Changelist::default();
        changelist.add.insert("new.txt".to_string());
        config::save_changelist(&dirs, &key, &changelist).unwrap();

        let (events, sink) = counting_sink();
        let mut ctx = ExecContext {
            dirs,
            api: Some(api),
            me: Some(DeviceIdentity { device_id: "dev-1".into(), device_name: "Test Box".into() }),
            project: Some(("proj-1".to_string(), "alpha".to_string())),
            workspace: Some(workspace_folder),
            interactive: false,
            progress: sink,
        };

        let cli = Cli {
            json: false,
            project: None,
            cmd: Cmd::Submit { paths: vec![], message: "test submit".to_string() },
        };
        let _ = submit(&cli, &mut ctx).await;

        assert!(!events.lock().unwrap().is_empty(), "ctx.progress sink was never invoked");
    }

    /// Background tiny_http server matching request paths exactly (query
    /// string included) against a fixed set of JSON bodies; every other
    /// route 404s.
    fn spawn_route_server(routes: Vec<(&'static str, Vec<u8>)>) -> String {
        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((_, body)) => {
                        let _ = req.respond(tiny_http::Response::from_data(body.clone()));
                    }
                    None => {
                        let _ = req.respond(tiny_http::Response::empty(404));
                    }
                }
            }
        });
        format!("http://127.0.0.1:{port}")
    }

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

    // ---- Finding 1 (task-8 review): `remembered` must reflect what the
    // server actually issued, not merely what `--remember` asked for -------
    //
    // Reachable in practice, not theoretical: server.py's device-limit-
    // reached branch returns 200 with a session token and NO `rememberToken`
    // (and `api.rs::login` returns `Ok` for it — `deviceLimitReached` is just
    // a field on the response), the same happens against any older server
    // that predates the field, and `issue_remember_token` can itself return
    // `None`. Before this fix, `login()` set `remembered = true`
    // unconditionally on any successful login, regardless of whether
    // `remember_token` was ever called.

    /// Serializes CAMBIUM_PASSWORD access across this module's tests that set
    /// it — mirrors auth.rs's `ENV_GUARD`. `login()`'s
    /// `RememberDecision::UseEnvPassword` arm reads the process-global env
    /// var unconditionally, so parallel tests setting it would otherwise
    /// race.
    static REMEMBER_LOGIN_ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());

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

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

    fn set_cambium_password(value: &str) -> CambiumPasswordGuard {
        let lock = REMEMBER_LOGIN_ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
        std::env::set_var("CAMBIUM_PASSWORD", value);
        CambiumPasswordGuard { _lock: lock }
    }

    fn login_cli(server: &str, remember: bool) -> Cli {
        Cli {
            json: false,
            project: None,
            cmd: Cmd::Login {
                server: Some(server.to_string()),
                user: Some("@gina".to_string()),
                remember,
            },
        }
    }

    /// `ctx.api` preset (so `ensure_session` short-circuits with no network
    /// call at all) is how these tests isolate `login()`'s `--remember`
    /// handling from session resolution — the same shortcut
    /// `submit_forwards_ctx_progress_to_the_upload_sink` above uses. The only
    /// network call `login()` then makes is the `RememberDecision::
    /// UseEnvPassword` arm's own `auth::login_with`, against `base`.
    fn login_test_ctx(dirs: CambiumDirs, base: &str) -> ExecContext {
        ExecContext {
            dirs,
            api: Some(ApiClient::new(base, None).unwrap()),
            me: None,
            project: None,
            workspace: None,
            interactive: false,
            progress: None,
        }
    }

    /// RED against the pre-fix code: a login response with no `rememberToken`
    /// (the device-limit-reached shape) reported `"remembered": true` and a
    /// human line claiming the password/token was remembered, while writing
    /// nothing to the credential store. This must not report success at
    /// remembering, and must not leave a stale/fabricated entry behind.
    #[tokio::test]
    async fn login_remember_without_a_token_in_the_response_does_not_report_remembered() {
        let _env = set_cambium_password("pw");
        let base = spawn_route_server(vec![(
            "/api/login",
            br#"{"token":"tok","user":{"username":"@gina"}}"#.to_vec(),
        )]);
        let (_tmp, dirs) = test_dirs();
        let mut ctx = login_test_ctx(dirs, &base);

        let out = run(login_cli(&base, true), &mut ctx).await;

        // The login itself succeeded (a real, usable session was
        // established) — only the convenience remember-token didn't. See
        // `RememberOutcome`'s doc for why this must not fail the command.
        assert!(out.ok, "login itself must still succeed: {out:?}");
        assert_eq!(
            out.data.get("remembered"),
            Some(&serde_json::Value::Bool(false)),
            "no rememberToken came back from the server; the CLI must not claim it stored one, got: {out:?}"
        );
        assert!(
            !out.human.contains("won't need a password"),
            "success text must not claim a remembering that didn't happen: {}",
            out.human
        );

        // And indeed nothing got stored: no stale/fabricated entry either.
        let session = SessionCache::load(&ctx.dirs).unwrap().unwrap();
        let account = auth::credential_account_key(&bare_host(&session.host), &session.username);
        assert_eq!(
            crate::secrets::load_secret_in(&ctx.dirs, &account).unwrap(),
            None,
            "no token was issued this run; nothing should have been written to the credential store"
        );
    }

    /// Coverage gap the reviewer noted: no existing test asserted that the
    /// token in a `LoginResponse` actually reaches the credential store. The
    /// positive twin of the test above.
    #[tokio::test]
    async fn login_remember_stores_the_token_the_server_actually_issues() {
        let _env = set_cambium_password("pw");
        let base = spawn_route_server(vec![(
            "/api/login",
            br#"{"token":"tok","user":{"username":"@gina"},"rememberToken":"rt-live"}"#.to_vec(),
        )]);
        let (_tmp, dirs) = test_dirs();
        let mut ctx = login_test_ctx(dirs, &base);

        let out = run(login_cli(&base, true), &mut ctx).await;

        assert!(out.ok, "login must succeed: {out:?}");
        assert_eq!(out.data.get("remembered"), Some(&serde_json::Value::Bool(true)), "{out:?}");

        let session = SessionCache::load(&ctx.dirs).unwrap().unwrap();
        let account = auth::credential_account_key(&bare_host(&session.host), &session.username);
        assert_eq!(
            crate::secrets::load_secret_in(&ctx.dirs, &account).unwrap().as_deref(),
            Some("rt-live"),
            "the token in the LoginResponse must actually reach the credential store"
        );
    }

    /// `logout` must clear the local remember-token. Leaving it would make the
    /// next command silently resume the session the user just ended.
    #[tokio::test]
    async fn logout_clears_the_local_remember_token() {
        let (_tmp, dirs) = test_dirs();
        let account = "localhost:8666|@gina";
        crate::secrets::save_secret_in(&dirs, account, "rt-live").unwrap();
        SessionCache {
            format_version: 1,
            host: "http://localhost:8666".to_string(),
            username: "@gina".to_string(),
            token: "tok".to_string(),
        }
        .save(&dirs)
        .unwrap();
        let (_events, sink) = counting_sink();
        let mut ctx = ExecContext {
            dirs,
            api: None,
            me: None,
            project: None,
            workspace: None,
            interactive: false,
            progress: sink,
        };
        let _ = logout(&mut ctx).await;
        assert_eq!(crate::secrets::load_secret_in(&ctx.dirs, account).unwrap(), None);
    }
}
