//! Self-update core + the default-on auto-update daemon task.
//!
//! Version skew across CAR's independent install/update channels is the problem
//! this closes: the CLI, the `car-server` daemon, and `CarHost.app` update
//! through different mechanisms, so components silently drift apart (a stale
//! `/usr/local/bin/car` that Sparkle's app-update never touched sends a login
//! redirect a newer server rejects). This module owns the mechanism both the
//! `car update` CLI command and the daemon's background auto-updater share:
//! resolve the latest release, download the platform archive, verify it against
//! the GitHub-reported SHA-256, and atomically replace the running `car` CLI +
//! its sibling `car-server` in place.
//!
//! **Auto-update is on by default.** The daemon checks on boot and daily and
//! applies updates itself. It is careful:
//! - it **defers to Sparkle** when running inside `CarHost.app` (the app bundle
//!   owns its embedded daemon), never fighting the app updater;
//! - it **verifies the SHA-256** the GitHub API reports for the asset before
//!   replacing anything, so a corrupted or tampered download is rejected;
//! - it stages the new binaries in place and lets them take effect on the next
//!   launch, so a running session is never interrupted mid-flight;
//! - it is disabled by `CAR_AUTO_UPDATE=0` or `.car/config.toml`
//!   `auto_update = false`.
//!
//! The npm/PyPI `car-runtime` packages are project dependencies (pinned in a
//! lockfile) and are never reached into — they update with `npm update` / `pip
//! install -U`.

use std::path::{Path, PathBuf};
use std::time::Duration;

use sha2::{Digest, Sha256};

const RELEASES_REPO: &str = "Parslee-ai/car-releases";
const USER_AGENT: &str = concat!("car-self-update/", env!("CARGO_PKG_VERSION"));
/// Daily cadence for the background checker.
const AUTO_UPDATE_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
/// Grace period after boot before the first check, so startup isn't slowed and a
/// crash-looping daemon doesn't hammer the API.
const AUTO_UPDATE_BOOT_DELAY: Duration = Duration::from_secs(90);

/// Options for a one-shot `car update` invocation.
#[derive(Default)]
pub struct UpdateOptions {
    pub check_only: bool,
    pub version: Option<String>,
    pub force: bool,
}

/// A resolved release: which version, and the platform asset's URL + digest.
struct ResolvedAsset {
    version: String,
    url: String,
    /// SHA-256 hex (without the `sha256:` prefix), if the API reported one.
    sha256: Option<String>,
}

/// What an applied update changed.
pub struct AppliedUpdate {
    pub version: String,
    pub binaries: Vec<String>,
}

/// The release archive asset for the host platform. Mirrors the asset names
/// `scripts/release.sh` produces and `install.sh`/`install.js` consume.
fn platform_asset() -> Result<&'static str, String> {
    match (std::env::consts::OS, std::env::consts::ARCH) {
        ("macos", "aarch64") => Ok("car-darwin-arm64.tar.gz"),
        ("linux", "x86_64") => Ok("car-linux-x64-gnu.tar.gz"),
        ("linux", "aarch64") => Ok("car-linux-arm64-gnu.tar.gz"),
        ("windows", "x86_64") => Ok("car-win32-x64-msvc.zip"),
        (os, arch) => Err(format!(
            "no CAR release archive for {os}/{arch} — install manually from \
             https://github.com/{RELEASES_REPO}/releases"
        )),
    }
}

/// Binaries reconciled when they sit next to the running CLI/daemon. The running
/// binary is always replaced; the others only if already installed alongside it.
fn managed_binaries() -> &'static [&'static str] {
    if cfg!(windows) {
        &["car.exe", "car-server.exe", "car-memgine-eval.exe"]
    } else {
        &["car", "car-server", "car-memgine-eval"]
    }
}

fn normalize_version(v: &str) -> String {
    v.trim().trim_start_matches('v').to_string()
}

/// True when the running executable lives inside a macOS `.app` bundle — those
/// are Sparkle's territory (the app ships and updates its own embedded daemon),
/// so the binary auto-updater stands down to avoid fighting the app updater.
pub fn running_in_app_bundle(exe: &Path) -> bool {
    exe.components()
        .any(|c| c.as_os_str().to_str().is_some_and(|s| s.ends_with(".app")))
}

fn http_client() -> Result<reqwest::Client, String> {
    reqwest::Client::builder()
        .user_agent(USER_AGENT)
        .build()
        .map_err(|e| format!("build HTTP client: {e}"))
}

/// Resolve the platform asset for `version` (or the latest release) from the
/// GitHub API, returning its download URL and reported SHA-256. Using the API
/// (rather than a templated URL) gets us the digest for free — the integrity
/// check that makes silent auto-update safe.
async fn resolve_asset(
    client: &reqwest::Client,
    version: Option<&str>,
) -> Result<ResolvedAsset, String> {
    let want_asset = platform_asset()?;
    let api = match version {
        Some(v) => format!(
            "https://api.github.com/repos/{RELEASES_REPO}/releases/tags/v{}",
            normalize_version(v)
        ),
        None => format!("https://api.github.com/repos/{RELEASES_REPO}/releases/latest"),
    };
    let body: serde_json::Value = client
        .get(&api)
        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
        .send()
        .await
        .map_err(|e| format!("query release: {e}"))?
        .error_for_status()
        .map_err(|e| format!("query release: {e}"))?
        .json()
        .await
        .map_err(|e| format!("parse release JSON: {e}"))?;

    let version = body
        .get("tag_name")
        .and_then(|t| t.as_str())
        .map(normalize_version)
        .filter(|s| !s.is_empty())
        .ok_or_else(|| "release had no tag_name".to_string())?;

    let asset = body
        .get("assets")
        .and_then(|a| a.as_array())
        .into_iter()
        .flatten()
        .find(|a| a.get("name").and_then(|n| n.as_str()) == Some(want_asset))
        .ok_or_else(|| format!("release {version} has no asset {want_asset}"))?;

    let url = asset
        .get("browser_download_url")
        .and_then(|u| u.as_str())
        .ok_or_else(|| "asset had no download URL".to_string())?
        .to_string();
    let sha256 = asset
        .get("digest")
        .and_then(|d| d.as_str())
        .and_then(|d| d.strip_prefix("sha256:"))
        .map(str::to_string);

    Ok(ResolvedAsset {
        version,
        url,
        sha256,
    })
}

/// Download `url` to `dest`, verifying the SHA-256 when the API reported one.
/// A digest mismatch is a hard error — we never replace binaries with bytes we
/// couldn't authenticate against the API's record.
async fn download_and_verify(
    client: &reqwest::Client,
    asset: &ResolvedAsset,
    dest: &Path,
) -> Result<(), String> {
    let bytes = client
        .get(&asset.url)
        .send()
        .await
        .map_err(|e| format!("download {}: {e}", asset.url))?
        .error_for_status()
        .map_err(|e| format!("download {}: {e}", asset.url))?
        .bytes()
        .await
        .map_err(|e| format!("read download body: {e}"))?;

    if let Some(expected) = &asset.sha256 {
        let got = hex_lower(&Sha256::digest(&bytes));
        if !got.eq_ignore_ascii_case(expected) {
            return Err(format!(
                "integrity check failed for {}: expected sha256 {expected}, got {got} — \
                 refusing to install",
                asset.url
            ));
        }
    }
    std::fs::write(dest, &bytes).map_err(|e| format!("write {}: {e}", dest.display()))
}

fn hex_lower(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

/// Extract `archive` (`.tar.gz` or `.zip`) into `dest` via the system `tar`
/// (present on macOS, Linux, and Windows 10+; bsdtar handles zip too).
fn extract(archive: &Path, dest: &Path) -> Result<(), String> {
    let status = std::process::Command::new("tar")
        .arg("-xf")
        .arg(archive)
        .arg("-C")
        .arg(dest)
        .status()
        .map_err(|e| format!("run tar (is it on PATH?): {e}"))?;
    if !status.success() {
        return Err(format!("tar failed to extract {}", archive.display()));
    }
    Ok(())
}

fn find_in_tree(root: &Path, name: &str) -> Option<PathBuf> {
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if path.file_name().and_then(|n| n.to_str()) == Some(name) {
                return Some(path);
            }
        }
    }
    None
}

/// Replace `dst` with `src` in place. On Unix a same-directory rename is atomic
/// and safe while the old binary runs (the inode outlives the process). On
/// Windows a running `.exe` can't be overwritten but can be renamed aside.
fn replace_binary(src: &Path, dst: &Path) -> Result<(), String> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(src, std::fs::Permissions::from_mode(0o755));
    }
    let dir = dst
        .parent()
        .ok_or_else(|| format!("{} has no parent directory", dst.display()))?;
    let staged = dir.join(format!(
        ".{}.new",
        dst.file_name().and_then(|n| n.to_str()).unwrap_or("car")
    ));
    std::fs::copy(src, &staged).map_err(|e| format!("stage {}: {e}", staged.display()))?;

    #[cfg(windows)]
    if dst.exists() {
        let old = dst.with_extension("old");
        let _ = std::fs::remove_file(&old);
        std::fs::rename(dst, &old)
            .map_err(|e| format!("move aside {} (running elsewhere?): {e}", dst.display()))?;
    }

    std::fs::rename(&staged, dst).map_err(|e| {
        let _ = std::fs::remove_file(&staged);
        format!("install {}: {e}", dst.display())
    })
}

fn dir_writable(dir: &Path) -> bool {
    let probe = dir.join(".car-update-write-probe");
    match std::fs::File::create(&probe) {
        Ok(_) => {
            let _ = std::fs::remove_file(&probe);
            true
        }
        Err(_) => false,
    }
}

/// Resolve the running executable, following symlinks so we replace the real
/// file. Returns `(canonical_exe, install_dir)`.
fn running_exe() -> Result<(PathBuf, PathBuf), String> {
    let exe = std::env::current_exe().map_err(|e| format!("locate running binary: {e}"))?;
    let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
    let dir = exe
        .parent()
        .ok_or_else(|| format!("{} has no parent directory", exe.display()))?
        .to_path_buf();
    Ok((exe, dir))
}

/// Download, verify, extract, and replace the managed binaries in `install_dir`
/// for `asset`. Only touches binaries already present alongside `running`.
async fn apply(
    client: &reqwest::Client,
    asset: &ResolvedAsset,
    install_dir: &Path,
    running: &Path,
) -> Result<AppliedUpdate, String> {
    let tmp = std::env::temp_dir().join(format!("car-update-{}", asset.version));
    let _ = std::fs::remove_dir_all(&tmp);
    std::fs::create_dir_all(&tmp).map_err(|e| format!("create temp dir: {e}"))?;
    let _guard = TempCleanup(tmp.clone());

    let archive = tmp.join(platform_asset()?);
    download_and_verify(client, asset, &archive).await?;

    let unpack = tmp.join("unpacked");
    std::fs::create_dir_all(&unpack).map_err(|e| format!("create unpack dir: {e}"))?;
    extract(&archive, &unpack)?;

    let mut updated = Vec::new();
    for name in managed_binaries() {
        let dst = install_dir.join(name);
        let is_running = dst == running;
        if !is_running && !dst.exists() {
            continue;
        }
        let Some(src) = find_in_tree(&unpack, name) else {
            if is_running {
                return Err(format!("archive did not contain {name}"));
            }
            continue;
        };
        replace_binary(&src, &dst)?;
        updated.push(name.to_string());
    }
    Ok(AppliedUpdate {
        version: asset.version.clone(),
        binaries: updated,
    })
}

/// Drive a one-shot `car update`. `emit` receives human-readable progress lines
/// (the CLI prints them; other callers can log them).
pub async fn run_update(opts: UpdateOptions, emit: &dyn Fn(&str)) -> Result<(), String> {
    let current = env!("CARGO_PKG_VERSION").to_string();
    let (exe, install_dir) = running_exe()?;
    let client = http_client()?;
    let asset = resolve_asset(&client, opts.version.as_deref()).await?;

    emit(&format!(
        "car {current}  (installed at {})",
        install_dir.display()
    ));
    emit(&format!("latest: {}", asset.version));

    if asset.version == current && !opts.force {
        emit("✓ already up to date.");
        return Ok(());
    }
    if opts.check_only {
        if asset.version == current {
            emit("✓ on the latest version.");
        } else {
            emit(&format!(
                "→ update available: {current} → {}. Run `car update` to install.",
                asset.version
            ));
        }
        return Ok(());
    }
    if !dir_writable(&install_dir) {
        return Err(format!(
            "{} is not writable — re-run with elevated privileges:\n    sudo car update",
            install_dir.display()
        ));
    }

    emit(&format!("↓ downloading {} …", platform_asset()?));
    let applied = apply(&client, &asset, &install_dir, &exe).await?;
    emit(&format!(
        "✓ updated to {}: {}",
        applied.version,
        applied.binaries.join(", ")
    ));
    emit(
        "  Restart the daemon to load the new server: `car daemon restart` (or relaunch CarHost).",
    );
    if cfg!(target_os = "macos") {
        emit("  CarHost.app updates separately via its built-in updater (Check for Updates…).");
    }
    Ok(())
}

/// Whether the background auto-updater should run, honoring the opt-outs.
/// Default is ON. Off when `CAR_AUTO_UPDATE` is `0`/`false`/`off`, or when the
/// running binary is inside a `.app` bundle (Sparkle owns that daemon).
pub fn auto_update_enabled() -> bool {
    // Explicit env override wins over the config file.
    match std::env::var("CAR_AUTO_UPDATE") {
        Ok(v) => {
            if matches!(
                v.trim().to_ascii_lowercase().as_str(),
                "0" | "false" | "off" | "no"
            ) {
                return false;
            }
        }
        Err(_) => {
            if config_auto_update_disabled() {
                return false;
            }
        }
    }
    // Always defer to Sparkle for the app-bundled daemon, whatever the config.
    match running_exe() {
        Ok((exe, _)) => !running_in_app_bundle(&exe),
        Err(_) => false,
    }
}

/// `.car/config.toml` `auto_update = false` (discovered from `CAR_PROJECT_DIR`
/// or cwd, like the evolution cadence). Absent config or key = not disabled.
fn config_auto_update_disabled() -> bool {
    let Some(anchor) = std::env::var_os("CAR_PROJECT_DIR")
        .map(std::path::PathBuf::from)
        .or_else(|| std::env::current_dir().ok())
    else {
        return false;
    };
    let Some(car_dir) = car_memgine::project::discover_project(&anchor) else {
        return false;
    };
    matches!(
        car_memgine::project::load_config_overrides(&car_dir).and_then(|c| c.auto_update),
        Some(false)
    )
}

/// One auto-update check: if a newer version exists, download+verify+replace the
/// on-disk binaries. Returns `Some(version)` when an update was applied (it
/// takes effect on the next launch), `None` when already current. Errors are
/// returned for the caller to log; auto-update never panics a running daemon.
pub async fn auto_update_tick() -> Result<Option<String>, String> {
    let current = env!("CARGO_PKG_VERSION");
    let (exe, install_dir) = running_exe()?;
    if running_in_app_bundle(&exe) {
        return Ok(None); // Sparkle's job.
    }
    if !dir_writable(&install_dir) {
        return Err(format!(
            "auto-update: {} not writable (needs elevated install); skipping",
            install_dir.display()
        ));
    }
    let client = http_client()?;
    let asset = resolve_asset(&client, None).await?;
    // `CAR_AUTO_UPDATE_FORCE=1` reinstalls the latest even when it matches the
    // running version — a recovery/verification knob (repair a corrupt install,
    // or exercise the full download→verify→replace path when already current).
    let force = std::env::var_os("CAR_AUTO_UPDATE_FORCE").is_some();
    if asset.version == current && !force {
        return Ok(None);
    }
    let applied = apply(&client, &asset, &install_dir, &exe).await?;
    Ok(Some(applied.version))
}

/// Spawn the default-on background auto-updater: after a short boot grace period,
/// check on a daily cadence and self-update in place. Non-fatal — any error is
/// logged and the loop continues. No-op (returns without spawning) when disabled.
pub fn spawn_auto_update() {
    if !auto_update_enabled() {
        tracing::info!(
            target: "car::auto_update",
            "auto-update disabled (CAR_AUTO_UPDATE=0 or running inside an app bundle)"
        );
        return;
    }
    // Boot delay is overridable via CAR_AUTO_UPDATE_BOOT_DELAY_SECS (tuning /
    // fast tests); defaults to the 90s grace period.
    let boot_delay = std::env::var("CAR_AUTO_UPDATE_BOOT_DELAY_SECS")
        .ok()
        .and_then(|s| s.parse::<u64>().ok())
        .map(Duration::from_secs)
        .unwrap_or(AUTO_UPDATE_BOOT_DELAY);
    tokio::spawn(async move {
        tokio::time::sleep(boot_delay).await;
        let mut tick = tokio::time::interval(AUTO_UPDATE_INTERVAL);
        loop {
            tick.tick().await;
            match auto_update_tick().await {
                Ok(Some(v)) => tracing::info!(
                    target: "car::auto_update",
                    version = %v,
                    "auto-update installed {v}; takes effect on next daemon/CLI launch"
                ),
                Ok(None) => tracing::debug!(target: "car::auto_update", "already up to date"),
                Err(e) => {
                    tracing::warn!(target: "car::auto_update", error = %e, "auto-update check failed")
                }
            }
        }
    });
}

struct TempCleanup(PathBuf);
impl Drop for TempCleanup {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

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

    #[test]
    fn normalize_strips_v_prefix_and_whitespace() {
        assert_eq!(normalize_version(" v0.34.0\n"), "0.34.0");
        assert_eq!(normalize_version("0.34.0"), "0.34.0");
    }

    #[test]
    fn platform_asset_resolves_or_errors_cleanly() {
        match platform_asset() {
            Ok(a) => {
                assert!(a.starts_with("car-") && (a.ends_with(".tar.gz") || a.ends_with(".zip")))
            }
            Err(e) => assert!(e.contains("no CAR release archive")),
        }
    }

    #[test]
    fn hex_lower_matches_known_sha256() {
        // sha256("") = e3b0c442...
        let empty = Sha256::digest(b"");
        assert!(hex_lower(&empty).starts_with("e3b0c44298fc1c14"));
    }

    #[test]
    fn app_bundle_detection() {
        assert!(running_in_app_bundle(Path::new(
            "/Applications/CarHost.app/Contents/MacOS/car-server"
        )));
        assert!(!running_in_app_bundle(Path::new("/usr/local/bin/car")));
        assert!(!running_in_app_bundle(Path::new(
            "/Users/x/.car/bin/car-server"
        )));
    }

    #[test]
    fn auto_update_disabled_by_env() {
        // Guarded save/restore so we don't leak env across tests.
        let prev = std::env::var_os("CAR_AUTO_UPDATE");
        unsafe { std::env::set_var("CAR_AUTO_UPDATE", "0") };
        assert!(!auto_update_enabled());
        unsafe {
            match prev {
                Some(v) => std::env::set_var("CAR_AUTO_UPDATE", v),
                None => std::env::remove_var("CAR_AUTO_UPDATE"),
            }
        }
    }

    #[test]
    fn find_in_tree_locates_nested_binary() {
        let tmp = std::env::temp_dir().join(format!("car-sscore-ut-{}", std::process::id()));
        let nested = tmp.join("a").join("b");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(nested.join("car-server"), b"x").unwrap();
        assert_eq!(
            find_in_tree(&tmp, "car-server"),
            Some(nested.join("car-server"))
        );
        assert_eq!(find_in_tree(&tmp, "nope"), None);
        let _ = std::fs::remove_dir_all(&tmp);
    }
}
