//! Cordance project-level configuration (`cordance.toml`).
//!
//! All fields have sane defaults so an absent file is valid.

use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Config {
    #[serde(default)]
    pub doctrine: DoctrineConfig,
    #[serde(default)]
    pub axiom: AxiomConfig,
    #[serde(default)]
    pub llm: LlmConfig,
    /// MCP server policy (`cordance serve`). Optional; defaults restrict
    /// target validation to the directory `cordance serve` was launched from.
    #[serde(default)]
    pub mcp: McpConfig,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DoctrineConfig {
    /// Path to engineering-doctrine repo. Relative to the target dir or absolute.
    #[serde(default = "default_doctrine_source")]
    pub source: String,
    #[serde(default = "default_doctrine_fallback")]
    pub fallback_repo: String,
    /// `"auto"` = use HEAD commit; any other value is used verbatim.
    #[serde(default = "default_pin_commit")]
    pub pin_commit: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AxiomConfig {
    /// Path to the pai-axiom repo. Relative to the target dir or absolute.
    #[serde(default = "default_axiom_source")]
    pub source: String,
    /// `"auto"` = read `PAI/Algorithm/LATEST` from `source`; any other value
    /// is used verbatim (e.g. `"v3.1.1-axiom"`).
    #[serde(default = "default_algorithm_latest")]
    pub algorithm_latest: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LlmConfig {
    /// `"none"` | `"ollama"` | `"lm-studio"`
    #[serde(default = "default_llm_provider")]
    pub provider: String,
    /// Ollama adapter configuration (used when `provider == "ollama"`).
    #[serde(default)]
    pub ollama: OllamaConfig,
}

/// `[llm.ollama]` section of `cordance.toml`. See ADR 0015.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OllamaConfig {
    #[serde(default = "default_ollama_base_url")]
    pub base_url: String,
    #[serde(default = "default_ollama_model")]
    pub model: String,
    /// Sampling temperature. 0.0–2.0. Low values are more deterministic.
    #[serde(default = "default_ollama_temperature")]
    pub temperature: f32,
    /// Context window in tokens.
    #[serde(default = "default_ollama_num_ctx")]
    pub num_ctx: u32,
}

fn default_ollama_base_url() -> String {
    "http://localhost:11434".into()
}
fn default_ollama_model() -> String {
    "qwen2.5-coder:14b".into()
}
const fn default_ollama_temperature() -> f32 {
    0.1
}
const fn default_ollama_num_ctx() -> u32 {
    8192
}

impl Default for OllamaConfig {
    fn default() -> Self {
        Self {
            base_url: default_ollama_base_url(),
            model: default_ollama_model(),
            temperature: default_ollama_temperature(),
            num_ctx: default_ollama_num_ctx(),
        }
    }
}

impl cordance_llm::OllamaSettings for OllamaConfig {
    fn base_url(&self) -> &str {
        &self.base_url
    }
    fn model(&self) -> &str {
        &self.model
    }
    fn temperature(&self) -> f32 {
        self.temperature
    }
    fn num_ctx(&self) -> u32 {
        self.num_ctx
    }
}

/// `[mcp]` section of `cordance.toml`.
///
/// Governs the path-canonicalisation guard applied to every tool that takes a
/// `target` parameter (see `mcp::validation::validate_target`). When this
/// section is absent the only acceptable target is the directory `cordance
/// serve` was launched from.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct McpConfig {
    /// Additional roots a `target` argument is permitted to canonicalise into.
    /// Each path is resolved against the server's working directory and is
    /// canonicalised on first use. Entries that fail to canonicalise are
    /// dropped silently (treated as not allowed).
    #[serde(default)]
    pub allowed_roots: Vec<String>,
}

fn default_doctrine_source() -> String {
    "../engineering-doctrine".into()
}
fn default_doctrine_fallback() -> String {
    "https://github.com/0ryant/engineering-doctrine".into()
}
fn default_pin_commit() -> String {
    "auto".into()
}
fn default_axiom_source() -> String {
    "../pai-axiom".into()
}
fn default_algorithm_latest() -> String {
    "auto".into()
}
fn default_llm_provider() -> String {
    "none".into()
}

impl Default for DoctrineConfig {
    fn default() -> Self {
        Self {
            source: default_doctrine_source(),
            fallback_repo: default_doctrine_fallback(),
            pin_commit: default_pin_commit(),
        }
    }
}

impl Default for AxiomConfig {
    fn default() -> Self {
        Self {
            source: default_axiom_source(),
            algorithm_latest: default_algorithm_latest(),
        }
    }
}

impl Default for LlmConfig {
    fn default() -> Self {
        Self {
            provider: default_llm_provider(),
            ollama: OllamaConfig::default(),
        }
    }
}

/// Errors returned by [`Config::load_strict`].
///
/// Every call site is strict: a malformed `cordance.toml` aborts the run with
/// the file path attached. Callers that want graceful degradation should
/// `.unwrap_or_default()` at the call site so the choice is visible.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("io error reading {path}: {source}")]
    Io {
        path: Utf8PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("toml parse error in {path}: {source}")]
    Parse {
        path: Utf8PathBuf,
        // `toml::de::Error` is ~128 bytes on its own; box it to keep the
        // `ConfigError` enum small (clippy::result_large_err).
        #[source]
        source: Box<toml::de::Error>,
    },
    #[error("invalid URL in cordance.toml: {0}")]
    InvalidUrl(String),
}

impl Config {
    /// Load from `{target}/cordance.toml`, returning a typed error on failure.
    ///
    /// In addition to surfacing parse errors, this validates remote-host
    /// constraints on `[llm.ollama].base_url`: any non-loopback host is reset
    /// to the default unless the environment variable
    /// `CORDANCE_ALLOW_REMOTE_LLM=1` is set. See the round-1 redteam review.
    ///
    /// # Errors
    ///
    /// - [`ConfigError::Io`] when the file exists but cannot be read.
    /// - [`ConfigError::Parse`] when the TOML cannot be deserialised.
    /// - [`ConfigError::InvalidUrl`] when `base_url` cannot be parsed as a URL.
    pub fn load_strict(target: &Utf8PathBuf) -> Result<Self, ConfigError> {
        let path = target.join("cordance.toml");
        if !path.exists() {
            return Ok(Self::default());
        }
        let content = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io {
            path: path.clone(),
            source,
        })?;
        let mut cfg: Self = toml::from_str(&content).map_err(|source| ConfigError::Parse {
            path: path.clone(),
            source: Box::new(source),
        })?;
        validate_llm_endpoints(&mut cfg)?;
        Ok(cfg)
    }

    /// Resolve the doctrine root path (relative to `target`, or absolute).
    pub fn doctrine_root(&self, target: &Utf8PathBuf) -> Utf8PathBuf {
        resolve_path(&self.doctrine.source, target)
    }

    /// Resolve the axiom source path (relative to `target`, or absolute).
    pub fn axiom_root(&self, target: &Utf8PathBuf) -> Utf8PathBuf {
        resolve_path(&self.axiom.source, target)
    }

    /// Return the axiom algorithm version string.
    ///
    /// When `algorithm_latest == "auto"`, reads the `LATEST` file from the
    /// configured axiom repo. Falls back to a hard-coded default.
    pub fn axiom_version(&self, target: &Utf8PathBuf) -> String {
        if self.axiom.algorithm_latest != "auto" {
            return self.axiom.algorithm_latest.clone();
        }
        let axiom = self.axiom_root(target);
        // Compatibility path (pre-rename layout).
        let candidates = [
            axiom.join("PAI/Algorithm/LATEST"),
            axiom.join("axiom/Algorithm/LATEST"),
        ];
        for candidate in &candidates {
            if let Ok(content) = std::fs::read_to_string(candidate) {
                let v = content.trim().to_string();
                if !v.is_empty() {
                    return v;
                }
            }
        }
        "v3.1.1-axiom".into()
    }

}

fn resolve_path(s: &str, base: &Utf8PathBuf) -> Utf8PathBuf {
    let p = Utf8PathBuf::from(s);
    if p.is_absolute() {
        p
    } else {
        base.join(s)
    }
}

/// Process-global toggle: when `CORDANCE_ALLOW_REMOTE_LLM=1` is set, the
/// loopback-only check on `[llm.ollama].base_url` is bypassed.
fn allow_remote_llm_from_env() -> bool {
    std::env::var("CORDANCE_ALLOW_REMOTE_LLM").as_deref() == Ok("1")
}

/// Reject non-loopback `[llm.ollama].base_url` entries to defeat a target-
/// controlled `cordance.toml` redirecting prompts to an attacker-run Ollama
/// instance.
///
/// Reads [`allow_remote_llm_from_env`] to decide whether to enforce the
/// loopback rule.
fn validate_llm_endpoints(cfg: &mut Config) -> Result<(), ConfigError> {
    validate_llm_endpoints_with(cfg, allow_remote_llm_from_env())
}

/// Test-friendly inner implementation: takes the `allow_remote` flag as a
/// parameter so tests don't have to mutate process-global env state (which is
/// `unsafe` in modern Rust and forbidden by this crate's lint policy).
fn validate_llm_endpoints_with(
    cfg: &mut Config,
    allow_remote: bool,
) -> Result<(), ConfigError> {
    if allow_remote {
        return Ok(());
    }

    let url_str = &cfg.llm.ollama.base_url;
    let parsed = url::Url::parse(url_str)
        .map_err(|e| ConfigError::InvalidUrl(format!("[llm.ollama].base_url: {e}")))?;

    match parsed.host_str() {
        Some("localhost" | "127.0.0.1" | "::1" | "[::1]") => Ok(()),
        other => {
            tracing::warn!(
                host = ?other,
                "Refusing non-loopback Ollama base_url from cordance.toml; \
                 set CORDANCE_ALLOW_REMOTE_LLM=1 to override"
            );
            cfg.llm.ollama.base_url = default_ollama_base_url();
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Write `cordance.toml` into a fresh temp directory and return the path
    /// (as `Utf8PathBuf`) that callers can pass to `Config::load_strict`.
    fn write_cfg(content: &str) -> (tempfile::TempDir, Utf8PathBuf) {
        let dir = tempfile::tempdir().expect("create tempdir");
        let path = dir.path().join("cordance.toml");
        std::fs::write(&path, content).expect("write cordance.toml");
        let utf8 = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
            .expect("tempdir is utf8");
        (dir, utf8)
    }

    fn parse_cfg(content: &str) -> Config {
        toml::from_str(content).expect("parse test cordance.toml")
    }

    // ── pure validator-level tests (no env mutation) ────────────────────────

    #[test]
    fn non_loopback_ollama_url_resets_to_default() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "https://evil.com"
"#,
        );
        validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
        assert_eq!(
            cfg.llm.ollama.base_url,
            "http://localhost:11434",
            "non-loopback host must be reset to the default loopback URL"
        );
    }

    #[test]
    fn loopback_ollama_url_preserved() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "http://127.0.0.1:11434"
"#,
        );
        validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
        assert_eq!(cfg.llm.ollama.base_url, "http://127.0.0.1:11434");
    }

    #[test]
    fn localhost_hostname_preserved() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "http://localhost:11434"
"#,
        );
        validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
        assert_eq!(cfg.llm.ollama.base_url, "http://localhost:11434");
    }

    #[test]
    fn ipv6_loopback_preserved() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "http://[::1]:11434"
"#,
        );
        validate_llm_endpoints_with(&mut cfg, false).expect("validate ok");
        assert_eq!(cfg.llm.ollama.base_url, "http://[::1]:11434");
    }

    #[test]
    fn env_var_overrides_loopback_check() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "https://evil.com"
"#,
        );
        // Simulate `CORDANCE_ALLOW_REMOTE_LLM=1` being set without touching
        // the process-global env (which would require `unsafe`).
        validate_llm_endpoints_with(&mut cfg, true).expect("validate ok");
        assert_eq!(
            cfg.llm.ollama.base_url, "https://evil.com",
            "env override must preserve the configured URL"
        );
    }

    #[test]
    fn malformed_url_returns_invalid_url_err() {
        let mut cfg = parse_cfg(
            r#"
[llm.ollama]
base_url = "not a url"
"#,
        );
        let err = validate_llm_endpoints_with(&mut cfg, false)
            .expect_err("expect InvalidUrl error");
        assert!(
            matches!(err, ConfigError::InvalidUrl(_)),
            "expected ConfigError::InvalidUrl, got {err:?}"
        );
    }

    // ── Config::load_strict smoke tests ─────────────────────────────────────

    #[test]
    fn toml_parse_error_returns_err() {
        // `=` without a value, deliberately malformed.
        let (_dir, target) = write_cfg("not = = valid");
        let err = Config::load_strict(&target).expect_err("expect Parse error");
        assert!(
            matches!(err, ConfigError::Parse { .. }),
            "expected ConfigError::Parse, got {err:?}"
        );
    }

    #[test]
    fn missing_config_returns_default() {
        let dir = tempfile::tempdir().expect("tempdir");
        let utf8 = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
            .expect("utf8 tempdir");
        let cfg = Config::load_strict(&utf8).expect("default on missing file");
        assert_eq!(cfg.llm.provider, "none");
        assert_eq!(cfg.llm.ollama.base_url, "http://localhost:11434");
    }

    #[test]
    fn load_strict_unwrap_or_default_recovers_from_parse_error() {
        // Replacement for the removed legacy `Config::load` test. Callers that
        // want graceful degradation must opt in explicitly at the call site,
        // and `.unwrap_or_default()` is the canonical recipe.
        let (_dir, target) = write_cfg("not = = valid");
        let cfg = Config::load_strict(&target).unwrap_or_default();
        assert_eq!(cfg.llm.ollama.base_url, "http://localhost:11434");
    }

    #[test]
    fn load_strict_resets_remote_url_unless_env_set() {
        // Without CORDANCE_ALLOW_REMOTE_LLM in the test environment, the
        // remote URL must be reset to the loopback default. We deliberately
        // do not mutate the env here; if the test process happens to inherit
        // the variable, the assertion below is skipped.
        if allow_remote_llm_from_env() {
            return;
        }

        let (_dir, target) = write_cfg(
            r#"
[llm.ollama]
base_url = "https://evil.com"
"#,
        );
        let cfg = Config::load_strict(&target).expect("load_strict ok");
        assert_eq!(
            cfg.llm.ollama.base_url, "http://localhost:11434",
            "remote URL must be reset to loopback default"
        );
    }
}
