//! PR size classification. Spec: small / medium / large / architectural.

use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Size {
    Small,
    Medium,
    Large,
    Architectural,
}

impl Size {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Small => "small",
            Self::Medium => "medium",
            Self::Large => "large",
            Self::Architectural => "architectural",
        }
    }
}

impl fmt::Display for Size {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Thresholds {
    /// Strictly less than → eligible for `small`.
    pub small_max_loc: u64,
    pub small_max_files: u64,
    /// Strictly less than → eligible for `medium` (must also be single-crate).
    pub medium_max_loc: u64,
}

impl Default for Thresholds {
    fn default() -> Self {
        Self {
            small_max_loc: 20,
            small_max_files: 3,
            medium_max_loc: 200,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DiffSummary {
    /// Total lines added + removed.
    pub loc_changed: u64,
    /// Distinct files touched.
    pub files_changed: u64,
    /// Distinct top-level crate directories touched (e.g. `koala-core`,
    /// `koala-cli`). Empty for changes outside `crates/`.
    pub crates_touched: Vec<String>,
    /// Did the diff touch any architectural-marker file? See
    /// [`is_architectural_path`].
    pub touches_architecture: bool,
    /// Did the diff add or remove a `pub fn` / `pub struct` /
    /// `pub trait` line?
    pub touches_public_api: bool,
    /// True iff every changed file is under a `tests/` directory or
    /// matches the `*_test.rs` / `*-test.*` pattern.
    pub all_tests: bool,
}

pub fn classify(d: &DiffSummary, t: &Thresholds) -> Size {
    if d.touches_architecture {
        return Size::Architectural;
    }
    if d.touches_public_api {
        return Size::Large;
    }
    if d.crates_touched.len() > 1 && !d.all_tests {
        return Size::Large;
    }
    if d.loc_changed >= t.medium_max_loc {
        return Size::Large;
    }
    if d.loc_changed < t.small_max_loc && d.files_changed < t.small_max_files {
        return Size::Small;
    }
    Size::Medium
}

/// Path patterns that promote any diff to `architectural`. The set is
/// intentionally narrow — we want the verdict to be obvious.
pub fn is_architectural_path(rel: &str) -> bool {
    rel == ".koala/architecture.toml"
        || rel.starts_with("wiki/decisions/") && rel.ends_with(".md") && !rel.contains("/_")
        || rel == "wiki/architecture.md"
}

/// Heuristic: a diff line that adds or removes a `pub` item is treated
/// as a public-API change.
pub fn is_public_api_change_line(line: &str) -> bool {
    let line = line.trim_start();
    let body = line.strip_prefix('+').or_else(|| line.strip_prefix('-'));
    let body = match body {
        Some(s) => s.trim_start(),
        None => return false,
    };
    body.starts_with("pub fn ")
        || body.starts_with("pub struct ")
        || body.starts_with("pub trait ")
        || body.starts_with("pub enum ")
        || body.starts_with("pub use ")
        || body.starts_with("pub const ")
        || body.starts_with("pub type ")
}

pub fn is_test_path(rel: &str) -> bool {
    rel.contains("/tests/")
        || rel.contains("\\tests\\")
        || rel.ends_with("_test.rs")
        || rel.ends_with("-test.rs")
}

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

    fn d(loc: u64, files: u64, crates: &[&str]) -> DiffSummary {
        DiffSummary {
            loc_changed: loc,
            files_changed: files,
            crates_touched: crates.iter().map(|s| (*s).to_string()).collect(),
            touches_architecture: false,
            touches_public_api: false,
            all_tests: false,
        }
    }

    #[test]
    fn small_fits_under_thresholds() {
        let t = Thresholds::default();
        assert_eq!(classify(&d(10, 2, &["a"]), &t), Size::Small);
    }

    #[test]
    fn medium_when_single_crate_and_under_200() {
        let t = Thresholds::default();
        assert_eq!(classify(&d(50, 5, &["a"]), &t), Size::Medium);
    }

    #[test]
    fn large_when_loc_exceeds_medium_max() {
        let t = Thresholds::default();
        assert_eq!(classify(&d(200, 5, &["a"]), &t), Size::Large);
    }

    #[test]
    fn cross_crate_promotes_to_large() {
        let t = Thresholds::default();
        assert_eq!(classify(&d(30, 4, &["a", "b"]), &t), Size::Large);
    }

    #[test]
    fn cross_crate_test_only_stays_small() {
        let t = Thresholds::default();
        let mut diff = d(10, 2, &["a", "b"]);
        diff.all_tests = true;
        assert_eq!(classify(&diff, &t), Size::Small);
    }

    #[test]
    fn public_api_change_lifts_to_large() {
        let t = Thresholds::default();
        let mut diff = d(5, 1, &["a"]);
        diff.touches_public_api = true;
        assert_eq!(classify(&diff, &t), Size::Large);
    }

    #[test]
    fn architectural_marker_overrides_everything() {
        let t = Thresholds::default();
        let mut diff = d(2, 1, &[]);
        diff.touches_architecture = true;
        assert_eq!(classify(&diff, &t), Size::Architectural);
    }

    #[test]
    fn architectural_path_detection() {
        assert!(is_architectural_path(".koala/architecture.toml"));
        assert!(is_architectural_path("wiki/decisions/0001-foo.md"));
        assert!(is_architectural_path("wiki/architecture.md"));
        assert!(!is_architectural_path("wiki/decisions/_index.md"));
        assert!(!is_architectural_path("wiki/features/x.md"));
    }

    #[test]
    fn public_api_line_detection() {
        assert!(is_public_api_change_line("+pub fn foo() {}"));
        assert!(is_public_api_change_line("-pub struct Bar;"));
        assert!(!is_public_api_change_line("+fn private() {}"));
        assert!(!is_public_api_change_line("+    let x = 1;"));
    }
}
