//! Shared auth-token path resolution and IO for car-server's
//! per-launch ephemeral handshake (Parslee-ai/car-releases#32).
//!
//! Every consumer that touches the token agrees on the same path:
//! - `car-server` writes it on startup unless `--no-auth` is set
//!   (the default flipped from opt-in to opt-out on 2026-05 after
//!   a security audit)
//! - `car-ffi-common::proxy` reads it on connect so NAPI/PyO3
//!   daemon-mode just-works
//! - The colocated UI server exposes it at `GET /auth-token` so
//!   the local HTML renderer can `session.auth` before any other
//!   RPC
//! - External WS clients can read it directly with `0600` perms
//!   guaranteeing only the launching user can do so
//!
//! There is also a sibling **host token** (`host-token`, see
//! [`host_default_path`] / [`read_host`] / [`write_host`]) granting the
//! host-management role (Parslee-ai/car#254). It is deliberately **NOT**
//! exposed over `GET /auth-token` — readable only from its `0600` file —
//! so a client that can reach loopback HTTP but cannot read the user's
//! secret files (or a different local user) cannot obtain it. Do not add a
//! `/host-token` HTTP route: that would reopen the self-elevation hole.
//!
//! ## Path
//!
//! - **macOS**: `~/Library/Application Support/ai.parslee.car/auth-token`
//! - **Linux**: `$XDG_RUNTIME_DIR/ai.parslee.car/auth-token` if set,
//!   otherwise `~/.config/ai.parslee.car/auth-token`
//! - **Windows**: `%LOCALAPPDATA%\ai.parslee.car\auth-token` (per-user
//!   profile-private — Windows ACLs handle the equivalent of `0600`
//!   for files under the user's profile)
//!
//! ## File format
//!
//! Single line, no trailing newline. The token itself is 32 random
//! bytes encoded as base64url-no-pad (`base64::URL_SAFE_NO_PAD`),
//! which is 43 ASCII chars. Distinct per launch; never persisted
//! across daemon restarts.

use std::io;
use std::path::PathBuf;

const DIR_NAME: &str = "ai.parslee.car";
const FILE_NAME: &str = "auth-token";

/// Environment variable that lets a client override the auth-token
/// source. When set and non-empty, [`read_for_client`] returns its
/// value verbatim instead of reading the per-platform token file.
///
/// Required for cross-host deployments — e.g. an FFI client running on
/// Windows talking to a daemon on a Linux host. The client has no file
/// at the local `%LOCALAPPDATA%` path because that's the daemon's
/// concern, but the daemon's token can be transferred out-of-band
/// (ssh, secrets store, vault) and surfaced here.
///
/// Companion to `CAR_DAEMON_URL` from `car_proto::daemon`; both are
/// client-side env-var overrides with the same precedence semantics:
/// env var wins when set, fall back to the default file path
/// otherwise. Empty or whitespace-only values are treated as unset —
/// see [`read_for_client`] for the full precedence rules. See
/// Parslee-ai/car#231 §8.0.3 for the cross-host gap this closes.
pub const TOKEN_ENV_VAR: &str = "CAR_AUTH_TOKEN";

/// Resolve the auth-token path for this platform. Returns
/// `Err(io::Error)` if neither HOME nor the platform-specific
/// fallback environment variable is set — in which case auth can't
/// be turned on at all.
pub fn default_path() -> io::Result<PathBuf> {
    let dir = default_dir()?;
    Ok(dir.join(FILE_NAME))
}

/// Filename of the per-launch **host** token — a sibling of the
/// auth-token in the same per-platform directory. Distinct credential
/// granting the host-management role (Parslee-ai/car#254). Unlike the
/// auth token it is **never** exposed over `GET /auth-token`; the only
/// way to read it is this `0600` file, which a different local user
/// cannot — that's what makes host-role unforgeable across users.
const HOST_FILE_NAME: &str = "host-token";

/// Resolve the host-token path for this platform (sibling of
/// [`default_path`] in the same directory).
pub fn host_default_path() -> io::Result<PathBuf> {
    Ok(default_dir()?.join(HOST_FILE_NAME))
}

/// Read the host token from [`host_default_path`]. `Ok(None)` when the
/// file is absent (host-role unavailable — e.g. `--no-auth`).
pub fn read_host() -> io::Result<Option<String>> {
    read_at(&host_default_path()?)
}

/// Write the host token to [`host_default_path`] with the same hardened
/// `0600` / Windows-ACL guarantees as [`write`] (both go through
/// [`write_at`]).
pub fn write_host(token: &str) -> io::Result<PathBuf> {
    let path = host_default_path()?;
    write_at(&path, token)?;
    Ok(path)
}

fn default_dir() -> io::Result<PathBuf> {
    #[cfg(target_os = "macos")]
    {
        if let Some(home) = std::env::var_os("HOME") {
            return Ok(PathBuf::from(home)
                .join("Library")
                .join("Application Support")
                .join(DIR_NAME));
        }
    }
    #[cfg(target_os = "linux")]
    {
        if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") {
            return Ok(PathBuf::from(rt).join(DIR_NAME));
        }
        if let Some(home) = std::env::var_os("HOME") {
            return Ok(PathBuf::from(home).join(".config").join(DIR_NAME));
        }
    }
    #[cfg(target_os = "windows")]
    {
        if let Some(local) = std::env::var_os("LOCALAPPDATA") {
            return Ok(PathBuf::from(local).join(DIR_NAME));
        }
    }
    // Last-resort fallback for any platform that didn't match any
    // of the above branches: `$HOME/.car/`. Less standard but always
    // produces a usable path on a typical unix-style host.
    if let Some(home) = std::env::var_os("HOME") {
        return Ok(PathBuf::from(home).join(".car"));
    }
    Err(io::Error::new(
        io::ErrorKind::NotFound,
        "no HOME / XDG_RUNTIME_DIR / LOCALAPPDATA — can't resolve auth-token directory",
    ))
}

/// Read the token from [`default_path`]. Returns `Ok(None)` when the
/// file doesn't exist (auth disabled on the daemon side); returns
/// the token string on success.
pub fn read() -> io::Result<Option<String>> {
    read_at(&default_path()?)
}

/// Read the token from an explicit path — used by tests; production
/// callers use [`read`].
pub fn read_at(path: &std::path::Path) -> io::Result<Option<String>> {
    match std::fs::read_to_string(path) {
        Ok(s) => Ok(Some(s.trim().to_string())),
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e),
    }
}

/// Read the token for a *client* connecting to a daemon, with
/// [`TOKEN_ENV_VAR`] taking precedence over the local file path.
///
/// Precedence (env var wins, same shape as `CAR_DAEMON_URL`):
///
/// 1. `$CAR_AUTH_TOKEN` if set and non-empty → returned verbatim
/// 2. otherwise → delegates to [`read`] (the local per-platform file)
///
/// Whitespace around the env-var value is trimmed; whitespace-only
/// values are treated as unset and fall through to the file read.
/// Empty values fall through for the same reason — an explicitly
/// empty env var is almost always a shell-quoting bug, not a
/// deliberate "no token" signal.
///
/// Server-side callers (the daemon reading its own token from disk,
/// the UI server exposing it at `GET /auth-token`) **must continue to
/// use [`read`] directly**, not this function — the daemon would not
/// want a client-side env-var override to influence what it advertises
/// as its own per-launch token.
pub fn read_for_client() -> io::Result<Option<String>> {
    if let Some(env_value) = std::env::var_os(TOKEN_ENV_VAR) {
        // var_os preserves bytes verbatim. Use `to_str()` (NOT
        // `to_string_lossy`) so a non-UTF-8 env var falls through to
        // the file read rather than substituting `U+FFFD` replacement
        // characters and shipping garbage as the auth token. CAR
        // tokens are ASCII base64url-no-pad — always valid UTF-8 — so
        // a non-UTF-8 value is by definition a corrupted env var, and
        // "treat as unset" is the safer failure mode. Cheaper too:
        // `to_str()` returns `Some(&str)` without allocating when the
        // OsString is already a valid `&str`.
        if let Some(s) = env_value.to_str() {
            let trimmed = s.trim();
            if !trimmed.is_empty() {
                return Ok(Some(trimmed.to_string()));
            }
        }
    }
    read()
}

/// Write `token` atomically to [`default_path`]. Creates the parent
/// directory if missing. Sets `0600` permissions on POSIX (Windows
/// inherits the per-user-profile ACL).
pub fn write(token: &str) -> io::Result<PathBuf> {
    let path = default_path()?;
    write_at(&path, token)?;
    Ok(path)
}

/// Write to an explicit path — used by tests; production callers
/// use [`write`].
pub fn write_at(path: &std::path::Path, token: &str) -> io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
        }
    }
    let tmp = path.with_extension("tmp");
    // Write under tight perms first, then rename — POSIX
    // guarantees the rename is atomic on the same fs, so concurrent
    // readers never see a partial write.
    std::fs::write(&tmp, token)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
    }
    std::fs::rename(&tmp, path)?;
    #[cfg(target_os = "windows")]
    {
        // On Windows the file inherits the per-user-profile ACL from
        // %LOCALAPPDATA%, which (despite the "per-user" framing)
        // grants `BUILTIN\Administrators: FullControl`. On a single-
        // admin dev box that's effectively the same as 0600, but on a
        // shared / managed / enterprise host with multiple admins
        // any local admin can read the token and impersonate the
        // daemon to the WS surface — a real privesc that the Unix
        // chmod 0o600 was designed to block. Drop inheritance and
        // re-grant explicit ACEs for SYSTEM + the launching user.
        // Failures are logged and swallowed: the file was written
        // successfully; weaker ACLs are a hardening miss, not a
        // functional failure. Fixes finding 2 of Parslee-ai/car#231.
        harden_windows_acl(path);
    }
    Ok(())
}

#[cfg(target_os = "windows")]
fn harden_windows_acl(path: &std::path::Path) {
    use std::process::Command;

    let path_str = match path.to_str() {
        Some(s) => s,
        None => {
            tracing::warn!(
                ?path,
                "auth-token ACL hardening skipped: path is not valid UTF-8"
            );
            return;
        }
    };

    // Order matters: grant the required ACEs first, THEN drop
    // inheritance. The reverse order would leave a window where the
    // file has no ACEs at all and the daemon can't re-open it.

    // SYSTEM via well-known SID (locale-independent).
    let _ = run_icacls(&[path_str, "/grant:r", "*S-1-5-18:F"]);

    // Launching user — USERPROFILE/USERNAME isn't guaranteed inside
    // every service context (SYSTEM-launched daemons have neither),
    // so skip this step when missing. The SYSTEM grant above keeps
    // the daemon process able to read the file.
    if let Some(username) = std::env::var_os("USERNAME") {
        if let Some(u) = username.to_str() {
            let grant = format!("{u}:F");
            let _ = run_icacls(&[path_str, "/grant:r", &grant]);
        }
    }

    // Drop inherited ACEs. After this, only the explicit ACEs granted
    // above remain — inherited BUILTIN\Administrators access is gone.
    if let Err(e) = run_icacls(&[path_str, "/inheritance:r"]) {
        tracing::warn!(?e, ?path, "auth-token ACL hardening: /inheritance:r failed");
    }

    // `/inheritance:r` only removes *inherited* ACEs. On the #231 §3.1
    // test host an *explicit* `BUILTIN\Administrators:(F)` ACE survived
    // inheritance removal (re-verified against the shipped v0.18.0
    // binary: `AreAccessRulesProtected: True`, yet Administrators still
    // listed with FullControl). It materializes when the daemon writes
    // the file while elevated — the kernel stamps an explicit owner/
    // admin ACE that is not inherited and so is immune to
    // `/inheritance:r`. Strip it explicitly via the locale-independent
    // well-known SID (S-1-5-32-544 = BUILTIN\Administrators) so the
    // final DACL is exactly SYSTEM + owner — true parity with the Unix
    // `chmod 0o600`. Remove both the allow (`:g`) and any deny (`:d`)
    // grant. No-ops cleanly when the ACE isn't present.
    let _ = run_icacls(&[path_str, "/remove:g", "*S-1-5-32-544"]);
    let _ = run_icacls(&[path_str, "/remove:d", "*S-1-5-32-544"]);

    // Belt-and-suspenders: an elevated writer leaves the file *owned*
    // by the Administrators group, and an owner retains WRITE_DAC —
    // any local admin could re-grant themselves read and then read the
    // token. Re-home ownership to the launching user so that implicit
    // owner power tracks a single principal, not the whole admin group.
    // Best-effort: setting an owner other than self needs
    // SeRestorePrivilege, which a non-elevated user-context daemon may
    // lack; the explicit ACE removal above is the load-bearing fix, so
    // a failure here is logged-and-swallowed like the rest. `USERNAME`
    // is the bare account name (same value the grant above uses) —
    // adequate for the single-admin dev/box case this targets; it can
    // be ambiguous for domain/Microsoft accounts, but icacls resolves
    // it against the local machine and a miss just leaves ownership
    // unchanged (logged).
    //
    // Scope note: this brings the *DACL* to chmod-0600 parity (only
    // SYSTEM + owner in the access list) and removes the casual
    // any-admin read path. It is NOT a guarantee against a *malicious*
    // elevated administrator — SeTakeOwnership / SeBackupPrivilege let
    // such a principal re-own or back-up-read the file regardless of
    // ACLs. Defeating that adversary needs OS-level secret storage
    // (DPAPI / Credential Manager), which is a larger change tracked
    // separately; #231 §3.1 asked specifically for chmod-0600 ACL
    // parity, which this delivers.
    if let Some(username) = std::env::var_os("USERNAME") {
        if let Some(u) = username.to_str() {
            if let Err(e) = run_icacls(&[path_str, "/setowner", u]) {
                tracing::warn!(?e, ?path, "auth-token ACL hardening: /setowner failed");
            }
        }
    }

    fn run_icacls(args: &[&str]) -> io::Result<()> {
        let status = Command::new("icacls").args(args).status()?;
        if !status.success() {
            return Err(io::Error::new(
                io::ErrorKind::Other,
                format!("icacls {args:?} exited with status {status}"),
            ));
        }
        Ok(())
    }
}

/// Remove the token file. Idempotent — `Ok(())` whether or not the
/// file existed. Used by the `car-server` shutdown path so a stale
/// token doesn't outlive the daemon that minted it.
pub fn remove() -> io::Result<()> {
    let path = default_path()?;
    match std::fs::remove_file(&path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(e),
    }
}

/// Generate a fresh 32-byte token, base64url-no-pad encoded
/// (43 chars). Uses the system CSPRNG via `getrandom` indirectly
/// through `uuid::Uuid::new_v4` which seeds from `getrandom` — we
/// concatenate two UUIDs (16 bytes each) and re-encode.
pub fn generate() -> String {
    use base64::Engine as _;
    let a = uuid::Uuid::new_v4();
    let b = uuid::Uuid::new_v4();
    let mut bytes = [0u8; 32];
    bytes[..16].copy_from_slice(a.as_bytes());
    bytes[16..].copy_from_slice(b.as_bytes());
    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}

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

    #[test]
    fn generated_token_is_43_chars_base64url() {
        let t = generate();
        assert_eq!(t.len(), 43);
        for c in t.chars() {
            assert!(
                c.is_ascii_alphanumeric() || c == '-' || c == '_',
                "non-base64url char: {c:?}"
            );
        }
    }

    #[test]
    fn read_at_missing_returns_none() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("nonexistent.token");
        let result = read_at(&path).unwrap();
        assert_eq!(result, None);
    }

    #[test]
    fn write_at_then_read_at_round_trips() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("ai.parslee.car").join("auth-token");
        let token = generate();
        write_at(&path, &token).unwrap();
        assert_eq!(read_at(&path).unwrap().as_deref(), Some(token.as_str()));
        assert!(path.exists());
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
            assert_eq!(mode & 0o777, 0o600, "token must be 0600");
        }
    }

    #[test]
    fn write_at_overwrites_atomically() {
        // Repeated writes must replace cleanly — the rename-into-place
        // pattern guarantees no partial-write window. Two consecutive
        // writes with different content; second wins.
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("auth-token");
        write_at(&path, "first").unwrap();
        write_at(&path, "second").unwrap();
        assert_eq!(read_at(&path).unwrap().as_deref(), Some("second"));
    }

    // Tests that touch `$CAR_AUTH_TOKEN` (or read an env-derived path
    // like `HOME` while another test mutates it) serialize on the
    // crate-wide [`crate::env_test_lock`] — a single process-global lock
    // shared with `memory_path::tests` / `proxy::tests` so cross-module
    // env races (e.g. this module reading `HOME` while `memory_path`
    // writes it) can't happen, and so a panic in one test doesn't poison
    // a per-module lock and cascade.

    /// Panic-safe snapshot/restore helper for a single env var.
    /// Captures the current value on construction, restores it on
    /// `Drop` — so a test panic between mutation and explicit
    /// cleanup doesn't leak the override to subsequent tests.
    struct EnvGuard {
        var: &'static str,
        prev: Option<std::ffi::OsString>,
    }
    impl EnvGuard {
        fn capture(var: &'static str) -> Self {
            Self {
                var,
                prev: std::env::var_os(var),
            }
        }
    }
    impl Drop for EnvGuard {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => unsafe { std::env::set_var(self.var, v) },
                None => unsafe { std::env::remove_var(self.var) },
            }
        }
    }

    /// F2 (#231 §8.0.3): `$CAR_AUTH_TOKEN` wins over the local file
    /// path when set and non-empty. Required for cross-host clients —
    /// the FFI process can't read the daemon's token off the local
    /// disk when the daemon is on a different machine.
    #[test]
    fn read_for_client_prefers_env_var_when_set() {
        let _lock = crate::env_test_lock();
        let _env = EnvGuard::capture(TOKEN_ENV_VAR);

        unsafe { std::env::set_var(TOKEN_ENV_VAR, "from-env") };
        assert_eq!(
            read_for_client().unwrap().as_deref(),
            Some("from-env"),
            "env var should win when set"
        );
    }

    /// Empty and whitespace-only env values must NOT satisfy auth —
    /// a shell-quoting bug shouldn't silently disable the handshake.
    /// We assert this by the property that matters: `read_for_client`
    /// with an empty/whitespace env var must return *exactly the same
    /// outcome* as `read()` would with no env var at all. This holds
    /// regardless of whether the host has a token file at the
    /// well-known path, which lets the test stay hermetic — no
    /// mutation of `HOME`, `XDG_RUNTIME_DIR`, or `LOCALAPPDATA`
    /// required. (Touching those vars would create races with
    /// `memory_path::tests` and other consumers that mutate HOME
    /// outside this lock.)
    #[test]
    fn read_for_client_treats_empty_env_as_unset() {
        let _lock = crate::env_test_lock();
        let _env = EnvGuard::capture(TOKEN_ENV_VAR);

        for empty_value in ["", "   ", "\t\n", " \r\n "] {
            unsafe { std::env::set_var(TOKEN_ENV_VAR, empty_value) };
            let via_client = read_for_client();
            // Snapshot `read()` with no env interference for comparison.
            unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
            let via_direct = read();
            // Restore the empty value so the next iteration's snapshot
            // semantics hold.
            unsafe { std::env::set_var(TOKEN_ENV_VAR, empty_value) };

            match (&via_client, &via_direct) {
                (Ok(a), Ok(b)) => assert_eq!(
                    a, b,
                    "with empty env value {empty_value:?}, read_for_client \
                     must match read()'s outcome (env var should be treated \
                     as unset)"
                ),
                (Err(_), Err(_)) => {} // both fail the same way — acceptable
                (a, b) => panic!(
                    "read_for_client and read disagreed for empty env value \
                     {empty_value:?}: client={a:?} direct={b:?}"
                ),
            }
        }
    }

    /// Without `$CAR_AUTH_TOKEN`, `read_for_client` behaves exactly
    /// like `read()` — no change for same-host loopback callers.
    #[test]
    fn read_for_client_falls_back_to_read_when_unset() {
        let _lock = crate::env_test_lock();
        let _env = EnvGuard::capture(TOKEN_ENV_VAR);

        unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
        let via_client = read_for_client();
        let via_direct = read();
        match (&via_client, &via_direct) {
            (Ok(a), Ok(b)) => assert_eq!(
                a, b,
                "read_for_client without env var must match read() exactly"
            ),
            (Err(_), Err(_)) => {} // both fail the same way — acceptable
            (a, b) => panic!(
                "read_for_client and read disagreed: client={a:?} direct={b:?}"
            ),
        }
    }

    /// Trimming: the env var value is trimmed before use. A token
    /// pasted via SSH with trailing newline (the §8.0.3 standard
    /// transfer mechanism) still works. Also covers Windows-style
    /// CRLF endings if a token file was cat'd on Windows and piped
    /// over.
    #[test]
    fn read_for_client_trims_whitespace_around_token() {
        let _lock = crate::env_test_lock();
        let _env = EnvGuard::capture(TOKEN_ENV_VAR);

        for (input, expected) in [
            ("  some-token\n", "some-token"),
            ("some-token\r\n", "some-token"),
            ("\tsome-token\t", "some-token"),
        ] {
            unsafe { std::env::set_var(TOKEN_ENV_VAR, input) };
            assert_eq!(
                read_for_client().unwrap().as_deref(),
                Some(expected),
                "trim should normalize {input:?} → {expected:?}"
            );
        }
    }

    /// Non-UTF-8 env var must fall through to the file read, not
    /// substitute replacement chars and ship garbage. CAR tokens are
    /// ASCII base64url-no-pad so a non-UTF-8 value is by definition
    /// corrupt. Unix-only because `set_var` on Windows already
    /// requires UTF-16 — non-UTF-8 isn't constructible there.
    #[cfg(unix)]
    #[test]
    fn read_for_client_treats_non_utf8_env_as_unset() {
        use std::os::unix::ffi::OsStrExt;
        let _lock = crate::env_test_lock();
        let _env = EnvGuard::capture(TOKEN_ENV_VAR);

        // 0xFF is not a valid UTF-8 start byte; this OsStr cannot be
        // converted via `to_str()` and must fall through.
        let bad = std::ffi::OsStr::from_bytes(&[b'a', 0xFF, b'b']);
        unsafe { std::env::set_var(TOKEN_ENV_VAR, bad) };

        let via_client = read_for_client();
        // Snapshot read() with no env for comparison.
        unsafe { std::env::remove_var(TOKEN_ENV_VAR) };
        let via_direct = read();
        unsafe { std::env::set_var(TOKEN_ENV_VAR, bad) };

        match (&via_client, &via_direct) {
            (Ok(a), Ok(b)) => assert_eq!(
                a, b,
                "non-UTF-8 env value must be treated as unset and \
                 fall through to read()"
            ),
            (Err(_), Err(_)) => {}
            (a, b) => panic!(
                "read_for_client and read disagreed for non-UTF-8: \
                 client={a:?} direct={b:?}"
            ),
        }
    }
}
