use serde::Deserialize;
use std::fs;
use std::path::Path;

use super::error::{LoreError, Result};
use super::paths::get_config_path;

#[derive(Debug, Clone, Deserialize)]
pub struct GitLabConfig {
    #[serde(rename = "baseUrl")]
    pub base_url: String,

    #[serde(rename = "tokenEnvVar", default = "default_token_env_var")]
    pub token_env_var: String,

    /// Optional stored token (env var takes priority when both are set).
    #[serde(default)]
    pub token: Option<String>,

    /// Optional GitLab username for `lore me` personal dashboard.
    #[serde(default)]
    pub username: Option<String>,
}

impl GitLabConfig {
    /// Resolve token with priority: env var > config file.
    pub fn resolve_token(&self) -> Result<String> {
        if let Ok(val) = std::env::var(&self.token_env_var)
            && !val.trim().is_empty()
        {
            return Ok(val.trim().to_string());
        }
        if let Some(ref t) = self.token
            && !t.trim().is_empty()
        {
            return Ok(t.trim().to_string());
        }
        Err(LoreError::TokenNotSet {
            env_var: self.token_env_var.clone(),
        })
    }

    /// Returns a human-readable label for where the token was found, or `None`.
    pub fn token_source(&self) -> Option<&'static str> {
        if let Ok(val) = std::env::var(&self.token_env_var)
            && !val.trim().is_empty()
        {
            return Some("environment variable");
        }
        if let Some(ref t) = self.token
            && !t.trim().is_empty()
        {
            return Some("config file");
        }
        None
    }
}

fn default_token_env_var() -> String {
    "GITLAB_TOKEN".to_string()
}

#[derive(Debug, Clone, Deserialize)]
pub struct ProjectConfig {
    pub path: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct SyncConfig {
    #[serde(rename = "backfillDays")]
    pub backfill_days: u32,

    #[serde(rename = "staleLockMinutes")]
    pub stale_lock_minutes: u32,

    #[serde(rename = "heartbeatIntervalSeconds")]
    pub heartbeat_interval_seconds: u32,

    #[serde(rename = "cursorRewindSeconds")]
    pub cursor_rewind_seconds: u32,

    #[serde(rename = "primaryConcurrency")]
    pub primary_concurrency: u32,

    #[serde(rename = "dependentConcurrency")]
    pub dependent_concurrency: u32,

    #[serde(rename = "requestsPerSecond")]
    pub requests_per_second: f64,

    #[serde(rename = "fetchResourceEvents", default = "default_true")]
    pub fetch_resource_events: bool,

    #[serde(rename = "fetchMrFileChanges", default = "default_true")]
    pub fetch_mr_file_changes: bool,

    #[serde(rename = "fetchWorkItemStatus", default = "default_true")]
    pub fetch_work_item_status: bool,
}

fn default_true() -> bool {
    true
}

impl Default for SyncConfig {
    fn default() -> Self {
        Self {
            backfill_days: 14,
            stale_lock_minutes: 10,
            heartbeat_interval_seconds: 30,
            cursor_rewind_seconds: 2,
            primary_concurrency: 4,
            dependent_concurrency: 8,
            requests_per_second: 30.0,
            fetch_resource_events: true,
            fetch_mr_file_changes: true,
            fetch_work_item_status: true,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct StorageConfig {
    #[serde(rename = "dbPath")]
    pub db_path: Option<String>,

    #[serde(rename = "backupDir")]
    pub backup_dir: Option<String>,

    #[serde(
        rename = "compressRawPayloads",
        default = "default_compress_raw_payloads"
    )]
    pub compress_raw_payloads: bool,
}

fn default_compress_raw_payloads() -> bool {
    true
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct EmbeddingConfig {
    pub provider: String,
    pub model: String,
    #[serde(rename = "baseUrl")]
    pub base_url: String,
    pub concurrency: u32,
}

impl Default for EmbeddingConfig {
    fn default() -> Self {
        Self {
            provider: "ollama".to_string(),
            model: "nomic-embed-text".to_string(),
            base_url: "http://localhost:11434".to_string(),
            concurrency: 4,
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LoggingConfig {
    #[serde(rename = "logDir")]
    pub log_dir: Option<String>,

    #[serde(rename = "retentionDays", default = "default_retention_days")]
    pub retention_days: u32,

    #[serde(rename = "fileLogging", default = "default_file_logging")]
    pub file_logging: bool,
}

fn default_retention_days() -> u32 {
    7
}

fn default_file_logging() -> bool {
    true
}

impl Default for LoggingConfig {
    fn default() -> Self {
        Self {
            log_dir: None,
            retention_days: default_retention_days(),
            file_logging: default_file_logging(),
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ScoringConfig {
    /// Points per MR where the user authored code touching the path.
    #[serde(rename = "authorWeight")]
    pub author_weight: i64,

    /// Points per MR where the user reviewed code touching the path.
    #[serde(rename = "reviewerWeight")]
    pub reviewer_weight: i64,

    /// Bonus points per individual inline review comment (DiffNote).
    #[serde(rename = "noteBonus")]
    pub note_bonus: i64,

    /// Points per MR where the user was assigned as a reviewer.
    #[serde(rename = "reviewerAssignmentWeight")]
    pub reviewer_assignment_weight: i64,

    /// Half-life in days for author contribution decay.
    #[serde(rename = "authorHalfLifeDays")]
    pub author_half_life_days: u32,

    /// Half-life in days for reviewer contribution decay.
    #[serde(rename = "reviewerHalfLifeDays")]
    pub reviewer_half_life_days: u32,

    /// Half-life in days for reviewer assignment decay.
    #[serde(rename = "reviewerAssignmentHalfLifeDays")]
    pub reviewer_assignment_half_life_days: u32,

    /// Half-life in days for note/comment contribution decay.
    #[serde(rename = "noteHalfLifeDays")]
    pub note_half_life_days: u32,

    /// Multiplier applied to scores from closed (not merged) MRs.
    #[serde(rename = "closedMrMultiplier")]
    pub closed_mr_multiplier: f64,

    /// Minimum character count for a review note to earn note_bonus.
    #[serde(rename = "reviewerMinNoteChars")]
    pub reviewer_min_note_chars: u32,

    /// Usernames excluded from expert/scoring results.
    #[serde(rename = "excludedUsernames")]
    pub excluded_usernames: Vec<String>,
}

impl Default for ScoringConfig {
    fn default() -> Self {
        Self {
            author_weight: 25,
            reviewer_weight: 10,
            note_bonus: 1,
            reviewer_assignment_weight: 3,
            author_half_life_days: 180,
            reviewer_half_life_days: 90,
            reviewer_assignment_half_life_days: 45,
            note_half_life_days: 45,
            closed_mr_multiplier: 0.5,
            reviewer_min_note_chars: 20,
            excluded_usernames: vec![],
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct Config {
    pub gitlab: GitLabConfig,
    pub projects: Vec<ProjectConfig>,

    #[serde(rename = "defaultProject")]
    pub default_project: Option<String>,

    #[serde(default)]
    pub sync: SyncConfig,

    #[serde(default)]
    pub storage: StorageConfig,

    #[serde(default)]
    pub embedding: EmbeddingConfig,

    #[serde(default)]
    pub logging: LoggingConfig,

    #[serde(default)]
    pub scoring: ScoringConfig,
}

impl Config {
    pub fn load(cli_override: Option<&str>) -> Result<Self> {
        let config_path = get_config_path(cli_override);

        if !config_path.exists() {
            return Err(LoreError::ConfigNotFound {
                path: config_path.display().to_string(),
            });
        }

        Self::load_from_path(&config_path)
    }

    pub fn load_from_path(path: &Path) -> Result<Self> {
        let content = fs::read_to_string(path).map_err(|e| LoreError::ConfigInvalid {
            details: format!("Failed to read config file: {e}"),
        })?;

        let config: Config =
            serde_json::from_str(&content).map_err(|e| LoreError::ConfigInvalid {
                details: format!("Invalid JSON: {e}"),
            })?;

        if config.projects.is_empty() {
            return Err(LoreError::ConfigInvalid {
                details: "At least one project is required".to_string(),
            });
        }

        for project in &config.projects {
            if project.path.is_empty() {
                return Err(LoreError::ConfigInvalid {
                    details: "Project path cannot be empty".to_string(),
                });
            }
        }

        if url::Url::parse(&config.gitlab.base_url).is_err() {
            return Err(LoreError::ConfigInvalid {
                details: format!("Invalid GitLab URL: {}", config.gitlab.base_url),
            });
        }

        if let Some(ref dp) = config.default_project {
            let matched = config.projects.iter().any(|p| {
                p.path.eq_ignore_ascii_case(dp)
                    || p.path
                        .to_ascii_lowercase()
                        .ends_with(&format!("/{}", dp.to_ascii_lowercase()))
            });
            if !matched {
                return Err(LoreError::ConfigInvalid {
                    details: format!(
                        "defaultProject '{}' does not match any configured project path",
                        dp
                    ),
                });
            }
        }

        validate_scoring(&config.scoring)?;

        Ok(config)
    }

    /// Return the effective project filter: CLI flag wins, then config default.
    pub fn effective_project<'a>(&'a self, cli_project: Option<&'a str>) -> Option<&'a str> {
        cli_project.or(self.default_project.as_deref())
    }
}

fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
    if scoring.author_weight < 0 {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.authorWeight must be >= 0".to_string(),
        });
    }
    if scoring.reviewer_weight < 0 {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.reviewerWeight must be >= 0".to_string(),
        });
    }
    if scoring.note_bonus < 0 {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.noteBonus must be >= 0".to_string(),
        });
    }
    if scoring.reviewer_assignment_weight < 0 {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.reviewerAssignmentWeight must be >= 0".to_string(),
        });
    }
    if scoring.author_half_life_days == 0 || scoring.author_half_life_days > 3650 {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.authorHalfLifeDays must be in 1..=3650".to_string(),
        });
    }
    if scoring.reviewer_half_life_days == 0 || scoring.reviewer_half_life_days > 3650 {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.reviewerHalfLifeDays must be in 1..=3650".to_string(),
        });
    }
    if scoring.reviewer_assignment_half_life_days == 0
        || scoring.reviewer_assignment_half_life_days > 3650
    {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.reviewerAssignmentHalfLifeDays must be in 1..=3650".to_string(),
        });
    }
    if scoring.note_half_life_days == 0 || scoring.note_half_life_days > 3650 {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.noteHalfLifeDays must be in 1..=3650".to_string(),
        });
    }
    if !scoring.closed_mr_multiplier.is_finite()
        || scoring.closed_mr_multiplier <= 0.0
        || scoring.closed_mr_multiplier > 1.0
    {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.closedMrMultiplier must be finite and in (0.0, 1.0]".to_string(),
        });
    }
    if scoring.reviewer_min_note_chars > 4096 {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.reviewerMinNoteChars must be <= 4096".to_string(),
        });
    }
    if scoring
        .excluded_usernames
        .iter()
        .any(|u| u.trim().is_empty())
    {
        return Err(LoreError::ConfigInvalid {
            details: "scoring.excludedUsernames entries must be non-empty".to_string(),
        });
    }
    Ok(())
}

#[derive(Debug, serde::Serialize)]
pub struct MinimalConfig {
    pub gitlab: MinimalGitLabConfig,
    pub projects: Vec<ProjectConfig>,
    #[serde(rename = "defaultProject", skip_serializing_if = "Option::is_none")]
    pub default_project: Option<String>,
}

#[derive(Debug, serde::Serialize)]
pub struct MinimalGitLabConfig {
    #[serde(rename = "baseUrl")]
    pub base_url: String,
    #[serde(rename = "tokenEnvVar")]
    pub token_env_var: String,
}

impl serde::Serialize for ProjectConfig {
    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        use serde::ser::SerializeStruct;
        let mut state = serializer.serialize_struct("ProjectConfig", 1)?;
        state.serialize_field("path", &self.path)?;
        state.end()
    }
}

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

    fn write_config(dir: &TempDir, scoring_json: &str) -> std::path::PathBuf {
        let path = dir.path().join("config.json");
        let config = format!(
            r#"{{
  "gitlab": {{
    "baseUrl": "https://gitlab.example.com",
    "tokenEnvVar": "GITLAB_TOKEN"
  }},
  "projects": [
    {{ "path": "group/project" }}
  ],
  "scoring": {scoring_json}
}}"#
        );
        fs::write(&path, config).unwrap();
        path
    }

    fn write_config_with_default_project(
        dir: &TempDir,
        default_project: Option<&str>,
    ) -> std::path::PathBuf {
        let path = dir.path().join("config.json");
        let dp_field = match default_project {
            Some(dp) => format!(r#","defaultProject": "{dp}""#),
            None => String::new(),
        };
        let config = format!(
            r#"{{
  "gitlab": {{
    "baseUrl": "https://gitlab.example.com",
    "tokenEnvVar": "GITLAB_TOKEN"
  }},
  "projects": [
    {{ "path": "group/project" }},
    {{ "path": "other/repo" }}
  ]{dp_field}
}}"#
        );
        fs::write(&path, config).unwrap();
        path
    }

    #[test]
    fn test_load_rejects_negative_author_weight() {
        let dir = TempDir::new().unwrap();
        let path = write_config(
            &dir,
            r#"{
  "authorWeight": -1,
  "reviewerWeight": 10,
  "noteBonus": 1
}"#,
        );
        let err = Config::load_from_path(&path).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("scoring.authorWeight"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn test_load_rejects_negative_reviewer_weight() {
        let dir = TempDir::new().unwrap();
        let path = write_config(
            &dir,
            r#"{
  "authorWeight": 25,
  "reviewerWeight": -1,
  "noteBonus": 1
}"#,
        );
        let err = Config::load_from_path(&path).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("scoring.reviewerWeight"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn test_config_fetch_work_item_status_default_true() {
        let config = SyncConfig::default();
        assert!(config.fetch_work_item_status);
    }

    #[test]
    fn test_config_deserialize_without_key() {
        let json = r#"{}"#;
        let config: SyncConfig = serde_json::from_str(json).unwrap();
        assert!(
            config.fetch_work_item_status,
            "Missing key should default to true"
        );
    }

    #[test]
    fn test_load_rejects_negative_note_bonus() {
        let dir = TempDir::new().unwrap();
        let path = write_config(
            &dir,
            r#"{
  "authorWeight": 25,
  "reviewerWeight": 10,
  "noteBonus": -1
}"#,
        );
        let err = Config::load_from_path(&path).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("scoring.noteBonus"), "unexpected error: {msg}");
    }

    #[test]
    fn test_effective_project_cli_overrides_default() {
        let config = Config {
            gitlab: GitLabConfig {
                base_url: "https://gitlab.example.com".to_string(),
                token_env_var: "GITLAB_TOKEN".to_string(),
                token: None,
                username: None,
            },
            projects: vec![ProjectConfig {
                path: "group/project".to_string(),
            }],
            default_project: Some("group/project".to_string()),
            sync: SyncConfig::default(),
            storage: StorageConfig::default(),
            embedding: EmbeddingConfig::default(),
            logging: LoggingConfig::default(),
            scoring: ScoringConfig::default(),
        };
        assert_eq!(
            config.effective_project(Some("other/repo")),
            Some("other/repo")
        );
    }

    #[test]
    fn test_effective_project_falls_back_to_default() {
        let config = Config {
            gitlab: GitLabConfig {
                base_url: "https://gitlab.example.com".to_string(),
                token_env_var: "GITLAB_TOKEN".to_string(),
                token: None,
                username: None,
            },
            projects: vec![ProjectConfig {
                path: "group/project".to_string(),
            }],
            default_project: Some("group/project".to_string()),
            sync: SyncConfig::default(),
            storage: StorageConfig::default(),
            embedding: EmbeddingConfig::default(),
            logging: LoggingConfig::default(),
            scoring: ScoringConfig::default(),
        };
        assert_eq!(config.effective_project(None), Some("group/project"));
    }

    #[test]
    fn test_effective_project_none_when_both_absent() {
        let config = Config {
            gitlab: GitLabConfig {
                base_url: "https://gitlab.example.com".to_string(),
                token_env_var: "GITLAB_TOKEN".to_string(),
                token: None,
                username: None,
            },
            projects: vec![ProjectConfig {
                path: "group/project".to_string(),
            }],
            default_project: None,
            sync: SyncConfig::default(),
            storage: StorageConfig::default(),
            embedding: EmbeddingConfig::default(),
            logging: LoggingConfig::default(),
            scoring: ScoringConfig::default(),
        };
        assert_eq!(config.effective_project(None), None);
    }

    #[test]
    fn test_load_with_valid_default_project() {
        let dir = TempDir::new().unwrap();
        let path = write_config_with_default_project(&dir, Some("group/project"));
        let config = Config::load_from_path(&path).unwrap();
        assert_eq!(config.default_project.as_deref(), Some("group/project"));
    }

    #[test]
    fn test_load_rejects_invalid_default_project() {
        let dir = TempDir::new().unwrap();
        let path = write_config_with_default_project(&dir, Some("nonexistent/project"));
        let err = Config::load_from_path(&path).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("defaultProject"), "unexpected error: {msg}");
    }

    #[test]
    fn test_load_default_project_suffix_match() {
        let dir = TempDir::new().unwrap();
        let path = write_config_with_default_project(&dir, Some("project"));
        let config = Config::load_from_path(&path).unwrap();
        assert_eq!(config.default_project.as_deref(), Some("project"));
    }

    #[test]
    fn test_minimal_config_omits_null_default_project() {
        let config = MinimalConfig {
            gitlab: MinimalGitLabConfig {
                base_url: "https://gitlab.example.com".to_string(),
                token_env_var: "GITLAB_TOKEN".to_string(),
            },
            projects: vec![ProjectConfig {
                path: "group/project".to_string(),
            }],
            default_project: None,
        };
        let json = serde_json::to_string(&config).unwrap();
        assert!(
            !json.contains("defaultProject"),
            "null default_project should be omitted: {json}"
        );
    }

    #[test]
    fn test_minimal_config_includes_default_project_when_set() {
        let config = MinimalConfig {
            gitlab: MinimalGitLabConfig {
                base_url: "https://gitlab.example.com".to_string(),
                token_env_var: "GITLAB_TOKEN".to_string(),
            },
            projects: vec![ProjectConfig {
                path: "group/project".to_string(),
            }],
            default_project: Some("group/project".to_string()),
        };
        let json = serde_json::to_string(&config).unwrap();
        assert!(
            json.contains("defaultProject"),
            "set default_project should be present: {json}"
        );
    }

    #[test]
    fn test_config_validation_rejects_zero_half_life() {
        let scoring = ScoringConfig {
            author_half_life_days: 0,
            ..Default::default()
        };
        let err = validate_scoring(&scoring).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("authorHalfLifeDays"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn test_config_validation_rejects_absurd_half_life() {
        let scoring = ScoringConfig {
            author_half_life_days: 5000,
            ..Default::default()
        };
        let err = validate_scoring(&scoring).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("authorHalfLifeDays"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn test_config_validation_rejects_nan_multiplier() {
        let scoring = ScoringConfig {
            closed_mr_multiplier: f64::NAN,
            ..Default::default()
        };
        let err = validate_scoring(&scoring).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("closedMrMultiplier"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn test_config_validation_rejects_zero_multiplier() {
        let scoring = ScoringConfig {
            closed_mr_multiplier: 0.0,
            ..Default::default()
        };
        let err = validate_scoring(&scoring).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("closedMrMultiplier"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn test_config_validation_rejects_negative_reviewer_assignment_weight() {
        let scoring = ScoringConfig {
            reviewer_assignment_weight: -1,
            ..Default::default()
        };
        let err = validate_scoring(&scoring).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("reviewerAssignmentWeight"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn test_config_validation_rejects_oversized_min_note_chars() {
        let scoring = ScoringConfig {
            reviewer_min_note_chars: 5000,
            ..Default::default()
        };
        let err = validate_scoring(&scoring).unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("reviewerMinNoteChars"),
            "unexpected error: {msg}"
        );
    }

    #[test]
    fn test_config_validation_rejects_empty_excluded_username() {
        let scoring = ScoringConfig {
            excluded_usernames: vec!["valid".to_string(), "  ".to_string()],
            ..Default::default()
        };
        let err = validate_scoring(&scoring).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("excludedUsernames"), "unexpected error: {msg}");
    }

    #[test]
    fn test_config_validation_accepts_valid_new_fields() {
        let scoring = ScoringConfig {
            author_half_life_days: 365,
            reviewer_half_life_days: 180,
            reviewer_assignment_half_life_days: 90,
            note_half_life_days: 60,
            closed_mr_multiplier: 0.5,
            reviewer_min_note_chars: 20,
            reviewer_assignment_weight: 3,
            excluded_usernames: vec!["bot-user".to_string()],
            ..Default::default()
        };
        validate_scoring(&scoring).unwrap();
    }

    #[test]
    fn test_config_validation_accepts_boundary_half_life() {
        // 1 and 3650 are both valid boundaries
        let scoring_min = ScoringConfig {
            author_half_life_days: 1,
            ..Default::default()
        };
        validate_scoring(&scoring_min).unwrap();

        let scoring_max = ScoringConfig {
            author_half_life_days: 3650,
            ..Default::default()
        };
        validate_scoring(&scoring_max).unwrap();
    }

    #[test]
    fn test_config_validation_accepts_multiplier_at_one() {
        let scoring = ScoringConfig {
            closed_mr_multiplier: 1.0,
            ..Default::default()
        };
        validate_scoring(&scoring).unwrap();
    }

    // ── token_source / resolve_token ────────────────────────────────

    /// Build a `GitLabConfig` that reads from the given unique env var name
    /// so parallel tests never collide.
    fn gitlab_cfg_with_env(env_var: &str, token: Option<&str>) -> GitLabConfig {
        GitLabConfig {
            base_url: "https://gitlab.example.com".to_string(),
            token_env_var: env_var.to_string(),
            token: token.map(ToString::to_string),
            username: None,
        }
    }

    #[test]
    fn test_token_source_env_wins_over_config() {
        const VAR: &str = "LORE_TEST_TS_ENV_WINS";
        // SAFETY: unique var name, no other code reads it.
        unsafe { std::env::set_var(VAR, "env-tok") };
        let cfg = gitlab_cfg_with_env(VAR, Some("config-tok"));
        assert_eq!(cfg.token_source(), Some("environment variable"));
        unsafe { std::env::remove_var(VAR) };
    }

    #[test]
    fn test_token_source_falls_back_to_config() {
        const VAR: &str = "LORE_TEST_TS_FALLBACK";
        unsafe { std::env::remove_var(VAR) };
        let cfg = gitlab_cfg_with_env(VAR, Some("config-tok"));
        assert_eq!(cfg.token_source(), Some("config file"));
    }

    #[test]
    fn test_token_source_none_when_both_absent() {
        const VAR: &str = "LORE_TEST_TS_NONE";
        unsafe { std::env::remove_var(VAR) };
        let cfg = gitlab_cfg_with_env(VAR, None);
        assert_eq!(cfg.token_source(), None);
    }

    #[test]
    fn test_token_source_ignores_whitespace_only_env() {
        const VAR: &str = "LORE_TEST_TS_WS_ENV";
        unsafe { std::env::set_var(VAR, "   ") };
        let cfg = gitlab_cfg_with_env(VAR, Some("real"));
        assert_eq!(cfg.token_source(), Some("config file"));
        unsafe { std::env::remove_var(VAR) };
    }

    #[test]
    fn test_token_source_ignores_whitespace_only_config() {
        const VAR: &str = "LORE_TEST_TS_WS_CFG";
        unsafe { std::env::remove_var(VAR) };
        let cfg = gitlab_cfg_with_env(VAR, Some("  \t  "));
        assert_eq!(cfg.token_source(), None);
    }

    #[test]
    fn test_resolve_token_env_wins_over_config() {
        const VAR: &str = "LORE_TEST_RT_ENV_WINS";
        unsafe { std::env::set_var(VAR, "  env-tok  ") };
        let cfg = gitlab_cfg_with_env(VAR, Some("config-tok"));
        assert_eq!(cfg.resolve_token().unwrap(), "env-tok");
        unsafe { std::env::remove_var(VAR) };
    }

    #[test]
    fn test_resolve_token_config_fallback() {
        const VAR: &str = "LORE_TEST_RT_FALLBACK";
        unsafe { std::env::remove_var(VAR) };
        let cfg = gitlab_cfg_with_env(VAR, Some("  config-tok  "));
        assert_eq!(cfg.resolve_token().unwrap(), "config-tok");
    }

    #[test]
    fn test_resolve_token_err_when_both_absent() {
        const VAR: &str = "LORE_TEST_RT_NONE";
        unsafe { std::env::remove_var(VAR) };
        let cfg = gitlab_cfg_with_env(VAR, None);
        assert!(cfg.resolve_token().is_err());
    }

    // ── gitlab.username ─────────────────────────────────────────────

    #[test]
    fn test_config_loads_with_username() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("config.json");
        let config = r#"{
  "gitlab": {
    "baseUrl": "https://gitlab.example.com",
    "tokenEnvVar": "GITLAB_TOKEN",
    "username": "jdoe"
  },
  "projects": [{ "path": "group/project" }]
}"#;
        fs::write(&path, config).unwrap();
        let cfg = Config::load_from_path(&path).unwrap();
        assert_eq!(cfg.gitlab.username.as_deref(), Some("jdoe"));
    }

    #[test]
    fn test_config_loads_without_username() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("config.json");
        let config = r#"{
  "gitlab": {
    "baseUrl": "https://gitlab.example.com",
    "tokenEnvVar": "GITLAB_TOKEN"
  },
  "projects": [{ "path": "group/project" }]
}"#;
        fs::write(&path, config).unwrap();
        let cfg = Config::load_from_path(&path).unwrap();
        assert_eq!(cfg.gitlab.username, None);
    }
}
