//! Task 10 integration harness: runs the *built* `cambium` binary as a real
//! subprocess against a live scratch server, exercising the same path a
//! user would (argv in, one printed line + exit code out) rather than
//! calling `executor::run` in-process.
//!
//! `#[ignore]`-gated like `TauriClient/src-tauri/tests/live_server.rs` so
//! the hermetic unit suite stays fast and offline. Run explicitly:
//!
//!   CAMBIUM_TEST_SERVER=http://127.0.0.1:8788 \
//!     cargo test -p cambium-cli --test integration -- --ignored
//!
//! These expect a throwaway Cambium Server on that address, serving plain
//! http (`disable_tls: true`), seeded with an owner account `@owner` /
//! `scratchpw123` and one project named "alpha" that the owner is a member
//! of. Point them at a scratch instance, never a real one: the suite
//! submits, rolls back and deletes freely. Every test gets its own
//! `CAMBIUM_CONFIG_DIR` tempdir so runs never share session/pin state.

use std::path::PathBuf;
use std::process::{Command, Output};

const OWNER_USER: &str = "@owner";
const OWNER_PASSWORD: &str = "scratchpw123";

fn test_server() -> String {
    std::env::var("CAMBIUM_TEST_SERVER")
        .expect("set CAMBIUM_TEST_SERVER=http://127.0.0.1:8788 (see this file's header)")
}

/// Path to the binary built for this test run — cargo sets this env var
/// for integration tests automatically (no need to guess `target/debug`).
fn bin_path() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_cambium"))
}

/// Runs `cambium <args>` with a fresh CAMBIUM_CONFIG_DIR (or the given one,
/// so a test can run several commands against the same config dir) and the
/// scratch server's connection env. `password` overrides CAMBIUM_PASSWORD
/// (tests want to exercise both the seeded owner's real password and a
/// deliberately wrong one).
fn run_cambium(config_dir: &std::path::Path, password: &str, args: &[&str]) -> Output {
    run_cambium_as(config_dir, OWNER_USER, password, args)
}

/// Like `run_cambium`, but as an arbitrary user — for the admin verbs, which
/// create a second account and then act as it (`CAMBIUM_USER`/`_PASSWORD`
/// drive the non-interactive auto-login in `auth::resolve`).
fn run_cambium_as(config_dir: &std::path::Path, user: &str, password: &str, args: &[&str]) -> Output {
    Command::new(bin_path())
        .args(args)
        .env("CAMBIUM_CONFIG_DIR", config_dir)
        .env("CAMBIUM_SERVER", test_server())
        .env("CAMBIUM_USER", user)
        .env("CAMBIUM_PASSWORD", password)
        // Never attached to a terminal under `cargo test`, but be explicit:
        // these tests exist specifically to prove the non-interactive path.
        .stdin(std::process::Stdio::null())
        .output()
        .expect("failed to run the cambium binary")
}

/// Like `run_cambium`, but with CAMBIUM_PASSWORD deliberately absent — the
/// only way in is a credential the store already holds.
fn run_cambium_no_password(config_dir: &std::path::Path, args: &[&str]) -> Output {
    Command::new(bin_path())
        .args(args)
        .env("CAMBIUM_CONFIG_DIR", config_dir)
        .env("CAMBIUM_SERVER", test_server())
        .env("CAMBIUM_USER", OWNER_USER)
        .env_remove("CAMBIUM_PASSWORD")
        .stdin(std::process::Stdio::null())
        .output()
        .expect("failed to run the cambium binary")
}

/// Pulls the revision number out of a `submit`'s "Submitted revision N: ..."
/// success line — the number is server-assigned and shared across every test
/// in this binary, so tests read it back rather than hard-coding it.
fn submitted_revision(text: &str) -> u64 {
    text.split("Submitted revision ")
        .nth(1)
        .unwrap_or_else(|| panic!("expected 'Submitted revision N: ...' in: {text}"))
        .split(':')
        .next()
        .unwrap()
        .trim()
        .parse()
        .unwrap_or_else(|e| panic!("unparseable revision in {text:?}: {e}"))
}

fn stdout(out: &Output) -> String {
    String::from_utf8_lossy(&out.stdout).to_string()
}

#[test]
#[ignore]
fn login_succeeds_and_reports_the_host() {
    let tmp = tempfile::tempdir().unwrap();
    let out = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(
        out.status.success(),
        "login failed: stdout={} stderr={}",
        stdout(&out),
        String::from_utf8_lossy(&out.stderr)
    );
    assert!(
        stdout(&out).contains("Logged in"),
        "expected 'Logged in' in stdout, got: {}",
        stdout(&out)
    );
}

#[test]
#[ignore]
fn whoami_shows_the_handle_after_login() {
    let tmp = tempfile::tempdir().unwrap();
    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));

    let who = run_cambium(tmp.path(), OWNER_PASSWORD, &["whoami"]);
    assert!(who.status.success(), "whoami failed: {}", stdout(&who));
    assert!(
        stdout(&who).contains(OWNER_USER),
        "expected {OWNER_USER} in whoami output, got: {}",
        stdout(&who)
    );
}

#[test]
#[ignore]
fn whoami_json_parses_and_reports_username() {
    let tmp = tempfile::tempdir().unwrap();
    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));

    let who = run_cambium(tmp.path(), OWNER_PASSWORD, &["whoami", "--json"]);
    assert!(who.status.success(), "whoami --json failed: {}", stdout(&who));
    let parsed: serde_json::Value =
        serde_json::from_str(stdout(&who).trim()).expect("whoami --json did not print valid JSON");
    assert_eq!(parsed["ok"], true);
    assert_eq!(parsed["data"]["username"], OWNER_USER);
}

#[test]
#[ignore]
fn projects_lists_the_seeded_project() {
    let tmp = tempfile::tempdir().unwrap();
    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));

    let projects = run_cambium(tmp.path(), OWNER_PASSWORD, &["projects"]);
    assert!(projects.status.success(), "projects failed: {}", stdout(&projects));
    assert!(
        stdout(&projects).contains("alpha"),
        "expected the seeded 'alpha' project in output, got: {}",
        stdout(&projects)
    );
}

#[test]
#[ignore]
fn link_then_whoami_shows_the_workspace() {
    let tmp = tempfile::tempdir().unwrap();
    let workspace = tempfile::tempdir().unwrap();

    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));

    let link = run_cambium(
        tmp.path(),
        OWNER_PASSWORD,
        &["link", "alpha", workspace.path().to_str().unwrap()],
    );
    assert!(link.status.success(), "link failed: {}", stdout(&link));

    let who = run_cambium(tmp.path(), OWNER_PASSWORD, &["whoami"]);
    assert!(who.status.success(), "whoami failed: {}", stdout(&who));
    // Compare against the canonicalized path: the executor canonicalizes
    // the folder it writes into workspaces.json (e.g. resolving /tmp's
    // platform-specific symlinks), so the raw tempdir path may differ.
    let canonical = std::fs::canonicalize(workspace.path()).unwrap();
    assert!(
        stdout(&who).contains(&canonical.display().to_string()),
        "expected the linked workspace path in whoami output, got: {}",
        stdout(&who)
    );
}

/// Raw HTTP GET /api/projects with a Bearer token, returning the status
/// code. Deliberately bypasses the cambium binary and ApiClient: this is
/// how the test proves the SERVER's view of a token (alive vs dropped),
/// independent of any client-side state. std TcpStream is enough because
/// the scratch server is plain http.
fn server_status_for_token(token: &str) -> u16 {
    use std::io::{Read, Write};
    let host = test_server()
        .trim_start_matches("http://")
        .trim_end_matches('/')
        .to_string();
    let mut stream = std::net::TcpStream::connect(&host).expect("connect to scratch server");
    // Bypassing ApiClient means bypassing the header it stamps on every
    // request, so this hand-rolled one has to carry it too — without it the
    // server's version gate answers 426 and the test learns nothing about
    // the token. Sent by name/value from `version` so it can't drift.
    write!(
        stream,
        "GET /api/projects HTTP/1.1\r\nHost: {host}\r\nAuthorization: Bearer {token}\r\n\
         {version_header}: {client_version}\r\nConnection: close\r\n\r\n",
        version_header = cambium_core::version::CLIENT_VERSION_HEADER,
        client_version = cambium_core::version::CLIENT_VERSION,
    )
    .expect("send request");
    let mut response = String::new();
    stream.read_to_string(&mut response).expect("read response");
    // Status line: "HTTP/1.1 200 OK"
    response
        .split_whitespace()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .unwrap_or_else(|| panic!("unparseable response: {response:?}"))
}

#[test]
#[ignore]
fn logout_invalidates_the_server_token() {
    let tmp = tempfile::tempdir().unwrap();
    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));

    // Capture the token login cached so we can ask the server about it
    // directly after it's been cleared locally.
    let session_text = std::fs::read_to_string(tmp.path().join("session.json"))
        .expect("login should have written session.json");
    let session: serde_json::Value = serde_json::from_str(&session_text).unwrap();
    let token = session["token"].as_str().expect("session.json has a token").to_string();

    // Sanity: the token is live server-side before logout.
    assert_eq!(server_status_for_token(&token), 200, "token should be live before logout");

    let logout = run_cambium(tmp.path(), OWNER_PASSWORD, &["logout"]);
    assert!(logout.status.success(), "logout failed: {}", stdout(&logout));
    assert!(
        stdout(&logout).contains("Logged out."),
        "expected 'Logged out.' in output, got: {}",
        stdout(&logout)
    );
    // The server WAS reachable, so the unreachable-note must not appear.
    assert!(
        !stdout(&logout).contains("server unreachable"),
        "server was up; got: {}",
        stdout(&logout)
    );

    // The server itself must now reject the old token (SESSIONS.drop ran).
    assert_eq!(server_status_for_token(&token), 401, "token must be dead after logout");

    // Local state is gone too: with no usable password, a session-needing
    // command can't silently re-auth (exit 3, the auth-failure code).
    let projects = run_cambium(tmp.path(), "", &["projects"]);
    assert_eq!(
        projects.status.code(),
        Some(3),
        "expected exit 3 after logout with no valid password, got {:?}; stdout={}",
        projects.status.code(),
        stdout(&projects)
    );
}

/// Task 11: `status` (default, whole workspace) picks up a brand-new local
/// file as `Not added`. The scratch server's seeded "alpha" project has an
/// EMPTY depot (a scratch server seeds a bare Database/, no Repository
/// content — see the script's own comment) — this test therefore only
/// exercises status's local-only branch (rule 4 in status.rs), not the
/// server-diff branches (Modified/Outdated); those need a project with
/// existing depot content, which is out of scope for this scratch harness.
#[test]
#[ignore]
fn status_reports_a_new_local_file_as_not_added() {
    let tmp = tempfile::tempdir().unwrap();
    let workspace = tempfile::tempdir().unwrap();

    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));

    let link = run_cambium(
        tmp.path(),
        OWNER_PASSWORD,
        &["link", "alpha", workspace.path().to_str().unwrap()],
    );
    assert!(link.status.success(), "link failed: {}", stdout(&link));

    std::fs::write(workspace.path().join("newfile.txt"), b"hello").unwrap();

    let status = run_cambium(tmp.path(), OWNER_PASSWORD, &["status"]);
    assert!(status.status.success(), "status failed: {}", stdout(&status));
    assert!(
        stdout(&status).contains("Not added  newfile.txt"),
        "expected 'Not added  newfile.txt' in status output, got: {}",
        stdout(&status)
    );

    let status_json = run_cambium(tmp.path(), OWNER_PASSWORD, &["status", "--json"]);
    assert!(status_json.status.success(), "status --json failed: {}", stdout(&status_json));
    let parsed: serde_json::Value = serde_json::from_str(stdout(&status_json).trim())
        .expect("status --json did not print valid JSON");
    assert_eq!(parsed["ok"], true);
    let entries = parsed["data"].as_array().expect("data should be an array");
    assert!(
        entries.iter().any(|e| e["path"] == "newfile.txt" && e["status"] == "notAdded"),
        "expected a newfile.txt/notAdded entry in {entries:?}"
    );
}

/// `locks` with nothing checked out prints a clear empty message rather than
/// a blank line.
#[test]
#[ignore]
fn locks_with_nothing_checked_out_prints_a_clear_message() {
    let tmp = tempfile::tempdir().unwrap();
    let workspace = tempfile::tempdir().unwrap();

    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));

    let link = run_cambium(
        tmp.path(),
        OWNER_PASSWORD,
        &["link", "alpha", workspace.path().to_str().unwrap()],
    );
    assert!(link.status.success(), "link failed: {}", stdout(&link));

    let locks = run_cambium(tmp.path(), OWNER_PASSWORD, &["locks"]);
    assert!(locks.status.success(), "locks failed: {}", stdout(&locks));
    assert!(
        stdout(&locks).contains("No files are checked out."),
        "expected the empty-locks message, got: {}",
        stdout(&locks)
    );
}

/// Task 12: seeds one depot file in the (empty-by-default, see the module
/// doc) scratch project by driving `cambium-core`'s own `ApiClient` +
/// `transfer::upload_files_impl` directly — the exact login -> upload ->
/// submit round trip a real `submit` verb will make once Task 13 wires it
/// into the CLI, just called straight from the test rather than through the
/// `cambium` binary (which doesn't have `submit` yet). This is simpler than
/// a raw-socket HTTP client (the pattern `server_status_for_token` above
/// uses for a single GET) because `cambium-core` already has typed,
/// tested wrappers for exactly these three calls — see the task-12 report
/// for the alternatives considered (a server-side seed hook does not exist
/// today; see `CambiumServer/utilities/seed.py`).
async fn seed_depot_file(rel_path: &str, content: &[u8]) {
    use cambium_core::api::{ApiClient, SubmitChange, SubmitPayload};
    use cambium_core::transfer::{upload_files_impl, UploadFile};

    let mut client = ApiClient::new(&test_server(), None).expect("build seeding client");
    client
        .login(OWNER_USER, OWNER_PASSWORD, "seed-device", "seed-device", false)
        .await
        .expect("seed login failed");
    let projects = client.projects().await.expect("seed: failed to list projects");
    let project = projects
        .iter()
        .find(|p| p.name == "alpha")
        .expect("seed: scratch server's 'alpha' project is missing");
    client.project_id = Some(project.id.clone());

    // upload_files_impl reads from disk (folder_path + path), same as the
    // real CLI's future `submit` would from the workspace — stage the seed
    // content under its own scratch dir rather than the test's workspace
    // tempdir, so seeding never leaves stray files in the workspace under
    // test.
    let stage_dir = tempfile::tempdir().unwrap();
    std::fs::write(stage_dir.path().join(rel_path), content).unwrap();

    let headers = client.auth_headers();
    let upload = upload_files_impl(
        stage_dir.path().to_str().unwrap(),
        &[UploadFile {
            path: rel_path.to_string(),
            url: client.file_upload_url(),
            size_bytes: Some(content.len() as u64),
        }],
        &headers,
        None,
        None,
    )
    .await;
    assert!(upload.ok, "seed: upload failed: {:?}", upload.results);
    let hash = upload.results[0].hash.clone().expect("seed: upload returned no hash");

    let result = client
        .submit(&SubmitPayload {
            description: "seed".to_string(),
            changes: vec![SubmitChange {
                path: rel_path.to_string(),
                change: "added".to_string(),
                hash: Some(hash),
                base_hash: None,
            }],
            folders: vec![],
        })
        .await
        .expect("seed: submit request failed");
    assert_eq!(result["ok"], true, "seed: submit did not report ok: {result}");
}

/// Task 12: `add`, `delete` (no target), `checkout`, and `revert` (with and
/// without `-f`) against a live scratch server. One long sequential flow
/// (rather than several independent tests) because checkout/revert need a
/// seeded depot file and a real lock, and standing that up is expensive
/// enough that sharing it across the whole sequence is worth the coupling —
/// same reasoning `link_then_whoami_shows_the_workspace` etc. already lean
/// on for login+link.
#[test]
#[ignore]
fn checkout_add_delete_revert_flow() {
    let seeded_path = "seed.txt";
    let seeded_content = b"seed content v1";
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(seed_depot_file(seeded_path, seeded_content));

    let tmp = tempfile::tempdir().unwrap();
    let workspace = tempfile::tempdir().unwrap();

    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));
    let link = run_cambium(
        tmp.path(),
        OWNER_PASSWORD,
        &["link", "alpha", workspace.path().to_str().unwrap()],
    );
    assert!(link.status.success(), "link failed: {}", stdout(&link));

    // ---- add: a brand-new local file gets marked, and status shows it ----
    std::fs::write(workspace.path().join("local_new.txt"), b"brand new").unwrap();
    let add = run_cambium(tmp.path(), OWNER_PASSWORD, &["add"]);
    assert!(add.status.success(), "add failed: {}", stdout(&add));

    let status_after_add = run_cambium(tmp.path(), OWNER_PASSWORD, &["status"]);
    assert!(status_after_add.status.success(), "status failed: {}", stdout(&status_after_add));
    assert!(
        stdout(&status_after_add).contains("Marked for add  local_new.txt"),
        "expected 'Marked for add  local_new.txt' in status output, got: {}",
        stdout(&status_after_add)
    );

    // ---- delete with no target: refused with the exact spec string ----
    let delete_no_target = run_cambium(tmp.path(), OWNER_PASSWORD, &["delete"]);
    assert!(
        !delete_no_target.status.success(),
        "delete with no target should fail, got: {}",
        stdout(&delete_no_target)
    );
    assert_eq!(delete_no_target.status.code(), Some(1), "expected exit code 1");
    assert!(
        stdout(&delete_no_target).contains("specify a file or folder to delete."),
        "expected the exact spec string, got: {}",
        stdout(&delete_no_target)
    );

    // ---- checkout the seeded depot file: locks it and pulls it to disk ----
    let checkout = run_cambium(
        tmp.path(),
        OWNER_PASSWORD,
        &["checkout", &format!("//{seeded_path}")],
    );
    assert!(checkout.status.success(), "checkout failed: {}", stdout(&checkout));

    let local_path = workspace.path().join(seeded_path);
    assert!(local_path.exists(), "checkout should have pulled the seeded file to disk");
    assert_eq!(
        std::fs::read(&local_path).unwrap(),
        seeded_content,
        "checkout should have pulled the depot head content"
    );

    let status_after_checkout = run_cambium(tmp.path(), OWNER_PASSWORD, &["status"]);
    assert!(status_after_checkout.status.success(), "status failed: {}", stdout(&status_after_checkout));
    assert!(
        stdout(&status_after_checkout).contains(&format!("Checked out  {seeded_path}")),
        "expected 'Checked out  {seeded_path}' in status output, got: {}",
        stdout(&status_after_checkout)
    );

    // ---- edit the checked-out file locally: chmod +w, append bytes ----
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(&local_path).unwrap().permissions();
        perms.set_mode(0o644);
        std::fs::set_permissions(&local_path, perms).unwrap();
    }
    {
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new().append(true).open(&local_path).unwrap();
        f.write_all(b" -- local edit").unwrap();
    }

    // ---- revert without -f: refused with the exact spec string ----
    let revert_no_force = run_cambium(tmp.path(), OWNER_PASSWORD, &["revert"]);
    assert!(
        !revert_no_force.status.success(),
        "revert without -f should refuse when local edits exist, got: {}",
        stdout(&revert_no_force)
    );
    assert_eq!(revert_no_force.status.code(), Some(1), "expected exit code 1");
    let expected_refusal = format!(
        "refusing to revert: this would discard local changes to {seeded_path}; pass -f to overwrite."
    );
    assert!(
        stdout(&revert_no_force).contains(&expected_refusal),
        "expected the exact spec string {expected_refusal:?}, got: {}",
        stdout(&revert_no_force)
    );
    // The refusal must not have touched the edit.
    assert_ne!(std::fs::read(&local_path).unwrap(), seeded_content);

    // ---- revert -f: content restored to depot head, lock released ----
    let revert_force = run_cambium(tmp.path(), OWNER_PASSWORD, &["revert", "-f"]);
    assert!(revert_force.status.success(), "revert -f failed: {}", stdout(&revert_force));
    assert_eq!(
        std::fs::read(&local_path).unwrap(),
        seeded_content,
        "revert -f should have restored the depot head content (same bytes -> same hash)"
    );

    let locks = run_cambium(tmp.path(), OWNER_PASSWORD, &["locks"]);
    assert!(locks.status.success(), "locks failed: {}", stdout(&locks));
    assert!(
        stdout(&locks).contains("No files are checked out."),
        "expected the checkout's lock to be released by revert -f, got: {}",
        stdout(&locks)
    );
}

#[test]
#[ignore]
fn bad_password_exits_with_auth_code() {
    let tmp = tempfile::tempdir().unwrap();
    let out = run_cambium(tmp.path(), "definitely-not-the-password", &["login"]);
    assert!(
        !out.status.success(),
        "expected login to fail with a bad password, got: {}",
        stdout(&out)
    );
    assert_eq!(
        out.status.code(),
        Some(3),
        "expected exit code 3 (auth failure), got {:?}; stdout={}",
        out.status.code(),
        stdout(&out)
    );
}

/// Task 13: `submit` (add / edit / delete) and `pull`, end to end against a
/// live scratch server. One long sequential flow, same rationale as
/// `checkout_add_delete_revert_flow`: standing up a checked-out, submitted,
/// then re-edited file is expensive enough to be worth sharing across the
/// whole sequence.
///
/// Revision numbers are read back from `--json` rather than hard-coded: the
/// scratch server's revision counter is shared with every other test in this
/// binary (all of them hit the same 'alpha' project), so only "increases by
/// at least one per submit" is a safe assertion, not an absolute number.
#[test]
#[ignore]
fn submit_and_pull_flow() {
    let tmp = tempfile::tempdir().unwrap();
    let workspace = tempfile::tempdir().unwrap();

    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));
    let link = run_cambium(
        tmp.path(),
        OWNER_PASSWORD,
        &["link", "alpha", workspace.path().to_str().unwrap()],
    );
    assert!(link.status.success(), "link failed: {}", stdout(&link));

    // `CommandOutput::to_json_line` omits `human` entirely on success (only
    // `error` carries text, and only when `ok` is false — see
    // commands/output.rs), so the exact "Submitted revision N: M file(s)."
    // line can only be read from the plain-text form. Parse the revision
    // number back out of that same line rather than making a second
    // (state-mutating) call with `--json`.
    fn parse_submitted_revision(text: &str) -> u64 {
        let after = text
            .split("Submitted revision ")
            .nth(1)
            .unwrap_or_else(|| panic!("expected 'Submitted revision N: ...' in: {text}"));
        after
            .split(':')
            .next()
            .unwrap()
            .trim()
            .parse()
            .unwrap_or_else(|e| panic!("unparseable revision in {text:?}: {e}"))
    }

    // ---- add + submit a brand-new file -------------------------------------
    let doc_a = workspace.path().join("docA.txt");
    std::fs::write(&doc_a, b"v1 content").unwrap();
    let add = run_cambium(tmp.path(), OWNER_PASSWORD, &["add"]);
    assert!(add.status.success(), "add failed: {}", stdout(&add));

    let submit1 = run_cambium(tmp.path(), OWNER_PASSWORD, &["submit", "-m", "add docA"]);
    assert!(submit1.status.success(), "submit(add) failed: {}", stdout(&submit1));
    let rev1 = parse_submitted_revision(&stdout(&submit1));
    assert!(
        stdout(&submit1).contains(&format!("Submitted revision {rev1}: 1 file(s).")),
        "expected the exact submit success line, got: {}",
        stdout(&submit1)
    );

    let status_after_add_submit = run_cambium(tmp.path(), OWNER_PASSWORD, &["status"]);
    assert!(status_after_add_submit.status.success());
    assert!(
        !stdout(&status_after_add_submit).contains("Marked for add"),
        "changelist should be clear after a successful submit, got: {}",
        stdout(&status_after_add_submit)
    );

    // ---- checkout + edit + submit an edit ----------------------------------
    let checkout = run_cambium(tmp.path(), OWNER_PASSWORD, &["checkout", "//docA.txt"]);
    assert!(checkout.status.success(), "checkout failed: {}", stdout(&checkout));
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(&doc_a).unwrap().permissions();
        perms.set_mode(0o644);
        std::fs::set_permissions(&doc_a, perms).unwrap();
    }
    std::fs::write(&doc_a, b"v2 content").unwrap();

    let submit2 = run_cambium(tmp.path(), OWNER_PASSWORD, &["submit", "-m", "edit docA"]);
    assert!(submit2.status.success(), "submit(edit) failed: {}", stdout(&submit2));
    let rev2 = parse_submitted_revision(&stdout(&submit2));
    assert!(rev2 > rev1, "expected the revision to increase: rev1={rev1} rev2={rev2}");
    assert!(
        stdout(&submit2).contains(&format!("Submitted revision {rev2}: 1 file(s).")),
        "got: {}",
        stdout(&submit2)
    );

    // ---- delete + submit ----------------------------------------------------
    let del = run_cambium(tmp.path(), OWNER_PASSWORD, &["delete", "//docA.txt"]);
    assert!(del.status.success(), "delete failed: {}", stdout(&del));
    assert!(!doc_a.exists(), "delete should remove the local copy immediately");

    let submit3 = run_cambium(tmp.path(), OWNER_PASSWORD, &["submit", "-m", "delete docA"]);
    assert!(submit3.status.success(), "submit(delete) failed: {}", stdout(&submit3));
    let rev3 = parse_submitted_revision(&stdout(&submit3));
    assert!(rev3 > rev2, "expected the revision to increase: rev2={rev2} rev3={rev3}");
    assert!(
        stdout(&submit3).contains(&format!("Submitted revision {rev3}: 1 file(s).")),
        "got: {}",
        stdout(&submit3)
    );

    let status_after_delete_submit = run_cambium(tmp.path(), OWNER_PASSWORD, &["status"]);
    assert!(status_after_delete_submit.status.success());
    assert!(
        !stdout(&status_after_delete_submit).contains("Marked for delete"),
        "changelist should be clear after a successful submit, got: {}",
        stdout(&status_after_delete_submit)
    );

    // ---- a second depot file, seeded for the pull scenarios below ----------
    let doc_b = workspace.path().join("docB.txt");
    std::fs::write(&doc_b, b"b v1").unwrap();
    let add_b = run_cambium(tmp.path(), OWNER_PASSWORD, &["add"]);
    assert!(add_b.status.success(), "add docB failed: {}", stdout(&add_b));
    let submit_b = run_cambium(tmp.path(), OWNER_PASSWORD, &["submit", "-m", "add docB"]);
    assert!(submit_b.status.success(), "submit(docB) failed: {}", stdout(&submit_b));

    // ---- a second, independently-linked workspace pulls identical content --
    let tmp2 = tempfile::tempdir().unwrap();
    let workspace2 = tempfile::tempdir().unwrap();
    let login2 = run_cambium(tmp2.path(), OWNER_PASSWORD, &["login"]);
    assert!(login2.status.success(), "login (workspace2) failed: {}", stdout(&login2));
    let link2 = run_cambium(
        tmp2.path(),
        OWNER_PASSWORD,
        &["link", "alpha", workspace2.path().to_str().unwrap()],
    );
    assert!(link2.status.success(), "link (workspace2) failed: {}", stdout(&link2));

    let pull1 = run_cambium(tmp2.path(), OWNER_PASSWORD, &["pull"]);
    assert!(pull1.status.success(), "pull failed: {}", stdout(&pull1));

    let doc_b2 = workspace2.path().join("docB.txt");
    assert!(doc_b2.exists(), "pull should have downloaded docB.txt");
    assert_eq!(
        std::fs::read(&doc_b2).unwrap(),
        b"b v1",
        "pulled content must match the depot head byte-for-byte"
    );
    // docA.txt was deleted server-side before workspace2 ever linked, so it
    // must never appear.
    assert!(!workspace2.path().join("docA.txt").exists());

    // ---- modified-file pull without -f: exact refusal, then -f overwrites --
    // Workspace1 edits + submits docB again, moving the server head.
    let checkout_b = run_cambium(tmp.path(), OWNER_PASSWORD, &["checkout", "//docB.txt"]);
    assert!(checkout_b.status.success(), "checkout docB failed: {}", stdout(&checkout_b));
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(&doc_b).unwrap().permissions();
        perms.set_mode(0o644);
        std::fs::set_permissions(&doc_b, perms).unwrap();
    }
    std::fs::write(&doc_b, b"b v2 from workspace1").unwrap();
    let submit_b2 = run_cambium(tmp.path(), OWNER_PASSWORD, &["submit", "-m", "edit docB"]);
    assert!(submit_b2.status.success(), "submit(edit docB) failed: {}", stdout(&submit_b2));

    // Workspace2 locally edits its now-stale copy without ever re-pulling.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(&doc_b2).unwrap().permissions();
        perms.set_mode(0o644);
        std::fs::set_permissions(&doc_b2, perms).unwrap();
    }
    std::fs::write(&doc_b2, b"conflicting local edit").unwrap();

    let pull_no_force = run_cambium(tmp2.path(), OWNER_PASSWORD, &["pull"]);
    assert!(
        !pull_no_force.status.success(),
        "pull without -f should refuse when local edits conflict with a moved server head, got: {}",
        stdout(&pull_no_force)
    );
    assert_eq!(pull_no_force.status.code(), Some(1), "expected exit code 1");
    let expected_refusal =
        "refusing to pull: this would overwrite local changes to docB.txt; pass -f to overwrite.";
    assert!(
        stdout(&pull_no_force).contains(expected_refusal),
        "expected the exact spec string {expected_refusal:?}, got: {}",
        stdout(&pull_no_force)
    );
    // The refusal must not have touched the conflicting local edit.
    assert_eq!(std::fs::read(&doc_b2).unwrap(), b"conflicting local edit");

    let pull_force = run_cambium(tmp2.path(), OWNER_PASSWORD, &["pull", "-f"]);
    assert!(pull_force.status.success(), "pull -f failed: {}", stdout(&pull_force));
    assert_eq!(
        std::fs::read(&doc_b2).unwrap(),
        b"b v2 from workspace1",
        "pull -f should have overwritten the local edit with the new depot head"
    );
}

/// Task 13 (review fix): a scoped `submit //subA` submits only the changes
/// localized under subA and leaves the rest of the changelist pending —
/// proving the change set (and its persistence) is scoped, not whole-workspace.
#[test]
#[ignore]
fn scoped_submit_leaves_other_changes_pending() {
    let tmp = tempfile::tempdir().unwrap();
    let workspace = tempfile::tempdir().unwrap();

    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));
    let link = run_cambium(
        tmp.path(),
        OWNER_PASSWORD,
        &["link", "alpha", workspace.path().to_str().unwrap()],
    );
    assert!(link.status.success(), "link failed: {}", stdout(&link));

    std::fs::create_dir_all(workspace.path().join("scopedSubA")).unwrap();
    std::fs::create_dir_all(workspace.path().join("scopedSubB")).unwrap();
    std::fs::write(workspace.path().join("scopedSubA/a.txt"), b"a content").unwrap();
    std::fs::write(workspace.path().join("scopedSubB/b.txt"), b"b content").unwrap();

    let add = run_cambium(tmp.path(), OWNER_PASSWORD, &["add"]);
    assert!(add.status.success(), "add failed: {}", stdout(&add));

    // Scoped submit: only the change under scopedSubA.
    let submit =
        run_cambium(tmp.path(), OWNER_PASSWORD, &["submit", "//scopedSubA", "-m", "just subA"]);
    assert!(submit.status.success(), "scoped submit failed: {}", stdout(&submit));
    assert!(
        stdout(&submit).contains(": 1 file(s)."),
        "expected exactly one file submitted, got: {}",
        stdout(&submit)
    );

    // scopedSubB's change stays pending; scopedSubA's is cleared (now synced).
    let status = run_cambium(tmp.path(), OWNER_PASSWORD, &["status"]);
    assert!(status.status.success(), "status failed: {}", stdout(&status));
    assert!(
        stdout(&status).contains("Marked for add  scopedSubB/b.txt"),
        "scopedSubB should still be pending after a scoped submit, got: {}",
        stdout(&status)
    );
    assert!(
        !stdout(&status).contains("scopedSubA/a.txt"),
        "scopedSubA should be cleared from the changelist after its scoped submit, got: {}",
        stdout(&status)
    );
}

/// Task 13 (review fix): a stale UNRELATED workspace file must NOT block a
/// valid submit — the exact regression the whole-workspace block gate caused.
/// Two independently-linked workspaces: ws1 publishes then moves the head of
/// `sharedStale.txt`; ws2 holds the now-Outdated copy on disk but submits an
/// unrelated add, which must succeed.
#[test]
#[ignore]
fn stale_unrelated_file_does_not_block_submit() {
    // ws1 publishes sharedStale.txt.
    let ws1_cfg = tempfile::tempdir().unwrap();
    let ws1 = tempfile::tempdir().unwrap();
    let login1 = run_cambium(ws1_cfg.path(), OWNER_PASSWORD, &["login"]);
    assert!(login1.status.success(), "ws1 login failed: {}", stdout(&login1));
    let link1 = run_cambium(
        ws1_cfg.path(),
        OWNER_PASSWORD,
        &["link", "alpha", ws1.path().to_str().unwrap()],
    );
    assert!(link1.status.success(), "ws1 link failed: {}", stdout(&link1));

    let shared1 = ws1.path().join("sharedStale.txt");
    std::fs::write(&shared1, b"shared v1").unwrap();
    let add1 = run_cambium(ws1_cfg.path(), OWNER_PASSWORD, &["add"]);
    assert!(add1.status.success(), "ws1 add failed: {}", stdout(&add1));
    let submit1 = run_cambium(ws1_cfg.path(), OWNER_PASSWORD, &["submit", "-m", "add shared"]);
    assert!(submit1.status.success(), "ws1 submit failed: {}", stdout(&submit1));

    // ws2 links and pulls sharedStale.txt at v1 (recording base = v1's hash).
    let ws2_cfg = tempfile::tempdir().unwrap();
    let ws2 = tempfile::tempdir().unwrap();
    let login2 = run_cambium(ws2_cfg.path(), OWNER_PASSWORD, &["login"]);
    assert!(login2.status.success(), "ws2 login failed: {}", stdout(&login2));
    let link2 = run_cambium(
        ws2_cfg.path(),
        OWNER_PASSWORD,
        &["link", "alpha", ws2.path().to_str().unwrap()],
    );
    assert!(link2.status.success(), "ws2 link failed: {}", stdout(&link2));
    let pull2 = run_cambium(ws2_cfg.path(), OWNER_PASSWORD, &["pull"]);
    assert!(pull2.status.success(), "ws2 pull failed: {}", stdout(&pull2));
    let shared2 = ws2.path().join("sharedStale.txt");
    assert_eq!(std::fs::read(&shared2).unwrap(), b"shared v1", "ws2 should have pulled v1");

    // ws1 moves the head: checkout, edit, submit v2.
    let checkout1 =
        run_cambium(ws1_cfg.path(), OWNER_PASSWORD, &["checkout", "//sharedStale.txt"]);
    assert!(checkout1.status.success(), "ws1 checkout failed: {}", stdout(&checkout1));
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(&shared1).unwrap().permissions();
        perms.set_mode(0o644);
        std::fs::set_permissions(&shared1, perms).unwrap();
    }
    std::fs::write(&shared1, b"shared v2").unwrap();
    let submit1b = run_cambium(ws1_cfg.path(), OWNER_PASSWORD, &["submit", "-m", "edit shared"]);
    assert!(submit1b.status.success(), "ws1 second submit failed: {}", stdout(&submit1b));

    // ws2's sharedStale.txt is now stale on disk (Outdated); it also has an
    // unrelated local-only file to add.
    std::fs::write(ws2.path().join("unrelatedNew.txt"), b"unrelated content").unwrap();
    let add2 = run_cambium(ws2_cfg.path(), OWNER_PASSWORD, &["add"]);
    assert!(add2.status.success(), "ws2 add failed: {}", stdout(&add2));

    // Sanity: ws2 really does see sharedStale.txt as Outdated (so the guard
    // below is meaningful, not vacuous).
    let status2 = run_cambium(ws2_cfg.path(), OWNER_PASSWORD, &["status"]);
    assert!(status2.status.success(), "ws2 status failed: {}", stdout(&status2));
    assert!(
        stdout(&status2).contains("Outdated  sharedStale.txt"),
        "expected ws2 to see sharedStale.txt as Outdated, got: {}",
        stdout(&status2)
    );

    // The regression guard: submitting the unrelated add must NOT be refused
    // because an unrelated file is outdated.
    let submit2 = run_cambium(ws2_cfg.path(), OWNER_PASSWORD, &["submit", "-m", "add unrelated"]);
    assert!(
        submit2.status.success(),
        "a stale UNRELATED file must not block a valid submit, got: {}",
        stdout(&submit2)
    );
    assert!(
        stdout(&submit2).contains(": 1 file(s)."),
        "expected the unrelated add to submit, got: {}",
        stdout(&submit2)
    );
    // And the stale file was left untouched on disk (never pulled/overwritten).
    assert_eq!(std::fs::read(&shared2).unwrap(), b"shared v1");
}

/// Task 15: the admin user verbs end to end. As the seeded owner: create a
/// user (who can then log in), prove a plain member is refused an admin
/// action (exit 1), promote them with `setrole`, prove the promotion took,
/// and prove `password` resets another user's credentials. Uses handles
/// unique to this test so it never collides with the parallel suite.
#[test]
#[ignore]
fn admin_user_verbs_flow() {
    let owner_cfg = tempfile::tempdir().unwrap();
    let login = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "owner login failed: {}", stdout(&login));

    // ---- adduser: owner creates @robin; the new user can log in -------------
    let add = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["adduser", "@robin", "robinpw"]);
    assert!(add.status.success(), "adduser failed: {}", stdout(&add));
    assert!(
        stdout(&add).contains("@robin"),
        "expected the new handle in adduser output, got: {}",
        stdout(&add)
    );

    let robin_cfg = tempfile::tempdir().unwrap();
    let robin_login = run_cambium_as(robin_cfg.path(), "@robin", "robinpw", &["login"]);
    assert!(
        robin_login.status.success(),
        "the freshly-created @robin should be able to log in, got: {}",
        stdout(&robin_login)
    );

    // ---- member forbidden path: @robin (a plain member) can't create users --
    let robin_add =
        run_cambium_as(robin_cfg.path(), "@robin", "robinpw", &["adduser", "@mallory", "pw"]);
    assert!(
        !robin_add.status.success(),
        "a member creating a user should be refused, got: {}",
        stdout(&robin_add)
    );
    assert_eq!(
        robin_add.status.code(),
        Some(1),
        "expected exit 1 for the refused admin action; stdout={}",
        stdout(&robin_add)
    );

    // ---- setrole: owner promotes @robin to admin; the promotion takes -------
    let setrole = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["setrole", "@robin", "admin"]);
    assert!(setrole.status.success(), "setrole failed: {}", stdout(&setrole));

    let robin_add2 =
        run_cambium_as(robin_cfg.path(), "@robin", "robinpw", &["adduser", "@mallory", "mallorypw"]);
    assert!(
        robin_add2.status.success(),
        "@robin (now admin) should be able to create users, got: {}",
        stdout(&robin_add2)
    );

    // ---- password: owner resets @mallory's password; she logs in with it ----
    let setpw = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["password", "@mallory", "mallorypw2"]);
    assert!(setpw.status.success(), "admin password reset failed: {}", stdout(&setpw));

    let mallory_cfg = tempfile::tempdir().unwrap();
    let mallory_login =
        run_cambium_as(mallory_cfg.path(), "@mallory", "mallorypw2", &["login"]);
    assert!(
        mallory_login.status.success(),
        "@mallory should log in with the admin-reset password, got: {}",
        stdout(&mallory_login)
    );
}

/// Task 15: `rollback <path> <revision>` moves the depot head back to an
/// earlier revision's content AND updates the local copy to match. Publishes
/// v1, edits to v2, then rolls back to v1's revision and checks the local
/// file is byte-for-byte v1 again.
#[test]
#[ignore]
fn rollback_restores_earlier_content() {
    let tmp = tempfile::tempdir().unwrap();
    let workspace = tempfile::tempdir().unwrap();

    let login = run_cambium(tmp.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));
    let link = run_cambium(
        tmp.path(),
        OWNER_PASSWORD,
        &["link", "alpha", workspace.path().to_str().unwrap()],
    );
    assert!(link.status.success(), "link failed: {}", stdout(&link));

    // ---- v1: add + submit --------------------------------------------------
    let rbfile = workspace.path().join("rbfile.txt");
    std::fs::write(&rbfile, b"rollback v1").unwrap();
    let add = run_cambium(tmp.path(), OWNER_PASSWORD, &["add"]);
    assert!(add.status.success(), "add failed: {}", stdout(&add));
    let submit1 = run_cambium(tmp.path(), OWNER_PASSWORD, &["submit", "-m", "rb v1"]);
    assert!(submit1.status.success(), "submit v1 failed: {}", stdout(&submit1));
    let rev1 = submitted_revision(&stdout(&submit1));

    // ---- v2: checkout + edit + submit --------------------------------------
    let checkout = run_cambium(tmp.path(), OWNER_PASSWORD, &["checkout", "//rbfile.txt"]);
    assert!(checkout.status.success(), "checkout failed: {}", stdout(&checkout));
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(&rbfile).unwrap().permissions();
        perms.set_mode(0o644);
        std::fs::set_permissions(&rbfile, perms).unwrap();
    }
    std::fs::write(&rbfile, b"rollback v2").unwrap();
    let submit2 = run_cambium(tmp.path(), OWNER_PASSWORD, &["submit", "-m", "rb v2"]);
    assert!(submit2.status.success(), "submit v2 failed: {}", stdout(&submit2));
    assert_eq!(std::fs::read(&rbfile).unwrap(), b"rollback v2");

    // ---- rollback to v1's revision: local content returns to v1 ------------
    let rollback = run_cambium(
        tmp.path(),
        OWNER_PASSWORD,
        &["rollback", "//rbfile.txt", &rev1.to_string()],
    );
    assert!(rollback.status.success(), "rollback failed: {}", stdout(&rollback));
    assert_eq!(
        std::fs::read(&rbfile).unwrap(),
        b"rollback v1",
        "rollback should have restored v1's content to the local copy"
    );
}

/// Task 15: `unlock` without `-f` refuses up front with the exact spec string
/// and a code-1 exit — before any network call, so it needs no lock to exist.
#[test]
#[ignore]
fn unlock_without_force_is_refused() {
    let tmp = tempfile::tempdir().unwrap();
    let out = run_cambium(tmp.path(), OWNER_PASSWORD, &["unlock", "//anything.txt"]);
    assert!(
        !out.status.success(),
        "unlock without -f should be refused, got: {}",
        stdout(&out)
    );
    assert_eq!(out.status.code(), Some(1), "expected exit 1; stdout={}", stdout(&out));
    assert!(
        stdout(&out).contains("unlock force-releases another user's lock; pass -f to confirm."),
        "expected the exact spec string, got: {}",
        stdout(&out)
    );
}

/// Task 3: `login --remember` writes a credential that a later command, run
/// with no session token and no CAMBIUM_PASSWORD, can still authenticate
/// from.
///
/// Uses `projects` rather than `whoami` as the probe: `whoami` only reads
/// the local session cache (see executor.rs's `whoami`, which never calls
/// `ensure_session`) and reports "-" fields instead of failing when there is
/// no session — so it exits 0 whether or not auth actually resolved, and
/// can't tell this test's two cases apart. `projects` calls `ensure_session`
/// and genuinely needs a working credential to succeed.
#[test]
#[ignore]
fn login_remember_lets_a_later_command_run_without_a_password() {
    let dir = tempfile::tempdir().unwrap();
    let out = run_cambium(dir.path(), OWNER_PASSWORD, &["login", "--remember"]);
    assert!(out.status.success(), "login failed: {}", String::from_utf8_lossy(&out.stderr));

    // Drop the session token so resolution MUST reach the credential store.
    std::fs::remove_file(dir.path().join("session.json")).unwrap();

    let out = run_cambium_no_password(dir.path(), &["projects"]);
    assert!(
        out.status.success(),
        "projects should have resolved from the stored credential: {}",
        String::from_utf8_lossy(&out.stderr)
    );
}

/// Task 3: `logout` must remove the remembered credential too, not just the
/// session token — otherwise the very next command silently re-authenticates.
/// See the note on `login_remember_lets_a_later_command_run_without_a_password`
/// for why this probes with `projects` instead of `whoami`.
#[test]
#[ignore]
fn logout_forgets_the_remembered_password() {
    let dir = tempfile::tempdir().unwrap();
    assert!(run_cambium(dir.path(), OWNER_PASSWORD, &["login", "--remember"]).status.success());
    assert!(run_cambium(dir.path(), OWNER_PASSWORD, &["logout"]).status.success());

    let out = run_cambium_no_password(dir.path(), &["projects"]);
    assert!(
        !out.status.success(),
        "logout must clear the credential, but projects still authenticated"
    );
}

/// Final-review Fix A: `login --remember` with no password available THIS
/// run must not report success. A first plain `login` (no `--remember`)
/// leaves a valid cached session token but stores no credential; a second,
/// non-interactive `login --remember` with no CAMBIUM_PASSWORD then resolves
/// auth fine from that cached token, but has nothing to validate-and-store —
/// it must fail instead of silently claiming the password is remembered.
#[test]
#[ignore]
fn login_remember_without_a_password_available_fails_instead_of_lying() {
    let dir = tempfile::tempdir().unwrap();
    let login = run_cambium(dir.path(), OWNER_PASSWORD, &["login"]);
    assert!(login.status.success(), "login failed: {}", stdout(&login));

    let remember = run_cambium_no_password(dir.path(), &["login", "--remember"]);
    assert!(
        !remember.status.success(),
        "login --remember with no password available must not report success, got: {}",
        stdout(&remember)
    );

    // And indeed nothing got stored: with the session token gone, `projects`
    // (a real auth probe — see the note on
    // `login_remember_lets_a_later_command_run_without_a_password` for why
    // not `whoami`) has no credential to fall back on either.
    std::fs::remove_file(dir.path().join("session.json")).unwrap();
    let projects = run_cambium_no_password(dir.path(), &["projects"]);
    assert!(
        !projects.status.success(),
        "no credential should have been stored by the failed --remember run, but projects still authenticated"
    );
}

/// Task 3 code review Fix 1: `login --remember` must only ever persist a
/// password THIS run actually validated against the server. Discriminates
/// the bug where `ensure_session`'s `Ok(())` (resolved from the cached
/// session token, not from CAMBIUM_PASSWORD) let a stale env password get
/// written over an already-good stored credential.
#[test]
#[ignore]
fn login_remember_does_not_clobber_a_good_credential_with_a_stale_password() {
    let dir = tempfile::tempdir().unwrap();
    // Establish a known-good remembered credential.
    assert!(run_cambium(dir.path(), OWNER_PASSWORD, &["login", "--remember"]).status.success());

    // Force resolution off the cached token and onto the stored credential.
    std::fs::remove_file(dir.path().join("session.json")).unwrap();

    // A stale env password must never be persisted over the good one.
    let _ = run_cambium(dir.path(), "wrong-password-entirely", &["login", "--remember"]);

    // The originally-stored credential must still work.
    std::fs::remove_file(dir.path().join("session.json")).ok();
    let out = run_cambium_no_password(dir.path(), &["projects"]);
    assert!(
        out.status.success(),
        "a stale --remember run clobbered the working credential: {}",
        String::from_utf8_lossy(&out.stderr)
    );
}

/// `addproject` / `delproject` end to end against a live scratch server.
///
/// One sequential flow, same rationale as the other multi-step tests here:
/// the server is shared across this binary, so the project is created,
/// inspected and destroyed in one place rather than leaking a project that a
/// later run would collide with.
///
/// Note these run with stdin at /dev/null, so `ExecContext::interactive` is
/// false — this is exactly the desktop console's path, where `-f` is the only
/// confirmation available.
#[test]
#[ignore]
fn addproject_and_delproject_flow() {
    let owner_cfg = tempfile::tempdir().unwrap();
    // Distinct name so a re-run (or a parallel binary) can't collide with the
    // seeded "alpha" or with a previous failed run's leftovers.
    let name = format!("probe-{}", std::process::id());

    // ---- addproject: the project appears in the caller's project list -------
    let add = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["addproject", &name]);
    assert!(add.status.success(), "addproject failed: {}", stdout(&add));
    assert!(
        stdout(&add).contains(&name),
        "expected the new project name in addproject output, got: {}",
        stdout(&add)
    );

    // A new project has NO members, and `projects` lists only what you belong
    // to — so it is deliberately absent until someone is assigned. This is the
    // documented follow-up, and asserting both halves pins the behaviour.
    let before = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["projects"]);
    assert!(
        !stdout(&before).contains(&name),
        "a memberless project should not appear in `projects`: {}",
        stdout(&before)
    );

    let assign =
        run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["assignmember", OWNER_USER, &name]);
    assert!(assign.status.success(), "assignmember failed: {}", stdout(&assign));

    let listed = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["projects"]);
    assert!(
        stdout(&listed).contains(&name),
        "created project missing from `projects` after assignmember: {}",
        stdout(&listed)
    );

    // ---- a duplicate name is refused, with the server's own reason ---------
    let dup = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["addproject", &name]);
    assert!(!dup.status.success(), "a duplicate name should be refused: {}", stdout(&dup));

    // ---- delproject without -f refuses, and does NOT delete ----------------
    let bare = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["delproject", &name]);
    assert!(!bare.status.success(), "delproject without -f must refuse");
    assert!(
        stdout(&bare).contains("-f"),
        "the refusal must name the flag that fixes it, got: {}",
        stdout(&bare)
    );
    let still_there = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["projects"]);
    assert!(
        stdout(&still_there).contains(&name),
        "a refused delproject deleted the project anyway: {}",
        stdout(&still_there)
    );

    // ---- an unknown name lists what's actually there -----------------------
    let missing =
        run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["delproject", "no-such-project", "-f"]);
    assert!(!missing.status.success(), "deleting an unknown project should fail");
    assert!(
        stdout(&missing).contains("no project named"),
        "expected an actionable 'no project named' error, got: {}",
        stdout(&missing)
    );

    // ---- delproject -f removes it ------------------------------------------
    let del = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["delproject", &name, "-f"]);
    assert!(del.status.success(), "delproject -f failed: {}", stdout(&del));
    let gone = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["projects"]);
    assert!(
        !stdout(&gone).contains(&name),
        "project survived delproject -f: {}",
        stdout(&gone)
    );
}

/// Deleting a project is owner-only on the server: a plain admin can create
/// one but is refused the delete (403). This is the one place the two verbs'
/// permissions differ, so it gets its own test.
#[test]
#[ignore]
fn delproject_is_refused_for_a_plain_admin() {
    let owner_cfg = tempfile::tempdir().unwrap();
    let admin_cfg = tempfile::tempdir().unwrap();
    let handle = format!("@admin{}", std::process::id());
    let name = format!("adminprobe-{}", std::process::id());

    // An admin who is emphatically not the owner.
    let add_user = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["adduser", &handle, "adminpw"]);
    assert!(add_user.status.success(), "adduser failed: {}", stdout(&add_user));
    let promote = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["setrole", &handle, "admin"]);
    assert!(promote.status.success(), "setrole failed: {}", stdout(&promote));

    // Creating is allowed for an admin...
    let created = run_cambium_as(admin_cfg.path(), &handle, "adminpw", &["addproject", &name]);
    assert!(created.status.success(), "an admin should be able to create: {}", stdout(&created));

    // ...deleting is not.
    let refused =
        run_cambium_as(admin_cfg.path(), &handle, "adminpw", &["delproject", &name, "-f"]);
    assert!(!refused.status.success(), "a plain admin must not delete a project");
    assert!(
        stdout(&refused).to_lowercase().contains("owner"),
        "the refusal should say the owner is required, got: {}",
        stdout(&refused)
    );

    // The owner can, which also cleans up after this test.
    let del = run_cambium(owner_cfg.path(), OWNER_PASSWORD, &["delproject", &name, "-f"]);
    assert!(del.status.success(), "owner delproject failed: {}", stdout(&del));
}
