use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs::{self, create_dir_all, File};
use std::io;
use std::path::{Path, PathBuf};

use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
use crate::profile::ProfileManager;
use crate::profile_import::report::ProfileImportReport;
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;

/// Prefix for temp directories that hold extracted profile archives. Cleanup
/// refuses to delete anything outside the system temp dir with this prefix.
const IMPORT_SCRATCH_PREFIX: &str = "donutbrowser-profile-import-";

#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct DetectedProfile {
  pub browser: String,
  pub mapped_browser: String,
  pub name: String,
  pub path: String,
  pub description: String,
}

#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct ImportProfileItem {
  pub source_path: String,
  /// The source browser family (`chromium`, `brave`, `edge`, …). Load-bearing:
  /// it selects which Keychain / secret-service item holds the key that
  /// unlocks the source's cookies and passwords.
  #[serde(default = "default_import_browser_type")]
  pub browser_type: String,
  pub new_profile_name: String,
  #[serde(default)]
  pub proxy_id: Option<String>,
  #[serde(default)]
  pub vpn_id: Option<String>,
  /// Import even though the source browser is running. Databases are still
  /// snapshotted consistently, but LevelDB site data may be mid-write.
  #[serde(default)]
  pub allow_running: Option<bool>,
}

fn default_import_browser_type() -> String {
  "chromium".to_string()
}

#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum DuplicateStrategy {
  /// Skip items whose requested name is already taken.
  Skip,
  /// Auto-suffix the requested name (`Name (2)`, `Name (3)`, …) until free.
  #[default]
  Rename,
}

#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct ProfileImportItemResult {
  /// Final profile name (after any duplicate-rename).
  pub name: String,
  pub source_path: String,
  /// "imported" | "skipped" | "failed"
  pub status: String,
  pub profile_id: Option<String>,
  /// Structured `{"code": …}` error string when status is "failed".
  pub error: Option<String>,
  /// What actually came across. Present when status is "imported".
  pub report: Option<ProfileImportReport>,
}

#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct ProfileImportBatchResult {
  pub imported_count: usize,
  pub skipped_count: usize,
  pub failed_count: usize,
  pub results: Vec<ProfileImportItemResult>,
}

#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct ArchiveScanResult {
  /// Temp directory the archive was extracted into. Pass back to
  /// `cleanup_profile_import_scratch` once the import is done.
  pub extracted_dir: String,
  pub profiles: Vec<DetectedProfile>,
}

#[derive(Debug, Serialize, Clone)]
struct ProfileImportProgress {
  total: usize,
  completed: usize,
  index: usize,
  name: String,
  /// "importing" | "imported" | "skipped" | "failed"
  status: String,
}

fn map_browser_type(_browser: &str) -> &str {
  // Every import source maps to Wayfern — the only launchable engine.
  "wayfern"
}

/// Convert an importer error into the structured `{"code": …}` string the
/// frontend translates. Errors that are already structured pass through.
pub fn error_to_code_string(e: Box<dyn std::error::Error>) -> String {
  let msg = e.to_string();
  if msg.starts_with('{') {
    msg
  } else {
    serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": msg } }).to_string()
  }
}

/// Resolve a requested profile name against the set of taken (lowercased)
/// names by appending ` (2)`, ` (3)`, … . The chosen name is added to `taken`.
fn resolve_duplicate_name(requested: &str, taken: &mut HashSet<String>) -> String {
  if taken.insert(requested.to_lowercase()) {
    return requested.to_string();
  }
  let mut n = 2usize;
  loop {
    let candidate = format!("{requested} ({n})");
    if taken.insert(candidate.to_lowercase()) {
      return candidate;
    }
    n += 1;
  }
}

fn emit_import_progress(total: usize, completed: usize, index: usize, name: &str, status: &str) {
  let _ = events::emit(
    "profile-import-progress",
    &ProfileImportProgress {
      total,
      completed,
      index,
      name: name.to_string(),
      status: status.to_string(),
    },
  );
}

/// A known Chromium-family browser install location.
struct BrowserSource {
  key: &'static str,
  dir: PathBuf,
  /// Opera-style quirk: the profile (Preferences, Cookies, …) lives at the
  /// root of the config dir instead of under `Default/`.
  root_profile: bool,
}

pub struct ProfileImporter {
  base_dirs: BaseDirs,
  downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
  profile_manager: &'static ProfileManager,
  wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
}

impl ProfileImporter {
  fn new() -> Self {
    Self {
      base_dirs: BaseDirs::new().expect("Failed to get base directories"),
      downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
      profile_manager: ProfileManager::instance(),
      wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
    }
  }

  pub fn instance() -> &'static ProfileImporter {
    &PROFILE_IMPORTER
  }

  pub fn detect_existing_profiles(
    &self,
  ) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
    let mut detected_profiles = Vec::new();

    // Only Chromium-based sources (mapping to Wayfern) are detected. Gecko-family
    // sources mapped to Camoufox, which was removed, so they can no longer be
    // imported.
    for source in self.browser_sources() {
      if source.root_profile {
        // Opera-style layout: the user-data root itself is the profile.
        if source.dir.join("Preferences").exists() {
          detected_profiles.push(DetectedProfile {
            browser: source.key.to_string(),
            mapped_browser: map_browser_type(source.key).to_string(),
            name: format!(
              "{} - Default Profile",
              self.get_browser_display_name(source.key)
            ),
            path: source.dir.to_string_lossy().to_string(),
            description: "Default profile".to_string(),
          });
        }
        // Newer Opera builds keep extra profiles under _side_profiles/.
        let side = source.dir.join("_side_profiles");
        if let Ok(entries) = fs::read_dir(&side) {
          for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() && path.join("Preferences").exists() {
              let dir_name = path
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("profile")
                .to_string();
              detected_profiles.push(DetectedProfile {
                browser: source.key.to_string(),
                mapped_browser: map_browser_type(source.key).to_string(),
                name: format!(
                  "{} - {}",
                  self.get_browser_display_name(source.key),
                  dir_name
                ),
                path: path.to_string_lossy().to_string(),
                description: format!("Side profile {dir_name}"),
              });
            }
          }
        }
      } else {
        detected_profiles.extend(self.scan_chrome_profiles_dir(&source.dir, source.key)?);
      }
    }

    let mut seen_paths = HashSet::new();
    let unique_profiles: Vec<DetectedProfile> = detected_profiles
      .into_iter()
      .filter(|profile| seen_paths.insert(profile.path.clone()))
      .collect();

    Ok(unique_profiles)
  }

  /// Every Chromium-family browser we know how to find, with its per-OS
  /// user-data directory. `root_profile` marks the Opera-style quirk where the
  /// profile lives at the root of the config dir instead of `Default/`.
  fn browser_sources(&self) -> Vec<BrowserSource> {
    let mut sources: Vec<BrowserSource> = Vec::new();

    #[cfg(target_os = "macos")]
    {
      let support = self
        .base_dirs
        .home_dir()
        .join("Library/Application Support");
      let standard: &[(&str, &str)] = &[
        ("chromium", "Google/Chrome"),
        ("chrome-beta", "Google/Chrome Beta"),
        ("chrome-dev", "Google/Chrome Dev"),
        ("chrome-canary", "Google/Chrome Canary"),
        ("chromium", "Chromium"),
        ("brave", "BraveSoftware/Brave-Browser"),
        ("brave-beta", "BraveSoftware/Brave-Browser-Beta"),
        ("brave-nightly", "BraveSoftware/Brave-Browser-Nightly"),
        ("edge", "Microsoft Edge"),
        ("edge-beta", "Microsoft Edge Beta"),
        ("edge-dev", "Microsoft Edge Dev"),
        ("vivaldi", "Vivaldi"),
        // Arc nests its user-data dir one level down, unlike Chrome.
        ("arc", "Arc/User Data"),
        ("yandex", "Yandex/YandexBrowser"),
      ];
      for (key, rel) in standard {
        sources.push(BrowserSource {
          key,
          dir: support.join(rel),
          root_profile: false,
        });
      }
      for (key, rel) in &[
        ("opera", "com.operasoftware.Opera"),
        ("opera-gx", "com.operasoftware.OperaGX"),
      ] {
        sources.push(BrowserSource {
          key,
          dir: support.join(rel),
          root_profile: true,
        });
      }
    }

    #[cfg(target_os = "windows")]
    {
      let local = self.base_dirs.data_local_dir().to_path_buf();
      let standard: &[(&str, &str)] = &[
        ("chromium", "Google/Chrome/User Data"),
        ("chrome-beta", "Google/Chrome Beta/User Data"),
        ("chrome-dev", "Google/Chrome Dev/User Data"),
        // Canary installs as "Chrome SxS" (side-by-side), not "Chrome Canary".
        ("chrome-canary", "Google/Chrome SxS/User Data"),
        ("chromium", "Chromium/User Data"),
        ("brave", "BraveSoftware/Brave-Browser/User Data"),
        ("brave-beta", "BraveSoftware/Brave-Browser-Beta/User Data"),
        (
          "brave-nightly",
          "BraveSoftware/Brave-Browser-Nightly/User Data",
        ),
        ("edge", "Microsoft/Edge/User Data"),
        ("edge-beta", "Microsoft/Edge Beta/User Data"),
        ("edge-dev", "Microsoft/Edge Dev/User Data"),
        ("vivaldi", "Vivaldi/User Data"),
        ("yandex", "Yandex/YandexBrowser/User Data"),
      ];
      for (key, rel) in standard {
        sources.push(BrowserSource {
          key,
          dir: local.join(rel),
          root_profile: false,
        });
      }
      // Opera keeps the profile under %APPDATA% (Roaming), not %LOCALAPPDATA%.
      let roaming = self.base_dirs.data_dir().to_path_buf();
      for (key, rel) in &[
        ("opera", "Opera Software/Opera Stable"),
        ("opera-gx", "Opera Software/Opera GX Stable"),
      ] {
        sources.push(BrowserSource {
          key,
          dir: roaming.join(rel),
          root_profile: true,
        });
      }
      // Arc on Windows is MSIX-packaged; the package-family suffix can vary,
      // so glob Packages/TheBrowserCompany.Arc_*.
      let packages = local.join("Packages");
      if let Ok(entries) = fs::read_dir(&packages) {
        for entry in entries.flatten() {
          let name = entry.file_name();
          let Some(name) = name.to_str() else { continue };
          if name.starts_with("TheBrowserCompany.Arc_") {
            sources.push(BrowserSource {
              key: "arc",
              dir: entry.path().join("LocalCache/Local/Arc/User Data"),
              root_profile: false,
            });
          }
        }
      }
    }

    #[cfg(target_os = "linux")]
    {
      let home = self.base_dirs.home_dir().to_path_buf();
      let config = home.join(".config");
      let standard: &[(&str, &str)] = &[
        ("chromium", "google-chrome"),
        ("chrome-beta", "google-chrome-beta"),
        // The Linux Dev channel dir is google-chrome-unstable.
        ("chrome-dev", "google-chrome-unstable"),
        ("chromium", "chromium"),
        ("brave", "BraveSoftware/Brave-Browser"),
        ("brave-beta", "BraveSoftware/Brave-Browser-Beta"),
        ("brave-nightly", "BraveSoftware/Brave-Browser-Nightly"),
        ("edge", "microsoft-edge"),
        ("edge-beta", "microsoft-edge-beta"),
        ("edge-dev", "microsoft-edge-dev"),
        ("vivaldi", "vivaldi"),
        ("yandex", "yandex-browser"),
        // The long-standing Linux package ships as the beta channel.
        ("yandex", "yandex-browser-beta"),
      ];
      for (key, rel) in standard {
        sources.push(BrowserSource {
          key,
          dir: config.join(rel),
          root_profile: false,
        });
      }
      sources.push(BrowserSource {
        key: "opera",
        dir: config.join("opera"),
        root_profile: true,
      });
      // Distro-packaged Chromium fallbacks.
      sources.push(BrowserSource {
        key: "chromium",
        dir: home.join("snap/chromium/common/chromium"),
        root_profile: false,
      });
      sources.push(BrowserSource {
        key: "chromium",
        dir: home.join(".var/app/org.chromium.Chromium/config/chromium"),
        root_profile: false,
      });
    }

    sources
  }

  /// Scan an arbitrary folder for importable Chromium-family profiles.
  /// Handles three shapes: the folder itself is a profile (has `Preferences`),
  /// the folder is a user-data dir (`Default` / `Profile N` children), or the
  /// folder holds one profile directory per child (exported/migrated layouts,
  /// including one nested user-data dir per child).
  pub fn scan_folder(
    &self,
    folder: &Path,
  ) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
    if !folder.exists() || !folder.is_dir() {
      return Err(
        serde_json::json!({ "code": "IMPORT_SOURCE_NOT_FOUND" })
          .to_string()
          .into(),
      );
    }

    if folder.join("Preferences").exists() {
      let name = folder
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("Imported profile")
        .to_string();
      return Ok(vec![DetectedProfile {
        browser: "chromium".to_string(),
        mapped_browser: map_browser_type("chromium").to_string(),
        name,
        path: folder.to_string_lossy().to_string(),
        description: "Chromium profile".to_string(),
      }]);
    }

    let mut profiles = self.scan_chrome_profiles_dir(folder, "chromium")?;
    let mut seen: HashSet<String> = profiles.iter().map(|p| p.path.clone()).collect();

    if let Ok(entries) = fs::read_dir(folder) {
      for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() {
          continue;
        }
        let dir_name = path
          .file_name()
          .and_then(|n| n.to_str())
          .unwrap_or("")
          .to_string();

        if path.join("Preferences").exists() {
          let path_str = path.to_string_lossy().to_string();
          if seen.insert(path_str.clone()) {
            profiles.push(DetectedProfile {
              browser: "chromium".to_string(),
              mapped_browser: map_browser_type("chromium").to_string(),
              name: dir_name,
              path: path_str,
              description: "Chromium profile".to_string(),
            });
          }
        } else {
          for nested in self.scan_chrome_profiles_dir(&path, "chromium")? {
            if seen.insert(nested.path.clone()) {
              profiles.push(DetectedProfile {
                name: format!("{} - {}", dir_name, nested.description),
                ..nested
              });
            }
          }
        }
      }
    }

    Ok(profiles)
  }

  /// Extract a ZIP archive into a scratch temp dir and scan it for profiles.
  pub async fn extract_archive_and_scan(
    &self,
    archive_path: &str,
  ) -> Result<ArchiveScanResult, Box<dyn std::error::Error>> {
    let path = Path::new(archive_path);
    if !path.exists() {
      return Err(
        serde_json::json!({ "code": "IMPORT_SOURCE_NOT_FOUND" })
          .to_string()
          .into(),
      );
    }

    let extension = path
      .extension()
      .and_then(|e| e.to_str())
      .unwrap_or("")
      .to_lowercase();
    if extension != "zip" {
      return Err(
        serde_json::json!({ "code": "UNSUPPORTED_ARCHIVE_FORMAT" })
          .to_string()
          .into(),
      );
    }

    let dest =
      std::env::temp_dir().join(format!("{IMPORT_SCRATCH_PREFIX}{}", uuid::Uuid::new_v4()));
    let archive = path.to_path_buf();
    let dest_clone = dest.clone();
    tokio::task::spawn_blocking(move || Self::extract_zip_archive(&archive, &dest_clone))
      .await
      .map_err(|e| format!("Archive extraction task failed: {e}"))?
      .map_err(|e| {
        let _ = fs::remove_dir_all(&dest);
        serde_json::json!({ "code": "ARCHIVE_EXTRACTION_FAILED", "params": { "detail": e } })
          .to_string()
      })?;

    let profiles = self.scan_folder(&dest)?;
    Ok(ArchiveScanResult {
      extracted_dir: dest.to_string_lossy().to_string(),
      profiles,
    })
  }

  fn extract_zip_archive(zip_path: &Path, dest: &Path) -> Result<(), String> {
    create_dir_all(dest).map_err(|e| e.to_string())?;
    let file = File::open(zip_path)
      .map_err(|e| format!("Failed to open ZIP file {}: {}", zip_path.display(), e))?;
    let mut archive = zip::ZipArchive::new(io::BufReader::new(file))
      .map_err(|e| format!("Failed to read ZIP archive {}: {}", zip_path.display(), e))?;

    for i in 0..archive.len() {
      let mut entry = archive
        .by_index(i)
        .map_err(|e| format!("Failed to read ZIP entry at index {i}: {e}"))?;

      // enclosed_name prevents path traversal via ../ entries.
      let enclosed = entry
        .enclosed_name()
        .ok_or_else(|| format!("ZIP contains an invalid entry path: {}", entry.name()))?;
      let outpath = dest.join(enclosed);

      if entry.is_dir() {
        create_dir_all(&outpath).map_err(|e| e.to_string())?;
      } else {
        if let Some(parent) = outpath.parent() {
          create_dir_all(parent).map_err(|e| e.to_string())?;
        }
        let mut outfile = File::create(&outpath)
          .map_err(|e| format!("Failed to create file {}: {}", outpath.display(), e))?;
        io::copy(&mut entry, &mut outfile)
          .map_err(|e| format!("Failed to extract file {}: {}", outpath.display(), e))?;
      }
    }

    Ok(())
  }

  /// Remove a scratch dir created by `extract_archive_and_scan`. Refuses paths
  /// outside the system temp dir or without the import-scratch prefix.
  pub fn cleanup_scratch_dir(extracted_dir: &str) -> Result<(), String> {
    let path = PathBuf::from(extracted_dir);
    let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
    if !path.starts_with(std::env::temp_dir()) || !dir_name.starts_with(IMPORT_SCRATCH_PREFIX) {
      return Err(
        serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": "Refusing to remove a non-scratch directory" } })
          .to_string(),
      );
    }
    if path.exists() {
      fs::remove_dir_all(&path).map_err(|e| {
        serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
          .to_string()
      })?;
    }
    Ok(())
  }

  fn scan_chrome_profiles_dir(
    &self,
    browser_dir: &Path,
    browser_type: &str,
  ) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
    let mut profiles = Vec::new();

    if !browser_dir.exists() {
      return Ok(profiles);
    }

    let default_profile = browser_dir.join("Default");
    if default_profile.exists() && default_profile.join("Preferences").exists() {
      profiles.push(DetectedProfile {
        browser: browser_type.to_string(),
        mapped_browser: map_browser_type(browser_type).to_string(),
        name: format!(
          "{} - Default Profile",
          self.get_browser_display_name(browser_type)
        ),
        path: default_profile.to_string_lossy().to_string(),
        description: "Default profile".to_string(),
      });
    }

    if let Ok(entries) = fs::read_dir(browser_dir) {
      for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
          let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");

          if dir_name.starts_with("Profile ") && path.join("Preferences").exists() {
            let profile_number = &dir_name[8..];
            profiles.push(DetectedProfile {
              browser: browser_type.to_string(),
              mapped_browser: map_browser_type(browser_type).to_string(),
              name: format!(
                "{} - Profile {}",
                self.get_browser_display_name(browser_type),
                profile_number
              ),
              path: path.to_string_lossy().to_string(),
              description: format!("Profile {profile_number}"),
            });
          }
        }
      }
    }

    Ok(profiles)
  }

  fn get_browser_display_name(&self, browser_type: &str) -> &str {
    match browser_type {
      "chromium" => "Chrome/Chromium",
      "chrome-beta" => "Chrome Beta",
      "chrome-dev" => "Chrome Dev",
      "chrome-canary" => "Chrome Canary",
      "brave" => "Brave",
      "brave-beta" => "Brave Beta",
      "brave-nightly" => "Brave Nightly",
      "edge" => "Microsoft Edge",
      "edge-beta" => "Edge Beta",
      "edge-dev" => "Edge Dev",
      "opera" => "Opera",
      "opera-gx" => "Opera GX",
      "vivaldi" => "Vivaldi",
      "arc" => "Arc",
      "yandex" => "Yandex Browser",
      "zen" => "Zen Browser",

      "wayfern" => "Wayfern",
      _ => "Unknown Browser",
    }
  }

  /// Import a batch of profiles. Items are isolated: one failure doesn't stop
  /// the rest. Emits `profile-import-progress` events around each item.
  pub async fn import_profiles(
    &self,
    app_handle: &tauri::AppHandle,
    items: Vec<ImportProfileItem>,
    group_id: Option<String>,
    duplicate_strategy: DuplicateStrategy,
    wayfern_config: Option<WayfernConfig>,
  ) -> Result<ProfileImportBatchResult, Box<dyn std::error::Error>> {
    if items.is_empty() {
      return Err(
        serde_json::json!({ "code": "IMPORT_NO_ITEMS" })
          .to_string()
          .into(),
      );
    }

    if let Some(ref gid) = group_id {
      let groups = crate::group_manager::GroupManager::new().get_all_groups()?;
      if !groups.iter().any(|g| &g.id == gid) {
        return Err(
          serde_json::json!({ "code": "GROUP_NOT_FOUND" })
            .to_string()
            .into(),
        );
      }
    }

    // Gate the paid fingerprint-OS override here rather than at each call site.
    // `wayfern_config` is only ever consumed by this function, so a caller that
    // forgets the check (the REST and MCP surfaces each had their own copy)
    // would bypass the restriction with no compile error.
    let fingerprint_os = wayfern_config.as_ref().and_then(|c| c.os.as_deref());
    if !crate::cloud_auth::CLOUD_AUTH
      .is_fingerprint_os_allowed(fingerprint_os)
      .await
    {
      return Err(
        serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" })
          .to_string()
          .into(),
      );
    }

    // A profile routes through a proxy or a VPN, never both: create_profile_with_group
    // rejects it, and at launch browser_runner resolves the proxy first and
    // silently ignores the VPN. The importer saves profiles directly, so
    // without this it is the one way to persist the invalid combination.
    if items
      .iter()
      .any(|i| i.proxy_id.is_some() && i.vpn_id.is_some())
    {
      return Err(
        serde_json::json!({ "code": "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE" })
          .to_string()
          .into(),
      );
    }

    let mut taken_names: HashSet<String> = self
      .profile_manager
      .list_profiles()?
      .iter()
      .map(|p| p.name.to_lowercase())
      .collect();

    let total = items.len();
    let mut results = Vec::with_capacity(total);
    let mut imported_count = 0usize;
    let mut skipped_count = 0usize;
    let mut failed_count = 0usize;
    let mut completed = 0usize;

    for (index, item) in items.into_iter().enumerate() {
      let requested = item.new_profile_name.trim().to_string();
      if requested.is_empty() {
        failed_count += 1;
        completed += 1;
        emit_import_progress(total, completed, index, &item.source_path, "failed");
        results.push(ProfileImportItemResult {
          name: requested,
          source_path: item.source_path,
          status: "failed".to_string(),
          profile_id: None,
          error: Some(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string()),
          report: None,
        });
        continue;
      }

      let final_name = if taken_names.contains(&requested.to_lowercase()) {
        match duplicate_strategy {
          DuplicateStrategy::Skip => {
            skipped_count += 1;
            completed += 1;
            emit_import_progress(total, completed, index, &requested, "skipped");
            results.push(ProfileImportItemResult {
              name: requested,
              source_path: item.source_path,
              status: "skipped".to_string(),
              profile_id: None,
              error: None,
              report: None,
            });
            continue;
          }
          DuplicateStrategy::Rename => resolve_duplicate_name(&requested, &mut taken_names),
        }
      } else {
        taken_names.insert(requested.to_lowercase());
        requested
      };

      emit_import_progress(total, completed, index, &final_name, "importing");

      match self
        .import_profile(
          app_handle,
          &item.source_path,
          &item.browser_type,
          &final_name,
          item.proxy_id.clone(),
          item.vpn_id.clone(),
          group_id.clone(),
          wayfern_config.clone(),
          item.allow_running.unwrap_or(false),
        )
        .await
      {
        Ok((profile, report)) => {
          imported_count += 1;
          completed += 1;
          emit_import_progress(total, completed, index, &final_name, "imported");
          let _ = events::emit_empty("profiles-changed");
          results.push(ProfileImportItemResult {
            name: final_name,
            source_path: item.source_path,
            status: "imported".to_string(),
            profile_id: Some(profile.id.to_string()),
            error: None,
            report: Some(report),
          });
        }
        Err(e) => {
          failed_count += 1;
          completed += 1;
          emit_import_progress(total, completed, index, &final_name, "failed");
          // The name was reserved but the import failed — free it again.
          taken_names.remove(&final_name.to_lowercase());
          results.push(ProfileImportItemResult {
            name: final_name,
            source_path: item.source_path,
            status: "failed".to_string(),
            profile_id: None,
            error: Some(error_to_code_string(e)),
            report: None,
          });
        }
      }
    }

    Ok(ProfileImportBatchResult {
      imported_count,
      skipped_count,
      failed_count,
      results,
    })
  }

  #[allow(clippy::too_many_arguments)]
  pub async fn import_profile(
    &self,
    app_handle: &tauri::AppHandle,
    source_path: &str,
    browser_type: &str,
    new_profile_name: &str,
    proxy_id: Option<String>,
    vpn_id: Option<String>,
    group_id: Option<String>,
    wayfern_config: Option<WayfernConfig>,
    allow_running: bool,
  ) -> Result<(BrowserProfile, ProfileImportReport), Box<dyn std::error::Error>> {
    let source_path = Path::new(source_path);
    if !source_path.exists() {
      return Err(
        serde_json::json!({ "code": "IMPORT_SOURCE_NOT_FOUND" })
          .to_string()
          .into(),
      );
    }

    let mapped = map_browser_type(browser_type);

    if let Some(ref pid) = proxy_id {
      if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
        crate::cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
      }
    }

    let existing_profiles = self.profile_manager.list_profiles()?;
    if existing_profiles
      .iter()
      .any(|p| p.name.to_lowercase() == new_profile_name.to_lowercase())
    {
      return Err(
        serde_json::json!({ "code": "PROFILE_NAME_EXISTS", "params": { "name": new_profile_name } })
          .to_string()
          .into(),
      );
    }

    let profile_id = uuid::Uuid::new_v4();
    let profiles_dir = self.profile_manager.get_profiles_dir();
    let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string());
    let new_profile_data_dir = new_profile_uuid_dir.join("profile");

    create_dir_all(&new_profile_uuid_dir)?;
    create_dir_all(&new_profile_data_dir)?;

    // Profile dirs can be multiple GB and the migration hits SQLite and the
    // OS keyring — keep all of it off the async runtime.
    let migrate_source = source_path.to_path_buf();
    let migrate_dest = new_profile_data_dir.clone();
    let source_family = browser_type.to_string();
    let migrate_result = match tokio::task::spawn_blocking(move || {
      crate::profile_import::import_into(
        &migrate_source,
        &migrate_dest,
        &source_family,
        allow_running,
      )
    })
    .await
    {
      Ok(r) => r,
      Err(e) => {
        // The task died (panic, or runtime shutdown mid-import). Clean up like
        // every other error path here, or the half-copied — possibly multi-GB
        // — directory is orphaned with no metadata pointing at it, so nothing
        // ever reclaims it.
        let _ = fs::remove_dir_all(&new_profile_uuid_dir);
        return Err(
          serde_json::json!({
            "code": "INTERNAL_ERROR",
            "params": { "detail": format!("Profile import task failed: {e}") },
          })
          .to_string()
          .into(),
        );
      }
    };
    let report = match migrate_result {
      Ok(report) => report,
      Err(e) => {
        let _ = fs::remove_dir_all(&new_profile_uuid_dir);
        // Structured codes (an unimportable source, a running browser) pass
        // through so the frontend can translate them; anything else is
        // internal.
        return Err(if e.starts_with('{') {
          e.into()
        } else {
          serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } })
            .to_string()
            .into()
        });
      }
    };

    let version = match self.get_default_version_for_browser(mapped) {
      Ok(version) => version,
      Err(e) => {
        let _ = fs::remove_dir_all(&new_profile_uuid_dir);
        return Err(e);
      }
    };

    let final_wayfern_config = if mapped == "wayfern" {
      let mut config = wayfern_config.unwrap_or_default();

      if let Some(ref proxy_id_val) = proxy_id {
        if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
          let proxy_url = if let (Some(username), Some(password)) =
            (&proxy_settings.username, &proxy_settings.password)
          {
            format!(
              "{}://{}:{}@{}:{}",
              proxy_settings.proxy_type.to_lowercase(),
              username,
              password,
              proxy_settings.host,
              proxy_settings.port
            )
          } else {
            format!(
              "{}://{}:{}",
              proxy_settings.proxy_type.to_lowercase(),
              proxy_settings.host,
              proxy_settings.port
            )
          };
          config.proxy = Some(proxy_url);
        }
      }

      if config.fingerprint.is_none() {
        let temp_profile = BrowserProfile {
          id: uuid::Uuid::new_v4(),
          name: new_profile_name.to_string(),
          browser: mapped.to_string(),
          version: version.clone(),
          proxy_id: proxy_id.clone(),
          vpn_id: None,
          launch_hook: None,
          process_id: None,
          last_launch: None,
          release_type: "stable".to_string(),
          wayfern_config: None,
          group_id: None,
          tags: Vec::new(),
          note: None,
          window_color: None,
          sync_mode: SyncMode::Disabled,
          encryption_salt: None,
          last_sync: None,
          host_os: None,
          ephemeral: false,
          extension_group_id: None,
          proxy_bypass_rules: Vec::new(),
          created_by_id: None,
          created_by_email: None,
          dns_blocklist: None,
          password_protected: false,
          clear_on_close: false,
          created_at: None,
          updated_at: None,
        };

        match self
          .wayfern_manager
          .generate_fingerprint_config(app_handle, &temp_profile, &config)
          .await
        {
          // geo_proxy_signature is intentionally left unset here: the first
          // launch's signature-mismatch refresh verifies the location either way.
          Ok(generated) => {
            config.fingerprint = Some(generated.fingerprint);
            config.identity_id = generated.identity_id;
            config.identity_baseline = generated.identity_baseline;
          }
          Err(e) => {
            let _ = fs::remove_dir_all(&new_profile_uuid_dir);
            return Err(
              serde_json::json!({
                "code": "INTERNAL_ERROR",
                "params": { "detail": format!("Failed to generate fingerprint for imported profile '{new_profile_name}': {e}") }
              })
              .to_string()
              .into(),
            );
          }
        }
      }

      config.proxy = None;
      Some(config)
    } else {
      None
    };

    let profile = BrowserProfile {
      id: profile_id,
      name: new_profile_name.to_string(),
      browser: mapped.to_string(),
      version,
      proxy_id,
      vpn_id,
      launch_hook: None,
      process_id: None,
      last_launch: None,
      release_type: "stable".to_string(),
      wayfern_config: final_wayfern_config,
      group_id,
      tags: Vec::new(),
      note: None,
      window_color: None,
      sync_mode: SyncMode::Disabled,
      encryption_salt: None,
      last_sync: None,
      host_os: Some(get_host_os()),
      ephemeral: false,
      extension_group_id: None,
      proxy_bypass_rules: Vec::new(),
      created_by_id: None,
      created_by_email: None,
      dns_blocklist: None,
      password_protected: false,
      clear_on_close: false,
      created_at: Some(
        std::time::SystemTime::now()
          .duration_since(std::time::UNIX_EPOCH)
          .map(|d| d.as_secs())
          .unwrap_or(0),
      ),
      updated_at: Some(crate::proxy_manager::now_secs()),
    };

    self.profile_manager.save_profile(&profile)?;

    if report.is_empty_import() {
      // Not an error — an empty source profile imports legitimately — but it is
      // the exact symptom the old layout bug produced, so it is worth a loud
      // line in the log rather than a silent success.
      log::warn!(
        "Imported profile '{}' from '{}' carried no readable data (warnings: {:?})",
        new_profile_name,
        source_path.display(),
        report.warnings
      );
    } else {
      log::info!(
        "Imported profile '{}' from '{}': {} cookies, {} passwords, {} history entries ({} unrecoverable secrets, warnings: {:?})",
        new_profile_name,
        source_path.display(),
        report.cookies_migrated,
        report.passwords_migrated,
        report.history_entries,
        report.cookies_unrecoverable + report.passwords_unrecoverable,
        report.warnings
      );
    }

    Ok((profile, report))
  }

  fn get_default_version_for_browser(
    &self,
    browser_type: &str,
  ) -> Result<String, Box<dyn std::error::Error>> {
    let downloaded_versions = self
      .downloaded_browsers_registry
      .get_downloaded_versions(browser_type);

    if let Some(version) = downloaded_versions.first() {
      return Ok(version.clone());
    }

    Err(
      serde_json::json!({
        "code": "BROWSER_NOT_DOWNLOADED",
        "params": { "browser": self.get_browser_display_name(browser_type) }
      })
      .to_string()
      .into(),
    )
  }

  pub fn copy_directory_recursive(
    source: &Path,
    destination: &Path,
  ) -> Result<(), Box<dyn std::error::Error>> {
    if !destination.exists() {
      create_dir_all(destination)?;
    }

    for entry in fs::read_dir(source)? {
      let entry = entry?;
      let source_path = entry.path();
      let dest_path = destination.join(entry.file_name());

      if source_path.is_dir() {
        Self::copy_directory_recursive(&source_path, &dest_path)?;
      } else {
        fs::copy(&source_path, &dest_path)?;
      }
    }

    Ok(())
  }
}

#[tauri::command]
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
  let importer = ProfileImporter::instance();
  importer
    .detect_existing_profiles()
    .map_err(error_to_code_string)
}

#[tauri::command]
pub async fn scan_folder_for_profiles(folder_path: String) -> Result<Vec<DetectedProfile>, String> {
  let importer = ProfileImporter::instance();
  importer
    .scan_folder(Path::new(&folder_path))
    .map_err(error_to_code_string)
}

#[tauri::command]
pub async fn scan_profile_archive(archive_path: String) -> Result<ArchiveScanResult, String> {
  let importer = ProfileImporter::instance();
  importer
    .extract_archive_and_scan(&archive_path)
    .await
    .map_err(error_to_code_string)
}

#[tauri::command]
pub async fn cleanup_profile_import_scratch(extracted_dir: String) -> Result<(), String> {
  ProfileImporter::cleanup_scratch_dir(&extracted_dir)
}

#[tauri::command]
pub async fn import_browser_profiles(
  app_handle: tauri::AppHandle,
  items: Vec<ImportProfileItem>,
  group_id: Option<String>,
  duplicate_strategy: Option<DuplicateStrategy>,
  wayfern_config: Option<WayfernConfig>,
) -> Result<ProfileImportBatchResult, String> {
  // The Pro gate for fingerprint OS spoofing lives inside import_profiles.
  let importer = ProfileImporter::instance();
  importer
    .import_profiles(
      &app_handle,
      items,
      group_id,
      duplicate_strategy.unwrap_or_default(),
      wayfern_config,
    )
    .await
    .map_err(error_to_code_string)
}

lazy_static::lazy_static! {
  static ref PROFILE_IMPORTER: ProfileImporter = ProfileImporter::new();
}

#[cfg(test)]
mod tests {
  use super::*;
  use std::env;
  use tempfile::TempDir;

  fn create_test_profile_importer() -> (ProfileImporter, TempDir) {
    let temp_dir = TempDir::new().expect("Failed to create temp directory");
    env::set_var("HOME", temp_dir.path());
    let importer = ProfileImporter::new();
    (importer, temp_dir)
  }

  #[test]
  fn test_profile_importer_creation() {
    let (_importer, _temp_dir) = create_test_profile_importer();
  }

  #[test]
  fn test_get_browser_display_name() {
    let (importer, _temp_dir) = create_test_profile_importer();

    assert_eq!(
      importer.get_browser_display_name("chromium"),
      "Chrome/Chromium"
    );
    assert_eq!(importer.get_browser_display_name("brave"), "Brave");
    assert_eq!(importer.get_browser_display_name("zen"), "Zen Browser");
    assert_eq!(
      importer.get_browser_display_name("unknown"),
      "Unknown Browser"
    );
  }

  #[test]
  fn test_map_browser_type() {
    assert_eq!(map_browser_type("chromium"), "wayfern");
    assert_eq!(map_browser_type("brave"), "wayfern");
    assert_eq!(map_browser_type("camoufox"), "wayfern");
    assert_eq!(map_browser_type("wayfern"), "wayfern");
    assert_eq!(map_browser_type("something_else"), "wayfern");
  }

  #[test]
  fn test_detect_existing_profiles_no_panic() {
    let (importer, _temp_dir) = create_test_profile_importer();

    let result = importer.detect_existing_profiles();
    assert!(result.is_ok(), "detect_existing_profiles should not fail");
    let _profiles = result.unwrap();
  }

  #[test]
  fn test_scan_chrome_profiles_dir_nonexistent() {
    let (importer, temp_dir) = create_test_profile_importer();

    let nonexistent_dir = temp_dir.path().join("nonexistent");
    let result = importer.scan_chrome_profiles_dir(&nonexistent_dir, "chromium");

    assert!(
      result.is_ok(),
      "Should handle nonexistent directory gracefully"
    );
    let profiles = result.unwrap();
    assert!(
      profiles.is_empty(),
      "Should return empty vector for nonexistent directory"
    );
  }

  #[test]
  fn test_copy_directory_recursive() {
    let temp_dir = TempDir::new().expect("Failed to create temp directory");

    let source_dir = temp_dir.path().join("source");
    let source_subdir = source_dir.join("subdir");
    fs::create_dir_all(&source_subdir).expect("Should create source directories");

    let source_file1 = source_dir.join("file1.txt");
    let source_file2 = source_subdir.join("file2.txt");
    fs::write(&source_file1, "content1").expect("Should create file1");
    fs::write(&source_file2, "content2").expect("Should create file2");

    let dest_dir = temp_dir.path().join("dest");

    let result = ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir);
    assert!(result.is_ok(), "Should copy directory successfully");

    let dest_file1 = dest_dir.join("file1.txt");
    let dest_file2 = dest_dir.join("subdir").join("file2.txt");

    assert!(dest_file1.exists(), "file1.txt should be copied");
    assert!(dest_file2.exists(), "file2.txt should be copied");

    let content1 = fs::read_to_string(&dest_file1).expect("Should read file1");
    let content2 = fs::read_to_string(&dest_file2).expect("Should read file2");

    assert_eq!(content1, "content1", "file1 content should match");
    assert_eq!(content2, "content2", "file2 content should match");
  }

  #[test]
  fn test_get_default_version_for_browser_no_versions() {
    let (importer, _temp_dir) = create_test_profile_importer();

    // Use a browser name that is guaranteed to have no downloaded versions,
    // since the global registry singleton may contain real data from the system.
    let result = importer.get_default_version_for_browser("nonexistent_browser_xyz");
    assert!(
      result.is_err(),
      "Should fail when no versions are available"
    );

    let error_msg = result.unwrap_err().to_string();
    assert!(
      error_msg.contains("BROWSER_NOT_DOWNLOADED"),
      "Error should carry the BROWSER_NOT_DOWNLOADED code"
    );
  }

  #[test]
  fn test_resolve_duplicate_name() {
    let mut taken: HashSet<String> = ["existing".to_string()].into_iter().collect();

    assert_eq!(resolve_duplicate_name("Fresh", &mut taken), "Fresh");
    // Case-insensitive collision gets a suffix.
    assert_eq!(
      resolve_duplicate_name("Existing", &mut taken),
      "Existing (2)"
    );
    assert_eq!(
      resolve_duplicate_name("Existing", &mut taken),
      "Existing (3)"
    );
    // The fresh name reserved above now collides too.
    assert_eq!(resolve_duplicate_name("fresh", &mut taken), "fresh (2)");
  }

  #[test]
  fn test_scan_folder_single_profile() {
    let (importer, temp_dir) = create_test_profile_importer();

    let profile_dir = temp_dir.path().join("my-profile");
    fs::create_dir_all(&profile_dir).unwrap();
    fs::write(profile_dir.join("Preferences"), "{}").unwrap();

    let profiles = importer.scan_folder(&profile_dir).unwrap();
    assert_eq!(profiles.len(), 1);
    assert_eq!(profiles[0].name, "my-profile");
    assert_eq!(profiles[0].mapped_browser, "wayfern");
  }

  #[test]
  fn test_scan_folder_user_data_dir_and_children() {
    let (importer, temp_dir) = create_test_profile_importer();

    let root = temp_dir.path().join("exported");
    // User-data-dir shape.
    fs::create_dir_all(root.join("Default")).unwrap();
    fs::write(root.join("Default/Preferences"), "{}").unwrap();
    fs::create_dir_all(root.join("Profile 2")).unwrap();
    fs::write(root.join("Profile 2/Preferences"), "{}").unwrap();
    // Free-form exported profile dir.
    fs::create_dir_all(root.join("account-a")).unwrap();
    fs::write(root.join("account-a/Preferences"), "{}").unwrap();
    // Nested user-data-dir one level down.
    fs::create_dir_all(root.join("old-chrome/Default")).unwrap();
    fs::write(root.join("old-chrome/Default/Preferences"), "{}").unwrap();
    // Noise: dir without Preferences anywhere.
    fs::create_dir_all(root.join("random")).unwrap();

    let profiles = importer.scan_folder(&root).unwrap();
    let mut names: Vec<String> = profiles.iter().map(|p| p.name.clone()).collect();
    names.sort();
    assert_eq!(profiles.len(), 4, "should find 4 profiles: {names:?}");
    assert!(names.contains(&"account-a".to_string()));
    assert!(names.iter().any(|n| n.contains("old-chrome")));
  }

  #[test]
  fn test_scan_folder_missing() {
    let (importer, temp_dir) = create_test_profile_importer();

    let result = importer.scan_folder(&temp_dir.path().join("nope"));
    assert!(result.is_err());
    assert!(result
      .unwrap_err()
      .to_string()
      .contains("IMPORT_SOURCE_NOT_FOUND"));
  }

  #[test]
  fn test_cleanup_scratch_dir_refuses_foreign_paths() {
    let temp_dir = TempDir::new().unwrap();
    let foreign = temp_dir.path().join("not-scratch");
    fs::create_dir_all(&foreign).unwrap();

    let result = ProfileImporter::cleanup_scratch_dir(&foreign.to_string_lossy());
    assert!(
      result.is_err(),
      "must refuse dirs without the scratch prefix"
    );
    assert!(foreign.exists());

    let scratch = std::env::temp_dir().join(format!("{IMPORT_SCRATCH_PREFIX}test-cleanup"));
    fs::create_dir_all(&scratch).unwrap();
    ProfileImporter::cleanup_scratch_dir(&scratch.to_string_lossy()).unwrap();
    assert!(!scratch.exists(), "scratch dir should be removed");
  }
}
