//! User-level configuration
//!
//! Personal preferences and per-project approved commands, not checked into git.

mod accessors;
mod merge;
pub(crate) mod mutation;
mod path;
mod persistence;
mod resolved;
mod schema;
mod sections;
#[cfg(test)]
mod tests;

use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use super::{ConfigError, ConfigFileKind};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Inline config overrides from the CLI `--config-set <toml>` flag, in
/// invocation order.
///
/// Set once from `main` after arg parsing (mirroring [`set_config_path`]) and
/// applied by [`UserConfig::load_with_warnings`] as the highest-priority layer,
/// above config files and `WORKTRUNK_*` env vars. Each entry is an independent
/// TOML fragment (e.g. `list.full = true`); later entries replace earlier ones
/// for the same key.
static CONFIG_OVERRIDES: OnceLock<Vec<String>> = OnceLock::new();

/// Record the CLI `--config-set` overrides (called once from the
/// `--config-set` flag in `main`).
pub fn set_config_overrides(overrides: Vec<String>) {
    CONFIG_OVERRIDES.set(overrides).ok();
}

/// The CLI `--config-set` overrides, empty when the flag was not used.
fn cli_config_overrides() -> &'static [String] {
    CONFIG_OVERRIDES.get().map_or(&[], Vec::as_slice)
}

// Re-export public types
pub use merge::Merge;
pub use path::{
    config_path, config_path_for_display, default_config_path, default_system_config_path,
    require_config_path, set_config_path, system_config_path,
};
pub use resolved::ResolvedConfig;
pub use schema::valid_user_config_keys;
pub use sections::{
    CommitConfig, CommitGenerationConfig, CopyIgnoredConfig, ListColumnConfig, ListConfig,
    MergeConfig, RemoveConfig, StageMode, StepConfig, SwitchConfig, SwitchPickerConfig,
    UserProjectOverrides,
};

/// Describes a problem encountered during config loading. Each variant
/// identifies which layer failed so callers can emit targeted diagnostics
/// (file errors with line/col vs env-var attribution).
///
/// Used as an error by [`UserConfig::load_with_cause()`] (first issue is
/// fatal) and as warnings by [`UserConfig::load_with_warnings()`] (issues
/// are collected, best-effort config returned).
#[derive(Debug)]
pub enum LoadError {
    /// A config file failed to parse. The `toml::de::Error` includes
    /// line/column info and a source-snippet pointer.
    File {
        path: PathBuf,
        kind: ConfigFileKind,
        err: Box<toml::de::Error>,
    },
    /// Config files parsed cleanly; applying env-var overrides failed.
    /// `vars` lists the exact `WORKTRUNK_*` env vars that were parsed
    /// as `(name, value)` pairs.
    Env {
        err: String,
        vars: Vec<(String, String)>,
    },
    /// Lower layers loaded cleanly; applying CLI `--config-set` overrides
    /// failed — either a fragment was not valid TOML or the merged result no
    /// longer deserializes. `overrides` lists the raw values for attribution.
    CliOverride { err: String, overrides: Vec<String> },
    /// Validation errors (e.g. empty worktree-path).
    Validation(String),
}

impl std::fmt::Display for LoadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            LoadError::File { path, kind, err } => {
                write!(
                    f,
                    "{} at {} failed to parse:\n{err}",
                    kind.label(),
                    crate::path::format_path_for_display(path)
                )
            }
            LoadError::Env { err, .. } => write!(f, "{err}"),
            LoadError::CliOverride { err, .. } => write!(f, "{err}"),
            LoadError::Validation(err) => write!(f, "{err}"),
        }
    }
}

impl std::error::Error for LoadError {}

// ---- Env-var overlay ----

/// A single parsed WORKTRUNK_* env var with both typed and string representations.
struct EnvVar {
    /// Original env var name (e.g., `WORKTRUNK__LIST__TIMEOUT_MS`)
    name: String,
    /// TOML path segments (e.g., `["list", "timeout-ms"]`)
    segments: Vec<String>,
    /// Typed TOML value (bool/int/float/string coercion via [`try_parse_value`])
    typed_value: toml::Value,
    /// Raw string value, kept for fallback when the typed form doesn't match
    /// the target field's type (e.g., `WORKTRUNK_WORKTREE_PATH=42` needs
    /// `String`, not `Integer`).
    raw_value: String,
}

/// Read `WORKTRUNK_*` env vars and parse each into an [`EnvVar`].
///
/// Env-var convention (matches the config crate's prior behavior):
/// - `WORKTRUNK_WORKTREE_PATH=foo` → `worktree-path = "foo"`
/// - `WORKTRUNK__LIST__TIMEOUT_MS=30` → `[list]\ntimeout-ms = 30`
/// - `WORKTRUNK_COMMIT__GENERATION__COMMAND=cmd` → `[commit.generation]\ncommand = "cmd"`
///
/// Infrastructure vars (`_CONFIG_PATH`, `_SYSTEM_CONFIG_PATH`,
/// `_APPROVALS_PATH`) and test vars (`_TEST_*`) are excluded.
fn parse_worktrunk_env_vars() -> Vec<EnvVar> {
    const INFRA_VARS: &[&str] = &[
        "WORKTRUNK_CONFIG_PATH",
        "WORKTRUNK_SYSTEM_CONFIG_PATH",
        "WORKTRUNK_APPROVALS_PATH",
    ];

    let mut env_vars: Vec<_> = std::env::vars()
        .filter(|(k, _)| k.starts_with("WORKTRUNK_"))
        .filter(|(k, _)| !INFRA_VARS.contains(&k.as_str()))
        .filter(|(k, _)| !k.starts_with("WORKTRUNK_TEST_"))
        .collect();
    env_vars.sort_by(|a, b| a.0.cmp(&b.0));

    env_vars
        .into_iter()
        .filter_map(|(key, value)| {
            // Strip WORKTRUNK_ prefix, split by __ for nesting, convert to kebab-case
            let stripped = &key["WORKTRUNK_".len()..];
            let segments: Vec<String> = stripped
                .split("__")
                .map(|s| {
                    s.to_lowercase()
                        .replace('_', "-")
                        .trim_start_matches('-')
                        .to_string()
                })
                .filter(|s| !s.is_empty())
                .collect();

            if segments.is_empty() {
                return None;
            }

            Some(EnvVar {
                name: key,
                segments,
                typed_value: try_parse_value(&value),
                raw_value: value,
            })
        })
        .collect()
}

/// For each env var, probe whether its typed or string representation
/// deserializes correctly against the file config, then build a single
/// overlay table with the correct representation per var.
///
/// Each var is tested independently against the file table (not against other
/// env vars). This lets serde itself decide the correct type — no schema
/// walking or guessing needed. O(N) deserializations where N is the number
/// of env vars (tiny in practice).
fn resolve_env_overlay(file_table: &toml::Table, vars: &[EnvVar]) -> toml::Table {
    let mut overlay = toml::Table::new();
    for var in vars {
        // Typed probe: merge just this var's typed value into the file table
        let mut probe = file_table.clone();
        set_nested_value(&mut probe, &var.segments, var.typed_value.clone());
        if toml::Value::Table(probe).try_into::<UserConfig>().is_ok() {
            set_nested_value(&mut overlay, &var.segments, var.typed_value.clone());
        } else {
            // Typed form doesn't fit the target field — use raw string.
            // If this is also wrong (e.g., "not-a-bool" for a bool field),
            // the final deserialize will catch it and surface LoadError::Env.
            set_nested_value(
                &mut overlay,
                &var.segments,
                toml::Value::String(var.raw_value.clone()),
            );
        }
    }
    overlay
}

/// Canonicalize a built env-var overlay through the same deprecation migration
/// as config files and `--config-set`, so a deprecated env var such as
/// `WORKTRUNK__MERGE__NO_FF` (which resolves to the deprecated key
/// `merge.no-ff`) takes effect as `merge.ff` instead of falling through as an
/// unknown field. Migrating before the deep-merge keeps env winning over a
/// lower layer's canonical key — the same fragment-before-merge ordering
/// [`UserConfig::apply_cli_overrides`] uses.
///
/// The rewrite is silent, matching the other ephemeral layer: an env overlay
/// has no file for `wt config update` to materialize.
///
/// Round-trips through [`super::deprecation::migrate_content`]'s string form.
/// The overlay's leaves are the scalars [`try_parse_value`] produces (bool,
/// int, float, string) plus nested tables, so it always serializes, and
/// `migrate_content` always returns valid TOML — mirroring the same
/// post-migration reparse `expect` in [`load_config_file`].
///
/// Canonicalization can surface a type mismatch the deprecated name hid: an
/// unknown key like `commit-generation.command = 42` deserializes (unknown
/// fields are ignored) but the migrated `commit.generation.command = 42` does
/// not, so it joins the whole-env-layer `LoadError::Env` drop at the call site
/// instead of being silently ignored — the same outcome a type-mismatched value
/// in a *canonical* env var already produces.
fn migrate_env_overlay(overlay: toml::Table) -> toml::Table {
    let serialized = toml::to_string(&overlay).expect("env overlay serializes to TOML");
    super::deprecation::migrate_content(&serialized)
        .parse::<toml::Table>()
        .expect("migrate_content returns valid TOML")
}

/// Try to coerce a string into a typed TOML value (bool → i64 → f64 → string).
fn try_parse_value(s: &str) -> toml::Value {
    if s.eq_ignore_ascii_case("true") {
        return toml::Value::Boolean(true);
    }
    if s.eq_ignore_ascii_case("false") {
        return toml::Value::Boolean(false);
    }
    if let Ok(n) = s.parse::<i64>() {
        return toml::Value::Integer(n);
    }
    if let Ok(n) = s.parse::<f64>() {
        return toml::Value::Float(n);
    }
    toml::Value::String(s.to_string())
}

/// Set a value at a nested path in a TOML table, creating intermediate tables.
fn set_nested_value(table: &mut toml::Table, path: &[String], value: toml::Value) {
    if path.len() == 1 {
        table.insert(path[0].clone(), value);
        return;
    }
    let entry = table
        .entry(&path[0])
        .or_insert_with(|| toml::Value::Table(toml::Table::new()));
    if let toml::Value::Table(inner) = entry {
        set_nested_value(inner, &path[1..], value);
    }
}

/// Recursively merge `overlay` into `base`. Overlay values win for scalars;
/// nested tables merge recursively.
fn deep_merge_table(base: &mut toml::Table, overlay: toml::Table) {
    for (key, value) in overlay {
        match (base.get_mut(&key), &value) {
            (Some(toml::Value::Table(base_t)), toml::Value::Table(overlay_t)) => {
                deep_merge_table(base_t, overlay_t.clone());
            }
            _ => {
                base.insert(key, value);
            }
        }
    }
}

/// Load and validate a single config file. Returns the parsed table for
/// merging and validates via `toml::from_str::<UserConfig>` for rich errors.
fn load_config_file(
    path: &Path,
    migrated: &str,
    kind: ConfigFileKind,
) -> Result<toml::Table, LoadError> {
    // Validate by deserializing — gives rich line/col errors.
    if let Err(err) = toml::from_str::<UserConfig>(migrated) {
        return Err(LoadError::File {
            path: path.to_path_buf(),
            kind,
            err: Box::new(err),
        });
    }
    // Parse as table for merging. Infallible after from_str::<UserConfig>
    // succeeds — valid UserConfig TOML is always valid TOML.
    Ok(migrated
        .parse::<toml::Table>()
        .expect("valid TOML after UserConfig parse"))
}

/// User-level configuration for worktree path formatting and LLM integration.
///
/// This config is stored at `~/.config/worktrunk/config.toml` (or platform equivalent)
/// and is NOT checked into git. Each developer maintains their own user config.
///
/// The `worktree-path` template is relative to the repository root.
/// Supported variables:
/// - `{{ repo }}` - Repository directory name (e.g., `myproject`)
/// - `{{ branch }}` - Raw branch name (e.g., `feature/auth`)
/// - `{{ branch | sanitize }}` - Branch name with `/` and `\` replaced by `-`
///
/// # Examples
///
/// ```toml
/// # Default - parent directory siblings
/// worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
///
/// # Inside repo (clean, no redundant directory)
/// worktree-path = ".worktrees/{{ branch | sanitize }}"
///
/// # Repository-namespaced (useful for shared directories with multiple repos)
/// worktree-path = "../worktrees/{{ repo }}/{{ branch | sanitize }}"
///
/// # Commit generation configuration
/// [commit.generation]
/// command = "llm -m claude-haiku-4.5"  # Shell command for generating commit messages
///
/// # Per-project configuration
/// [projects."github.com/user/repo"]
/// worktree-path = ".worktrees/{{ branch | sanitize }}"
/// ```
///
/// Config file location:
/// - Linux: `$XDG_CONFIG_HOME/worktrunk/config.toml` or `~/.config/worktrunk/config.toml`
/// - macOS: `$XDG_CONFIG_HOME/worktrunk/config.toml` or `~/.config/worktrunk/config.toml`
/// - Windows: `%APPDATA%\worktrunk\config.toml`
///
/// Environment variables can override config file settings using `WORKTRUNK_` prefix with
/// `__` separator for nested fields (e.g., `WORKTRUNK_COMMIT__GENERATION__COMMAND`).
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
pub struct UserConfig {
    /// Per-project configuration (approved commands, etc.)
    /// Uses BTreeMap for deterministic serialization order and better diff readability
    #[serde(default)]
    pub projects: std::collections::BTreeMap<String, UserProjectOverrides>,

    /// Hooks configuration (top-level keys like pre-merge, post-switch, etc.)
    #[serde(flatten, default)]
    pub hooks: crate::config::HooksConfig,

    /// Worktree path template
    #[serde(
        rename = "worktree-path",
        default,
        skip_serializing_if = "Option::is_none"
    )]
    pub worktree_path: Option<String>,

    /// Configuration for the `wt list` command
    #[serde(default, skip_serializing_if = "super::is_default")]
    pub list: sections::ListConfig,

    /// Configuration for the `wt step commit` command (also used by merge)
    #[serde(default, skip_serializing_if = "super::is_default")]
    pub commit: sections::CommitConfig,

    /// Configuration for the `wt merge` command
    #[serde(default, skip_serializing_if = "super::is_default")]
    pub merge: sections::MergeConfig,

    /// Configuration for the `wt remove` command
    #[serde(default, skip_serializing_if = "super::is_default")]
    pub remove: sections::RemoveConfig,

    /// Configuration for the `wt switch` command
    #[serde(default, skip_serializing_if = "super::is_default")]
    pub switch: sections::SwitchConfig,

    /// Configuration for `wt step` subcommands
    #[serde(default, skip_serializing_if = "super::is_default")]
    pub step: sections::StepConfig,

    /// Command aliases for `wt <name>`
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub aliases: std::collections::BTreeMap<String, crate::config::commands::CommandConfig>,

    /// Skip the first-run shell integration prompt
    #[serde(
        default,
        rename = "skip-shell-integration-prompt",
        skip_serializing_if = "std::ops::Not::not"
    )]
    pub skip_shell_integration_prompt: bool,

    /// Skip the first-run commit generation prompt
    #[serde(
        default,
        rename = "skip-commit-generation-prompt",
        skip_serializing_if = "std::ops::Not::not"
    )]
    pub skip_commit_generation_prompt: bool,
}

impl UserConfig {
    /// Load configuration from system config, user config, and environment variables.
    ///
    /// Configuration is loaded in the following order (later sources override earlier ones):
    /// 1. Default values
    /// 2. System config (organization-wide defaults)
    /// 3. User config file (personal preferences)
    /// 4. Environment variables (WORKTRUNK_*)
    pub fn load() -> Result<Self, ConfigError> {
        Self::load_with_cause().map_err(|e| ConfigError(e.to_string()))
    }

    /// Like [`load()`](Self::load), but returns a [`LoadError`] that
    /// distinguishes file-level parse failures (with line/col) from
    /// env-var override failures.
    pub(crate) fn load_with_cause() -> Result<Self, LoadError> {
        let (config, warnings) = Self::load_with_warnings();
        if let Some(err) = warnings.into_iter().next() {
            return Err(err);
        }
        Ok(config)
    }

    /// Load the best config achievable, collecting issues as warnings.
    ///
    /// Each config layer (system file, user file, env-var overlay) degrades
    /// independently — a failure in one preserves data from earlier layers
    /// instead of falling back to defaults. This means:
    /// - Bad system config → skipped, user config + env vars still apply
    /// - Bad user config → skipped, system config + env vars still apply
    /// - Bad env vars → ignored, file-based config preserved
    /// - Validation failure → warning emitted, defaults used (invalid config
    ///   causes bad behavior if applied, e.g. empty worktree-path template)
    pub(crate) fn load_with_warnings() -> (Self, Vec<LoadError>) {
        let mut warnings = Vec::new();
        let mut merged_table = toml::Table::new();

        // 1. System config (lowest priority)
        if let Some(system_path) = path::system_config_path()
            && let Ok(content) = std::fs::read_to_string(&system_path)
        {
            match super::deprecation::check_and_migrate(
                &system_path,
                &content,
                true,
                ConfigFileKind::System,
                None,
                true,
            ) {
                Ok(result) => {
                    super::deprecation::warn_unknown_fields::<UserConfig>(
                        &content,
                        &system_path,
                        ConfigFileKind::System,
                    );

                    match load_config_file(
                        &system_path,
                        &result.migrated_content,
                        ConfigFileKind::System,
                    ) {
                        Ok(table) => deep_merge_table(&mut merged_table, table),
                        Err(e) => warnings.push(e),
                    }
                }
                Err(err) => {
                    warnings.push(LoadError::Validation(err.to_string()));
                }
            }
        }

        // 2. User config (overrides system)
        let config_path = config_path();
        if let Some(config_path) = config_path.as_ref()
            && config_path.exists()
        {
            if let Ok(content) = std::fs::read_to_string(config_path) {
                match super::deprecation::check_and_migrate(
                    config_path,
                    &content,
                    true,
                    ConfigFileKind::User,
                    None,
                    true,
                ) {
                    Ok(result) => {
                        super::deprecation::warn_unknown_fields::<UserConfig>(
                            &content,
                            config_path,
                            ConfigFileKind::User,
                        );

                        match load_config_file(
                            config_path,
                            &result.migrated_content,
                            ConfigFileKind::User,
                        ) {
                            Ok(table) => deep_merge_table(&mut merged_table, table),
                            Err(e) => warnings.push(e),
                        }
                    }
                    Err(err) => {
                        warnings.push(LoadError::Validation(err.to_string()));
                    }
                }
            }
        } else if let Some(config_path) = config_path.as_ref()
            && path::is_config_path_explicit()
        {
            crate::styling::eprintln!(
                "{}",
                crate::styling::warning_message(color_print::cformat!(
                    "Config file not found: <bold>{}</>",
                    crate::path::format_path_for_display(config_path)
                ))
            );
        }

        // 3. Env-var overrides (override config files)
        let env_vars = parse_worktrunk_env_vars();
        if !env_vars.is_empty() {
            // Resolve each env var's type independently: probe typed form against
            // the file table, fall back to string if typed doesn't fit the target
            // field. This handles mixed cases (e.g., WORKTRUNK__LIST__TIMEOUT_MS=100
            // needs Integer for u64, WORKTRUNK_WORKTREE_PATH=42 needs String).
            let file_table = merged_table.clone();
            let env_overlay = migrate_env_overlay(resolve_env_overlay(&file_table, &env_vars));
            deep_merge_table(&mut merged_table, env_overlay);

            // Env overlay broke deserialization — fall back to file-only config.
            // Each file was individually validated by load_config_file(), so the
            // merged table should deserialize cleanly.
            if let Err(err) = toml::Value::Table(merged_table.clone()).try_into::<Self>() {
                warnings.push(LoadError::Env {
                    err: err.to_string(),
                    vars: env_vars
                        .iter()
                        .map(|v| (v.name.clone(), v.raw_value.clone()))
                        .collect(),
                });
                merged_table = file_table;
            }
        }

        // 4. CLI `--config-set` overrides (override env vars and config files)
        Self::apply_cli_overrides(cli_config_overrides(), &mut merged_table, &mut warnings);

        Self::finalize(merged_table, warnings)
    }

    /// Apply CLI `--config-set <toml>` overrides as the highest-priority layer.
    ///
    /// Each override is an independent TOML fragment merged in order, so a
    /// later override replaces an earlier one for the same key. Scalars and
    /// arrays replace the lower-layer value, while nested tables deep-merge
    /// (via [`deep_merge_table`]) so sibling keys survive —
    /// `--config-set list.full=true` leaves `list.branches` untouched.
    ///
    /// Each fragment first runs through the same deprecation migration as a
    /// config file ([`super::deprecation::migrate_content`] — structural and
    /// silent rewrites, no `UpdateOnly`), so a deprecated key such as
    /// `merge.no-ff` is canonicalized to `merge.ff` *before* the fragment is
    /// parsed and merged, instead of falling through as an unknown field.
    /// Migrating the fragment
    /// (not the merged document) keeps layer precedence intact: the
    /// canonicalized override wins over a lower layer's canonical key, rather
    /// than colliding with it. The rewrite is silent — there is no file to
    /// materialize, so the file-scoped "run `wt config update`" warning would
    /// have nothing to act on.
    ///
    /// The whole layer degrades as a unit: if any fragment fails to parse, or
    /// the merged result fails to deserialize or validate, every override is
    /// dropped and a [`LoadError::CliOverride`] is recorded, so a bad override
    /// never silently corrupts (or wipes) the lower layers.
    fn apply_cli_overrides(
        overrides: &[String],
        merged_table: &mut toml::Table,
        warnings: &mut Vec<LoadError>,
    ) {
        if overrides.is_empty() {
            return;
        }

        let base = merged_table.clone();
        for raw in overrides {
            // `migrate_content` returns the fragment unchanged when it is not
            // valid TOML, so the parse below still catches a malformed fragment
            // and drops the whole layer with an attributed warning.
            let migrated = super::deprecation::migrate_content(raw);
            match migrated.parse::<toml::Table>() {
                Ok(fragment) => deep_merge_table(merged_table, fragment),
                Err(err) => {
                    warnings.push(LoadError::CliOverride {
                        err: err.to_string(),
                        overrides: overrides.to_vec(),
                    });
                    *merged_table = base;
                    return;
                }
            }
        }

        // Probe deserialize *and* validate, so a semantically-invalid override
        // (e.g. an empty worktree-path) drops just this layer rather than
        // falling through to finalize(), which would wipe the lower layers to
        // defaults.
        let probe = match toml::Value::Table(merged_table.clone()).try_into::<Self>() {
            Ok(config) => config.validate().map_err(|e| e.0),
            Err(err) => Err(err.to_string()),
        };
        if let Err(err) = probe {
            warnings.push(LoadError::CliOverride {
                err,
                overrides: overrides.to_vec(),
            });
            *merged_table = base;
        }
    }

    /// Deserialize a merged table into `UserConfig`, validate, and collect
    /// any issues as warnings. Shared by the no-env and env-fallback paths.
    fn finalize(table: toml::Table, mut warnings: Vec<LoadError>) -> (Self, Vec<LoadError>) {
        match toml::Value::Table(table).try_into::<Self>() {
            Ok(config) => match config.validate() {
                Ok(()) => (config, warnings),
                Err(e) => {
                    // Validation means the config is semantically wrong (e.g.,
                    // worktree-path=""). Using it causes bad behavior, so fall
                    // back to defaults rather than applying the broken config.
                    warnings.push(LoadError::Validation(e.0));
                    (Self::default(), warnings)
                }
            },
            Err(err) => {
                warnings.push(LoadError::Validation(err.to_string()));
                (Self::default(), warnings)
            }
        }
    }

    /// Load configuration from a TOML string for testing.
    #[cfg(test)]
    pub(crate) fn load_from_str(content: &str) -> Result<Self, ConfigError> {
        let migrated = crate::config::deprecation::migrate_content(content);
        let config: Self = toml::from_str(&migrated).map_err(|e| ConfigError(e.to_string()))?;
        config.validate()?;
        Ok(config)
    }
}
