//! Parser for jj change-ids embedded in git commit headers.
//!
//! jj ≥ 0.30 writes a `change-id` header into every commit it creates, which
//! survives amends and rebases. This module extracts and validates those IDs
//! from raw git commit objects.

use std::process::Command;

use tracing::info;

/// A valid jj change-id: exactly 32 lowercase reverse-hex characters (z–k).
///
/// The reverse-hex alphabet uses letters z, y, x, w, v, u, t, s, r, q, p, o,
/// n, m, l, k — 16 characters total, representing values 0–15 in reverse order.
const CHANGE_ID_LEN: usize = 32;

/// Validates whether a string is a well-formed jj change-id.
///
/// A valid change-id is exactly 32 characters long and uses only the
/// reverse-hex alphabet: z, y, x, w, v, u, t, s, r, q, p, o, n, m, l, k (16
/// letters from 'k' to 'z' inclusive).
fn is_valid_change_id(s: &str) -> bool {
    if s.len() != CHANGE_ID_LEN {
        return false;
    }
    s.chars().all(|c| matches!(c, 'k'..='z'))
}

/// Parses the raw commit headers (everything before the first blank line) and
/// returns the change-id if present and valid.
///
/// The headers are expected in the format git uses: one header per line, with
/// a blank line separating headers from the commit message.
fn parse_change_id_from_headers(headers: &str) -> Option<String> {
    for line in headers.lines() {
        if let Some(value) = line.strip_prefix("change-id ") {
            let value = value.trim();
            if is_valid_change_id(value) {
                return Some(value.to_string());
            }
            info!(
                change_id = value,
                "change-id header present but malformed; treating as absent"
            );
            return None;
        }
    }
    None
}

/// Reads the raw commit object for the given OID and extracts the change-id,
/// if one is present and valid.
///
/// Returns `None` if the commit has no change-id header, if the header is
/// malformed, or if the object is not a commit.
#[tracing::instrument(level = "debug", skip(bare), fields(bare = %bare.display()))]
pub async fn parse_change_id(bare: &std::path::Path, oid: &str) -> Option<String> {
    let output = tokio::process::Command::new("git")
        .arg("-C")
        .arg(bare)
        .arg("cat-file")
        .arg("commit")
        .arg(oid)
        .output()
        .await
        .ok()?;

    if !output.status.success() {
        info!(oid, "git cat-file commit failed; no change-id extracted");
        return None;
    }

    let raw = String::from_utf8_lossy(&output.stdout);

    // Split at the first blank line: everything before it is headers.
    let headers = raw.split("\n\n").next()?;

    let change_id = parse_change_id_from_headers(headers);

    if let Some(ref cid) = change_id {
        info!(%cid, oid, "extracted change-id from commit");
    }

    change_id
}

/// Synchronous version for use in non-async contexts (e.g., tests).
pub fn parse_change_id_sync(bare: &std::path::Path, oid: &str) -> Option<String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(bare)
        .arg("cat-file")
        .arg("commit")
        .arg(oid)
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let raw = String::from_utf8_lossy(&output.stdout);
    let headers = raw.split("\n\n").next()?;

    parse_change_id_from_headers(headers)
}

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

    #[test]
    fn valid_change_id_is_accepted() {
        assert!(is_valid_change_id("mwyosuvoswmkponquzmrkrtkyqqwsorp"));
        assert!(is_valid_change_id("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"));
        assert!(is_valid_change_id("kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk"));
    }

    #[test]
    fn wrong_length_is_rejected() {
        assert!(!is_valid_change_id("mwyosuvoswmkponquzmrkrtkyqqwsor")); // 31 chars
        assert!(!is_valid_change_id("mwyosuvoswmkponquzmrkrtkyqqwsorpp")); // 33 chars
        assert!(!is_valid_change_id(""));
    }

    #[test]
    fn invalid_characters_are_rejected() {
        // Contains 'a' which is not in reverse-hex alphabet
        assert!(!is_valid_change_id("mwyosuvoswmkponquzmrkrtkyqqwsora"));
        // Contains uppercase
        assert!(!is_valid_change_id("MWYOSUVOSWMKPONQUZMRKRTKYQQWSORP"));
        // Contains digit
        assert!(!is_valid_change_id("mwyosuvoswmkponquzmrkrtkyqqwsor1"));
    }

    #[test]
    fn parses_present_change_id() {
        let headers = "tree abc123\n\
                       parent def456\n\
                       author Test <test@example.com> 1234567890 +0000\n\
                       committer Test <test@example.com> 1234567890 +0000\n\
                       change-id mwyosuvoswmkponquzmrkrtkyqqwsorp\n";

        assert_eq!(
            parse_change_id_from_headers(headers),
            Some("mwyosuvoswmkponquzmrkrtkyqqwsorp".to_string())
        );
    }

    #[test]
    fn absent_change_id_returns_none() {
        let headers = "tree abc123\n\
                       parent def456\n\
                       author Test <test@example.com> 1234567890 +0000\n\
                       committer Test <test@example.com> 1234567890 +0000\n";

        assert_eq!(parse_change_id_from_headers(headers), None);
    }

    #[test]
    fn malformed_change_id_returns_none() {
        let headers = "tree abc123\n\
                       change-id tooshort\n";

        assert_eq!(parse_change_id_from_headers(headers), None);
    }

    #[test]
    fn change_id_with_invalid_chars_returns_none() {
        let headers = "tree abc123\n\
                       change-id M.WYOSUVOSWMKPONQUZMRKRTKYQQWSORP\n";

        assert_eq!(parse_change_id_from_headers(headers), None);
    }
}
