//! Configuration: provider ids, layered config loading, and on-disk paths.
//!
//! Precedence (first wins), per plan §10:
//!   flags  →  env / .env  →  project .hephaes/config.toml  →  global config.toml
//!
//! Secrets (API keys) never live in config.toml — see [`crate::secret`].

use anyhow::{Context, Result};
use figment::{
    providers::{Format, Toml},
    Figment,
};
use serde::{Deserialize, Serialize};
use std::env;
use std::path::{Path, PathBuf};

/// The model provider backends (plan §10). `claude`/`codex` are native-agent
/// (subprocess); the rest are raw-model (HTTP).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum ProviderId {
    Claude,
    Codex,
    Anthropic,
    Openai,
    Openrouter,
    Ollama,
}

impl ProviderId {
    pub fn as_str(self) -> &'static str {
        match self {
            ProviderId::Claude => "claude",
            ProviderId::Codex => "codex",
            ProviderId::Anthropic => "anthropic",
            ProviderId::Openai => "openai",
            ProviderId::Openrouter => "openrouter",
            ProviderId::Ollama => "ollama",
        }
    }

    /// Native-agent backends run an existing CLI by subprocess and use its own
    /// subscription auth; they have no API key for hephaes to store.
    pub fn is_native_agent(self) -> bool {
        matches!(self, ProviderId::Claude | ProviderId::Codex)
    }

    /// Environment variable that supplies this provider's API key, if any.
    pub fn env_key_var(self) -> Option<&'static str> {
        match self {
            ProviderId::Anthropic => Some("ANTHROPIC_API_KEY"),
            ProviderId::Openai => Some("OPENAI_API_KEY"),
            ProviderId::Openrouter => Some("OPENROUTER_API_KEY"),
            _ => None,
        }
    }

    pub fn all() -> [ProviderId; 6] {
        [
            ProviderId::Claude,
            ProviderId::Codex,
            ProviderId::Anthropic,
            ProviderId::Openai,
            ProviderId::Openrouter,
            ProviderId::Ollama,
        ]
    }

    pub fn parse(s: &str) -> Option<ProviderId> {
        match s.trim().to_ascii_lowercase().as_str() {
            "claude" => Some(ProviderId::Claude),
            "codex" => Some(ProviderId::Codex),
            "anthropic" => Some(ProviderId::Anthropic),
            "openai" => Some(ProviderId::Openai),
            "openrouter" => Some(ProviderId::Openrouter),
            "ollama" => Some(ProviderId::Ollama),
            _ => None,
        }
    }
}

impl std::fmt::Display for ProviderId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
pub const DEFAULT_WIKI_DIR: &str = "wiki";
pub const DEFAULT_MAX_FILE_BYTES: u64 = 512 * 1024;

/// Per-command model overrides (`[models]` table in config.toml).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PerCommandModels {
    pub init: Option<String>,
    pub update: Option<String>,
    pub ask: Option<String>,
}

/// What `init`/`update`/`ask` resolve a model for.
#[derive(Debug, Clone, Copy)]
pub enum Op {
    Init,
    Update,
    Ask,
}

/// All-optional view of config used while merging layers.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct PartialConfig {
    pub provider: Option<ProviderId>,
    pub model: Option<String>,
    pub wiki_dir: Option<String>,
    pub ollama_url: Option<String>,
    pub concurrency: Option<usize>,
    pub max_file_bytes: Option<u64>,
    #[serde(default)]
    pub include: Option<Vec<String>>,
    #[serde(default)]
    pub exclude: Option<Vec<String>>,
    // `models` is a TOML sub-table; keep it last so scalar/array fields are
    // serialized before it (TOML requires tables after values).
    #[serde(default)]
    pub models: Option<PerCommandModels>,
}

/// Fully-resolved configuration for a run.
#[derive(Debug, Clone)]
pub struct Config {
    pub provider: ProviderId,
    pub model: Option<String>,
    pub models: PerCommandModels,
    pub wiki_dir: String,
    pub ollama_url: String,
    /// Reserved for parallel page builds (plan §15, M5).
    #[allow(dead_code)]
    pub concurrency: usize,
    pub include: Vec<String>,
    pub exclude: Vec<String>,
    pub max_file_bytes: u64,
}

impl Config {
    /// Model to use for a given operation: per-command override, else default.
    pub fn model_for(&self, op: Op) -> Option<String> {
        let specific = match op {
            Op::Init => self.models.init.clone(),
            Op::Update => self.models.update.clone(),
            Op::Ask => self.models.ask.clone(),
        };
        specific.or_else(|| self.model.clone())
    }
}

/// CLI overrides applied on top of file/env config.
#[derive(Debug, Clone, Default)]
pub struct Overrides {
    pub provider: Option<ProviderId>,
    pub model: Option<String>,
    pub wiki_dir: Option<String>,
    pub concurrency: Option<usize>,
    pub include: Vec<String>,
    pub exclude: Vec<String>,
    pub max_file_bytes: Option<u64>,
}

/// On-disk locations derived from the repository root.
#[derive(Debug, Clone)]
pub struct Paths {
    #[allow(dead_code)]
    pub repo_root: PathBuf,
    pub hephaes_dir: PathBuf,
    pub project_config: PathBuf,
    pub credentials: PathBuf,
    pub manifest: PathBuf,
    pub global_config: PathBuf,
}

impl Paths {
    pub fn new(repo_root: &Path) -> Paths {
        let hephaes_dir = repo_root.join(".hephaes");
        Paths {
            repo_root: repo_root.to_path_buf(),
            project_config: hephaes_dir.join("config.toml"),
            credentials: hephaes_dir.join("credentials.toml"),
            manifest: hephaes_dir.join("manifest.json"),
            hephaes_dir,
            global_config: global_config_path(),
        }
    }
}

fn global_config_path() -> PathBuf {
    if let Some(dirs) = directories::ProjectDirs::from("", "", "hephaes") {
        return dirs.config_dir().join("config.toml");
    }
    // Fallback when ProjectDirs is unavailable.
    let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
    PathBuf::from(home)
        .join(".config")
        .join("hephaes")
        .join("config.toml")
}

/// Read and merge the two TOML layers (global then project). Missing files are
/// treated as empty. Env and flags are applied later in [`resolve`].
fn load_partial(paths: &Paths) -> Result<PartialConfig> {
    Figment::new()
        .merge(Toml::file(&paths.global_config))
        .merge(Toml::file(&paths.project_config))
        .extract()
        .context("parsing config.toml")
}

fn env_str(key: &str) -> Option<String> {
    env::var(key).ok().filter(|v| !v.is_empty())
}

/// Resolve the effective config. Returns `Ok(None)` when no provider can be
/// determined (the caller then runs the onboarding selector on a TTY).
pub fn resolve(paths: &Paths, ov: &Overrides) -> Result<Option<Config>> {
    let mut p = load_partial(paths)?;

    // env layer
    if let Some(v) = env_str("HEPHAES_PROVIDER") {
        p.provider = ProviderId::parse(&v);
    }
    if let Some(v) = env_str("HEPHAES_MODEL") {
        p.model = Some(v);
    }
    if let Some(v) = env_str("HEPHAES_WIKI_DIR") {
        p.wiki_dir = Some(v);
    }
    if let Some(v) = env_str("HEPHAES_OLLAMA_URL") {
        p.ollama_url = Some(v);
    }
    if let Some(v) = env_str("HEPHAES_CONCURRENCY").and_then(|v| v.parse().ok()) {
        p.concurrency = Some(v);
    }
    if let Some(v) = env_str("HEPHAES_MAX_FILE_BYTES").and_then(|v| v.parse().ok()) {
        p.max_file_bytes = Some(v);
    }

    // flag layer (highest)
    if ov.provider.is_some() {
        p.provider = ov.provider;
    }
    if ov.model.is_some() {
        p.model = ov.model.clone();
    }
    if ov.wiki_dir.is_some() {
        p.wiki_dir = ov.wiki_dir.clone();
    }
    if ov.concurrency.is_some() {
        p.concurrency = ov.concurrency;
    }
    if ov.max_file_bytes.is_some() {
        p.max_file_bytes = ov.max_file_bytes;
    }

    let Some(provider) = p.provider else {
        return Ok(None);
    };

    let mut include = p.include.unwrap_or_default();
    include.extend(ov.include.iter().cloned());
    let mut exclude = p.exclude.unwrap_or_default();
    exclude.extend(ov.exclude.iter().cloned());

    Ok(Some(Config {
        provider,
        model: p.model,
        models: p.models.unwrap_or_default(),
        wiki_dir: p.wiki_dir.unwrap_or_else(|| DEFAULT_WIKI_DIR.to_string()),
        ollama_url: p
            .ollama_url
            .unwrap_or_else(|| DEFAULT_OLLAMA_URL.to_string()),
        concurrency: p.concurrency.unwrap_or(2).max(1),
        include,
        exclude,
        max_file_bytes: p.max_file_bytes.unwrap_or(DEFAULT_MAX_FILE_BYTES),
    }))
}

/// Persist non-secret selection to the project config file.
pub fn save_partial(paths: &Paths, partial: &PartialConfig) -> Result<()> {
    std::fs::create_dir_all(&paths.hephaes_dir)
        .with_context(|| format!("creating {}", paths.hephaes_dir.display()))?;
    let toml = toml::to_string_pretty(partial).context("serializing config")?;
    std::fs::write(&paths.project_config, toml)
        .with_context(|| format!("writing {}", paths.project_config.display()))?;
    Ok(())
}

/// Merge a single key=value into the project config (used by `config set`).
pub fn set_key(paths: &Paths, key: &str, value: &str) -> Result<()> {
    let mut p = if paths.project_config.exists() {
        let s = std::fs::read_to_string(&paths.project_config)?;
        toml::from_str::<PartialConfig>(&s).unwrap_or_default()
    } else {
        PartialConfig::default()
    };
    match key {
        "provider" => {
            p.provider = Some(
                ProviderId::parse(value)
                    .ok_or_else(|| anyhow::anyhow!("unknown provider: {value}"))?,
            )
        }
        "model" => p.model = Some(value.to_string()),
        "wiki_dir" => p.wiki_dir = Some(value.to_string()),
        "ollama_url" => p.ollama_url = Some(value.to_string()),
        "concurrency" => p.concurrency = Some(value.parse().context("concurrency")?),
        "max_file_bytes" => p.max_file_bytes = Some(value.parse().context("max_file_bytes")?),
        other => anyhow::bail!("unknown config key: {other}"),
    }
    save_partial(paths, &p)
}
