//! Read a git diff (between `<base>` and `HEAD`) into a `DiffSummary`.
//! Walks the working tree via `git diff --numstat` and `git diff
//! --unified=0` for the line-content scan.

use crate::size::{is_architectural_path, is_public_api_change_line, is_test_path, DiffSummary};
use std::collections::BTreeSet;
use std::path::Path;
use std::process::Command;

#[derive(Debug)]
pub enum DiffError {
    GitMissing,
    GitFailed(String),
    Utf8(std::string::FromUtf8Error),
}

impl std::fmt::Display for DiffError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::GitMissing => write!(f, "`git` not found on PATH"),
            Self::GitFailed(s) => write!(f, "git failed: {s}"),
            Self::Utf8(e) => write!(f, "git output is not utf-8: {e}"),
        }
    }
}

impl std::error::Error for DiffError {}

/// Compute the diff summary between `base..HEAD` (or `base...HEAD` if
/// `merge_base = true`).
pub fn from_git(repo_root: &Path, base: &str, merge_base: bool) -> Result<DiffSummary, DiffError> {
    let range = if merge_base {
        format!("{base}...HEAD")
    } else {
        format!("{base}..HEAD")
    };
    let numstat = run_git(repo_root, &["diff", "--numstat", &range])?;
    let unified = run_git(repo_root, &["diff", "--unified=0", &range])?;
    Ok(parse_diff(&numstat, &unified))
}

fn run_git(repo_root: &Path, args: &[&str]) -> Result<String, DiffError> {
    let out = Command::new("git")
        .args(args)
        .current_dir(repo_root)
        .output()
        .map_err(|_| DiffError::GitMissing)?;
    if !out.status.success() {
        return Err(DiffError::GitFailed(
            String::from_utf8_lossy(&out.stderr).into_owned(),
        ));
    }
    String::from_utf8(out.stdout).map_err(DiffError::Utf8)
}

pub fn parse_diff(numstat: &str, unified: &str) -> DiffSummary {
    let mut files: BTreeSet<String> = BTreeSet::new();
    let mut crates: BTreeSet<String> = BTreeSet::new();
    let mut loc: u64 = 0;
    let mut touches_architecture = false;
    let mut all_tests = true;
    let mut any_file = false;

    for line in numstat.lines() {
        let mut parts = line.split('\t');
        let added = parts.next().unwrap_or("0");
        let removed = parts.next().unwrap_or("0");
        let path = parts.next().unwrap_or("").trim();
        if path.is_empty() {
            continue;
        }
        any_file = true;
        files.insert(path.to_string());
        if let Ok(n) = added.parse::<u64>() {
            loc += n;
        }
        if let Ok(n) = removed.parse::<u64>() {
            loc += n;
        }
        if let Some(crate_name) = crate_dir_of(path) {
            crates.insert(crate_name);
        }
        if is_architectural_path(path) {
            touches_architecture = true;
        }
        if !is_test_path(path) {
            all_tests = false;
        }
    }
    if !any_file {
        all_tests = false;
    }

    let touches_public_api = unified.lines().any(is_public_api_change_line);

    DiffSummary {
        loc_changed: loc,
        files_changed: files.len() as u64,
        crates_touched: crates.into_iter().collect(),
        touches_architecture,
        touches_public_api,
        all_tests,
    }
}

fn crate_dir_of(path: &str) -> Option<String> {
    let rest = path.strip_prefix("crates/")?;
    let name = rest.split('/').next()?;
    if name.is_empty() {
        None
    } else {
        Some(name.to_string())
    }
}

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

    #[test]
    fn parses_numstat_into_summary() {
        let numstat = "5\t3\tcrates/koala-core/src/lib.rs\n10\t2\tcrates/koala-cli/src/main.rs\n";
        let unified = "";
        let s = parse_diff(numstat, unified);
        assert_eq!(s.loc_changed, 20);
        assert_eq!(s.files_changed, 2);
        assert_eq!(s.crates_touched, vec!["koala-cli", "koala-core"]);
    }

    #[test]
    fn detects_architectural_marker_files() {
        let numstat = "1\t0\twiki/decisions/0014-x.md\n";
        let s = parse_diff(numstat, "");
        assert!(s.touches_architecture);
    }

    #[test]
    fn detects_public_api_change_in_unified() {
        let numstat = "1\t0\tcrates/x/src/lib.rs\n";
        let unified = "@@\n+pub fn new_thing() {}\n";
        let s = parse_diff(numstat, unified);
        assert!(s.touches_public_api);
    }

    #[test]
    fn all_tests_when_only_test_paths_changed() {
        let numstat = "5\t0\tcrates/x/tests/unit.rs\n";
        let s = parse_diff(numstat, "");
        assert!(s.all_tests);
    }

    #[test]
    fn mixed_paths_clear_all_tests_flag() {
        let numstat = "5\t0\tcrates/x/tests/unit.rs\n3\t0\tcrates/x/src/lib.rs\n";
        let s = parse_diff(numstat, "");
        assert!(!s.all_tests);
    }
}
