//! Git context extraction
//!
//! Extracts git metadata for the current commit deterministically.
//!
//! Global invariants enforced:
//! - Commit hash is the sole identity
//! - Branch names are metadata only
//! - Missing parents produce baselines, not errors
//!
//! Uses git CLI directly (no libgit2) for portability.

use anyhow::{Context, Result};
use regex::Regex;
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;

static JIRA_RE: OnceLock<Regex> = OnceLock::new();
static GITHUB_RE: OnceLock<Regex> = OnceLock::new();

fn jira_re() -> &'static Regex {
    JIRA_RE.get_or_init(|| Regex::new(r"([A-Z]+-\d+)").unwrap())
}

fn github_re() -> &'static Regex {
    GITHUB_RE.get_or_init(|| Regex::new(r"(?:fixes|closes|fixed|closed)?\s*#(\d+)").unwrap())
}

/// Git context for the current commit
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitContext {
    pub head_sha: String,
    pub parent_shas: Vec<String>,
    pub timestamp: i64,
    pub branch: Option<String>,
    pub is_detached: bool,
    pub message: Option<String>,
    pub author: Option<String>,
    pub is_fix_commit: Option<bool>,
    pub is_revert_commit: Option<bool>,
    pub ticket_ids: Vec<String>,
}

/// File churn metrics (lines added/deleted)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileChurn {
    pub file: String,
    pub lines_added: usize,
    pub lines_deleted: usize,
}

/// Batched touch metrics for all files in a repository.
///
/// Computed by two git log calls instead of one per file, reducing subprocess
/// overhead from O(files) to O(1).
#[derive(Debug, Clone)]
pub struct BatchedTouchMetrics {
    /// Number of commits touching each file in the 30-day window, keyed by relative path.
    pub touch_count_30d: std::collections::HashMap<String, usize>,
    /// Days since last change for each file, keyed by relative path.
    pub days_since_last_change: std::collections::HashMap<String, u32>,
}

/// Execute a git command and return the trimmed stdout
fn git(args: &[&str]) -> Result<String> {
    let output = Command::new("git")
        .args(args)
        .output()
        .context("failed to invoke git")?;

    if !output.status.success() {
        anyhow::bail!(
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

/// Execute a git command in a specific directory and return the trimmed stdout
fn git_at(repo_path: &Path, args: &[&str]) -> Result<String> {
    let output = Command::new("git")
        .current_dir(repo_path)
        .args(args)
        .output()
        .context("failed to invoke git")?;

    if !output.status.success() {
        anyhow::bail!(
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

/// Extract git context for the current commit
///
/// Returns context including HEAD SHA, parent SHAs, timestamp, and branch name.
/// Handles detached HEAD (branch = None) and shallow clones gracefully.
///
/// # Errors
///
/// Returns error if:
/// - Not in a git repository
/// - Git commands fail for reasons other than shallow history
///
/// # Shallow Clone Handling
///
/// If parent SHAs cannot be resolved (shallow clone), this function warns
/// and continues. Missing parents are treated as baseline = true.
pub fn extract_git_context() -> Result<GitContext> {
    // Check if we're in a git repository
    // Use `rev-parse --git-dir` which returns non-zero exit code if not in a repo
    if git(&["rev-parse", "--git-dir"]).is_err() {
        anyhow::bail!("not in a git repository");
    }

    let head_sha = git(&["rev-parse", "HEAD"]).context("failed to extract HEAD SHA")?;

    // Extract parent SHAs
    // Use `rev-list --parents -n 1 HEAD` which outputs: HEAD_SHA PARENT1 PARENT2 ...
    let parents_raw = git(&["rev-list", "--parents", "-n", "1", "HEAD"])
        .context("failed to extract parent SHAs")?;

    // Parse parent SHAs (first token is HEAD, rest are parents)
    let mut parts = parents_raw.split_whitespace();
    let _ = parts.next(); // Skip HEAD SHA (we already have it)
    let parent_shas = parts.map(|s| s.to_string()).collect::<Vec<_>>();

    // Warn if no parents (shouldn't happen for normal commits, but handle gracefully)
    if parent_shas.is_empty() && head_sha != "4b825dc642cb6eb9a060e54bf8d69288fbee4904" {
        // The SHA above is git's empty tree, which is expected to have no parents
        // For other commits with no parents, this is likely an initial commit
        // which is valid, so we don't warn here
    }

    // Extract commit timestamp (%ct = Unix timestamp)
    let timestamp = git(&["show", "-s", "--format=%ct", "HEAD"])
        .context("failed to extract commit timestamp")?
        .parse::<i64>()
        .context("failed to parse commit timestamp")?;

    // Extract branch name (best effort, None if detached)
    let branch = git(&["symbolic-ref", "--short", "HEAD"]).ok();

    // Extract commit message and author
    let message = git(&["log", "-1", "--format=%B", "HEAD"]).ok();
    let author = git(&["log", "-1", "--format=%an", "HEAD"]).ok();

    // Detect fix and revert commits
    let is_fix_commit = message.as_ref().map(|m| detect_fix_commit(m));
    let is_revert_commit = message.as_ref().map(|m| detect_revert_commit(m));

    // Extract ticket IDs
    let ticket_ids = message
        .as_ref()
        .map(|m| extract_ticket_ids(m, branch.as_deref()))
        .unwrap_or_default();

    Ok(GitContext {
        head_sha,
        parent_shas,
        timestamp,
        is_detached: branch.is_none(),
        branch,
        message,
        author,
        is_fix_commit,
        is_revert_commit,
        ticket_ids,
    })
}

/// Extract git context for a specific repository path
///
/// This variant accepts a repository path instead of using the current working directory.
/// Useful for tests and scenarios where you don't want to change the process-wide directory.
///
/// # Arguments
///
/// * `repo_path` - Path to the git repository root
///
/// # Errors
///
/// Returns error if:
/// - Not in a git repository at the specified path
/// - Git commands fail for reasons other than shallow history
pub fn extract_git_context_at(repo_path: &Path) -> Result<GitContext> {
    // Check if we're in a git repository
    if git_at(repo_path, &["rev-parse", "--git-dir"]).is_err() {
        anyhow::bail!("not in a git repository at {}", repo_path.display());
    }

    let head_sha =
        git_at(repo_path, &["rev-parse", "HEAD"]).context("failed to extract HEAD SHA")?;

    // Extract parent SHAs
    let parents_raw = git_at(repo_path, &["rev-list", "--parents", "-n", "1", "HEAD"])
        .context("failed to extract parent SHAs")?;

    // Parse parent SHAs (first token is HEAD, rest are parents)
    let mut parts = parents_raw.split_whitespace();
    let _ = parts.next(); // Skip HEAD SHA (we already have it)
    let parent_shas = parts.map(|s| s.to_string()).collect::<Vec<_>>();

    // Extract commit timestamp (%ct = Unix timestamp)
    let timestamp = git_at(repo_path, &["show", "-s", "--format=%ct", "HEAD"])
        .context("failed to extract commit timestamp")?
        .parse::<i64>()
        .context("failed to parse commit timestamp")?;

    // Extract branch name (best effort, None if detached)
    let branch = git_at(repo_path, &["symbolic-ref", "--short", "HEAD"]).ok();

    // Extract commit message and author
    let message = git_at(repo_path, &["log", "-1", "--format=%B", "HEAD"]).ok();
    let author = git_at(repo_path, &["log", "-1", "--format=%an", "HEAD"]).ok();

    // Detect fix and revert commits
    let is_fix_commit = message.as_ref().map(|m| detect_fix_commit(m));
    let is_revert_commit = message.as_ref().map(|m| detect_revert_commit(m));

    // Extract ticket IDs
    let ticket_ids = message
        .as_ref()
        .map(|m| extract_ticket_ids(m, branch.as_deref()))
        .unwrap_or_default();

    Ok(GitContext {
        head_sha,
        parent_shas,
        timestamp,
        is_detached: branch.is_none(),
        branch,
        message,
        author,
        is_fix_commit,
        is_revert_commit,
        ticket_ids,
    })
}

/// PR context information
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrContext {
    pub is_pr: bool,
    pub merge_base: Option<String>,
}

/// Detect if we're in a PR context via CI environment variables
///
/// Checks CI environment variables (GitHub: `GITHUB_EVENT_NAME`, `GITHUB_REF`).
/// Best-effort detection - returns `is_pr=false` if context is ambiguous.
/// Never hard-fails on ambiguous context.
pub fn detect_pr_context() -> PrContext {
    // Check GitHub Actions environment variables
    let github_event_name = std::env::var("GITHUB_EVENT_NAME").ok();
    let github_ref = std::env::var("GITHUB_REF").ok();

    // Check if this looks like a PR (pull_request event)
    let is_pr = match (&github_event_name, &github_ref) {
        (Some(event), Some(ref_name)) => {
            // GitHub PR events have event_name = "pull_request" and ref starts with "refs/pull/"
            event == "pull_request" || ref_name.starts_with("refs/pull/")
        }
        _ => false,
    };

    PrContext {
        is_pr,
        merge_base: None, // Will be computed later if needed
    }
}

/// Resolve merge-base between current HEAD and target branch
///
/// For PRs, this finds the common ancestor with the base branch.
/// Falls back to direct parent if merge-base cannot be resolved.
///
/// # Arguments
///
/// * `target_branch` - Target branch (e.g., "main", "master")
///
/// # Returns
///
/// Returns merge-base SHA, or None if merge-base fails (falls back to parent)
pub fn resolve_merge_base(target_branch: &str) -> Result<Option<String>> {
    // Try to get merge-base with target branch
    // git merge-base HEAD <target_branch>
    match git(&["merge-base", "HEAD", target_branch]) {
        Ok(sha) => Ok(Some(sha)),
        Err(_) => {
            // Merge-base failed - fall back to direct parent
            // This is acceptable (best-effort), so we just return None
            // The caller will use direct parent instead
            Ok(None)
        }
    }
}

/// Resolve merge-base with common target branches
///
/// Tries common branch names (main, master, develop) to find merge-base.
/// Returns first successful merge-base, or None if all fail.
pub fn resolve_merge_base_auto() -> Option<String> {
    let common_branches = ["main", "master", "develop", "trunk"];

    for branch in &common_branches {
        if let Ok(Some(sha)) = resolve_merge_base(branch) {
            return Some(sha);
        }
    }

    None
}

/// Resolve a git ref (branch, tag, SHA, HEAD~N, etc.) to a full 40-character SHA.
///
/// Runs `git rev-parse <ref>` in the given repository root.
///
/// # Errors
///
/// Returns an error if the ref does not exist or git fails.
pub fn resolve_ref_to_sha(repo_root: &Path, git_ref: &str) -> Result<String> {
    git_at(repo_root, &["rev-parse", "--verify", git_ref])
        .with_context(|| format!("failed to resolve git ref '{git_ref}'"))
}

/// A temporary git worktree that is removed when dropped.
///
/// Created by [`create_worktree`]. The worktree directory is cleaned up via
/// `git worktree remove --force` on drop, so it is always removed even if the
/// caller returns an error mid-analysis.
pub struct TempWorktree {
    /// Absolute path to the worktree root.
    pub path: std::path::PathBuf,
    /// Repo root used to issue `git worktree remove`.
    repo_root: std::path::PathBuf,
}

impl Drop for TempWorktree {
    fn drop(&mut self) {
        let result = git_at(
            &self.repo_root,
            &[
                "worktree",
                "remove",
                "--force",
                &self.path.to_string_lossy(),
            ],
        );
        if let Err(e) = result {
            eprintln!(
                "warning: failed to remove temporary worktree {}: {e}",
                self.path.display()
            );
        }
    }
}

/// Create a temporary detached worktree at `sha` and return a drop guard.
///
/// The directory name includes the current process ID so concurrent hotspots
/// processes analyzing the same SHA use separate paths. Any stale directory
/// left by an interrupted previous run with the same PID is removed before
/// creating the new worktree. The worktree is automatically torn down when
/// the returned [`TempWorktree`] is dropped.
pub fn create_worktree(repo_root: &Path, sha: &str) -> Result<TempWorktree> {
    let dir = std::env::temp_dir().join(format!("hotspots-worktree-{sha}-{}", std::process::id()));

    // Always attempt to unregister any stale git worktree entry first.
    // A temp cleaner may have removed the directory while the .git/worktrees
    // metadata entry remains; `git worktree add` fails in that case even though
    // `dir.exists()` returns false.
    if let Err(e) = git_at(
        repo_root,
        &["worktree", "remove", "--force", &dir.to_string_lossy()],
    ) {
        // Not fatal — the entry simply may not exist yet.
        eprintln!(
            "warning: failed to remove existing git worktree at {}: {e}",
            dir.display()
        );
    }
    // Remove the directory itself if it is still present.
    if dir.exists() {
        std::fs::remove_dir_all(&dir).with_context(|| {
            format!(
                "failed to remove existing worktree directory {}; \
                 try removing it manually and re-run",
                dir.display()
            )
        })?;
    }

    git_at(
        repo_root,
        &["worktree", "add", "--detach", &dir.to_string_lossy(), sha],
    )
    .with_context(|| format!("failed to create worktree for {sha} at {}", dir.display()))?;
    Ok(TempWorktree {
        path: dir,
        repo_root: repo_root.to_path_buf(),
    })
}

/// Find the merge-base SHA and Unix timestamp between HEAD and main/master.
/// Returns None when already on main (merge-base == HEAD) or no divergence found.
pub fn find_merge_base(repo_root: &Path) -> Option<(String, i64)> {
    for branch in &["main", "master", "origin/HEAD"] {
        if let Ok(sha) = git_at(repo_root, &["merge-base", "HEAD", branch]) {
            let sha = sha.trim().to_string();
            if sha.is_empty() {
                continue;
            }
            if let Ok(head) = git_at(repo_root, &["rev-parse", "HEAD"]) {
                if head.trim() == sha {
                    return None; // already on main
                }
            }
            if let Ok(ts_str) = git_at(repo_root, &["log", "-1", "--format=%ct", &sha]) {
                if let Ok(ts) = ts_str.trim().parse::<i64>() {
                    return Some((sha, ts));
                }
            }
        }
    }
    None
}

/// Days since the last change to `file` at or before `before_sha`.
/// Returns None if the file has no history before that SHA.
pub fn days_since_last_change_at_sha(
    repo_root: &Path,
    file: &str,
    before_sha: &str,
    as_of_timestamp: i64,
) -> Option<u32> {
    git_at(
        repo_root,
        &["log", "-1", "--format=%ct", before_sha, "--", file],
    )
    .ok()
    .and_then(|out| out.trim().parse::<i64>().ok())
    .map(|ts| ((as_of_timestamp - ts).max(0) / 86400) as u32)
}

/// Extract commit churn (lines added/deleted per file)
///
/// Uses `git show --numstat <sha>` to get churn data for all files in the commit.
/// Binary files (shown as `-\t-\t<file>`) are skipped.
///
/// # Arguments
///
/// * `sha` - Commit SHA to analyze
///
/// # Returns
///
/// Returns vector of FileChurn entries, one per changed file.
/// Returns empty vector for:
/// - Initial commits (no parent)
/// - Merge commits (uses first parent only)
/// - Commits with no file changes
///
/// # Errors
///
/// Returns error if git command fails or output cannot be parsed
pub fn extract_commit_churn(sha: &str) -> Result<Vec<FileChurn>> {
    // Use git show --numstat to get lines added/deleted per file
    // Format: <added>\t<deleted>\t<file>
    // Binary files show: -\t-\t<file>
    let output = match git(&["show", "--numstat", "--format=", sha]) {
        Ok(out) => out,
        Err(_) => {
            // If git show fails (e.g., initial commit with no parent), return empty
            return Ok(Vec::new());
        }
    };

    let mut churns = Vec::new();
    for line in output.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }

        let parts: Vec<&str> = line.split('\t').collect();
        if parts.len() < 3 {
            continue; // Invalid line, skip
        }

        let added_str = parts[0];
        let deleted_str = parts[1];
        let file = parts[2].to_string();

        // Skip binary files (shown as -)
        if added_str == "-" || deleted_str == "-" {
            continue;
        }

        // Parse numbers
        let lines_added = added_str
            .parse::<usize>()
            .with_context(|| format!("Failed to parse lines added: {}", added_str))?;
        let lines_deleted = deleted_str
            .parse::<usize>()
            .with_context(|| format!("Failed to parse lines deleted: {}", deleted_str))?;

        churns.push(FileChurn {
            file,
            lines_added,
            lines_deleted,
        });
    }

    Ok(churns)
}

/// Extract commit churn at a specific repository path
///
/// Like `extract_commit_churn`, but operates on a repository at a specific path.
///
/// # Arguments
///
/// * `repo_path` - Path to git repository
/// * `sha` - Commit SHA to analyze
///
/// # Returns
///
/// Returns vector of FileChurn entries for the commit
pub fn extract_commit_churn_at(repo_path: &Path, sha: &str) -> Result<Vec<FileChurn>> {
    let output = match git_at(repo_path, &["show", "--numstat", "--format=", sha]) {
        Ok(out) => out,
        Err(_) => {
            return Ok(Vec::new());
        }
    };

    let mut churns = Vec::new();
    for line in output.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }

        let parts: Vec<&str> = line.split('\t').collect();
        if parts.len() < 3 {
            continue;
        }

        let added_str = parts[0];
        let deleted_str = parts[1];
        let file = parts[2].to_string();

        if added_str == "-" || deleted_str == "-" {
            continue;
        }

        let lines_added = added_str
            .parse::<usize>()
            .with_context(|| format!("Failed to parse lines added: {}", added_str))?;
        let lines_deleted = deleted_str
            .parse::<usize>()
            .with_context(|| format!("Failed to parse lines deleted: {}", deleted_str))?;

        churns.push(FileChurn {
            file,
            lines_added,
            lines_deleted,
        });
    }

    Ok(churns)
}

/// Compute touch metrics for all files in a repository using two git log calls.
///
/// Replaces the previous O(files) approach (one subprocess per file) with two
/// batched calls, reducing overhead from ~7.5 ms × N to ~40 ms total.
///
/// # Algorithm
///
/// Call 1: `git log --format="COMMIT %ct" --name-only --since=X --until=Y`
///   → builds `touch_count_30d` and finds last-change timestamp for files in window.
///
/// Call 2 (fallback): for any file not seen in call 1, a single `git log -1 --format=%ct`
///   call per file (typically very few files; most active files appear in the window).
pub fn batch_touch_metrics_at(
    repo_root: &Path,
    as_of_timestamp: i64,
) -> Result<BatchedTouchMetrics> {
    use std::collections::HashMap;

    let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
    let since_arg = format!("--since={}", thirty_days_ago);
    let until_arg = format!("--until={}", as_of_timestamp);

    // Call 1: all commits in the 30-day window
    let window_output = git_at(
        repo_root,
        &[
            "log",
            "--format=COMMIT %ct",
            "--name-only",
            &since_arg,
            &until_arg,
        ],
    )
    .unwrap_or_default();

    let mut touch_count: HashMap<String, usize> = HashMap::new();
    let mut last_touch_ts: HashMap<String, i64> = HashMap::new();
    let mut current_ts: i64 = 0;

    for line in window_output.lines() {
        if let Some(ts_str) = line.strip_prefix("COMMIT ") {
            current_ts = ts_str.trim().parse().unwrap_or(0);
        } else if !line.trim().is_empty() {
            let file = line.trim().to_string();
            *touch_count.entry(file.clone()).or_insert(0) += 1;
            // First occurrence = most recent (git log is newest-first)
            last_touch_ts.entry(file).or_insert(current_ts);
        }
    }

    // Build days_since map from what we have so far
    let days_since: HashMap<String, u32> = last_touch_ts
        .iter()
        .map(|(file, &ts)| {
            let days = ((as_of_timestamp - ts).max(0) / (24 * 60 * 60)) as u32;
            (file.clone(), days)
        })
        .collect();

    Ok(BatchedTouchMetrics {
        touch_count_30d: touch_count,
        days_since_last_change: days_since,
    })
}

/// For files absent from the 30-day window, find the most recent commit timestamp
/// for each using batched `git log` calls (up to 500 files per invocation).
/// This replaces O(N) individual `git log -1 -- file` calls with O(N/500) calls.
pub fn batch_last_touch_for_files(
    repo_path: &Path,
    stale_files: &std::collections::HashSet<&str>,
    as_of_timestamp: i64,
) -> std::collections::HashMap<String, u32> {
    use std::collections::HashMap;
    if stale_files.is_empty() {
        return HashMap::new();
    }

    let until_arg = format!("--until={}", as_of_timestamp);
    let files: Vec<&str> = stale_files.iter().copied().collect();
    let mut result: HashMap<String, u32> = HashMap::new();

    for chunk in files.chunks(500) {
        let mut args: Vec<&str> = vec![
            "log",
            "--format=COMMIT %ct",
            "--name-only",
            &until_arg,
            "--",
        ];
        args.extend_from_slice(chunk);

        let output = match git_at(repo_path, &args) {
            Ok(o) => o,
            Err(_) => continue,
        };

        let mut current_ts: i64 = 0;
        for line in output.lines() {
            if let Some(ts_str) = line.strip_prefix("COMMIT ") {
                current_ts = ts_str.trim().parse().unwrap_or(0);
            } else if current_ts > 0 && !line.trim().is_empty() {
                let file = line.trim();
                if stale_files.contains(file) {
                    // entry() only inserts on first (= most recent) occurrence
                    result.entry(file.to_string()).or_insert_with(|| {
                        ((as_of_timestamp - current_ts).max(0) / (24 * 60 * 60)) as u32
                    });
                }
            }
        }
    }

    result
}

/// Per-function touch metrics using `git log -L start,end:file`.
///
/// Returns `(touch_count_30d, days_since_last_change)` for the specific line range.
/// More accurate than file-level metrics but ~50× slower per function.
///
/// # Arguments
///
/// * `repo_path` - Path to git repository
/// * `file` - Relative path to file from repository root
/// * `start_line` - First line of function (1-based)
/// * `end_line` - Last line of function (1-based)
/// * `as_of_timestamp` - Unix timestamp to use as "now"
pub fn function_touch_metrics_at(
    repo_path: &Path,
    file: &str,
    start_line: u32,
    end_line: u32,
    as_of_timestamp: i64,
) -> Result<(usize, Option<u32>)> {
    let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
    let since_arg = format!("--since={}", thirty_days_ago);
    let until_arg = format!("--until={}", as_of_timestamp);
    let range_arg = format!("-L{},{}:{}", start_line, end_line, file);

    // Count touches in 30-day window; filter diff output by looking for "COMMIT <ts>" markers
    let window_output = git_at(
        repo_path,
        &[
            "log",
            &range_arg,
            "--format=COMMIT %ct",
            &since_arg,
            &until_arg,
        ],
    )
    .unwrap_or_default();

    let window_timestamps: Vec<i64> = window_output
        .lines()
        .filter_map(|l| l.strip_prefix("COMMIT "))
        .filter_map(|ts| ts.trim().parse::<i64>().ok())
        .collect();

    let touch_count = window_timestamps.len();

    let days_since = if let Some(&ts) = window_timestamps.first() {
        Some(((as_of_timestamp - ts).max(0) / (24 * 60 * 60)) as u32)
    } else {
        // Not in 30-day window: find most recent commit touching this range
        let recent_until = format!("--until={}", as_of_timestamp);
        let recent_output = git_at(
            repo_path,
            &[
                "log",
                &range_arg,
                "--format=COMMIT %ct",
                "-1",
                &recent_until,
            ],
        )
        .unwrap_or_default();

        recent_output
            .lines()
            .filter_map(|l| l.strip_prefix("COMMIT "))
            .filter_map(|ts| ts.trim().parse::<i64>().ok())
            .next()
            .map(|ts| ((as_of_timestamp - ts).max(0) / (24 * 60 * 60)) as u32)
    };

    Ok((touch_count, days_since))
}

/// Count how many commits touched a file in the last 30 days
///
/// Counts commits relative to a specific timestamp (typically the commit timestamp),
/// not wall clock time. This allows deterministic analysis of historical commits.
///
/// Returns count of commits in the 30 days before `as_of_timestamp`, or 0 if none.
pub fn count_file_touches_30d(file: &str, as_of_timestamp: i64) -> Result<usize> {
    // Calculate 30 days before the reference timestamp
    let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);

    // Use git log with --since and --until to get commits in the window
    // Format: --since=<timestamp> --until=<timestamp>
    let since_arg = format!("--since={}", thirty_days_ago);
    let until_arg = format!("--until={}", as_of_timestamp);

    let output = match git(&["log", &since_arg, &until_arg, "--oneline", "--", file]) {
        Ok(out) => out,
        Err(_) => {
            // File doesn't exist or no commits in window
            return Ok(0);
        }
    };

    // Count non-empty lines
    let count = output
        .lines()
        .filter(|line| !line.trim().is_empty())
        .count();
    Ok(count)
}

/// Count file touches at a specific repository path
pub fn count_file_touches_30d_at(
    repo_path: &Path,
    file: &str,
    as_of_timestamp: i64,
) -> Result<usize> {
    let thirty_days_ago = as_of_timestamp - (30 * 24 * 60 * 60);
    let since_arg = format!("--since={}", thirty_days_ago);
    let until_arg = format!("--until={}", as_of_timestamp);

    let output = match git_at(
        repo_path,
        &["log", &since_arg, &until_arg, "--oneline", "--", file],
    ) {
        Ok(out) => out,
        Err(_) => {
            return Ok(0);
        }
    };

    let count = output
        .lines()
        .filter(|line| !line.trim().is_empty())
        .count();
    Ok(count)
}

/// Calculate days since last change to a file
///
/// Returns number of days between the file's last modification and the reference timestamp.
///
/// # Arguments
///
/// * `file` - Relative path to file from repository root
/// * `as_of_timestamp` - Unix timestamp to use as "now" (typically commit timestamp)
///
/// # Returns
///
/// Returns days since last change, or 0 if file was just modified.
/// Returns 0 if file doesn't exist or has no history.
pub fn days_since_last_change(file: &str, as_of_timestamp: i64) -> Result<u32> {
    // Get the timestamp of the most recent commit that modified this file
    // Use: git log -1 --format=%ct -- <file>
    let output = match git(&["log", "-1", "--format=%ct", "--", file]) {
        Ok(out) => out,
        Err(_) => {
            // File doesn't exist or has no history
            return Ok(0);
        }
    };

    let last_change_timestamp = output
        .trim()
        .parse::<i64>()
        .context("failed to parse last change timestamp")?;

    // Calculate days difference
    let seconds_diff = as_of_timestamp - last_change_timestamp;
    let days = (seconds_diff / (24 * 60 * 60)).max(0) as u32;

    Ok(days)
}

/// Calculate days since last change at a specific repository path
pub fn days_since_last_change_at(
    repo_path: &Path,
    file: &str,
    as_of_timestamp: i64,
) -> Result<u32> {
    let output = match git_at(repo_path, &["log", "-1", "--format=%ct", "--", file]) {
        Ok(out) => out,
        Err(_) => {
            return Ok(0);
        }
    };

    let last_change_timestamp = output
        .trim()
        .parse::<i64>()
        .context("failed to parse last change timestamp")?;

    let seconds_diff = as_of_timestamp - last_change_timestamp;
    let days = (seconds_diff / (24 * 60 * 60)).max(0) as u32;

    Ok(days)
}

/// Detect if a commit message indicates a fix/bug fix
///
/// Looks for common keywords: "fix", "bug", "hotfix", "bugfix", etc.
pub fn detect_fix_commit(message: &str) -> bool {
    let lower = message.to_lowercase();
    lower.contains("fix")
        || lower.contains("bug")
        || lower.contains("hotfix")
        || lower.contains("bugfix")
}

/// Detect if a commit is a revert
///
/// Looks for "revert" keyword in the message
pub fn detect_revert_commit(message: &str) -> bool {
    let lower = message.to_lowercase();
    lower.contains("revert")
}

/// Extract ticket IDs from commit message and branch name
///
/// Supports formats:
/// - JIRA-1234
/// - ABC-123
/// - #123
/// - fixes #123
/// - closes #123
pub fn extract_ticket_ids(message: &str, branch: Option<&str>) -> Vec<String> {
    let mut tickets = Vec::new();

    // Pattern for JIRA-style tickets: PROJECT-123
    for cap in jira_re().captures_iter(message) {
        if let Some(ticket) = cap.get(1) {
            let ticket_str = ticket.as_str().to_string();
            if !tickets.contains(&ticket_str) {
                tickets.push(ticket_str);
            }
        }
    }

    // Pattern for GitHub issues: #123, fixes #123, closes #123
    for cap in github_re().captures_iter(message) {
        if let Some(number) = cap.get(1) {
            let ticket_str = format!("#{}", number.as_str());
            if !tickets.contains(&ticket_str) {
                tickets.push(ticket_str);
            }
        }
    }

    // Also extract from branch name if provided
    if let Some(branch_name) = branch {
        for cap in jira_re().captures_iter(branch_name) {
            if let Some(ticket) = cap.get(1) {
                let ticket_str = ticket.as_str().to_string();
                if !tickets.contains(&ticket_str) {
                    tickets.push(ticket_str);
                }
            }
        }

        for cap in github_re().captures_iter(branch_name) {
            if let Some(number) = cap.get(1) {
                let ticket_str = format!("#{}", number.as_str());
                if !tickets.contains(&ticket_str) {
                    tickets.push(ticket_str);
                }
            }
        }
    }

    tickets
}

/// A pair of files that frequently change together in the same commit
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct CoChangePair {
    pub file_a: String,
    pub file_b: String,
    /// Number of commits where both files changed
    pub co_change_count: usize,
    /// co_change_count / min(total_changes_a, total_changes_b)
    pub coupling_ratio: f64,
    /// "high" if ratio > 0.5 and no static dep, "moderate" if > 0.25 and no static dep,
    /// "expected" if a direct import exists between the two files, else "low"
    pub risk: String,
    /// Whether a direct import relationship exists between file_a and file_b
    pub has_static_dep: bool,
}

/// Returns true for pairs that are trivially expected to co-change (test+source,
/// mod.rs+sibling) and should be excluded from coupling analysis.
fn is_trivial_pair(file_a: &str, file_b: &str) -> bool {
    // foo.rs + foo_test.rs or foo_tests.rs (same dir, same stem)
    let dir_a = std::path::Path::new(file_a)
        .parent()
        .unwrap_or(std::path::Path::new(""));
    let dir_b = std::path::Path::new(file_b)
        .parent()
        .unwrap_or(std::path::Path::new(""));
    let stem_a = std::path::Path::new(file_a)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("");
    let stem_b = std::path::Path::new(file_b)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("");

    if dir_a == dir_b {
        // Test file paired with its source: foo + foo_test or foo_tests
        if stem_b == format!("{}_test", stem_a) || stem_b == format!("{}_tests", stem_a) {
            return true;
        }
        if stem_a == format!("{}_test", stem_b) || stem_a == format!("{}_tests", stem_b) {
            return true;
        }
        // mod.rs paired with any sibling in the same directory
        if stem_a == "mod" || stem_b == "mod" {
            return true;
        }
    }
    false
}

/// Mine co-change pairs from git log over the last `window_days` days.
///
/// Returns pairs that co-changed at least `min_count` times, ranked by
/// coupling_ratio descending. Pairs where both files are the same are excluded.
pub fn extract_co_change_pairs(
    repo_root: &Path,
    window_days: u64,
    min_count: usize,
) -> Result<Vec<CoChangePair>> {
    let since = format!("{} days ago", window_days);
    let output = git_at(
        repo_root,
        &[
            "log",
            "--name-only",
            "--format=COMMIT:%H",
            &format!("--since={}", since),
            "--diff-filter=AM",
        ],
    )
    .unwrap_or_default();

    // Parse into per-commit file sets
    let mut commit_files: Vec<Vec<String>> = Vec::new();
    let mut current: Vec<String> = Vec::new();
    for line in output.lines() {
        if line.starts_with("COMMIT:") {
            if !current.is_empty() {
                commit_files.push(std::mem::take(&mut current));
            }
        } else if !line.trim().is_empty() {
            current.push(line.trim().to_string());
        }
    }
    if !current.is_empty() {
        commit_files.push(current);
    }

    // Count per-file total appearances and per-pair co-occurrences
    let mut file_counts: std::collections::HashMap<String, usize> =
        std::collections::HashMap::new();
    let mut pair_counts: std::collections::HashMap<(String, String), usize> =
        std::collections::HashMap::new();

    // Skip commits that touch more than this many files — they are mass-change
    // commits (version bumps, renames, reformats) that produce O(n²) pairs and
    // dominate memory without adding meaningful co-change signal.
    const MAX_FILES_PER_COMMIT: usize = 200;

    for files in &commit_files {
        for f in files {
            *file_counts.entry(f.clone()).or_insert(0) += 1;
        }
        if files.len() > MAX_FILES_PER_COMMIT {
            continue;
        }
        // All unique pairs in this commit
        let mut sorted = files.clone();
        sorted.sort();
        sorted.dedup();
        for i in 0..sorted.len() {
            for j in (i + 1)..sorted.len() {
                let key = (sorted[i].clone(), sorted[j].clone());
                *pair_counts.entry(key).or_insert(0) += 1;
            }
        }
    }

    let mut pairs: Vec<CoChangePair> = pair_counts
        .into_iter()
        .filter(|(_, count)| *count >= min_count)
        .filter(|((file_a, file_b), _)| {
            // Skip pairs where either file no longer exists (e.g. renamed/deleted files)
            repo_root.join(file_a).exists() && repo_root.join(file_b).exists()
        })
        .filter(|((file_a, file_b), _)| !is_trivial_pair(file_a, file_b))
        .map(|((file_a, file_b), co_change_count)| {
            let count_a = file_counts.get(&file_a).copied().unwrap_or(1);
            let count_b = file_counts.get(&file_b).copied().unwrap_or(1);
            let coupling_ratio = co_change_count as f64 / count_a.min(count_b) as f64;
            let risk = if coupling_ratio > 0.5 {
                "high".to_string()
            } else if coupling_ratio > 0.25 {
                "moderate".to_string()
            } else {
                "low".to_string()
            };
            CoChangePair {
                file_a,
                file_b,
                co_change_count,
                coupling_ratio: (coupling_ratio * 1000.0).round() / 1000.0,
                risk,
                has_static_dep: false, // annotated later in compute_snapshot_aggregates
            }
        })
        .collect();

    pairs.sort_by(|a, b| {
        b.coupling_ratio
            .partial_cmp(&a.coupling_ratio)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then(a.file_a.cmp(&b.file_a))
            .then(a.file_b.cmp(&b.file_b))
    });

    Ok(pairs)
}

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

    #[test]
    fn test_extract_git_context() {
        // This test requires a git repository
        // Skip if not in a git repo (e.g., in CI without git context)
        if git(&["rev-parse", "--git-dir"]).is_err() {
            eprintln!("Skipping test: not in a git repository");
            return;
        }

        let context = extract_git_context().expect("should extract git context");

        // HEAD SHA should be 40 characters (full SHA) or 7+ characters (short SHA)
        assert!(!context.head_sha.is_empty(), "HEAD SHA should not be empty");

        // Timestamp should be reasonable (after 2005-04-07, before year 2100)
        assert!(
            context.timestamp > 1112832000,
            "timestamp should be after 2005"
        );
        assert!(
            context.timestamp < 4102444800,
            "timestamp should be before 2100"
        );

        // is_detached should be consistent with branch
        assert_eq!(
            context.is_detached,
            context.branch.is_none(),
            "is_detached should match branch presence"
        );
    }

    #[test]
    fn test_extract_commit_churn() {
        // Skip if not in a git repo
        if git(&["rev-parse", "--git-dir"]).is_err() {
            eprintln!("Skipping test: not in a git repository");
            return;
        }

        let context = match extract_git_context() {
            Ok(ctx) => ctx,
            Err(_) => {
                eprintln!("Skipping test: could not extract git context");
                return;
            }
        };

        // Skip if this is an initial commit (no parents)
        if context.parent_shas.is_empty() {
            eprintln!("Skipping test: initial commit has no churn");
            return;
        }

        // Extract churn for HEAD
        let churns = extract_commit_churn(&context.head_sha).expect("should extract churn");

        // Churn should be a list (may be empty for commits with no changes)
        // We just verify it doesn't error
        println!("Extracted {} file churns for HEAD", churns.len());

        // Validate structure if we have any churns
        for churn in &churns {
            assert!(!churn.file.is_empty(), "file path should not be empty");
            // lines_added and lines_deleted can be 0, so just check they parse
        }
    }

    #[test]
    fn test_resolve_ref_to_sha_head() {
        let cwd = std::env::current_dir().expect("failed to get cwd");
        if git_at(&cwd, &["rev-parse", "--git-dir"]).is_err() {
            eprintln!("Skipping test: not in a git repository");
            return;
        }

        let sha = resolve_ref_to_sha(&cwd, "HEAD").expect("should resolve HEAD");

        // Full SHA is 40 hex characters
        assert_eq!(sha.len(), 40, "resolved SHA should be 40 characters");
        assert!(
            sha.chars().all(|c| c.is_ascii_hexdigit()),
            "resolved SHA should be hex"
        );
    }

    #[test]
    fn test_resolve_ref_to_sha_abbreviated() {
        let cwd = std::env::current_dir().expect("failed to get cwd");
        if git_at(&cwd, &["rev-parse", "--git-dir"]).is_err() {
            eprintln!("Skipping test: not in a git repository");
            return;
        }

        // Get HEAD SHA, then resolve its first 8 characters as an abbreviated ref
        let full_sha = resolve_ref_to_sha(&cwd, "HEAD").expect("should resolve HEAD");
        let abbrev = &full_sha[..8];
        let resolved = resolve_ref_to_sha(&cwd, abbrev).expect("should resolve abbreviated SHA");

        assert_eq!(
            resolved, full_sha,
            "abbreviated SHA should resolve to the same full SHA as HEAD"
        );
    }

    #[test]
    fn test_resolve_ref_to_sha_invalid_ref() {
        let cwd = std::env::current_dir().expect("failed to get cwd");
        if git_at(&cwd, &["rev-parse", "--git-dir"]).is_err() {
            eprintln!("Skipping test: not in a git repository");
            return;
        }

        let result = resolve_ref_to_sha(&cwd, "refs/heads/this-branch-definitely-does-not-exist");
        assert!(result.is_err(), "invalid ref should return an error");
    }
}
