use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;

use anyhow::{Context, Result, anyhow};
use hashtree_blossom::{BlossomClient, BlossomStore};
use hashtree_core::{HashTree, HashTreeConfig};
use hashtree_resolver::nostr::{NostrResolverConfig, NostrRootResolver};
use hashtree_updater::{
    DownloadOptions, HashtreeUpdater, UpdateAsset, UpdateCheckOptions, UpdateManifest, UpdateRef,
    UpdateTarget,
};
use serde::{Deserialize, Serialize};

use super::{PRODUCT_VERSION, UpdateArgs, UpdateSource};

const GITHUB_LATEST_RELEASE_URL: &str =
    "https://api.github.com/repos/mmalmi/nostr-vpn/releases/latest";
const HTREE_MANIFEST_URL: &str = "https://upload.iris.to/npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm/releases%2Fnostr-vpn/latest/release.json";
const HTREE_UPDATE_REF: &str = "htree://npub1xdhnr9mrv47kkrn95k6cwecearydeh8e895990n3acntwvmgk2dsdeeycm/releases%2Fnostr-vpn/latest";
const UPDATE_CONNECT_TIMEOUT_SECS: &str = "4";
const UPDATE_MANIFEST_TIMEOUT_SECS: &str = "8";
const UPDATE_DOWNLOAD_TIMEOUT_SECS: &str = "180";
const UPDATE_USER_AGENT: &str = "nvpn-updater";
const DEFAULT_UPDATE_RELAYS: &[&str] = &[
    "wss://temp.iris.to",
    "wss://relay.damus.io",
    "wss://relay.snort.social",
    "wss://relay.primal.net",
    "wss://upload.iris.to/nostr",
];
const DEFAULT_BLOSSOM_READ_SERVERS: &[&str] = &[
    "https://cdn.iris.to",
    "https://hashtree.iris.to",
    "https://upload.iris.to",
    "https://blossom.primal.net",
];
const SECURE_SOURCE_NAME: &str = "hashtree-nostr-blossom";
const LEGACY_HTREE_SOURCE_NAME: &str = "legacy-htree-url";
const GITHUB_SOURCE_NAME: &str = "github";

#[derive(Debug, Deserialize)]
struct ReleaseManifest {
    #[serde(alias = "tag_name")]
    tag: String,
    assets: Vec<ReleaseAsset>,
}

#[derive(Clone, Debug, Deserialize)]
struct ReleaseAsset {
    name: String,
    #[serde(alias = "browser_download_url")]
    path: String,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UpdateMode {
    Cli,
    App,
}

impl UpdateMode {
    fn from_args(args: &UpdateArgs) -> Self {
        if args.app { Self::App } else { Self::Cli }
    }

    fn noun(self) -> &'static str {
        match self {
            Self::Cli => "nvpn CLI",
            Self::App => "Nostr VPN app",
        }
    }
}

#[derive(Debug, Serialize)]
struct UpdateJson<'a> {
    available: bool,
    current_version: &'a str,
    latest_version: String,
    tag: String,
    asset: String,
    source: &'a str,
    verified: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    path: Option<String>,
}

struct LegacySelection {
    manifest: ReleaseManifest,
    asset: ReleaseAsset,
    asset_url: String,
    source_name: &'static str,
    update_available: bool,
}

pub(crate) async fn run_update(args: UpdateArgs) -> Result<()> {
    if should_use_secure_hashtree(args.source) {
        return run_secure_update(args).await;
    }

    run_legacy_update(args)
}

fn should_use_secure_hashtree(source: UpdateSource) -> bool {
    std::env::var("NVPN_UPDATE_MANIFEST_URL")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .is_none()
        && !matches!(source, UpdateSource::Github)
}

fn run_legacy_update(args: UpdateArgs) -> Result<()> {
    let mode = UpdateMode::from_args(&args);
    let selection = legacy_selection(args.source, mode)?;

    if args.check {
        print_update_check(
            mode,
            selection.update_available,
            &selection.manifest.tag,
            &selection.asset.name,
            selection.source_name,
            false,
            Some(&selection.asset_url),
            args.json,
            None,
        )?;
        return Ok(());
    }

    if !selection.update_available && !args.force {
        print_up_to_date(
            mode,
            &selection.manifest.tag,
            selection.source_name,
            false,
            args.json,
        )?;
        return Ok(());
    }

    let temp_dir = create_temp_dir("nvpn-update")?;
    let archive_path = selected_download_path(
        args.download_dir.as_deref(),
        &selection.asset.name,
        &temp_dir,
    )?;
    download_asset(&selection.asset_url, &archive_path)?;

    if args.download_only || mode == UpdateMode::App {
        print_downloaded(
            DownloadedUpdate {
                available: selection.update_available,
                tag: &selection.manifest.tag,
                asset: &selection.asset.name,
                source: selection.source_name,
                verified: false,
                url: Some(&selection.asset_url),
                path: &archive_path,
            },
            args.json,
        )?;
        return Ok(());
    }

    install_cli_archive(&archive_path, &temp_dir, args.path.as_deref())?;
    let _ = fs::remove_dir_all(&temp_dir);

    println!(
        "updated nvpn at {} from {PRODUCT_VERSION} to {}",
        args.path
            .as_deref()
            .map_or_else(current_exe_display, |path| path.display().to_string()),
        selection.manifest.tag
    );
    Ok(())
}

fn legacy_selection(source: UpdateSource, mode: UpdateMode) -> Result<LegacySelection> {
    let (manifest_url, manifest) = fetch_first_manifest(source)?;
    let newer = version_is_newer(&manifest.tag, PRODUCT_VERSION);
    let asset = preferred_asset(&manifest, mode).ok_or_else(|| {
        anyhow!(
            "release {} has no {} asset for {}",
            manifest.tag,
            mode.noun(),
            current_target()
        )
    })?;
    let asset_url = manifest_asset_url(&manifest_url, &asset.path);
    let source_name = if manifest_url.contains("api.github.com") {
        GITHUB_SOURCE_NAME
    } else {
        LEGACY_HTREE_SOURCE_NAME
    };

    Ok(LegacySelection {
        manifest,
        asset,
        asset_url,
        source_name,
        update_available: newer,
    })
}

async fn run_secure_update(args: UpdateArgs) -> Result<()> {
    let mode = UpdateMode::from_args(&args);
    let reference = secure_update_ref()?;
    let relays = update_relays();
    let blossom_servers = blossom_read_servers();
    let resolver = NostrRootResolver::new(NostrResolverConfig {
        relays,
        resolve_timeout: Duration::from_secs(
            UPDATE_MANIFEST_TIMEOUT_SECS.parse::<u64>().unwrap_or(8),
        ),
        secret_key: None,
    })
    .await
    .context("failed to connect to Nostr release relays")?;
    let blossom = BlossomClient::new_empty(nostr::Keys::generate())
        .with_read_servers(blossom_servers)
        .with_timeout(Duration::from_secs(
            UPDATE_DOWNLOAD_TIMEOUT_SECS.parse::<u64>().unwrap_or(180),
        ));
    let store = Arc::new(BlossomStore::new(blossom));
    let tree = HashTree::new(HashTreeConfig::new(store).public());
    let updater = HashtreeUpdater::new(resolver, tree);
    let mut check = updater
        .check(UpdateCheckOptions {
            reference,
            current_version: PRODUCT_VERSION.to_string(),
            target: UpdateTarget::new(current_target()),
            ..UpdateCheckOptions::default()
        })
        .await
        .context("failed to resolve signed hashtree release")?;
    let asset = preferred_secure_asset(&check.manifest, mode).ok_or_else(|| {
        anyhow!(
            "release {} has no {} asset for {}",
            check.manifest.effective_version(),
            mode.noun(),
            current_target()
        )
    })?;
    check.asset = Some(asset.clone());
    let tag = display_manifest_tag(&check.manifest);
    let available = check.update_available;

    if args.check {
        print_update_check(
            mode,
            available,
            &tag,
            &asset.name,
            SECURE_SOURCE_NAME,
            true,
            None,
            args.json,
            None,
        )?;
        return Ok(());
    }

    if !available && !args.force {
        print_up_to_date(mode, &tag, SECURE_SOURCE_NAME, true, args.json)?;
        return Ok(());
    }

    let temp_dir = create_temp_dir("nvpn-update")?;
    let destination = selected_download_path(args.download_dir.as_deref(), &asset.name, &temp_dir)?;
    let downloaded = updater
        .download(
            &check,
            DownloadOptions {
                max_size: None,
                ..DownloadOptions::default()
            },
            None,
        )
        .await
        .with_context(|| format!("failed to download verified hashtree asset {}", asset.name))?;
    write_downloaded_asset(&destination, &downloaded.bytes)?;

    if args.download_only || mode == UpdateMode::App {
        print_downloaded(
            DownloadedUpdate {
                available,
                tag: &tag,
                asset: &asset.name,
                source: SECURE_SOURCE_NAME,
                verified: true,
                url: None,
                path: &destination,
            },
            args.json,
        )?;
        return Ok(());
    }

    install_cli_archive(&destination, &temp_dir, args.path.as_deref())?;
    let _ = fs::remove_dir_all(&temp_dir);

    println!(
        "updated nvpn at {} from {PRODUCT_VERSION} to {tag}",
        args.path
            .as_deref()
            .map_or_else(current_exe_display, |path| path.display().to_string())
    );
    Ok(())
}

fn fetch_first_manifest(source: UpdateSource) -> Result<(String, ReleaseManifest)> {
    let mut last_error = None;
    for url in manifest_urls(source) {
        match fetch_manifest(&url) {
            Ok(manifest) => return Ok((url, manifest)),
            Err(error) => last_error = Some(error),
        }
    }
    Err(last_error.unwrap_or_else(|| anyhow!("no update manifest URL configured")))
}

fn secure_update_ref() -> Result<UpdateRef> {
    let raw = std::env::var("NVPN_UPDATE_HTREE_REF")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| HTREE_UPDATE_REF.to_string());
    UpdateRef::parse(&raw).with_context(|| format!("invalid update hashtree ref: {raw}"))
}

fn update_relays() -> Vec<String> {
    split_env_csv("NVPN_UPDATE_RELAYS").unwrap_or_else(|| {
        DEFAULT_UPDATE_RELAYS
            .iter()
            .map(|value| (*value).to_string())
            .collect()
    })
}

fn blossom_read_servers() -> Vec<String> {
    split_env_csv("NVPN_UPDATE_BLOSSOM_SERVERS").unwrap_or_else(|| {
        DEFAULT_BLOSSOM_READ_SERVERS
            .iter()
            .map(|value| (*value).to_string())
            .collect()
    })
}

fn split_env_csv(name: &str) -> Option<Vec<String>> {
    let values = std::env::var(name)
        .ok()?
        .split(',')
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .collect::<Vec<_>>();
    (!values.is_empty()).then_some(values)
}

fn manifest_urls(source: UpdateSource) -> Vec<String> {
    manifest_urls_for(
        source,
        std::env::var("NVPN_UPDATE_MANIFEST_URL")
            .ok()
            .filter(|value| !value.trim().is_empty()),
    )
}

fn manifest_urls_for(source: UpdateSource, override_url: Option<String>) -> Vec<String> {
    if let Some(override_url) = override_url.filter(|value| !value.trim().is_empty()) {
        return vec![override_url];
    }

    match source {
        UpdateSource::Auto => vec![
            HTREE_MANIFEST_URL.to_string(),
            GITHUB_LATEST_RELEASE_URL.to_string(),
        ],
        UpdateSource::Github => vec![GITHUB_LATEST_RELEASE_URL.to_string()],
        UpdateSource::Hashtree => vec![HTREE_MANIFEST_URL.to_string()],
    }
}

fn fetch_manifest(url: &str) -> Result<ReleaseManifest> {
    let mut command = Command::new("curl");
    command.args([
        "-fsSL",
        "--connect-timeout",
        UPDATE_CONNECT_TIMEOUT_SECS,
        "--max-time",
        UPDATE_MANIFEST_TIMEOUT_SECS,
    ]);
    if url.contains("api.github.com") {
        command
            .arg("-H")
            .arg("Accept: application/vnd.github+json")
            .arg("-H")
            .arg(format!("User-Agent: {UPDATE_USER_AGENT}"));
    }
    let output = command
        .arg(url)
        .output()
        .with_context(|| format!("failed to run curl for {url}"))?;
    if !output.status.success() {
        return Err(anyhow!("{}", command_error("update check failed", &output)));
    }
    serde_json::from_slice(&output.stdout).context("failed to parse release manifest")
}

fn preferred_cli_asset(manifest: &ReleaseManifest) -> Option<ReleaseAsset> {
    let target = current_target();
    let archive_ext = if cfg!(target_os = "windows") {
        ".zip"
    } else {
        ".tar.gz"
    };
    let exact = format!("nvpn-{}-{target}{archive_ext}", manifest.tag);
    let unversioned = format!("nvpn-{target}{archive_ext}");

    manifest
        .assets
        .iter()
        .find(|asset| asset.name == exact)
        .or_else(|| {
            manifest
                .assets
                .iter()
                .find(|asset| asset.name == unversioned)
        })
        .or_else(|| {
            manifest.assets.iter().find(|asset| {
                asset.name.starts_with("nvpn-")
                    && asset.name.contains(target)
                    && asset.name.ends_with(archive_ext)
            })
        })
        .cloned()
}

fn preferred_asset(manifest: &ReleaseManifest, mode: UpdateMode) -> Option<ReleaseAsset> {
    match mode {
        UpdateMode::Cli => preferred_cli_asset(manifest),
        UpdateMode::App => preferred_legacy_app_asset(manifest),
    }
}

fn preferred_legacy_app_asset(manifest: &ReleaseManifest) -> Option<ReleaseAsset> {
    manifest
        .assets
        .iter()
        .find(|asset| app_asset_name_matches_current_target(&asset.name))
        .cloned()
}

fn preferred_secure_asset(manifest: &UpdateManifest, mode: UpdateMode) -> Option<UpdateAsset> {
    match mode {
        UpdateMode::Cli => preferred_secure_cli_asset(manifest),
        UpdateMode::App => preferred_secure_app_asset(manifest),
    }
}

fn preferred_secure_cli_asset(manifest: &UpdateManifest) -> Option<UpdateAsset> {
    let tag = display_manifest_tag(manifest);
    let target = current_target();
    let archive_ext = if cfg!(target_os = "windows") {
        ".zip"
    } else {
        ".tar.gz"
    };
    let exact = format!("nvpn-{tag}-{target}{archive_ext}");
    let unversioned = format!("nvpn-{target}{archive_ext}");

    manifest
        .assets
        .iter()
        .find(|asset| asset.name == exact)
        .or_else(|| {
            manifest
                .assets
                .iter()
                .find(|asset| asset.name == unversioned)
        })
        .or_else(|| {
            manifest.assets.iter().find(|asset| {
                asset.name.starts_with("nvpn-")
                    && asset.name.contains(target)
                    && asset.name.ends_with(archive_ext)
            })
        })
        .cloned()
}

fn preferred_secure_app_asset(manifest: &UpdateManifest) -> Option<UpdateAsset> {
    manifest
        .assets
        .iter()
        .find(|asset| app_asset_name_matches_current_target(&asset.name))
        .cloned()
}

fn app_asset_name_matches_current_target(name: &str) -> bool {
    let lower = name.to_ascii_lowercase();
    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
    {
        lower.ends_with("-macos-arm64.app.tar.gz")
            || lower.ends_with("-macos-arm64.dmg")
            || lower.ends_with("-macos-arm64.zip")
    }
    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
    {
        lower.ends_with("-linux-x64.deb") || lower.ends_with("-linux-x64.appimage")
    }
    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
    {
        lower.ends_with("-linux-arm64.deb") || lower.ends_with("-linux-arm64.appimage")
    }
    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
    {
        lower.ends_with("-windows-x64-setup.exe")
    }
    #[cfg(not(any(
        all(target_os = "macos", target_arch = "aarch64"),
        all(target_os = "linux", target_arch = "x86_64"),
        all(target_os = "linux", target_arch = "aarch64"),
        all(target_os = "windows", target_arch = "x86_64"),
    )))]
    {
        let _ = lower;
        false
    }
}

fn display_manifest_tag(manifest: &UpdateManifest) -> String {
    manifest
        .tag
        .clone()
        .filter(|tag| !tag.trim().is_empty())
        .unwrap_or_else(|| format!("v{}", manifest.effective_version()))
}

fn current_target() -> &'static str {
    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
    {
        "aarch64-apple-darwin"
    }
    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
    {
        "x86_64-unknown-linux-musl"
    }
    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
    {
        "aarch64-unknown-linux-musl"
    }
    #[cfg(all(target_os = "linux", target_arch = "arm"))]
    {
        "arm-unknown-linux-musleabihf"
    }
    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
    {
        "x86_64-pc-windows-msvc"
    }
    #[cfg(not(any(
        all(target_os = "macos", target_arch = "aarch64"),
        all(target_os = "linux", target_arch = "x86_64"),
        all(target_os = "linux", target_arch = "aarch64"),
        all(target_os = "linux", target_arch = "arm"),
        all(target_os = "windows", target_arch = "x86_64"),
    )))]
    {
        "unsupported"
    }
}

fn manifest_asset_url(manifest_url: &str, path: &str) -> String {
    if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
        return path.to_string();
    }
    let base = manifest_url
        .rsplit_once('/')
        .map(|(base, _)| base)
        .unwrap_or(manifest_url);
    format!("{}/{}", base, path.trim_start_matches('/'))
}

fn download_asset(url: &str, destination: &Path) -> Result<()> {
    if let Some(parent) = destination.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    let output = Command::new("curl")
        .arg("-fL")
        .arg("--connect-timeout")
        .arg(UPDATE_CONNECT_TIMEOUT_SECS)
        .arg("--max-time")
        .arg(UPDATE_DOWNLOAD_TIMEOUT_SECS)
        .arg("-o")
        .arg(destination)
        .arg(url)
        .output()
        .with_context(|| format!("failed to run curl for {url}"))?;
    if !output.status.success() {
        return Err(anyhow!(
            "{}",
            command_error("update download failed", &output)
        ));
    }
    Ok(())
}

fn selected_download_path(
    download_dir: Option<&Path>,
    asset_name: &str,
    temp_dir: &Path,
) -> Result<PathBuf> {
    let file_name = safe_file_name(asset_name);
    let parent = download_dir.unwrap_or(temp_dir);
    fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
    Ok(parent.join(file_name))
}

fn write_downloaded_asset(destination: &Path, bytes: &[u8]) -> Result<()> {
    if let Some(parent) = destination.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    fs::write(destination, bytes).with_context(|| {
        format!(
            "failed to write verified update to {}",
            destination.display()
        )
    })
}

fn install_cli_archive(
    archive_path: &Path,
    temp_dir: &Path,
    destination: Option<&Path>,
) -> Result<()> {
    extract_archive(archive_path, temp_dir)?;
    let binary = find_nvpn_binary(temp_dir)?;
    let destination = destination
        .map(Path::to_path_buf)
        .map(Ok)
        .unwrap_or_else(|| {
            std::env::current_exe().context("failed to resolve current executable")
        })?;
    install_parent(&destination)?;
    install_bundled_helpers(&binary, &destination)?;
    install_binary(&binary, &destination)
}

fn current_exe_display() -> String {
    std::env::current_exe().map_or_else(
        |_| "<current executable>".to_string(),
        |path| path.display().to_string(),
    )
}

#[allow(clippy::too_many_arguments)]
fn print_update_check(
    mode: UpdateMode,
    available: bool,
    tag: &str,
    asset: &str,
    source: &'static str,
    verified: bool,
    url: Option<&str>,
    json: bool,
    path: Option<&Path>,
) -> Result<()> {
    if json {
        print_update_json(UpdateJson {
            available,
            current_version: PRODUCT_VERSION,
            latest_version: tag.trim_start_matches('v').to_string(),
            tag: tag.to_string(),
            asset: asset.to_string(),
            source,
            verified,
            url: url.map(ToOwned::to_owned),
            path: path.map(|value| value.display().to_string()),
        })?;
        return Ok(());
    }

    if available {
        println!("update available: {PRODUCT_VERSION} -> {tag}");
    } else {
        println!("{} {PRODUCT_VERSION} is up to date", mode.noun());
    }
    println!("asset={asset}");
    println!("source={source}");
    println!("verified={verified}");
    if let Some(url) = url {
        println!("url={url}");
    }
    if let Some(path) = path {
        println!("path={}", path.display());
    }
    Ok(())
}

struct DownloadedUpdate<'a> {
    available: bool,
    tag: &'a str,
    asset: &'a str,
    source: &'static str,
    verified: bool,
    url: Option<&'a str>,
    path: &'a Path,
}

fn print_downloaded(download: DownloadedUpdate<'_>, json: bool) -> Result<()> {
    if json {
        print_update_json(UpdateJson {
            available: download.available,
            current_version: PRODUCT_VERSION,
            latest_version: download.tag.trim_start_matches('v').to_string(),
            tag: download.tag.to_string(),
            asset: download.asset.to_string(),
            source: download.source,
            verified: download.verified,
            url: download.url.map(ToOwned::to_owned),
            path: Some(download.path.display().to_string()),
        })?;
        return Ok(());
    }
    println!("downloaded {}", download.asset);
    println!("path={}", download.path.display());
    println!("source={}", download.source);
    println!("verified={}", download.verified);
    if let Some(url) = download.url {
        println!("url={url}");
    }
    Ok(())
}

fn print_up_to_date(
    mode: UpdateMode,
    tag: &str,
    source: &'static str,
    verified: bool,
    json: bool,
) -> Result<()> {
    if json {
        print_update_json(UpdateJson {
            available: false,
            current_version: PRODUCT_VERSION,
            latest_version: tag.trim_start_matches('v').to_string(),
            tag: tag.to_string(),
            asset: String::new(),
            source,
            verified,
            url: None,
            path: None,
        })?;
        return Ok(());
    }
    println!("{} {PRODUCT_VERSION} is up to date", mode.noun());
    Ok(())
}

fn print_update_json(output: UpdateJson<'_>) -> Result<()> {
    println!("{}", serde_json::to_string(&output)?);
    Ok(())
}

fn extract_archive(archive_path: &Path, destination: &Path) -> Result<()> {
    let mut command = Command::new("tar");
    if archive_path
        .file_name()
        .and_then(|value| value.to_str())
        .is_some_and(|name| name.ends_with(".tar.gz") || name.ends_with(".tgz"))
    {
        command.arg("-xzf");
    } else {
        command.arg("-xf");
    }
    let output = command
        .arg(archive_path)
        .arg("-C")
        .arg(destination)
        .output()
        .with_context(|| format!("failed to extract {}", archive_path.display()))?;
    if !output.status.success() {
        return Err(anyhow!(
            "{}",
            command_error("archive extraction failed", &output)
        ));
    }
    Ok(())
}

fn find_nvpn_binary(root: &Path) -> Result<PathBuf> {
    let binary_name = if cfg!(target_os = "windows") {
        "nvpn.exe"
    } else {
        "nvpn"
    };
    let mut stack = vec![root.to_path_buf()];
    while let Some(path) = stack.pop() {
        for entry in
            fs::read_dir(&path).with_context(|| format!("failed to read {}", path.display()))?
        {
            let entry = entry?;
            let path = entry.path();
            let metadata = entry.metadata()?;
            if metadata.is_dir() {
                stack.push(path);
            } else if metadata.is_file()
                && path.file_name().and_then(|value| value.to_str()) == Some(binary_name)
            {
                return Ok(path);
            }
        }
    }
    Err(anyhow!("downloaded archive did not contain {binary_name}"))
}

fn install_binary(source: &Path, destination: &Path) -> Result<()> {
    let parent = install_parent(destination)?;
    fs::create_dir_all(parent)
        .with_context(|| format!("failed to create directory {}", parent.display()))?;

    let temp_path = parent.join(format!(
        ".nvpn-update-{}-{}{}",
        std::process::id(),
        unix_timestamp(),
        std::env::consts::EXE_SUFFIX
    ));
    if temp_path.exists() {
        let _ = fs::remove_file(&temp_path);
    }
    fs::copy(source, &temp_path).with_context(|| {
        format!(
            "failed to copy {} to {}",
            source.display(),
            temp_path.display()
        )
    })?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o755)).with_context(|| {
            format!(
                "failed to set executable permissions on {}",
                temp_path.display()
            )
        })?;
    }
    #[cfg(target_os = "windows")]
    if destination.exists() {
        fs::remove_file(destination)
            .with_context(|| format!("failed to replace {}", destination.display()))?;
    }
    fs::rename(&temp_path, destination).with_context(|| {
        format!(
            "failed to move {} into {}",
            temp_path.display(),
            destination.display()
        )
    })?;
    Ok(())
}

fn install_parent(destination: &Path) -> Result<&Path> {
    if destination.as_os_str().is_empty() {
        return Err(anyhow!("install path must not be empty"));
    }
    if destination.is_dir() {
        return Err(anyhow!(
            "install path points to a directory: {}",
            destination.display()
        ));
    }
    destination.parent().ok_or_else(|| {
        anyhow!(
            "install path must include parent directory: {}",
            destination.display()
        )
    })
}

#[cfg(target_os = "windows")]
fn install_bundled_helpers(source_binary: &Path, destination_binary: &Path) -> Result<()> {
    let Some(source_dir) = bundled_helper_source_dir(source_binary) else {
        return Ok(());
    };
    let destination_dir = install_parent(destination_binary)?.join("binaries");
    install_bundled_helper_dir(&source_dir, &destination_dir)?;
    Ok(())
}

#[cfg(not(target_os = "windows"))]
fn install_bundled_helpers(_source_binary: &Path, _destination_binary: &Path) -> Result<()> {
    Ok(())
}

#[cfg(any(target_os = "windows", test))]
fn bundled_helper_source_dir(source_binary: &Path) -> Option<PathBuf> {
    source_binary
        .parent()
        .map(|parent| parent.join("binaries"))
        .filter(|path| path.is_dir())
}

#[cfg(any(target_os = "windows", test))]
fn install_bundled_helper_dir(source_dir: &Path, destination_dir: &Path) -> Result<Vec<PathBuf>> {
    let mut installed = Vec::new();
    let mut stack = vec![source_dir.to_path_buf()];
    while let Some(dir) = stack.pop() {
        for entry in
            fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))?
        {
            let entry = entry?;
            let path = entry.path();
            let file_type = entry.file_type()?;
            if file_type.is_dir() {
                stack.push(path);
                continue;
            }
            if !file_type.is_file() {
                continue;
            }

            let relative = path
                .strip_prefix(source_dir)
                .with_context(|| format!("failed to relativize {}", path.display()))?;
            let destination = destination_dir.join(relative);
            install_helper_file(&path, &destination)?;
            installed.push(destination);
        }
    }
    Ok(installed)
}

#[cfg(any(target_os = "windows", test))]
fn install_helper_file(source: &Path, destination: &Path) -> Result<()> {
    let parent = destination.parent().ok_or_else(|| {
        anyhow!(
            "helper install path must include parent directory: {}",
            destination.display()
        )
    })?;
    fs::create_dir_all(parent)
        .with_context(|| format!("failed to create directory {}", parent.display()))?;

    let file_name = destination
        .file_name()
        .and_then(|value| value.to_str())
        .unwrap_or("helper");
    let temp_path = destination.with_file_name(format!(
        ".nvpn-update-{}-{}-{file_name}",
        std::process::id(),
        unix_timestamp(),
    ));
    if temp_path.exists() {
        let _ = fs::remove_file(&temp_path);
    }
    fs::copy(source, &temp_path).with_context(|| {
        format!(
            "failed to copy helper {} to {}",
            source.display(),
            temp_path.display()
        )
    })?;
    if destination.exists() {
        fs::remove_file(destination)
            .with_context(|| format!("failed to replace {}", destination.display()))?;
    }
    fs::rename(&temp_path, destination).with_context(|| {
        format!(
            "failed to move helper {} into {}",
            temp_path.display(),
            destination.display()
        )
    })?;
    Ok(())
}

fn create_temp_dir(prefix: &str) -> Result<PathBuf> {
    let base = std::env::temp_dir();
    for attempt in 0..128u32 {
        let path = base.join(format!(
            "{prefix}-{}-{}-{attempt}",
            std::process::id(),
            unix_timestamp()
        ));
        match fs::create_dir(&path) {
            Ok(()) => return Ok(path),
            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
            Err(error) => {
                return Err(error).with_context(|| format!("failed to create {}", path.display()));
            }
        }
    }
    Err(anyhow!("failed to allocate temporary update directory"))
}

fn safe_file_name(name: &str) -> String {
    let value = name
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>();
    if value.is_empty() {
        "nvpn-update-archive".to_string()
    } else {
        value
    }
}

fn command_error(prefix: &str, output: &std::process::Output) -> String {
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if !stderr.is_empty() {
        format!("{prefix}: {stderr}")
    } else if !stdout.is_empty() {
        format!("{prefix}: {stdout}")
    } else {
        format!("{prefix}: exit {}", output.status)
    }
}

fn version_is_newer(candidate: &str, current: &str) -> bool {
    let left = version_parts(candidate);
    let right = version_parts(current);
    for index in 0..left.len().max(right.len()) {
        let left_value = left.get(index).copied().unwrap_or_default();
        let right_value = right.get(index).copied().unwrap_or_default();
        if left_value != right_value {
            return left_value > right_value;
        }
    }
    false
}

fn version_parts(value: &str) -> Vec<u32> {
    value
        .trim_matches(|ch: char| ch == 'v' || ch == 'V' || ch.is_whitespace())
        .split(|ch: char| !ch.is_ascii_digit())
        .filter(|part| !part.is_empty())
        .map(|part| part.parse::<u32>().unwrap_or_default())
        .collect()
}

fn unix_timestamp() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |duration| duration.as_secs())
}

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

    fn unique_temp_dir(label: &str) -> PathBuf {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("clock after epoch")
            .as_nanos();
        std::env::temp_dir().join(format!("nvpn-updater-{label}-{nonce}"))
    }

    #[test]
    fn parses_github_release_manifest() {
        let manifest: ReleaseManifest = serde_json::from_str(
            r#"{
                "tag_name": "v4.0.12",
                "assets": [
                    {
                        "name": "nvpn-v4.0.12-aarch64-apple-darwin.tar.gz",
                        "browser_download_url": "https://example.invalid/nvpn.tgz"
                    }
                ]
            }"#,
        )
        .expect("manifest");

        assert_eq!(manifest.tag, "v4.0.12");
        assert_eq!(manifest.assets[0].path, "https://example.invalid/nvpn.tgz");
    }

    #[test]
    fn resolves_relative_manifest_asset_urls() {
        assert_eq!(
            manifest_asset_url(
                "https://example.invalid/latest/release.json",
                "assets/nvpn.tgz"
            ),
            "https://example.invalid/latest/assets/nvpn.tgz"
        );
    }

    #[test]
    fn auto_source_checks_htree_before_github() {
        assert_eq!(
            manifest_urls_for(UpdateSource::Auto, None),
            vec![
                HTREE_MANIFEST_URL.to_string(),
                GITHUB_LATEST_RELEASE_URL.to_string(),
            ]
        );
    }

    #[test]
    fn secure_cli_selection_prefers_cli_archive_over_desktop_app() {
        let archive_ext = if cfg!(target_os = "windows") {
            ".zip"
        } else {
            ".tar.gz"
        };
        let manifest: UpdateManifest = serde_json::from_str(&format!(
            r#"{{
                "tag": "v4.0.48",
                "assets": [
                    {{ "name": "nostr-vpn-v4.0.48-macos-arm64.app.tar.gz", "path": "assets/app.tgz" }},
                    {{ "name": "nvpn-v4.0.48-{target}{archive_ext}", "path": "assets/cli.tgz" }}
                ]
            }}"#,
            target = current_target(),
            archive_ext = archive_ext,
        ))
        .expect("manifest");

        let asset = preferred_secure_asset(&manifest, UpdateMode::Cli).expect("cli asset");
        assert_eq!(asset.path, "assets/cli.tgz");
    }

    #[test]
    fn secure_app_selection_ignores_cli_archives() {
        let manifest: UpdateManifest = serde_json::from_str(
            r#"{
                "tag": "v4.0.48",
                "assets": [
                    { "name": "nvpn-v4.0.48-aarch64-apple-darwin.tar.gz", "path": "assets/cli.tgz" },
                    { "name": "nostr-vpn-v4.0.48-macos-arm64.app.tar.gz", "path": "assets/app.tgz" }
                ]
            }"#,
        )
        .expect("manifest");

        let asset = preferred_secure_asset(&manifest, UpdateMode::App);

        #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
        {
            let asset = asset.expect("app asset");
            assert_eq!(asset.path, "assets/app.tgz");
        }

        #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
        assert!(asset.is_none());
    }

    #[test]
    fn app_asset_name_matching_rejects_cli_archive() {
        assert!(!app_asset_name_matches_current_target(&format!(
            "nvpn-v4.0.48-{}.tar.gz",
            current_target()
        )));
    }

    #[test]
    fn compares_semver_like_tags() {
        assert!(version_is_newer("v4.0.13", "4.0.12"));
        assert!(!version_is_newer("v4.0.12", "4.0.12"));
        assert!(!version_is_newer("v4.0.11", "4.0.12"));
    }

    #[test]
    fn helper_source_dir_is_next_to_downloaded_binary() {
        let root = unique_temp_dir("helper-source");
        let binary = root.join("archive").join("nvpn.exe");
        let helper_dir = binary.parent().expect("binary parent").join("binaries");
        fs::create_dir_all(&helper_dir).expect("create helper dir");
        fs::write(&binary, b"exe").expect("write binary placeholder");
        fs::write(helper_dir.join("wintun.dll"), b"dll").expect("write helper placeholder");

        assert_eq!(bundled_helper_source_dir(&binary), Some(helper_dir));

        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn installs_bundled_helper_files_recursively() {
        let root = unique_temp_dir("helper-install");
        let source_dir = root.join("source").join("binaries");
        let destination_dir = root.join("install").join("binaries");
        fs::create_dir_all(source_dir.join("drivers")).expect("create source dirs");
        fs::create_dir_all(&destination_dir).expect("create destination dir");
        fs::write(source_dir.join("wintun.dll"), b"new").expect("write wintun helper");
        fs::write(source_dir.join("drivers").join("extra.dll"), b"extra")
            .expect("write nested helper");
        fs::write(destination_dir.join("wintun.dll"), b"old").expect("write old helper");

        let installed = install_bundled_helper_dir(&source_dir, &destination_dir)
            .expect("install bundled helpers");

        assert!(installed.contains(&destination_dir.join("wintun.dll")));
        assert!(installed.contains(&destination_dir.join("drivers").join("extra.dll")));
        assert_eq!(
            fs::read(destination_dir.join("wintun.dll")).expect("read installed wintun"),
            b"new"
        );
        assert_eq!(
            fs::read(destination_dir.join("drivers").join("extra.dll"))
                .expect("read installed nested helper"),
            b"extra"
        );

        let _ = fs::remove_dir_all(&root);
    }
}
