use regex::Regex;
use serde::{Deserialize, Serialize};

use crate::error::ReleaseError;
use crate::version::BumpLevel;

/// A raw commit as read from git history.
#[derive(Debug, Clone)]
pub struct Commit {
    pub sha: String,
    pub message: String,
}

/// A commit parsed according to the Conventional Commits specification.
#[derive(Debug, Clone, Serialize)]
pub struct ConventionalCommit {
    pub sha: String,
    pub r#type: String,
    pub scope: Option<String>,
    pub description: String,
    pub body: Option<String>,
    pub breaking: bool,
}

/// Describes a recognised commit type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CommitType {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bump: Option<BumpLevel>,
    /// Changelog section heading (e.g. "Features"). None = exclude from changelog.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub section: Option<String>,
    /// Optional regex matched against the full raw commit message as a fallback
    /// when the standard type-prefix pattern doesn't match. Useful for
    /// non-conventional commit formats (e.g. Dependabot, automated tooling).
    /// Named groups `type`, `scope`, `breaking`, `description` are used if present.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pattern: Option<String>,
}

/// Single source of truth for commit type classification.
pub trait CommitClassifier: Send + Sync {
    fn types(&self) -> &[CommitType];

    /// Commit message regex with named groups: type, scope, breaking, description.
    fn pattern(&self) -> &str;

    fn bump_level(&self, type_name: &str, breaking: bool) -> Option<BumpLevel> {
        if breaking {
            return Some(BumpLevel::Major);
        }
        self.types().iter().find(|t| t.name == type_name)?.bump
    }

    fn changelog_section(&self, type_name: &str) -> Option<&str> {
        self.types()
            .iter()
            .find(|t| t.name == type_name)?
            .section
            .as_deref()
    }

    fn is_allowed(&self, type_name: &str) -> bool {
        self.types().iter().any(|t| t.name == type_name)
    }
}

/// Default conventional commits pattern.
/// Named groups: type, scope (optional), breaking (optional `!`), description.
pub const DEFAULT_COMMIT_PATTERN: &str =
    r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)";

pub struct DefaultCommitClassifier {
    types: Vec<CommitType>,
    pattern: String,
}

impl DefaultCommitClassifier {
    pub fn new(types: Vec<CommitType>, pattern: String) -> Self {
        Self { types, pattern }
    }
}

impl Default for DefaultCommitClassifier {
    fn default() -> Self {
        Self::new(default_commit_types(), DEFAULT_COMMIT_PATTERN.into())
    }
}

impl CommitClassifier for DefaultCommitClassifier {
    fn types(&self) -> &[CommitType] {
        &self.types
    }
    fn pattern(&self) -> &str {
        &self.pattern
    }
}

pub fn default_commit_types() -> Vec<CommitType> {
    vec![
        CommitType {
            name: "feat".into(),
            bump: Some(BumpLevel::Minor),
            section: Some("Features".into()),
            pattern: None,
        },
        CommitType {
            name: "fix".into(),
            bump: Some(BumpLevel::Patch),
            section: Some("Bug Fixes".into()),
            pattern: None,
        },
        CommitType {
            name: "perf".into(),
            bump: Some(BumpLevel::Patch),
            section: Some("Performance".into()),
            pattern: None,
        },
        CommitType {
            name: "docs".into(),
            bump: None,
            section: Some("Documentation".into()),
            pattern: None,
        },
        CommitType {
            name: "refactor".into(),
            bump: Some(BumpLevel::Patch),
            section: Some("Refactoring".into()),
            pattern: None,
        },
        CommitType {
            name: "revert".into(),
            bump: None,
            section: Some("Reverts".into()),
            pattern: None,
        },
        CommitType {
            name: "chore".into(),
            bump: None,
            section: None,
            pattern: None,
        },
        CommitType {
            name: "ci".into(),
            bump: None,
            section: None,
            pattern: None,
        },
        CommitType {
            name: "test".into(),
            bump: None,
            section: None,
            pattern: None,
        },
        CommitType {
            name: "build".into(),
            bump: None,
            section: None,
            pattern: None,
        },
        CommitType {
            name: "style".into(),
            bump: None,
            section: None,
            pattern: None,
        },
    ]
}

/// Parses raw commits into conventional commits.
pub trait CommitParser: Send + Sync {
    fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
}

/// Default parser using the built-in `DEFAULT_COMMIT_PATTERN` regex.
pub struct DefaultCommitParser;

impl CommitParser for DefaultCommitParser {
    fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
        let re =
            Regex::new(DEFAULT_COMMIT_PATTERN).map_err(|e| ReleaseError::Config(e.to_string()))?;

        let caps = re.captures(&commit.message).ok_or_else(|| {
            ReleaseError::Config(format!("not a conventional commit: {}", commit.message))
        })?;

        let r#type = caps.name("type").unwrap().as_str().to_string();
        let scope = caps.name("scope").map(|m| m.as_str().to_string());
        let breaking = caps.name("breaking").is_some();
        let description = caps.name("description").unwrap().as_str().to_string();

        let body = commit
            .message
            .split_once("\n\n")
            .map(|x| x.1)
            .map(|b| b.to_string());

        // Detect BREAKING CHANGE / BREAKING-CHANGE footers in the body
        let breaking = breaking
            || body.as_deref().is_some_and(|b| {
                b.lines().any(|line| {
                    let trimmed = line.trim();
                    trimmed.starts_with("BREAKING CHANGE:")
                        || trimmed.starts_with("BREAKING CHANGE ")
                        || trimmed.starts_with("BREAKING-CHANGE:")
                        || trimmed.starts_with("BREAKING-CHANGE ")
                })
            });

        Ok(ConventionalCommit {
            sha: commit.sha.clone(),
            r#type,
            scope,
            description,
            body,
            breaking,
        })
    }
}

/// Parser that tries the standard commit pattern first, then falls back to
/// per-type `pattern` regexes for non-conventional commit formats.
pub struct ConfiguredCommitParser {
    types: Vec<CommitType>,
    commit_pattern: String,
}

impl ConfiguredCommitParser {
    pub fn new(types: Vec<CommitType>, commit_pattern: String) -> Self {
        Self {
            types,
            commit_pattern,
        }
    }
}

impl CommitParser for ConfiguredCommitParser {
    fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
        let re =
            Regex::new(&self.commit_pattern).map_err(|e| ReleaseError::Config(e.to_string()))?;

        let first_line = commit.message.lines().next().unwrap_or("");

        // Try the standard type-prefix pattern first.
        if let Some(caps) = re.captures(first_line) {
            let r#type = caps.name("type").unwrap().as_str().to_string();
            let scope = caps.name("scope").map(|m| m.as_str().to_string());
            let breaking = caps.name("breaking").is_some();
            let description = caps.name("description").unwrap().as_str().to_string();

            let body = commit
                .message
                .split_once("\n\n")
                .map(|x| x.1)
                .map(|b| b.to_string());

            let breaking = breaking
                || body.as_deref().is_some_and(|b| {
                    b.lines().any(|line| {
                        let trimmed = line.trim();
                        trimmed.starts_with("BREAKING CHANGE:")
                            || trimmed.starts_with("BREAKING CHANGE ")
                            || trimmed.starts_with("BREAKING-CHANGE:")
                            || trimmed.starts_with("BREAKING-CHANGE ")
                    })
                });

            return Ok(ConventionalCommit {
                sha: commit.sha.clone(),
                r#type,
                scope,
                description,
                body,
                breaking,
            });
        }

        // Fallback: try per-type pattern regexes.
        for ct in &self.types {
            let Some(ref pat) = ct.pattern else {
                continue;
            };
            let Ok(type_re) = Regex::new(pat) else {
                continue;
            };
            if let Some(caps) = type_re.captures(first_line) {
                let scope = caps.name("scope").map(|m| m.as_str().to_string());
                let breaking = caps.name("breaking").is_some();
                let description = caps
                    .name("description")
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_else(|| first_line.to_string());

                let body = commit
                    .message
                    .split_once("\n\n")
                    .map(|x| x.1)
                    .map(|b| b.to_string());

                return Ok(ConventionalCommit {
                    sha: commit.sha.clone(),
                    r#type: ct.name.clone(),
                    scope,
                    description,
                    body,
                    breaking,
                });
            }
        }

        Err(ReleaseError::Config(format!(
            "not a conventional commit: {}",
            commit.message
        )))
    }
}

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

    fn raw(message: &str) -> Commit {
        Commit {
            sha: "abc1234".into(),
            message: message.into(),
        }
    }

    #[test]
    fn parse_simple_feat() {
        let result = DefaultCommitParser.parse(&raw("feat: add button")).unwrap();
        assert_eq!(result.r#type, "feat");
        assert_eq!(result.description, "add button");
        assert_eq!(result.scope, None);
        assert!(!result.breaking);
    }

    #[test]
    fn parse_scoped_fix() {
        let result = DefaultCommitParser
            .parse(&raw("fix(core): null check"))
            .unwrap();
        assert_eq!(result.r#type, "fix");
        assert_eq!(result.scope.as_deref(), Some("core"));
    }

    #[test]
    fn parse_breaking_bang() {
        let result = DefaultCommitParser.parse(&raw("feat!: new API")).unwrap();
        assert!(result.breaking);
    }

    #[test]
    fn parse_with_body() {
        let result = DefaultCommitParser
            .parse(&raw("fix: x\n\ndetails"))
            .unwrap();
        assert_eq!(result.body.as_deref(), Some("details"));
    }

    #[test]
    fn parse_breaking_change_footer() {
        let result = DefaultCommitParser
            .parse(&raw(
                "feat: new API\n\nBREAKING CHANGE: removed old endpoint",
            ))
            .unwrap();
        assert!(result.breaking);
        assert_eq!(result.r#type, "feat");
    }

    #[test]
    fn parse_breaking_change_hyphenated_footer() {
        let result = DefaultCommitParser
            .parse(&raw("fix: update schema\n\nBREAKING-CHANGE: field renamed"))
            .unwrap();
        assert!(result.breaking);
    }

    #[test]
    fn parse_breaking_change_footer_with_bang() {
        // Both bang and footer — should still be breaking
        let result = DefaultCommitParser
            .parse(&raw(
                "feat!: overhaul\n\nBREAKING CHANGE: everything changed",
            ))
            .unwrap();
        assert!(result.breaking);
    }

    #[test]
    fn parse_no_breaking_change_in_body() {
        // Body text that mentions "BREAKING CHANGE" but not as a footer line
        let result = DefaultCommitParser
            .parse(&raw("fix: tweak\n\nThis is not a BREAKING CHANGE footer"))
            .unwrap();
        assert!(!result.breaking);
    }

    #[test]
    fn parse_invalid_message() {
        let result = DefaultCommitParser.parse(&raw("not conventional"));
        assert!(result.is_err());
    }

    // --- CommitClassifier tests ---

    #[test]
    fn classifier_bump_level_feat() {
        let c = DefaultCommitClassifier::default();
        assert_eq!(c.bump_level("feat", false), Some(BumpLevel::Minor));
    }

    #[test]
    fn classifier_bump_level_fix() {
        let c = DefaultCommitClassifier::default();
        assert_eq!(c.bump_level("fix", false), Some(BumpLevel::Patch));
    }

    #[test]
    fn classifier_bump_level_breaking_overrides() {
        let c = DefaultCommitClassifier::default();
        assert_eq!(c.bump_level("fix", true), Some(BumpLevel::Major));
        assert_eq!(c.bump_level("chore", true), Some(BumpLevel::Major));
    }

    #[test]
    fn classifier_bump_level_no_bump_type() {
        let c = DefaultCommitClassifier::default();
        assert_eq!(c.bump_level("chore", false), None);
        assert_eq!(c.bump_level("docs", false), None);
    }

    #[test]
    fn classifier_bump_level_unknown_type() {
        let c = DefaultCommitClassifier::default();
        assert_eq!(c.bump_level("unknown", false), None);
    }

    #[test]
    fn classifier_changelog_section() {
        let c = DefaultCommitClassifier::default();
        assert_eq!(c.changelog_section("feat"), Some("Features"));
        assert_eq!(c.changelog_section("fix"), Some("Bug Fixes"));
        assert_eq!(c.changelog_section("perf"), Some("Performance"));
        assert_eq!(c.changelog_section("docs"), Some("Documentation"));
        assert_eq!(c.changelog_section("refactor"), Some("Refactoring"));
        assert_eq!(c.changelog_section("revert"), Some("Reverts"));
        assert_eq!(c.changelog_section("chore"), None);
        assert_eq!(c.changelog_section("unknown"), None);
    }

    #[test]
    fn classifier_is_allowed() {
        let c = DefaultCommitClassifier::default();
        assert!(c.is_allowed("feat"));
        assert!(c.is_allowed("chore"));
        assert!(!c.is_allowed("unknown"));
    }

    #[test]
    fn classifier_pattern() {
        let c = DefaultCommitClassifier::default();
        assert_eq!(c.pattern(), DEFAULT_COMMIT_PATTERN);
    }

    #[test]
    fn default_commit_types_count() {
        let types = default_commit_types();
        assert_eq!(types.len(), 11);
    }

    #[test]
    fn commit_type_serialization_roundtrip() {
        let ct = CommitType {
            name: "feat".into(),
            bump: Some(BumpLevel::Minor),
            section: Some("Features".into()),
            pattern: None,
        };
        let yaml = serde_yaml_ng::to_string(&ct).unwrap();
        let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
        assert_eq!(parsed, ct);
    }

    #[test]
    fn commit_type_no_bump_no_section_roundtrip() {
        let ct = CommitType {
            name: "chore".into(),
            bump: None,
            section: None,
            pattern: None,
        };
        let yaml = serde_yaml_ng::to_string(&ct).unwrap();
        assert!(!yaml.contains("bump"));
        assert!(!yaml.contains("section"));
        assert!(!yaml.contains("pattern"));
        let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
        assert_eq!(parsed, ct);
    }

    #[test]
    fn commit_type_with_pattern_roundtrip() {
        let ct = CommitType {
            name: "deps".into(),
            bump: Some(BumpLevel::Patch),
            section: Some("Dependencies".into()),
            pattern: Some(r"^Bump .+ from .+ to .+".into()),
        };
        let yaml = serde_yaml_ng::to_string(&ct).unwrap();
        assert!(yaml.contains("pattern"));
        let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
        assert_eq!(parsed, ct);
    }

    // --- ConfiguredCommitParser tests ---

    fn configured_parser_with_deps() -> ConfiguredCommitParser {
        let mut types = default_commit_types();
        types.push(CommitType {
            name: "deps".into(),
            bump: Some(BumpLevel::Patch),
            section: Some("Dependencies".into()),
            pattern: Some(r"^Bump (?P<description>.+)".into()),
        });
        ConfiguredCommitParser::new(types, DEFAULT_COMMIT_PATTERN.into())
    }

    #[test]
    fn configured_parser_standard_match_preferred() {
        let parser = configured_parser_with_deps();
        let result = parser.parse(&raw("feat: add button")).unwrap();
        assert_eq!(result.r#type, "feat");
        assert_eq!(result.description, "add button");
    }

    #[test]
    fn configured_parser_fallback_match() {
        let parser = configured_parser_with_deps();
        let result = parser
            .parse(&raw("Bump serde from 1.0.0 to 1.1.0"))
            .unwrap();
        assert_eq!(result.r#type, "deps");
        assert_eq!(result.description, "serde from 1.0.0 to 1.1.0");
    }

    #[test]
    fn configured_parser_fallback_no_named_groups() {
        let mut types = default_commit_types();
        types.push(CommitType {
            name: "deps".into(),
            bump: Some(BumpLevel::Patch),
            section: Some("Dependencies".into()),
            pattern: Some(r"^Bump .+ from .+ to .+".into()),
        });
        let parser = ConfiguredCommitParser::new(types, DEFAULT_COMMIT_PATTERN.into());
        let result = parser
            .parse(&raw("Bump serde from 1.0.0 to 1.1.0"))
            .unwrap();
        assert_eq!(result.r#type, "deps");
        // Without named description group, uses full first line
        assert_eq!(result.description, "Bump serde from 1.0.0 to 1.1.0");
    }

    #[test]
    fn configured_parser_no_match() {
        let parser = configured_parser_with_deps();
        let result = parser.parse(&raw("random garbage message"));
        assert!(result.is_err());
    }
}
