//! Profile management — path resolution, validation, and global config.
//!
//! A "profile" is a named vault file. Default paths split durable vault data,
//! mutable app state, and config across the platform directories exposed by
//! `ProjectDirs`. All path helpers also respect `TSAFE_VAULT_DIR` so tests can
//! redirect I/O to a temporary directory without touching the real user paths.

use std::path::PathBuf;

use directories::{ProjectDirs, UserDirs};
use serde::{Deserialize, Serialize};

use crate::errors::{SafeError, SafeResult};

const EXEC_CONFIG_KEY: &str = "exec";
const EXEC_MODE_KEY: &str = "mode";
const EXEC_CUSTOM_INHERIT_KEY: &str = "custom_inherit";
const EXEC_CUSTOM_DENY_DANGEROUS_ENV_KEY: &str = "custom_deny_dangerous_env";
const EXEC_AUTO_REDACT_OUTPUT_KEY: &str = "auto_redact_output";
const EXEC_EXTRA_SENSITIVE_PARENT_VARS_KEY: &str = "extra_sensitive_parent_vars";
const QUICK_UNLOCK_CONFIG_KEY: &str = "quick_unlock";
const QUICK_UNLOCK_AUTO_RETRIEVE_KEY: &str = "auto_retrieve";
const QUICK_UNLOCK_RETRY_COOLDOWN_SECS_KEY: &str = "retry_cooldown_secs";

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExecMode {
    Standard,
    Hardened,
    Custom,
}

impl ExecMode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Standard => "standard",
            Self::Hardened => "hardened",
            Self::Custom => "custom",
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExecCustomInheritMode {
    Full,
    Minimal,
    Clean,
}

impl ExecCustomInheritMode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Full => "full",
            Self::Minimal => "minimal",
            Self::Clean => "clean",
        }
    }
}

fn project_dirs() -> Option<ProjectDirs> {
    ProjectDirs::from("", "", "tsafe")
}

/// Platform data root for durable vault data and snapshots (app id: `tsafe`).
fn platform_data_root() -> PathBuf {
    project_dirs()
        .map(|d| d.data_dir().to_path_buf())
        .unwrap_or_else(|| PathBuf::from(".tsafe"))
}

/// Platform config root for persisted settings such as `config.json`.
fn platform_config_root() -> PathBuf {
    project_dirs()
        .map(|d| d.config_dir().to_path_buf())
        .unwrap_or_else(platform_data_root)
}

/// Platform state root for receipts and mutable runtime state.
fn platform_state_root() -> PathBuf {
    project_dirs()
        .and_then(|d| d.state_dir().map(|p| p.to_path_buf()))
        .unwrap_or_else(platform_data_root)
}

fn vault_location_from_env() -> Option<PathBuf> {
    std::env::var("TSAFE_VAULT_DIR").ok().map(PathBuf::from)
}

/// Parent directory of `vaults/` — durable vault data, snapshots, browser mappings.
pub fn app_data_dir() -> PathBuf {
    if let Some(v) = vault_location_from_env() {
        v.parent()
            .map(|p| p.to_path_buf())
            .unwrap_or_else(|| PathBuf::from("."))
    } else {
        platform_data_root()
    }
}

/// Parent directory for audit receipts and mutable runtime state.
pub fn app_state_dir() -> PathBuf {
    if let Some(v) = vault_location_from_env() {
        v.parent()
            .map(|p| p.join("state"))
            .unwrap_or_else(|| PathBuf::from(".tsafe-state"))
    } else {
        platform_state_root()
    }
}

/// Base dir for all vault files. Override with `TSAFE_VAULT_DIR`.
pub fn vault_dir() -> PathBuf {
    if let Some(v) = vault_location_from_env() {
        return v;
    }
    platform_data_root().join("vaults")
}

/// Base dir for audit log files. Follows `TSAFE_VAULT_DIR` or the platform state root.
pub fn audit_dir() -> PathBuf {
    app_state_dir().join("audit")
}

/// Path to the global config file (`~/.config/tsafe/config.json` or similar).
pub fn config_path() -> PathBuf {
    if let Some(v) = vault_location_from_env() {
        return v
            .parent()
            .map(|p| p.join("config.json"))
            .unwrap_or_else(|| PathBuf::from(".tsafe/config.json"));
    }
    platform_config_root().join("config.json")
}

/// Return the persisted default profile name. Falls back to `"default"` if no config is set.
pub fn get_default_profile() -> String {
    let path = config_path();
    if let Ok(contents) = std::fs::read_to_string(&path) {
        if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&contents) {
            if let Some(name) = cfg.get("default_profile").and_then(|v| v.as_str()) {
                if !name.is_empty() {
                    return name.to_string();
                }
            }
        }
    }
    "default".to_string()
}

fn read_config_map() -> serde_json::Map<String, serde_json::Value> {
    let path = config_path();
    std::fs::read_to_string(&path)
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default()
}

fn write_config_map(cfg: &serde_json::Map<String, serde_json::Value>) -> SafeResult<()> {
    let path = config_path();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
    }
    let json = serde_json::to_string_pretty(&serde_json::Value::Object(cfg.clone()))?;
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, json).map_err(SafeError::Io)?;
    std::fs::rename(&tmp, &path).map_err(SafeError::Io)?;
    Ok(())
}

fn ensure_object_slot<'a>(
    cfg: &'a mut serde_json::Map<String, serde_json::Value>,
    key: &str,
) -> &'a mut serde_json::Map<String, serde_json::Value> {
    if !matches!(cfg.get(key), Some(serde_json::Value::Object(_))) {
        cfg.insert(
            key.to_string(),
            serde_json::Value::Object(Default::default()),
        );
    }
    cfg.get_mut(key)
        .and_then(serde_json::Value::as_object_mut)
        .expect("object slot must exist")
}

fn exec_config(
    cfg: &serde_json::Map<String, serde_json::Value>,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
    cfg.get(EXEC_CONFIG_KEY)
        .and_then(serde_json::Value::as_object)
}

fn quick_unlock_config(
    cfg: &serde_json::Map<String, serde_json::Value>,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
    cfg.get(QUICK_UNLOCK_CONFIG_KEY)
        .and_then(serde_json::Value::as_object)
}

fn parse_env_toggle(name: &str) -> Option<bool> {
    let raw = std::env::var(name).ok()?;
    match raw.trim().to_ascii_lowercase().as_str() {
        "1" | "true" | "yes" | "on" => Some(true),
        "0" | "false" | "no" | "off" => Some(false),
        _ => None,
    }
}

/// Persist `name` as the default profile in the config file.
pub fn set_default_profile(name: &str) -> SafeResult<()> {
    let mut cfg = read_config_map();
    cfg.insert(
        "default_profile".to_string(),
        serde_json::Value::String(name.to_string()),
    );
    write_config_map(&cfg)
}

/// If set, after each **new** password vault is created, its master password is copied into this
/// profile's vault under `profile-passwords/<new-profile>` (for recovery via main-vault bridging).
pub fn get_backup_new_profile_passwords_to() -> Option<String> {
    let cfg = serde_json::Value::Object(read_config_map());
    cfg.get("backup_new_profile_passwords_to")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
}

/// Set or clear the backup target profile (`main` and `default` are typical). Pass `None` to disable.
pub fn set_backup_new_profile_passwords_to(target: Option<&str>) -> SafeResult<()> {
    let mut cfg = read_config_map();
    match target {
        None | Some("") => {
            cfg.remove("backup_new_profile_passwords_to");
        }
        Some(t) => {
            validate_profile_name(t)?;
            cfg.insert(
                "backup_new_profile_passwords_to".to_string(),
                serde_json::Value::String(t.to_string()),
            );
        }
    }
    write_config_map(&cfg)
}

/// Return whether the CLI should automatically try the OS credential store during normal vault opens.
///
/// Environment override: `TSAFE_AUTO_QUICK_UNLOCK=on|off`.
pub fn get_auto_quick_unlock() -> bool {
    if let Some(v) = parse_env_toggle("TSAFE_AUTO_QUICK_UNLOCK") {
        return v;
    }
    let cfg = read_config_map();
    quick_unlock_config(&cfg)
        .and_then(|quick_unlock| quick_unlock.get(QUICK_UNLOCK_AUTO_RETRIEVE_KEY))
        .and_then(serde_json::Value::as_bool)
        .unwrap_or(true)
}

/// Persist whether the CLI should automatically try the OS credential store during normal vault opens.
pub fn set_auto_quick_unlock(enabled: bool) -> SafeResult<()> {
    let mut cfg = read_config_map();
    let quick_unlock = ensure_object_slot(&mut cfg, QUICK_UNLOCK_CONFIG_KEY);
    quick_unlock.insert(
        QUICK_UNLOCK_AUTO_RETRIEVE_KEY.to_string(),
        serde_json::Value::Bool(enabled),
    );
    write_config_map(&cfg)
}

/// Return the cooldown, in seconds, applied after an automatic quick-unlock failure.
///
/// Environment override: `TSAFE_QUICK_UNLOCK_RETRY_COOLDOWN_SECS=<n>`.
pub fn get_quick_unlock_retry_cooldown_secs() -> u64 {
    if let Ok(raw) = std::env::var("TSAFE_QUICK_UNLOCK_RETRY_COOLDOWN_SECS") {
        if let Ok(secs) = raw.trim().parse::<u64>() {
            return secs;
        }
    }
    let cfg = read_config_map();
    quick_unlock_config(&cfg)
        .and_then(|quick_unlock| quick_unlock.get(QUICK_UNLOCK_RETRY_COOLDOWN_SECS_KEY))
        .and_then(serde_json::Value::as_u64)
        .unwrap_or(300)
}

/// Persist the cooldown, in seconds, applied after an automatic quick-unlock failure.
pub fn set_quick_unlock_retry_cooldown_secs(seconds: u64) -> SafeResult<()> {
    let mut cfg = read_config_map();
    let quick_unlock = ensure_object_slot(&mut cfg, QUICK_UNLOCK_CONFIG_KEY);
    quick_unlock.insert(
        QUICK_UNLOCK_RETRY_COOLDOWN_SECS_KEY.to_string(),
        serde_json::Value::Number(seconds.into()),
    );
    write_config_map(&cfg)
}

/// Return true when `tsafe exec` should redact child stdout/stderr by default.
pub fn get_exec_auto_redact_output() -> bool {
    let cfg = read_config_map();
    exec_config(&cfg)
        .and_then(|exec| exec.get(EXEC_AUTO_REDACT_OUTPUT_KEY))
        .and_then(serde_json::Value::as_bool)
        .unwrap_or(false)
}

/// Persist whether `tsafe exec` should redact child stdout/stderr by default.
pub fn set_exec_auto_redact_output(enabled: bool) -> SafeResult<()> {
    let mut cfg = read_config_map();
    let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
    exec.insert(
        EXEC_AUTO_REDACT_OUTPUT_KEY.to_string(),
        serde_json::Value::Bool(enabled),
    );
    write_config_map(&cfg)
}

/// Return the persisted exec trust mode. Defaults to `custom`, which preserves the
/// current config-driven exec behavior until stricter presets are selected.
pub fn get_exec_mode() -> ExecMode {
    let cfg = read_config_map();
    match exec_config(&cfg)
        .and_then(|exec| exec.get(EXEC_MODE_KEY))
        .and_then(serde_json::Value::as_str)
    {
        Some("standard") => ExecMode::Standard,
        Some("hardened") => ExecMode::Hardened,
        Some("custom") => ExecMode::Custom,
        _ => ExecMode::Custom,
    }
}

/// Persist the exec trust mode.
pub fn set_exec_mode(mode: ExecMode) -> SafeResult<()> {
    let mut cfg = read_config_map();
    let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
    exec.insert(
        EXEC_MODE_KEY.to_string(),
        serde_json::Value::String(mode.as_str().to_string()),
    );
    write_config_map(&cfg)
}

/// Return the inherit strategy used by exec when mode=`custom`.
pub fn get_exec_custom_inherit_mode() -> ExecCustomInheritMode {
    let cfg = read_config_map();
    match exec_config(&cfg)
        .and_then(|exec| exec.get(EXEC_CUSTOM_INHERIT_KEY))
        .and_then(serde_json::Value::as_str)
    {
        Some("minimal") => ExecCustomInheritMode::Minimal,
        Some("clean") => ExecCustomInheritMode::Clean,
        _ => ExecCustomInheritMode::Full,
    }
}

/// Persist the inherit strategy used by exec when mode=`custom`.
pub fn set_exec_custom_inherit_mode(mode: ExecCustomInheritMode) -> SafeResult<()> {
    let mut cfg = read_config_map();
    let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
    exec.insert(
        EXEC_CUSTOM_INHERIT_KEY.to_string(),
        serde_json::Value::String(mode.as_str().to_string()),
    );
    write_config_map(&cfg)
}

/// Return whether exec should deny dangerous injected env names when mode=`custom`.
pub fn get_exec_custom_deny_dangerous_env() -> bool {
    let cfg = read_config_map();
    exec_config(&cfg)
        .and_then(|exec| exec.get(EXEC_CUSTOM_DENY_DANGEROUS_ENV_KEY))
        .and_then(serde_json::Value::as_bool)
        .unwrap_or(true)
}

/// Persist whether exec should deny dangerous injected env names when mode=`custom`.
pub fn set_exec_custom_deny_dangerous_env(enabled: bool) -> SafeResult<()> {
    let mut cfg = read_config_map();
    let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
    exec.insert(
        EXEC_CUSTOM_DENY_DANGEROUS_ENV_KEY.to_string(),
        serde_json::Value::Bool(enabled),
    );
    write_config_map(&cfg)
}

/// Return additional parent environment variable names to strip during `tsafe exec`.
pub fn get_exec_extra_sensitive_parent_vars() -> Vec<String> {
    let cfg = read_config_map();
    let mut out = Vec::new();
    if let Some(values) = exec_config(&cfg)
        .and_then(|exec| exec.get(EXEC_EXTRA_SENSITIVE_PARENT_VARS_KEY))
        .and_then(serde_json::Value::as_array)
    {
        for value in values {
            if let Some(name) = value.as_str() {
                let trimmed = name.trim();
                if validate_env_var_name(trimmed).is_ok()
                    && !out
                        .iter()
                        .any(|existing: &String| existing.eq_ignore_ascii_case(trimmed))
                {
                    out.push(trimmed.to_string());
                }
            }
        }
    }
    out
}

/// Add a parent environment variable name to the extra strip list for `tsafe exec`.
pub fn add_exec_extra_sensitive_parent_var(name: &str) -> SafeResult<()> {
    let trimmed = name.trim();
    validate_env_var_name(trimmed)?;

    let mut names = get_exec_extra_sensitive_parent_vars();
    if !names
        .iter()
        .any(|existing| existing.eq_ignore_ascii_case(trimmed))
    {
        names.push(trimmed.to_string());
        names.sort();
    }
    set_exec_extra_sensitive_parent_vars(&names)
}

/// Remove a parent environment variable name from the extra strip list for `tsafe exec`.
pub fn remove_exec_extra_sensitive_parent_var(name: &str) -> SafeResult<bool> {
    let trimmed = name.trim();
    validate_env_var_name(trimmed)?;

    let mut names = get_exec_extra_sensitive_parent_vars();
    let original_len = names.len();
    names.retain(|existing| !existing.eq_ignore_ascii_case(trimmed));
    if names.len() == original_len {
        return Ok(false);
    }
    set_exec_extra_sensitive_parent_vars(&names)?;
    Ok(true)
}

fn set_exec_extra_sensitive_parent_vars(names: &[String]) -> SafeResult<()> {
    let mut cfg = read_config_map();
    let exec = ensure_object_slot(&mut cfg, EXEC_CONFIG_KEY);
    exec.insert(
        EXEC_EXTRA_SENSITIVE_PARENT_VARS_KEY.to_string(),
        serde_json::Value::Array(
            names
                .iter()
                .map(|name| serde_json::Value::String(name.clone()))
                .collect(),
        ),
    );
    write_config_map(&cfg)
}

/// Default age identity path for a profile: `~/.age/tsafe-<profile>.txt`.
pub fn default_age_identity_path(profile: &str) -> PathBuf {
    let base = UserDirs::new()
        .map(|d| d.home_dir().join(".age"))
        .unwrap_or_else(|| PathBuf::from(".age"));
    base.join(format!("tsafe-{profile}.txt"))
}

/// Resolve age identity: `TSAFE_AGE_IDENTITY` if set, else [`default_age_identity_path`].
pub fn resolve_age_identity_path(profile: &str) -> PathBuf {
    if let Ok(p) = std::env::var("TSAFE_AGE_IDENTITY") {
        return PathBuf::from(p);
    }
    default_age_identity_path(profile)
}

/// Resolve the vault file path for a named profile.
pub fn vault_path(profile: &str) -> PathBuf {
    vault_dir().join(format!("{profile}.vault"))
}

/// Resolve the audit log path for a named profile.
pub fn audit_log_path(profile: &str) -> PathBuf {
    audit_dir().join(format!("{profile}.audit.jsonl"))
}

/// Move a profile's snapshot history to a new profile name, updating both the
/// directory and the snapshot file prefixes so future snapshot commands keep
/// working under the destination profile.
pub fn rename_profile_snapshot_history(from: &str, to: &str) -> SafeResult<bool> {
    let src_dir = crate::snapshot::snapshot_dir(from);
    if !src_dir.exists() {
        return Ok(false);
    }

    let dst_dir = crate::snapshot::snapshot_dir(to);
    if dst_dir.exists() {
        return Err(SafeError::InvalidVault {
            reason: format!("snapshot history already exists for profile '{to}'"),
        });
    }

    if let Some(parent) = dst_dir.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::create_dir(&dst_dir)?;

    let from_prefix = format!("{from}.vault.");
    let to_prefix = format!("{to}.vault.");
    for entry in std::fs::read_dir(&src_dir)? {
        let entry = entry?;
        let path = entry.path();
        let name = entry.file_name();
        let name_text = name.to_string_lossy();
        let dest_name = if name_text.starts_with(&from_prefix) && name_text.ends_with(".snap") {
            format!("{}{}", to_prefix, &name_text[from_prefix.len()..])
        } else {
            name_text.into_owned()
        };
        let dst_path = dst_dir.join(dest_name);
        if dst_path.exists() {
            return Err(SafeError::InvalidVault {
                reason: format!(
                    "snapshot migration target already exists at '{}'",
                    dst_path.display()
                ),
            });
        }
        std::fs::rename(path, dst_path)?;
    }

    std::fs::remove_dir(&src_dir)?;
    Ok(true)
}

/// Path to the browser domain -> profile mapping file.
pub fn browser_profiles_path() -> PathBuf {
    vault_dir().join("browser-profiles.json")
}

/// Structural validation for hostnames the browser extension sends to the native host.
///
/// Rejects empty, oversized, non-ASCII, malformed DNS-like, or punycode-prefixed
/// hostnames before profile mapping or vault I/O. This is a lightweight abuse
/// / oddity guard. Punycode labels (`xn--*`) are rejected outright because they
/// are the only IDN-attack vector that survives the "ASCII-only" check —
/// `xn--pyal-9ja.com` IS ASCII but encodes Cyrillic Unicode confusables.
/// Full IDN support (decode + confusables-table) is post-v1.
pub fn browser_hostname_fill_guard(hostname: &str) -> Result<(), &'static str> {
    let host = hostname.trim().trim_end_matches('.');
    if host.is_empty() {
        return Err("empty hostname");
    }
    if host.len() > 253 {
        return Err("hostname too long");
    }
    if !host.is_ascii() {
        return Err("hostname must be ASCII (IDN should be sent as punycode)");
    }
    let lower = host.to_ascii_lowercase();
    let labels: Vec<&str> = lower.split('.').collect();
    if labels.len() > 12 {
        return Err("too many hostname labels");
    }
    for label in &labels {
        if label.is_empty() {
            return Err("empty hostname label");
        }
        if label.len() > 63 {
            return Err("hostname label too long");
        }
        if label.starts_with('-') || label.ends_with('-') {
            return Err("hostname label has invalid hyphen placement");
        }
        // RFC 3490 punycode labels (`xn--*`) are how IDN homoglyph attacks
        // bypass the "ASCII-only" check above: `xn--pyal-9ja.com` IS ASCII
        // but encodes Cyrillic characters that look identical to a registered
        // Latin-script domain. Until tsafe ships full IDN support (decode +
        // confusables-table check), reject all xn-- labels at the door.
        // Mirrored in `extension/src/content/autofill.ts`'s
        // `browserHostnameFillGuard` — keep both copies in sync.
        if label.starts_with("xn--") {
            return Err("punycode/IDN labels not supported (post-v1)");
        }
    }
    Ok(())
}

/// Load browser domain -> profile mappings.
pub fn load_browser_profiles() -> SafeResult<Vec<(String, String)>> {
    let path = browser_profiles_path();
    if !path.exists() {
        return Ok(Vec::new());
    }

    let value: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path)?)?;
    let map = value.as_object().ok_or_else(|| SafeError::InvalidVault {
        reason: format!(
            "browser profile mappings at '{}' must be a JSON object",
            path.display()
        ),
    })?;

    Ok(map
        .iter()
        .filter_map(|(domain, profile)| {
            profile
                .as_str()
                .map(|target| (domain.to_string(), target.to_string()))
        })
        .collect())
}

/// Resolve a hostname to a configured browser profile.
///
/// Exact matches win. Otherwise, the longest wildcard suffix (`*.corp.example`)
/// that matches a subdomain is returned.
pub fn resolve_browser_profile(hostname: &str) -> SafeResult<Option<String>> {
    let host = hostname.trim().trim_end_matches('.').to_ascii_lowercase();
    if host.is_empty() {
        return Ok(None);
    }

    let mappings = load_browser_profiles()?;
    if let Some((_, profile)) = mappings
        .iter()
        .find(|(domain, _)| !domain.starts_with("*.") && domain.eq_ignore_ascii_case(&host))
    {
        return Ok(Some(profile.clone()));
    }

    let mut best: Option<(usize, &str)> = None;
    for (pattern, profile) in &mappings {
        let Some(suffix) = pattern.strip_prefix("*.") else {
            continue;
        };
        let suffix = suffix.trim_end_matches('.').to_ascii_lowercase();
        if suffix.is_empty() || host == suffix {
            continue;
        }
        if host.ends_with(&suffix) && host.as_bytes()[host.len() - suffix.len() - 1] == b'.' {
            match best {
                Some((best_len, _)) if best_len >= suffix.len() => {}
                _ => best = Some((suffix.len(), profile.as_str())),
            }
        }
    }

    Ok(best.map(|(_, profile)| profile.to_string()))
}

/// Return all profile names that have an existing vault file.
pub fn list_profiles() -> SafeResult<Vec<String>> {
    let dir = vault_dir();
    if !dir.exists() {
        return Ok(Vec::new());
    }
    let mut names: Vec<String> = std::fs::read_dir(&dir)?
        .filter_map(|e| e.ok())
        .filter_map(|e| {
            let name = e.file_name().to_string_lossy().into_owned();
            name.strip_suffix(".vault").map(|s| s.to_string())
        })
        .collect();
    names.sort();
    Ok(names)
}

/// Return `true` if a vault file exists for the given profile name.
pub fn profile_exists(profile: &str) -> bool {
    vault_path(profile).exists()
}

/// Validate a profile name: alphanumeric, hyphens, underscores only.
pub fn validate_profile_name(name: &str) -> SafeResult<()> {
    if name.is_empty() {
        return Err(SafeError::ProfileNotFound {
            name: name.to_string(),
        });
    }
    if !name
        .chars()
        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
    {
        return Err(SafeError::InvalidVault {
            reason: format!("profile '{name}': only alphanumeric, '-', '_' characters allowed"),
        });
    }
    Ok(())
}

/// Validate an environment variable name for config-based strip rules.
pub fn validate_env_var_name(name: &str) -> SafeResult<()> {
    if name.is_empty() {
        return Err(SafeError::InvalidVault {
            reason: "environment variable name cannot be empty".to_string(),
        });
    }
    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
        return Err(SafeError::InvalidVault {
            reason: format!(
                "environment variable '{name}': only ASCII letters, digits, and '_' are allowed"
            ),
        });
    }
    Ok(())
}

// ── Profile metadata and protection ──────────────────────────────────────────

/// Metadata associated with a named profile, stored in a sidecar JSON file
/// (`<vault_dir>/<profile>.meta.json`). This is separate from the vault file so
/// it can be read without the vault password.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileMeta {
    /// Human-readable description of the profile's purpose.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// When the vault file was first created (best-effort; set at meta creation time).
    pub created_at: chrono::DateTime<chrono::Utc>,
    /// When the metadata was last modified.
    pub last_modified: chrono::DateTime<chrono::Utc>,
    /// If `true`, deletion without `--force` must be blocked with a warning.
    #[serde(default)]
    pub is_protected: bool,
}

impl ProfileMeta {
    /// Create new metadata with default values and the current timestamp.
    pub fn new() -> Self {
        let now = chrono::Utc::now();
        Self {
            description: None,
            created_at: now,
            last_modified: now,
            is_protected: false,
        }
    }

    /// Touch `last_modified` without changing any other fields.
    pub fn touch(&mut self) {
        self.last_modified = chrono::Utc::now();
    }
}

impl Default for ProfileMeta {
    fn default() -> Self {
        Self::new()
    }
}

/// Path to the metadata sidecar file for a named profile.
pub fn profile_meta_path(profile: &str) -> PathBuf {
    vault_dir().join(format!("{profile}.meta.json"))
}

/// Read profile metadata. Returns `None` if the sidecar file does not exist.
pub fn read_profile_meta(profile: &str) -> SafeResult<Option<ProfileMeta>> {
    let path = profile_meta_path(profile);
    if !path.exists() {
        return Ok(None);
    }
    let contents = std::fs::read_to_string(&path).map_err(SafeError::Io)?;
    let meta: ProfileMeta = serde_json::from_str(&contents)?;
    Ok(Some(meta))
}

/// Write profile metadata, creating the vault directory if necessary.
pub fn write_profile_meta(profile: &str, meta: &ProfileMeta) -> SafeResult<()> {
    let path = profile_meta_path(profile);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
    }
    let json = serde_json::to_string_pretty(meta)?;
    let tmp = path.with_extension("meta.json.tmp");
    std::fs::write(&tmp, json).map_err(SafeError::Io)?;
    std::fs::rename(&tmp, &path).map_err(SafeError::Io)?;
    Ok(())
}

/// Ensure a profile's metadata sidecar exists, creating it with defaults if not.
pub fn ensure_profile_meta(profile: &str) -> SafeResult<ProfileMeta> {
    if let Some(existing) = read_profile_meta(profile)? {
        return Ok(existing);
    }
    let meta = ProfileMeta::new();
    write_profile_meta(profile, &meta)?;
    Ok(meta)
}

/// Set the protection flag on a profile's metadata, creating the sidecar if needed.
pub fn set_profile_protected(profile: &str, protected: bool) -> SafeResult<()> {
    let mut meta = read_profile_meta(profile)?.unwrap_or_default();
    meta.is_protected = protected;
    meta.touch();
    write_profile_meta(profile, &meta)
}

/// Returns `true` if the profile exists and has `is_protected = true`.
pub fn is_profile_protected(profile: &str) -> bool {
    read_profile_meta(profile)
        .ok()
        .flatten()
        .map(|m| m.is_protected)
        .unwrap_or(false)
}

// ── Profile portability (export / import) ─────────────────────────────────────

/// A self-contained, re-encrypted export bundle for a single profile.
///
/// The vault file bytes are stored as base64. A separate re-encryption wrapper
/// (PBKDF2 + XChaCha20-Poly1305) protects the payload so the export file is
/// safe to copy across machines. The original vault password is **not** stored.
///
/// Bundle format version: `1`.
#[derive(Debug, Serialize, Deserialize)]
pub struct ProfileBundle {
    /// Format version for forward compatibility.
    pub version: u8,
    /// Profile name at export time (advisory — import may use a different name).
    pub profile: String,
    /// Export timestamp.
    pub exported_at: chrono::DateTime<chrono::Utc>,
    /// Metadata sidecar at export time (optional).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub meta: Option<ProfileMeta>,
    /// PBKDF2-HMAC-SHA256 salt (base64) used to derive the bundle encryption key.
    pub salt: String,
    /// XChaCha20-Poly1305 nonce (base64) for the encrypted payload.
    pub nonce: String,
    /// Encrypted vault file bytes (base64).
    pub ciphertext: String,
}

impl ProfileBundle {
    const NONCE_LEN: usize = 24;
    const SALT_LEN: usize = 32;
    /// Argon2id parameters for bundle key derivation — intentionally lower than
    /// the vault KDF to allow faster export/import on constrained machines.
    const KDF_M_COST: u32 = 32_768; // 32 MiB
    const KDF_T_COST: u32 = 2;
    const KDF_P_COST: u32 = 1;

    /// Derive a 256-bit key from `password` and `salt` using Argon2id.
    fn derive_key(password: &[u8], salt: &[u8]) -> SafeResult<[u8; 32]> {
        let vault_key = crate::crypto::derive_key(
            password,
            salt,
            Self::KDF_M_COST,
            Self::KDF_T_COST,
            Self::KDF_P_COST,
        )?;
        Ok(*vault_key.as_bytes())
    }

    /// Encrypt `plaintext` with XChaCha20-Poly1305 under `key` and a random nonce.
    fn seal(key: &[u8; 32], plaintext: &[u8]) -> SafeResult<([u8; Self::NONCE_LEN], Vec<u8>)> {
        use chacha20poly1305::{
            aead::{Aead, KeyInit},
            XChaCha20Poly1305, XNonce,
        };
        use rand::RngCore;
        let mut nonce_bytes = [0u8; Self::NONCE_LEN];
        rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
        let cipher = XChaCha20Poly1305::new(key.into());
        let nonce = XNonce::from_slice(&nonce_bytes);
        let ciphertext = cipher
            .encrypt(nonce, plaintext)
            .map_err(|_| SafeError::Crypto {
                context: "bundle encryption failed".into(),
            })?;
        Ok((nonce_bytes, ciphertext))
    }

    /// Decrypt `ciphertext` with XChaCha20-Poly1305 under `key` and `nonce`.
    fn open(key: &[u8; 32], nonce: &[u8], ciphertext: &[u8]) -> SafeResult<Vec<u8>> {
        use chacha20poly1305::{
            aead::{Aead, KeyInit},
            XChaCha20Poly1305, XNonce,
        };
        let cipher = XChaCha20Poly1305::new(key.into());
        let nonce = XNonce::from_slice(nonce);
        cipher
            .decrypt(nonce, ciphertext)
            .map_err(|_| SafeError::DecryptionFailed)
    }
}

/// Export a profile's vault file to a self-contained encrypted bundle.
///
/// `profile` — the profile name whose vault file will be read.
/// `dest_path` — where to write the bundle JSON.
/// `bundle_password` — the export password; independent of the vault's master password.
///
/// Returns `SafeError::VaultNotFound` if the profile has no vault file.
pub fn export_profile(
    profile: &str,
    dest_path: &std::path::Path,
    bundle_password: &[u8],
) -> SafeResult<()> {
    use base64::{engine::general_purpose::STANDARD as B64, Engine};
    use rand::RngCore;

    let vault_file_path = vault_path(profile);
    if !vault_file_path.exists() {
        return Err(SafeError::VaultNotFound {
            path: vault_file_path.display().to_string(),
        });
    }

    let vault_bytes = std::fs::read(&vault_file_path).map_err(SafeError::Io)?;
    let meta = read_profile_meta(profile)?;

    let mut salt = [0u8; ProfileBundle::SALT_LEN];
    rand::rngs::OsRng.fill_bytes(&mut salt);

    let key = ProfileBundle::derive_key(bundle_password, &salt)?;
    let (nonce, ciphertext) = ProfileBundle::seal(&key, &vault_bytes)?;

    let bundle = ProfileBundle {
        version: 1,
        profile: profile.to_string(),
        exported_at: chrono::Utc::now(),
        meta,
        salt: B64.encode(salt),
        nonce: B64.encode(nonce),
        ciphertext: B64.encode(&ciphertext),
    };

    let json = serde_json::to_string_pretty(&bundle)?;
    if let Some(parent) = dest_path.parent() {
        if !parent.as_os_str().is_empty() {
            std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
        }
    }
    std::fs::write(dest_path, json).map_err(SafeError::Io)?;
    Ok(())
}

/// Import a profile bundle, registering it under `dest_profile`.
///
/// The vault file is decrypted from the bundle and written to the standard vault
/// directory. If `dest_profile` is `None`, the name embedded in the bundle is used.
/// Returns `SafeError::VaultAlreadyExists` if the target vault file already exists.
pub fn import_profile(
    src_path: &std::path::Path,
    dest_profile: Option<&str>,
    bundle_password: &[u8],
) -> SafeResult<String> {
    use base64::{engine::general_purpose::STANDARD as B64, Engine};

    let json = std::fs::read_to_string(src_path).map_err(SafeError::Io)?;
    let bundle: ProfileBundle = serde_json::from_str(&json)?;

    if bundle.version != 1 {
        return Err(SafeError::InvalidVault {
            reason: format!(
                "unsupported profile bundle version: {} (expected 1)",
                bundle.version
            ),
        });
    }

    let salt = B64
        .decode(&bundle.salt)
        .map_err(|_| SafeError::InvalidVault {
            reason: "bundle salt is not valid base64".into(),
        })?;
    let nonce = B64
        .decode(&bundle.nonce)
        .map_err(|_| SafeError::InvalidVault {
            reason: "bundle nonce is not valid base64".into(),
        })?;
    let ciphertext = B64
        .decode(&bundle.ciphertext)
        .map_err(|_| SafeError::InvalidVault {
            reason: "bundle ciphertext is not valid base64".into(),
        })?;

    if salt.len() != ProfileBundle::SALT_LEN {
        return Err(SafeError::InvalidVault {
            reason: format!(
                "bundle salt has wrong length: {} (expected {})",
                salt.len(),
                ProfileBundle::SALT_LEN
            ),
        });
    }

    let key = ProfileBundle::derive_key(bundle_password, &salt)?;
    let vault_bytes = ProfileBundle::open(&key, &nonce, &ciphertext)?;

    let profile_name = dest_profile.unwrap_or(&bundle.profile);
    validate_profile_name(profile_name)?;

    let dest_vault = vault_path(profile_name);
    if dest_vault.exists() {
        return Err(SafeError::VaultAlreadyExists {
            path: dest_vault.display().to_string(),
        });
    }

    if let Some(parent) = dest_vault.parent() {
        std::fs::create_dir_all(parent).map_err(SafeError::Io)?;
    }
    std::fs::write(&dest_vault, &vault_bytes).map_err(SafeError::Io)?;

    // Restore metadata sidecar if present in the bundle.
    if let Some(meta) = &bundle.meta {
        write_profile_meta(profile_name, meta)?;
    }

    Ok(profile_name.to_string())
}

// ── Phishing / lookalike guard ────────────────────────────────────────────────

/// Space-optimised Levenshtein edit distance between two ASCII strings.
fn edit_distance(a: &str, b: &str) -> usize {
    let a: Vec<char> = a.chars().collect();
    let b: Vec<char> = b.chars().collect();
    let (m, n) = (a.len(), b.len());
    if m == 0 {
        return n;
    }
    if n == 0 {
        return m;
    }
    let mut row: Vec<usize> = (0..=n).collect();
    for i in 1..=m {
        let mut prev = row[0];
        row[0] = i;
        for j in 1..=n {
            let temp = row[j];
            row[j] = if a[i - 1] == b[j - 1] {
                prev
            } else {
                1 + prev.min(row[j]).min(row[j - 1])
            };
            prev = temp;
        }
    }
    row[n]
}

/// Strip a leading `www.` from a hostname (ASCII, already lowercased).
fn strip_www_prefix(host: &str) -> &str {
    host.strip_prefix("www.").unwrap_or(host)
}

/// A suspicious domain match returned by [`lookalike_check`].
pub struct LookalikeMatch {
    /// The registered profile domain that the candidate closely resembles.
    pub registered: String,
    /// Levenshtein edit distance between the normalised forms.
    pub edit_distance: usize,
}

/// Check whether `hostname` is a typosquat lookalike of any registered browser-profile
/// domain.
///
/// Returns the closest suspicious match (edit distance ≤ 1, non-exact) or `None`.
///
/// Rules:
/// - Wildcard patterns (`*.`-prefixed) are skipped — they cover legitimate subdomains.
/// - Leading `www.` is stripped from both sides before comparison.
/// - Comparison is case-insensitive (ASCII lowercase).
/// - Exact matches return `None` (already handled by profile-mapping logic).
pub fn lookalike_check(hostname: &str, profiles: &[(String, String)]) -> Option<LookalikeMatch> {
    const THRESHOLD: usize = 1;

    let candidate = strip_www_prefix(&hostname.to_ascii_lowercase()).to_string();
    let mut best: Option<LookalikeMatch> = None;

    for (registered_pattern, _) in profiles {
        // Skip wildcards — they are subdomain matchers, not canonical domain names.
        if registered_pattern.starts_with("*.") {
            continue;
        }
        let registered_lower = registered_pattern.to_ascii_lowercase();
        let registered = strip_www_prefix(&registered_lower);

        // Exact match → already allowed; not a phishing signal.
        if candidate == registered {
            continue;
        }

        let dist = edit_distance(&candidate, registered);
        if dist <= THRESHOLD && best.as_ref().is_none_or(|b| dist < b.edit_distance) {
            best = Some(LookalikeMatch {
                registered: registered_pattern.clone(),
                edit_distance: dist,
            });
        }
    }

    best
}

#[cfg(test)]
mod tests {
    use std::sync::Mutex;

    use super::*;

    /// `TSAFE_VAULT_DIR` is process-global; serialize tests that touch it.
    static PROFILE_TEST_ENV_LOCK: Mutex<()> = Mutex::new(());

    // ── Task 1.7: Profile metadata and protection ─────────────────────────────

    #[test]
    fn profile_meta_roundtrip() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                std::fs::create_dir_all(&vaults).unwrap();
                let meta = ProfileMeta {
                    description: Some("my dev vault".into()),
                    created_at: chrono::Utc::now(),
                    last_modified: chrono::Utc::now(),
                    is_protected: true,
                };
                write_profile_meta("dev", &meta).unwrap();
                let loaded = read_profile_meta("dev")
                    .unwrap()
                    .expect("meta should exist");
                assert_eq!(loaded.description.as_deref(), Some("my dev vault"));
                assert!(loaded.is_protected);
            },
        );
    }

    #[test]
    fn read_profile_meta_missing_returns_none() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                assert!(read_profile_meta("nonexistent").unwrap().is_none());
            },
        );
    }

    #[test]
    fn set_profile_protected_blocks_deletion_signal() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                std::fs::create_dir_all(&vaults).unwrap();
                // Not protected by default.
                assert!(!is_profile_protected("prod"));

                set_profile_protected("prod", true).unwrap();
                assert!(is_profile_protected("prod"));

                // Caller must check is_profile_protected; clearing protection lifts the block.
                set_profile_protected("prod", false).unwrap();
                assert!(!is_profile_protected("prod"));
            },
        );
    }

    #[test]
    fn ensure_profile_meta_creates_defaults_when_missing() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                std::fs::create_dir_all(&vaults).unwrap();
                let meta = ensure_profile_meta("newprofile").unwrap();
                assert!(!meta.is_protected);
                assert!(meta.description.is_none());
                // Calling again should return the persisted value.
                let meta2 = ensure_profile_meta("newprofile").unwrap();
                assert_eq!(meta.created_at, meta2.created_at);
            },
        );
    }

    // ── Task 1.6: Profile portability ─────────────────────────────────────────

    #[test]
    fn export_import_profile_roundtrip() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                std::fs::create_dir_all(&vaults).unwrap();

                // Create a minimal vault file (just needs to exist for export).
                let vault_content = b"fake-vault-bytes-for-testing";
                std::fs::write(vault_path("source"), vault_content).unwrap();

                let bundle_path = dir.path().join("source.bundle.json");
                let bundle_pw = b"export-password-123";

                export_profile("source", &bundle_path, bundle_pw).unwrap();
                assert!(bundle_path.exists(), "bundle file should be written");

                let imported_name =
                    import_profile(&bundle_path, Some("imported"), bundle_pw).unwrap();
                assert_eq!(imported_name, "imported");

                let imported_vault = vault_path("imported");
                assert!(imported_vault.exists(), "imported vault should exist");
                let imported_bytes = std::fs::read(&imported_vault).unwrap();
                assert_eq!(imported_bytes, vault_content);
            },
        );
    }

    #[test]
    fn export_import_uses_bundle_profile_name_when_dest_is_none() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                std::fs::create_dir_all(&vaults).unwrap();
                // Write a source vault under one name, export it,
                // then import to a different directory to avoid collision.
                std::fs::write(vault_path("srcprofile"), b"vault-data").unwrap();

                let bundle_path = dir.path().join("srcprofile.bundle.json");
                export_profile("srcprofile", &bundle_path, b"pw").unwrap();

                // Remove the source vault so the import-with-embedded-name doesn't collide.
                std::fs::remove_file(vault_path("srcprofile")).unwrap();

                let name = import_profile(&bundle_path, None, b"pw").unwrap();
                assert_eq!(name, "srcprofile");
                assert!(vault_path("srcprofile").exists());
            },
        );
    }

    #[test]
    fn import_with_wrong_password_fails() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                std::fs::create_dir_all(&vaults).unwrap();
                std::fs::write(vault_path("src"), b"vault-data").unwrap();
                let bundle_path = dir.path().join("src.bundle.json");
                export_profile("src", &bundle_path, b"correct-pw").unwrap();

                let result = import_profile(&bundle_path, Some("dst"), b"wrong-pw");
                assert!(
                    matches!(result, Err(SafeError::DecryptionFailed)),
                    "wrong password should fail decryption"
                );
            },
        );
    }

    #[test]
    fn export_nonexistent_profile_fails() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                let bundle_path = dir.path().join("out.json");
                let result = export_profile("no-such-profile", &bundle_path, b"pw");
                assert!(
                    matches!(result, Err(SafeError::VaultNotFound { .. })),
                    "expected VaultNotFound"
                );
            },
        );
    }

    #[test]
    fn import_into_existing_profile_fails() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                std::fs::create_dir_all(&vaults).unwrap();
                std::fs::write(vault_path("src"), b"v1").unwrap();
                let bundle_path = dir.path().join("bundle.json");
                export_profile("src", &bundle_path, b"pw").unwrap();

                // Pre-create the destination.
                std::fs::write(vault_path("dst"), b"pre-existing").unwrap();
                let result = import_profile(&bundle_path, Some("dst"), b"pw");
                assert!(
                    matches!(result, Err(SafeError::VaultAlreadyExists { .. })),
                    "expected VaultAlreadyExists"
                );
            },
        );
    }

    #[test]
    fn export_import_preserves_metadata() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                std::fs::create_dir_all(&vaults).unwrap();
                std::fs::write(vault_path("prod"), b"vault-data").unwrap();

                // Set up metadata with protection.
                set_profile_protected("prod", true).unwrap();
                let bundle_path = dir.path().join("prod.bundle.json");
                export_profile("prod", &bundle_path, b"pw").unwrap();

                let imported = import_profile(&bundle_path, Some("prod-copy"), b"pw").unwrap();
                assert_eq!(imported, "prod-copy");
                // Metadata should be restored.
                let meta = read_profile_meta("prod-copy").unwrap();
                assert!(meta.is_some(), "metadata should be imported");
                assert!(
                    meta.unwrap().is_protected,
                    "protection flag should be preserved"
                );
            },
        );
    }

    #[test]
    fn vault_dir_uses_env_override() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some("/tmp/tsafe-vault-test"), || {
            assert_eq!(vault_dir(), PathBuf::from("/tmp/tsafe-vault-test"));
        });
    }

    #[test]
    fn state_and_config_paths_follow_env_override() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some("/tmp/tsafe/vaults"), || {
            assert_eq!(app_data_dir(), PathBuf::from("/tmp/tsafe"));
            assert_eq!(app_state_dir(), PathBuf::from("/tmp/tsafe/state"));
            assert_eq!(audit_dir(), PathBuf::from("/tmp/tsafe/state/audit"));
            assert_eq!(config_path(), PathBuf::from("/tmp/tsafe/config.json"));
        });
    }

    #[test]
    fn vault_path_suffix() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        temp_env::with_var("TSAFE_VAULT_DIR", Some("/tmp/ts"), || {
            let p = vault_path("dev");
            assert!(p.to_string_lossy().ends_with("dev.vault"));
        });
    }

    #[test]
    fn rename_profile_snapshot_history_moves_and_reprefixes_snapshots() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                let src_dir = crate::snapshot::snapshot_dir("work");
                std::fs::create_dir_all(&src_dir).unwrap();
                std::fs::write(src_dir.join("work.vault.123.0000.snap"), b"one").unwrap();
                std::fs::write(src_dir.join("work.vault.124.0000.snap"), b"two").unwrap();
                std::fs::write(src_dir.join("keep.tmp"), b"tmp").unwrap();

                let migrated = rename_profile_snapshot_history("work", "prod").unwrap();

                assert!(migrated);
                assert!(!src_dir.exists());
                let dst_dir = crate::snapshot::snapshot_dir("prod");
                assert!(dst_dir.join("prod.vault.123.0000.snap").exists());
                assert!(dst_dir.join("prod.vault.124.0000.snap").exists());
                assert!(dst_dir.join("keep.tmp").exists());
                let listed = crate::snapshot::list("prod").unwrap();
                assert_eq!(listed.len(), 2);
                assert!(crate::snapshot::list("work").unwrap().is_empty());
            },
        );
    }

    #[test]
    fn rename_profile_snapshot_history_is_noop_when_source_missing() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                let migrated = rename_profile_snapshot_history("missing", "renamed").unwrap();
                assert!(!migrated);
                assert!(!crate::snapshot::snapshot_dir("renamed").exists());
            },
        );
    }

    #[test]
    fn rename_profile_snapshot_history_rejects_existing_destination() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                std::fs::create_dir_all(crate::snapshot::snapshot_dir("from")).unwrap();
                std::fs::create_dir_all(crate::snapshot::snapshot_dir("to")).unwrap();

                let err = rename_profile_snapshot_history("from", "to").unwrap_err();

                assert!(matches!(err, SafeError::InvalidVault { .. }));
            },
        );
    }

    #[test]
    fn validate_valid_names() {
        for n in ["dev", "prod-1", "my_profile", "ABC123"] {
            assert!(validate_profile_name(n).is_ok(), "{n} should be valid");
        }
    }

    #[test]
    fn validate_rejects_bad_names() {
        for n in ["", "has spaces", "has/slash", "path\\sep", "na:me"] {
            assert!(validate_profile_name(n).is_err(), "{n} should be invalid");
        }
    }

    #[test]
    fn backup_new_profile_passwords_config_roundtrip() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                assert!(get_backup_new_profile_passwords_to().is_none());
                set_backup_new_profile_passwords_to(Some("main")).unwrap();
                assert_eq!(
                    get_backup_new_profile_passwords_to().as_deref(),
                    Some("main")
                );
                set_backup_new_profile_passwords_to(None).unwrap();
                assert!(get_backup_new_profile_passwords_to().is_none());
            },
        );
    }

    #[test]
    fn exec_auto_redact_output_config_roundtrip() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                assert!(!get_exec_auto_redact_output());
                set_exec_auto_redact_output(true).unwrap();
                assert!(get_exec_auto_redact_output());
                set_exec_auto_redact_output(false).unwrap();
                assert!(!get_exec_auto_redact_output());
            },
        );
    }

    #[test]
    fn exec_mode_config_roundtrip() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                assert_eq!(get_exec_mode(), ExecMode::Custom);
                set_exec_mode(ExecMode::Hardened).unwrap();
                assert_eq!(get_exec_mode(), ExecMode::Hardened);
                set_exec_mode(ExecMode::Standard).unwrap();
                assert_eq!(get_exec_mode(), ExecMode::Standard);
            },
        );
    }

    #[test]
    fn exec_custom_settings_roundtrip() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                assert_eq!(get_exec_custom_inherit_mode(), ExecCustomInheritMode::Full);
                assert!(get_exec_custom_deny_dangerous_env()); // default is true (deny by default)

                set_exec_custom_inherit_mode(ExecCustomInheritMode::Minimal).unwrap();
                set_exec_custom_deny_dangerous_env(false).unwrap();

                assert_eq!(
                    get_exec_custom_inherit_mode(),
                    ExecCustomInheritMode::Minimal
                );
                assert!(!get_exec_custom_deny_dangerous_env()); // explicitly set to false
            },
        );
    }

    #[test]
    fn exec_extra_sensitive_parent_vars_roundtrip() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                assert!(get_exec_extra_sensitive_parent_vars().is_empty());
                add_exec_extra_sensitive_parent_var("OPENAI_API_KEY").unwrap();
                add_exec_extra_sensitive_parent_var("openai_api_key").unwrap();
                add_exec_extra_sensitive_parent_var("ANTHROPIC_API_KEY").unwrap();
                assert_eq!(
                    get_exec_extra_sensitive_parent_vars(),
                    vec![
                        "ANTHROPIC_API_KEY".to_string(),
                        "OPENAI_API_KEY".to_string()
                    ]
                );
                assert!(remove_exec_extra_sensitive_parent_var("OPENAI_API_KEY").unwrap());
                assert_eq!(
                    get_exec_extra_sensitive_parent_vars(),
                    vec!["ANTHROPIC_API_KEY".to_string()]
                );
                assert!(!remove_exec_extra_sensitive_parent_var("OPENAI_API_KEY").unwrap());
            },
        );
    }

    #[test]
    fn auto_quick_unlock_config_roundtrip() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                assert!(get_auto_quick_unlock());
                set_auto_quick_unlock(false).unwrap();
                assert!(!get_auto_quick_unlock());
                set_auto_quick_unlock(true).unwrap();
                assert!(get_auto_quick_unlock());
            },
        );
    }

    #[test]
    fn quick_unlock_retry_cooldown_roundtrip() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                assert_eq!(get_quick_unlock_retry_cooldown_secs(), 300);
                set_quick_unlock_retry_cooldown_secs(45).unwrap();
                assert_eq!(get_quick_unlock_retry_cooldown_secs(), 45);
                set_quick_unlock_retry_cooldown_secs(0).unwrap();
                assert_eq!(get_quick_unlock_retry_cooldown_secs(), 0);
            },
        );
    }

    #[test]
    fn resolve_browser_profile_prefers_exact_then_longest_wildcard() {
        let _guard = PROFILE_TEST_ENV_LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let vaults = dir.path().join("vaults");
        std::fs::create_dir_all(&vaults).unwrap();
        std::fs::write(
            vaults.join("browser-profiles.json"),
            r#"{
  "github.com": "work",
  "*.corp.example": "corp",
  "*.deep.corp.example": "deep"
}"#,
        )
        .unwrap();

        temp_env::with_var(
            "TSAFE_VAULT_DIR",
            Some(vaults.as_os_str().to_str().unwrap()),
            || {
                assert_eq!(
                    resolve_browser_profile("github.com").unwrap().as_deref(),
                    Some("work")
                );
                assert_eq!(
                    resolve_browser_profile("jira.corp.example")
                        .unwrap()
                        .as_deref(),
                    Some("corp")
                );
                assert_eq!(
                    resolve_browser_profile("login.deep.corp.example")
                        .unwrap()
                        .as_deref(),
                    Some("deep")
                );
                assert!(resolve_browser_profile("corp.example").unwrap().is_none());
                assert!(resolve_browser_profile("unknown.example")
                    .unwrap()
                    .is_none());
            },
        );
    }

    // ── edit_distance ──────────────────────────────────────────────────────────

    #[test]
    fn edit_distance_identical_strings_is_zero() {
        assert_eq!(edit_distance("paypal.com", "paypal.com"), 0);
        assert_eq!(edit_distance("", ""), 0);
    }

    #[test]
    fn edit_distance_single_substitution_is_one() {
        // '1' instead of 'l' — classic zero-to-letter swap
        assert_eq!(edit_distance("paypa1.com", "paypal.com"), 1);
        // one char off at the end
        assert_eq!(edit_distance("github.co", "github.com"), 1);
    }

    #[test]
    fn edit_distance_unrelated_domains_exceeds_threshold() {
        assert!(edit_distance("amazon.com", "paypal.com") > 1);
        assert!(edit_distance("example.org", "google.com") > 1);
    }

    // ── lookalike_check ────────────────────────────────────────────────────────

    fn profiles_fixture() -> Vec<(String, String)> {
        vec![
            ("paypal.com".into(), "finance".into()),
            ("github.com".into(), "work".into()),
            ("*.corp.example".into(), "corp".into()),
        ]
    }

    #[test]
    fn lookalike_check_exact_match_returns_none() {
        let result = lookalike_check("paypal.com", &profiles_fixture());
        assert!(
            result.is_none(),
            "exact match should not trigger phishing warning"
        );
    }

    #[test]
    fn lookalike_check_typosquat_returns_match() {
        // paypa1.com is 1 edit away from paypal.com
        let result = lookalike_check("paypa1.com", &profiles_fixture());
        assert!(
            result.is_some(),
            "typosquat should trigger phishing warning"
        );
        let m = result.unwrap();
        assert_eq!(m.registered, "paypal.com");
        assert_eq!(m.edit_distance, 1);
    }

    #[test]
    fn lookalike_check_www_prefix_stripped() {
        // www.paypa1.com should still match paypal.com after stripping www.
        let result = lookalike_check("www.paypa1.com", &profiles_fixture());
        assert!(result.is_some());
        assert_eq!(result.unwrap().registered, "paypal.com");
    }

    #[test]
    fn lookalike_check_unrelated_domain_returns_none() {
        let result = lookalike_check("totally-different.io", &profiles_fixture());
        assert!(
            result.is_none(),
            "unrelated domain should not trigger phishing warning"
        );
    }

    #[test]
    fn lookalike_check_skips_wildcard_patterns() {
        // A 1-edit lookalike of the wildcard suffix shouldn't be flagged
        // (wildcards are for subdomains, not canonical domain names)
        let result = lookalike_check("corp.exampl", &profiles_fixture());
        assert!(result.is_none(), "wildcard patterns should be skipped");
    }

    #[test]
    fn browser_hostname_fill_guard_accepts_normal_hosts() {
        assert!(browser_hostname_fill_guard("github.com").is_ok());
        assert!(browser_hostname_fill_guard("login.deep.corp.example.").is_ok());
    }

    #[test]
    fn browser_hostname_fill_guard_rejects_garbage() {
        assert_eq!(browser_hostname_fill_guard(""), Err("empty hostname"));
        assert_eq!(browser_hostname_fill_guard("   "), Err("empty hostname"));
        let long_label = format!("{}.com", "a".repeat(64));
        assert_eq!(
            browser_hostname_fill_guard(&long_label),
            Err("hostname label too long")
        );
        let many = (0..14)
            .map(|i| format!("l{i}"))
            .collect::<Vec<_>>()
            .join(".");
        assert_eq!(
            browser_hostname_fill_guard(&many),
            Err("too many hostname labels")
        );
        assert_eq!(
            browser_hostname_fill_guard("bad-.example.com"),
            Err("hostname label has invalid hyphen placement")
        );
    }

    #[test]
    fn browser_hostname_fill_guard_rejects_punycode_labels() {
        // The IDN-homoglyph attack vector that survives the ASCII-only check.
        // `xn--pyal-9ja.com` is ASCII but encodes Cyrillic confusables of
        // `paypal.com`. Reject all xn-- labels until we ship full IDN support.
        assert_eq!(
            browser_hostname_fill_guard("xn--pyal-9ja.com"),
            Err("punycode/IDN labels not supported (post-v1)")
        );
        assert_eq!(
            browser_hostname_fill_guard("login.xn--anything-9ja.com"),
            Err("punycode/IDN labels not supported (post-v1)")
        );
        // Case-insensitive: lowercased before the prefix check.
        assert_eq!(
            browser_hostname_fill_guard("XN--PYAL-9JA.COM"),
            Err("punycode/IDN labels not supported (post-v1)")
        );
        // Existing ASCII edit-distance attacks (paypa1.com vs paypal.com) are
        // unrelated to this guard — they're caught downstream by lookalike_check.
        assert!(browser_hostname_fill_guard("paypa1.com").is_ok());
    }
}
