// Copyright 2024 The Jujutsu Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Parsing trailers from commit messages.

use itertools::Itertools as _;
use thiserror::Error;

/// A key-value pair representing a trailer in a commit message, of the
/// form `Key: Value`.
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Trailer {
    /// trailer key
    pub key: String,
    /// trailer value
    ///
    /// It is trimmed at the start and the end but includes new line characters
    /// (\n) and multi-line escape chars ( ) for multi line values.
    pub value: String,
}

#[expect(missing_docs)]
#[derive(Error, Debug)]
pub enum TrailerParseError {
    #[error("The trailer paragraph can't contain a blank line")]
    BlankLine,
    #[error("Invalid trailer line: {line}")]
    NonTrailerLine { line: String },
}

/// Parse the trailers from a commit message; these are simple key-value
/// pairs, separated by a colon, describing extra information in a commit
/// message; an example is the following:
///
/// ```text
/// chore: update itertools to version 0.14.0
///
/// Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
/// tempor incididunt ut labore et dolore magna aliqua.
///
/// Co-authored-by: Alice <alice@example.com>
/// Co-authored-by: Bob <bob@example.com>
/// Reviewed-by: Charlie <charlie@example.com>
/// Change-Id: I1234567890abcdef1234567890abcdef12345678
/// ```
///
/// In this case, there are four trailers: two `Co-authored-by` lines, one
/// `Reviewed-by` line, and one `Change-Id` line.
pub fn parse_description_trailers(body: &str) -> Vec<Trailer> {
    let (trailers, blank, found_git_trailer, non_trailer) = parse_trailers_impl(body);
    if !blank {
        // no blank found, this means there was a single paragraph, so whatever
        // was found can't come from the trailer
        vec![]
    } else if non_trailer.is_some() && !found_git_trailer {
        // at least one non trailer line was found in the trailers paragraph
        // the trailers are considered as trailers only if there is a predefined
        // trailers from git
        vec![]
    } else {
        trailers
    }
}

/// Parse the trailers from a trailer paragraph. This function behaves like
/// `parse_description_trailer`, but will return an error if a blank or
/// non trailer line is found.
pub fn parse_trailers(body: &str) -> Result<Vec<Trailer>, TrailerParseError> {
    let (trailers, blank, _, non_trailer) = parse_trailers_impl(body);
    if blank {
        return Err(TrailerParseError::BlankLine);
    }
    if let Some(line) = non_trailer {
        return Err(TrailerParseError::NonTrailerLine { line });
    }
    Ok(trailers)
}

fn parse_trailers_impl(body: &str) -> (Vec<Trailer>, bool, bool, Option<String>) {
    // a trailer always comes at the end of a message; we can split the message
    // by newline, but we need to immediately reverse the order of the lines
    // to ensure we parse the trailer in an unambiguous manner; this avoids cases
    // where a colon in the body of the message is mistaken for a trailer
    let lines = body.trim_ascii_end().lines().rev();
    let trailer_re =
        regex::Regex::new(r"^([a-zA-Z0-9-]+) *: *(.*)$").expect("Trailer regex should be valid");
    let mut trailers: Vec<Trailer> = Vec::new();
    let mut multiline_value = vec![];
    let mut found_blank = false;
    let mut found_git_trailer = false;
    let mut non_trailer_line = None;
    for line in lines {
        if line.starts_with(' ') {
            multiline_value.push(line);
        } else if let Some(groups) = trailer_re.captures(line) {
            let key = groups[1].to_string();
            multiline_value.push(groups.get(2).unwrap().as_str());
            // trim the end of the multiline value
            // the start is already trimmed with the regex
            multiline_value[0] = multiline_value[0].trim_ascii_end();
            let value = multiline_value.iter().rev().join("\n");
            multiline_value.clear();
            if key == "Signed-off-by" {
                found_git_trailer = true;
            }
            trailers.push(Trailer { key, value });
        } else if line.starts_with("(cherry picked from commit ") {
            found_git_trailer = true;
            non_trailer_line = Some(line.to_owned());
            multiline_value.clear();
        } else if line.trim_ascii().is_empty() {
            // end of the trailer
            found_blank = true;
            break;
        } else {
            // a non trailer in the trailer paragraph
            // the line is ignored, as well as the multiline value that may
            // have previously been accumulated
            multiline_value.clear();
            non_trailer_line = Some(line.to_owned());
        }
    }
    // reverse the insert order, since we parsed the trailer in reverse
    trailers.reverse();
    (trailers, found_blank, found_git_trailer, non_trailer_line)
}

#[cfg(test)]
mod tests {
    use indoc::indoc;
    use pretty_assertions::assert_eq;

    use super::*;

    #[test]
    fn test_simple_trailers() {
        let descriptions = indoc! {r#"
            chore: update itertools to version 0.14.0

            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed
            do eiusmod tempor incididunt ut labore et dolore magna aliqua.

            Co-authored-by: Alice <alice@example.com>
            Co-authored-by: Bob <bob@example.com>
            Reviewed-by: Charlie <charlie@example.com>
            Change-Id: I1234567890abcdef1234567890abcdef12345678
        "#};

        let trailers = parse_description_trailers(descriptions);
        assert_eq!(trailers.len(), 4);

        assert_eq!(trailers[0].key, "Co-authored-by");
        assert_eq!(trailers[0].value, "Alice <alice@example.com>");

        assert_eq!(trailers[1].key, "Co-authored-by");
        assert_eq!(trailers[1].value, "Bob <bob@example.com>");

        assert_eq!(trailers[2].key, "Reviewed-by");
        assert_eq!(trailers[2].value, "Charlie <charlie@example.com>");

        assert_eq!(trailers[3].key, "Change-Id");
        assert_eq!(
            trailers[3].value,
            "I1234567890abcdef1234567890abcdef12345678"
        );
    }

    #[test]
    fn test_trailers_with_colon_in_body() {
        let descriptions = indoc! {r#"
            chore: update itertools to version 0.14.0

            Summary: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
            tempor incididunt ut labore et dolore magna aliqua.

            Change-Id: I1234567890abcdef1234567890abcdef12345678
        "#};

        let trailers = parse_description_trailers(descriptions);

        // should only have Change-Id
        assert_eq!(trailers.len(), 1);
        assert_eq!(trailers[0].key, "Change-Id");
    }

    #[test]
    fn test_multiline_trailer() {
        let description = indoc! {r#"
            chore: update itertools to version 0.14.0

            key: This is a very long value, with spaces and
              newlines in it.
        "#};

        let trailers = parse_description_trailers(description);

        // should only have Change-Id
        assert_eq!(trailers.len(), 1);
        assert_eq!(trailers[0].key, "key");
        assert_eq!(
            trailers[0].value,
            indoc! {r"
                This is a very long value, with spaces and
                  newlines in it."}
        );
    }

    #[test]
    fn test_ignore_line_in_trailer() {
        let description = indoc! {r#"
            chore: update itertools to version 0.14.0

            Signed-off-by: Random J Developer <random@developer.example.org>
            [lucky@maintainer.example.org: struct foo moved from foo.c to foo.h]
            Signed-off-by: Lucky K Maintainer <lucky@maintainer.example.org>
        "#};

        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 2);
    }

    #[test]
    fn test_trailers_with_single_line_description() {
        let description = r#"chore: update itertools to version 0.14.0"#;
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 0);
    }

    #[test]
    fn test_parse_trailers() {
        let trailers_txt = indoc! {r#"
            foo: 1
            bar: 2
        "#};
        let res = parse_trailers(trailers_txt);
        let trailers = res.expect("trailers to be valid");
        assert_eq!(trailers.len(), 2);
        assert_eq!(trailers[0].key, "foo");
        assert_eq!(trailers[0].value, "1");
        assert_eq!(trailers[1].key, "bar");
        assert_eq!(trailers[1].value, "2");
    }

    #[test]
    fn test_blank_line_in_trailers() {
        let trailers = indoc! {r#"
            foo: 1

            foo: 2
        "#};
        let res = parse_trailers(trailers);
        assert!(matches!(res, Err(TrailerParseError::BlankLine)));
    }

    #[test]
    fn test_non_trailer_line_in_trailers() {
        let trailers = indoc! {r#"
            bar
            foo: 1
        "#};
        let res = parse_trailers(trailers);
        assert!(matches!(
            res,
            Err(TrailerParseError::NonTrailerLine { line: _ })
        ));
    }

    #[test]
    fn test_blank_line_after_trailer() {
        let description = indoc! {r#"
            subject

            foo: 1

        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 1);
    }

    #[test]
    fn test_blank_line_inbetween() {
        let description = indoc! {r#"
            subject

            foo: 1

            bar: 2
        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 1);
    }

    #[test]
    fn test_no_blank_line() {
        let description = indoc! {r#"
            subject: whatever
            foo: 1
        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 0);
    }

    #[test]
    fn test_whitespace_before_key() {
        let description = indoc! {r#"
            subject

             foo: 1
        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 0);
    }

    #[test]
    fn test_whitespace_after_key() {
        let description = indoc! {r#"
            subject

            foo : 1
        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 1);
        assert_eq!(trailers[0].key, "foo");
    }

    #[test]
    fn test_whitespace_around_value() {
        let description = indoc! {"
            subject

            foo:  1\x20
        "};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 1);
        assert_eq!(trailers[0].value, "1");
    }

    #[test]
    fn test_whitespace_around_multiline_value() {
        let description = indoc! {"
            subject

            foo:  1\x20
             2\x20
        "};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 1);
        assert_eq!(trailers[0].value, "1 \n 2");
    }

    #[test]
    fn test_whitespace_around_multiliple_trailers() {
        let description = indoc! {"
            subject

            foo:  1\x20
            bar:  2\x20
        "};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 2);
        assert_eq!(trailers[0].value, "1");
        assert_eq!(trailers[1].value, "2");
    }

    #[test]
    fn test_no_whitespace_before_value() {
        let description = indoc! {r#"
            subject

            foo:1
        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 1);
    }

    #[test]
    fn test_empty_value() {
        let description = indoc! {r#"
            subject

            foo:
        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 1);
    }

    #[test]
    fn test_invalid_key() {
        let description = indoc! {r#"
            subject

            f_o_o: bar
        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 0);
    }

    #[test]
    fn test_content_after_trailer() {
        let description = indoc! {r#"
            subject

            foo: bar
            baz
        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 0);
    }

    #[test]
    fn test_invalid_content_after_trailer() {
        let description = indoc! {r#"
            subject

            foo: bar

            baz
        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 0);
    }

    #[test]
    fn test_empty_description() {
        let description = "";
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 0);
    }

    #[test]
    fn test_cherry_pick_trailer() {
        let description = indoc! {r#"
            subject

            some non-trailer text
            foo: bar
            (cherry picked from commit 72bb9f9cf4bbb6bbb11da9cda4499c55c44e87b9)
        "#};
        let trailers = parse_description_trailers(description);
        assert_eq!(trailers.len(), 1);
        assert_eq!(trailers[0].key, "foo");
        assert_eq!(trailers[0].value, "bar");
    }
}
