//! Runtime config loading and normalization.
//!
//! This module accepts sparse user config and expands it into a fully resolved
//! runtime shape. Paths, config aliases, and defaults are normalized here so
//! the rest of the program can operate on one consistent configuration model.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::time::Duration;

use directories::UserDirs;
use serde::Deserialize;

use crate::infra::error::{CriewError, ErrorCode, Result};

pub const DEFAULT_SOURCE_MAILBOX: &str = "linux-kernel";
pub const IMAP_INBOX_MAILBOX: &str = "INBOX";
pub const DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS: u64 = 30;
const DEFAULT_LORE_BASE_URL: &str = "https://lore.kernel.org";
const DEFAULT_RUNTIME_DIR_NAME: &str = ".criew";
const DEFAULT_CONFIG_FILE_NAME: &str = "criew-config.toml";
const LEGACY_CONFIG_FILE_NAME: &str = "config.toml";
const DEFAULT_DATABASE_FILE_NAME: &str = "criew.db";
const GIT_EMAIL_LOOKUP_ARGS: &[&str] = &["config", "user.email"];
const DEFAULT_CONFIG_TEMPLATE: &str = r#"# Auto-generated by CRIEW.
# You can keep this file minimal; omitted storage paths default under ~/.criew.

[source]
mailbox = "linux-kernel"

[ui]
startup_sync = true
# keymap = "default" # Supported: default, vim, custom
# keymap_base = "default" # Base scheme used when keymap = "custom"
# [ui.custom_keymap]
# focus_prev = ["j"]
# focus_next = ["l"]
# move_up = ["i"]
# move_down = ["k"]
# jump_top = ["g", "g"]
# jump_bottom = ["G"]
# quick_quit = ["q", "q"]
# inbox_auto_sync_interval_secs = 30

[logging]
filter = "info"
"#;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImapEncryption {
    #[serde(alias = "ssl")]
    Tls,
    Starttls,
    None,
}

impl ImapEncryption {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Tls => "tls",
            Self::Starttls => "starttls",
            Self::None => "none",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum UiKeymap {
    #[default]
    Default,
    Vim,
    Custom,
}

impl UiKeymap {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Default => "default",
            Self::Vim => "vim",
            Self::Custom => "custom",
        }
    }

    pub fn default_base(self) -> UiKeymapBase {
        match self {
            Self::Vim => UiKeymapBase::Vim,
            Self::Default | Self::Custom => UiKeymapBase::Default,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum UiKeymapBase {
    #[default]
    Default,
    Vim,
}

impl UiKeymapBase {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Default => "default",
            Self::Vim => "vim",
        }
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UiCustomKeymapConfig {
    pub focus_prev: Option<Vec<String>>,
    pub focus_next: Option<Vec<String>>,
    pub move_up: Option<Vec<String>>,
    pub move_down: Option<Vec<String>>,
    pub jump_top: Option<Vec<String>>,
    pub jump_bottom: Option<Vec<String>>,
    pub quick_quit: Option<Vec<String>>,
}

#[derive(Debug, Clone, Default)]
pub struct ImapConfig {
    pub email: Option<String>,
    pub user: Option<String>,
    pub pass: Option<String>,
    pub server: Option<String>,
    pub server_port: Option<u16>,
    pub encryption: Option<ImapEncryption>,
    pub proxy: Option<String>,
}

impl ImapConfig {
    pub fn login_user(&self) -> Option<&str> {
        self.user.as_deref().or(self.email.as_deref())
    }

    pub fn is_complete(&self) -> bool {
        self.missing_required_fields().is_empty()
    }

    pub fn missing_required_fields(&self) -> Vec<&'static str> {
        let mut missing = Vec::new();
        if self.login_user().is_none() {
            missing.push("imap.user");
        }
        if self.pass.is_none() {
            missing.push("imap.pass");
        }
        if self.server.is_none() {
            missing.push("imap.server");
        }
        if self.server_port.is_none() {
            missing.push("imap.serverport");
        }
        if self.encryption.is_none() {
            missing.push("imap.encryption");
        }
        missing
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelfEmailSource {
    CriewImapConfig,
    GitConfig,
}

impl SelfEmailSource {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::CriewImapConfig => "imap.email",
            Self::GitConfig => "git config user.email",
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct SelfEmailResolution {
    pub email: Option<String>,
    pub source: Option<SelfEmailSource>,
    pub git_error: Option<String>,
}

#[derive(Debug, Clone)]
pub struct RuntimeConfig {
    pub config_path: PathBuf,
    pub data_dir: PathBuf,
    pub database_path: PathBuf,
    pub raw_mail_dir: PathBuf,
    pub patch_dir: PathBuf,
    pub log_dir: PathBuf,
    pub b4_path: Option<PathBuf>,
    pub log_filter: String,
    pub source_mailbox: String,
    pub imap: ImapConfig,
    pub lore_base_url: String,
    pub startup_sync: bool,
    pub ui_keymap: UiKeymap,
    pub ui_keymap_base: UiKeymapBase,
    pub ui_custom_keymap: UiCustomKeymapConfig,
    pub inbox_auto_sync_interval_secs: u64,
    pub kernel_trees: Vec<PathBuf>,
}

impl RuntimeConfig {
    pub fn default_active_mailbox(&self) -> &str {
        if self.imap.is_complete() {
            IMAP_INBOX_MAILBOX
        } else {
            &self.source_mailbox
        }
    }

    pub fn inbox_auto_sync_interval(&self) -> Duration {
        Duration::from_secs(self.inbox_auto_sync_interval_secs)
    }
}

#[derive(Debug, Default, Deserialize)]
struct FileConfig {
    #[serde(default)]
    storage: StorageConfig,
    #[serde(default)]
    b4: B4Config,
    #[serde(default)]
    logging: LoggingConfig,
    #[serde(default)]
    source: SourceConfig,
    #[serde(default)]
    ui: UiConfig,
    #[serde(default)]
    imap: ImapFileConfig,
    #[serde(default)]
    kernel: KernelConfig,
}

#[derive(Debug, Default, Deserialize)]
struct StorageConfig {
    data_dir: Option<PathBuf>,
    database: Option<PathBuf>,
    patch_dir: Option<PathBuf>,
    raw_mail_dir: Option<PathBuf>,
}

#[derive(Debug, Default, Deserialize)]
struct B4Config {
    path: Option<PathBuf>,
}

#[derive(Debug, Default, Deserialize)]
struct LoggingConfig {
    filter: Option<String>,
    dir: Option<PathBuf>,
}

#[derive(Debug, Default, Deserialize)]
struct SourceConfig {
    mailbox: Option<String>,
    lore_base_url: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
struct UiConfig {
    startup_sync: Option<bool>,
    keymap: Option<UiKeymap>,
    keymap_base: Option<UiKeymapBase>,
    #[serde(default)]
    custom_keymap: UiCustomKeymapFileConfig,
    inbox_auto_sync_interval_secs: Option<u64>,
}

#[derive(Debug, Default, Deserialize)]
struct UiCustomKeymapFileConfig {
    focus_prev: Option<Vec<String>>,
    focus_next: Option<Vec<String>>,
    move_up: Option<Vec<String>>,
    move_down: Option<Vec<String>>,
    jump_top: Option<Vec<String>>,
    jump_bottom: Option<Vec<String>>,
    quick_quit: Option<Vec<String>>,
}

#[derive(Debug, Default, Deserialize)]
struct ImapFileConfig {
    mailbox: Option<String>,
    email: Option<String>,
    #[serde(alias = "imapuser")]
    user: Option<String>,
    #[serde(alias = "imappass")]
    pass: Option<String>,
    #[serde(alias = "imapserver")]
    server: Option<String>,
    #[serde(alias = "imapserverport")]
    serverport: Option<u16>,
    #[serde(alias = "imapencryption")]
    encryption: Option<ImapEncryption>,
    proxy: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
struct KernelConfig {
    tree: Option<PathBuf>,
    #[serde(default)]
    trees: Vec<PathBuf>,
}

pub fn load(config_override: Option<&Path>) -> Result<RuntimeConfig> {
    let home_dir = UserDirs::new()
        .map(|dirs| dirs.home_dir().to_path_buf())
        .ok_or_else(|| {
            CriewError::new(
                ErrorCode::ConfigRead,
                "failed to determine HOME directory for criew runtime",
            )
        })?;

    load_with_home(config_override, &home_dir)
}

pub(crate) fn load_from_document(config_path: &Path, content: &str) -> Result<RuntimeConfig> {
    let home_dir = UserDirs::new()
        .map(|dirs| dirs.home_dir().to_path_buf())
        .ok_or_else(|| {
            CriewError::new(
                ErrorCode::ConfigRead,
                "failed to determine HOME directory for criew runtime",
            )
        })?;
    let default_root = home_dir.join(DEFAULT_RUNTIME_DIR_NAME);
    let runtime_root = runtime_root_from_config_path(config_path, &default_root);
    let file_config = parse_config_document(content, config_path)?;

    build_runtime_config(config_path.to_path_buf(), file_config, &runtime_root)
}

fn load_with_home(config_override: Option<&Path>, home_dir: &Path) -> Result<RuntimeConfig> {
    let default_root = home_dir.join(DEFAULT_RUNTIME_DIR_NAME);

    let (config_path, runtime_root) = if let Some(path) = config_override {
        let config_path = path.to_path_buf();
        let runtime_root = runtime_root_from_config_path(&config_path, &default_root);
        (config_path, runtime_root)
    } else {
        // Missing config is not an error on first run: auto-select or
        // initialize the default path so the rest of startup can operate on one
        // concrete file location.
        select_or_initialize_default_config_path(home_dir)?
    };

    let file_config = if config_path.exists() {
        parse_config_file(&config_path)?
    } else {
        FileConfig::default()
    };

    build_runtime_config(config_path, file_config, &runtime_root)
}

fn build_runtime_config(
    config_path: PathBuf,
    file_config: FileConfig,
    default_root: &Path,
) -> Result<RuntimeConfig> {
    // Resolve user-provided relative paths against the config file location
    // instead of the process cwd so `criew --config <file>` stays portable.
    let config_base_dir = config_path
        .parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| default_root.to_path_buf());

    let data_dir = file_config
        .storage
        .data_dir
        .map(|path| resolve_path(&config_base_dir, path))
        .unwrap_or_else(|| default_root.to_path_buf());

    let database_path = file_config
        .storage
        .database
        .map(|path| resolve_path(&config_base_dir, path))
        .unwrap_or_else(|| {
            data_dir
                .join("db")
                .join(default_database_file_name(default_root))
        });

    let raw_mail_dir = file_config
        .storage
        .raw_mail_dir
        .map(|path| resolve_path(&config_base_dir, path))
        .unwrap_or_else(|| data_dir.join("mail/raw"));

    let patch_dir = file_config
        .storage
        .patch_dir
        .map(|path| resolve_path(&config_base_dir, path))
        .unwrap_or_else(|| data_dir.join("patches"));

    let log_dir = file_config
        .logging
        .dir
        .map(|path| resolve_path(&config_base_dir, path))
        .unwrap_or_else(|| data_dir.join("logs"));

    let b4_path = file_config
        .b4
        .path
        .map(|path| resolve_path(&config_base_dir, path));

    let log_filter =
        normalize_optional_string(file_config.logging.filter).unwrap_or_else(|| "info".to_string());

    let source_mailbox = normalize_optional_string(
        file_config
            .source
            .mailbox
            .clone()
            .or(file_config.imap.mailbox.clone()),
    )
    // Keep honoring the historical `[imap].mailbox` alias while routing new
    // config toward the source-oriented name used by the current runtime model.
    .unwrap_or_else(|| DEFAULT_SOURCE_MAILBOX.to_string());

    let imap = build_imap_config(&file_config.imap, &config_path)?;

    let lore_base_url = normalize_optional_string(file_config.source.lore_base_url)
        .unwrap_or_else(|| DEFAULT_LORE_BASE_URL.to_string());
    let startup_sync = file_config.ui.startup_sync.unwrap_or(true);
    let ui_keymap = file_config.ui.keymap.unwrap_or_default();
    let ui_keymap_base = file_config
        .ui
        .keymap_base
        .unwrap_or_else(|| ui_keymap.default_base());
    let ui_custom_keymap = build_ui_custom_keymap_config(&file_config.ui.custom_keymap)?;
    validate_ui_custom_keymap_config(ui_keymap_base, &ui_custom_keymap)?;
    let inbox_auto_sync_interval_secs = file_config
        .ui
        .inbox_auto_sync_interval_secs
        .unwrap_or(DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS);
    if inbox_auto_sync_interval_secs == 0 {
        return Err(CriewError::new(
            ErrorCode::ConfigParse,
            "ui.inbox_auto_sync_interval_secs must be greater than 0",
        ));
    }

    let mut kernel_trees = Vec::new();
    if let Some(tree) = file_config.kernel.tree {
        kernel_trees.push(resolve_path(&config_base_dir, tree));
    }
    for tree in file_config.kernel.trees {
        let resolved = resolve_path(&config_base_dir, tree);
        // Preserve declaration order while suppressing duplicates so the first
        // configured tree keeps its priority in the UI and palette.
        if !kernel_trees.iter().any(|existing| existing == &resolved) {
            kernel_trees.push(resolved);
        }
    }

    Ok(RuntimeConfig {
        config_path,
        data_dir,
        database_path,
        raw_mail_dir,
        patch_dir,
        log_dir,
        b4_path,
        log_filter,
        source_mailbox,
        imap,
        lore_base_url,
        startup_sync,
        ui_keymap,
        ui_keymap_base,
        ui_custom_keymap,
        inbox_auto_sync_interval_secs,
        kernel_trees,
    })
}

fn select_or_initialize_default_config_path(home_dir: &Path) -> Result<(PathBuf, PathBuf)> {
    let default_root = home_dir.join(DEFAULT_RUNTIME_DIR_NAME);

    let candidates = [
        (&default_root, DEFAULT_CONFIG_FILE_NAME),
        (&default_root, LEGACY_CONFIG_FILE_NAME),
    ];

    for (runtime_root, file_name) in candidates {
        let candidate = runtime_root.join(file_name);
        if candidate.exists() {
            return Ok((candidate, runtime_root.to_path_buf()));
        }
    }

    // Auto-initialize only after both supported locations are absent. That
    // keeps first-run setup zero-touch without overwriting user-managed files.
    let preferred_path = default_root.join(DEFAULT_CONFIG_FILE_NAME);
    write_default_config_file(&preferred_path)?;
    Ok((preferred_path, default_root))
}

fn write_default_config_file(path: &Path) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|error| {
            CriewError::with_source(
                ErrorCode::Io,
                format!("failed to create config directory {}", parent.display()),
                error,
            )
        })?;
    }

    fs::write(path, DEFAULT_CONFIG_TEMPLATE).map_err(|error| {
        CriewError::with_source(
            ErrorCode::ConfigRead,
            format!("failed to write default config file {}", path.display()),
            error,
        )
    })
}

fn parse_config_file(path: &Path) -> Result<FileConfig> {
    let content = fs::read_to_string(path).map_err(|error| {
        CriewError::with_source(
            ErrorCode::ConfigRead,
            format!("failed to read config file {}", path.display()),
            error,
        )
    })?;

    parse_config_document(&content, path)
}

fn parse_config_document(content: &str, path: &Path) -> Result<FileConfig> {
    if content.trim().is_empty() {
        return Ok(FileConfig::default());
    }

    toml::from_str::<FileConfig>(content).map_err(|error| {
        CriewError::with_source(
            ErrorCode::ConfigParse,
            format!("failed to parse TOML config {}", path.display()),
            error,
        )
    })
}

fn build_imap_config(file_config: &ImapFileConfig, config_path: &Path) -> Result<ImapConfig> {
    build_imap_config_with_env(file_config, config_path, |key| std::env::var(key).ok())
}

fn build_ui_custom_keymap_config(
    file_config: &UiCustomKeymapFileConfig,
) -> Result<UiCustomKeymapConfig> {
    Ok(UiCustomKeymapConfig {
        focus_prev: normalize_ui_custom_keymap_binding(
            "ui.custom_keymap.focus_prev",
            file_config.focus_prev.clone(),
            1,
            1,
        )?,
        focus_next: normalize_ui_custom_keymap_binding(
            "ui.custom_keymap.focus_next",
            file_config.focus_next.clone(),
            1,
            1,
        )?,
        move_up: normalize_ui_custom_keymap_binding(
            "ui.custom_keymap.move_up",
            file_config.move_up.clone(),
            1,
            1,
        )?,
        move_down: normalize_ui_custom_keymap_binding(
            "ui.custom_keymap.move_down",
            file_config.move_down.clone(),
            1,
            1,
        )?,
        jump_top: normalize_ui_custom_keymap_binding(
            "ui.custom_keymap.jump_top",
            file_config.jump_top.clone(),
            1,
            2,
        )?,
        jump_bottom: normalize_ui_custom_keymap_binding(
            "ui.custom_keymap.jump_bottom",
            file_config.jump_bottom.clone(),
            1,
            2,
        )?,
        quick_quit: normalize_ui_custom_keymap_binding(
            "ui.custom_keymap.quick_quit",
            file_config.quick_quit.clone(),
            1,
            2,
        )?,
    })
}

fn normalize_ui_custom_keymap_binding(
    key: &str,
    tokens: Option<Vec<String>>,
    min_len: usize,
    max_len: usize,
) -> Result<Option<Vec<String>>> {
    let Some(tokens) = tokens else {
        return Ok(None);
    };
    if tokens.len() < min_len || tokens.len() > max_len {
        return Err(CriewError::new(
            ErrorCode::ConfigParse,
            format!("{key} must contain between {min_len} and {max_len} keys"),
        ));
    }

    let mut normalized = Vec::with_capacity(tokens.len());
    for token in tokens {
        let Some(character) = normalize_ui_custom_keymap_token(&token) else {
            return Err(CriewError::new(
                ErrorCode::ConfigParse,
                format!("{key} contains an unsupported key token: {token}"),
            ));
        };
        if is_reserved_main_page_keymap_character(character) {
            return Err(CriewError::new(
                ErrorCode::ConfigParse,
                format!("{key} uses reserved key {character}"),
            ));
        }
        normalized.push(character.to_string());
    }

    Ok(Some(normalized))
}

fn normalize_ui_custom_keymap_token(token: &str) -> Option<char> {
    let trimmed = token.trim();
    let mut characters = trimmed.chars();
    let character = characters.next()?;
    if characters.next().is_some()
        || character.is_ascii_control()
        || character.is_ascii_whitespace()
        || character.is_ascii_digit()
    {
        return None;
    }

    Some(character)
}

fn is_reserved_main_page_keymap_character(character: char) -> bool {
    matches!(
        character,
        ':' | '/'
            | 'e'
            | 'r'
            | 'a'
            | 'd'
            | 'u'
            | 'y'
            | 'n'
            | '['
            | ']'
            | '{'
            | '}'
            | 'E'
            | '-'
            | '='
            | '+'
    )
}

fn validate_ui_custom_keymap_config(
    base: UiKeymapBase,
    custom: &UiCustomKeymapConfig,
) -> Result<()> {
    let mut bindings = vec![
        (
            "focus_prev",
            resolved_ui_keymap_binding(base, custom.focus_prev.as_ref(), "focus_prev")
                .expect("focus_prev should always resolve"),
        ),
        (
            "focus_next",
            resolved_ui_keymap_binding(base, custom.focus_next.as_ref(), "focus_next")
                .expect("focus_next should always resolve"),
        ),
        (
            "move_up",
            resolved_ui_keymap_binding(base, custom.move_up.as_ref(), "move_up")
                .expect("move_up should always resolve"),
        ),
        (
            "move_down",
            resolved_ui_keymap_binding(base, custom.move_down.as_ref(), "move_down")
                .expect("move_down should always resolve"),
        ),
    ];

    if let Some(binding) = resolved_ui_keymap_binding(base, custom.jump_top.as_ref(), "jump_top") {
        bindings.push(("jump_top", binding));
    }
    if let Some(binding) =
        resolved_ui_keymap_binding(base, custom.jump_bottom.as_ref(), "jump_bottom")
    {
        bindings.push(("jump_bottom", binding));
    }
    if let Some(binding) =
        resolved_ui_keymap_binding(base, custom.quick_quit.as_ref(), "quick_quit")
    {
        bindings.push(("quick_quit", binding));
    }

    for (index, (left_name, left_binding)) in bindings.iter().enumerate() {
        for (right_name, right_binding) in bindings.iter().skip(index + 1) {
            if left_binding == right_binding {
                return Err(CriewError::new(
                    ErrorCode::ConfigParse,
                    format!("ui.custom_keymap.{left_name} conflicts with {right_name}"),
                ));
            }
            if binding_is_prefix(left_binding, right_binding)
                || binding_is_prefix(right_binding, left_binding)
            {
                return Err(CriewError::new(
                    ErrorCode::ConfigParse,
                    format!("ui.custom_keymap.{left_name} has a prefix conflict with {right_name}"),
                ));
            }
        }
    }

    Ok(())
}

fn resolved_ui_keymap_binding<'a>(
    base: UiKeymapBase,
    custom: Option<&'a Vec<String>>,
    action: &str,
) -> Option<Vec<&'a str>> {
    if let Some(binding) = custom {
        return Some(binding.iter().map(String::as_str).collect());
    }

    preset_ui_keymap_binding(base, action).map(|binding| binding.to_vec())
}

fn preset_ui_keymap_binding(base: UiKeymapBase, action: &str) -> Option<&'static [&'static str]> {
    match (base, action) {
        (UiKeymapBase::Default, "focus_prev") => Some(&["j"]),
        (UiKeymapBase::Default, "focus_next") => Some(&["l"]),
        (UiKeymapBase::Default, "move_up") => Some(&["i"]),
        (UiKeymapBase::Default, "move_down") => Some(&["k"]),
        (UiKeymapBase::Default, "jump_top") => None,
        (UiKeymapBase::Default, "jump_bottom") => None,
        (UiKeymapBase::Default, "quick_quit") => None,
        (UiKeymapBase::Vim, "focus_prev") => Some(&["h"]),
        (UiKeymapBase::Vim, "focus_next") => Some(&["l"]),
        (UiKeymapBase::Vim, "move_up") => Some(&["k"]),
        (UiKeymapBase::Vim, "move_down") => Some(&["j"]),
        (UiKeymapBase::Vim, "jump_top") => Some(&["g", "g"]),
        (UiKeymapBase::Vim, "jump_bottom") => Some(&["G"]),
        (UiKeymapBase::Vim, "quick_quit") => Some(&["q", "q"]),
        _ => unreachable!("unexpected keymap action"),
    }
}

fn binding_is_prefix(left: &[&str], right: &[&str]) -> bool {
    left.len() < right.len() && right.starts_with(left)
}

fn build_imap_config_with_env<F>(
    file_config: &ImapFileConfig,
    config_path: &Path,
    env_lookup: F,
) -> Result<ImapConfig>
where
    F: Fn(&str) -> Option<String>,
{
    let email = normalize_optional_string(file_config.email.clone());
    let config = ImapConfig {
        email: email.clone(),
        user: normalize_optional_string(file_config.user.clone()).or(email),
        pass: normalize_optional_string(file_config.pass.clone()),
        server: normalize_optional_string(file_config.server.clone()),
        server_port: file_config.serverport,
        encryption: file_config.encryption,
        proxy: normalize_optional_string(file_config.proxy.clone()).or_else(|| {
            // Check CRIEW-specific proxy configuration first, then honor the
            // common shell proxy variables so existing environments work
            // without duplicating config in two places.
            [
                "CRIEW_IMAP_PROXY",
                "ALL_PROXY",
                "all_proxy",
                "HTTPS_PROXY",
                "https_proxy",
                "HTTP_PROXY",
                "http_proxy",
            ]
            .into_iter()
            .find_map(&env_lookup)
            .and_then(|value| normalize_optional_string(Some(value)))
        }),
    };

    if config.server_port == Some(0) {
        return Err(CriewError::new(
            ErrorCode::ConfigParse,
            format!(
                "invalid IMAP config in {}: imap.serverport must be between 1 and 65535",
                config_path.display()
            ),
        ));
    }

    Ok(config)
}

fn normalize_optional_string(value: Option<String>) -> Option<String> {
    value.and_then(|value| {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed.to_string())
        }
    })
}

fn resolve_path(base_dir: &Path, candidate: PathBuf) -> PathBuf {
    if candidate.is_absolute() {
        candidate
    } else {
        base_dir.join(candidate)
    }
}

fn runtime_root_from_config_path(config_path: &Path, default_root: &Path) -> PathBuf {
    if let Some(parent) = config_path.parent() {
        let Some(file_name) = parent.file_name().and_then(|value| value.to_str()) else {
            return default_root.to_path_buf();
        };
        if file_name == DEFAULT_RUNTIME_DIR_NAME {
            return parent.to_path_buf();
        }
    }

    default_root.to_path_buf()
}

fn default_database_file_name(runtime_root: &Path) -> &'static str {
    let _ = runtime_root;
    DEFAULT_DATABASE_FILE_NAME
}

pub fn resolve_self_email(config: &RuntimeConfig) -> SelfEmailResolution {
    resolve_self_email_with(config, git_user_email)
}

fn resolve_self_email_with<F>(config: &RuntimeConfig, git_lookup: F) -> SelfEmailResolution
where
    F: FnOnce() -> std::result::Result<Option<String>, String>,
{
    // Prefer CRIEW config over git config so reply identity follows the mail
    // account actually used for sync when both are present.
    if let Some(email) = config.imap.email.as_ref() {
        return SelfEmailResolution {
            email: Some(email.clone()),
            source: Some(SelfEmailSource::CriewImapConfig),
            git_error: None,
        };
    }

    match git_lookup() {
        Ok(Some(email)) => SelfEmailResolution {
            email: Some(email),
            source: Some(SelfEmailSource::GitConfig),
            git_error: None,
        },
        Ok(None) => SelfEmailResolution::default(),
        Err(error) => SelfEmailResolution {
            email: None,
            source: None,
            git_error: Some(error),
        },
    }
}

fn git_user_email() -> std::result::Result<Option<String>, String> {
    let output = ProcessCommand::new("git")
        .args(GIT_EMAIL_LOOKUP_ARGS)
        .output()
        .map_err(|error| format!("failed to run git config user.email: {error}"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        return Err(if stderr.is_empty() {
            format!("git config user.email exited with {}", output.status)
        } else {
            format!("git config user.email failed: {stderr}")
        });
    }

    let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if value.is_empty() {
        Ok(None)
    } else {
        Ok(Some(value))
    }
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::Path;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::{
        DEFAULT_CONFIG_FILE_NAME, DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, IMAP_INBOX_MAILBOX,
        ImapFileConfig, LEGACY_CONFIG_FILE_NAME, SelfEmailSource, UiKeymap, UiKeymapBase,
        build_imap_config_with_env, load, load_with_home, resolve_self_email_with,
    };

    fn temp_dir(label: &str) -> PathBuf {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time")
            .as_nanos();
        let path = std::env::temp_dir().join(format!("criew-{label}-{nonce}"));
        fs::create_dir_all(&path).expect("create temp dir");
        path
    }

    #[test]
    fn resolves_relative_paths_from_config_dir() {
        let base = temp_dir("config-relative");
        let config_path = base.join("config.toml");

        fs::write(
            &config_path,
            r#"
[storage]
data_dir = "./state"
database = "./state/db/criew.sqlite"
patch_dir = "./state/patches"
raw_mail_dir = "./state/raw"

[b4]
path = "./bin/b4"

[logging]
filter = "debug"
dir = "./state/logs"

[source]
mailbox = "linux-kernel"
lore_base_url = "https://lore.kernel.org"

[ui]
startup_sync = false
keymap = "vim"
inbox_auto_sync_interval_secs = 45

[kernel]
tree = "./linux"
trees = ["./linux-next"]
"#,
        )
        .expect("write config");

        let loaded = load(Some(&config_path)).expect("load config");

        assert_eq!(loaded.data_dir, base.join("./state"));
        assert_eq!(loaded.database_path, base.join("./state/db/criew.sqlite"));
        assert_eq!(loaded.patch_dir, base.join("./state/patches"));
        assert_eq!(loaded.raw_mail_dir, base.join("./state/raw"));
        assert_eq!(loaded.log_dir, base.join("./state/logs"));
        assert_eq!(loaded.b4_path, Some(base.join("./bin/b4")));
        assert_eq!(loaded.log_filter, "debug");
        assert_eq!(loaded.source_mailbox, "linux-kernel");
        assert_eq!(loaded.lore_base_url, "https://lore.kernel.org");
        assert!(!loaded.startup_sync);
        assert_eq!(loaded.ui_keymap, UiKeymap::Vim);
        assert_eq!(loaded.inbox_auto_sync_interval_secs, 45);
        assert_eq!(
            loaded.kernel_trees,
            vec![base.join("./linux"), base.join("./linux-next")]
        );
        assert!(!loaded.imap.is_complete());

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn creates_default_config_file_under_criew_root() {
        let home = temp_dir("config-default-home");

        let loaded = load_with_home(None, &home).expect("load config");
        let expected_config_path = home.join(".criew").join(DEFAULT_CONFIG_FILE_NAME);

        assert_eq!(loaded.config_path, expected_config_path);
        assert!(loaded.config_path.exists());
        assert_eq!(loaded.data_dir, home.join(".criew"));
        assert_eq!(loaded.log_dir, home.join(".criew/logs"));
        assert_eq!(loaded.raw_mail_dir, home.join(".criew/mail/raw"));
        assert_eq!(loaded.patch_dir, home.join(".criew/patches"));
        assert_eq!(loaded.database_path, home.join(".criew/db/criew.db"));
        assert!(loaded.startup_sync);
        assert_eq!(loaded.ui_keymap, UiKeymap::Default);
        assert_eq!(
            loaded.inbox_auto_sync_interval_secs,
            DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS
        );
        assert_eq!(loaded.default_active_mailbox(), "linux-kernel");

        let content = fs::read_to_string(&loaded.config_path).expect("read generated config");
        assert!(content.contains("[source]"));
        assert!(content.contains("mailbox = \"linux-kernel\""));

        let _ = fs::remove_dir_all(home);
    }

    #[test]
    fn loads_custom_ui_keymap_from_config() {
        let base = temp_dir("config-custom-keymap");
        let config_path = base.join("config.toml");

        fs::write(
            &config_path,
            r#"
[ui]
keymap = "custom"
"#,
        )
        .expect("write config");

        let loaded = load(Some(&config_path)).expect("load config");

        assert_eq!(loaded.ui_keymap, UiKeymap::Custom);

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn loads_custom_ui_keymap_overrides_and_base_from_config() {
        let base = temp_dir("config-custom-keymap-overrides");
        let config_path = base.join("config.toml");

        fs::write(
            &config_path,
            r#"
[ui]
keymap = "custom"
keymap_base = "vim"

[ui.custom_keymap]
focus_prev = ["x"]
quick_quit = ["z", "z"]
"#,
        )
        .expect("write config");

        let loaded = load(Some(&config_path)).expect("load config");

        assert_eq!(loaded.ui_keymap, UiKeymap::Custom);
        assert_eq!(loaded.ui_keymap_base, UiKeymapBase::Vim);
        assert_eq!(
            loaded.ui_custom_keymap.focus_prev,
            Some(vec!["x".to_string()])
        );
        assert_eq!(
            loaded.ui_custom_keymap.quick_quit,
            Some(vec!["z".to_string(), "z".to_string()])
        );

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn infers_custom_keymap_base_from_vim_scheme_when_omitted() {
        let base = temp_dir("config-vim-infers-custom-base");
        let config_path = base.join("config.toml");

        fs::write(
            &config_path,
            r#"
[ui]
keymap = "vim"
"#,
        )
        .expect("write config");

        let loaded = load(Some(&config_path)).expect("load config");

        assert_eq!(loaded.ui_keymap, UiKeymap::Vim);
        assert_eq!(loaded.ui_keymap_base, UiKeymapBase::Vim);

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn rejects_custom_keymap_prefix_conflicts_against_base() {
        let base = temp_dir("config-custom-keymap-conflict");
        let config_path = base.join("config.toml");

        fs::write(
            &config_path,
            r#"
[ui]
keymap = "custom"
keymap_base = "vim"

[ui.custom_keymap]
focus_prev = ["g"]
"#,
        )
        .expect("write config");

        let error = match load(Some(&config_path)) {
            Ok(_) => panic!("conflicting config should fail"),
            Err(error) => error,
        };
        assert!(
            error.to_string().contains("prefix conflict"),
            "unexpected error: {error}"
        );

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn falls_back_to_config_alias_filename_when_present() {
        let home = temp_dir("config-legacy-home");
        let legacy_config_path = home.join(".criew").join(LEGACY_CONFIG_FILE_NAME);
        fs::create_dir_all(legacy_config_path.parent().expect("legacy config parent"))
            .expect("create legacy config dir");
        fs::write(
            &legacy_config_path,
            r#"
[source]
mailbox = "legacy-mailbox"
"#,
        )
        .expect("write legacy config");

        let loaded = load_with_home(None, &home).expect("load config");

        assert_eq!(loaded.config_path, legacy_config_path);
        assert_eq!(loaded.source_mailbox, "legacy-mailbox");
        assert!(!home.join(".criew").join(DEFAULT_CONFIG_FILE_NAME).exists());

        let _ = fs::remove_dir_all(home);
    }

    #[test]
    fn loads_imap_config_with_modern_and_legacy_keys() {
        let base = temp_dir("imap-config");
        let config_path = base.join("config.toml");
        fs::write(
            &config_path,
            r#"
[source]
mailbox = "linux-kernel"

[imap]
email = "me@example.com"
imapuser = "imap-user"
imappass = "imap-pass"
imapserver = "imap.example.com"
imapserverport = 993
imapencryption = "tls"
"#,
        )
        .expect("write config");

        let loaded = load(Some(&config_path)).expect("load config");
        assert_eq!(loaded.imap.email.as_deref(), Some("me@example.com"));
        assert_eq!(loaded.imap.user.as_deref(), Some("imap-user"));
        assert_eq!(loaded.imap.pass.as_deref(), Some("imap-pass"));
        assert_eq!(loaded.imap.server.as_deref(), Some("imap.example.com"));
        assert_eq!(loaded.imap.server_port, Some(993));
        assert_eq!(
            loaded.imap.encryption.map(|value| value.as_str()),
            Some("tls")
        );
        assert!(loaded.imap.is_complete());
        assert_eq!(loaded.default_active_mailbox(), IMAP_INBOX_MAILBOX);

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn rejects_zero_inbox_auto_sync_interval() {
        let base = temp_dir("config-zero-auto-sync-interval");
        let config_path = base.join("config.toml");
        fs::write(
            &config_path,
            r#"
[ui]
inbox_auto_sync_interval_secs = 0
"#,
        )
        .expect("write config");

        let error = load(Some(&config_path)).expect_err("zero interval should be rejected");
        assert!(
            error
                .to_string()
                .contains("ui.inbox_auto_sync_interval_secs must be greater than 0")
        );

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn falls_back_to_imap_email_for_login_user_when_user_is_omitted() {
        let base = temp_dir("imap-email-fallback");
        let config_path = base.join("config.toml");
        fs::write(
            &config_path,
            r#"
[imap]
email = "me@example.com"
pass = "imap-pass"
server = "imap.gmail.com"
serverport = 993
encryption = "ssl"
"#,
        )
        .expect("write config");

        let loaded = load(Some(&config_path)).expect("load config");

        assert_eq!(loaded.imap.email.as_deref(), Some("me@example.com"));
        assert_eq!(loaded.imap.user.as_deref(), Some("me@example.com"));
        assert_eq!(loaded.imap.login_user(), Some("me@example.com"));
        assert!(loaded.imap.is_complete());
        assert_eq!(loaded.default_active_mailbox(), IMAP_INBOX_MAILBOX);

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn accepts_ssl_alias_for_tls_imap_encryption() {
        let base = temp_dir("imap-ssl-alias");
        let config_path = base.join("config.toml");
        fs::write(
            &config_path,
            r#"
[imap]
user = "imap-user"
pass = "imap-pass"
server = "imap.gmail.com"
serverport = 993
encryption = "ssl"
"#,
        )
        .expect("write config");

        let loaded = load(Some(&config_path)).expect("load config");

        assert_eq!(
            loaded.imap.encryption.map(|value| value.as_str()),
            Some("tls")
        );
        assert!(loaded.imap.is_complete());

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn loads_explicit_imap_proxy_from_config() {
        let config = build_imap_config_with_env(
            &ImapFileConfig {
                proxy: Some("socks5://10.0.2.2:7890".to_string()),
                ..ImapFileConfig::default()
            },
            Path::new("/tmp/criew-config.toml"),
            |_| None,
        )
        .expect("build imap config");

        assert_eq!(config.proxy.as_deref(), Some("socks5://10.0.2.2:7890"));
    }

    #[test]
    fn falls_back_to_imap_proxy_environment_variable() {
        let config = build_imap_config_with_env(
            &ImapFileConfig::default(),
            Path::new("/tmp/criew-config.toml"),
            |key| match key {
                "CRIEW_IMAP_PROXY" => Some("http://10.0.2.2:7890".to_string()),
                _ => None,
            },
        )
        .expect("build imap config");

        assert_eq!(config.proxy.as_deref(), Some("http://10.0.2.2:7890"));
    }

    #[test]
    fn rejects_zero_imap_port() {
        let base = temp_dir("imap-port");
        let config_path = base.join("config.toml");
        fs::write(
            &config_path,
            r#"
[imap]
user = "imap-user"
pass = "imap-pass"
server = "imap.example.com"
serverport = 0
encryption = "tls"
"#,
        )
        .expect("write config");

        let error = load(Some(&config_path)).expect_err("invalid config should fail");
        assert!(error.to_string().contains("imap.serverport"));

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn self_email_prefers_imap_config_over_git() {
        let base = temp_dir("self-email-imap");
        let config_path = base.join("config.toml");
        fs::write(
            &config_path,
            r#"
[imap]
email = "criew@example.com"
"#,
        )
        .expect("write config");

        let loaded = load(Some(&config_path)).expect("load config");
        let resolution =
            resolve_self_email_with(&loaded, || Ok(Some("git@example.com".to_string())));

        assert_eq!(resolution.email.as_deref(), Some("criew@example.com"));
        assert_eq!(resolution.source, Some(SelfEmailSource::CriewImapConfig));
        assert!(resolution.git_error.is_none());

        let _ = fs::remove_dir_all(base);
    }

    #[test]
    fn self_email_falls_back_to_git_when_imap_email_missing() {
        let base = temp_dir("self-email-git");
        let config_path = base.join("config.toml");
        fs::write(
            &config_path,
            r#"
[source]
mailbox = "linux-kernel"
"#,
        )
        .expect("write config");

        let loaded = load(Some(&config_path)).expect("load config");
        let resolution =
            resolve_self_email_with(&loaded, || Ok(Some("git@example.com".to_string())));

        assert_eq!(resolution.email.as_deref(), Some("git@example.com"));
        assert_eq!(resolution.source, Some(SelfEmailSource::GitConfig));
        assert!(resolution.git_error.is_none());

        let _ = fs::remove_dir_all(base);
    }
}
