//! Auto-merge policy engine with confidence scoring.
//!
//! Evaluates completed task diffs and decides whether to auto-merge
//! or route to manual review based on configurable thresholds.

use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::process::Command;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use super::config::AutoMergePolicy;

const OVERRIDES_FILE: &str = ".batty/auto_merge_overrides.json";

/// Load per-task auto-merge overrides from disk.
pub fn load_overrides(project_root: &Path) -> HashMap<u32, bool> {
    let path = project_root.join(OVERRIDES_FILE);
    let Ok(content) = std::fs::read_to_string(&path) else {
        return HashMap::new();
    };
    serde_json::from_str(&content).unwrap_or_default()
}

/// Save a per-task auto-merge override to disk.
pub fn save_override(project_root: &Path, task_id: u32, enabled: bool) -> Result<()> {
    let path = project_root.join(OVERRIDES_FILE);
    let mut overrides = load_overrides(project_root);
    overrides.insert(task_id, enabled);
    let content = serde_json::to_string_pretty(&overrides)
        .context("failed to serialize auto-merge overrides")?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).ok();
    }
    std::fs::write(&path, content).context("failed to write auto-merge overrides file")?;
    Ok(())
}

/// Summary of a git diff between two refs.
#[derive(Debug, Clone)]
pub struct DiffSummary {
    pub files_changed: usize,
    pub lines_added: usize,
    pub lines_removed: usize,
    pub generated_lines_added: usize,
    pub generated_lines_removed: usize,
    pub modules_touched: HashSet<String>,
    pub sensitive_files: Vec<String>,
    pub has_unsafe: bool,
    pub has_conflicts: bool,
    /// Number of renamed files (pure renames are lower risk).
    pub rename_count: usize,
    /// Whether the diff touches migration-like files (schema changes, etc.).
    pub has_migrations: bool,
    /// Whether the diff touches config files (YAML, TOML, JSON config).
    pub has_config_changes: bool,
}

impl DiffSummary {
    pub fn total_lines(&self) -> usize {
        self.lines_added + self.lines_removed
    }

    pub fn generated_data_lines(&self) -> usize {
        self.generated_lines_added + self.generated_lines_removed
    }

    pub fn review_lines(&self) -> usize {
        self.total_lines()
            .saturating_sub(self.generated_data_lines())
    }
}

/// Decision returned by the policy engine.
#[derive(Debug, Clone, PartialEq)]
pub enum AutoMergeDecision {
    AutoMerge {
        confidence: f64,
    },
    ManualReview {
        confidence: f64,
        reasons: Vec<String>,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AutoMergeDecisionKind {
    Accepted,
    ManualReview,
}

impl AutoMergeDecisionKind {
    pub fn action_type(self) -> &'static str {
        match self {
            Self::Accepted => "accepted",
            Self::ManualReview => "manual_review",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AutoMergeDecisionRecord {
    pub decision: AutoMergeDecisionKind,
    pub confidence: f64,
    pub reasons: Vec<String>,
    pub files_changed: usize,
    pub lines_changed: usize,
    pub modules_touched: usize,
    pub has_migrations: bool,
    pub has_config_changes: bool,
    pub has_unsafe: bool,
    pub has_conflicts: bool,
    pub rename_count: usize,
    pub tests_passed: bool,
    pub override_forced: Option<bool>,
    pub diff_available: bool,
}

impl AutoMergeDecisionRecord {
    fn from_summary(
        summary: Option<&DiffSummary>,
        confidence: f64,
        decision: AutoMergeDecisionKind,
        reasons: Vec<String>,
        tests_passed: bool,
        override_forced: Option<bool>,
    ) -> Self {
        Self {
            decision,
            confidence,
            reasons,
            files_changed: summary.map_or(0, |value| value.files_changed),
            lines_changed: summary.map_or(0, DiffSummary::total_lines),
            modules_touched: summary.map_or(0, |value| value.modules_touched.len()),
            has_migrations: summary.is_some_and(|value| value.has_migrations),
            has_config_changes: summary.is_some_and(|value| value.has_config_changes),
            has_unsafe: summary.is_some_and(|value| value.has_unsafe),
            has_conflicts: summary.is_some_and(|value| value.has_conflicts),
            rename_count: summary.map_or(0, |value| value.rename_count),
            tests_passed,
            override_forced,
            diff_available: summary.is_some(),
        }
    }
}

/// Analyze the diff between `base` and `branch` in the given repo.
pub fn analyze_diff(repo: &Path, base: &str, branch: &str) -> Result<DiffSummary> {
    // Get --stat for file count and per-file changes
    let stat_output = Command::new("git")
        .args(["diff", "--numstat", &format!("{}...{}", base, branch)])
        .current_dir(repo)
        .output()
        .context("failed to run git diff --numstat")?;

    let stat_str = String::from_utf8_lossy(&stat_output.stdout);

    let mut files_changed = 0usize;
    let mut lines_added = 0usize;
    let mut lines_removed = 0usize;
    let mut generated_lines_added = 0usize;
    let mut generated_lines_removed = 0usize;
    let mut modules_touched = HashSet::new();
    let mut changed_paths = Vec::new();

    for line in stat_str.lines() {
        let parts: Vec<&str> = line.split('\t').collect();
        if parts.len() < 3 {
            continue;
        }
        files_changed += 1;
        let added = parts[0].parse::<usize>().ok();
        let removed = parts[1].parse::<usize>().ok();
        if let Some(added) = added {
            lines_added += added;
        }
        if let Some(removed) = removed {
            lines_removed += removed;
        }
        let path = parts[2];
        changed_paths.push(path.to_string());
        if is_generated_data_file(path) {
            if let Some(added) = added {
                generated_lines_added += added;
            }
            if let Some(removed) = removed {
                generated_lines_removed += removed;
            }
        }

        // Extract top-level module (first component under src/)
        if let Some(rest) = path.strip_prefix("src/") {
            if let Some(module) = rest.split('/').next() {
                modules_touched.insert(module.to_string());
            }
        }
    }

    // Get full diff to check for unsafe blocks
    let diff_output = Command::new("git")
        .args(["diff", &format!("{}...{}", base, branch)])
        .current_dir(repo)
        .output()
        .context("failed to run git diff")?;

    let diff_str = String::from_utf8_lossy(&diff_output.stdout);
    let has_unsafe = diff_str.lines().any(|line| {
        line.starts_with('+') && (line.contains("unsafe {") || line.contains("unsafe fn"))
    });

    // Count renames (pure renames are lower risk than logic changes)
    let rename_output = Command::new("git")
        .args([
            "diff",
            "--diff-filter=R",
            "--name-only",
            &format!("{}...{}", base, branch),
        ])
        .current_dir(repo)
        .output()
        .context("failed to run git diff --diff-filter=R")?;
    let rename_count = String::from_utf8_lossy(&rename_output.stdout)
        .lines()
        .filter(|l| !l.is_empty())
        .count();

    // Detect migration and config file changes
    let has_migrations = changed_paths.iter().any(|p| is_migration_file(p));
    let has_config_changes = changed_paths.iter().any(|p| is_config_file(p));

    // Check if branch can merge cleanly into base
    let has_conflicts = check_has_conflicts(repo, base, branch);

    Ok(DiffSummary {
        files_changed,
        lines_added,
        lines_removed,
        generated_lines_added,
        generated_lines_removed,
        modules_touched,
        sensitive_files: changed_paths, // filtered by caller via policy
        has_unsafe,
        has_conflicts,
        rename_count,
        has_migrations,
        has_config_changes,
    })
}

/// Check whether merging `branch` into `base` would produce conflicts.
fn check_has_conflicts(repo: &Path, base: &str, branch: &str) -> bool {
    let merge_base = Command::new("git")
        .args(["merge-base", base, branch])
        .current_dir(repo)
        .output();
    let merge_base_sha = match merge_base {
        Ok(output) if output.status.success() => {
            String::from_utf8_lossy(&output.stdout).trim().to_string()
        }
        _ => return true, // Can't find merge base — treat as conflicting
    };

    let result = Command::new("git")
        .args(["merge-tree", &merge_base_sha, base, branch])
        .current_dir(repo)
        .output();
    match result {
        Ok(output) => {
            let stdout = String::from_utf8_lossy(&output.stdout);
            stdout.contains("<<<<<<") || stdout.contains("changed in both")
        }
        Err(_) => true, // merge-tree failed — assume conflicts
    }
}

/// Returns true if the path looks like a migration file.
fn is_migration_file(path: &str) -> bool {
    let lower = path.to_lowercase();
    lower.contains("migration")
        || lower.contains("migrate")
        || lower.contains("/db/")
        || lower.contains("schema")
        || lower.ends_with(".sql")
}

fn is_generated_data_file(path: &str) -> bool {
    let lower = path.to_lowercase();
    (lower.contains("generated/") || lower.contains("reference/") || lower.contains("fixtures/"))
        && !lower.starts_with("src/")
}

/// Returns true if the path looks like a config file (not source code).
///
/// Generated data files (under `generated/`, `reference/`, test fixtures,
/// or lockfiles) are excluded — they are outputs, not configuration.
fn is_config_file(path: &str) -> bool {
    let lower = path.to_lowercase();
    let has_config_ext = lower.ends_with(".yaml")
        || lower.ends_with(".yml")
        || lower.ends_with(".toml")
        || lower.ends_with(".json")
        || lower.ends_with(".env")
        || lower.ends_with(".env.example");
    if !has_config_ext {
        return false;
    }
    // Exclude generated/reference data, test fixtures, and lockfiles
    let is_generated = lower.contains("generated/")
        || lower.contains("reference/")
        || lower.contains("fixtures/")
        || lower.contains("tests/")
        || lower.ends_with(".lock")
        || lower.ends_with("lock.json");
    has_config_ext && !is_generated
}

/// Compute merge confidence score (0.0-1.0) from a diff summary and policy.
pub fn compute_merge_confidence(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
    let mut confidence = 1.0f64;

    // Subtract 0.1 per file over 3
    if summary.files_changed > 3 {
        confidence -= 0.1 * (summary.files_changed - 3) as f64;
    }

    // Subtract 0.2 per module touched over 1
    if summary.modules_touched.len() > 1 {
        confidence -= 0.2 * (summary.modules_touched.len() - 1) as f64;
    }

    // Subtract 0.3 if any sensitive path touched
    let touches_sensitive = summary
        .sensitive_files
        .iter()
        .any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
    if touches_sensitive {
        confidence -= 0.3;
    }

    // Subtract 0.1 per 50 lines over 100
    let total_lines = summary.total_lines();
    if total_lines > 100 {
        let excess = total_lines - 100;
        confidence -= 0.1 * (excess / 50) as f64;
    }

    // Subtract 0.4 if unsafe blocks or FFI
    if summary.has_unsafe {
        confidence -= 0.4;
    }

    // Subtract 0.5 if conflicts detected with main
    if summary.has_conflicts {
        confidence -= 0.5;
    }

    // Subtract 0.3 for migration/schema changes (high risk)
    if summary.has_migrations {
        confidence -= 0.3;
    }

    // Subtract 0.15 for config file changes
    if summary.has_config_changes {
        confidence -= 0.15;
    }

    // Boost confidence when most changes are renames (low-risk)
    if summary.rename_count > 0 && summary.files_changed > 0 {
        let rename_ratio = summary.rename_count as f64 / summary.files_changed as f64;
        confidence += 0.1 * rename_ratio;
    }

    // Floor at 0.0
    confidence.max(0.0)
}

pub fn score_auto_merge_candidate(summary: &DiffSummary, policy: &AutoMergePolicy) -> f64 {
    compute_merge_confidence(summary, policy)
}

pub fn evaluate_auto_merge_candidate(
    summary: &DiffSummary,
    policy: &AutoMergePolicy,
    tests_passed: bool,
) -> AutoMergeDecisionRecord {
    if !policy.enabled {
        return AutoMergeDecisionRecord::from_summary(
            Some(summary),
            score_auto_merge_candidate(summary, policy),
            AutoMergeDecisionKind::ManualReview,
            vec!["auto-merge disabled by policy".to_string()],
            tests_passed,
            None,
        );
    }

    let confidence = score_auto_merge_candidate(summary, policy);
    let mut reasons = Vec::new();

    if policy.require_tests_pass && !tests_passed {
        reasons.push("tests did not pass".to_string());
    }

    if summary.has_conflicts {
        reasons.push("conflicts with main".to_string());
    }

    if confidence < policy.confidence_threshold {
        reasons.push(format!(
            "confidence {:.2} below threshold {:.2}",
            confidence, policy.confidence_threshold
        ));
    }

    if summary.files_changed > policy.max_files_changed {
        reasons.push(format!(
            "{} files changed (max {})",
            summary.files_changed, policy.max_files_changed
        ));
    }

    let review_lines = summary.review_lines();
    if review_lines > policy.max_diff_lines {
        reasons.push(format!(
            "{} diff lines (max {})",
            review_lines, policy.max_diff_lines
        ));
    }

    if summary.modules_touched.len() > policy.max_modules_touched {
        reasons.push(format!(
            "{} modules touched (max {})",
            summary.modules_touched.len(),
            policy.max_modules_touched
        ));
    }

    let touches_sensitive = summary
        .sensitive_files
        .iter()
        .any(|f| policy.sensitive_paths.iter().any(|s| f.contains(s)));
    if touches_sensitive {
        reasons.push("touches sensitive paths".to_string());
    }

    if summary.has_unsafe {
        reasons.push("contains unsafe blocks".to_string());
    }

    if summary.has_migrations {
        reasons.push("contains migration/schema changes".to_string());
    }

    // Config changes are a soft signal — they already reduce confidence by
    // 0.15 in compute_merge_confidence.  If that drops below threshold the
    // confidence check above catches it.  Don't hard-block: the manager
    // review path is unreliable for codex agents, so routing there just
    // stalls the pipeline.

    if reasons.is_empty() {
        AutoMergeDecisionRecord::from_summary(
            Some(summary),
            confidence,
            AutoMergeDecisionKind::Accepted,
            vec![format!(
                "confidence {:.2} meets threshold {:.2}; diff stays within file/module/line policy limits",
                confidence, policy.confidence_threshold
            )],
            tests_passed,
            None,
        )
    } else {
        AutoMergeDecisionRecord::from_summary(
            Some(summary),
            confidence,
            AutoMergeDecisionKind::ManualReview,
            reasons,
            tests_passed,
            None,
        )
    }
}

pub fn forced_auto_merge_decision(
    summary: Option<&DiffSummary>,
    policy: &AutoMergePolicy,
    tests_passed: bool,
) -> AutoMergeDecisionRecord {
    AutoMergeDecisionRecord::from_summary(
        summary,
        summary.map_or(0.0, |value| score_auto_merge_candidate(value, policy)),
        AutoMergeDecisionKind::Accepted,
        vec!["auto-merge forced by per-task override".to_string()],
        tests_passed,
        Some(true),
    )
}

pub fn forced_manual_review_decision(
    summary: Option<&DiffSummary>,
    policy: &AutoMergePolicy,
    tests_passed: bool,
) -> AutoMergeDecisionRecord {
    AutoMergeDecisionRecord::from_summary(
        summary,
        summary.map_or(0.0, |value| score_auto_merge_candidate(value, policy)),
        AutoMergeDecisionKind::ManualReview,
        vec!["auto-merge disabled by per-task override".to_string()],
        tests_passed,
        Some(false),
    )
}

pub fn explain_auto_merge_decision(record: &AutoMergeDecisionRecord) -> String {
    let decision = match record.decision {
        AutoMergeDecisionKind::Accepted => "accepted for auto-merge",
        AutoMergeDecisionKind::ManualReview => "routed to manual review",
    };
    let override_text = match record.override_forced {
        Some(true) => " (forced by override)",
        Some(false) => " (disabled by override)",
        None => "",
    };
    let diff_shape = if record.diff_available {
        format!(
            "{} files, {} lines, {} modules",
            record.files_changed, record.lines_changed, record.modules_touched
        )
    } else {
        "diff summary unavailable".to_string()
    };
    format!(
        "{decision}{override_text}: confidence {:.2}; {diff_shape}; reasons: {}",
        record.confidence,
        record.reasons.join("; ")
    )
}

/// Decide whether to auto-merge or route to manual review.
///
/// `tests_passed` indicates whether the task's test suite passed. When
/// `policy.require_tests_pass` is true and tests haven't passed, the
/// decision is always manual review regardless of other criteria.
pub fn should_auto_merge(
    summary: &DiffSummary,
    policy: &AutoMergePolicy,
    tests_passed: bool,
) -> AutoMergeDecision {
    let record = evaluate_auto_merge_candidate(summary, policy, tests_passed);
    match record.decision {
        AutoMergeDecisionKind::Accepted => AutoMergeDecision::AutoMerge {
            confidence: record.confidence,
        },
        AutoMergeDecisionKind::ManualReview => AutoMergeDecision::ManualReview {
            confidence: record.confidence,
            reasons: record.reasons,
        },
    }
}

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

    fn default_policy() -> AutoMergePolicy {
        AutoMergePolicy::default()
    }

    fn enabled_policy() -> AutoMergePolicy {
        AutoMergePolicy {
            enabled: true,
            ..AutoMergePolicy::default()
        }
    }

    fn make_summary(
        files: usize,
        added: usize,
        removed: usize,
        modules: Vec<&str>,
        sensitive: Vec<&str>,
        has_unsafe: bool,
    ) -> DiffSummary {
        DiffSummary {
            files_changed: files,
            lines_added: added,
            lines_removed: removed,
            generated_lines_added: 0,
            generated_lines_removed: 0,
            modules_touched: modules.into_iter().map(String::from).collect(),
            sensitive_files: sensitive.into_iter().map(String::from).collect(),
            has_unsafe,
            has_conflicts: false,
            rename_count: 0,
            has_migrations: false,
            has_config_changes: false,
        }
    }

    #[test]
    fn small_clean_diff_auto_merges() {
        let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
        let policy = enabled_policy();
        let decision = should_auto_merge(&summary, &policy, true);
        match decision {
            AutoMergeDecision::AutoMerge { confidence } => {
                assert!(
                    confidence >= 0.8,
                    "confidence should be >= 0.8, got {}",
                    confidence
                );
            }
            other => panic!("expected AutoMerge, got {:?}", other),
        }
    }

    #[test]
    fn large_diff_routes_to_review() {
        // With relaxed thresholds (max_diff_lines=2000), need a truly large diff
        let summary = make_summary(3, 1500, 600, vec!["team"], vec![], false);
        let policy = enabled_policy();
        let decision = should_auto_merge(&summary, &policy, true);
        match decision {
            AutoMergeDecision::ManualReview { reasons, .. } => {
                assert!(
                    reasons.iter().any(|r| r.contains("diff lines")),
                    "should mention diff lines: {:?}",
                    reasons
                );
            }
            other => panic!("expected ManualReview, got {:?}", other),
        }
    }

    #[test]
    fn sensitive_file_routes_to_review() {
        let summary = make_summary(2, 20, 10, vec!["team"], vec!["Cargo.toml"], false);
        let policy = enabled_policy();
        let decision = should_auto_merge(&summary, &policy, true);
        match decision {
            AutoMergeDecision::ManualReview { reasons, .. } => {
                assert!(
                    reasons.iter().any(|r| r.contains("sensitive")),
                    "should mention sensitive paths: {:?}",
                    reasons
                );
            }
            other => panic!("expected ManualReview, got {:?}", other),
        }
    }

    #[test]
    fn multi_module_reduces_confidence() {
        let summary = make_summary(
            4,
            40,
            10,
            vec!["team", "cli", "tmux", "agent"],
            vec![],
            false,
        );
        let policy = enabled_policy();
        let confidence = compute_merge_confidence(&summary, &policy);
        // 1.0 - 0.1*(4-3) - 0.2*(4-1) = 1.0 - 0.1 - 0.6 = 0.3
        // Confidence is reduced but still above the relaxed threshold (0.0)
        assert!(
            confidence < 0.5,
            "multi-module diff should have reduced confidence: {}",
            confidence,
        );
    }

    #[test]
    fn confidence_floor_at_zero() {
        let summary = make_summary(
            20,
            2000,
            1000,
            vec!["team", "cli", "tmux", "agent", "config"],
            vec!["Cargo.toml", ".env"],
            true,
        );
        let policy = enabled_policy();
        let confidence = compute_merge_confidence(&summary, &policy);
        assert_eq!(confidence, 0.0, "confidence should be floored at 0.0");
    }

    #[test]
    fn disabled_policy_always_manual() {
        let summary = make_summary(1, 5, 2, vec!["team"], vec![], false);
        let mut policy = default_policy();
        policy.enabled = false;
        let decision = should_auto_merge(&summary, &policy, true);
        match decision {
            AutoMergeDecision::ManualReview { reasons, .. } => {
                assert!(
                    reasons.iter().any(|r| r.contains("disabled")),
                    "should mention disabled: {:?}",
                    reasons
                );
            }
            other => panic!("expected ManualReview, got {:?}", other),
        }
    }

    #[test]
    fn config_deserializes_with_defaults() {
        let yaml = "{}";
        let policy: AutoMergePolicy = serde_yaml::from_str(yaml).unwrap();
        assert!(policy.enabled);
        assert_eq!(policy.max_diff_lines, 2000);
        assert_eq!(policy.max_files_changed, 30);
        assert_eq!(policy.max_modules_touched, 10);
        assert_eq!(policy.confidence_threshold, 0.0);
        assert!(policy.require_tests_pass);
        assert!(policy.post_merge_verify);
        assert!(policy.sensitive_paths.contains(&"Cargo.toml".to_string()));
    }

    #[test]
    fn unsafe_blocks_reduce_confidence() {
        let summary = make_summary(2, 30, 20, vec!["team"], vec![], true);
        let policy = enabled_policy();
        let confidence = compute_merge_confidence(&summary, &policy);
        // 1.0 - 0.4 = 0.6
        assert!(
            (confidence - 0.6).abs() < 0.001,
            "confidence should be 0.6, got {}",
            confidence
        );
    }

    #[test]
    fn tests_not_passed_routes_to_review() {
        let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
        let policy = enabled_policy();
        let decision = should_auto_merge(&summary, &policy, false);
        match decision {
            AutoMergeDecision::ManualReview { reasons, .. } => {
                assert!(
                    reasons.iter().any(|r| r.contains("tests did not pass")),
                    "should mention tests: {:?}",
                    reasons
                );
            }
            other => panic!("expected ManualReview, got {:?}", other),
        }
    }

    #[test]
    fn tests_not_required_allows_merge_without_passing() {
        let summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
        let mut policy = enabled_policy();
        policy.require_tests_pass = false;
        let decision = should_auto_merge(&summary, &policy, false);
        match decision {
            AutoMergeDecision::AutoMerge { .. } => {}
            other => panic!(
                "expected AutoMerge when tests not required, got {:?}",
                other
            ),
        }
    }

    #[test]
    fn conflicts_reduce_confidence_and_route_to_review() {
        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
        summary.has_conflicts = true;
        let policy = enabled_policy();
        let confidence = compute_merge_confidence(&summary, &policy);
        // 1.0 - 0.5 = 0.5
        assert!(
            (confidence - 0.5).abs() < 0.001,
            "confidence should be 0.5, got {}",
            confidence
        );
        let decision = should_auto_merge(&summary, &policy, true);
        match decision {
            AutoMergeDecision::ManualReview { reasons, .. } => {
                assert!(
                    reasons.iter().any(|r| r.contains("conflicts")),
                    "should mention conflicts: {:?}",
                    reasons
                );
            }
            other => panic!("expected ManualReview, got {:?}", other),
        }
    }

    #[test]
    fn migrations_reduce_confidence() {
        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
        summary.has_migrations = true;
        let policy = enabled_policy();
        let confidence = compute_merge_confidence(&summary, &policy);
        // 1.0 - 0.3 = 0.7
        assert!(
            (confidence - 0.7).abs() < 0.001,
            "confidence should be 0.7, got {}",
            confidence
        );
    }

    #[test]
    fn migrations_route_to_review() {
        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
        summary.has_migrations = true;
        let policy = enabled_policy();
        let decision = should_auto_merge(&summary, &policy, true);
        match decision {
            AutoMergeDecision::ManualReview { reasons, .. } => {
                assert!(
                    reasons.iter().any(|r| r.contains("migration")),
                    "should mention migration: {:?}",
                    reasons
                );
            }
            other => panic!("expected ManualReview, got {:?}", other),
        }
    }

    #[test]
    fn config_changes_reduce_confidence() {
        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
        summary.has_config_changes = true;
        let policy = enabled_policy();
        let confidence = compute_merge_confidence(&summary, &policy);
        // 1.0 - 0.15 = 0.85
        assert!(
            (confidence - 0.85).abs() < 0.001,
            "confidence should be 0.85, got {}",
            confidence
        );
    }

    #[test]
    fn config_changes_auto_merge_when_confidence_above_threshold() {
        // Config changes reduce confidence by 0.15 (1.0 → 0.85) but should
        // still auto-merge since 0.85 > 0.80 threshold.  Config is a soft
        // signal, not a hard blocker.
        let mut summary = make_summary(2, 30, 20, vec!["team"], vec![], false);
        summary.has_config_changes = true;
        let policy = enabled_policy();
        let decision = should_auto_merge(&summary, &policy, true);
        match decision {
            AutoMergeDecision::AutoMerge { .. } => {} // expected
            other => panic!("config-only change should auto-merge, got {:?}", other),
        }
    }

    #[test]
    fn renames_boost_confidence() {
        let mut summary = make_summary(4, 10, 10, vec!["team"], vec![], false);
        // 4 files changed, 3 of them are renames
        summary.rename_count = 3;
        let policy = enabled_policy();
        let confidence_with_renames = compute_merge_confidence(&summary, &policy);

        let summary_no_renames = make_summary(4, 10, 10, vec!["team"], vec![], false);
        let confidence_without = compute_merge_confidence(&summary_no_renames, &policy);

        assert!(
            confidence_with_renames > confidence_without,
            "renames should boost confidence: with={}, without={}",
            confidence_with_renames,
            confidence_without
        );
    }

    #[test]
    fn all_renames_gives_full_boost() {
        let mut summary = make_summary(4, 0, 0, vec!["team"], vec![], false);
        summary.rename_count = 4;
        let policy = enabled_policy();
        let confidence = compute_merge_confidence(&summary, &policy);
        // 1.0 - 0.1*(4-3) + 0.1*(4/4) = 1.0 - 0.1 + 0.1 = 1.0
        assert!(
            (confidence - 1.0).abs() < 0.001,
            "all-rename diff should have full confidence: {}",
            confidence
        );
    }

    #[test]
    fn heterogeneous_but_bounded_diff_still_auto_merges() {
        let summary = make_summary(3, 45, 15, vec!["team", "metrics"], vec![], false);
        let policy = enabled_policy();
        let record = evaluate_auto_merge_candidate(&summary, &policy, true);
        assert_eq!(record.decision, AutoMergeDecisionKind::Accepted);
        assert!(
            record.reasons[0].contains("meets threshold"),
            "should contain acceptance reason: {:?}",
            record.reasons
        );
    }

    #[test]
    fn forced_override_decision_is_explicit() {
        let summary = make_summary(5, 80, 20, vec!["team", "metrics", "daemon"], vec![], false);
        let policy = enabled_policy();
        let record = forced_auto_merge_decision(Some(&summary), &policy, true);
        assert_eq!(record.decision, AutoMergeDecisionKind::Accepted);
        assert_eq!(record.override_forced, Some(true));
        assert_eq!(
            explain_auto_merge_decision(&record),
            "accepted for auto-merge (forced by override): confidence 0.40; 5 files, 100 lines, 3 modules; reasons: auto-merge forced by per-task override"
        );
    }

    #[test]
    fn migration_file_detection() {
        assert!(is_migration_file("db/migrate/001_add_users.sql"));
        assert!(is_migration_file("src/migrations/v2.rs"));
        assert!(is_migration_file("schema.sql"));
        assert!(!is_migration_file("src/team/mod.rs"));
    }

    #[test]
    fn config_file_detection() {
        assert!(is_config_file("team.yaml"));
        assert!(is_config_file("Cargo.toml"));
        assert!(is_config_file("package.json"));
        assert!(is_config_file(".env"));
        assert!(!is_config_file("src/team/config.rs"));
    }

    #[test]
    fn generated_data_diff_does_not_trip_line_count_gate() {
        let mut summary = make_summary(1, 39035, 0, vec![], vec!["generated/catalog.json"], false);
        summary.generated_lines_added = 39035;
        let policy = enabled_policy();
        let decision = should_auto_merge(&summary, &policy, true);
        match decision {
            AutoMergeDecision::AutoMerge { .. } => {}
            other => panic!(
                "generated data extraction should auto-merge, got {:?}",
                other
            ),
        }
    }

    #[test]
    fn source_diff_still_trips_line_count_gate() {
        let summary = make_summary(1, 2500, 0, vec!["team"], vec!["src/team/catalog.rs"], false);
        let policy = enabled_policy();
        let decision = should_auto_merge(&summary, &policy, true);
        match decision {
            AutoMergeDecision::ManualReview { reasons, .. } => {
                assert!(
                    reasons.iter().any(|r| r.contains("2500 diff lines")),
                    "should mention gated source diff lines: {:?}",
                    reasons
                );
            }
            other => panic!(
                "large source diff should route to manual review, got {:?}",
                other
            ),
        }
    }

    #[test]
    fn combined_risk_factors_accumulate() {
        let mut summary = make_summary(
            6,
            200,
            100,
            vec!["team", "cli", "tmux"],
            vec!["Cargo.toml"],
            true,
        );
        summary.has_migrations = true;
        summary.has_config_changes = true;
        summary.has_conflicts = true;
        let policy = enabled_policy();
        let confidence = compute_merge_confidence(&summary, &policy);
        assert_eq!(confidence, 0.0, "extreme risk diff should floor at 0.0");
    }

    #[test]
    fn override_persistence_roundtrip() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        std::fs::create_dir_all(root.join(".batty")).unwrap();

        // No overrides file — returns empty
        assert!(load_overrides(root).is_empty());

        // Save override
        save_override(root, 42, true).unwrap();
        let overrides = load_overrides(root);
        assert_eq!(overrides.get(&42), Some(&true));

        // Save another override, first one persists
        save_override(root, 99, false).unwrap();
        let overrides = load_overrides(root);
        assert_eq!(overrides.get(&42), Some(&true));
        assert_eq!(overrides.get(&99), Some(&false));

        // Overwrite existing
        save_override(root, 42, false).unwrap();
        let overrides = load_overrides(root);
        assert_eq!(overrides.get(&42), Some(&false));
    }

    // --- Error path and recovery tests (Task #265) ---

    #[test]
    fn load_overrides_malformed_json_returns_empty() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let path = root.join(OVERRIDES_FILE);
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(&path, "not valid json {{{}").unwrap();
        assert!(load_overrides(root).is_empty());
    }

    #[test]
    fn load_overrides_wrong_json_type_returns_empty() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let path = root.join(OVERRIDES_FILE);
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        // Valid JSON but wrong type (array instead of object)
        std::fs::write(&path, "[1, 2, 3]").unwrap();
        assert!(load_overrides(root).is_empty());
    }

    #[test]
    fn save_override_creates_batty_dir() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        // .batty dir doesn't exist yet
        save_override(root, 1, true).unwrap();
        assert!(root.join(OVERRIDES_FILE).exists());
    }

    #[test]
    fn save_override_to_readonly_dir_returns_error() {
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let tmp = tempfile::tempdir().unwrap();
            let root = tmp.path();
            let batty_dir = root.join(".batty");
            std::fs::create_dir(&batty_dir).unwrap();
            std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o444)).unwrap();

            let result = save_override(root, 1, true);
            assert!(result.is_err());

            // Restore for cleanup
            std::fs::set_permissions(&batty_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
        }
    }

    #[test]
    fn analyze_diff_on_non_git_dir_returns_empty_summary() {
        let tmp = tempfile::tempdir().unwrap();
        // analyze_diff doesn't fail — git commands produce empty output on non-git dirs
        // This verifies graceful degradation rather than hard failure
        let result = analyze_diff(tmp.path(), "main", "feature");
        if let Ok(summary) = result {
            assert_eq!(summary.files_changed, 0);
            assert_eq!(summary.total_lines(), 0);
        }
        // If it errors, that's also acceptable graceful behavior
    }
}
