/*!
# App Auto Updater

This module provides comprehensive self-update functionality for the Donut Browser application
across multiple operating systems and installation methods.

## Supported Platforms

### macOS
- **Format**: DMG files
- **Installation**: Replaces the .app bundle in place
- **Architecture**: Supports both x64 and aarch64 (Apple Silicon)

### Windows
- **Formats**: MSI (preferred), EXE, ZIP
- **Installation**:
  - MSI: Silent installation using msiexec
  - EXE: Silent installation with multiple fallback flags (NSIS, Inno Setup)
  - ZIP: Binary replacement
- **Architecture**: Supports both x64 and x86_64

### Linux
- **Formats**: DEB, RPM, AppImage, TAR.GZ
- **Installation Methods**:
  - **DEB**: Uses dpkg or apt with pkexec for privilege escalation
  - **RPM**: Uses rpm, dnf, yum, or zypper with pkexec
  - **AppImage**: Direct replacement or installation to ~/.local/bin
  - **TAR.GZ**: Binary extraction and replacement
- **Architecture**: Supports x64, x86_64, amd64, aarch64, arm64

## Linux Installation Detection

The updater automatically detects how the application was installed:
- **AppImage**: Detected via APPIMAGE environment variable
- **Package Manager**: Detected by executable location and package queries
- **Manual**: Detected by location in user directories
- **System**: Detected by location in system directories

## Update Process

1. **Check**: Fetches releases from GitHub API
2. **Filter**: Filters releases based on build type (stable vs nightly)
3. **Compare**: Compares versions using semantic versioning or commit hashes
4. **Download**: Downloads appropriate asset with progress tracking
5. **Extract**: Extracts or prepares installer based on format
6. **Install**: Installs using platform-appropriate method
7. **Restart**: Restarts application after successful installation

## Error Handling

- Comprehensive error messages for each platform
- Fallback mechanisms for different package managers
- Backup creation before installation
- Cleanup of temporary files
- Graceful handling of permission issues

## Testing

Includes comprehensive unit tests for:
- Version comparison logic
- Platform detection
- Asset selection
- Installation method detection (Linux)
- File format support
*/

use crate::events;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;

#[cfg(target_os = "linux")]
#[derive(Debug, Clone)]
enum LinuxInstallationMethod {
  Deb,      // Installed via DEB package
  Rpm,      // Installed via RPM package
  AppImage, // Running from AppImage
  Manual,   // Manually installed (e.g., extracted tarball)
  Unknown,  // Cannot determine
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppReleaseAsset {
  pub name: String,
  pub browser_download_url: String,
  pub size: u64,
  /// GitHub-computed digest ("sha256:<hex>"); absent on assets uploaded
  /// before GitHub started calculating digests.
  #[serde(default)]
  pub digest: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppRelease {
  pub tag_name: String,
  pub name: String,
  pub body: String,
  pub published_at: String,
  pub prerelease: bool,
  pub assets: Vec<AppReleaseAsset>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppUpdateInfo {
  pub current_version: String,
  pub new_version: String,
  pub release_notes: String,
  pub download_url: String,
  pub is_nightly: bool,
  pub published_at: String,
  pub manual_update_required: bool,
  pub release_page_url: Option<String>,
  /// True when a system package manager repo is configured (apt/dnf/zypper)
  pub repo_update: bool,
  /// URL of the release's SHA256SUMS.txt asset. The downloaded update is
  /// verified against it before installation; without it the update is
  /// refused.
  #[serde(default)]
  pub checksums_url: Option<String>,
  /// GitHub's server-side digest of the chosen asset ("sha256:<hex>"),
  /// cross-checked in addition to SHA256SUMS.txt when present.
  #[serde(default)]
  pub asset_digest: Option<String>,
}

pub struct AppAutoUpdater {
  client: Client,
  extractor: &'static crate::extraction::Extractor,
}

impl AppAutoUpdater {
  fn new() -> Self {
    Self {
      client: Client::new(),
      extractor: crate::extraction::Extractor::instance(),
    }
  }

  pub fn instance() -> &'static AppAutoUpdater {
    &APP_AUTO_UPDATER
  }

  /// Check if running a nightly build based on environment variable
  pub fn is_nightly_build() -> bool {
    // If STABLE_RELEASE env var is set at compile time, it's a stable build
    if option_env!("STABLE_RELEASE").is_some() {
      return false;
    }

    // Also check if the current version starts with "nightly-"
    let current_version = Self::get_current_version();
    if current_version.starts_with("nightly-") {
      return true;
    }

    // If STABLE_RELEASE is not set and version doesn't start with "nightly-",
    // it's still considered a nightly build (dev builds, main branch builds, etc.)
    true
  }

  /// Get current app version from build-time injection
  pub fn get_current_version() -> String {
    // Use build-time injected version instead of CARGO_PKG_VERSION
    env!("BUILD_VERSION").to_string()
  }

  /// Check for app updates
  pub async fn check_for_updates(
    &self,
  ) -> Result<Option<AppUpdateInfo>, Box<dyn std::error::Error + Send + Sync>> {
    let current_version = Self::get_current_version();
    let is_nightly = Self::is_nightly_build();

    log::info!("=== App Update Check ===");
    log::info!("Current version: {current_version}");
    log::info!("Is nightly build: {is_nightly}");
    log::info!("STABLE_RELEASE env: {:?}", option_env!("STABLE_RELEASE"));

    let releases = self.fetch_app_releases().await?;
    log::info!("Fetched {} releases from GitHub", releases.len());

    // Filter releases based on build type
    let filtered_releases: Vec<&AppRelease> = if is_nightly {
      // For nightly builds, look for nightly releases
      let nightly_releases: Vec<&AppRelease> = releases
        .iter()
        .filter(|release| release.tag_name.starts_with("nightly-"))
        .collect();
      log::info!("Found {} nightly releases", nightly_releases.len());
      nightly_releases
    } else {
      // For stable builds, look for stable releases (semver format)
      let stable_releases: Vec<&AppRelease> = releases
        .iter()
        .filter(|release| release.tag_name.starts_with('v'))
        .collect();
      log::info!("Found {} stable releases", stable_releases.len());
      stable_releases
    };

    if filtered_releases.is_empty() {
      log::info!("No releases found for build type (nightly: {is_nightly})");
      return Ok(None);
    }

    // Get the latest release
    let latest_release = filtered_releases[0];
    log::info!(
      "Latest release: {} ({})",
      latest_release.tag_name,
      latest_release.name
    );

    // Check if we need to update
    if self.should_update(&current_version, &latest_release.tag_name, is_nightly) {
      log::info!("Update available!");

      // Build the release page URL
      let release_page_url = format!(
        "https://github.com/zhom/donutbrowser/releases/tag/{}",
        latest_release.tag_name
      );

      // Find the appropriate asset for current platform
      let download_url = self.get_download_url_for_platform(&latest_release.assets);

      // Locate the release's checksums file and the chosen asset's
      // GitHub-computed digest for post-download verification.
      let checksums_url = Self::find_checksums_url(&latest_release.assets);
      let asset_digest = download_url.as_deref().and_then(|url| {
        latest_release
          .assets
          .iter()
          .find(|a| a.browser_download_url == url)
          .and_then(|a| a.digest.clone())
      });

      // Both release workflows upload SHA256SUMS.txt only after every platform
      // build finishes, so a release without it is still being assembled (or
      // its pipeline broke). Downloading now is guaranteed to fail closed, so
      // treat the release as not ready and retry on a later check instead of
      // surfacing an error for a healthy in-progress release. Applies only to
      // the auto-download path — manual/repo notifications don't download.
      let auto_download_possible = download_url.is_some();
      #[cfg(target_os = "linux")]
      let auto_download_possible = auto_download_possible && !self.is_repo_configured();
      if auto_download_possible && checksums_url.is_none() {
        log::info!(
          "Release {} has no {} yet; treating as not ready for auto-update",
          latest_release.tag_name,
          Self::CHECKSUMS_ASSET_NAME
        );
        return Ok(None);
      }

      // On Linux, when a package repo is configured, notify users to update via
      // their package manager instead of auto-downloading from GitHub.
      #[cfg(target_os = "linux")]
      {
        let repo_update = self.is_repo_configured();
        let manual_update_required = download_url.is_none() || repo_update;
        let update_info = AppUpdateInfo {
          current_version,
          new_version: latest_release.tag_name.clone(),
          release_notes: latest_release.body.clone(),
          download_url: download_url.unwrap_or_else(|| release_page_url.clone()),
          is_nightly,
          published_at: latest_release.published_at.clone(),
          manual_update_required,
          release_page_url: Some(release_page_url),
          repo_update,
          checksums_url,
          asset_digest,
        };

        log::info!(
          "Update info prepared: {} -> {} (manual_update_required: {}, repo_update: {})",
          update_info.current_version,
          update_info.new_version,
          update_info.manual_update_required,
          update_info.repo_update
        );
        return Ok(Some(update_info));
      }

      #[cfg(not(target_os = "linux"))]
      {
        if let Some(url) = download_url {
          let update_info = AppUpdateInfo {
            current_version,
            new_version: latest_release.tag_name.clone(),
            release_notes: latest_release.body.clone(),
            download_url: url,
            is_nightly,
            published_at: latest_release.published_at.clone(),
            manual_update_required: false,
            release_page_url: Some(release_page_url),
            repo_update: false,
            checksums_url,
            asset_digest,
          };

          log::info!(
            "Update info prepared: {} -> {}",
            update_info.current_version,
            update_info.new_version
          );
          return Ok(Some(update_info));
        } else {
          log::info!("No suitable download asset found for current platform");
        }
      }
    } else {
      log::info!("No update needed");
    }

    Ok(None)
  }

  /// Fetch app releases from GitHub
  async fn fetch_app_releases(
    &self,
  ) -> Result<Vec<AppRelease>, Box<dyn std::error::Error + Send + Sync>> {
    let url = "https://api.github.com/repos/zhom/donutbrowser/releases?per_page=100";
    let response = self
      .client
      .get(url)
      .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
      .send()
      .await?;

    if !response.status().is_success() {
      return Err(format!("GitHub API request failed: {}", response.status()).into());
    }

    let releases: Vec<AppRelease> = response.json().await?;
    Ok(releases)
  }

  /// Determine if an update should be performed
  fn should_update(&self, current_version: &str, new_version: &str, is_nightly: bool) -> bool {
    if current_version.starts_with("dev-") {
      return false;
    }

    log::info!(
      "Comparing versions: current={current_version}, new={new_version}, is_nightly={is_nightly}"
    );

    if is_nightly {
      // For nightly builds, always update if there's a newer nightly
      if let (Some(current_hash), Some(new_hash)) = (
        current_version.strip_prefix("nightly-"),
        new_version.strip_prefix("nightly-"),
      ) {
        // Different commit hashes mean we should update
        let should_update = new_hash != current_hash;
        log::info!("Nightly comparison: current_hash={current_hash}, new_hash={new_hash}, should_update={should_update}");
        return should_update;
      }

      // If current version doesn't have nightly prefix but we're in nightly mode,
      // this could be a dev build or stable build upgrading to nightly
      if !current_version.starts_with("nightly-") {
        log::info!("Upgrading from non-nightly to nightly: {new_version}");
        return true;
      }
    } else {
      // For stable builds, use semantic versioning comparison
      let should_update = self.is_version_newer(new_version, current_version);
      log::info!("Stable comparison: {new_version} > {current_version} = {should_update}");
      return should_update;
    }

    false
  }

  /// Compare semantic versions (returns true if version1 > version2)
  fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
    let v1 = self.parse_semver(version1);
    let v2 = self.parse_semver(version2);
    v1 > v2
  }

  /// Parse semantic version string into comparable tuple
  fn parse_semver(&self, version: &str) -> (u32, u32, u32) {
    let clean_version = version.trim_start_matches('v');
    let parts: Vec<&str> = clean_version.split('.').collect();

    let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
    let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
    let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);

    (major, minor, patch)
  }

  /// Detect if we're running from an AppImage
  #[cfg(target_os = "linux")]
  fn is_running_from_appimage(&self) -> bool {
    // Check APPIMAGE environment variable first
    if std::env::var("APPIMAGE").is_ok() {
      return true;
    }

    // Check if current executable path looks like an AppImage
    if let Ok(exe_path) = std::env::current_exe() {
      if let Some(file_name) = exe_path.file_name().and_then(|n| n.to_str()) {
        if file_name.to_lowercase().contains("appimage") {
          return true;
        }
      }

      // Check if the executable is in a temporary mount point (typical for AppImages)
      if let Some(path_str) = exe_path.to_str() {
        if path_str.contains("/tmp/.mount_") || path_str.contains("/tmp/appimage") {
          return true;
        }
      }
    }

    false
  }

  /// Detect how the application was installed on Linux
  #[cfg(target_os = "linux")]
  fn detect_linux_installation_method(&self) -> LinuxInstallationMethod {
    // First check if we're running from an AppImage
    if self.is_running_from_appimage() {
      return LinuxInstallationMethod::AppImage;
    }

    // Get current executable path
    let exe_path = match std::env::current_exe() {
      Ok(path) => path,
      Err(_) => return LinuxInstallationMethod::Unknown,
    };

    let exe_path_str = exe_path.to_string_lossy();
    log::info!("Detecting installation method for: {exe_path_str}");

    // Check if installed via package manager by querying package databases
    if let Some(exe_name) = exe_path.file_name().and_then(|n| n.to_str()) {
      // Try to find the package that owns this file

      // Check DEB systems (dpkg)
      if let Ok(output) = Command::new("dpkg").args(["-S", &exe_path_str]).output() {
        if output.status.success() {
          let stdout = String::from_utf8_lossy(&output.stdout);
          if !stdout.trim().is_empty() && !stdout.contains("no path found") {
            log::info!("Found DEB package owning the executable");
            return LinuxInstallationMethod::Deb;
          }
        }
      }

      // Check RPM systems (rpm)
      if let Ok(output) = Command::new("rpm").args(["-qf", &exe_path_str]).output() {
        if output.status.success() {
          let stdout = String::from_utf8_lossy(&output.stdout);
          if !stdout.trim().is_empty() && !stdout.contains("not owned") {
            log::info!("Found RPM package owning the executable");
            return LinuxInstallationMethod::Rpm;
          }
        }
      }

      // Alternative RPM check with different systems
      for rpm_cmd in &["dnf", "yum", "zypper"] {
        if let Ok(output) = Command::new(rpm_cmd)
          .args(["provides", &exe_path_str])
          .output()
        {
          if output.status.success() {
            let stdout = String::from_utf8_lossy(&output.stdout);
            if !stdout.trim().is_empty() && stdout.contains(exe_name) {
              log::info!("Found RPM package via {rpm_cmd}");
              return LinuxInstallationMethod::Rpm;
            }
          }
        }
      }
    }

    // Check installation location to infer method
    if exe_path_str.starts_with("/usr/bin/") || exe_path_str.starts_with("/usr/local/bin/") {
      // Likely installed via package manager or system-wide installation
      log::info!("Executable in system directory, assuming package installation");

      // Try to determine which package system is available
      if Command::new("dpkg").arg("--version").output().is_ok() {
        return LinuxInstallationMethod::Deb;
      } else if Command::new("rpm").arg("--version").output().is_ok() {
        return LinuxInstallationMethod::Rpm;
      }

      return LinuxInstallationMethod::Manual;
    } else if exe_path_str.contains("/.local/") || exe_path_str.starts_with("/home/") {
      // User-local installation
      log::info!("Executable in user directory, assuming manual installation");
      return LinuxInstallationMethod::Manual;
    }

    log::info!("Could not determine installation method");
    LinuxInstallationMethod::Unknown
  }

  /// Check if the APT repository is configured
  #[cfg(target_os = "linux")]
  fn is_deb_repo_configured() -> bool {
    Path::new("/etc/apt/sources.list.d/donutbrowser.list").exists()
  }

  /// Check if an RPM repository is configured (yum/dnf or zypper)
  #[cfg(target_os = "linux")]
  fn is_rpm_repo_configured() -> bool {
    Path::new("/etc/yum.repos.d/donutbrowser.repo").exists()
      || Path::new("/etc/zypp/repos.d/donutbrowser.repo").exists()
  }

  /// Check if a system package manager repo is configured for this installation.
  #[cfg(target_os = "linux")]
  fn is_repo_configured(&self) -> bool {
    let installation_method = self.detect_linux_installation_method();
    match installation_method {
      LinuxInstallationMethod::Deb => Self::is_deb_repo_configured(),
      LinuxInstallationMethod::Rpm => Self::is_rpm_repo_configured(),
      _ => false,
    }
  }

  /// Get the appropriate download URL for the current platform
  fn get_download_url_for_platform(&self, assets: &[AppReleaseAsset]) -> Option<String> {
    let arch = if cfg!(target_arch = "aarch64") {
      "aarch64"
    } else if cfg!(target_arch = "x86_64") {
      "x64"
    } else {
      "unknown"
    };

    log::info!("Looking for platform-specific asset for arch: {arch}");

    #[cfg(target_os = "linux")]
    {
      // If we're running from an AppImage, disable auto-updates for safety
      if self.is_running_from_appimage() {
        log::info!("Running from AppImage - auto-updates disabled for safety");
        return None;
      }
    }

    #[cfg(target_os = "macos")]
    {
      self.get_macos_download_url(assets, arch)
    }

    #[cfg(target_os = "windows")]
    {
      self.get_windows_download_url(assets, arch)
    }

    #[cfg(target_os = "linux")]
    {
      self.get_linux_download_url(assets, arch)
    }

    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
    {
      log::info!("Unsupported platform for auto-update");
      None
    }
  }

  #[cfg(target_os = "macos")]
  fn get_macos_download_url(&self, assets: &[AppReleaseAsset], arch: &str) -> Option<String> {
    // Look for exact architecture match in DMG
    for asset in assets {
      if asset.name.contains(".dmg")
        && (asset.name.contains(&format!("_{arch}.dmg"))
          || asset.name.contains(&format!("-{arch}.dmg"))
          || asset.name.contains(&format!("_{arch}_"))
          || asset.name.contains(&format!("-{arch}-"))
          || asset.name.contains(&format!("_{arch}-")))
      {
        log::info!("Found exact architecture match: {}", asset.name);
        return Some(asset.browser_download_url.clone());
      }
    }

    // Look for x86_64 variations if we're looking for x64
    if arch == "x64" {
      for asset in assets {
        if asset.name.contains(".dmg")
          && (asset.name.contains("x86_64") || asset.name.contains("x86-64"))
        {
          log::info!("Found x86_64 variant: {}", asset.name);
          return Some(asset.browser_download_url.clone());
        }
      }
    }

    // Look for arm64 variations if we're looking for aarch64
    if arch == "aarch64" {
      for asset in assets {
        if asset.name.contains(".dmg")
          && (asset.name.contains("arm64") || asset.name.contains("aarch64"))
        {
          log::info!("Found arm64 variant: {}", asset.name);
          return Some(asset.browser_download_url.clone());
        }
      }
    }

    // Fallback to any macOS DMG
    for asset in assets {
      if asset.name.contains(".dmg")
        && (asset.name.to_lowercase().contains("macos")
          || asset.name.to_lowercase().contains("darwin")
          || !asset.name.contains(".app.tar.gz"))
      {
        log::info!("Found fallback DMG: {}", asset.name);
        return Some(asset.browser_download_url.clone());
      }
    }

    None
  }

  #[cfg(target_os = "windows")]
  fn get_windows_download_url(&self, assets: &[AppReleaseAsset], arch: &str) -> Option<String> {
    // Priority order: MSI > EXE > ZIP
    let extensions = ["msi", "exe", "zip"];

    for ext in &extensions {
      // Look for exact architecture match
      for asset in assets {
        if asset.name.to_lowercase().ends_with(&format!(".{ext}"))
          && (asset.name.contains(&format!("_{arch}.{ext}"))
            || asset.name.contains(&format!("-{arch}.{ext}"))
            || asset.name.contains(&format!("_{arch}_"))
            || asset.name.contains(&format!("-{arch}-"))
            || asset.name.contains(&format!("_{arch}-")))
        {
          log::info!("Found Windows {ext} with exact arch match: {}", asset.name);
          return Some(asset.browser_download_url.clone());
        }
      }

      // Look for x86_64 variations if we're looking for x64
      if arch == "x64" {
        for asset in assets {
          if asset.name.to_lowercase().ends_with(&format!(".{ext}"))
            && (asset.name.contains("x86_64") || asset.name.contains("x86-64"))
          {
            log::info!("Found Windows {ext} with x86_64 variant: {}", asset.name);
            return Some(asset.browser_download_url.clone());
          }
        }
      }

      // Fallback to any Windows file of this type
      for asset in assets {
        if asset.name.to_lowercase().ends_with(&format!(".{ext}"))
          && (asset.name.to_lowercase().contains("windows")
            || asset.name.to_lowercase().contains("win32")
            || asset.name.to_lowercase().contains("win64"))
        {
          log::info!("Found Windows {ext} fallback: {}", asset.name);
          return Some(asset.browser_download_url.clone());
        }
      }
    }

    None
  }

  #[cfg(target_os = "linux")]
  fn get_linux_download_url(&self, assets: &[AppReleaseAsset], arch: &str) -> Option<String> {
    // Detect installation method to prioritize appropriate formats
    let installation_method = self.detect_linux_installation_method();
    log::info!("Detected Linux installation method: {installation_method:?}");

    // Priority order based on installation method
    let extensions = match installation_method {
      LinuxInstallationMethod::Deb => vec!["deb", "tar.gz"],
      LinuxInstallationMethod::Rpm => vec!["rpm", "tar.gz"],
      LinuxInstallationMethod::AppImage => {
        // AppImages should not auto-update for safety
        log::info!("AppImage installation detected - auto-updates disabled");
        return None;
      }
      LinuxInstallationMethod::Manual | LinuxInstallationMethod::Unknown => {
        vec!["deb", "rpm", "tar.gz"]
      }
    };

    for ext in &extensions {
      // Look for exact architecture match
      for asset in assets {
        let asset_name_lower = asset.name.to_lowercase();
        if asset_name_lower.ends_with(&format!(".{ext}"))
          && (asset.name.contains(&format!("_{arch}.{ext}"))
            || asset.name.contains(&format!("-{arch}.{ext}"))
            || asset.name.contains(&format!("_{arch}_"))
            || asset.name.contains(&format!("-{arch}-"))
            || asset.name.contains(&format!("_{arch}-")))
        {
          log::info!("Found Linux {ext} with exact arch match: {}", asset.name);
          return Some(asset.browser_download_url.clone());
        }
      }

      // Look for x86_64 variations if we're looking for x64
      if arch == "x64" {
        for asset in assets {
          let asset_name_lower = asset.name.to_lowercase();
          if asset_name_lower.ends_with(&format!(".{ext}"))
            && (asset.name.contains("x86_64")
              || asset.name.contains("x86-64")
              || asset.name.contains("amd64"))
          {
            log::info!("Found Linux {ext} with x86_64 variant: {}", asset.name);
            return Some(asset.browser_download_url.clone());
          }
        }
      }

      // Look for arm64 variations if we're looking for aarch64
      if arch == "aarch64" {
        for asset in assets {
          let asset_name_lower = asset.name.to_lowercase();
          if asset_name_lower.ends_with(&format!(".{ext}"))
            && (asset.name.contains("arm64") || asset.name.contains("aarch64"))
          {
            log::info!("Found Linux {ext} with arm64 variant: {}", asset.name);
            return Some(asset.browser_download_url.clone());
          }
        }
      }

      // Fallback to any Linux file of this type
      for asset in assets {
        let asset_name_lower = asset.name.to_lowercase();
        if asset_name_lower.ends_with(&format!(".{ext}"))
          && (asset_name_lower.contains("linux")
            || asset_name_lower.contains("ubuntu")
            || asset_name_lower.contains("debian"))
        {
          log::info!("Found Linux {ext} fallback: {}", asset.name);
          return Some(asset.browser_download_url.clone());
        }
      }
    }

    None
  }

  /// Name of the checksums asset both release workflows publish.
  const CHECKSUMS_ASSET_NAME: &'static str = "SHA256SUMS.txt";

  fn find_checksums_url(assets: &[AppReleaseAsset]) -> Option<String> {
    assets
      .iter()
      .find(|a| a.name == Self::CHECKSUMS_ASSET_NAME)
      .map(|a| a.browser_download_url.clone())
  }

  /// Extract the hex digest for `filename` from standard `sha256sum` output
  /// (`<hex>  <name>`, optionally with the `*` binary-mode marker).
  fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option<String> {
    checksums_text.lines().find_map(|line| {
      let (hash, rest) = line.split_once(char::is_whitespace)?;
      let name = rest.trim_start().trim_start_matches('*');
      if name == filename && hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
        Some(hash.to_ascii_lowercase())
      } else {
        None
      }
    })
  }

  fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
    use sha2::{Digest, Sha256};
    use std::io::Read;
    let mut file = fs::File::open(path)?;
    let mut hasher = Sha256::new();
    let mut buf = vec![0u8; 1024 * 1024];
    loop {
      let n = file.read(&mut buf)?;
      if n == 0 {
        break;
      }
      hasher.update(&buf[..n]);
    }
    let digest = hasher.finalize();
    let mut hex = String::with_capacity(digest.len() * 2);
    for byte in digest {
      use std::fmt::Write;
      let _ = write!(hex, "{byte:02x}");
    }
    Ok(hex)
  }

  /// Fetch the release's SHA256SUMS.txt and return the expected digest for
  /// `filename`. Called BEFORE the (large) asset download so an unverifiable
  /// release is rejected without wasting the transfer. Every failure mode
  /// maps to the UPDATE_CHECKSUMS_UNAVAILABLE code; details go to the log.
  async fn fetch_expected_checksum(
    &self,
    update_info: &AppUpdateInfo,
    filename: &str,
  ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
    let unavailable = || -> Box<dyn std::error::Error + Send + Sync> {
      serde_json::json!({
        "code": "UPDATE_CHECKSUMS_UNAVAILABLE",
        "params": { "version": update_info.new_version }
      })
      .to_string()
      .into()
    };

    let Some(checksums_url) = update_info.checksums_url.as_deref() else {
      log::warn!(
        "No {} asset on release {}",
        Self::CHECKSUMS_ASSET_NAME,
        update_info.new_version
      );
      return Err(unavailable());
    };

    let response = match self
      .client
      .get(checksums_url)
      .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
      .send()
      .await
    {
      Ok(response) if response.status().is_success() => response,
      Ok(response) => {
        log::warn!("Checksums file request failed: HTTP {}", response.status());
        return Err(unavailable());
      }
      Err(e) => {
        log::warn!("Checksums file request failed: {e}");
        return Err(unavailable());
      }
    };

    let checksums_text = match response.text().await {
      Ok(text) => text,
      Err(e) => {
        log::warn!("Failed to read checksums file: {e}");
        return Err(unavailable());
      }
    };

    let Some(expected) = Self::find_checksum_for_file(&checksums_text, filename) else {
      log::warn!(
        "No checksum entry for {filename} in {}",
        Self::CHECKSUMS_ASSET_NAME
      );
      return Err(unavailable());
    };
    Ok(expected)
  }

  /// Verify the downloaded update against the expected SHA256SUMS.txt digest
  /// (and GitHub's server-side asset digest when available) before anything
  /// is extracted or installed. A corrupt download is deleted so the next
  /// attempt starts fresh.
  fn verify_update_checksum(
    file_path: &Path,
    filename: &str,
    expected: &str,
    asset_digest: Option<&str>,
  ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let actual = Self::sha256_file(file_path)?;

    let mut mismatch = !actual.eq_ignore_ascii_case(expected);

    // Cross-check GitHub's server-side digest: SHA256SUMS.txt is computed by
    // re-downloading assets in CI, so this catches corruption in that step.
    if !mismatch {
      if let Some(hex) = asset_digest.and_then(|d| d.strip_prefix("sha256:")) {
        mismatch = !actual.eq_ignore_ascii_case(hex);
      }
    }

    if mismatch {
      log::error!(
        "Checksum mismatch for {filename}: expected {expected}, got {actual} (asset digest: {asset_digest:?})"
      );
      let _ = fs::remove_file(file_path);
      return Err(
        serde_json::json!({
          "code": "UPDATE_CHECKSUM_MISMATCH",
          "params": { "file": filename }
        })
        .to_string()
        .into(),
      );
    }

    log::info!("Checksum verified for {filename}: {actual}");
    Ok(())
  }

  /// Download the update file without progress tracking (silent download)
  async fn download_update_silent(
    &self,
    download_url: &str,
    dest_dir: &Path,
    filename: &str,
  ) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
    let file_path = dest_dir.join(filename);

    let response = self
      .client
      .get(download_url)
      .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
      .send()
      .await?;

    if !response.status().is_success() {
      return Err(format!("Download failed with status: {}", response.status()).into());
    }

    let total_size = response.content_length().unwrap_or(0);
    log::info!("Silent download size: {} bytes", total_size);
    let raw_file = fs::File::create(&file_path)?;
    let mut file = std::io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
    let mut stream = response.bytes_stream();

    use futures_util::StreamExt;
    while let Some(chunk) = stream.next().await {
      let chunk = chunk?;
      file.write_all(&chunk)?;
    }
    std::io::Write::flush(&mut file)?;

    log::info!("Silent download completed: {}", file_path.display());
    Ok(file_path)
  }

  /// Download and prepare app update (silent download + install + notify)
  pub async fn download_and_prepare_update(
    &self,
    _app_handle: &tauri::AppHandle,
    update_info: &AppUpdateInfo,
  ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    log::info!("Starting background update download and install");

    let temp_dir = std::env::temp_dir().join("donut_app_update");
    fs::create_dir_all(&temp_dir)?;

    let filename = update_info
      .download_url
      .split('/')
      .next_back()
      .unwrap_or("update.dmg")
      .to_string();

    // Resolve the expected checksum first so an unverifiable release is
    // rejected before the multi-hundred-MB download, not after.
    let expected_sha256 = self.fetch_expected_checksum(update_info, &filename).await?;

    log::info!("Downloading update");

    let download_path = self
      .download_update_silent(&update_info.download_url, &temp_dir, &filename)
      .await?;

    log::info!("Verifying update checksum...");
    Self::verify_update_checksum(
      &download_path,
      &filename,
      &expected_sha256,
      update_info.asset_digest.as_deref(),
    )?;

    log::info!("Extracting update...");
    let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;

    // On Windows, MSI/EXE installers close the running app, so running them now
    // would kill the process before the "Update ready" toast can appear. Instead,
    // defer execution to restart_application() when the user clicks "Restart Now".
    #[cfg(target_os = "windows")]
    {
      let ext = extracted_app_path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("")
        .to_lowercase();
      if ext == "msi" || ext == "exe" {
        log::info!("Deferring Windows installer execution until user-initiated restart");
        *PENDING_INSTALLER_PATH.lock().unwrap() = Some(extracted_app_path);
      } else {
        log::info!("Installing update (overwriting binary)...");
        self.install_update(&extracted_app_path).await?;
        log::info!("Cleaning up temporary files...");
        let _ = fs::remove_dir_all(&temp_dir);
      }
    }

    #[cfg(not(target_os = "windows"))]
    {
      log::info!("Installing update (overwriting binary)...");
      self.install_update(&extracted_app_path).await?;
      log::info!("Cleaning up temporary files...");
      let _ = fs::remove_dir_all(&temp_dir);
    }

    log::info!("Update ready, emitting app-update-ready event");

    let _ = events::emit("app-update-ready", update_info.new_version.clone());

    Ok(())
  }

  /// Extract the update using the extraction module
  async fn extract_update(
    &self,
    archive_path: &Path,
    dest_dir: &Path,
  ) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
    let file_name = archive_path
      .file_name()
      .and_then(|name| name.to_str())
      .unwrap_or("");

    // Handle compound extensions like .tar.gz
    if file_name.ends_with(".tar.gz") {
      return self
        .extractor
        .extract_tar_gz(archive_path, dest_dir, None)
        .await;
    }

    let extension = archive_path
      .extension()
      .and_then(|ext| ext.to_str())
      .unwrap_or("");

    match extension {
      "dmg" => {
        #[cfg(target_os = "macos")]
        {
          self
            .extractor
            .extract_dmg(archive_path, dest_dir, None)
            .await
        }
        #[cfg(not(target_os = "macos"))]
        {
          Err("DMG extraction is only supported on macOS".into())
        }
      }
      "msi" => {
        #[cfg(target_os = "windows")]
        {
          // For MSI files on Windows, we need to run the installer
          // MSI files can't be extracted like archives, they need to be executed
          // Return the path to the MSI file itself for installation
          Ok(archive_path.to_path_buf())
        }
        #[cfg(not(target_os = "windows"))]
        {
          Err("MSI installation is only supported on Windows".into())
        }
      }
      "exe" => {
        #[cfg(target_os = "windows")]
        {
          // For exe installers on Windows, return the path for execution
          Ok(archive_path.to_path_buf())
        }
        #[cfg(not(target_os = "windows"))]
        {
          Err("EXE installation is only supported on Windows".into())
        }
      }
      "deb" => {
        #[cfg(target_os = "linux")]
        {
          // For DEB files on Linux, return the path for installation
          Ok(archive_path.to_path_buf())
        }
        #[cfg(not(target_os = "linux"))]
        {
          Err("DEB installation is only supported on Linux".into())
        }
      }
      "rpm" => {
        #[cfg(target_os = "linux")]
        {
          // For RPM files on Linux, return the path for installation
          Ok(archive_path.to_path_buf())
        }
        #[cfg(not(target_os = "linux"))]
        {
          Err("RPM installation is only supported on Linux".into())
        }
      }
      "appimage" => {
        #[cfg(target_os = "linux")]
        {
          // For AppImage files, return the path for installation
          Ok(archive_path.to_path_buf())
        }
        #[cfg(not(target_os = "linux"))]
        {
          Err("AppImage installation is only supported on Linux".into())
        }
      }
      "zip" => {
        self
          .extractor
          .extract_zip(archive_path, dest_dir, None)
          .await
      }
      _ => Err(format!("Unsupported archive format: {extension}").into()),
    }
  }

  /// Install the update by replacing the current app
  async fn install_update(
    &self,
    #[allow(unused_variables)] installer_path: &Path,
  ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    #[cfg(target_os = "macos")]
    {
      // Get the current application bundle path
      let current_app_path = self.get_current_app_path()?;

      // Create a backup of the current app
      let backup_path = current_app_path.with_extension("app.backup");
      if backup_path.exists() {
        fs::remove_dir_all(&backup_path)?;
      }

      // Move current app to backup
      fs::rename(&current_app_path, &backup_path)?;

      // Move new app to current location
      fs::rename(installer_path, &current_app_path)?;

      // Remove the macOS quarantine attribute from the freshly-installed app
      // so Gatekeeper doesn't block its first launch — but only if it's
      // actually present. macOS Sequoia's App Management TCC fires on the
      // modify-class syscall regardless of whether anything is actually
      // modified, so we gate the call behind a read-only `getxattr` check.
      let needs_quarantine_removal = {
        use std::ffi::CString;
        use std::os::unix::ffi::OsStrExt;
        let path_c = CString::new(current_app_path.as_os_str().as_bytes()).ok();
        let attr_c = CString::new("com.apple.quarantine").ok();
        match (path_c, attr_c) {
          (Some(p), Some(a)) => {
            // SAFETY: getxattr with a null buffer is a read-only size query.
            let result =
              unsafe { libc::getxattr(p.as_ptr(), a.as_ptr(), std::ptr::null_mut(), 0, 0, 0) };
            result >= 0
          }
          _ => false,
        }
      };
      if needs_quarantine_removal {
        let _ = Command::new("xattr")
          .args([
            "-dr",
            "com.apple.quarantine",
            current_app_path.to_str().unwrap(),
          ])
          .output();
      }

      // Clean up backup after successful installation
      let _ = fs::remove_dir_all(&backup_path);

      // Clean up old "Donut Browser.app" if it exists (from before the project rename)
      if let Some(parent_dir) = current_app_path.parent() {
        let old_app_path = parent_dir.join("Donut Browser.app");
        if old_app_path.exists() && old_app_path != current_app_path {
          log::info!(
            "Removing old 'Donut Browser.app' from: {}",
            old_app_path.display()
          );
          if let Err(e) = fs::remove_dir_all(&old_app_path) {
            log::warn!("Warning: Failed to remove old 'Donut Browser.app': {e}");
          } else {
            log::info!("Successfully removed old 'Donut Browser.app'");
          }
        }
      }

      Ok(())
    }

    #[cfg(target_os = "windows")]
    {
      let extension = installer_path
        .extension()
        .and_then(|ext| ext.to_str())
        .unwrap_or("");

      log::info!("Installing Windows update with extension: {extension}");

      match extension {
        "msi" => {
          // Install MSI silently with enhanced error handling
          log::info!("Running MSI installer: {}", installer_path.display());

          let mut cmd = Command::new("msiexec");
          cmd.args([
            "/i",
            installer_path.to_str().unwrap(),
            "/quiet",
            "/norestart",
            "REBOOT=ReallySuppress",
            "/l*v", // Enable verbose logging
            &format!("{}.log", installer_path.to_str().unwrap()),
          ]);

          use std::os::windows::process::CommandExt;
          const CREATE_NO_WINDOW: u32 = 0x08000000;
          cmd.creation_flags(CREATE_NO_WINDOW);

          let output = cmd.output()?;

          if !output.status.success() {
            let error_msg = String::from_utf8_lossy(&output.stderr);
            let exit_code = output.status.code().unwrap_or(-1);

            // Try to read the log file for more details
            let log_path = format!("{}.log", installer_path.to_str().unwrap());
            let log_content = fs::read_to_string(&log_path).unwrap_or_default();

            log::info!("MSI installation failed with exit code: {exit_code}");
            log::info!("Error output: {error_msg}");
            if !log_content.is_empty() {
              log::info!(
                "Log file content (last 500 chars): {}",
                log_content
                  .chars()
                  .rev()
                  .take(500)
                  .collect::<String>()
                  .chars()
                  .rev()
                  .collect::<String>()
              );
            }

            return Err(
              format!("MSI installation failed (exit code {exit_code}): {error_msg}").into(),
            );
          }

          log::info!("MSI installation completed successfully");
        }
        "exe" => {
          // Run exe installer silently with multiple fallback options
          log::info!("Running EXE installer: {}", installer_path.display());

          // Try NSIS silent flag first (most common for Tauri)
          let mut success = false;
          let mut last_error = String::new();

          // NSIS installer flags (used by Tauri)
          let nsis_args = vec![
            vec!["/S"],                                             // Standard NSIS silent flag
            vec!["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], // Inno Setup flags
            vec!["/quiet"],                                         // Generic quiet flag
            vec!["/silent"],                                        // Alternative silent flag
          ];

          for args in nsis_args {
            log::info!("Trying installer with args: {:?}", args);
            let output = Command::new(installer_path).args(&args).output();

            match output {
              Ok(output) if output.status.success() => {
                log::info!(
                  "EXE installation completed successfully with args: {:?}",
                  args
                );
                success = true;
                break;
              }
              Ok(output) => {
                let error_msg = String::from_utf8_lossy(&output.stderr);
                last_error = format!(
                  "Exit code {}: {}",
                  output.status.code().unwrap_or(-1),
                  error_msg
                );
                log::info!("Installer failed with args {:?}: {}", args, last_error);
              }
              Err(e) => {
                last_error = format!("Failed to execute installer: {e}");
                log::info!(
                  "Failed to execute installer with args {:?}: {}",
                  args,
                  last_error
                );
              }
            }
          }

          if !success {
            return Err(
              format!(
                "EXE installation failed after trying multiple methods. Last error: {last_error}"
              )
              .into(),
            );
          }
        }
        "zip" => {
          // Handle ZIP files by extracting and replacing the current executable
          log::info!("Handling ZIP update: {}", installer_path.display());

          let temp_extract_dir = installer_path.parent().unwrap().join("extracted");
          fs::create_dir_all(&temp_extract_dir)?;

          // Extract ZIP file
          let extracted_path = self
            .extractor
            .extract_zip(installer_path, &temp_extract_dir, None)
            .await?;

          // Find the executable in the extracted files
          let current_exe = self.get_current_app_path()?;
          let current_exe_name = current_exe.file_name().unwrap();

          // Look for the new executable
          let new_exe_path =
            if extracted_path.is_file() && extracted_path.file_name() == Some(current_exe_name) {
              extracted_path
            } else {
              // Search in extracted directory
              let mut found_exe = None;
              if let Ok(entries) = fs::read_dir(&extracted_path) {
                for entry in entries.flatten() {
                  let path = entry.path();
                  if path.file_name() == Some(current_exe_name) {
                    found_exe = Some(path);
                    break;
                  }
                }
              }
              found_exe.ok_or("Could not find executable in ZIP file")?
            };

          // Create backup of current executable
          let backup_path = current_exe.with_extension("exe.backup");
          if backup_path.exists() {
            fs::remove_file(&backup_path)?;
          }
          fs::copy(&current_exe, &backup_path)?;

          // Replace current executable
          fs::copy(&new_exe_path, &current_exe)?;

          // Clean up
          let _ = fs::remove_dir_all(&temp_extract_dir);

          log::info!("ZIP update completed successfully");
        }
        _ => {
          return Err(format!("Unsupported installer format: {extension}").into());
        }
      }

      Ok(())
    }

    #[cfg(target_os = "linux")]
    {
      let file_name = installer_path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("");

      log::info!("Installing Linux update: {}", installer_path.display());

      // Handle compound extensions like .tar.gz
      if file_name.ends_with(".tar.gz") {
        return self.install_linux_tarball(installer_path).await;
      }

      let extension = installer_path
        .extension()
        .and_then(|ext| ext.to_str())
        .unwrap_or("");

      match extension {
        "deb" => self.install_linux_deb(installer_path).await,
        "rpm" => self.install_linux_rpm(installer_path).await,
        "appimage" => self.install_linux_appimage(installer_path).await,
        _ => Err(format!("Unsupported Linux installer format: {extension}").into()),
      }
    }

    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
    {
      Err("Auto-update installation not supported on this platform".into())
    }
  }

  /// Install Linux DEB package
  #[cfg(target_os = "linux")]
  async fn install_linux_deb(
    &self,
    deb_path: &Path,
  ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    log::info!("Installing DEB package: {}", deb_path.display());
    Self::install_linux_package_with_privileges(deb_path, "dpkg", "-i")
  }

  /// Install Linux RPM package
  #[cfg(target_os = "linux")]
  async fn install_linux_rpm(
    &self,
    rpm_path: &Path,
  ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    log::info!("Installing RPM package: {}", rpm_path.display());
    Self::install_linux_package_with_privileges(rpm_path, "rpm", "-Uvh")
  }

  /// Install a Linux package with privilege escalation, using a fallback chain:
  /// 1. pkexec (graphical PolicyKit prompt — most common on desktop Linux)
  /// 2. zenity/kdialog password dialog → sudo -S (graphical sudo experience)
  /// 3. sudo (terminal fallback — works in TTY sessions)
  #[cfg(target_os = "linux")]
  fn install_linux_package_with_privileges(
    pkg_path: &Path,
    install_cmd: &str,
    install_arg: &str,
  ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let pkg = pkg_path.to_str().unwrap_or_default();

    // 1. Try pkexec (graphical PolicyKit prompt)
    if let Ok(status) = Command::new("pkexec")
      .args([install_cmd, install_arg, pkg])
      .status()
    {
      if status.success() {
        log::info!("Installed {pkg} with pkexec");
        return Ok(());
      }
    }

    // 2. Try graphical password dialog → sudo -S
    if let Some(password) = Self::get_password_graphically() {
      if Self::install_with_sudo_stdin(pkg_path, &password, install_cmd, install_arg) {
        log::info!("Installed {pkg} with graphical sudo");
        return Ok(());
      }
    }

    // 3. Terminal sudo fallback
    if let Ok(status) = Command::new("sudo")
      .args([install_cmd, install_arg, pkg])
      .status()
    {
      if status.success() {
        log::info!("Installed {pkg} with sudo");
        return Ok(());
      }
    }

    Err(format!("Failed to install {pkg} — all privilege escalation methods failed").into())
  }

  /// Try zenity then kdialog to get a password graphically.
  #[cfg(target_os = "linux")]
  fn get_password_graphically() -> Option<String> {
    // Try zenity
    if let Ok(output) = Command::new("zenity")
      .args([
        "--password",
        "--title=Authentication Required",
        "--text=Enter your password to install the update:",
      ])
      .output()
    {
      if output.status.success() {
        let pw = String::from_utf8_lossy(&output.stdout).trim().to_string();
        if !pw.is_empty() {
          return Some(pw);
        }
      }
    }

    // Fall back to kdialog
    if let Ok(output) = Command::new("kdialog")
      .args(["--password", "Enter your password to install the update:"])
      .output()
    {
      if output.status.success() {
        let pw = String::from_utf8_lossy(&output.stdout).trim().to_string();
        if !pw.is_empty() {
          return Some(pw);
        }
      }
    }

    None
  }

  /// Pipe a password to `sudo -S <install_cmd> <install_arg> <pkg>`.
  #[cfg(target_os = "linux")]
  fn install_with_sudo_stdin(
    pkg_path: &Path,
    password: &str,
    install_cmd: &str,
    install_arg: &str,
  ) -> bool {
    use std::io::Write;

    let child = Command::new("sudo")
      .args([
        "-S",
        install_cmd,
        install_arg,
        pkg_path.to_str().unwrap_or_default(),
      ])
      .stdin(std::process::Stdio::piped())
      .stdout(std::process::Stdio::piped())
      .stderr(std::process::Stdio::piped())
      .spawn();

    match child {
      Ok(mut child) => {
        if let Some(mut stdin) = child.stdin.take() {
          let _ = writeln!(stdin, "{password}");
        }
        child.wait().map(|s| s.success()).unwrap_or(false)
      }
      Err(_) => false,
    }
  }

  /// Install Linux AppImage
  #[cfg(target_os = "linux")]
  async fn install_linux_appimage(
    &self,
    appimage_path: &Path,
  ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    log::info!("Installing AppImage: {}", appimage_path.display());

    // This function should not be called for AppImages since we disable auto-updates for them
    // But if it somehow gets called, we'll handle it safely

    if !self.is_running_from_appimage() {
      return Err("AppImage installation attempted but not running from AppImage".into());
    }

    let current_exe = self.get_current_app_path()?;

    // Detect if we're running from an AppImage using multiple methods
    let current_appimage = if let Ok(appimage_env) = std::env::var("APPIMAGE") {
      PathBuf::from(appimage_env)
    } else {
      // Fallback: use current executable path
      current_exe.clone()
    };

    // Create backup
    let backup_path = current_appimage.with_extension("appimage.backup");
    if backup_path.exists() {
      fs::remove_file(&backup_path)?;
    }
    fs::copy(&current_appimage, &backup_path)?;

    // Make new AppImage executable
    let _ = Command::new("chmod")
      .args(["+x", appimage_path.to_str().unwrap()])
      .output();

    // Replace the AppImage
    fs::copy(appimage_path, &current_appimage)?;

    log::info!("AppImage replacement completed successfully");
    Ok(())
  }

  /// Install Linux tarball
  #[cfg(target_os = "linux")]
  async fn install_linux_tarball(
    &self,
    tarball_path: &Path,
  ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    log::info!("Installing tarball: {}", tarball_path.display());

    let current_exe = self.get_current_app_path()?;
    let temp_extract_dir = tarball_path.parent().unwrap().join("extracted");
    fs::create_dir_all(&temp_extract_dir)?;

    // Extract tarball
    let extracted_path = self
      .extractor
      .extract_tar_gz(tarball_path, &temp_extract_dir, None)
      .await?;

    // Find the executable in the extracted files
    let current_exe_name = current_exe.file_name().unwrap();
    let new_exe_path =
      if extracted_path.is_file() && extracted_path.file_name() == Some(current_exe_name) {
        extracted_path
      } else {
        // Search in extracted directory
        let mut found_exe = None;
        if let Ok(entries) = fs::read_dir(&extracted_path) {
          for entry in entries.flatten() {
            let path = entry.path();
            if path.file_name() == Some(current_exe_name) {
              found_exe = Some(path);
              break;
            }
            // Also check subdirectories
            if path.is_dir() {
              if let Ok(sub_entries) = fs::read_dir(&path) {
                for sub_entry in sub_entries.flatten() {
                  let sub_path = sub_entry.path();
                  if sub_path.file_name() == Some(current_exe_name) {
                    found_exe = Some(sub_path);
                    break;
                  }
                }
              }
            }
          }
        }
        found_exe.ok_or("Could not find executable in tarball")?
      };

    // Create backup of current executable
    let backup_path = current_exe.with_extension("backup");
    if backup_path.exists() {
      fs::remove_file(&backup_path)?;
    }
    fs::copy(&current_exe, &backup_path)?;

    // Replace current executable
    fs::copy(&new_exe_path, &current_exe)?;

    // Make sure it's executable
    let _ = Command::new("chmod")
      .args(["+x", current_exe.to_str().unwrap()])
      .output();

    // Clean up
    let _ = fs::remove_dir_all(&temp_extract_dir);

    log::info!("Tarball installation completed successfully");
    Ok(())
  }

  /// Get the current application bundle path
  fn get_current_app_path(&self) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
    #[cfg(target_os = "macos")]
    {
      // Get the current executable path
      let exe_path = std::env::current_exe()?;

      // Navigate up to find the .app bundle
      let mut current = exe_path.as_path();
      while let Some(parent) = current.parent() {
        if parent.extension().is_some_and(|ext| ext == "app") {
          return Ok(parent.to_path_buf());
        }
        current = parent;
      }

      Err("Could not find application bundle".into())
    }

    #[cfg(target_os = "windows")]
    {
      // On Windows, just return the current executable path
      std::env::current_exe().map_err(|e| e.into())
    }

    #[cfg(target_os = "linux")]
    {
      // On Linux, return the current executable path
      std::env::current_exe().map_err(|e| e.into())
    }

    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
    {
      Err("Platform not supported".into())
    }
  }

  #[cfg(any(target_os = "windows", test))]
  async fn prepare_windows_installer() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let profiles = match crate::profile::ProfileManager::instance().list_profiles() {
      Ok(profiles) => profiles,
      Err(e) => {
        log::error!("Failed to inspect running profiles before app update: {e}");
        return Err(
          serde_json::json!({
            "code": "UPDATE_PREPARATION_FAILED"
          })
          .to_string()
          .into(),
        );
      }
    };

    let has_running_profiles = profiles.into_iter().any(|profile| {
      profile
        .process_id
        .is_some_and(|pid| pid != 0 && crate::proxy_storage::is_process_running(pid))
    });
    if has_running_profiles {
      return Err(
        serde_json::json!({
          "code": "UPDATE_PROFILES_RUNNING"
        })
        .to_string()
        .into(),
      );
    }

    let proxy_configs = crate::proxy_storage::list_proxy_configs();
    let vpn_configs = crate::vpn_worker_storage::list_vpn_worker_configs();
    let mut worker_pids: Vec<u32> = proxy_configs
      .iter()
      .filter_map(|config| config.pid)
      .chain(vpn_configs.iter().filter_map(|config| config.pid))
      .collect();
    worker_pids.sort_unstable();
    worker_pids.dedup();

    let proxy_ids: Vec<String> = proxy_configs.into_iter().map(|config| config.id).collect();
    let vpn_ids: Vec<String> = vpn_configs.into_iter().map(|config| config.id).collect();

    let stop_proxies = futures_util::future::join_all(proxy_ids.iter().map(|id| async move {
      crate::proxy_runner::stop_proxy_process(id)
        .await
        .map_err(|error| error.to_string())
    }));
    let stop_vpns = futures_util::future::join_all(vpn_ids.iter().map(|id| async move {
      crate::vpn_worker_runner::stop_vpn_worker(id)
        .await
        .map_err(|error| error.to_string())
    }));
    let (proxy_results, vpn_results) = tokio::join!(stop_proxies, stop_vpns);

    for result in proxy_results.into_iter().chain(vpn_results) {
      if let Err(e) = result {
        log::warn!("Failed to stop a network worker before app update: {e}");
      }
    }

    for _ in 0..20 {
      if worker_pids
        .iter()
        .all(|pid| !crate::proxy_storage::is_process_running(*pid))
      {
        return Ok(());
      }
      tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
    }

    let remaining: Vec<u32> = worker_pids
      .into_iter()
      .filter(|pid| crate::proxy_storage::is_process_running(*pid))
      .collect();
    log::error!(
      "App update aborted because donut-proxy worker PIDs are still running: {:?}",
      remaining
    );
    Err(
      serde_json::json!({
        "code": "UPDATE_PREPARATION_FAILED"
      })
      .to_string()
      .into(),
    )
  }

  /// Restart the application
  async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    #[cfg(target_os = "macos")]
    {
      let app_path = self.get_current_app_path()?;
      let current_pid = std::process::id();

      // Create a temporary restart script
      let temp_dir = std::env::temp_dir();
      let script_path = temp_dir.join("donut_restart.sh");

      // Create the restart script content
      let script_content = format!(
        r#"#!/bin/sh
# Wait for the current process to exit
while kill -0 {} 2>/dev/null; do
  sleep 0.5
done

# Wait a bit more to ensure clean exit
sleep 1

# Start the new application
open "{}"

# Clean up this script
rm "{}"
"#,
        current_pid,
        app_path.to_str().unwrap(),
        script_path.to_str().unwrap()
      );

      // Write the script to file
      fs::write(&script_path, script_content)?;

      // Make the script executable
      let _ = Command::new("chmod")
        .args(["+x", script_path.to_str().unwrap()])
        .output();

      // Execute the restart script in the background
      let mut cmd = Command::new("sh");
      cmd.arg(script_path.to_str().unwrap());

      // Detach the process completely
      use std::os::unix::process::CommandExt;
      cmd.process_group(0);

      let _child = cmd.spawn()?;

      // Give the script a moment to start
      tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;

      // Exit the current process
      std::process::exit(0);
    }

    #[cfg(target_os = "windows")]
    {
      use std::ffi::OsStr;
      use std::os::windows::ffi::OsStrExt;

      let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();

      if let Some(installer_path) = pending {
        if let Err(e) = Self::prepare_windows_installer().await {
          *PENDING_INSTALLER_PATH.lock().unwrap() = Some(installer_path);
          return Err(e);
        }

        // Use ShellExecuteW to run the installer directly — no batch script,
        // no cmd.exe console window. The NSIS/MSI installer handles killing the
        // old process and restarting the app natively (via /UPDATE and
        // AUTOLAUNCHAPP flags).
        let ext = installer_path
          .extension()
          .and_then(|e| e.to_str())
          .unwrap_or("")
          .to_lowercase();

        let (file, parameters) = match ext.as_str() {
          "exe" => {
            // NSIS installer: /S for silent, /UPDATE tells it this is an update
            let file = installer_path.as_os_str().to_os_string();
            let params = std::ffi::OsString::from("/S /UPDATE");
            (file, params)
          }
          "msi" => {
            // MSI: run msiexec.exe with the package
            let msiexec = std::env::var("SYSTEMROOT")
              .map(|p| format!("{p}\\System32\\msiexec.exe"))
              .unwrap_or_else(|_| "msiexec.exe".to_string());
            let file = std::ffi::OsString::from(msiexec);
            let params = std::ffi::OsString::from(format!(
              "/i {} /quiet /norestart /promptrestart AUTOLAUNCHAPP=True",
              installer_path
                .to_str()
                .map(|p| format!("\"{p}\""))
                .unwrap_or_default()
            ));
            (file, params)
          }
          _ => {
            return Err("Unsupported Windows installer format for restart".into());
          }
        };

        fn encode_wide(s: impl AsRef<OsStr>) -> Vec<u16> {
          s.as_ref().encode_wide().chain(std::iter::once(0)).collect()
        }

        let file_w = encode_wide(&file);
        let params_w = encode_wide(&parameters);

        log::info!(
          "Running installer via ShellExecuteW: {:?} {:?}",
          file,
          parameters
        );

        // windows-sys is not a direct dep, so use the raw FFI via the
        // windows crate that Tauri pulls in. ShellExecuteW returns an
        // HINSTANCE > 32 on success.
        #[link(name = "shell32")]
        extern "system" {
          fn ShellExecuteW(
            hwnd: *mut std::ffi::c_void,
            operation: *const u16,
            file: *const u16,
            parameters: *const u16,
            directory: *const u16,
            show_cmd: i32,
          ) -> isize;
        }
        const SW_SHOWNORMAL: i32 = 1;
        let open: Vec<u16> = "open\0".encode_utf16().collect();

        let result = unsafe {
          ShellExecuteW(
            std::ptr::null_mut(),
            open.as_ptr(),
            file_w.as_ptr(),
            params_w.as_ptr(),
            std::ptr::null(),
            SW_SHOWNORMAL,
          )
        };

        if result as usize <= 32 {
          return Err(format!("ShellExecuteW failed with code {result}").into());
        }
      } else {
        // No pending installer — just restart the app. Use a minimal
        // detached process to relaunch after we exit.
        let app_path = self.get_current_app_path()?;
        let current_pid = std::process::id();
        let temp_dir = std::env::temp_dir();
        let script_path = temp_dir.join("donut_restart.bat");

        let script_content = format!(
          "@echo off\n\
           :w\n\
           tasklist /fi \"PID eq {current_pid}\" 2>nul | find \"{current_pid}\" >nul && (timeout /t 1 /nobreak >nul & goto w)\n\
           timeout /t 1 /nobreak >nul\n\
           start \"\" \"{app}\"\n\
           del \"%~f0\"\n",
          app = app_path.to_str().unwrap(),
        );
        fs::write(&script_path, script_content)?;

        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x08000000;
        let _child = Command::new("cmd")
          .args(["/C", script_path.to_str().unwrap()])
          .creation_flags(CREATE_NO_WINDOW)
          .spawn()?;
      }

      tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
      std::process::exit(0);
    }

    #[cfg(target_os = "linux")]
    {
      let app_path = self.get_current_app_path()?;
      let current_pid = std::process::id();

      // Create a temporary restart script
      let temp_dir = std::env::temp_dir();
      let script_path = temp_dir.join("donut_restart.sh");

      // Create the restart script content
      let script_content = format!(
        r#"#!/bin/sh
# Wait for the current process to exit
while kill -0 {} 2>/dev/null; do
  sleep 0.5
done

# Wait a bit more to ensure clean exit
sleep 1

# Start the new application
"{}" &

# Clean up this script
rm "{}"
"#,
        current_pid,
        app_path.to_str().unwrap(),
        script_path.to_str().unwrap()
      );

      // Write the script to file
      fs::write(&script_path, script_content)?;

      // Make the script executable
      let _ = Command::new("chmod")
        .args(["+x", script_path.to_str().unwrap()])
        .output();

      // Execute the restart script in the background
      let mut cmd = Command::new("sh");
      cmd.arg(script_path.to_str().unwrap());

      // Detach the process completely
      use std::os::unix::process::CommandExt;
      cmd.process_group(0);

      let _child = cmd.spawn()?;

      // Give the script a moment to start
      tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;

      // Exit the current process
      std::process::exit(0);
    }

    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
    {
      Err("Application restart not supported on this platform".into())
    }
  }
}

// Tauri commands

#[tauri::command]
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
  #[cfg(feature = "e2e")]
  if crate::e2e_automation_enabled()
    && std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
  {
    log::info!("E2E: skipping automatic app update check");
    return Ok(None);
  }

  if crate::app_dirs::is_portable() {
    log::info!("App auto-updates disabled in portable mode");
    return Ok(None);
  }
  // The disable_auto_updates setting controls app self-updates only
  let disabled = crate::settings_manager::SettingsManager::instance()
    .load_settings()
    .map(|s| s.disable_auto_updates)
    .unwrap_or(false);
  if disabled {
    log::info!("App auto-updates disabled by user setting");
    return Ok(None);
  }

  let updater = AppAutoUpdater::instance();
  updater
    .check_for_updates()
    .await
    .map_err(|e| format!("Failed to check for app updates: {e}"))
}

#[tauri::command]
pub async fn download_and_prepare_app_update(
  app_handle: tauri::AppHandle,
  update_info: AppUpdateInfo,
) -> Result<(), String> {
  let updater = AppAutoUpdater::instance();
  updater
    .download_and_prepare_update(&app_handle, &update_info)
    .await
    .map_err(|e| {
      let msg = e.to_string();
      // Structured error codes (`{"code": ...}`) must reach the frontend
      // unwrapped so translateBackendError can resolve them.
      if msg.starts_with('{') {
        msg
      } else {
        format!("Failed to download and prepare app update: {msg}")
      }
    })
}

#[tauri::command]
pub async fn restart_application() -> Result<(), String> {
  let updater = AppAutoUpdater::instance();
  updater
    .restart_application()
    .await
    .map_err(|e| crate::wrap_backend_error(e, "Failed to restart application"))
}

#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
  #[cfg(feature = "e2e")]
  if crate::e2e_automation_enabled()
    && std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
  {
    log::info!("E2E: skipping manual app update check");
    return Ok(None);
  }

  log::info!("Manual app update check triggered");
  let updater = AppAutoUpdater::instance();
  updater
    .check_for_updates()
    .await
    .map_err(|e| format!("Failed to check for app updates: {e}"))
}

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

  #[test]
  fn test_is_nightly_build() {
    // This will depend on whether STABLE_RELEASE is set during test compilation
    let is_nightly = AppAutoUpdater::is_nightly_build();
    log::info!("Is nightly build: {is_nightly}");

    // The result should be true for test builds since STABLE_RELEASE is not set
    // unless the test is run in a stable release environment
    assert!(is_nightly || option_env!("STABLE_RELEASE").is_some());
  }

  #[test]
  fn test_version_comparison() {
    let updater = AppAutoUpdater::instance();

    // Test semantic version comparison
    assert!(updater.is_version_newer("v1.1.0", "v1.0.0"));
    assert!(updater.is_version_newer("v2.0.0", "v1.9.9"));
    assert!(updater.is_version_newer("v1.0.1", "v1.0.0"));
    assert!(!updater.is_version_newer("v1.0.0", "v1.0.0"));
    assert!(!updater.is_version_newer("v1.0.0", "v1.0.1"));
  }

  #[test]
  fn test_parse_semver() {
    let updater = AppAutoUpdater::instance();

    assert_eq!(updater.parse_semver("v1.2.3"), (1, 2, 3));
    assert_eq!(updater.parse_semver("1.2.3"), (1, 2, 3));
    assert_eq!(updater.parse_semver("v2.0.0"), (2, 0, 0));
    assert_eq!(updater.parse_semver("0.1.0"), (0, 1, 0));
  }

  #[test]
  fn test_should_update_stable() {
    let updater = AppAutoUpdater::instance();

    // Stable version updates
    assert!(updater.should_update("v1.0.0", "v1.1.0", false));
    assert!(updater.should_update("v1.0.0", "v2.0.0", false));
    assert!(!updater.should_update("v1.1.0", "v1.0.0", false));
    assert!(!updater.should_update("v1.0.0", "v1.0.0", false));
  }

  #[test]
  fn test_should_update_nightly() {
    let updater = AppAutoUpdater::instance();

    // Nightly version updates
    assert!(updater.should_update("nightly-abc123", "nightly-def456", true));
    assert!(!updater.should_update("nightly-abc123", "nightly-abc123", true));

    // Upgrade from stable to nightly
    assert!(updater.should_update("v1.0.0", "nightly-abc123", true));

    // Don't upgrade dev, ever
    assert!(!updater.should_update("dev-0.1.0", "nightly-xyz987", false));
    assert!(!updater.should_update("dev-0.1.0", "nightly-xyz987", true));
    assert!(!updater.should_update("dev-0.1.0", "v1.2.3", false));
  }

  #[test]
  fn test_should_update_edge_cases() {
    let updater = AppAutoUpdater::instance();

    // Test with different nightly formats
    assert!(updater.should_update("nightly-abc123", "nightly-def456", true));
    assert!(!updater.should_update("nightly-abc123", "nightly-abc123", true));

    // Test stable version edge cases
    assert!(updater.should_update("v0.9.9", "v1.0.0", false));
    assert!(!updater.should_update("v1.0.0", "v0.9.9", false));
    assert!(!updater.should_update("v1.0.0", "v1.0.0", false));

    // Test version without 'v' prefix
    assert!(updater.should_update("0.9.9", "v1.0.0", false));
    assert!(updater.should_update("v0.9.9", "1.0.0", false));
  }

  #[test]
  fn test_extract_update_uses_extractor() {
    // This test verifies that the extract_update method properly uses the Extractor
    // We can't run the actual extraction in unit tests without real DMG files,
    // but we can verify the method signature and basic logic
    let updater = AppAutoUpdater::instance();

    // Test that unsupported formats would be rejected
    let temp_dir = std::env::temp_dir();
    let unsupported_file = temp_dir.join("test.rar");

    // Create a mock runtime to test the logic
    let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");

    // This would fail because .rar is not supported, which proves
    // our method is using the Extractor logic
    let result = rt.block_on(async { updater.extract_update(&unsupported_file, &temp_dir).await });

    // Should fail with unsupported format error
    assert!(result.is_err(), "Unsupported format should return error");
    let error_msg = result.expect_err("Should have error").to_string();
    assert!(
      error_msg.contains("Unsupported archive format: rar")
        || error_msg.contains("unknown")
        || error_msg.contains("unsupported"),
      "Error should mention unsupported format, got: {error_msg}"
    );
  }

  #[test]
  fn test_find_checksum_for_file() {
    let sums = "\
0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11  Donut_0.29.0_aarch64.dmg
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg
not-a-hash  Donut_0.29.0_amd64.deb
";

    // Plain entry.
    assert_eq!(
      AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(),
      Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11")
    );
    // Binary-mode marker is stripped; hash is normalized to lowercase.
    assert_eq!(
      AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(),
      Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22")
    );
    // Entries with malformed hashes are rejected rather than trusted.
    assert_eq!(
      AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"),
      None
    );
    // Missing file.
    assert_eq!(
      AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"),
      None
    );
  }

  #[test]
  fn test_sha256_file_matches_known_digest() {
    let temp_dir = tempfile::TempDir::new().unwrap();
    let path = temp_dir.path().join("data.bin");
    std::fs::write(&path, b"hello world").unwrap();
    assert_eq!(
      AppAutoUpdater::sha256_file(&path).unwrap(),
      // sha256 of "hello world"
      "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
    );
  }

  #[test]
  fn test_find_checksums_url() {
    let assets = vec![
      AppReleaseAsset {
        name: "Donut_0.29.0_x64.dmg".to_string(),
        browser_download_url: "https://example.com/x64.dmg".to_string(),
        size: 1,
        digest: None,
      },
      AppReleaseAsset {
        name: "SHA256SUMS.txt".to_string(),
        browser_download_url: "https://example.com/SHA256SUMS.txt".to_string(),
        size: 1,
        digest: None,
      },
    ];
    assert_eq!(
      AppAutoUpdater::find_checksums_url(&assets).as_deref(),
      Some("https://example.com/SHA256SUMS.txt")
    );
    assert_eq!(AppAutoUpdater::find_checksums_url(&assets[..1]), None);
  }

  #[test]
  fn test_release_asset_digest_is_optional_in_api_json() {
    // Assets uploaded before GitHub started computing digests omit the field.
    let without: AppReleaseAsset = serde_json::from_str(
      r#"{"name": "a.dmg", "browser_download_url": "https://example.com/a.dmg", "size": 5}"#,
    )
    .expect("asset without digest should deserialize");
    assert_eq!(without.digest, None);

    let with: AppReleaseAsset = serde_json::from_str(
      r#"{"name": "a.dmg", "browser_download_url": "https://example.com/a.dmg", "size": 5, "digest": "sha256:ab12"}"#,
    )
    .expect("asset with digest should deserialize");
    assert_eq!(with.digest.as_deref(), Some("sha256:ab12"));
  }

  #[test]
  fn test_windows_installer_hook_protects_sidecar_replacement() {
    let _ = AppAutoUpdater::prepare_windows_installer;

    let config: serde_json::Value =
      serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
    assert_eq!(
      config["bundle"]["windows"]["nsis"]["installerHooks"].as_str(),
      Some("installer-hooks.nsh")
    );

    let hooks = include_str!("../installer-hooks.nsh");
    assert!(hooks.contains("NSIS_HOOK_PREINSTALL"));
    assert!(hooks.contains("IfFileExists \"$INSTDIR\\donut-proxy.exe\""));
    assert!(hooks.contains("taskkill.exe"));
    assert!(hooks.contains("donut-proxy.exe"));
    assert!(hooks.contains("Delete \"$INSTDIR\\donut-proxy.exe\""));
  }

  #[test]
  fn test_windows_installer_preparation_future_is_send() {
    fn assert_send<T: Send>(_: T) {}

    assert_send(AppAutoUpdater::prepare_windows_installer());
  }

  #[test]
  fn test_platform_specific_download_urls() {
    let updater = AppAutoUpdater::instance();

    // Create comprehensive assets for all platforms
    let all_assets = vec![
      // macOS assets
      AppReleaseAsset {
        name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
        browser_download_url: "https://example.com/aarch64.dmg".to_string(),
        size: 12345,
        digest: None,
      },
      AppReleaseAsset {
        name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
        browser_download_url: "https://example.com/x64.dmg".to_string(),
        size: 12345,
        digest: None,
      },
      // Windows assets (NSIS naming: _ARCH-setup.exe)
      AppReleaseAsset {
        name: "Donut_0.1.0_x64-setup.exe".to_string(),
        browser_download_url: "https://example.com/x64-setup.exe".to_string(),
        size: 12345,
        digest: None,
      },
      // Linux assets
      AppReleaseAsset {
        name: "donutbrowser_0.1.0_amd64.deb".to_string(),
        browser_download_url: "https://example.com/amd64.deb".to_string(),
        size: 12345,
        digest: None,
      },
      AppReleaseAsset {
        name: "donutbrowser-0.1.0-1.x86_64.rpm".to_string(),
        browser_download_url: "https://example.com/x86_64.rpm".to_string(),
        size: 12345,
        digest: None,
      },
      AppReleaseAsset {
        name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
        browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
        size: 12345,
        digest: None,
      },
    ];

    // Test that the method returns a URL for the current platform
    let url = updater.get_download_url_for_platform(&all_assets);
    assert!(
      url.is_some(),
      "Should find a suitable download URL for current platform"
    );

    // Test platform-specific behavior
    #[cfg(target_os = "macos")]
    {
      let url = url.unwrap();
      assert!(url.contains(".dmg"), "macOS should prefer DMG files");
    }

    #[cfg(target_os = "windows")]
    {
      let url = url.unwrap();
      assert!(
        url.contains(".msi") || url.contains(".exe") || url.contains(".zip"),
        "Windows should prefer MSI, EXE, or ZIP files"
      );
    }

    #[cfg(target_os = "linux")]
    {
      let url = url.unwrap();
      assert!(
        url.contains(".deb")
          || url.contains(".rpm")
          || url.contains(".appimage")
          || url.contains(".tar.gz"),
        "Linux should prefer DEB, RPM, AppImage, or TAR.GZ files"
      );
    }
  }

  #[test]
  fn test_supported_file_extensions() {
    let updater = AppAutoUpdater::instance();
    let temp_dir = std::env::temp_dir();
    let rt = tokio::runtime::Runtime::new().unwrap();

    // Test that all supported extensions are handled
    let supported_extensions = ["dmg", "msi", "exe", "deb", "rpm", "appimage", "zip"];

    for ext in &supported_extensions {
      let test_file = temp_dir.join(format!("test.{ext}"));
      let result = rt.block_on(async { updater.extract_update(&test_file, &temp_dir).await });

      // The result should either succeed or fail with a platform-specific error,
      // but not with "Unsupported archive format"
      if let Err(e) = result {
        let error_msg = e.to_string();
        assert!(
          !error_msg.contains("Unsupported archive format"),
          "Extension {ext} should be supported but got: {error_msg}"
        );
      }
    }

    // Test tar.gz compound extension
    let tar_gz_file = temp_dir.join("test.tar.gz");
    let result = rt.block_on(async { updater.extract_update(&tar_gz_file, &temp_dir).await });

    if let Err(e) = result {
      let error_msg = e.to_string();
      assert!(
        !error_msg.contains("Unsupported archive format"),
        "tar.gz should be supported but got: {error_msg}"
      );
    }
  }

  #[cfg(target_os = "linux")]
  #[test]
  fn test_appimage_detection() {
    let updater = AppAutoUpdater::instance();

    // Test that AppImage detection works with various scenarios
    // Note: These tests can't fully simulate AppImage environment without actual AppImage

    // Test that the method exists and doesn't panic
    let _is_appimage = updater.is_running_from_appimage();

    // Test installation method detection
    let _method = updater.detect_linux_installation_method();
  }

  #[cfg(target_os = "linux")]
  #[test]
  fn test_appimage_auto_update_disabled() {
    let updater = AppAutoUpdater::instance();

    // Create mock assets including AppImage
    let assets = vec![
      AppReleaseAsset {
        name: "donutbrowser_0.1.0_amd64.deb".to_string(),
        browser_download_url: "https://example.com/amd64.deb".to_string(),
        size: 12345,
        digest: None,
      },
      AppReleaseAsset {
        name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
        browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
        size: 12345,
        digest: None,
      },
    ];

    // If we're running from AppImage, should return None (disabled)
    // If not, should return a suitable download URL
    let url = updater.get_download_url_for_platform(&assets);

    // The test should pass regardless of whether we're in AppImage or not
    // If in AppImage: url should be None
    // If not in AppImage: url should be Some(...)
    if updater.is_running_from_appimage() {
      assert!(
        url.is_none(),
        "Auto-updates should be disabled for AppImages"
      );
    } else {
      // Should find a suitable non-AppImage download
      if let Some(url_str) = url {
        assert!(
          !url_str.contains("AppImage"),
          "Should not select AppImage when not running from AppImage"
        );
      }
    }
  }

  #[test]
  fn test_appimage_detection_logic() {
    let updater = AppAutoUpdater::instance();

    // Test that the get_download_url_for_platform method properly handles AppImage detection
    // This test can run on all platforms

    // Create comprehensive assets for all platforms including AppImage
    let all_assets = vec![
      // macOS assets
      AppReleaseAsset {
        name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
        browser_download_url: "https://example.com/aarch64.dmg".to_string(),
        size: 12345,
        digest: None,
      },
      // Windows assets
      AppReleaseAsset {
        name: "Donut.Browser_0.1.0_x64.msi".to_string(),
        browser_download_url: "https://example.com/x64.msi".to_string(),
        size: 12345,
        digest: None,
      },
      // Linux assets
      AppReleaseAsset {
        name: "donutbrowser_0.1.0_amd64.deb".to_string(),
        browser_download_url: "https://example.com/amd64.deb".to_string(),
        size: 12345,
        digest: None,
      },
      AppReleaseAsset {
        name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
        browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
        size: 12345,
        digest: None,
      },
    ];

    // Test that the method returns a URL for the current platform
    let url = updater.get_download_url_for_platform(&all_assets);

    // On non-Linux platforms, should always return a URL
    #[cfg(not(target_os = "linux"))]
    {
      assert!(
        url.is_some(),
        "Should find a suitable download URL for non-Linux platforms"
      );
      let url_str = url.unwrap();
      assert!(
        !url_str.contains("AppImage"),
        "Non-Linux platforms should not get AppImage downloads"
      );
    }

    // On Linux platforms, behavior depends on AppImage detection
    #[cfg(target_os = "linux")]
    {
      // The URL might be None if AppImage is detected, or Some if not
      // This is expected behavior based on our implementation
      if let Some(url_str) = url {
        // If we get a URL, it should not be an AppImage
        assert!(
          !url_str.contains("AppImage"),
          "Should not select AppImage format"
        );
      }
      // If url is None, it means AppImage was detected and auto-updates are disabled
    }
  }

  #[test]
  #[cfg(target_os = "linux")]
  fn test_repo_detection_returns_bool() {
    // These just verify the functions run without panicking.
    // Actual values depend on the host system configuration.
    let _deb = AppAutoUpdater::is_deb_repo_configured();
    let _rpm = AppAutoUpdater::is_rpm_repo_configured();
  }
}

// Global singleton instance
lazy_static::lazy_static! {
  static ref APP_AUTO_UPDATER: AppAutoUpdater = AppAutoUpdater::new();
  static ref PENDING_INSTALLER_PATH: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
}
