use std::path::PathBuf;
use std::sync::LazyLock;

use git2::Repository;
use regex::Regex;

use crate::detection::finding::{Finding, FindingCategory, Severity};
use crate::domain::errors::PapertowelError;

pub const DETECTOR_NAME: &str = "commit_pattern";

#[expect(
    clippy::expect_used,
    reason = "LazyLock init — regex literal is a compile-time invariant"
)]
static CONVENTIONAL_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(
        r"^(?:feat|fix|chore|refactor|test|docs|style|ci|build|perf|revert)(?:\([^)]+\))?!?:\s",
    )
    .expect("CONVENTIONAL_RE is a valid regex")
});

/// Terms that appear in normal human "recovery" commits.
const RECOVERY_TERMS: &[&str] = &[
    "wip", "oops", "fixup", "fix:", "fix!: ", "squash", "revert", "undo", "wrong", "mistake",
    "amend", "whoops", "typo", "missed", "forgot",
];

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CommitPatternConfig {
    pub min_commit_count: usize,
    /// CV of inter-commit gaps (seconds) below this value is flagged as
    /// machine-clean cadence.
    pub max_cadence_cv: f64,
    pub min_conventional_fraction: f64,
}

impl Default for CommitPatternConfig {
    fn default() -> Self {
        Self {
            min_commit_count: 6,
            max_cadence_cv: 0.35,
            min_conventional_fraction: 0.87,
        }
    }
}

/// Metrics computed from a series of commit samples.
#[derive(Debug, Clone, PartialEq)]
pub struct CommitPatternMetrics {
    pub commit_count: usize,
    /// Coefficient of variation of inter-commit gaps in seconds.
    pub cadence_cv: f64,
    pub conventional_fraction: f64,
    /// Number of commits carrying a human "recovery" marker.
    pub recovery_commit_count: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommitSample {
    pub timestamp: i64,
    pub message: String,
}

// ─── Public API ──────────────────────────────────────────────────────────────

pub fn detect_repo(repo_root: impl Into<PathBuf>) -> Result<Vec<Finding>, PapertowelError> {
    detect_repo_with_config(repo_root, CommitPatternConfig::default())
}

pub fn detect_repo_with_config(
    repo_root: impl Into<PathBuf>,
    config: CommitPatternConfig,
) -> Result<Vec<Finding>, PapertowelError> {
    let repo_root = repo_root.into();
    let repo = Repository::open(&repo_root).map_err(PapertowelError::Git)?;
    let commits = collect_commits(&repo)?;

    let metrics = analyze_commits(&commits);

    if metrics.commit_count < config.min_commit_count {
        return Ok(Vec::new());
    }

    let cadence_uniform = metrics.cadence_cv < config.max_cadence_cv;
    let messages_uniform = metrics.conventional_fraction >= config.min_conventional_fraction;
    let no_recovery = metrics.recovery_commit_count == 0 && metrics.commit_count >= 8;

    let signal_count =
        usize::from(cadence_uniform) + usize::from(messages_uniform) + usize::from(no_recovery);

    if signal_count < 2 {
        return Ok(Vec::new());
    }

    let severity = if signal_count == 3 {
        Severity::High
    } else {
        Severity::Medium
    };

    let confidence = (1.0_f64 - metrics.cadence_cv.min(1.0)).mul_add(
        0.5,
        metrics
            .conventional_fraction
            .mul_add(0.3, if no_recovery { 0.2 } else { 0.0 }),
    );

    let evidence = format!(
        "commits: {}, cadence CV: {:.2}, conventional: {:.0}%, recovery commits: {}",
        metrics.commit_count,
        metrics.cadence_cv,
        metrics.conventional_fraction * 100.0,
        metrics.recovery_commit_count,
    );

    #[expect(
        clippy::cast_possible_truncation,
        reason = "confidence is clamped to 0.0–1.0; truncation is intentional and bounded"
    )]
    let mut finding = Finding::new(
        "commit_pattern.machine_clean",
        FindingCategory::CommitPattern,
        severity,
        (confidence as f32).clamp(0.0, 1.0),
        repo_root,
        evidence,
    )?;
    finding.suggestion = Some(
        "Mix in occasional fix-up commits, varied message styles, and natural timing gaps."
            .to_owned(),
    );

    Ok(vec![finding])
}

// ─── Public analysis functions ───────────────────────────────────────────────

pub fn analyze_commits(commits: &[CommitSample]) -> CommitPatternMetrics {
    let commit_count = commits.len();

    if commit_count < 2 {
        return CommitPatternMetrics {
            commit_count,
            cadence_cv: 0.0,
            conventional_fraction: 0.0,
            recovery_commit_count: 0,
        };
    }

    // Compute inter-commit gaps (sorted ascending by timestamp first).
    let mut sorted: Vec<i64> = commits.iter().map(|c| c.timestamp).collect();
    sorted.sort_unstable();

    let gaps: Vec<f64> = sorted
        .windows(2)
        .filter_map(|w| match w {
            [a, b] => {
                let diff = b.saturating_sub(*a);
                if diff <= 0 {
                    return None;
                }
                #[expect(
                    clippy::cast_precision_loss,
                    reason = "timestamp diff is bounded; precision is sufficient"
                )]
                let diff_f = diff as f64;
                Some(diff_f)
            }
            _ => None,
        })
        .collect();

    let cadence_cv = coefficient_of_variation(&gaps);

    #[expect(
        clippy::cast_precision_loss,
        reason = "bounded commit count: no meaningful precision loss"
    )]
    let conventional_fraction = commits
        .iter()
        .filter(|c| CONVENTIONAL_RE.is_match(&c.message))
        .count() as f64
        / commit_count as f64;

    let recovery_commit_count = commits
        .iter()
        .filter(|c| {
            let lower = c.message.to_lowercase();
            RECOVERY_TERMS.iter().any(|t| lower.contains(t))
        })
        .count();

    CommitPatternMetrics {
        commit_count,
        cadence_cv,
        conventional_fraction,
        recovery_commit_count,
    }
}

pub fn has_conventional_prefix(message: &str) -> bool {
    CONVENTIONAL_RE.is_match(message)
}

// ─── Internal helpers ────────────────────────────────────────────────────────

fn collect_commits(repo: &Repository) -> Result<Vec<CommitSample>, PapertowelError> {
    let head = repo.head().map_err(PapertowelError::Git)?;
    let head_oid = head
        .target()
        .ok_or_else(|| PapertowelError::Config("HEAD is not a direct reference".to_owned()))?;

    let mut walk = repo.revwalk().map_err(PapertowelError::Git)?;
    walk.push(head_oid).map_err(PapertowelError::Git)?;

    let mut samples = Vec::new();
    for oid_result in walk {
        let oid = oid_result.map_err(PapertowelError::Git)?;
        let commit = repo.find_commit(oid).map_err(PapertowelError::Git)?;

        let message = commit.summary().unwrap_or("").to_owned();
        let timestamp = commit.time().seconds();

        samples.push(CommitSample { timestamp, message });
    }

    Ok(samples)
}

fn coefficient_of_variation(values: &[f64]) -> f64 {
    if values.len() < 2 {
        return 0.0;
    }
    #[expect(
        clippy::cast_precision_loss,
        reason = "bounded count; mantissa is sufficient"
    )]
    let n = values.len() as f64;
    let mean = values.iter().sum::<f64>() / n;
    if mean == 0.0 {
        return 0.0;
    }
    let variance = values.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n;
    variance.sqrt() / mean
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    #![expect(clippy::expect_used, reason = "test fixtures")]
    #![expect(clippy::float_cmp, reason = "test numeric assertions")]
    #![expect(
        clippy::cast_possible_wrap,
        reason = "test fixture indices bounded by small values"
    )]

    use super::{CommitPatternConfig, CommitSample, analyze_commits, has_conventional_prefix};
    use crate::scrubber::commit_pattern::detect_repo_with_config;

    fn uniform_samples(count: usize, gap_secs: i64) -> Vec<CommitSample> {
        (0..count)
            .map(|i| {
                let ts = 1_700_000_000 + i as i64 * gap_secs;
                CommitSample {
                    timestamp: ts,
                    message: format!("feat(module{i}): implement feature {i}"),
                }
            })
            .collect()
    }

    fn messy_samples() -> Vec<CommitSample> {
        vec![
            CommitSample {
                timestamp: 1_700_000_000,
                message: "feat: initial setup".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_003_600,
                message: "wip: half done".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_007_200,
                message: "fix: oops missed import".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_014_400,
                message: "refactor: clean up".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_025_000,
                message: "revert previous change".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_040_000,
                message: "chore: misc".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_090_000,
                message: "feat: finish module".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_200_000,
                message: "docs: update readme".to_owned(),
            },
        ]
    }

    #[test]
    fn uniform_cadence_and_messages_detected() {
        let commits = uniform_samples(10, 3_600);
        let metrics = analyze_commits(&commits);
        assert_eq!(metrics.commit_count, 10);
        assert!(
            metrics.cadence_cv < 0.1,
            "identical gaps → CV near zero, got {}",
            metrics.cadence_cv
        );
        assert!(
            metrics.conventional_fraction > 0.9,
            "all conventional messages"
        );
        assert_eq!(metrics.recovery_commit_count, 0);
    }

    #[test]
    fn messy_commits_not_detected() {
        let commits = messy_samples();
        let metrics = analyze_commits(&commits);
        assert!(
            metrics.recovery_commit_count > 0,
            "messy commits should have recovery markers"
        );
        assert!(
            metrics.cadence_cv > 0.3,
            "irregular gaps → high CV: {}",
            metrics.cadence_cv
        );
    }

    #[test]
    fn below_min_commit_count_skipped() {
        let commits = uniform_samples(3, 3_600);
        let config = CommitPatternConfig::default();
        // Not a repo path test — just verify metrics function returns low count
        let metrics = analyze_commits(&commits);
        assert!(metrics.commit_count < config.min_commit_count);
    }

    #[test]
    fn empty_commits_returns_zero_metrics() {
        let metrics = analyze_commits(&[]);
        assert_eq!(metrics.commit_count, 0);
        assert!(
            metrics.cadence_cv.abs() < f64::EPSILON,
            "expected cadence_cv == 0.0"
        );
    }

    #[test]
    fn conventional_prefix_detection() {
        assert!(has_conventional_prefix("feat: add thing"));
        assert!(has_conventional_prefix("fix(auth): handle error"));
        assert!(has_conventional_prefix("refactor!: breaking change"));
        assert!(!has_conventional_prefix("WIP some hack"));
        assert!(!has_conventional_prefix("oops forgot file"));
    }

    #[test]
    fn detect_repo_on_invalid_path_returns_error() {
        let result = detect_repo_with_config("/nonexistent/path", CommitPatternConfig::default());
        assert!(result.is_err(), "invalid repo path should error");
    }

    #[test]
    fn two_commit_sample_returns_non_zero_cadence() {
        let commits = vec![
            CommitSample {
                timestamp: 1_700_000_000,
                message: "feat: initial".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_003_600,
                message: "feat: second".to_owned(),
            },
        ];
        let metrics = analyze_commits(&commits);
        assert_eq!(metrics.commit_count, 2);
        assert!(metrics.cadence_cv.abs() < f64::EPSILON);
    }

    #[test]
    fn recovery_terms_are_counted() {
        let commits = vec![
            CommitSample {
                timestamp: 1_700_000_000,
                message: "feat: add thing".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_001_000,
                message: "oops missed file".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_002_000,
                message: "wip: not done".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_003_000,
                message: "fixup! squash me".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_004_000,
                message: "feat: clean up".to_owned(),
            },
        ];
        let metrics = analyze_commits(&commits);
        assert!(
            metrics.recovery_commit_count >= 3,
            "oops, wip, fixup should all match"
        );
    }

    #[test]
    fn signal_below_threshold_produces_no_finding() {
        // Low cv, but conventional fraction is low and recovery count is high
        // → only one signal (cadence) → should produce no finding
        let mut commits: Vec<CommitSample> = (0..10)
            .map(|i| CommitSample {
                timestamp: 1_700_000_000 + i64::from(i) * 3_600,
                message: "wip: something random messy commit".to_owned(),
            })
            .collect();
        if let Some(c) = commits.first_mut() {
            c.message = "oops fix typo".to_owned();
        }
        let metrics = analyze_commits(&commits);
        // → only 1 signal → no finding
        let result = detect_repo_with_config("/nonexistent/path", CommitPatternConfig::default());
        // We can't easily call detect_repo_with_config on a real path without
        // a temp repo; just verify the metrics are as expected.
        assert!(metrics.cadence_cv < 0.01, "uniform cadence gives cv≈0");
        assert!(
            metrics.conventional_fraction < 0.1,
            "no conventional messages"
        );
        assert!(result.is_err()); // path check still holds
    }

    #[test]
    fn three_signals_produces_high_severity_finding_via_detect_repo() {
        use git2::{Repository, Signature};
        use tempfile::TempDir;

        let tmp = TempDir::new().expect("tempdir");
        let repo = Repository::init(tmp.path()).expect("init");
        let _sig = Signature::now("Test User", "test@example.com").expect("sig");

        // Create initial empty tree
        let mut index = repo.index().expect("index");
        let tree_oid = index.write_tree().expect("tree");
        let tree = repo.find_tree(tree_oid).expect("find tree");

        let mut parent_commit: Option<git2::Oid> = None;
        for i in 0..10_u32 {
            let ts = git2::Time::new(1_700_000_000 + i64::from(i) * 3_600, 0);
            let tsig = Signature::new("Test User", "test@example.com", &ts).expect("sig");
            let msg = format!("feat(module{i}): implement feature {i}");
            let parents: Vec<git2::Commit<'_>> = parent_commit
                .map(|oid| vec![repo.find_commit(oid).expect("find commit")])
                .unwrap_or_default();
            let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
            let oid = repo
                .commit(Some("HEAD"), &tsig, &tsig, &msg, &tree, &parent_refs)
                .expect("commit");
            parent_commit = Some(oid);
        }

        // config with very loose thresholds so our synthetic repo triggers 3 signals
        let config = CommitPatternConfig {
            min_commit_count: 6,
            max_cadence_cv: 0.35,
            min_conventional_fraction: 0.87,
        };
        let findings = detect_repo_with_config(tmp.path(), config).expect("detect");
        assert!(
            !findings.is_empty(),
            "3-signal uniform history should produce a finding"
        );
    }

    #[test]
    fn conventional_prefix_all_types_accepted() {
        for prefix in &[
            "feat", "fix", "chore", "refactor", "test", "docs", "style", "ci", "build", "perf",
            "revert",
        ] {
            assert!(
                has_conventional_prefix(&format!("{prefix}: message")),
                "{prefix}: should be accepted"
            );
        }
    }

    #[test]
    fn conventional_prefix_with_scope_accepted() {
        assert!(has_conventional_prefix("feat(auth): add login"));
        assert!(has_conventional_prefix("fix(ui)!: breaking button change"));
    }

    #[test]
    fn below_min_commit_count_returns_empty() {
        // Having fewer commits than config requires → early return
        let config = CommitPatternConfig {
            min_commit_count: 20,
            ..CommitPatternConfig::default()
        };
        let commits: Vec<_> = (0..3)
            .map(|i| CommitSample {
                timestamp: 1_700_000_000 + i * 3_600,
                message: format!("feat(module{i}): add feature {i}"),
            })
            .collect();
        let metrics = analyze_commits(&commits);
        // Verify metric — then simulate config filter
        assert!(metrics.commit_count < config.min_commit_count);
    }

    #[test]
    fn three_signal_finding_is_high_severity() {
        use crate::detection::finding::Severity;

        // Since we can't use git2 easily in a unit test, call analyze_commits directly
        // and verify the metrics, then confirm via detect_repo_with_config on a real worktree.
        let commits = uniform_samples(10, 3_600);
        let metrics = analyze_commits(&commits);
        assert!(metrics.conventional_fraction >= 0.87, "all conventional");
        assert!(metrics.cadence_cv < 0.35, "uniform cadence");
        // No recovery commits
        assert_eq!(metrics.recovery_commit_count, 0);

        // If we had a real git repo, signal_count == 3 → High. Check that the
        // severity path is reachable through a stub test on analyze output.
        let signal_count = usize::from(metrics.cadence_cv < 0.35)
            + usize::from(metrics.conventional_fraction >= 0.87)
            + usize::from(metrics.recovery_commit_count == 0 && metrics.commit_count >= 8);
        assert_eq!(signal_count, 3, "all 3 signals should be present");
        // High severity is emitted when signal_count == 3
        let severity = if signal_count == 3 {
            Severity::High
        } else {
            Severity::Medium
        };
        assert_eq!(severity, Severity::High);
    }

    #[test]
    fn cadence_cv_handles_same_timestamp_commits() {
        // Commits with identical timestamps → diff == 0 → filtered out → empty diffs → CV = 0.0
        let commits: Vec<CommitSample> = vec![
            CommitSample {
                timestamp: 1_700_000_000,
                message: "feat: a".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_000_000,
                message: "feat: b".to_owned(),
            },
            CommitSample {
                timestamp: 1_700_000_000,
                message: "feat: c".to_owned(),
            },
        ];
        let metrics = analyze_commits(&commits);
        // With all-zero diffs filtered out, cadence_cv = 0.0
        assert_eq!(metrics.cadence_cv, 0.0);
    }

    #[test]
    fn cadence_cv_handles_single_commit() {
        // Single commit → no pairs → cadence_cv = 0.0
        let commits = vec![CommitSample {
            timestamp: 1_700_000_000,
            message: "feat: initial".to_owned(),
        }];
        let metrics = analyze_commits(&commits);
        assert_eq!(metrics.commit_count, 1);
        assert_eq!(metrics.cadence_cv, 0.0);
    }

    #[test]
    fn detect_repo_delegates_to_with_config() {
        // Covers lines 74-75: detect_repo wrapper.
        // Use a temp dir without.git → will error (no repo), which is fine.
        use super::detect_repo;
        use tempfile::TempDir;
        let tmp = TempDir::new().expect("tempdir");
        // Should error since it's not a git repo, but the line is still executed.
        let _ = detect_repo(tmp.path());
    }

    #[test]
    fn medium_severity_when_exactly_two_signals() {
        // Covers line 106 (Severity::Medium): signal_count == 2.
        // no_recovery = (recovery_commit_count == 0 && commit_count >= 8)
        use git2::{Repository, Signature, Time};
        use tempfile::TempDir;

        let tmp = TempDir::new().expect("tempdir");
        let repo = Repository::init(tmp.path()).expect("init");
        let sig = Signature::new("Test", "t@t.com", &Time::new(1_700_000_000, 0)).expect("sig");

        let tree_oid = {
            let mut idx = repo.index().expect("index");
            idx.write_tree().expect("write tree")
        };
        let tree = repo.find_tree(tree_oid).expect("find tree");

        // but recovery_commit_count >= 1 → no_recovery = false → signal_count = 2.
        let messages = [
            "feat: one",
            "feat: two",
            "feat: three",
            "feat: four",
            "feat: five",
            "feat: six",
            "feat: seven",
            "feat: eight",
            "fixup! squash me",
        ];
        let mut parent_oid = None;
        for (i, msg) in messages.iter().enumerate() {
            let ts = 1_700_000_000_i64 + i as i64 * 3600;
            let s = Signature::new("Test", "t@t.com", &Time::new(ts, 0)).expect("sig");
            let parents: Vec<_> = parent_oid
                .map(|oid| repo.find_commit(oid).expect("find"))
                .into_iter()
                .collect();
            let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
            parent_oid = Some(
                repo.commit(Some("HEAD"), &s, &s, msg, &tree, &parent_refs)
                    .expect("commit"),
            );
        }
        drop(sig);

        let config = CommitPatternConfig {
            min_commit_count: 9,
            max_cadence_cv: 0.5,
            min_conventional_fraction: 0.7,
        };
        let findings = detect_repo_with_config(tmp.path(), config).expect("detect");
        // 2 signals → Medium severity if found
        for f in &findings {
            assert_eq!(f.severity, crate::detection::finding::Severity::Medium);
        }
    }

    #[test]
    fn detect_repo_returns_empty_when_below_min_commit_count() {
        // Covers line 89: early-return when commit_count < min_commit_count.
        use git2::{Repository, Signature};
        use tempfile::TempDir;

        let tmp = TempDir::new().expect("tempdir");
        let repo = Repository::init(tmp.path()).expect("init");
        let mut index = repo.index().expect("index");
        let tree_oid = index.write_tree().expect("tree");
        let tree = repo.find_tree(tree_oid).expect("find tree");

        let ts = git2::Time::new(1_700_000_000, 0);
        let sig = Signature::new("Test", "t@t.com", &ts).expect("sig");
        repo.commit(Some("HEAD"), &sig, &sig, "feat: only commit", &tree, &[])
            .expect("commit");

        // Require far more commits than we have — should return empty.
        let config = CommitPatternConfig {
            min_commit_count: 100,
            ..CommitPatternConfig::default()
        };
        let findings = detect_repo_with_config(tmp.path(), config).expect("detect");
        assert!(
            findings.is_empty(),
            "below min_commit_count should return empty"
        );
    }

    #[test]
    fn detect_repo_returns_empty_when_signal_count_below_two() {
        // Covers line 100: early-return when signal_count < 2 despite enough commits.
        use git2::{Repository, Signature, Time};
        use tempfile::TempDir;

        let tmp = TempDir::new().expect("tempdir");
        let repo = Repository::init(tmp.path()).expect("init");
        let mut index = repo.index().expect("index");
        let tree_oid = index.write_tree().expect("tree");
        let tree = repo.find_tree(tree_oid).expect("find tree");

        // 10 commits with irregular gaps, messy messages, and recovery keywords — 0 signals.
        let messages = [
            "oops forgot file",
            "wip: not done",
            "random change",
            "fixup! squash me",
            "revert previous",
            "misc stuff",
            "tweak things",
            "more changes",
            "yet another commit",
            "done I think",
        ];
        let gaps = [1i64, 100, 5, 3600, 1, 72000, 4, 9000, 3, 1];
        let mut ts = 1_700_000_000_i64;
        let mut parent_oid = None;
        for (msg, gap) in messages.iter().zip(gaps.iter()) {
            ts += gap;
            let s = Signature::new("Test", "t@t.co", &Time::new(ts, 0)).expect("sig");
            let parents: Vec<_> = parent_oid
                .map(|oid| repo.find_commit(oid).expect("find"))
                .into_iter()
                .collect();
            let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
            parent_oid = Some(
                repo.commit(Some("HEAD"), &s, &s, msg, &tree, &parent_refs)
                    .expect("commit"),
            );
        }

        // High cadence CV threshold: true, but no_recovery = false (has recovery commits).
        // signal_count == 1 → empty result.
        let config = CommitPatternConfig {
            min_commit_count: 9,
            max_cadence_cv: 1000.0,         // cadence_uniform = true
            min_conventional_fraction: 1.0, // messages_uniform = false (0 conventional)
        };
        let findings = detect_repo_with_config(tmp.path(), config).expect("detect");
        assert!(findings.is_empty(), "only 1 signal should return empty");
    }
}
