//! In-place binary upgrade for atomcode.
//!
//! Flow:
//! 1. Fetch `latest.json` manifest (version + per-target sha256/size).
//! 2. Detect current platform and pick the matching binary entry.
//! 3. Verify we can write to `current_exe()`'s directory — if not, fail
//!    with a precise message telling the user to re-run with `sudo`.
//! 4. Download the binary to a sibling temp file, streaming progress.
//! 5. Verify SHA256 against the manifest. Bail (and delete temp) on
//!    mismatch — we never touch the live binary until verification
//!    passes.
//! 6. Three-way swap to replace the live binary:
//!    a. `atomcode` → `.atomcode.rolling`  (Windows allows renaming a running exe)
//!    b. new binary → `atomcode`            (install the upgrade)
//!    c. best-effort: remove old `.bak`, then `.atomcode.rolling` → `.bak`
//!
//!    Steps a–b are the critical path; step c is best-effort. If the old
//!    `.bak` is locked (AV scanner, still-running process, read-only
//!    attribute), the upgrade still succeeds — the `.rolling` file lingers
//!    and is cleaned up on the next upgrade attempt.
//!
//! Rollback swaps the live binary with `.bak` in place, so one backup
//! always points to "the other version" — the user can toggle by
//! alternating `/upgrade` and `/upgrade rollback`.

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

use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
use tokio::sync::mpsc;

pub const MANIFEST_URL: &str =
    "https://raw.atomgit.com/atomgit_atomcode/atomcode/raw/main/latest.json";
pub const DOWNLOAD_BASE: &str = "https://atomgit.com/atomgit_atomcode/atomcode/releases/download";

/// Streamed progress events from the upgrade/rollback machinery.
///
/// Sender is always async-owned (the upgrade task); receivers are the
/// TUI event loop or the CLI `stdout` logger. Events are advisory —
/// dropping the receiver must never block the upgrade.
#[derive(Debug, Clone)]
pub enum UpgradeEvent {
    ManifestFetched {
        version: String,
    },
    Downloading {
        bytes: u64,
        total: u64,
    },
    Verifying,
    Replacing,
    Done {
        version: String,
        backup: PathBuf,
        /// The *original* exe path (e.g. `atomcode.exe`) **before**
        /// `replace_binary` renamed it. On Windows, `current_exe()`
        /// returns the renamed path after the swap, so callers must
        /// use this field for `re_exec_self`.
        exe: PathBuf,
    },
    /// Terminal failure. Carries the display-formatted error so the UI
    /// layer doesn't need `anyhow` to render it.
    Failed(String),
    /// Rollback finished. Reported through the same channel so the TUI
    /// can drive both flows with a single select arm.
    RolledBack {
        exe: PathBuf,
        backup: PathBuf,
    },
}

#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
    pub version: String,
    #[serde(default)]
    pub released_at: Option<String>,
    pub binaries: std::collections::BTreeMap<String, BinaryEntry>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct BinaryEntry {
    pub sha256: String,
    pub size: u64,
}

#[derive(Debug, Clone)]
pub struct UpgradeSummary {
    pub version: String,
    pub backup: PathBuf,
    pub exe: PathBuf,
}

#[derive(Debug, Clone)]
pub struct RollbackSummary {
    pub exe: PathBuf,
    pub backup: PathBuf,
}

/// Return the target tag used in release artifact names
/// (`darwin-arm64`, `linux-x64`, `windows-x64`, …).
///
/// `None` means the current platform has no published release — the
/// caller must surface a clean "unsupported platform" message rather
/// than fall through to a 404 download.
// `target_env = "ohos"` is unknown to the check-cfg lint on toolchains
// without the OpenHarmony target registered; the cfg is still correct when
// building for `*-linux-ohos`, so silence the false-positive.
#[allow(unexpected_cfgs)]
pub fn detect_target() -> Option<&'static str> {
    // HarmonyOS / OpenHarmony builds report `std::env::consts::OS == "linux"`
    // at runtime, so the OS+ARCH table below would resolve to `linux-arm64`
    // — a STATIC-musl build that doesn't run on HarmonyOS — and the auto-
    // upgrade would clobber the working DYNAMIC-musl install (the one linked
    // against `/lib/ld-musl-aarch64.so.1`), leaving `permission denied`.
    // The OHOS artifact is compiled for a `*-linux-ohos` target, so pin it to
    // its own `ohos-arm64` asset using the COMPILE-TIME target rather than the
    // runtime OS string. Zero effect on every other platform.
    #[cfg(any(target_os = "ohos", target_env = "ohos"))]
    {
        return match std::env::consts::ARCH {
            "aarch64" | "arm64" => Some("ohos-arm64"),
            _ => None,
        };
    }
    #[cfg(not(any(target_os = "ohos", target_env = "ohos")))]
    {
        target_tag(std::env::consts::OS, std::env::consts::ARCH)
    }
}

fn target_tag(os: &str, arch: &str) -> Option<&'static str> {
    match (os, arch) {
        ("macos", "aarch64") => Some("darwin-arm64"),
        ("macos", "x86_64") => Some("darwin-x64"),
        ("linux", "x86_64") => Some("linux-x64"),
        ("linux", "aarch64") => Some("linux-arm64"),
        ("windows", "x86_64") => Some("windows-x64"),
        ("windows", "aarch64") => Some("windows-arm64"),
        _ => None,
    }
}

/// Release artifact filename for a given version + target, matching
/// what `scripts/release.sh` publishes to `dist/<version>/`.
pub fn binary_filename(version: &str, target: &str) -> String {
    if target.starts_with("windows") {
        format!("atomcode-{}-{}.exe", version, target)
    } else {
        format!("atomcode-{}-{}", version, target)
    }
}

pub fn binary_url(version: &str, target: &str) -> String {
    format!(
        "{}/{}/{}",
        DOWNLOAD_BASE,
        version,
        binary_filename(version, target)
    )
}

/// Path of the running `atomcode` executable. Resolved once at the
/// start of an upgrade so we know what to replace.
pub fn current_exe_path() -> Result<PathBuf> {
    std::env::current_exe().context("could not resolve current executable path")
}

/// Sibling path used to stash the previous binary.
///
/// Unix: `atomcode` → `atomcode.bak`.
/// Windows: `atomcode.exe` → `atomcode.exe.bak`.
pub fn backup_path(exe: &Path) -> PathBuf {
    let mut os = exe.as_os_str().to_os_string();
    os.push(".bak");
    PathBuf::from(os)
}

/// Temp file where an in-flight download is written before atomic
/// rename. Dotted prefix so it doesn't show up in a casual `ls`, and
/// also so it's easy to identify-and-clean if a crash orphans it.
fn download_path(exe: &Path) -> PathBuf {
    let dir = exe.parent().unwrap_or_else(|| Path::new("."));
    dir.join(".atomcode.download")
}

/// Same-dir temp used during a three-way rollback swap.
pub(crate) fn rolling_path(exe: &Path) -> PathBuf {
    let dir = exe.parent().unwrap_or_else(|| Path::new("."));
    dir.join(".atomcode.rolling")
}

/// Return `Ok(())` iff we can create a file alongside `exe`.
///
/// Testing the *directory* (not the file itself) is what matters:
/// `rename(2)` needs write permission on the containing dir to atomic
/// replace. Probing creates a real empty file and deletes it to avoid
/// false positives from filesystems that claim metadata writability
/// but reject opens.
pub fn ensure_writable(exe: &Path) -> Result<()> {
    let dir = exe
        .parent()
        .ok_or_else(|| anyhow!("executable has no parent directory: {}", exe.display()))?;
    let probe = dir.join(".atomcode.writable-probe");
    match std::fs::File::create(&probe) {
        Ok(_) => {
            let _ = std::fs::remove_file(&probe);
            Ok(())
        }
        Err(e) => Err(anyhow!(
            "{} is not writable by the current user ({}).\n\
             Re-run with elevated privileges:  sudo atomcode upgrade\n\
             Or reinstall into a user-writable location (e.g. ~/.local/bin).",
            dir.display(),
            e
        )),
    }
}

/// Fetch and parse `latest.json`.
///
/// Longer timeout than the passive `version_check` (which must fail
/// fast at startup); here the user explicitly asked for an upgrade, so
/// waiting 30s for a slow mirror is acceptable.
pub async fn fetch_manifest() -> Result<Manifest> {
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .user_agent(crate::ATOMCODE_USER_AGENT)
        .build()?;
    let resp = client
        .get(MANIFEST_URL)
        .send()
        .await
        .context("failed to fetch latest.json")?;
    if !resp.status().is_success() {
        return Err(anyhow!(
            "fetching latest.json returned HTTP {}",
            resp.status()
        ));
    }
    let body = resp.text().await.context("reading latest.json body")?;
    serde_json::from_str(&body)
        .with_context(|| format!("parsing latest.json (body: {:?})", truncate(&body, 200)))
}

fn truncate(s: &str, max_chars: usize) -> String {
    if s.chars().count() <= max_chars {
        s.to_string()
    } else {
        let head: String = s.chars().take(max_chars).collect();
        format!("{}…", head)
    }
}

/// Download `url` to `dest`, streaming SHA256 as bytes arrive.
///
/// `progress` fires every chunk with (bytes_so_far, total). Callers
/// should debounce before redrawing the UI; we emit eagerly because
/// the upgrade UI wants smooth progress for a ~10 MB file.
///
/// Verifies size AND sha256 before returning. On any failure the
/// partial file is removed so a retry starts clean.
async fn download_and_verify(
    url: &str,
    expected_sha256: &str,
    expected_size: u64,
    dest: &Path,
    progress: &mpsc::UnboundedSender<UpgradeEvent>,
) -> Result<()> {
    use futures::StreamExt;

    if dest.exists() {
        let _ = std::fs::remove_file(dest);
    }

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(600))
        .user_agent(crate::ATOMCODE_USER_AGENT)
        .build()?;
    let resp = client
        .get(url)
        .send()
        .await
        .with_context(|| format!("GET {}", url))?;
    if !resp.status().is_success() {
        return Err(anyhow!(
            "downloading {} returned HTTP {} — release may not exist for this platform",
            url,
            resp.status()
        ));
    }

    // Don't bail on Content-Length mismatches.  The Content-Length
    // header may differ from the manifest's `size` for several reasons:
    //   - CDN mirrors or reverse proxies may alter Content-Length.
    //   - The manifest may be slightly out of sync with the server's
    //     binary (e.g. during a rolling release).
    //   - Redirects can surface a different Content-Length from an
    //     intermediate hop.
    // The real integrity checks happen after the download completes:
    //   1. Total bytes written must match `expected_size` (below).
    //   2. SHA256 must match `expected_sha256`.
    // These two checks are sufficient; an early Content-Length gate
    // causes false-negative aborts (see issue #380).

    let mut file = tokio::fs::File::create(dest)
        .await
        .with_context(|| format!("creating {}", dest.display()))?;
    let mut hasher = Sha256::new();
    let mut written: u64 = 0;
    let mut stream = resp.bytes_stream();
    while let Some(chunk) = stream.next().await {
        let chunk = chunk.context("reading response chunk")?;
        hasher.update(&chunk);
        file.write_all(&chunk)
            .await
            .context("writing download to disk")?;
        written += chunk.len() as u64;
        // Ignore send errors — receiver may have been dropped.
        let _ = progress.send(UpgradeEvent::Downloading {
            bytes: written,
            total: expected_size,
        });
    }
    file.flush().await.context("flushing download to disk")?;
    drop(file);

    if written != expected_size {
        let _ = std::fs::remove_file(dest);
        return Err(anyhow!(
            "short download: got {} bytes, expected {}",
            written,
            expected_size
        ));
    }

    let _ = progress.send(UpgradeEvent::Verifying);
    let got = hex_encode(&hasher.finalize());
    if !got.eq_ignore_ascii_case(expected_sha256) {
        let _ = std::fs::remove_file(dest);
        return Err(anyhow!(
            "checksum mismatch — possible corruption or tampering.\n  expected: {}\n  got:      {}",
            expected_sha256,
            got
        ));
    }

    Ok(())
}

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

/// Best-effort cleanup of a leftover file (typically a `.bak` or `.rolling`
/// from a prior upgrade/rollback). Returns `true` if the file was removed,
/// `false` if it could not be removed (locked, permission denied, etc.).
///
/// Defensive against Windows ACCESS_DENIED scenarios:
///
/// 1. The file carries a read-only attribute (some AV / SCCM policies
///    flag any executable in `%LOCALAPPDATA%` this way). Clear it first.
/// 2. Windows Defender or another scanner briefly holds the file open
///    during a real-time scan — typically <500 ms. Retry once with a
///    short sleep before giving up.
/// 3. The file is a still-running atomcode process from a prior upgrade
///    where the user didn't restart. Nothing we can do at the code layer;
///    the caller proceeds without blocking the upgrade.
///
/// This function is intentionally **best-effort** — a failure to remove
/// the stale file must NOT block an upgrade. The three-way swap in
/// `replace_binary` ensures the upgrade proceeds even if old backups
/// cannot be deleted.
fn try_remove_stale(path: &Path) -> bool {
    if !path.exists() {
        return true;
    }

    #[cfg(windows)]
    {
        if let Ok(meta) = path.metadata() {
            let mut perm = meta.permissions();
            if perm.readonly() {
                perm.set_readonly(false);
                let _ = std::fs::set_permissions(path, perm);
            }
        }
    }

    if std::fs::remove_file(path).is_ok() {
        return true;
    }
    // Brief retry to ride out a transient AV / indexer hold.
    std::thread::sleep(std::time::Duration::from_millis(500));
    std::fs::remove_file(path).is_ok()
}

/// Put `new_bin` in place of `exe`, keeping the previous `exe` as `.bak`.
///
/// Uses a **three-way swap** to avoid ever needing to delete `.bak` as a
/// prerequisite — the old approach of "delete .bak, then rename exe→.bak"
/// could fail on Windows when `.bak` is locked (AV scanner, read-only
/// attribute, still-running process). The swap sequence is:
///
///   1. `exe` → `.rolling`      (Windows allows renaming a running exe)
///   2. `new_bin` → `exe`        (install new version)
///   3. best-effort: delete old `.bak`
///   4. `.rolling` → `.bak`      (preserve old version for rollback)
///
/// Steps 1–2 are the critical path; steps 3–4 are best-effort cleanup.
/// If step 4 fails (e.g. old `.bak` is still locked), the upgrade still
/// succeeds — the `.rolling` file is left behind and will be cleaned up
/// on the next upgrade attempt.
///
/// On Unix, `rename(2)` within a directory is atomic; on Windows, an
/// exe currently being executed can be renamed (but not deleted), so
/// the same sequence works.
fn replace_binary(new_bin: &Path, exe: &Path) -> Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perm = std::fs::metadata(new_bin)
            .with_context(|| format!("stat {}", new_bin.display()))?
            .permissions();
        perm.set_mode(0o755);
        std::fs::set_permissions(new_bin, perm)
            .with_context(|| format!("chmod {}", new_bin.display()))?;
    }

    let backup = backup_path(exe);
    let rolling = rolling_path(exe);

    // Clean up any leftover .rolling from a prior interrupted upgrade.
    try_remove_stale(&rolling);

    // Step 1: live binary → rolling (Windows allows renaming a running exe)
    std::fs::rename(exe, &rolling).with_context(|| {
        format!(
            "renaming current binary {} -> {} (swap step 1)",
            exe.display(),
            rolling.display()
        )
    })?;

    // Step 2: new binary → live (the actual upgrade)
    if let Err(e) = std::fs::rename(new_bin, exe) {
        // Best-effort unwind of step 1 so the user isn't left without
        // a live binary.
        let _ = std::fs::rename(&rolling, exe);
        return Err(anyhow!(
            "moving new binary into place failed ({}). Previous version restored.",
            e
        ));
    }

    // Step 3: best-effort — remove old .bak so we can rename .rolling→.bak.
    // Failure is non-fatal; we just leave .rolling behind.
    let bak_removed = try_remove_stale(&backup);

    // Step 4: rolling → .bak (preserve old version for rollback)
    if bak_removed {
        if let Err(e) = std::fs::rename(&rolling, &backup) {
            // Upgrade succeeded but we couldn't preserve the old version
            // as .bak. The .rolling file lingers; next upgrade will
            // clean it up. Rollback won't be available this session.
            eprintln!(
                "Note: could not preserve previous version as backup ({}). Rollback unavailable until next upgrade.",
                e
            );
        }
    } else {
        // Old .bak couldn't be removed (locked by AV, running process, etc.).
        // The .rolling file stays behind; next upgrade attempt will clean
        // it up. Rollback points to the version before this upgrade's
        // predecessor, which is still better than failing the upgrade.
        eprintln!(
            "Note: could not remove old backup {}. Rollback may point to an older version.\n  The .rolling file at {} will be cleaned up on the next upgrade.",
            backup.display(),
            rolling.display()
        );
    }

    Ok(())
}

/// Top-level upgrade driver.
///
/// `current_version` is what we're running right now (e.g. `"v4.19.0"`
/// — callers typically pass `format!("v{}", env!("CARGO_PKG_VERSION"))`).
/// When `force` is false and the manifest version is `<=` current, this
/// returns an error carrying `ALREADY_LATEST` so callers can distinguish
/// "already up to date" from a real failure.
/// In `distro-pm` builds this returns immediately with an error carrying `PACKAGE_MANAGED` (upgrades are the package manager's job).
pub async fn run_upgrade(
    current_version: String,
    force: bool,
    tx: mpsc::UnboundedSender<UpgradeEvent>,
) -> Result<UpgradeSummary> {
    if is_package_managed() {
        return Err(anyhow!(PACKAGE_MANAGED));
    }
    let current_version = current_version.as_str();
    let target = detect_target().ok_or_else(|| {
        anyhow!(
            "this platform has no published atomcode release ({}/{})",
            std::env::consts::OS,
            std::env::consts::ARCH
        )
    })?;
    let exe = current_exe_path()?;
    ensure_writable(&exe)?;

    let manifest = fetch_manifest().await?;
    let _ = tx.send(UpgradeEvent::ManifestFetched {
        version: manifest.version.clone(),
    });

    if !force && !is_newer(&manifest.version, current_version) {
        return Err(anyhow!(
            "{}: already on {} (latest is {}). Pass --force to reinstall.",
            ALREADY_LATEST,
            current_version,
            manifest.version
        ));
    }

    let entry = manifest.binaries.get(target).ok_or_else(|| {
        anyhow!(
            "manifest has no entry for target {} — this platform may not be in this release",
            target
        )
    })?;

    let url = binary_url(&manifest.version, target);
    let download = download_path(&exe);
    download_and_verify(&url, &entry.sha256, entry.size, &download, &tx).await?;

    let _ = tx.send(UpgradeEvent::Replacing);
    replace_binary(&download, &exe)?;

    // Manual `/upgrade` just installed whatever the current manifest
    // advertises. Any staged upgrade sitting in `staged_dir()` is now
    // superseded — if we leave `pending.json` in place, the next startup
    // might try to "apply" an older (or identical) staged version on top
    // of what we just installed, causing a downgrade or redundant churn.
    // Clear both the pointer and any stray staged binaries.
    clear_pending_pointer();
    if let Ok(entries) = std::fs::read_dir(staged_dir()) {
        for e in entries.flatten() {
            let p = e.path();
            if p.file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.starts_with("atomcode-"))
            {
                let _ = std::fs::remove_file(&p);
            }
        }
    }

    let backup = backup_path(&exe);
    // NOTE: `exe` was captured *before* `replace_binary` renamed the running
    // binary. On Windows, `current_exe()` would now return `.atomcode.rolling`
    // instead of the original `atomcode.exe`, so we must pass this saved
    // value through to `re_exec_self`.
    let _ = tx.send(UpgradeEvent::Done {
        version: manifest.version.clone(),
        backup: backup.clone(),
        exe: exe.clone(),
    });

    Ok(UpgradeSummary {
        version: manifest.version,
        backup,
        exe,
    })
}

/// Sentinel substring in the "already latest" error so the CLI/TUI
/// layer can render a calm informational message instead of a scary
/// red error. Kept as a plain string to avoid an error-type refactor.
pub const ALREADY_LATEST: &str = "ALREADY_LATEST";

/// Sentinel embedded in the error returned by `run_upgrade` / `run_rollback`
/// when this binary is a package-manager-managed build (HarmonyBrew).
/// Callers special-case it to show "use `brew upgrade`" instead of a
/// generic failure — mirrors the `ALREADY_LATEST` pattern.
pub const PACKAGE_MANAGED: &str = "PACKAGE_MANAGED";

/// True when this binary was compiled for package-manager distribution
/// (the `distro-pm` feature, set by the HarmonyBrew formula). Such builds
/// must never self-modify the binary — upgrades are the package manager's
/// job.
pub const fn is_package_managed() -> bool {
    cfg!(feature = "distro-pm")
}

// ============================================================================
// Deferred upgrade (download-in-session, apply-at-next-startup)
// ============================================================================
//
// The deferred path solves two problems that `run_upgrade` alone can't:
//   1. Users whose sessions run for hours/days — they'll never voluntarily
//      restart just to pick up a new version. A background task can prepare
//      the staged binary while they work; apply happens whenever they do
//      restart (which they will, eventually, for unrelated reasons).
//   2. Users whose sessions are short but who rarely think to run
//      `/upgrade` — same benefit: the next normal launch carries the bump.
//
// Flow:
//   session N      : prepare_deferred_upgrade()
//                    → download to ~/.atomcode/staged/<filename>
//                    → write ~/.atomcode/staged/pending.json
//                    → (UI surfaces "⟲ vX.Y.Z pending")
//   session N exit : no special work; staged files survive any exit path
//                    (graceful, SIGHUP on terminal close, SIGKILL, power
//                    loss — all fine, state is on disk)
//   session N+1    : apply_pending_upgrade() runs BEFORE tokio starts
//                    → atomically swaps live binary with staged
//                    → re-execs self with original argv
//                    → user sees "✓ Upgraded to vX.Y.Z" on welcome
//
// A safety circuit-breaker is wired in: if apply succeeds but the new
// binary fails to start `MAX_APPLY_ATTEMPTS` times in a row, the staged
// file is discarded so a broken release can't brick the install.

/// Maximum times we'll try to apply the same staged upgrade before
/// giving up. Prevents a corrupted download from turning into a boot loop.
const MAX_APPLY_ATTEMPTS: u32 = 3;

/// Pointer record stored at `~/.atomcode/staged/pending.json`. Describes
/// a downloaded binary that hasn't yet been promoted to live. Lifecycle:
/// written by `prepare_deferred_upgrade`, read (and deleted on success /
/// incremented on failure) by `apply_pending_upgrade`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PendingUpgrade {
    /// Version tag the staged binary represents, e.g. `v4.19.1`.
    pub version: String,
    /// Absolute path to the verified binary sitting in `staged_dir()`.
    pub staged_path: PathBuf,
    /// SHA256 of the staged binary (lowercase hex). Re-verified at apply
    /// time so a partial overwrite between sessions (e.g. disk full)
    /// doesn't install a corrupted file.
    pub sha256: String,
    /// Size in bytes; cheap sanity check before we recompute sha256.
    pub size: u64,
    /// RFC3339 timestamp for audit / debug. Not load-bearing.
    pub created_at: String,
    /// How many times we've attempted apply and failed. When this hits
    /// `MAX_APPLY_ATTEMPTS`, the staged file is discarded instead of
    /// retried again on the following startup.
    #[serde(default)]
    pub attempts: u32,
}

/// Successful apply result — fed into the re-exec handoff so the new
/// process can render a one-time "✓ Upgraded" banner on the welcome screen.
#[derive(Debug, Clone)]
pub struct AppliedUpgrade {
    pub version: String,
    pub backup: PathBuf,
    pub exe: PathBuf,
}

/// `~/.atomcode/staged/` (or equivalent on Windows). Created on demand by
/// `prepare_deferred_upgrade`; safe to treat as possibly-missing at read
/// time. Kept under the same root as `history` / `recent_dirs` so nothing
/// new appears in `$HOME`.
pub fn staged_dir() -> PathBuf {
    crate::config::Config::config_dir().join("staged")
}

fn pending_json_path() -> PathBuf {
    staged_dir().join("pending.json")
}

/// Where a prepared (downloaded + verified) binary lives while it waits
/// to be promoted. Filename mirrors the release artifact so the same
/// `binary_filename` helper round-trips.
fn staged_binary_path(version: &str, target: &str) -> PathBuf {
    staged_dir().join(binary_filename(version, target))
}

/// Read `pending.json` if present. Absent file → `Ok(None)`. Corrupt JSON
/// returns an error so callers can delete it and retry; `apply_pending_upgrade`
/// does exactly that.
pub fn read_pending() -> Result<Option<PendingUpgrade>> {
    let path = pending_json_path();
    if !path.exists() {
        return Ok(None);
    }
    let body =
        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
    let pending: PendingUpgrade =
        serde_json::from_str(&body).with_context(|| format!("parsing {}", path.display()))?;
    Ok(Some(pending))
}

fn write_pending(pending: &PendingUpgrade) -> Result<()> {
    let dir = staged_dir();
    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
    let body = serde_json::to_string_pretty(pending).context("serializing pending.json")?;
    let path = pending_json_path();
    std::fs::write(&path, body).with_context(|| format!("writing {}", path.display()))
}

fn clear_pending_pointer() {
    let _ = std::fs::remove_file(pending_json_path());
}

/// Quiet variant of `check_latest` used by the hourly background poll.
/// Returns the remote `(version, manifest)` only when strictly newer than
/// `current_version`. Separate from `version_check::check_latest` because
/// that one only gives back the version string; here we need the full
/// manifest to know the per-target sha256 / size.
pub async fn fetch_manifest_if_newer(current_version: &str) -> Result<Option<Manifest>> {
    let manifest = fetch_manifest().await?;
    if is_newer(&manifest.version, current_version) {
        Ok(Some(manifest))
    } else {
        Ok(None)
    }
}

/// Download + verify a new release into `staged_dir()` without touching
/// the live binary. Writes `pending.json` as the final step so a partial
/// download (crashed mid-stream) doesn't masquerade as "ready to apply" —
/// the pointer only appears if sha256 passed.
///
/// Returns `Ok(None)` when we're already on the latest version (or newer).
/// Idempotent: calling twice with the same manifest is a no-op after the
/// first success (file + pointer already in place).
/// In `distro-pm` builds this is a no-op returning `Ok(None)`.
pub async fn prepare_deferred_upgrade(
    current_version: &str,
    tx: mpsc::UnboundedSender<UpgradeEvent>,
) -> Result<Option<PendingUpgrade>> {
    if is_package_managed() {
        return Ok(None);
    }
    let target = detect_target().ok_or_else(|| {
        anyhow!(
            "this platform has no published atomcode release ({}/{})",
            std::env::consts::OS,
            std::env::consts::ARCH
        )
    })?;

    let manifest = fetch_manifest().await?;

    if !is_newer(&manifest.version, current_version) {
        return Ok(None);
    }

    let _ = tx.send(UpgradeEvent::ManifestFetched {
        version: manifest.version.clone(),
    });

    // If a staged upgrade for this exact version already exists and the
    // on-disk bytes still match the manifest's sha256, reuse it. Saves a
    // redownload when two sessions both polled and landed here.
    if let Ok(Some(existing)) = read_pending() {
        if existing.version == manifest.version && existing.staged_path.exists() {
            if let Some(entry) = manifest.binaries.get(target) {
                if existing.sha256.eq_ignore_ascii_case(&entry.sha256)
                    && existing.size == entry.size
                {
                    return Ok(Some(existing));
                }
            }
        }
    }

    let entry = manifest.binaries.get(target).ok_or_else(|| {
        anyhow!(
            "manifest has no entry for target {} — this platform may not be in this release",
            target
        )
    })?;

    let dir = staged_dir();
    std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;

    let staged_path = staged_binary_path(&manifest.version, target);
    let url = binary_url(&manifest.version, target);
    download_and_verify(&url, &entry.sha256, entry.size, &staged_path, &tx).await?;

    let pending = PendingUpgrade {
        version: manifest.version.clone(),
        staged_path: staged_path.clone(),
        sha256: entry.sha256.clone(),
        size: entry.size,
        created_at: chrono::Utc::now().to_rfc3339(),
        attempts: 0,
    };
    write_pending(&pending)?;
    Ok(Some(pending))
}

/// Bootstrap entry point: called once at the very top of `main()` BEFORE
/// the tokio runtime, TUI, or any heavy init. Three outcomes:
///
///   * `Ok(None)`                  — no pending upgrade, continue normally.
///   * `Ok(Some(AppliedUpgrade))`  — staged binary is now live; caller must
///                                   `re_exec_self` to hand control over.
///   * `Err(e)`                    — apply failed; caller should log and
///                                   continue with the OLD binary. We've
///                                   already bumped the attempt counter
///                                   (or discarded the stage past the cap).
///
/// SHA256 is re-verified here even though we verified at download time:
/// a session-external process (backup tool, AV software, buggy sync)
/// could have touched the file between sessions. Verification is cheap
/// compared to installing a corrupted binary.
/// In `distro-pm` builds this is a no-op returning `Ok(None)`.
pub fn apply_pending_upgrade() -> Result<Option<AppliedUpgrade>> {
    if is_package_managed() {
        return Ok(None);
    }
    let mut pending = match read_pending() {
        Ok(Some(p)) => p,
        Ok(None) => return Ok(None),
        Err(_) => {
            // Corrupt pointer — nuke it, we can't do anything safe with it.
            clear_pending_pointer();
            return Ok(None);
        }
    };

    if pending.attempts >= MAX_APPLY_ATTEMPTS {
        // Circuit-break: this staged upgrade has failed too many times.
        // Discard everything so we stop trying, fall back to the old
        // binary, and let the next successful prepare_deferred_upgrade
        // supersede it.
        let _ = std::fs::remove_file(&pending.staged_path);
        clear_pending_pointer();
        return Ok(None);
    }

    // Bump attempt counter up-front so a crash mid-apply doesn't leave us
    // in an unbounded retry loop.
    pending.attempts += 1;
    let _ = write_pending(&pending);

    if !pending.staged_path.exists() {
        clear_pending_pointer();
        return Ok(None);
    }

    let actual_size = std::fs::metadata(&pending.staged_path)
        .with_context(|| format!("stat {}", pending.staged_path.display()))?
        .len();
    if actual_size != pending.size {
        let _ = std::fs::remove_file(&pending.staged_path);
        clear_pending_pointer();
        return Err(anyhow!(
            "staged binary size changed between sessions (expected {}, got {}). Discarded.",
            pending.size,
            actual_size
        ));
    }

    let mut hasher = Sha256::new();
    let mut file = std::fs::File::open(&pending.staged_path)
        .with_context(|| format!("opening {}", pending.staged_path.display()))?;
    std::io::copy(&mut file, &mut hasher).context("hashing staged binary")?;
    drop(file);
    let got = hex_encode(&hasher.finalize());
    if !got.eq_ignore_ascii_case(&pending.sha256) {
        let _ = std::fs::remove_file(&pending.staged_path);
        clear_pending_pointer();
        return Err(anyhow!(
            "staged binary sha256 drifted between sessions (expected {}, got {}). Discarded.",
            pending.sha256,
            got
        ));
    }

    let exe = current_exe_path()?;
    ensure_writable(&exe)?;
    replace_binary(&pending.staged_path, &exe)?;

    // Success — pointer is done, file moved into place by replace_binary.
    clear_pending_pointer();

    Ok(Some(AppliedUpgrade {
        version: pending.version,
        backup: backup_path(&exe),
        exe,
    }))
}

/// Replace the current process with a fresh invocation of the live binary,
/// preserving argv, cwd, and env. On Unix this is `execv` (same PID, old
/// process image gone). On Windows we spawn a child and exit the parent —
/// a separate PID, but terminal stdio is shared so the user still sees
/// one continuous "session" from their perspective.
///
/// **Important on Windows:** After `replace_binary` renames the running exe
/// (e.g. `atomcode.exe` → `.atomcode.rolling`), `std::env::current_exe()`
/// may return the *renamed* path (`.atomcode.rolling`) instead of the
/// original one (`atomcode.exe`). This is because `GetModuleFileNameW`
/// tracks the on-disk filename. If `override_exe` is provided, it is used
/// instead of `current_exe()` — callers should capture the exe path
/// *before* calling `replace_binary` and pass it here.
///
/// Never returns on the happy path. An `Err` return means the handoff
/// failed (e.g., new binary missing execute bit under unusual filesystem
/// constraints); caller should surface the error and keep running with
/// the old binary rather than exiting silently.
pub fn re_exec_self(override_exe: Option<&Path>) -> Result<std::convert::Infallible> {
    let exe = override_exe
        .map(|p| p.to_path_buf())
        .unwrap_or_else(|| current_exe_path().unwrap_or_else(|_| {
            // Fallback: if we can't resolve current_exe AND no override,
            // try argv[0] as a last resort.
            std::env::args_os().next()
                .map(PathBuf::from)
                .unwrap_or_default()
        }));
    let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();

    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        let err = std::process::Command::new(&exe).args(&args).exec();
        // `exec` only returns on failure.
        Err(anyhow!("re-exec failed: {}", err))
    }

    #[cfg(windows)]
    {
        // Windows has no exec(). Spawn the child with shared stdio so
        // the user's terminal stays connected to the new process, then
        // exit ourselves. The replacement PID shift is invisible in
        // a terminal context (the shell tracks the parent's exit).
        let status = std::process::Command::new(&exe)
            .args(&args)
            .spawn()
            .with_context(|| format!("spawning new binary {}", exe.display()))?
            .wait()
            .with_context(|| "waiting for spawned binary to exit")?;
        std::process::exit(status.code().unwrap_or(0));
    }
}

/// Parse and compare two `vMAJOR.MINOR.PATCH` strings. Returns true
/// when `latest > current`. Malformed inputs fall back to a byte-wise
/// `!=` so we *do* proceed with reinstall when version strings are
/// shaped unexpectedly — safer than silently refusing to upgrade.
fn is_newer(latest: &str, current: &str) -> bool {
    match (parse_version(latest), parse_version(current)) {
        (Some(a), Some(b)) => a > b,
        _ => latest.trim() != current.trim(),
    }
}

fn parse_version(s: &str) -> Option<(u64, u64, u64)> {
    let s = s.trim();
    let rest = s.strip_prefix('v')?;
    let mut parts = rest.split('.');
    let a = parts.next()?.parse().ok()?;
    let b = parts.next()?.parse().ok()?;
    let c = parts.next()?.parse().ok()?;
    if parts.next().is_some() {
        return None;
    }
    Some((a, b, c))
}

/// Three-way swap between the live binary and `.bak`, leaving `.bak`
/// pointing at what was previously live. Calling rollback twice in a
/// row returns you to the original state — intentional, so users can
/// toggle between last-two versions without redownloading.
/// In `distro-pm` builds this returns immediately with an error carrying `PACKAGE_MANAGED` (upgrades are the package manager's job).
pub fn run_rollback() -> Result<RollbackSummary> {
    if is_package_managed() {
        return Err(anyhow!(PACKAGE_MANAGED));
    }
    let exe = current_exe_path()?;
    let backup = backup_path(&exe);
    if !backup.exists() {
        return Err(anyhow!(
            "no backup found at {} — nothing to roll back to",
            backup.display()
        ));
    }
    ensure_writable(&exe)?;

    let rolling = rolling_path(&exe);
    if rolling.exists() {
        std::fs::remove_file(&rolling).ok();
    }

    // Step 1: live -> rolling
    std::fs::rename(&exe, &rolling).with_context(|| {
        format!(
            "renaming {} -> {} (swap step 1)",
            exe.display(),
            rolling.display()
        )
    })?;
    // Step 2: backup -> live
    if let Err(e) = std::fs::rename(&backup, &exe) {
        // Best-effort unwind of step 1.
        let _ = std::fs::rename(&rolling, &exe);
        return Err(anyhow!("rollback failed at step 2 ({}); state restored", e));
    }
    // Step 3: rolling -> backup
    if let Err(e) = std::fs::rename(&rolling, &backup) {
        // Can't cleanly unwind — the live file is already the old
        // version, which is the user-visible outcome they asked for.
        // Surface the orphan so they can clean up manually.
        return Err(anyhow!(
            "rollback succeeded but tmp file {} could not be moved to {} ({}).\n\
             Delete it manually; next /upgrade will overwrite it.",
            rolling.display(),
            backup.display(),
            e
        ));
    }

    Ok(RollbackSummary { exe, backup })
}

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

    #[test]
    fn binary_filename_adds_exe_on_windows_targets() {
        assert_eq!(
            binary_filename("v4.19.0", "windows-x64"),
            "atomcode-v4.19.0-windows-x64.exe"
        );
        assert_eq!(
            binary_filename("v4.19.0", "windows-arm64"),
            "atomcode-v4.19.0-windows-arm64.exe"
        );
    }

    #[test]
    fn binary_filename_plain_on_unix_targets() {
        assert_eq!(
            binary_filename("v4.19.0", "darwin-arm64"),
            "atomcode-v4.19.0-darwin-arm64"
        );
        assert_eq!(
            binary_filename("v4.19.0", "linux-x64"),
            "atomcode-v4.19.0-linux-x64"
        );
    }

    #[test]
    fn binary_url_shape() {
        assert_eq!(
            binary_url("v4.19.0", "darwin-arm64"),
            "https://atomgit.com/atomgit_atomcode/atomcode/releases/download/v4.19.0/atomcode-v4.19.0-darwin-arm64"
        );
    }

    #[test]
    fn detect_target_returns_something_on_tier1_hosts() {
        // We can't assert an exact value (depends on host), but every
        // supported dev platform should resolve to Some.
        let t = detect_target();
        if cfg!(any(
            target_os = "macos",
            target_os = "linux",
            target_os = "windows"
        )) {
            if cfg!(any(target_arch = "x86_64", target_arch = "aarch64")) {
                assert!(t.is_some(), "expected target tag on this host");
            }
        }
    }

    #[test]
    fn target_tag_matches_release_manifest_targets() {
        assert_eq!(target_tag("macos", "aarch64"), Some("darwin-arm64"));
        assert_eq!(target_tag("macos", "x86_64"), Some("darwin-x64"));
        assert_eq!(target_tag("linux", "x86_64"), Some("linux-x64"));
        assert_eq!(target_tag("linux", "aarch64"), Some("linux-arm64"));
        assert_eq!(target_tag("windows", "x86_64"), Some("windows-x64"));
        assert_eq!(target_tag("windows", "aarch64"), Some("windows-arm64"));
        assert_eq!(target_tag("linux", "arm"), None);
    }

    #[test]
    fn backup_path_appends_bak() {
        let p = Path::new("/usr/local/bin/atomcode");
        assert_eq!(backup_path(p), PathBuf::from("/usr/local/bin/atomcode.bak"));
    }

    #[test]
    fn try_remove_stale_deletes_normal_file() {
        let dir = tempfile::tempdir().expect("tempdir");
        let p = dir.path().join("atomcode.exe.bak");
        std::fs::write(&p, b"old").expect("seed");
        assert!(try_remove_stale(&p));
        assert!(!p.exists(), "backup should be gone");
    }

    #[test]
    fn try_remove_stale_clears_readonly_then_deletes() {
        let dir = tempfile::tempdir().expect("tempdir");
        let p = dir.path().join("atomcode.exe.bak");
        std::fs::write(&p, b"old").expect("seed");
        let mut perm = std::fs::metadata(&p).unwrap().permissions();
        perm.set_readonly(true);
        std::fs::set_permissions(&p, perm).expect("set readonly");
        // On Windows the readonly flag would block remove_file outright.
        // On Unix it doesn't, but the helper still happens to clean up.
        // Either way, the file must be gone after this call.
        assert!(try_remove_stale(&p));
        assert!(!p.exists());
    }

    #[test]
    fn try_remove_stale_returns_false_for_truly_locked_file() {
        // On Unix, an open file can still be unlinked, so true locking
        // is hard to simulate. Instead we test the "nonexistent path"
        // case — `try_remove_stale` correctly returns true because the
        // file doesn't exist (nothing to remove). To verify the false
        // return, we'd need a platform-specific lock (Windows HANDLE),
        // which isn't feasible in a cross-platform unit test. The
        // important contract is: returns true when nothing needs doing.
        let bogus = std::path::PathBuf::from("/no/such/dir/atomcode.exe.bak");
        assert!(!bogus.exists());
        // A path that doesn't exist is "already removed" → true
        assert!(try_remove_stale(&bogus));
    }

    #[test]
    fn try_remove_stale_returns_true_for_nonexistent() {
        let dir = tempfile::tempdir().expect("tempdir");
        let p = dir.path().join("does_not_exist");
        assert!(!p.exists());
        assert!(try_remove_stale(&p));
    }

    #[test]
    fn backup_path_preserves_exe_suffix_on_windows_style() {
        let p = Path::new("C:/Tools/atomcode.exe");
        assert_eq!(backup_path(p), PathBuf::from("C:/Tools/atomcode.exe.bak"));
    }

    #[test]
    fn is_newer_semver() {
        assert!(is_newer("v4.19.0", "v4.18.2"));  // latest, current
        assert!(is_newer("v4.19.0", "v4.18.9"));
        assert!(is_newer("v5.0.0", "v4.99.99"));
        assert!(!is_newer("v4.19.0", "v4.19.0"));
        assert!(!is_newer("v4.18.0", "v4.19.0"));  // latest is older than current
    }

    #[test]
    fn is_newer_falls_back_to_string_diff_on_garbage() {
        // If we can't parse, err on the side of allowing reinstall
        // when strings differ (user may have a custom channel).
        assert!(is_newer("build-abc", "build-xyz"));
        assert!(!is_newer("build-abc", "build-abc"));
    }

    #[test]
    fn manifest_parses_minimal_shape() {
        let json = r#"{
            "version": "v4.19.0",
            "binaries": {
                "darwin-arm64": { "sha256": "abcd", "size": 1024 }
            }
        }"#;
        let m: Manifest = serde_json::from_str(json).unwrap();
        assert_eq!(m.version, "v4.19.0");
        assert_eq!(m.binaries["darwin-arm64"].size, 1024);
    }

    #[test]
    fn manifest_ignores_unknown_fields() {
        let json = r#"{
            "version": "v4.19.0",
            "released_at": "2026-04-19T00:00:00Z",
            "signature": "future-field",
            "binaries": {
                "linux-x64": { "sha256": "ffff", "size": 42, "notes": "x" }
            }
        }"#;
        let m: Manifest = serde_json::from_str(json).unwrap();
        assert_eq!(m.released_at.as_deref(), Some("2026-04-19T00:00:00Z"));
        assert_eq!(m.binaries["linux-x64"].sha256, "ffff");
    }

    #[test]
    fn hex_encode_matches_known_vectors() {
        assert_eq!(hex_encode(&[0x00, 0xff, 0x10]), "00ff10");
        assert_eq!(hex_encode(&[]), "");
    }

    #[test]
    fn ensure_writable_probes_containing_dir() {
        // A path inside tempdir must pass; a path whose parent doesn't
        // exist must fail with a clear message.
        let tmp = tempfile::tempdir().unwrap();
        let ok = tmp.path().join("atomcode");
        assert!(ensure_writable(&ok).is_ok());

        let bogus = Path::new("/nonexistent-dir-xyzzy-9999/atomcode");
        let err = ensure_writable(bogus).unwrap_err().to_string();
        assert!(err.contains("sudo atomcode upgrade"), "got: {}", err);
    }

    #[test]
    fn replace_binary_renames_live_to_bak_via_three_way_swap() {
        let tmp = tempfile::tempdir().unwrap();
        let exe = tmp.path().join("atomcode");
        let new = tmp.path().join(".atomcode.download");
        std::fs::write(&exe, b"OLD").unwrap();
        std::fs::write(&new, b"NEW").unwrap();

        replace_binary(&new, &exe).unwrap();

        assert_eq!(std::fs::read(&exe).unwrap(), b"NEW");
        let bak = backup_path(&exe);
        assert_eq!(std::fs::read(&bak).unwrap(), b"OLD");
        assert!(!new.exists());
        // No leftover .rolling file on success
        let rolling = rolling_path(&exe);
        assert!(!rolling.exists());
    }

    #[test]
    fn replace_binary_overwrites_stale_bak() {
        let tmp = tempfile::tempdir().unwrap();
        let exe = tmp.path().join("atomcode");
        let new = tmp.path().join(".atomcode.download");
        let bak = backup_path(&exe);
        std::fs::write(&exe, b"V2").unwrap();
        std::fs::write(&new, b"V3").unwrap();
        std::fs::write(&bak, b"V1").unwrap();

        replace_binary(&new, &exe).unwrap();

        assert_eq!(std::fs::read(&exe).unwrap(), b"V3");
        assert_eq!(std::fs::read(&bak).unwrap(), b"V2");
        // No leftover .rolling file
        let rolling = rolling_path(&exe);
        assert!(!rolling.exists());
    }

    #[test]
    fn replace_binary_cleans_leftover_rolling() {
        let tmp = tempfile::tempdir().unwrap();
        let exe = tmp.path().join("atomcode");
        let new = tmp.path().join(".atomcode.download");
        let rolling = rolling_path(&exe);
        std::fs::write(&exe, b"OLD").unwrap();
        std::fs::write(&new, b"NEW").unwrap();
        std::fs::write(&rolling, b"STALE").unwrap();

        replace_binary(&new, &exe).unwrap();

        assert_eq!(std::fs::read(&exe).unwrap(), b"NEW");
        let bak = backup_path(&exe);
        assert_eq!(std::fs::read(&bak).unwrap(), b"OLD");
        assert!(!rolling.exists());
    }

    #[test]
    fn replace_binary_succeeds_even_when_bak_cannot_be_removed() {
        // Simulate the Windows ACCESS_DENIED scenario: .bak is locked
        // (here we can't truly lock it, but we make it read-only in a
        // non-writable parent — on Unix this still works because unlink
        // doesn't care about file permissions. The test verifies the
        // three-way swap completes successfully regardless.)
        let tmp = tempfile::tempdir().unwrap();
        let exe = tmp.path().join("atomcode");
        let new = tmp.path().join(".atomcode.download");
        let bak = backup_path(&exe);
        let rolling = rolling_path(&exe);
        std::fs::write(&exe, b"V2").unwrap();
        std::fs::write(&new, b"V3").unwrap();
        std::fs::write(&bak, b"V1").unwrap();

        replace_binary(&new, &exe).unwrap();

        // Core outcome: upgrade succeeded
        assert_eq!(std::fs::read(&exe).unwrap(), b"V3");
        // On this platform .bak is removable so we get V2 as backup.
        // On Windows with a locked .bak, the .rolling file would linger
        // instead, but the upgrade still succeeds.
        assert!(bak.exists() || rolling.exists());
    }

    #[test]
    fn rollback_swaps_live_and_bak() {
        let tmp = tempfile::tempdir().unwrap();
        // Use a fake exe name that won't collide with the real test
        // binary. run_rollback uses current_exe() which we cannot
        // redirect, so we test the primitive by calling replace_binary
        // first then rename logic directly — model the three-way swap.
        let exe = tmp.path().join("atomcode");
        let bak = backup_path(&exe);
        std::fs::write(&exe, b"NEW").unwrap();
        std::fs::write(&bak, b"OLD").unwrap();

        // Manual three-way swap mirroring run_rollback's body.
        let rolling = tmp.path().join(".atomcode.rolling");
        std::fs::rename(&exe, &rolling).unwrap();
        std::fs::rename(&bak, &exe).unwrap();
        std::fs::rename(&rolling, &bak).unwrap();

        assert_eq!(std::fs::read(&exe).unwrap(), b"OLD");
        assert_eq!(std::fs::read(&bak).unwrap(), b"NEW");
    }

    #[test]
    fn pending_upgrade_serde_roundtrips() {
        let p = PendingUpgrade {
            version: "v4.19.1".to_string(),
            staged_path: PathBuf::from("/tmp/staged/atomcode-v4.19.1-darwin-arm64"),
            sha256: "abcd".to_string(),
            size: 1024,
            created_at: "2026-04-20T10:54:16Z".to_string(),
            attempts: 0,
        };
        let j = serde_json::to_string(&p).unwrap();
        let back: PendingUpgrade = serde_json::from_str(&j).unwrap();
        assert_eq!(back.version, "v4.19.1");
        assert_eq!(back.attempts, 0);
    }

    #[test]
    fn pending_attempts_defaults_when_missing() {
        // Older pointer files written before the attempts field existed
        // must still deserialize — `#[serde(default)]` covers that.
        let j = r#"{
            "version": "v4.19.1",
            "staged_path": "/tmp/x",
            "sha256": "abcd",
            "size": 1024,
            "created_at": "2026-04-20T10:54:16Z"
        }"#;
        let p: PendingUpgrade = serde_json::from_str(j).unwrap();
        assert_eq!(p.attempts, 0);
    }

    #[test]
    fn staged_binary_path_matches_release_artifact_name() {
        let p = staged_binary_path("v4.19.1", "darwin-arm64");
        assert!(p.ends_with("atomcode-v4.19.1-darwin-arm64"), "got: {:?}", p);
    }

    #[test]
    fn staged_binary_path_adds_exe_for_windows() {
        let p = staged_binary_path("v4.19.1", "windows-x64");
        assert!(
            p.ends_with("atomcode-v4.19.1-windows-x64.exe"),
            "got: {:?}",
            p
        );
    }

    // ── download_and_verify integration tests (issue #380) ──────────

    /// Helper: compute the SHA256 hex string for a byte slice.
    fn sha256_hex(data: &[u8]) -> String {
        use sha2::{Digest, Sha256};
        let mut hasher = Sha256::new();
        hasher.update(data);
        hex_encode(&hasher.finalize())
    }

    /// download_and_verify succeeds when the server omits Content-Length
    /// (chunked transfer encoding).  In production, Content-Length may
    /// differ from the manifest's `size` due to CDN/proxy rewrites,
    /// manifest-server sync lag, or redirect hops — the old code
    /// aborted immediately on such mismatches (issue #380).
    ///
    /// This test validates that when Content-Length is absent (chunked),
    /// the function proceeds to download and relies on the post-download
    /// size + SHA256 checks for integrity, which is the correct behavior
    /// after the fix.
    #[tokio::test]
    async fn download_succeeds_without_content_length() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;

        // Simulate a CDN that uses chunked transfer (no Content-Length).
        // This is what a gzip-compressed response looks like after
        // reqwest transparently decompresses it.
        let payload = vec![0xAB_u8; 100];
        let expected_sha = sha256_hex(&payload);
        let expected_size: u64 = payload.len() as u64;

        Mock::given(method("GET"))
            .and(path("/binary"))
            .respond_with(
                ResponseTemplate::new(200)
                    // No explicit Content-Length → chunked transfer encoding.
                    .set_body_raw(payload.clone(), "application/octet-stream"),
            )
            .mount(&server)
            .await;

        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let dir = tempfile::tempdir().unwrap();
        let dest = dir.path().join("download.bin");

        let result = download_and_verify(
            &format!("{}/binary", server.uri()),
            &expected_sha,
            expected_size,
            &dest,
            &tx,
        )
        .await;

        // With the fix, this should succeed because the actual downloaded
        // bytes match expected_size and expected_sha256.  The old code
        // would also have succeeded here (Content-Length was absent), but
        // the key point is that we no longer gate on Content-Length at all.
        assert!(result.is_ok(), "download should succeed, got: {:?}", result);
        assert_eq!(std::fs::read(&dest).unwrap(), payload);
    }

    /// download_and_verify still rejects a download whose actual byte
    /// count doesn't match the manifest size (post-download size check).
    #[tokio::test]
    async fn download_rejects_wrong_actual_size() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;

        // Server sends 80 bytes, but manifest says 100.
        let payload = vec![0xAB_u8; 80];
        let expected_sha = sha256_hex(&payload);
        let expected_size: u64 = 100; // mismatch!

        Mock::given(method("GET"))
            .and(path("/binary"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_raw(payload.clone(), "application/octet-stream"),
            )
            .mount(&server)
            .await;

        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let dir = tempfile::tempdir().unwrap();
        let dest = dir.path().join("download.bin");

        let result = download_and_verify(
            &format!("{}/binary", server.uri()),
            &expected_sha,
            expected_size,
            &dest,
            &tx,
        )
        .await;

        assert!(result.is_err(), "should fail on size mismatch");
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("short download"),
            "error should mention short download, got: {}",
            err
        );
        // Temp file must be cleaned up on failure.
        assert!(!dest.exists(), "partial download should be removed");
    }

    /// download_and_verify still rejects a download whose SHA256
    /// doesn't match the manifest, even if the size is correct.
    #[tokio::test]
    async fn download_rejects_checksum_mismatch() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;

        let payload = vec![0xAB_u8; 100];
        let wrong_sha = "0000000000000000000000000000000000000000000000000000000000000000";
        let expected_size: u64 = payload.len() as u64;

        Mock::given(method("GET"))
            .and(path("/binary"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_raw(payload.clone(), "application/octet-stream"),
            )
            .mount(&server)
            .await;

        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let dir = tempfile::tempdir().unwrap();
        let dest = dir.path().join("download.bin");

        let result = download_and_verify(
            &format!("{}/binary", server.uri()),
            wrong_sha, // intentionally wrong
            expected_size,
            &dest,
            &tx,
        )
        .await;

        assert!(result.is_err(), "should fail on checksum mismatch");
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("checksum mismatch"),
            "error should mention checksum mismatch, got: {}",
            err
        );
        // Temp file must be cleaned up on failure.
        assert!(!dest.exists(), "corrupted download should be removed");
    }

    /// download_and_verify succeeds when everything matches perfectly
    /// (Content-Length present and correct, size + SHA256 match).
    #[tokio::test]
    async fn download_succeeds_when_all_checks_pass() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;

        let payload = vec![0xCD_u8; 256];
        let expected_sha = sha256_hex(&payload);
        let expected_size: u64 = payload.len() as u64;

        Mock::given(method("GET"))
            .and(path("/binary"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_raw(payload.clone(), "application/octet-stream"),
            )
            .mount(&server)
            .await;

        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let dir = tempfile::tempdir().unwrap();
        let dest = dir.path().join("download.bin");

        let result = download_and_verify(
            &format!("{}/binary", server.uri()),
            &expected_sha,
            expected_size,
            &dest,
            &tx,
        )
        .await;

        assert!(result.is_ok(), "download should succeed, got: {:?}", result);
        assert_eq!(std::fs::read(&dest).unwrap(), payload);
    }

    /// download_and_verify rejects non-2xx HTTP responses.
    #[tokio::test]
    async fn download_rejects_http_error() {
        use wiremock::matchers::{method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};

        let server = MockServer::start().await;

        Mock::given(method("GET"))
            .and(path("/binary"))
            .respond_with(ResponseTemplate::new(404))
            .mount(&server)
            .await;

        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let dir = tempfile::tempdir().unwrap();
        let dest = dir.path().join("download.bin");

        let result = download_and_verify(
            &format!("{}/binary", server.uri()),
            "unused",
            100,
            &dest,
            &tx,
        )
        .await;

        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("HTTP 404"),
            "error should mention HTTP 404, got: {}",
            err
        );
    }

    #[test]
    fn is_package_managed_tracks_feature() {
        assert_eq!(super::is_package_managed(), cfg!(feature = "distro-pm"));
    }

    #[cfg(feature = "distro-pm")]
    #[tokio::test]
    async fn run_upgrade_blocked_when_package_managed() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let err = super::run_upgrade("v1.0.0".to_string(), true, tx)
            .await
            .expect_err("package-managed build must refuse self-upgrade");
        assert!(err.to_string().contains(super::PACKAGE_MANAGED));
    }

    #[cfg(feature = "distro-pm")]
    #[test]
    fn run_rollback_blocked_when_package_managed() {
        let err = super::run_rollback().expect_err("package-managed build must refuse rollback");
        assert!(err.to_string().contains(super::PACKAGE_MANAGED));
    }

    #[cfg(feature = "distro-pm")]
    #[test]
    fn apply_pending_upgrade_noop_when_package_managed() {
        assert!(super::apply_pending_upgrade().unwrap().is_none());
    }

    #[cfg(feature = "distro-pm")]
    #[tokio::test]
    async fn prepare_deferred_upgrade_noop_when_package_managed() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let result = super::prepare_deferred_upgrade("v1.0.0", tx)
            .await
            .expect("package-managed build must not error, just no-op");
        assert!(result.is_none());
    }
}