//! Configuration types deserialized from YAML.
//!
//! The full configuration schema is documented in
//! `docs/requirements/configuration.md`. This module implements the practical
//! subset needed by the pipeline; unknown YAML keys are ignored (forward
//! compatible) so newer config files can be loaded by older binaries without
//! a hard failure.
//!
//! Paths support tilde-expansion (`~`, `~/foo`) via [`expand_path`].
//!
//! # Example
//!
//! ```ignore
//! use std::path::Path;
//! use tga::core::config::Config;
//!
//! let cfg = Config::load(Path::new("config.yaml")).expect("load");
//! println!("repos: {}", cfg.repositories.len());
//! ```

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::classify::taxonomy::SubcategoryDef;
use crate::core::errors::{Result, TgaError};

pub mod aliases;
pub mod azdo;
pub mod validator;

pub use aliases::{AliasFile, DeveloperAliasEntry};
pub use azdo::AzureDevOpsConfig;
pub use validator::{ConfigError, ConfigValidator};

/// Top-level configuration root.
///
/// Mirrors the YAML schema from the Python predecessor. All top-level
/// sections are optional except `repositories`, which must contain at
/// least one entry to be useful.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
    /// Repositories to analyze.
    #[serde(default)]
    pub repositories: Vec<RepositoryConfig>,

    /// Team / member roster and aliases.
    #[serde(default)]
    pub team: Option<TeamConfig>,

    /// Output destination and format flags.
    #[serde(default)]
    pub output: Option<OutputConfig>,

    /// Classification cascade settings.
    #[serde(default)]
    pub classification: Option<ClassificationConfig>,

    /// GitHub API credentials and scope.
    #[serde(default)]
    pub github: Option<GithubConfig>,

    /// JIRA API credentials and scope.
    #[serde(default)]
    pub jira: Option<JiraConfig>,

    /// Linear integration settings.
    #[serde(default)]
    pub linear: Option<LinearConfig>,

    /// Project management integrations. Canonical location for ADO,
    /// and future PM tools.
    #[serde(default)]
    pub pm: Option<PmConfig>,

    /// Schema version string (e.g. `"1.0"`).
    ///
    /// Stored for forward compatibility with the Python predecessor's YAML
    /// format. Not enforced by the Rust loader — present so files written
    /// for the Python tool deserialize cleanly.
    #[serde(default)]
    pub version: Option<String>,

    /// Named profile (e.g. `"balanced"`).
    ///
    /// Stored for forward compatibility with the Python predecessor. Not
    /// currently consumed by the Rust pipeline.
    #[serde(default)]
    pub profile: Option<String>,

    /// Python-compatible flat alias map: canonical name → list of email
    /// addresses or login aliases.
    ///
    /// When non-empty, takes precedence over [`TeamConfig::members`] for
    /// identity resolution (see [`Config::resolved_aliases`]).
    #[serde(default)]
    pub developer_aliases: HashMap<String, Vec<String>>,

    /// Path to an external aliases file (YAML). If set, entries are merged
    /// with any inline [`Config::developer_aliases`]. The external file takes
    /// precedence for entries with the same canonical name.
    ///
    /// Supports `~` home-directory expansion. Relative paths are resolved
    /// against the directory of the loaded config file when known (passed
    /// to [`Config::resolved_alias_map`]) and otherwise against the current
    /// working directory.
    #[serde(default)]
    pub aliases_file: Option<String>,

    /// Analysis settings (ML categorization, etc.).
    ///
    /// Parsed for forward compatibility; individual sub-features gate their
    /// own behavior on its presence.
    #[serde(default)]
    pub analysis: Option<AnalysisConfig>,

    /// Cache directory and related settings.
    #[serde(default)]
    pub cache: Option<CacheConfig>,

    /// Filesystem path to the loaded config file, if any.
    ///
    /// Populated by [`Config::load`] and used to resolve relative paths
    /// (notably [`Config::aliases_file`]). Not serialized to YAML.
    #[serde(skip)]
    pub source_path: Option<PathBuf>,
}

/// Analysis pipeline configuration (forward-compat with Python schema).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AnalysisConfig {
    /// ML-based commit categorization settings.
    #[serde(default)]
    pub ml_categorization: Option<MlCategorizationConfig>,
}

/// ML categorization toggle and model selection.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MlCategorizationConfig {
    /// Whether ML categorization is enabled.
    #[serde(default)]
    pub enabled: bool,

    /// Optional model identifier.
    #[serde(default)]
    pub model: Option<String>,
}

/// Cache layer configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CacheConfig {
    /// Filesystem directory used for cached artifacts. Supports `~` expansion.
    #[serde(default)]
    pub directory: Option<PathBuf>,
}

/// A single repository to collect commits from.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RepositoryConfig {
    /// Local filesystem path to the repository (supports `~` expansion).
    pub path: PathBuf,

    /// Display name used in reports. Falls back to the directory basename.
    #[serde(default)]
    pub name: Option<String>,

    /// Branch override; if `None`, the default branch is auto-detected.
    #[serde(default)]
    pub branch: Option<String>,

    /// Inclusive start date for commit collection (ISO 8601).
    #[serde(default)]
    pub since_date: Option<String>,

    /// Inclusive end date for commit collection (ISO 8601).
    #[serde(default)]
    pub until_date: Option<String>,
}

/// Team roster and identity aliases.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TeamConfig {
    /// Canonical team members.
    #[serde(default)]
    pub members: Vec<TeamMember>,

    /// Free-form aliases map: alias → canonical name.
    #[serde(default)]
    pub aliases: HashMap<String, String>,
}

/// A canonical team member with optional alias list.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TeamMember {
    /// Canonical display name.
    pub name: String,

    /// Primary email address (canonical).
    pub email: String,

    /// Alternative names/emails that map to this member.
    #[serde(default)]
    pub aliases: Vec<String>,
}

/// Output / reporting configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OutputConfig {
    /// Single output format identifier (`csv`, `json`, `markdown`).
    ///
    /// Retained for backward compatibility; prefer [`OutputConfig::formats`].
    #[serde(default)]
    pub format: Option<String>,

    /// Destination directory for reports.
    ///
    /// Accepts both `directory` (Python-compat) and `output_path` (legacy
    /// Rust) keys in the YAML.
    #[serde(default, alias = "output_path")]
    pub directory: Option<PathBuf>,

    /// Output format list (e.g. `["csv", "markdown"]`).
    #[serde(default)]
    pub formats: Vec<String>,

    /// Include unclassified commits in output.
    #[serde(default)]
    pub include_unclassified: bool,

    /// Include merge commits in output.
    #[serde(default)]
    pub include_merges: bool,

    /// Include file-level details in output.
    #[serde(default)]
    pub include_files: bool,
}

/// Classification cascade configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassificationConfig {
    /// Path to user-supplied rules YAML/JSON.
    #[serde(default)]
    pub rules_file: Option<PathBuf>,

    /// Whether to engage the LLM fallback tier.
    #[serde(default)]
    pub use_llm: bool,

    /// LLM model identifier (provider-specific).
    #[serde(default)]
    pub llm_model: Option<String>,

    /// LLM provider: `"openrouter"`, `"openai"`, or `"auto"` (default `"auto"`).
    ///
    /// `"auto"` prefers OpenRouter when `OPENROUTER_API_KEY` is set, then
    /// falls back to OpenAI when `OPENAI_API_KEY` is set.
    #[serde(default = "default_llm_provider")]
    pub llm_provider: String,

    /// Optional OpenRouter API key. If unset the environment variable
    /// `OPENROUTER_API_KEY` is consulted.
    #[serde(default)]
    pub openrouter_api_key: Option<String>,

    /// Minimum confidence required to accept a classification.
    #[serde(default = "default_confidence_threshold")]
    pub confidence_threshold: f64,

    /// User-defined subcategories. Each entry must declare a `parent`
    /// top-level category. These extend the built-in subcategory registry;
    /// entries whose `name` matches an existing built-in replace it.
    ///
    /// Example YAML:
    /// ```yaml
    /// classification:
    ///   custom_categories:
    ///     - name: "payments"
    ///       parent: "integrations"
    ///       display_name: "Payments Integration"
    ///     - name: "auth"
    ///       parent: "feature"
    /// ```
    #[serde(default)]
    pub custom_categories: Vec<SubcategoryDef>,

    /// Minimum acceptable classification coverage percentage (0–100).
    ///
    /// After a classification run, the pipeline computes the share of
    /// commits that received a non-null, non-`"uncategorized"` verdict and
    /// emits a `tracing::warn!` if the result falls below this threshold.
    #[serde(default = "default_min_coverage_pct")]
    pub min_coverage_pct: f64,
}

fn default_confidence_threshold() -> f64 {
    0.7
}

fn default_min_coverage_pct() -> f64 {
    20.0
}

fn default_llm_provider() -> String {
    "auto".to_string()
}

impl Default for ClassificationConfig {
    fn default() -> Self {
        Self {
            rules_file: None,
            use_llm: false,
            llm_model: None,
            llm_provider: default_llm_provider(),
            openrouter_api_key: None,
            confidence_threshold: default_confidence_threshold(),
            custom_categories: Vec::new(),
            min_coverage_pct: default_min_coverage_pct(),
        }
    }
}

fn default_true() -> bool {
    true
}

/// Linear project management integration settings.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LinearConfig {
    /// Linear API key (personal or workspace).
    ///
    /// Supports `${LINEAR_API_KEY}` env-var substitution.
    #[serde(default)]
    pub api_key: Option<String>,

    /// Only fetch issues from these team keys (e.g. `["ENG", "FE"]`).
    /// Empty = all teams.
    #[serde(default)]
    pub team_keys: Vec<String>,

    /// Fetch issue details when a commit message references a Linear issue ID.
    #[serde(default = "default_true")]
    pub fetch_on_reference: bool,
}

/// Project management integrations config block.
///
/// Located at `pm:` in YAML (clean namespace, avoids jira/jira_integration
/// dual-stack). Each member is independently optional; presence of the `pm`
/// block does not require any specific integration to be configured.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PmConfig {
    /// Azure DevOps integration (Phase 1: config + stub client).
    #[serde(default)]
    pub azure_devops: Option<AzureDevOpsConfig>,
}

/// GitHub API integration settings.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GithubConfig {
    /// Personal access token (often sourced from `GITHUB_TOKEN`).
    #[serde(default)]
    pub token: Option<String>,

    /// Organization slug for org-wide queries.
    #[serde(default)]
    pub org: Option<String>,

    /// Single-repository slug (`owner/name`).
    #[serde(default)]
    pub repo: Option<String>,

    /// Whether to fetch pull request metadata.
    #[serde(default)]
    pub fetch_prs: bool,
}

/// JIRA Cloud / Server integration settings.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct JiraConfig {
    /// Base URL of the JIRA instance.
    #[serde(default)]
    pub url: Option<String>,

    /// API username (typically an email address for Cloud).
    #[serde(default)]
    pub username: Option<String>,

    /// API token.
    #[serde(default)]
    pub token: Option<String>,

    /// Project key for filtering issues (e.g. `API`).
    #[serde(default)]
    pub project_key: Option<String>,

    /// Maps JIRA project keys to canonical work types (subcategory names).
    ///
    /// Used by the Tier 3 [`crate::classify::tiers::jira_project_tier::JiraProjectTier`]
    /// classifier. Example YAML:
    /// ```yaml
    /// jira:
    ///   jira_project_mappings:
    ///     INFRA: platform
    ///     DATA: feature
    /// ```
    #[serde(default)]
    pub jira_project_mappings: HashMap<String, String>,
}

/// Expand a leading `~` in a path to the current user's home directory.
///
/// Returns the path unchanged if it does not start with `~`. If `~` is
/// present but the home directory cannot be determined, the path is also
/// returned unchanged.
pub fn expand_path(path: &Path) -> PathBuf {
    let s = match path.to_str() {
        Some(s) => s,
        None => return path.to_path_buf(),
    };
    if let Some(rest) = s.strip_prefix("~/") {
        if let Some(home) = std::env::var_os("HOME") {
            return PathBuf::from(home).join(rest);
        }
    } else if s == "~" {
        if let Some(home) = std::env::var_os("HOME") {
            return PathBuf::from(home);
        }
    }
    path.to_path_buf()
}

impl Config {
    /// Load a YAML configuration from disk.
    ///
    /// # Errors
    ///
    /// - [`TgaError::IoError`] if the file cannot be read.
    /// - [`TgaError::SerdeYamlError`] if YAML parsing fails.
    pub fn load(path: &Path) -> Result<Config> {
        let resolved = expand_path(path);
        tracing::debug!(path = %resolved.display(), "loading config");
        let text = std::fs::read_to_string(&resolved)?;
        let mut cfg: Config = serde_yaml::from_str(&text)?;
        cfg.source_path = Some(resolved);
        Ok(cfg)
    }

    /// Directory containing the loaded config file, if known.
    ///
    /// Returns the parent of [`Config::source_path`]; used to resolve
    /// relative paths declared inside the config (e.g.
    /// [`Config::aliases_file`]).
    pub fn config_dir(&self) -> Option<&Path> {
        self.source_path.as_deref().and_then(|p| p.parent())
    }

    /// Resolve identity aliases from either the Python-compatible
    /// [`Config::developer_aliases`] map or from [`TeamConfig::members`].
    ///
    /// `developer_aliases` (when non-empty) takes precedence. The returned
    /// map is keyed by canonical name; values are the list of email
    /// addresses or login aliases that should resolve to that name.
    pub fn resolved_aliases(&self) -> HashMap<String, Vec<String>> {
        // Fall back to whatever we can resolve without surfacing errors;
        // callers that need to fail loudly on a bad `aliases_file` should
        // use [`Config::resolved_alias_map`] directly.
        match self.resolved_alias_map(self.config_dir()) {
            Ok(map) if !map.is_empty() => map,
            _ => {
                if let Some(team) = &self.team {
                    team.members
                        .iter()
                        .map(|m| (m.name.clone(), m.aliases.clone()))
                        .collect()
                } else {
                    HashMap::new()
                }
            }
        }
    }

    /// Resolve the full alias map by merging inline [`Config::developer_aliases`]
    /// with entries loaded from an external [`Config::aliases_file`] (if set).
    ///
    /// Merge semantics: external file entries **override** inline entries
    /// with the same canonical name. Entries in inline that are not in the
    /// external file are kept as-is.
    ///
    /// Path resolution for `aliases_file`:
    /// 1. Leading `~` is expanded to the user's home directory.
    /// 2. If still relative, resolved against `config_dir` if provided,
    ///    otherwise against the current working directory.
    ///
    /// # Errors
    ///
    /// Returns [`TgaError::ConfigError`] if [`Config::aliases_file`] is set
    /// but cannot be loaded or parsed.
    pub fn resolved_alias_map(
        &self,
        config_dir: Option<&Path>,
    ) -> Result<HashMap<String, Vec<String>>> {
        let mut merged = self.developer_aliases.clone();

        if let Some(rel) = &self.aliases_file {
            let expanded = expand_path(Path::new(rel));
            let resolved = if expanded.is_absolute() {
                expanded
            } else if let Some(dir) = config_dir {
                dir.join(expanded)
            } else {
                expanded
            };

            let external = AliasFile::load(&resolved).map_err(|e| {
                TgaError::ConfigError(format!(
                    "failed to load aliases_file {}: {e}",
                    resolved.display()
                ))
            })?;
            for (name, list) in external.to_alias_map() {
                // External overrides inline for matching canonical names.
                merged.insert(name, list);
            }
        }

        Ok(merged)
    }

    /// Convenience accessor for the Azure DevOps integration config, if any.
    ///
    /// Returns `Some(&AzureDevOpsConfig)` when `pm.azure_devops` is set in
    /// the YAML, otherwise `None`.
    pub fn azure_devops_config(&self) -> Option<&AzureDevOpsConfig> {
        self.pm.as_ref().and_then(|p| p.azure_devops.as_ref())
    }

    /// Validate cross-field invariants of the config.
    ///
    /// # Errors
    ///
    /// Returns [`TgaError::ValidationError`] if any invariant is violated,
    /// or [`TgaError::ConfigError`] propagated from per-integration
    /// validators (e.g. Azure DevOps URL checks).
    pub fn validate(&self) -> Result<()> {
        if self.repositories.is_empty() {
            return Err(TgaError::ValidationError(
                "at least one repository must be configured".into(),
            ));
        }
        for r in &self.repositories {
            if r.path.as_os_str().is_empty() {
                return Err(TgaError::ValidationError(
                    "repository.path must not be empty".into(),
                ));
            }
        }
        if let Some(adzo_config) = self.azure_devops_config() {
            adzo_config.validate()?;
        }
        Ok(())
    }
}
