//! The single dispatch table for global configuration keys.
//!
//! Three contexts accept global keys: `rumdl.toml`/`.rumdl.toml` (parsed
//! with `toml_edit`), `pyproject.toml` (parsed with `toml`), and inline
//! `--config key=value` overrides. They previously each carried their own
//! per-key match with its own type checks, and drifted. The key list, the
//! expected types, and the setters now live here once: adding a global key
//! means adding it to [`GLOBAL_VALUE_KEYS`] and one arm in
//! [`apply_global_key`]; every context picks it up.
//!
//! Callers keep their own key discovery (which table to scan, alias
//! spellings) and their own diagnostics phrasing, driven by the returned
//! [`ApplyOutcome`].

use std::str::FromStr;

use super::flavor::{MarkdownFlavor, normalize_key};
use super::registry::RuleRegistry;
use super::source_tracking::{ConfigSource, SourcedGlobalConfig, SourcedValue};
use super::types::GlobalConfig;
use crate::types::LineLength;

/// Global configuration keys that hold plain values (normalized kebab-case).
pub const GLOBAL_VALUE_KEYS: &[&str] = &[
    "enable",
    "disable",
    "include",
    "exclude",
    "extend-enable",
    "extend-disable",
    "respect-gitignore",
    "force-exclude",
    "line-length",
    "output-format",
    "cache-dir",
    "cache",
    "fixable",
    "unfixable",
    "flavor",
    "editorconfig",
];

/// Whether a (normalized) key names a global value setting.
pub fn is_global_value_key(key: &str) -> bool {
    GLOBAL_VALUE_KEYS.contains(&key)
}

/// What reading a global key found.
#[derive(Debug)]
pub enum GlobalKeyValue {
    /// The key holds a value, shown with where it came from.
    Set(toml::Value, ConfigSource),
    /// The key is accepted but holds nothing and has no default to show. A caller
    /// must report this as unset, never as an unknown key: the two are different
    /// answers and collapsing them tells the user a real setting does not exist.
    Unset,
}

/// Read one global key back out of the global config section.
///
/// The read half of [`apply_global_key`], kept beside it so the two cannot drift:
/// every key in [`GLOBAL_VALUE_KEYS`] answers here. Returns `None` only for a key
/// that is not a global setting at all.
///
/// The value comes from `effective` and the provenance from `sourced`, because the
/// two answer different questions. `sourced` records what the config files said;
/// `effective` is what the run actually uses, after
/// [`Config::apply_per_rule_enabled`](crate::config::Config::apply_per_rule_enabled)
/// has folded per-rule `enabled` into the rule lists and after those lists have been
/// canonicalized. Reporting the sourced value would tell a user that MD013 is
/// disabled while the very same config runs it.
pub fn read_global_key(
    effective: &GlobalConfig,
    sourced: &SourcedGlobalConfig,
    norm_key: &str,
) -> Option<GlobalKeyValue> {
    let strings = |value: &[String], sv: &SourcedValue<Vec<String>>| {
        GlobalKeyValue::Set(
            toml::Value::Array(value.iter().map(|s| toml::Value::String(s.clone())).collect()),
            sv.source,
        )
    };
    let boolean = |value: bool, sv: &SourcedValue<bool>| GlobalKeyValue::Set(toml::Value::Boolean(value), sv.source);
    let optional_string = |value: &Option<String>, slot: &Option<SourcedValue<String>>| match value {
        Some(value) => GlobalKeyValue::Set(
            toml::Value::String(value.clone()),
            slot.as_ref().map_or(ConfigSource::Default, |sv| sv.source),
        ),
        None => GlobalKeyValue::Unset,
    };

    Some(match norm_key {
        "enable" => strings(&effective.enable, &sourced.enable),
        "disable" => strings(&effective.disable, &sourced.disable),
        "include" => strings(&effective.include, &sourced.include),
        "exclude" => strings(&effective.exclude, &sourced.exclude),
        "extend-enable" => strings(&effective.extend_enable, &sourced.extend_enable),
        "extend-disable" => strings(&effective.extend_disable, &sourced.extend_disable),
        "fixable" => strings(&effective.fixable, &sourced.fixable),
        "unfixable" => strings(&effective.unfixable, &sourced.unfixable),
        "respect-gitignore" => boolean(effective.respect_gitignore, &sourced.respect_gitignore),
        "force-exclude" => {
            // The field is deprecated and inert, but it is still a key a config may
            // carry, so `config get` answers for it rather than calling it unknown.
            #[allow(deprecated)]
            let value = effective.force_exclude;
            boolean(value, &sourced.force_exclude)
        }
        "cache" => boolean(effective.cache, &sourced.cache),
        "editorconfig" => boolean(effective.editorconfig, &sourced.editorconfig),
        "line-length" => GlobalKeyValue::Set(
            toml::Value::Integer(effective.line_length.get() as i64),
            sourced.line_length.source,
        ),
        "output-format" => optional_string(&effective.output_format, &sourced.output_format),
        "cache-dir" => optional_string(&effective.cache_dir, &sourced.cache_dir),
        "flavor" => GlobalKeyValue::Set(toml::Value::String(effective.flavor.to_string()), sourced.flavor.source),
        _ => return None,
    })
}

/// Result of applying a candidate global key.
#[derive(Debug)]
pub enum ApplyOutcome {
    /// Key recognized and value stored.
    Applied,
    /// Key recognized but the value has the wrong TOML type; nothing stored.
    TypeMismatch { expected: &'static str },
    /// Key recognized, type correct, but the value is invalid (e.g. an
    /// unknown flavor name); nothing stored.
    InvalidValue { message: String },
    /// Not a global value key.
    Unrecognized,
}

/// Apply one global key to the global config section.
///
/// `norm_key` must already be normalized (see [`normalize_key`]); rule-list
/// values resolve rule-name aliases through `registry`. `origin` is the
/// config file supplying the value (`None` for CLI input) and feeds the
/// provenance shown by `rumdl config`.
pub fn apply_global_key(
    global: &mut SourcedGlobalConfig,
    norm_key: &str,
    value: &toml::Value,
    source: ConfigSource,
    origin: Option<&str>,
    registry: &RuleRegistry,
) -> ApplyOutcome {
    let origin = origin.map(std::string::ToString::to_string);

    let resolve_rule_list = |arr: &[toml::Value]| -> Vec<String> {
        arr.iter()
            .filter_map(|v| v.as_str())
            .map(|s| registry.resolve_rule_name(s).unwrap_or_else(|| normalize_key(s)))
            .collect()
    };
    let to_strings =
        |arr: &[toml::Value]| -> Vec<String> { arr.iter().filter_map(|v| v.as_str()).map(str::to_string).collect() };

    match norm_key {
        "enable" | "disable" | "extend-enable" | "extend-disable" | "fixable" | "unfixable" => {
            let toml::Value::Array(arr) = value else {
                return ApplyOutcome::TypeMismatch { expected: "array" };
            };
            let values = resolve_rule_list(arr);
            match norm_key {
                "enable" => global.enable.push_override(values, source, origin),
                "disable" => global.disable.push_override(values, source, origin),
                "extend-enable" => global.extend_enable.push_override(values, source, origin),
                "extend-disable" => global.extend_disable.push_override(values, source, origin),
                "fixable" => global.fixable.push_override(values, source, origin),
                "unfixable" => global.unfixable.push_override(values, source, origin),
                _ => unreachable!("outer match limits the keys"),
            }
            ApplyOutcome::Applied
        }
        "include" | "exclude" => {
            let toml::Value::Array(arr) = value else {
                return ApplyOutcome::TypeMismatch { expected: "array" };
            };
            let values = to_strings(arr);
            match norm_key {
                "include" => global.include.push_override(values, source, origin),
                "exclude" => global.exclude.push_override(values, source, origin),
                _ => unreachable!("outer match limits the keys"),
            }
            ApplyOutcome::Applied
        }
        "respect-gitignore" | "force-exclude" | "cache" | "editorconfig" => {
            let Some(b) = value.as_bool() else {
                return ApplyOutcome::TypeMismatch { expected: "boolean" };
            };
            match norm_key {
                "respect-gitignore" => global.respect_gitignore.push_override(b, source, origin),
                "force-exclude" => global.force_exclude.push_override(b, source, origin),
                "cache" => global.cache.push_override(b, source, origin),
                "editorconfig" => global.editorconfig.push_override(b, source, origin),
                _ => unreachable!("outer match limits the keys"),
            }
            ApplyOutcome::Applied
        }
        "line-length" => {
            let Some(n) = value.as_integer() else {
                return ApplyOutcome::TypeMismatch { expected: "integer" };
            };
            // Negative lengths are nonsense; clamp instead of wrapping.
            global
                .line_length
                .push_override(LineLength::new(n.max(0) as usize), source, origin);
            ApplyOutcome::Applied
        }
        "output-format" | "cache-dir" => {
            let Some(s) = value.as_str() else {
                return ApplyOutcome::TypeMismatch { expected: "string" };
            };
            let slot = match norm_key {
                "output-format" => &mut global.output_format,
                "cache-dir" => &mut global.cache_dir,
                _ => unreachable!("outer match limits the keys"),
            };
            if let Some(sv) = slot.as_mut() {
                sv.push_override(s.to_string(), source, origin);
            } else {
                let mut sv = SourcedValue::new(s.to_string(), source);
                sv.origin = origin;
                *slot = Some(sv);
            }
            ApplyOutcome::Applied
        }
        "flavor" => {
            let Some(s) = value.as_str() else {
                return ApplyOutcome::TypeMismatch { expected: "string" };
            };
            match MarkdownFlavor::from_str(s) {
                Ok(flavor) => {
                    global.flavor.push_override(flavor, source, origin);
                    ApplyOutcome::Applied
                }
                Err(_) => ApplyOutcome::InvalidValue {
                    message: format!("unknown markdown flavor '{s}'"),
                },
            }
        }
        _ => ApplyOutcome::Unrecognized,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::registry::default_registry;

    fn apply(key: &str, value: &toml::Value) -> (SourcedGlobalConfig, ApplyOutcome) {
        let mut global = SourcedGlobalConfig::default();
        let outcome = apply_global_key(
            &mut global,
            key,
            value,
            ConfigSource::ProjectConfig,
            Some("test.toml"),
            default_registry(),
        );
        (global, outcome)
    }

    /// The effective config a sourced one produces, built through the same conversion
    /// the CLI uses rather than by mirroring fields here, so a field this test reads
    /// cannot silently stop tracking the real one.
    fn effective(sourced: &SourcedGlobalConfig) -> GlobalConfig {
        let sourced = crate::config::SourcedConfig {
            global: sourced.clone(),
            ..Default::default()
        };
        let config: crate::config::Config = sourced.into_validated_unchecked().into();
        config.global
    }

    #[test]
    fn every_global_key_is_recognized() {
        // The key list and the dispatch must stay in lockstep: every listed
        // key must produce Applied or TypeMismatch, never Unrecognized.
        for key in GLOBAL_VALUE_KEYS {
            let (_, outcome) = apply(key, &toml::Value::Datetime("1979-05-27".parse().unwrap()));
            assert!(
                !matches!(outcome, ApplyOutcome::Unrecognized),
                "key '{key}' is listed but not dispatched"
            );
        }
        let (_, outcome) = apply("not-a-key", &toml::Value::Boolean(true));
        assert!(matches!(outcome, ApplyOutcome::Unrecognized));
    }

    #[test]
    fn every_global_key_reads_back() {
        // The read half must cover the same key list as the write half, or
        // `rumdl config get global.<key>` calls a real setting unknown.
        let global = SourcedGlobalConfig::default();
        let config = effective(&global);
        for key in GLOBAL_VALUE_KEYS {
            assert!(
                read_global_key(&config, &global, key).is_some(),
                "key '{key}' is listed but cannot be read back"
            );
        }
        assert!(read_global_key(&config, &global, "not-a-key").is_none());
    }

    #[test]
    fn a_set_value_reads_back_with_its_provenance() {
        let (global, _) = apply("line-length", &toml::Value::Integer(120));
        let Some(GlobalKeyValue::Set(value, source)) = read_global_key(&effective(&global), &global, "line-length")
        else {
            panic!("a set line-length must read back as Set");
        };
        assert_eq!(value, toml::Value::Integer(120));
        assert_eq!(source, ConfigSource::ProjectConfig);
    }

    #[test]
    fn an_unset_optional_key_reads_back_as_unset_not_missing() {
        let global = SourcedGlobalConfig::default();
        assert!(matches!(
            read_global_key(&effective(&global), &global, "output-format"),
            Some(GlobalKeyValue::Unset)
        ));
        assert!(matches!(
            read_global_key(&effective(&global), &global, "cache-dir"),
            Some(GlobalKeyValue::Unset)
        ));

        let (global, _) = apply("output-format", &toml::Value::String("json".to_string()));
        assert!(matches!(
            read_global_key(&effective(&global), &global, "output-format"),
            Some(GlobalKeyValue::Set(toml::Value::String(_), _))
        ));
    }

    #[test]
    fn a_rule_list_reads_back_as_the_run_will_use_it() {
        // `[global] disable = ["MD013"]` with `[MD013] enabled = true` runs MD013:
        // per-rule `enabled` outranks the global list. Reporting the list as the config
        // file wrote it would name a rule as disabled while the same config lints with
        // it, so the read reports the list the run actually uses.
        let (global, _) = apply(
            "disable",
            &toml::Value::Array(vec![toml::Value::String("MD013".to_string())]),
        );
        let mut sourced = crate::config::SourcedConfig {
            global,
            ..Default::default()
        };
        sourced.rules.entry("MD013".to_string()).or_default().values.insert(
            "enabled".to_string(),
            SourcedValue::new(toml::Value::Boolean(true), ConfigSource::ProjectConfig),
        );
        let config: crate::config::Config = sourced.clone().into_validated_unchecked().into();

        let Some(GlobalKeyValue::Set(disabled, _)) = read_global_key(&config.global, &sourced.global, "disable") else {
            panic!("disable must read back as Set");
        };
        assert_eq!(
            disabled,
            toml::Value::Array(vec![]),
            "MD013 is enabled by its own section, so it is not in the effective disable list"
        );

        // Control: without the per-rule override the rule stays disabled and listed.
        let (global, _) = apply(
            "disable",
            &toml::Value::Array(vec![toml::Value::String("MD013".to_string())]),
        );
        let sourced = crate::config::SourcedConfig {
            global,
            ..Default::default()
        };
        let config: crate::config::Config = sourced.clone().into_validated_unchecked().into();
        let Some(GlobalKeyValue::Set(disabled, _)) = read_global_key(&config.global, &sourced.global, "disable") else {
            panic!("disable must read back as Set");
        };
        assert_eq!(
            disabled,
            toml::Value::Array(vec![toml::Value::String("MD013".to_string())])
        );
    }

    #[test]
    fn applies_values_with_origin() {
        let (global, outcome) = apply("line-length", &toml::Value::Integer(120));
        assert!(matches!(outcome, ApplyOutcome::Applied));
        assert_eq!(global.line_length.value.get(), 120);
        assert_eq!(global.line_length.origin.as_deref(), Some("test.toml"));

        let (global, outcome) = apply(
            "enable",
            &toml::Value::Array(vec![toml::Value::String("ul-style".to_string())]),
        );
        assert!(matches!(outcome, ApplyOutcome::Applied));
        assert_eq!(global.enable.value, vec!["MD004".to_string()], "aliases resolve");
    }

    #[test]
    fn rejects_wrong_types_without_storing() {
        let (global, outcome) = apply("line-length", &toml::Value::String("wide".to_string()));
        assert!(matches!(outcome, ApplyOutcome::TypeMismatch { expected: "integer" }));
        assert_eq!(global.line_length.source, ConfigSource::Default);
    }

    #[test]
    fn negative_line_length_clamps_to_zero() {
        let (global, outcome) = apply("line-length", &toml::Value::Integer(-5));
        assert!(matches!(outcome, ApplyOutcome::Applied));
        assert_eq!(global.line_length.value.get(), 0);
    }

    #[test]
    fn unknown_flavor_is_invalid_not_stored() {
        let (global, outcome) = apply("flavor", &toml::Value::String("nonexistent".to_string()));
        assert!(matches!(outcome, ApplyOutcome::InvalidValue { .. }));
        assert_eq!(global.flavor.source, ConfigSource::Default);
    }
}
