use anyhow::{bail, Context, Result};
#[cfg(unix)]
use std::ffi::CString;
use std::io;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};

use crate::backend;
use crate::release_target::{CanonicalArch, CanonicalOs, ReleaseTarget};

const DEFAULT_RELEASE_REPO: &str = "Mesh-LLM/mesh-llm";
#[cfg(not(windows))]
const INSTALL_SCRIPT_URL: &str =
    "https://raw.githubusercontent.com/Mesh-LLM/mesh-llm/main/install.sh";
const RELEASES_URL: &str = "https://github.com/Mesh-LLM/mesh-llm/releases/latest";
const SELF_UPDATE_ATTEMPTED_ENV: &str = "MESH_LLM_SELF_UPDATE_ATTEMPTED";
const SELF_UPDATE_REPO_ENV: &str = "MESH_LLM_SELF_UPDATE_REPO";

enum InstallOutcome {
    #[cfg_attr(windows, allow(dead_code))]
    RestartNow,
    #[cfg_attr(windows, allow(dead_code))]
    ExitNow,
    #[cfg_attr(not(windows), allow(dead_code))]
    HandoffAndExit,
}

#[derive(Clone, Copy)]
enum PostInstallAction {
    RestartCurrentProcess,
    ExitAfterInstall,
}

struct ReleaseInfo {
    tag: String,
    version: String,
    assets: Vec<String>,
}

struct UpdateTarget {
    exe: PathBuf,
    install_dir: PathBuf,
    release_target: ReleaseTarget,
    bundle_flavor: backend::BinaryFlavor,
}

#[derive(Clone, Copy, Debug)]
pub struct AutoUpdateOptions {
    pub auto_update: bool,
    pub plugin_requested: bool,
    pub command_is_update: bool,
    pub llama_flavor: Option<backend::BinaryFlavor>,
    pub current_version: &'static str,
}

#[derive(Clone, Copy, Debug)]
pub struct UpdateCommandOptions<'a> {
    pub llama_flavor: Option<backend::BinaryFlavor>,
    pub requested_version: Option<&'a str>,
    pub current_version: &'static str,
}

#[derive(Clone, Copy)]
enum ReleaseAssetPreference {
    StableFirst,
    VersionedFirst,
}

pub async fn check_for_update(current_version: &str) {
    if !platform_has_release_assets() {
        return;
    }
    if let Some(release) = latest_release_info().await {
        if !version_newer(&release.version, current_version) {
            return;
        }
        // Determine whether this is a bundle install and, if so, whether the
        // specific installed flavor's asset is present in the new release.
        let bundle_asset = std::env::current_exe().ok().and_then(|exe| {
            let (_, flavor) = bundle_install_dir(&exe, None)?;
            current_release_target(flavor).and_then(|target| {
                resolve_release_asset_name(&release, target, ReleaseAssetPreference::StableFirst)
            })
        });
        match bundle_asset {
            Some(ref asset) if release.assets.iter().any(|a| a == asset) => {
                eprintln!(
                    "✨ New version: v{current_version} -> v{}. Run 'mesh-llm update'.",
                    release.version
                );
            }
            _ => {
                // Either not a bundle install, or the installed flavor's asset
                // is not published in the new release — fall back to generic guidance.
                #[cfg(not(windows))]
                if release_has_any_platform_asset(
                    &release,
                    std::env::consts::OS,
                    std::env::consts::ARCH,
                ) {
                    eprintln!(
                        "✨ New version: v{current_version} -> v{}. Reinstall with: curl -fsSL {INSTALL_SCRIPT_URL} | bash",
                        release.version
                    );
                }
                #[cfg(windows)]
                if release_has_any_platform_asset(
                    &release,
                    std::env::consts::OS,
                    std::env::consts::ARCH,
                ) {
                    eprintln!(
                        "✨ New version: v{current_version} -> v{}. Download from {RELEASES_URL}",
                        release.version
                    );
                }
            }
        }
    }
}

fn platform_has_release_assets() -> bool {
    platform_has_release_assets_for(std::env::consts::OS, std::env::consts::ARCH)
}

fn platform_has_release_assets_for(os: &str, arch: &str) -> bool {
    backend::BinaryFlavor::ALL.into_iter().any(|flavor| {
        ReleaseTarget::from_raw(os, arch, flavor)
            .map(|target| target.support_status().is_supported())
            .unwrap_or(false)
    })
}

pub async fn maybe_auto_update(options: AutoUpdateOptions) -> Result<bool> {
    if !should_attempt_auto_update(options) {
        return Ok(false);
    }
    let Some(target) = discover_update_target(options.llama_flavor) else {
        return Ok(false);
    };
    apply_update_if_available(
        target,
        PostInstallAction::RestartCurrentProcess,
        options.current_version,
    )
    .await
}

pub async fn run_update_command(options: UpdateCommandOptions<'_>) -> Result<()> {
    let target = require_update_target(options.llama_flavor)?;
    let requested_version = options.requested_version;
    let Some(release) = resolve_release_info(requested_version).await? else {
        bail!("Could not check for a release right now. Try again shortly.");
    };
    if requested_version.is_none() && !version_newer(&release.version, options.current_version) {
        eprintln!(
            "mesh-llm is already up to date (v{}).",
            options.current_version
        );
        return Ok(());
    }
    let asset_preference = if requested_version.is_some() {
        ReleaseAssetPreference::VersionedFirst
    } else {
        ReleaseAssetPreference::StableFirst
    };
    let Some(asset_name) =
        resolve_release_asset_name(&release, target.release_target, asset_preference)
    else {
        bail!(
            "Release v{} does not include a bundle for this install (tried: {}).",
            release.version,
            release_asset_candidates(target.release_target, &release.tag, asset_preference)
                .join(", ")
        );
    };
    if !path_is_writable(&target.exe) {
        bail!("{} is not writable.", target.exe.display());
    }

    eprintln!(
        "⬇️ {} mesh-llm v{} -> v{} ({})...",
        describe_requested_update(
            &release.version,
            options.current_version,
            requested_version.is_some()
        ),
        options.current_version,
        release.version,
        target.bundle_flavor.suffix()
    );
    match install_latest_bundle(
        &target.exe,
        &target.install_dir,
        &release,
        &asset_name,
        target.bundle_flavor,
        PostInstallAction::ExitAfterInstall,
    )
    .await
    {
        Ok(InstallOutcome::ExitNow) => {
            eprintln!("✅ Updated to v{}", release.version);
            Ok(())
        }
        Ok(InstallOutcome::HandoffAndExit) => {
            eprintln!(
                "✅ Applying update to v{}; exiting so the installer can finish",
                release.version
            );
            std::process::exit(0);
        }
        Ok(InstallOutcome::RestartNow) => {
            eprintln!("✅ Updated to v{}", release.version);
            Ok(())
        }
        Err(err) => Err(err),
    }
}

pub async fn latest_release_version() -> Option<String> {
    latest_release_info().await.map(|release| release.version)
}

async fn latest_release_info() -> Option<ReleaseInfo> {
    fetch_release_info(&latest_release_api_url()).await
}

async fn release_info_for_tag(tag: &str) -> Option<ReleaseInfo> {
    fetch_release_info(&release_api_url_for_tag(tag)).await
}

async fn fetch_release_info(url: &str) -> Option<ReleaseInfo> {
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(3))
        .build()
        .ok()?;
    let resp = client
        .get(url)
        .header("User-Agent", "mesh-llm")
        .send()
        .await
        .ok()?;
    let body: serde_json::Value = resp.json().await.ok()?;
    release_info_from_json(&body)
}

pub fn version_newer(a: &str, b: &str) -> bool {
    match (semver::Version::parse(a), semver::Version::parse(b)) {
        (Ok(a), Ok(b)) => a > b,
        _ => false,
    }
}

fn should_attempt_auto_update(options: AutoUpdateOptions) -> bool {
    options.auto_update
        && !options.plugin_requested
        && !options.command_is_update
        && std::env::var_os(SELF_UPDATE_ATTEMPTED_ENV).is_none()
}

fn discover_update_target(llama_flavor: Option<backend::BinaryFlavor>) -> Option<UpdateTarget> {
    let exe = std::env::current_exe().ok()?;
    let (install_dir, bundle_flavor) = bundle_install_dir(&exe, llama_flavor)?;
    let release_target = current_release_target(bundle_flavor)?;
    Some(UpdateTarget {
        exe,
        install_dir,
        release_target,
        bundle_flavor,
    })
}

fn require_update_target(llama_flavor: Option<backend::BinaryFlavor>) -> Result<UpdateTarget> {
    if !platform_has_release_assets() {
        bail!(
            "`mesh-llm update` is not supported on this platform. Download the latest release from {RELEASES_URL}."
        );
    }

    let exe = std::env::current_exe().context("Cannot determine mesh-llm executable path")?;
    let Some((install_dir, bundle_flavor)) = bundle_install_dir(&exe, llama_flavor) else {
        bail!(
            "`mesh-llm update` only works for release-bundle installs. Current executable: {}",
            exe.display()
        );
    };
    let Some(release_target) = current_release_target(bundle_flavor) else {
        #[cfg(not(windows))]
        bail!("No published release bundle matches this install. Reinstall with {INSTALL_SCRIPT_URL}.");
        #[cfg(windows)]
        bail!("No published release bundle matches this install. Download the latest release from {RELEASES_URL}.");
    };

    Ok(UpdateTarget {
        exe,
        install_dir,
        release_target,
        bundle_flavor,
    })
}

async fn apply_update_if_available(
    target: UpdateTarget,
    action: PostInstallAction,
    current_version: &str,
) -> Result<bool> {
    let Some(release) = latest_release_info().await else {
        return Ok(true);
    };
    if !version_newer(&release.version, current_version) {
        return Ok(true);
    }
    let Some(asset_name) = resolve_release_asset_name(
        &release,
        target.release_target,
        ReleaseAssetPreference::StableFirst,
    ) else {
        return Ok(false);
    };
    if !path_is_writable(&target.exe) {
        eprintln!(
            "⚠️  Auto-update skipped: {} is not writable",
            target.exe.display()
        );
        return Ok(true);
    }

    eprintln!(
        "⬇️ Updating mesh-llm v{current_version} -> v{} ({})...",
        release.version,
        target.bundle_flavor.suffix()
    );
    match install_latest_bundle(
        &target.exe,
        &target.install_dir,
        &release,
        &asset_name,
        target.bundle_flavor,
        action,
    )
    .await
    {
        Ok(InstallOutcome::RestartNow) => {
            eprintln!("✅ Updated to v{}; restarting", release.version);
            std::env::set_var(SELF_UPDATE_ATTEMPTED_ENV, "1");
            exec_current_binary(&target.exe)?;
        }
        Ok(InstallOutcome::ExitNow) => {
            eprintln!("✅ Updated to v{}", release.version);
        }
        Ok(InstallOutcome::HandoffAndExit) => {
            eprintln!("✅ Updated to v{}; restarting", release.version);
            std::process::exit(0);
        }
        Err(err) => {
            eprintln!("⚠️  Auto-update failed: {err}");
        }
    }

    Ok(true)
}

fn current_release_target(flavor: backend::BinaryFlavor) -> Option<ReleaseTarget> {
    ReleaseTarget::from_raw(std::env::consts::OS, std::env::consts::ARCH, flavor).ok()
}

#[cfg(test)]
fn stable_release_asset_name_for(
    os: &str,
    arch: &str,
    flavor: backend::BinaryFlavor,
) -> Option<String> {
    ReleaseTarget::from_raw(os, arch, flavor)
        .ok()
        .and_then(ReleaseTarget::stable_asset_name)
}

fn legacy_release_asset_name(target: ReleaseTarget) -> Option<String> {
    (target
        == ReleaseTarget::new(
            CanonicalOs::Macos,
            CanonicalArch::Aarch64,
            backend::BinaryFlavor::Metal,
        ))
    .then_some("mesh-bundle.tar.gz".to_string())
}

fn push_release_asset_candidate(candidates: &mut Vec<String>, asset_name: Option<String>) {
    let Some(asset_name) = asset_name else {
        return;
    };
    if !candidates.iter().any(|candidate| candidate == &asset_name) {
        candidates.push(asset_name);
    }
}

fn release_asset_candidates(
    target: ReleaseTarget,
    release_tag: &str,
    preference: ReleaseAssetPreference,
) -> Vec<String> {
    let mut candidates = Vec::new();
    match preference {
        ReleaseAssetPreference::StableFirst => {
            push_release_asset_candidate(&mut candidates, target.stable_asset_name());
            push_release_asset_candidate(&mut candidates, target.versioned_asset_name(release_tag));
        }
        ReleaseAssetPreference::VersionedFirst => {
            push_release_asset_candidate(&mut candidates, target.versioned_asset_name(release_tag));
            push_release_asset_candidate(&mut candidates, target.stable_asset_name());
        }
    }
    push_release_asset_candidate(&mut candidates, legacy_release_asset_name(target));
    candidates
}

fn resolve_release_asset_name(
    release: &ReleaseInfo,
    target: ReleaseTarget,
    preference: ReleaseAssetPreference,
) -> Option<String> {
    release_asset_candidates(target, &release.tag, preference)
        .into_iter()
        .find(|asset_name| {
            release
                .assets
                .iter()
                .any(|candidate| candidate == asset_name)
        })
}

fn release_has_any_platform_asset(release: &ReleaseInfo, os: &str, arch: &str) -> bool {
    backend::BinaryFlavor::ALL.into_iter().any(|flavor| {
        ReleaseTarget::from_raw(os, arch, flavor)
            .ok()
            .and_then(|target| {
                resolve_release_asset_name(release, target, ReleaseAssetPreference::StableFirst)
            })
            .is_some()
    })
}

fn mesh_binary_name() -> String {
    backend::platform_bin_name("mesh-llm")
}

fn installed_bundle_flavor(
    _dir: &Path,
    requested: Option<backend::BinaryFlavor>,
) -> Option<backend::BinaryFlavor> {
    if let Some(flavor) = requested {
        return Some(flavor);
    }

    if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
        Some(backend::BinaryFlavor::Metal)
    } else {
        Some(backend::BinaryFlavor::Cpu)
    }
}

fn release_repo() -> String {
    match std::env::var(SELF_UPDATE_REPO_ENV) {
        Ok(repo) if repo.contains('/') && !repo.trim().is_empty() => repo,
        _ => DEFAULT_RELEASE_REPO.to_string(),
    }
}

fn latest_release_api_url() -> String {
    format!(
        "https://api.github.com/repos/{}/releases/latest",
        release_repo()
    )
}

fn release_api_url_for_tag(tag: &str) -> String {
    format!(
        "https://api.github.com/repos/{}/releases/tags/{tag}",
        release_repo()
    )
}

fn release_asset_url(tag: &str, asset_name: &str) -> String {
    format!(
        "https://github.com/{}/releases/download/{tag}/{asset_name}",
        release_repo()
    )
}

fn release_info_from_json(body: &serde_json::Value) -> Option<ReleaseInfo> {
    let tag = body["tag_name"].as_str()?.trim();
    let version = tag.trim_start_matches('v').trim();
    if tag.is_empty() || version.is_empty() {
        return None;
    }

    let assets = body["assets"]
        .as_array()
        .map(|items| {
            items
                .iter()
                .filter_map(|item| item["name"].as_str().map(str::to_string))
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();

    Some(ReleaseInfo {
        tag: tag.to_string(),
        version: version.to_string(),
        assets,
    })
}

async fn resolve_release_info(requested_version: Option<&str>) -> Result<Option<ReleaseInfo>> {
    let Some(requested_version) = requested_version else {
        return Ok(latest_release_info().await);
    };
    let tag = normalize_release_tag(requested_version)?;
    Ok(release_info_for_tag(&tag).await)
}

fn normalize_release_tag(raw: &str) -> Result<String> {
    let trimmed = raw.trim();
    anyhow::ensure!(!trimmed.is_empty(), "release version must not be empty");
    let version = trimmed.trim_start_matches('v');
    semver::Version::parse(version).with_context(|| format!("Invalid release version: {raw}"))?;
    Ok(format!("v{version}"))
}

fn describe_requested_update(
    target_version: &str,
    current_version: &str,
    exact: bool,
) -> &'static str {
    if !exact {
        return "Updating";
    }

    match (
        semver::Version::parse(target_version),
        semver::Version::parse(current_version),
    ) {
        (Ok(target), Ok(current)) if target < current => "Downgrading",
        (Ok(target), Ok(current)) if target == current => "Reinstalling",
        _ => "Installing",
    }
}

fn path_is_writable(path: &Path) -> bool {
    #[cfg(unix)]
    {
        let Ok(c_path) = CString::new(path.as_os_str().as_bytes()) else {
            return false;
        };
        unsafe { libc::access(c_path.as_ptr(), libc::W_OK) == 0 }
    }

    #[cfg(not(unix))]
    {
        std::fs::metadata(path)
            .map(|meta| !meta.permissions().readonly())
            .unwrap_or(false)
    }
}

fn bundle_install_dir(
    exe: &Path,
    requested_flavor: Option<backend::BinaryFlavor>,
) -> Option<(PathBuf, backend::BinaryFlavor)> {
    let dir = exe.parent()?;
    let file_name = exe.file_name()?.to_str()?;
    #[cfg(windows)]
    {
        if !file_name.eq_ignore_ascii_case(&mesh_binary_name()) {
            return None;
        }
    }
    #[cfg(not(windows))]
    {
        if file_name != mesh_binary_name() {
            return None;
        }
    }
    let flavor = installed_bundle_flavor(dir, requested_flavor)?;
    Some((dir.to_path_buf(), flavor))
}

async fn install_latest_bundle(
    exe: &Path,
    install_dir: &Path,
    release: &ReleaseInfo,
    asset_name: &str,
    expected_flavor: backend::BinaryFlavor,
    action: PostInstallAction,
) -> Result<InstallOutcome> {
    let unique = format!(
        "{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs()
    );
    let workspace = install_dir.join(format!(".mesh-llm-update-{unique}"));
    let extracted = workspace.join("bundle");
    let archive = workspace.join(asset_name);
    let backup = workspace.join("backup");

    std::fs::create_dir_all(&extracted)
        .with_context(|| format!("Failed to create update workspace {}", workspace.display()))?;

    let result = async {
        download_url(&release_asset_url(&release.tag, asset_name), &archive).await?;
        extract_bundle_archive(&archive, &extracted)?;
        let staged_files = collect_bundle_files(&extracted, expected_flavor)?;
        finish_bundle_install(
            exe,
            install_dir,
            &workspace,
            &extracted,
            &backup,
            &staged_files,
            action,
        )?;
        Ok::<InstallOutcome, anyhow::Error>(install_outcome(action))
    }
    .await;

    if !matches!(result, Ok(InstallOutcome::HandoffAndExit)) {
        let _ = std::fs::remove_dir_all(&workspace);
    }
    result
}

async fn download_url(url: &str, path: &Path) -> Result<()> {
    let response = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(300))
        .build()
        .context("Build release download HTTP client")?
        .get(url)
        .header("User-Agent", "mesh-llm")
        .send()
        .await
        .with_context(|| format!("Download release asset {url}"))?
        .error_for_status()
        .with_context(|| format!("Release asset request failed for {url}"))?;
    let bytes = response
        .bytes()
        .await
        .with_context(|| format!("Read release asset body from {url}"))?;
    std::fs::write(path, &bytes)
        .with_context(|| format!("Write release asset {}", path.display()))?;
    Ok(())
}

fn extract_bundle_archive(archive: &Path, extracted: &Path) -> Result<()> {
    match archive
        .extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.to_ascii_lowercase())
        .as_deref()
    {
        Some("zip") => extract_zip_archive(archive, extracted),
        _ => extract_tar_archive(archive, extracted),
    }
}

fn extract_tar_archive(archive: &Path, extracted: &Path) -> Result<()> {
    let status = std::process::Command::new("tar")
        .arg("-xzf")
        .arg(archive)
        .arg("-C")
        .arg(extracted)
        .arg("--strip-components=1")
        .status()
        .with_context(|| format!("Failed to extract {}", archive.display()))?;
    anyhow::ensure!(status.success(), "tar extraction failed");
    Ok(())
}

fn extract_zip_archive(archive: &Path, extracted: &Path) -> Result<()> {
    let file = std::fs::File::open(archive)
        .with_context(|| format!("Failed to open {}", archive.display()))?;
    let mut zip = zip::ZipArchive::new(file)
        .with_context(|| format!("Failed to read ZIP archive {}", archive.display()))?;

    for index in 0..zip.len() {
        let mut entry = zip.by_index(index)?;
        let enclosed = entry
            .enclosed_name()
            .context("ZIP archive contained an invalid path")?;
        let mut components = enclosed.components();
        let _top_level = components.next();
        let relative: PathBuf = components.collect();
        if relative.as_os_str().is_empty() {
            continue;
        }

        let output = extracted.join(&relative);
        if entry.is_dir() {
            std::fs::create_dir_all(&output)
                .with_context(|| format!("Failed to create {}", output.display()))?;
            continue;
        }

        if let Some(parent) = output.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create {}", parent.display()))?;
        }
        let mut out = std::fs::File::create(&output)
            .with_context(|| format!("Failed to create {}", output.display()))?;
        io::copy(&mut entry, &mut out)
            .with_context(|| format!("Failed to extract {}", output.display()))?;
    }

    Ok(())
}

#[cfg(test)]
mod zip_tests {
    use super::*;
    use std::fs;
    use std::io::Write;
    use std::time::{SystemTime, UNIX_EPOCH};

    use zip::write::SimpleFileOptions;
    use zip::CompressionMethod;

    fn unique_temp_dir(prefix: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("{}_{}", prefix, nanos));
        // Best-effort cleanup in case something is left behind from a previous run.
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).expect("failed to create temporary directory");
        dir
    }

    #[test]
    fn extract_zip_archive_strips_top_level_directory() -> Result<()> {
        let base_dir = unique_temp_dir("mesh_llm_extract_zip_test");
        let archive_path = base_dir.join("bundle.zip");
        let extracted_dir = base_dir.join("extracted");
        fs::create_dir_all(&extracted_dir)?;

        // Create a ZIP with a single top-level directory, similar to the release packager.
        let file = std::fs::File::create(&archive_path)
            .with_context(|| format!("Failed to create test archive {}", archive_path.display()))?;
        let mut writer = zip::ZipWriter::new(file);
        let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);

        // Top-level directory.
        writer.add_directory("bundle-1.0.0/", options)?;
        // Nested directory and file under the top-level directory.
        writer.add_directory("bundle-1.0.0/bin/", options)?;
        writer.start_file("bundle-1.0.0/bin/server", options)?;
        writer.write_all(b"dummy-server")?;

        writer
            .finish()
            .with_context(|| "Failed to finalize test ZIP archive")?;

        // Now extract and verify that the top-level directory is stripped.
        extract_zip_archive(&archive_path, &extracted_dir)?;

        let server_path = extracted_dir.join("bin").join("server");
        anyhow::ensure!(
            server_path.is_file(),
            "Expected extracted server file at {}",
            server_path.display()
        );

        let top_level = extracted_dir.join("bundle-1.0.0");
        anyhow::ensure!(
            !top_level.exists(),
            "Top-level directory should have been stripped, but {} exists",
            top_level.display()
        );

        Ok(())
    }
}
fn collect_bundle_files(
    extracted: &Path,
    expected_flavor: backend::BinaryFlavor,
) -> Result<Vec<String>> {
    let _ = expected_flavor;

    let mut files = Vec::new();
    for entry in std::fs::read_dir(extracted)
        .with_context(|| format!("Failed to read {}", extracted.display()))?
    {
        let entry = entry?;
        let file_type = entry.file_type()?;
        let name = entry.file_name();
        let name = name.to_string_lossy().to_string();
        if file_type.is_dir() {
            anyhow::bail!("Unexpected directory in bundle: {name}");
        }
        if file_type.is_file() {
            files.push(name);
        }
    }

    anyhow::ensure!(!files.is_empty(), "Downloaded bundle was empty");
    anyhow::ensure!(
        files.iter().any(|name| name == &mesh_binary_name()),
        "Downloaded bundle missing {}",
        mesh_binary_name()
    );
    files.sort_by_key(|name| (name == &mesh_binary_name(), name.clone()));
    Ok(files)
}

#[cfg(not(windows))]
fn backup_existing_file(
    install_dir: &Path,
    backup: &Path,
    name: &str,
    backed_up: &mut Vec<String>,
) -> Result<()> {
    if backed_up.iter().any(|existing| existing == name) {
        return Ok(());
    }

    let dest = install_dir.join(name);
    if !dest.exists() {
        return Ok(());
    }

    let backup_path = backup.join(name);
    std::fs::rename(&dest, &backup_path).with_context(|| {
        format!(
            "Failed to move {} to {}",
            dest.display(),
            backup_path.display()
        )
    })?;
    backed_up.push(name.to_string());
    Ok(())
}

#[cfg(not(windows))]
fn replace_bundle_files(
    install_dir: &Path,
    extracted: &Path,
    backup: &Path,
    staged_files: &[String],
) -> Result<()> {
    use std::collections::BTreeSet;

    std::fs::create_dir_all(backup)
        .with_context(|| format!("Failed to create backup dir {}", backup.display()))?;

    let managed_names: BTreeSet<String> = BTreeSet::from([mesh_binary_name()]);

    let mut backed_up = Vec::new();
    for name in managed_names {
        backup_existing_file(install_dir, backup, &name, &mut backed_up)?;
    }
    for name in staged_files {
        backup_existing_file(install_dir, backup, name, &mut backed_up)?;
    }

    let mut installed = Vec::new();
    for name in staged_files {
        let source = extracted.join(name);
        let dest = install_dir.join(name);
        if let Err(err) = std::fs::rename(&source, &dest) {
            rollback_bundle_replace(install_dir, backup, &installed, &backed_up);
            return Err(err).with_context(|| {
                format!(
                    "Failed to install {} into {}",
                    source.display(),
                    dest.display()
                )
            });
        }
        installed.push(name.clone());
    }

    Ok(())
}

#[cfg(not(windows))]
fn install_outcome(action: PostInstallAction) -> InstallOutcome {
    match action {
        PostInstallAction::RestartCurrentProcess => InstallOutcome::RestartNow,
        PostInstallAction::ExitAfterInstall => InstallOutcome::ExitNow,
    }
}

#[cfg(windows)]
fn install_outcome(_action: PostInstallAction) -> InstallOutcome {
    InstallOutcome::HandoffAndExit
}

#[cfg(not(windows))]
fn finish_bundle_install(
    _exe: &Path,
    install_dir: &Path,
    _workspace: &Path,
    extracted: &Path,
    backup: &Path,
    staged_files: &[String],
    _action: PostInstallAction,
) -> Result<()> {
    replace_bundle_files(install_dir, extracted, backup, staged_files)
}

#[cfg(windows)]
fn finish_bundle_install(
    exe: &Path,
    install_dir: &Path,
    workspace: &Path,
    extracted: &Path,
    backup: &Path,
    staged_files: &[String],
    action: PostInstallAction,
) -> Result<()> {
    use std::process::Command;

    let script = workspace.join("apply-update.ps1");
    let script_body = windows_update_script(
        exe,
        install_dir,
        workspace,
        extracted,
        backup,
        staged_files,
        action,
    )?;
    std::fs::write(&script, script_body)
        .with_context(|| format!("Failed to write {}", script.display()))?;

    Command::new("powershell")
        .arg("-NoProfile")
        .arg("-ExecutionPolicy")
        .arg("Bypass")
        .arg("-File")
        .arg(&script)
        .spawn()
        .with_context(|| format!("Failed to launch Windows updater {}", script.display()))?;

    Ok(())
}

#[cfg(windows)]
fn windows_update_script(
    exe: &Path,
    install_dir: &Path,
    workspace: &Path,
    extracted: &Path,
    backup: &Path,
    staged_files: &[String],
    action: PostInstallAction,
) -> Result<String> {
    use std::collections::BTreeSet;

    let staged_json = serde_json::to_string(staged_files)?;
    let restart_after_update = matches!(action, PostInstallAction::RestartCurrentProcess);
    let args: Vec<String> = std::env::args_os()
        .skip(1)
        .map(|arg| arg.to_string_lossy().to_string())
        .collect();
    let args_json = serde_json::to_string(&args)?;

    let managed_names: BTreeSet<String> = BTreeSet::from([mesh_binary_name()]);
    let managed_json = serde_json::to_string(&managed_names.into_iter().collect::<Vec<_>>())?;

    let quote = |path: &Path| path.to_string_lossy().replace('\'', "''");

    let args_json_ps = args_json.replace('\'', "''");
    let managed_json_ps = managed_json.replace('\'', "''");
    let staged_json_ps = staged_json.replace('\'', "''");

    Ok(format!(
        r#"$ErrorActionPreference = 'Stop'
$installDir = '{install_dir}'
$workspace = '{workspace}'
$stagingDir = '{staging_dir}'
$backupDir = '{backup_dir}'
$exePath = '{exe_path}'
$waitPid = {wait_pid}
$restartAfterUpdate = {restart_after_update}
$managedNames = @((ConvertFrom-Json '{managed_json_ps}'))
$stagedNames = @((ConvertFrom-Json '{staged_json_ps}'))
$args = @((ConvertFrom-Json '{args_json_ps}'))

function Restore-Backups([string[]]$BackedUpNames, [string[]]$InstalledNames) {{
    foreach ($name in $InstalledNames) {{
        $dest = Join-Path $installDir $name
        Remove-Item $dest -Force -ErrorAction SilentlyContinue
    }}
    foreach ($name in $BackedUpNames) {{
        $backupPath = Join-Path $backupDir $name
        $dest = Join-Path $installDir $name
        if (Test-Path $backupPath) {{
            Move-Item -Force $backupPath $dest
        }}
    }}
}}

while (Get-Process -Id $waitPid -ErrorAction SilentlyContinue) {{
    Start-Sleep -Milliseconds 200
}}

$backedUp = New-Object System.Collections.Generic.List[string]
$installed = New-Object System.Collections.Generic.List[string]

try {{
    New-Item -ItemType Directory -Path $backupDir -Force | Out-Null

    foreach ($name in $managedNames) {{
        if ($stagedNames -contains $name) {{
            continue
        }}
        $dest = Join-Path $installDir $name
        if (-not (Test-Path $dest)) {{
            continue
        }}
        $backupPath = Join-Path $backupDir $name
        Move-Item -Force $dest $backupPath
        $backedUp.Add($name) | Out-Null
    }}

    foreach ($name in $stagedNames) {{
        $dest = Join-Path $installDir $name
        if (-not (Test-Path $dest)) {{
            continue
        }}
        if ($backedUp.Contains($name)) {{
            continue
        }}
        $backupPath = Join-Path $backupDir $name
        Move-Item -Force $dest $backupPath
        $backedUp.Add($name) | Out-Null
    }}

    foreach ($name in $stagedNames) {{
        $source = Join-Path $stagingDir $name
        $dest = Join-Path $installDir $name
        Move-Item -Force $source $dest
        $installed.Add($name) | Out-Null
    }}

    if ($restartAfterUpdate) {{
        $env:MESH_LLM_SELF_UPDATE_ATTEMPTED = '1'
        & $exePath @args
        exit $LASTEXITCODE
    }}
    exit 0
}} catch {{
    Restore-Backups $backedUp.ToArray() $installed.ToArray()
    throw
}} finally {{
    Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue
}}
"#,
        install_dir = quote(install_dir),
        workspace = quote(workspace),
        staging_dir = quote(extracted),
        backup_dir = quote(backup),
        exe_path = quote(exe),
        wait_pid = std::process::id(),
        restart_after_update = if restart_after_update {
            "$true"
        } else {
            "$false"
        },
        managed_json_ps = managed_json_ps,
        staged_json_ps = staged_json_ps,
        args_json_ps = args_json_ps
    ))
}

#[cfg(not(windows))]
fn rollback_bundle_replace(
    install_dir: &Path,
    backup: &Path,
    installed: &[String],
    backed_up: &[String],
) {
    for name in installed.iter().rev() {
        let dest = install_dir.join(name);
        let _ = std::fs::remove_file(&dest);
    }
    for name in backed_up.iter().rev() {
        let backup_path = backup.join(name);
        let dest = install_dir.join(name);
        let _ = std::fs::rename(&backup_path, &dest);
    }
}

#[cfg(unix)]
fn exec_current_binary(exe: &Path) -> Result<()> {
    let exe_c = CString::new(exe.as_os_str().as_bytes())
        .context("Executable path contains an unexpected NUL byte")?;
    let args: Vec<CString> = std::env::args_os()
        .map(|arg| {
            CString::new(arg.as_os_str().as_bytes())
                .context("Argument contains an unexpected NUL byte")
        })
        .collect::<Result<_>>()?;
    let mut argv: Vec<*const libc::c_char> = args.iter().map(|arg| arg.as_ptr()).collect();
    argv.push(std::ptr::null());
    let rc = unsafe { libc::execv(exe_c.as_ptr(), argv.as_ptr()) };
    let errno = std::io::Error::last_os_error();
    anyhow::ensure!(rc != 0, "execv unexpectedly returned success");
    Err(errno).context("Failed to restart updated mesh-llm")
}

#[cfg(not(unix))]
fn exec_current_binary(_exe: &Path) -> Result<()> {
    anyhow::bail!("Self-update restart is only supported on Unix")
}

#[cfg(test)]
mod tests {
    use super::*;
    use serial_test::serial;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_dir(name: &str) -> PathBuf {
        let unique = format!(
            "mesh-llm-{name}-{}-{}",
            std::process::id(),
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_nanos()
        );
        let path = std::env::temp_dir().join(unique);
        std::fs::create_dir_all(&path).unwrap();
        path
    }

    #[test]
    fn test_version_newer() {
        assert!(version_newer("0.33.1", "0.33.0"));
        assert!(!version_newer("0.33.0", "0.33.0"));
        assert!(!version_newer("0.32.0", "0.33.0"));
        assert!(version_newer("0.33.0", "0.33.0-rc.1"));
        assert!(!version_newer("0.33.0-rc.1", "0.33.0"));
        assert!(version_newer("0.33.0-rc.2", "0.33.0-rc.1"));
    }

    #[test]
    #[serial]
    fn test_release_asset_url() {
        std::env::remove_var(SELF_UPDATE_REPO_ENV);
        assert_eq!(
            release_asset_url("v0.60.0", "mesh-llm-aarch64-apple-darwin.tar.gz"),
            "https://github.com/Mesh-LLM/mesh-llm/releases/download/v0.60.0/mesh-llm-aarch64-apple-darwin.tar.gz"
        );
    }

    #[test]
    #[serial]
    fn test_release_repo_defaults_to_main_repo() {
        std::env::remove_var(SELF_UPDATE_REPO_ENV);
        assert_eq!(release_repo(), "Mesh-LLM/mesh-llm");
        assert_eq!(
            latest_release_api_url(),
            "https://api.github.com/repos/Mesh-LLM/mesh-llm/releases/latest"
        );
    }

    #[test]
    #[serial]
    fn test_release_repo_can_be_overridden_for_testing() {
        std::env::set_var(SELF_UPDATE_REPO_ENV, "jdumay/mesh-llm");
        assert_eq!(release_repo(), "jdumay/mesh-llm");
        assert_eq!(
            latest_release_api_url(),
            "https://api.github.com/repos/jdumay/mesh-llm/releases/latest"
        );
        assert_eq!(
            release_api_url_for_tag("v0.60.0"),
            "https://api.github.com/repos/jdumay/mesh-llm/releases/tags/v0.60.0"
        );
        assert_eq!(
            release_asset_url("v0.60.0", "mesh-llm-x86_64-unknown-linux-gnu.tar.gz"),
            "https://github.com/jdumay/mesh-llm/releases/download/v0.60.0/mesh-llm-x86_64-unknown-linux-gnu.tar.gz"
        );
        std::env::remove_var(SELF_UPDATE_REPO_ENV);
    }

    #[test]
    fn test_normalize_release_tag() {
        assert_eq!(normalize_release_tag("v0.60.0").unwrap(), "v0.60.0");
        assert_eq!(
            normalize_release_tag("0.60.0-rc.1").unwrap(),
            "v0.60.0-rc.1"
        );
        assert!(normalize_release_tag("latest").is_err());
    }

    #[test]
    fn test_describe_requested_update() {
        assert_eq!(
            describe_requested_update("0.60.0", "0.66.0", false),
            "Updating"
        );
        assert_eq!(
            describe_requested_update("0.66.0", "0.66.0", true),
            "Reinstalling"
        );
        assert_eq!(
            describe_requested_update("0.0.1", "0.66.0", true),
            "Downgrading"
        );
        assert_eq!(
            describe_requested_update("999.0.0", "0.66.0", true),
            "Installing"
        );
    }

    #[test]
    fn test_stable_release_asset_name_matches_platform() {
        let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
            ("macos", "aarch64") => Some((
                backend::BinaryFlavor::Metal,
                "mesh-llm-aarch64-apple-darwin.tar.gz",
            )),
            ("linux", "x86_64") => Some((
                backend::BinaryFlavor::Cpu,
                "mesh-llm-x86_64-unknown-linux-gnu.tar.gz",
            )),
            _ => None,
        };

        let Some((flavor, asset)) = expected else {
            return;
        };
        assert_eq!(
            stable_release_asset_name_for(std::env::consts::OS, std::env::consts::ARCH, flavor),
            Some(asset.to_string())
        );
    }

    #[test]
    fn test_windows_release_asset_names() {
        assert!(platform_has_release_assets_for("windows", "x86_64"));
        assert_eq!(
            stable_release_asset_name_for("windows", "x86_64", backend::BinaryFlavor::Cpu),
            Some("mesh-llm-x86_64-pc-windows-msvc.zip".to_string())
        );
        assert_eq!(
            stable_release_asset_name_for("windows", "x86_64", backend::BinaryFlavor::Cuda),
            Some("mesh-llm-x86_64-pc-windows-msvc-cuda.zip".to_string())
        );
        assert_eq!(
            stable_release_asset_name_for("windows", "x86_64", backend::BinaryFlavor::Rocm),
            Some("mesh-llm-x86_64-pc-windows-msvc-rocm.zip".to_string())
        );
        assert_eq!(
            stable_release_asset_name_for("windows", "x86_64", backend::BinaryFlavor::Vulkan),
            Some("mesh-llm-x86_64-pc-windows-msvc-vulkan.zip".to_string())
        );
        let release = ReleaseInfo {
            tag: "v0.60.0".to_string(),
            version: "0.60.0".to_string(),
            assets: vec!["mesh-llm-v0.60.0-x86_64-pc-windows-msvc.zip".to_string()],
        };
        assert!(release_has_any_platform_asset(
            &release, "windows", "x86_64"
        ));
        let empty_release = ReleaseInfo {
            tag: "v0.60.0".to_string(),
            version: "0.60.0".to_string(),
            assets: Vec::new(),
        };
        assert!(!release_has_any_platform_asset(
            &empty_release,
            "windows",
            "x86_64"
        ));
    }

    #[test]
    fn test_linux_arm64_release_asset_names() {
        let stable_asset = "mesh-llm-aarch64-unknown-linux-gnu.tar.gz".to_string();
        assert!(platform_has_release_assets_for("linux", "aarch64"));
        assert_eq!(
            stable_release_asset_name_for("linux", "aarch64", backend::BinaryFlavor::Cpu),
            Some(stable_asset.clone())
        );

        let published_release = ReleaseInfo {
            tag: "v0.60.0".to_string(),
            version: "0.60.0".to_string(),
            assets: vec![stable_asset],
        };
        assert!(release_has_any_platform_asset(
            &published_release,
            "linux",
            "aarch64"
        ));

        let missing_release = ReleaseInfo {
            tag: "v0.60.0".to_string(),
            version: "0.60.0".to_string(),
            assets: Vec::new(),
        };
        assert!(!release_has_any_platform_asset(
            &missing_release,
            "linux",
            "aarch64"
        ));
    }

    #[test]
    fn test_linux_arm64_aliases_resolve_identical_release_assets() {
        let arm64_asset =
            stable_release_asset_name_for("linux", "arm64", backend::BinaryFlavor::Cpu);
        let aarch64_asset =
            stable_release_asset_name_for("linux", "aarch64", backend::BinaryFlavor::Cpu);
        assert_eq!(arm64_asset, aarch64_asset);
        assert_eq!(
            arm64_asset,
            Some("mesh-llm-aarch64-unknown-linux-gnu.tar.gz".to_string())
        );
    }

    #[test]
    fn test_resolve_release_asset_name_prefers_stable_linux_arm64_asset() {
        let release = ReleaseInfo {
            tag: "v0.60.0".to_string(),
            version: "0.60.0".to_string(),
            assets: vec![
                "mesh-llm-aarch64-unknown-linux-gnu.tar.gz".to_string(),
                "mesh-llm-v0.60.0-aarch64-unknown-linux-gnu.tar.gz".to_string(),
            ],
        };

        assert_eq!(
            resolve_release_asset_name(
                &release,
                ReleaseTarget::from_raw("linux", "arm64", backend::BinaryFlavor::Cpu).unwrap(),
                ReleaseAssetPreference::StableFirst,
            ),
            Some("mesh-llm-aarch64-unknown-linux-gnu.tar.gz".to_string())
        );
    }

    #[test]
    fn test_resolve_release_asset_name_falls_back_to_versioned_linux_arm64_asset() {
        let release = ReleaseInfo {
            tag: "v0.60.0".to_string(),
            version: "0.60.0".to_string(),
            assets: vec!["mesh-llm-v0.60.0-aarch64-unknown-linux-gnu.tar.gz".to_string()],
        };

        assert_eq!(
            resolve_release_asset_name(
                &release,
                ReleaseTarget::from_raw("linux", "aarch64", backend::BinaryFlavor::Cpu).unwrap(),
                ReleaseAssetPreference::StableFirst,
            ),
            Some("mesh-llm-v0.60.0-aarch64-unknown-linux-gnu.tar.gz".to_string())
        );
    }

    #[test]
    fn test_resolve_release_asset_name_prefers_versioned_for_explicit_install() {
        let release = ReleaseInfo {
            tag: "v0.60.0".to_string(),
            version: "0.60.0".to_string(),
            assets: vec![
                "mesh-llm-aarch64-unknown-linux-gnu.tar.gz".to_string(),
                "mesh-llm-v0.60.0-aarch64-unknown-linux-gnu.tar.gz".to_string(),
            ],
        };

        assert_eq!(
            resolve_release_asset_name(
                &release,
                ReleaseTarget::from_raw("linux", "aarch64", backend::BinaryFlavor::Cpu).unwrap(),
                ReleaseAssetPreference::VersionedFirst,
            ),
            Some("mesh-llm-v0.60.0-aarch64-unknown-linux-gnu.tar.gz".to_string())
        );
    }

    #[test]
    fn test_resolve_release_asset_name_versioned_first_falls_back_to_stable() {
        let release = ReleaseInfo {
            tag: "v0.60.0".to_string(),
            version: "0.60.0".to_string(),
            assets: vec!["mesh-llm-aarch64-unknown-linux-gnu.tar.gz".to_string()],
        };

        assert_eq!(
            resolve_release_asset_name(
                &release,
                ReleaseTarget::from_raw("linux", "arm64", backend::BinaryFlavor::Cpu).unwrap(),
                ReleaseAssetPreference::VersionedFirst,
            ),
            Some("mesh-llm-aarch64-unknown-linux-gnu.tar.gz".to_string())
        );
    }

    #[test]
    fn test_macos_legacy_bundle_asset_remains_compatible() {
        let release = ReleaseInfo {
            tag: "v0.60.0".to_string(),
            version: "0.60.0".to_string(),
            assets: vec!["mesh-bundle.tar.gz".to_string()],
        };

        assert_eq!(
            resolve_release_asset_name(
                &release,
                ReleaseTarget::new(
                    CanonicalOs::Macos,
                    CanonicalArch::Aarch64,
                    backend::BinaryFlavor::Metal,
                ),
                ReleaseAssetPreference::StableFirst,
            ),
            Some("mesh-bundle.tar.gz".to_string())
        );
    }

    #[test]
    fn test_path_is_writable_for_temp_file() {
        let dir = temp_dir("self-update-writable");
        let path = dir.join("mesh-llm");
        std::fs::write(&path, b"binary").unwrap();
        assert!(path_is_writable(&path));
        let _ = std::fs::remove_dir_all(dir);
    }

    #[test]
    #[serial]
    fn test_should_attempt_auto_update_only_when_flag_is_set() {
        std::env::remove_var(SELF_UPDATE_ATTEMPTED_ENV);
        assert!(should_attempt_auto_update(AutoUpdateOptions {
            auto_update: true,
            plugin_requested: false,
            command_is_update: false,
            llama_flavor: None,
            current_version: "0.66.0",
        }));

        assert!(!should_attempt_auto_update(AutoUpdateOptions {
            auto_update: false,
            plugin_requested: false,
            command_is_update: false,
            llama_flavor: None,
            current_version: "0.66.0",
        }));

        assert!(!should_attempt_auto_update(AutoUpdateOptions {
            auto_update: true,
            plugin_requested: false,
            command_is_update: true,
            llama_flavor: None,
            current_version: "0.66.0",
        }));
    }

    #[test]
    #[serial]
    fn test_should_attempt_auto_update_respects_restart_guard() {
        std::env::set_var(SELF_UPDATE_ATTEMPTED_ENV, "1");
        assert!(!should_attempt_auto_update(AutoUpdateOptions {
            auto_update: true,
            plugin_requested: false,
            command_is_update: false,
            llama_flavor: None,
            current_version: "0.66.0",
        }));
        std::env::remove_var(SELF_UPDATE_ATTEMPTED_ENV);
    }

    #[test]
    fn test_bundle_install_dir_uses_requested_or_platform_default_flavor() {
        let dir = temp_dir("bundle-install");
        let exe = dir.join(mesh_binary_name());
        std::fs::write(&exe, b"binary").unwrap();
        let default_flavor = if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
            backend::BinaryFlavor::Metal
        } else {
            backend::BinaryFlavor::Cpu
        };

        assert_eq!(
            bundle_install_dir(&exe, None),
            Some((dir.clone(), default_flavor))
        );
        assert_eq!(
            bundle_install_dir(&exe, Some(backend::BinaryFlavor::Vulkan)),
            Some((dir.clone(), backend::BinaryFlavor::Vulkan))
        );

        let _ = std::fs::remove_dir_all(dir);
    }
}
