//! Configuration loading for Pathfinder.
//!
//! Supports zero-config defaults with optional `pathfinder.config.json`
//! override. See PRD §10 for the configuration schema.

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;

/// Top-level Pathfinder configuration.
///
/// All fields have sensible defaults. An absent `pathfinder.config.json`
/// file is perfectly valid — Pathfinder works out of the box.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathfinderConfig {
    /// Per-language LSP configurations.
    #[serde(default)]
    pub lsp: HashMap<String, LspConfig>,

    /// Sandbox configuration overrides.
    #[serde(default)]
    pub sandbox: SandboxConfig,

    /// Search defaults.
    #[serde(default)]
    pub search: SearchConfig,

    /// Repo map defaults.
    #[serde(default)]
    pub repo_map: RepoMapConfig,

    /// Log level.
    #[serde(default = "default_log_level")]
    pub log_level: String,
}

impl Default for PathfinderConfig {
    fn default() -> Self {
        Self {
            lsp: HashMap::new(),
            sandbox: SandboxConfig::default(),
            search: SearchConfig::default(),
            repo_map: RepoMapConfig::default(),
            log_level: default_log_level(),
        }
    }
}

impl PathfinderConfig {
    /// Load configuration from the workspace root.
    ///
    /// If `pathfinder.config.json` exists, it is loaded and merged with defaults.
    /// If it doesn't exist, defaults are returned.
    ///
    /// # Errors
    /// Returns an error if the file exists but contains invalid JSON.
    pub async fn load(workspace_root: &Path) -> Result<Self, ConfigError> {
        let config_path = workspace_root.join("pathfinder.config.json");

        if !config_path.exists() {
            tracing::debug!("No pathfinder.config.json found, using defaults");
            return Ok(Self::default());
        }

        let content =
            tokio::fs::read_to_string(&config_path)
                .await
                .map_err(|e| ConfigError::ReadFailed {
                    path: config_path.clone(),
                    source: e,
                })?;

        let config: Self =
            serde_json::from_str(&content).map_err(|e| ConfigError::ParseFailed {
                path: config_path,
                source: e,
            })?;

        tracing::info!("Loaded configuration from pathfinder.config.json");
        Ok(config)
    }
}

/// LSP server configuration for a specific language.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspConfig {
    /// Command to start the LSP server.
    pub command: String,

    /// Arguments to pass to the LSP server.
    #[serde(default)]
    pub args: Vec<String>,

    /// Idle timeout in minutes before auto-termination.
    #[serde(default = "default_idle_timeout")]
    pub idle_timeout_minutes: u64,

    /// Additional LSP settings (workspace/didChangeConfiguration).
    #[serde(default)]
    pub settings: serde_json::Value,

    /// Override the root directory used for this language server.
    ///
    /// Use this in monorepo layouts where the language marker file (e.g.,
    /// `go.mod`, `tsconfig.json`) is not at the workspace root. The path
    /// is relative to the workspace root.
    ///
    /// Example: `"apps/backend"` for a Go backend in a monorepo.
    #[serde(default)]
    pub root_override: Option<String>,

    /// TypeScript plugins to load via initializationOptions.
    ///
    /// Each entry is a plugin name that will be resolved from `node_modules`.
    /// Example: `"@vue/typescript-plugin"`
    #[serde(default)]
    pub typescript_plugins: Vec<String>,
}

/// Sandbox configuration overrides.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SandboxConfig {
    /// Additional patterns to deny (on top of defaults).
    #[serde(default)]
    pub additional_deny: Vec<String>,

    /// Patterns to allow (override default deny list).
    #[serde(default)]
    pub allow_override: Vec<String>,
}

/// Search tool defaults.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfig {
    /// Maximum results returned by `search_codebase`.
    #[serde(default = "default_max_results")]
    pub max_results: usize,

    /// Default filter mode.
    #[serde(default = "default_filter_mode")]
    pub default_filter_mode: String,
}

impl Default for SearchConfig {
    fn default() -> Self {
        Self {
            max_results: default_max_results(),
            default_filter_mode: default_filter_mode(),
        }
    }
}

/// Repo map defaults.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoMapConfig {
    /// Default maximum tokens for `get_repo_map`.
    #[serde(default = "default_max_tokens")]
    pub max_tokens: usize,

    /// Token counting method.
    #[serde(default = "default_token_method")]
    pub token_method: String,
}

impl Default for RepoMapConfig {
    fn default() -> Self {
        Self {
            max_tokens: default_max_tokens(),
            token_method: default_token_method(),
        }
    }
}

/// Configuration loading errors.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    /// Error when reading the configuration file fails.
    #[error("failed to read config file {}: {source}", path.display())]
    ReadFailed {
        /// Path to the configuration file that failed to read.
        path: std::path::PathBuf,
        /// Underlying I/O error.
        source: std::io::Error,
    },

    /// Error when parsing the configuration file fails.
    #[error("failed to parse config file {}: {source}", path.display())]
    ParseFailed {
        /// Path to the configuration file that failed to parse.
        path: std::path::PathBuf,
        /// Underlying JSON parsing error.
        source: serde_json::Error,
    },
}

fn default_log_level() -> String {
    "info".to_owned()
}

const fn default_idle_timeout() -> u64 {
    15
}

const fn default_max_results() -> usize {
    50
}

fn default_filter_mode() -> String {
    "code_only".to_owned()
}

const fn default_max_tokens() -> usize {
    16_000
}

fn default_token_method() -> String {
    "char_div_4".to_owned()
}

#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_default_config() {
        let config = PathfinderConfig::default();
        assert_eq!(config.log_level, "info");
        assert_eq!(config.search.max_results, 50);
        assert_eq!(config.repo_map.max_tokens, 16_000);
        assert!(config.lsp.is_empty());
    }

    #[test]
    fn test_config_deserialization() {
        let json = r#"{
            "lsp": {
                "typescript": {
                    "command": "typescript-language-server",
                    "args": ["--stdio"],
                    "idle_timeout_minutes": 30
                }
            },
            "sandbox": {
                "additional_deny": ["*.generated.ts"],
                "allow_override": [".env.example"]
            },
            "search": {
                "max_results": 100
            },
            "log_level": "debug"
        }"#;

        let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
        assert_eq!(config.log_level, "debug");
        assert_eq!(config.search.max_results, 100);
        assert!(config.lsp.contains_key("typescript"));

        let ts_config = &config.lsp["typescript"];
        assert_eq!(ts_config.command, "typescript-language-server");
        assert_eq!(ts_config.idle_timeout_minutes, 30);
    }

    #[test]
    fn test_partial_config_uses_defaults() {
        let json = r#"{ "log_level": "warn" }"#;
        let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
        assert_eq!(config.log_level, "warn");
        // Other fields should be defaults
        assert_eq!(config.search.max_results, 50);
        assert_eq!(config.repo_map.max_tokens, 16_000);
    }

    #[tokio::test]
    async fn test_load_missing_config_returns_defaults() {
        let temp = tempdir().expect("create tempdir");
        let config = PathfinderConfig::load(temp.path())
            .await
            .expect("should return defaults");
        assert_eq!(config.log_level, "info");
        // TempDir cleans up automatically on drop
    }

    #[tokio::test]
    async fn test_load_invalid_json_returns_error() {
        let temp = std::env::temp_dir().join("pathfinder_test_bad_json");
        let _ = std::fs::create_dir_all(&temp);
        std::fs::write(temp.join("pathfinder.config.json"), "not json").expect("should write");

        let result = PathfinderConfig::load(&temp).await;
        assert!(result.is_err());
        assert!(matches!(result, Err(ConfigError::ParseFailed { .. })));

        let _ = std::fs::remove_dir_all(&temp);
    }

    #[tokio::test]
    async fn test_load_valid_config_from_file() {
        let temp = tempdir().expect("create tempdir");
        let config_json = r#"{ "log_level": "trace", "idle_timeout": 30 }"#;
        std::fs::write(temp.path().join("pathfinder.config.json"), config_json)
            .expect("should write config");

        let config = PathfinderConfig::load(temp.path())
            .await
            .expect("should load valid config");
        assert_eq!(config.log_level, "trace");
        // Fields not in the JSON should retain defaults
        assert_eq!(config.search.max_results, 50);
    }

    #[test]
    fn test_default_idle_timeout_value() {
        assert_eq!(default_idle_timeout(), 15);
    }

    #[test]
    fn test_typescript_plugins_defaults_to_empty() {
        let config: LspConfig =
            serde_json::from_str(r#"{ "command": "tsserver" }"#).expect("should parse");
        assert!(config.typescript_plugins.is_empty());
    }

    #[test]
    fn test_typescript_plugins_deserialization() {
        let json = r#"{
            "lsp": {
                "typescript": {
                    "command": "typescript-language-server",
                    "typescript_plugins": ["@vue/typescript-plugin", "@angular/language-service"]
                }
            }
        }"#;

        let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
        let ts_config = &config.lsp["typescript"];
        assert_eq!(ts_config.typescript_plugins.len(), 2);
        assert_eq!(ts_config.typescript_plugins[0], "@vue/typescript-plugin");
        assert_eq!(ts_config.typescript_plugins[1], "@angular/language-service");
    }

    // ── Phase 2 §5: [lsp.java] config section ────────────────────────────────

    /// Phase 2 §5: Full jdtls config block deserializes correctly via the
    /// generic `lsp` `HashMap` (no Java-specific schema additions needed).
    ///
    /// Verifies: command, args, `idle_timeout_minutes`, and settings all round-trip
    /// through `serde_json`.
    #[test]
    fn test_java_lsp_config_section() {
        let json = r#"{
            "lsp": {
                "java": {
                    "command": "jdtls",
                    "args": ["--jvm-arg=-Xmx2G"],
                    "idle_timeout_minutes": 20,
                    "settings": {
                        "java": {
                            "format": { "enabled": true },
                            "import": {
                                "gradle": { "enabled": true },
                                "maven": { "enabled": true }
                            }
                        }
                    }
                }
            }
        }"#;

        let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
        assert!(
            config.lsp.contains_key("java"),
            "[lsp.java] key must be present"
        );

        let java_config = &config.lsp["java"];
        assert_eq!(java_config.command, "jdtls", "command must be jdtls");
        assert_eq!(
            java_config.args,
            vec!["--jvm-arg=-Xmx2G"],
            "args must round-trip"
        );
        assert_eq!(
            java_config.idle_timeout_minutes, 20,
            "idle timeout must be 20"
        );

        // settings blob preserved
        let settings = &java_config.settings;
        assert!(!settings.is_null(), "settings must not be null");
        assert!(
            settings.get("java").is_some(),
            "settings.java key must be present"
        );
    }

    /// Phase 2 §5: Minimal jdtls config (command only) uses defaults for
    /// all optional fields.
    #[test]
    fn test_java_lsp_config_minimal() {
        let json = r#"{
            "lsp": {
                "java": {
                    "command": "jdtls"
                }
            }
        }"#;

        let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
        let java_config = &config.lsp["java"];

        assert_eq!(java_config.command, "jdtls");
        assert!(java_config.args.is_empty(), "args should default to empty");
        assert_eq!(
            java_config.idle_timeout_minutes,
            default_idle_timeout(),
            "idle_timeout_minutes must default to {}",
            default_idle_timeout()
        );
        assert!(
            java_config.settings.is_null(),
            "settings should default to null"
        );
        assert!(
            java_config.root_override.is_none(),
            "root_override should default to None"
        );
        assert!(
            java_config.typescript_plugins.is_empty(),
            "typescript_plugins should default to empty"
        );
    }

    /// Phase 2 §5: Java and TypeScript LSP configs can coexist in the same
    /// config file without interference.
    #[test]
    fn test_java_and_typescript_lsp_configs_coexist() {
        let json = r#"{
            "lsp": {
                "java": {
                    "command": "jdtls",
                    "idle_timeout_minutes": 30
                },
                "typescript": {
                    "command": "typescript-language-server",
                    "args": ["--stdio"]
                }
            }
        }"#;

        let config: PathfinderConfig = serde_json::from_str(json).expect("should deserialize");
        assert_eq!(
            config.lsp.len(),
            2,
            "both java and typescript must be present"
        );

        assert_eq!(config.lsp["java"].command, "jdtls");
        assert_eq!(config.lsp["java"].idle_timeout_minutes, 30);

        assert_eq!(
            config.lsp["typescript"].command,
            "typescript-language-server"
        );
        assert_eq!(config.lsp["typescript"].args, vec!["--stdio"]);
    }
}
