use std::{
    borrow::Cow,
    convert::TryFrom,
    fs::File,
    io,
    io::Read,
    path::{Path, PathBuf},
};

use miette::Diagnostic;
use regex::Regex;
use thiserror::Error;

use super::{
    bodies::Bodies, body::Body, comment::Comment, comments::Comments, fragment::Fragment,
    subject::Subject, trailers::Trailers,
};
use crate::{Trailer, scissors::Scissors};

/// A [`Self`], the primary entry point to the library
#[derive(Debug, PartialEq, Eq, Clone, Default)]
pub struct CommitMessage<'a> {
    scissors: Option<Scissors<'a>>,
    ast: Vec<Fragment<'a>>,
    subject: Subject<'a>,
    trailers: Trailers<'a>,
    comments: Comments<'a>,
    bodies: Bodies<'a>,
}

impl<'a> CommitMessage<'a> {
    /// Convert from [`Fragment`] back into a full [`CommitMessage`]
    ///
    /// Get back to a [`CommitMessage`] from an ast, usually after you've been
    /// editing the text.
    ///
    /// # Arguments
    ///
    /// * `fragments` - Vector of Fragment objects representing the abstract syntax tree
    /// * `scissors` - Optional Scissors section to include in the commit message
    ///
    /// # Returns
    ///
    /// A new `CommitMessage` instance constructed from the provided fragments and scissors
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{Bodies, CommitMessage, Subject};
    ///
    /// let message = CommitMessage::from(indoc!(
    ///     "
    ///     Update bashrc to include kubernetes completions
    ///
    ///     This should make it easier to deploy things for the developers.
    ///     Benchmarked with Hyperfine, no noticable performance decrease.
    ///
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
    ///     ;
    ///     ; Auf Branch master
    ///     ;
    ///     ; Initialer Commit
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;    neue Datei:     .bashrc
    ///     ;"
    /// ));
    /// assert_eq!(
    ///     CommitMessage::from_fragments(message.get_ast(), message.get_scissors()),
    ///     message,
    /// )
    /// ```
    #[must_use]
    pub fn from_fragments(fragments: Vec<Fragment<'_>>, scissors: Option<Scissors<'_>>) -> Self {
        let body = fragments
            .into_iter()
            .map(|x| match x {
                Fragment::Body(contents) => String::from(contents),
                Fragment::Comment(contents) => String::from(contents),
            })
            .collect::<Vec<String>>()
            .join("\n");

        let scissors: String = scissors
            .map(|contents| format!("\n{}", String::from(contents)))
            .unwrap_or_default();

        Self::from(format!("{body}{scissors}"))
    }

    /// A helper method to let you insert [`Trailer`]
    ///
    /// # Arguments
    ///
    /// * `trailer` - The trailer to add to the commit message
    ///
    /// # Returns
    ///
    /// A new `CommitMessage` with the trailer added in the appropriate location
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{CommitMessage, Trailer};
    /// let commit = CommitMessage::from(indoc!(
    ///     "
    ///     Example Commit Message
    ///
    ///     This is an example commit message for linting
    ///
    ///     Relates-to: #153
    ///
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Auf Branch main
    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;    neue Datei:     file
    ///     ;
    ///     "
    /// ));
    ///
    /// assert_eq!(
    ///     String::from(commit.add_trailer(Trailer::new(
    ///         "Co-authored-by".into(),
    ///         "Test Trailer <test@example.com>".into()
    ///     ))),
    ///     String::from(CommitMessage::from(indoc!(
    ///         "
    ///         Example Commit Message
    ///
    ///         This is an example commit message for linting
    ///
    ///         Relates-to: #153
    ///         Co-authored-by: Test Trailer <test@example.com>
    ///
    ///         ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///         ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///         ; bricht den Commit ab.
    ///         ;
    ///         ; Auf Branch main
    ///         ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
    ///         ;
    ///         ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///         ;    neue Datei:     file
    ///         ;
    ///         "
    ///     )))
    /// );
    /// ```
    #[must_use]
    pub fn add_trailer(&self, trailer: Trailer<'_>) -> Self {
        // Preallocate with capacity to avoid reallocations
        let mut fragments = Vec::with_capacity(2);

        // Only add an empty body if we have no bodies or all bodies are empty,
        // and we have no trailers
        let needs_empty_body = self.bodies.iter().all(Body::is_empty) && self.trailers.is_empty();

        if needs_empty_body {
            fragments.push(Body::default().into());
            fragments.push(Body::default().into());
        } else if self.trailers.is_empty() {
            // Only add a separator if we have non-empty bodies but no trailers
            fragments.push(Body::default().into());
        }

        fragments.push(trailer.into());

        self.insert_after_last_full_body(fragments)
    }

    /// Insert text in the place you're most likely to want it
    ///
    /// In the case you don't have any full [`Body`] in there, it inserts it at
    /// the top of the commit, in the [`Subject`] line.
    ///
    /// # Arguments
    ///
    /// * `fragment` - Vector of Fragment objects to insert after the last non-empty body
    ///
    /// # Returns
    ///
    /// A new `CommitMessage` with the fragments inserted after the last non-empty body
    ///
    /// # Examples
    ///
    /// ```
    /// use mit_commit::{Fragment, Body, CommitMessage, Comment};
    ///
    ///         let ast: Vec<Fragment> = vec![
    ///             Fragment::Body(Body::from("Add file")),
    ///             Fragment::Body(Body::default()),
    ///             Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
    ///             Fragment::Body(Body::default()),
    ///             Fragment::Body(Body::from("This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.")),
    ///             Fragment::Body(Body::default()),
    ///             Fragment::Body(Body::from("Relates-to: #128")),
    ///             Fragment::Body(Body::default()),
    ///             Fragment::Comment(Comment::from("# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here")),
    ///             Fragment::Body(Body::default()),
    ///             Fragment::Comment(Comment::from("# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#"))
    ///         ];
    ///         let commit = CommitMessage::from_fragments(ast, None);
    ///
    ///         assert_eq!(commit.insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))]).get_ast(), vec![
    ///             Fragment::Body(Body::from("Add file")),
    ///             Fragment::Body(Body::default()),
    ///             Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
    ///             Fragment::Body(Body::default()),
    ///             Fragment::Body(Body::from("This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.")),
    ///             Fragment::Body(Body::default()),
    ///             Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
    ///             Fragment::Body(Body::default()),
    ///             Fragment::Comment(Comment::from("# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here")),
    ///             Fragment::Body(Body::default()),
    ///             Fragment::Comment(Comment::from("# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#"))
    ///         ])
    /// ```
    #[must_use]
    pub fn insert_after_last_full_body(&self, fragment: Vec<Fragment<'_>>) -> Self {
        let position = self.ast.iter().rposition(|fragment| match fragment {
            Fragment::Body(body) => !body.is_empty(),
            Fragment::Comment(_) => false,
        });

        // Preallocate with capacity to avoid reallocations
        let mut new_ast = Vec::with_capacity(self.ast.len() + fragment.len());

        if let Some(position) = position {
            // Copy elements up to and including the position
            new_ast.extend_from_slice(&self.ast[..=position]);
            // Add the new fragments
            new_ast.extend(fragment);
            // Add the remaining elements
            if position + 1 < self.ast.len() {
                new_ast.extend_from_slice(&self.ast[position + 1..]);
            }
        } else {
            // If no non-empty body found, add fragments at the beginning
            new_ast.extend(fragment);
            new_ast.extend_from_slice(&self.ast);
        }

        Self::from_fragments(new_ast, self.get_scissors())
    }

    fn convert_to_per_line_ast(comment_character: Option<char>, rest: &str) -> Vec<Fragment<'a>> {
        rest.lines()
            .map(|line| {
                comment_character.map_or_else(
                    || Body::from(line.to_string()).into(),
                    |comment_character| {
                        if line.starts_with(comment_character) {
                            Comment::from(line.to_string()).into()
                        } else {
                            Body::from(line.to_string()).into()
                        }
                    },
                )
            })
            .collect()
    }

    /// Group consecutive fragments of the same type
    fn group_ast(ungrouped_ast: Vec<Fragment<'a>>) -> Vec<Fragment<'a>> {
        // Using a more functional approach with fold
        ungrouped_ast
            .into_iter()
            .fold(Vec::new(), |mut acc, fragment| {
                match (acc.last_mut(), fragment) {
                    // Consecutive Comment fragments
                    (Some(Fragment::Comment(existing)), Fragment::Comment(new)) => {
                        *existing = existing.append(&new);
                    }

                    // Consecutive Body fragments
                    (Some(Fragment::Body(existing)), Fragment::Body(new)) => {
                        if new.is_empty() || existing.is_empty() {
                            acc.push(Fragment::from(new));
                        } else {
                            *existing = existing.append(&new);
                        }
                    }

                    // First fragment or different fragment types
                    (_, fragment) => {
                        acc.push(fragment);
                    }
                }

                acc
            })
    }

    /// Get the [`Subject`] line from the [`CommitMessage`]
    ///
    /// It's possible to get this from the ast, but it's a bit of a faff, so
    /// this is a convenience method
    ///
    /// # Returns
    ///
    /// The Subject of the commit message
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{Bodies, CommitMessage, Subject};
    ///
    /// let message = CommitMessage::from(indoc!(
    ///     "
    ///     Update bashrc to include kubernetes completions
    ///
    ///     This should make it easier to deploy things for the developers.
    ///     Benchmarked with Hyperfine, no noticable performance decrease.
    ///
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
    ///     ;
    ///     ; Auf Branch master
    ///     ;
    ///     ; Initialer Commit
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;    neue Datei:     .bashrc
    ///     ;"
    /// ));
    /// assert_eq!(
    ///     message.get_subject(),
    ///     Subject::from("Update bashrc to include kubernetes completions")
    /// )
    /// ```
    #[must_use]
    pub fn get_subject(&self) -> Subject<'a> {
        self.subject.clone()
    }

    /// Get the underlying data structure that represents the [`CommitMessage`]
    ///
    /// This is the underlying datastructure for the [`CommitMessage`]. You
    /// might want this to create a complicated linter, or modify the
    /// [`CommitMessage`] to your liking.
    ///
    /// Notice how it doesn't include the [`Scissors`] section.
    ///
    /// # Returns
    ///
    /// A vector of Fragment objects representing the abstract syntax tree of the commit message
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{Body, CommitMessage, Fragment, Trailer, Trailers, Comment};
    ///
    /// let message = CommitMessage::from(indoc!(
    ///     "
    ///     Add file
    ///
    ///     Looks-like-a-trailer: But isn't
    ///
    ///     This adds file primarily for demonstration purposes. It might not be
    ///     useful as an actual commit, but it's very useful as a example to use in
    ///     tests.
    ///
    ///     Relates-to: #128
    ///     Relates-to: #129
    ///
    ///     ; Short (50 chars or less) summary of changes
    ///     ;
    ///     ; More detailed explanatory text, if necessary.  Wrap it to
    ///     ; about 72 characters or so.  In some contexts, the first
    ///     ; line is treated as the subject of an email and the rest of
    ///     ; the text as the body.  The blank line separating the
    ///     ; summary from the body is critical (unless you omit the body
    ///     ; entirely); tools like rebase can get confused if you run
    ///     ; the two together.
    ///     ;
    ///     ; Further paragraphs come after blank lines.
    ///     ;
    ///     ;   - Bullet points are okay, too
    ///     ;
    ///     ;   - Typically a hyphen or asterisk is used for the bullet,
    ///     ;     preceded by a single space, with blank lines in
    ///     ;     between, but conventions vary here
    ///
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Auf Branch main
    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;   neue Datei:     file
    ///     ;
    ///     ; ------------------------ >8 ------------------------
    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
    ///     ; Alles unterhalb von ihr wird ignoriert.
    ///     diff --git a/file b/file
    ///     new file mode 100644
    ///     index 0000000..e69de29
    ///     "
    /// ));
    /// let ast = vec![
    ///     Fragment::Body(Body::from("Add file")),
    ///     Fragment::Body(Body::default()),
    ///     Fragment::Body(Body::from("Looks-like-a-trailer: But isn't")),
    ///     Fragment::Body(Body::default()),
    ///     Fragment::Body(Body::from("This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.")),
    ///     Fragment::Body(Body::default()),
    ///     Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #129")),
    ///     Fragment::Body(Body::default()),
    ///     Fragment::Comment(Comment::from("; Short (50 chars or less) summary of changes\n;\n; More detailed explanatory text, if necessary.  Wrap it to\n; about 72 characters or so.  In some contexts, the first\n; line is treated as the subject of an email and the rest of\n; the text as the body.  The blank line separating the\n; summary from the body is critical (unless you omit the body\n; entirely); tools like rebase can get confused if you run\n; the two together.\n;\n; Further paragraphs come after blank lines.\n;\n;   - Bullet points are okay, too\n;\n;   - Typically a hyphen or asterisk is used for the bullet,\n;     preceded by a single space, with blank lines in\n;     between, but conventions vary here")),
    ///     Fragment::Body(Body::default()),
    ///     Fragment::Comment(Comment::from("; Bitte geben Sie eine Commit-Beschreibung für Ihre änderungen ein. Zeilen,\n; die mit \';\' beginnen, werden ignoriert, und eine leere Beschreibung\n; bricht den Commit ab.\n;\n; Auf Branch main\n; Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n;\n; Zum Commit vorgemerkte änderungen:\n;   neue Datei:     file\n;"))
    /// ];
    /// assert_eq!(message.get_ast(), ast)
    /// ```
    #[must_use]
    pub fn get_ast(&self) -> Vec<Fragment<'_>> {
        self.ast.clone()
    }

    /// Get the `Bodies` from the [`CommitMessage`]
    ///
    /// This gets the [`Bodies`] from the [`CommitMessage`] in easy to use
    /// paragraphs, we add in blank bodies because starting a new paragraph
    /// is a visual delimiter so we want to make that easy to detect.
    ///
    /// It doesn't include the [`Subject`] line, but if there's a blank line
    /// after it (as is recommended by the manual), the [`Bodies`] will
    /// start with a new empty [`Body`].
    ///
    /// # Returns
    ///
    /// The Bodies of the commit message, containing all body paragraphs
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{Bodies, Body, CommitMessage, Subject};
    ///
    /// let message = CommitMessage::from(indoc!(
    ///     "
    ///     Update bashrc to include kubernetes completions
    ///
    ///     This should make it easier to deploy things for the developers.
    ///     Benchmarked with Hyperfine, no noticable performance decrease.
    ///
    ///     I am unsure as to why this wasn't being automatically discovered from Brew.
    ///     I've filed a bug report with them.
    ///
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
    ///     ;
    ///     ; Auf Branch master
    ///     ;
    ///     ; Initialer Commit
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;    neue Datei:     .bashrc
    ///     ;"
    /// ));
    /// let bodies = vec![
    ///     Body::default(),
    ///     Body::from(indoc!(
    ///         "
    ///         This should make it easier to deploy things for the developers.
    ///         Benchmarked with Hyperfine, no noticable performance decrease."
    ///     )),
    ///     Body::default(),
    ///     Body::from(indoc!(
    ///         "
    ///         I am unsure as to why this wasn't being automatically discovered from Brew.
    ///         I've filed a bug report with them."
    ///     )),
    /// ];
    /// assert_eq!(message.get_body(), Bodies::from(bodies))
    /// ```
    #[must_use]
    pub fn get_body(&self) -> Bodies<'_> {
        self.bodies.clone()
    }

    /// Get the [`Comments`] from the [`CommitMessage`]
    ///
    /// This will get you all the comments before the `Scissors` section. The
    /// [`Scissors`] section is the bit that appears when you run `git commit
    /// --verbose`, that contains the diffs.
    ///
    /// If there's [`Comment`] mixed in with the body, it'll return those too,
    /// but not any of the [`Body`] around them.
    ///
    /// # Returns
    ///
    /// The Comments from the commit message, excluding those in the Scissors section
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{Body, Comment, Comments, CommitMessage, Subject};
    ///
    /// let message = CommitMessage::from(indoc!(
    ///     "
    ///     Update bashrc to include kubernetes completions
    ///
    ///     This should make it easier to deploy things for the developers.
    ///     Benchmarked with Hyperfine, no noticable performance decrease.
    ///
    ///     I am unsure as to why this wasn't being automatically discovered from Brew.
    ///     I've filed a bug report with them.
    ///
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
    ///     ;
    ///     ; Auf Branch master
    ///     ;
    ///     ; Initialer Commit
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;    neue Datei:     .bashrc
    ///     ;"
    /// ));
    /// let comments = vec![Comment::from(indoc!(
    ///     "
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
    ///     ;
    ///     ; Auf Branch master
    ///     ;
    ///     ; Initialer Commit
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;    neue Datei:     .bashrc
    ///     ;"
    /// ))];
    /// assert_eq!(message.get_comments(), Comments::from(comments))
    /// ```
    #[must_use]
    pub fn get_comments(&self) -> Comments<'_> {
        self.comments.clone()
    }

    /// Get the [`Scissors`] from the [`CommitMessage`]
    ///
    /// This will get you the [`Scissors`] section. The
    /// [`Scissors`] section is the bit that appears when you run `git commit
    /// --verbose`, that contains the diffs, and is not preserved when you
    /// save the commit.
    ///
    /// # Returns
    ///
    /// An optional Scissors section if present in the commit message, or None if not present
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{Body, CommitMessage, Scissors, Subject};
    ///
    /// let message = CommitMessage::from(indoc!(
    ///     "
    ///     Add file
    ///
    ///     This adds file primarily for demonstration purposes. It might not be
    ///     useful as an actual commit, but it's very useful as a example to use in
    ///     tests.
    ///
    ///     Relates-to: #128
    ///
    ///     ; Short (50 chars or less) summary of changes
    ///     ;
    ///     ; More detailed explanatory text, if necessary.  Wrap it to
    ///     ; about 72 characters or so.  In some contexts, the first
    ///     ; line is treated as the subject of an email and the rest of
    ///     ; the text as the body.  The blank line separating the
    ///     ; summary from the body is critical (unless you omit the body
    ///     ; entirely); tools like rebase can get confused if you run
    ///     ; the two together.
    ///     ;
    ///     ; Further paragraphs come after blank lines.
    ///     ;
    ///     ;   - Bullet points are okay, too
    ///     ;
    ///     ;   - Typically a hyphen or asterisk is used for the bullet,
    ///     ;     preceded by a single space, with blank lines in
    ///     ;     between, but conventions vary here
    ///
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Auf Branch main
    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;   neue Datei:     file
    ///     ;
    ///     ; ------------------------ >8 ------------------------
    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
    ///     ; Alles unterhalb von ihr wird ignoriert.
    ///     diff --git a/file b/file
    ///     new file mode 100644
    ///     index 0000000..e69de29
    ///     "
    /// ));
    /// let scissors = Scissors::from(indoc!(
    ///     "
    ///     ; ------------------------ >8 ------------------------
    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
    ///     ; Alles unterhalb von ihr wird ignoriert.
    ///     diff --git a/file b/file
    ///     new file mode 100644
    ///     index 0000000..e69de29
    ///     "
    /// ));
    /// assert_eq!(message.get_scissors(), Some(scissors))
    /// ```
    #[must_use]
    pub fn get_scissors(&self) -> Option<Scissors<'_>> {
        self.scissors.clone()
    }

    /// Get the [`Trailers`] from the [`CommitMessage`]
    ///
    /// This will get you all the trailers in the commit message. Trailers are
    /// special metadata lines at the end of the commit message, like "Signed-off-by:"
    /// or "Relates-to:".
    ///
    /// # Returns
    ///
    /// The Trailers found in the commit message
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{Body, CommitMessage, Trailer, Trailers};
    ///
    /// let message = CommitMessage::from(indoc!(
    ///     "
    ///     Add file
    ///
    ///     Looks-like-a-trailer: But isn't
    ///
    ///     This adds file primarily for demonstration purposes. It might not be
    ///     useful as an actual commit, but it's very useful as a example to use in
    ///     tests.
    ///
    ///     Relates-to: #128
    ///     Relates-to: #129
    ///
    ///     ; Short (50 chars or less) summary of changes
    ///     ;
    ///     ; More detailed explanatory text, if necessary.  Wrap it to
    ///     ; about 72 characters or so.  In some contexts, the first
    ///     ; line is treated as the subject of an email and the rest of
    ///     ; the text as the body.  The blank line separating the
    ///     ; summary from the body is critical (unless you omit the body
    ///     ; entirely); tools like rebase can get confused if you run
    ///     ; the two together.
    ///     ;
    ///     ; Further paragraphs come after blank lines.
    ///     ;
    ///     ;   - Bullet points are okay, too
    ///     ;
    ///     ;   - Typically a hyphen or asterisk is used for the bullet,
    ///     ;     preceded by a single space, with blank lines in
    ///     ;     between, but conventions vary here
    ///
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Auf Branch main
    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;   neue Datei:     file
    ///     ;
    ///     ; ------------------------ >8 ------------------------
    ///     ; \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
    ///     ; Alles unterhalb von ihr wird ignoriert.
    ///     diff --git a/file b/file
    ///     new file mode 100644
    ///     index 0000000..e69de29
    ///     "
    /// ));
    /// let trailers = vec![
    ///     Trailer::new("Relates-to".into(), "#128".into()),
    ///     Trailer::new("Relates-to".into(), "#129".into()),
    /// ];
    /// assert_eq!(message.get_trailers(), Trailers::from(trailers))
    /// ```
    #[must_use]
    pub fn get_trailers(&self) -> Trailers<'_> {
        self.trailers.clone()
    }

    /// Checks if the [`CommitMessage`] matches a given pattern in the saved portions
    ///
    /// This takes a regex and matches it to the visible portions of the
    /// commits, so it excludes comments, and everything after the scissors.
    ///
    /// # Arguments
    ///
    /// * `re` - The regex pattern to match against the commit message
    ///
    /// # Returns
    ///
    /// `true` if the pattern matches any part of the visible commit message, `false` otherwise
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::CommitMessage;
    /// use regex::Regex;
    ///
    /// let commit = CommitMessage::from(indoc!(
    ///     "
    ///     Example Commit Message
    ///
    ///     This is an example commit message for linting
    ///
    ///
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Auf Branch main
    ///     ; Ihr Branch ist auf demselben Stand wie 'origin/main'.
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;    neue Datei:     file
    ///     ;
    ///     "
    /// ));
    ///
    /// let re = Regex::new("[Bb]itte").unwrap();
    /// assert_eq!(commit.matches_pattern(&re), false);
    ///
    /// let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
    /// assert_eq!(commit.matches_pattern(&re), true);
    ///
    /// let re = Regex::new("[Ee]xample Commit Message").unwrap();
    /// assert_eq!(commit.matches_pattern(&re), true);
    /// ```
    #[must_use]
    pub fn matches_pattern(&self, re: &Regex) -> bool {
        let text = self
            .get_ast()
            .into_iter()
            .filter_map(|fragment| match fragment {
                Fragment::Body(body) => Some(String::from(body)),
                Fragment::Comment(_) => None,
            })
            .collect::<Vec<_>>()
            .join("\n");
        re.is_match(&text)
    }

    fn guess_comment_character(message: &str) -> Option<char> {
        Scissors::guess_comment_character(message)
    }

    /// Give you a new [`CommitMessage`] with the provided subject
    ///
    /// # Arguments
    ///
    /// * `subject` - The new Subject to use for the commit message
    ///
    /// # Returns
    ///
    /// A new `CommitMessage` with the updated subject
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{CommitMessage, Subject};
    /// use regex::Regex;
    ///
    /// let commit = CommitMessage::from(indoc!(
    ///     "
    ///     Example Commit Message
    ///
    ///     This is an example commit message
    ///     "
    /// ));
    ///
    /// assert_eq!(
    ///     commit.with_subject("Subject".into()).get_subject(),
    ///     Subject::from("Subject")
    /// );
    /// ```
    #[must_use]
    pub fn with_subject(self, subject: Subject<'a>) -> Self {
        let mut ast = self.ast;

        if !ast.is_empty() {
            ast.remove(0);
        }
        ast.insert(0, Body::from(subject.to_string()).into());

        let comments = Comments::from(ast.clone());
        let bodies = Bodies::from(ast.clone());
        // Changing the subject (first Body fragment) does not affect which of
        // the remaining bodies are trailers, so we keep the correctly-extracted
        // trailers rather than re-extracting from the grouped AST, which would
        // merge consecutive trailer lines into a single multi-line Body and
        // produce a single bogus trailer.
        let trailers = self.trailers;

        Self {
            scissors: self.scissors,
            ast,
            subject,
            trailers,
            comments,
            bodies,
        }
    }

    /// Give you a new [`CommitMessage`] with the provided body
    ///
    /// # Arguments
    ///
    /// * `contents` - The new body content to use for the commit message
    ///
    /// # Returns
    ///
    /// A new `CommitMessage` with the updated body contents
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{CommitMessage, Subject};
    /// use regex::Regex;
    ///
    /// let commit = CommitMessage::from(indoc!(
    ///     "
    ///     Example Commit Message
    ///
    ///     This is an example commit message
    ///     "
    /// ));
    /// let expected = CommitMessage::from(indoc!(
    ///     "
    ///     Example Commit Message
    ///
    ///     New body"
    /// ));
    ///
    /// assert_eq!(commit.with_body_contents("New body"), expected);
    /// ```
    ///
    /// A note on what we consider the body. The body is what falls after the
    /// gutter. This means the following behaviour might happen
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{CommitMessage, Subject};
    /// use regex::Regex;
    /// let commit = CommitMessage::from(indoc!(
    ///     "
    ///     Example Commit Message
    ///     without gutter"
    /// ));
    /// let expected = CommitMessage::from(indoc!(
    ///     "
    ///     Example Commit Message
    ///     without gutter
    ///
    ///     New body"
    /// ));
    ///
    /// assert_eq!(commit.with_body_contents("New body"), expected);
    /// ```
    #[must_use]
    pub fn with_body_contents(self, contents: &'a str) -> Self {
        let existing_subject: Subject<'a> = self.get_subject();
        let outer_scissors = self.scissors.map(|s| {
            let string: String = s.into();
            Scissors::from(string)
        });
        let body = format!("Unused\n\n{contents}");
        let inner_commit = Self::from(body);

        let inner_scissors = inner_commit.scissors.clone().map(|s| {
            let string: String = s.into();
            Scissors::from(string)
        });

        let mut result = inner_commit.with_subject(existing_subject);
        // Preserve the outer scissors if present, otherwise keep any inner
        // scissors so content containing the scissors marker is not silently
        // dropped.
        result.scissors = outer_scissors.or(inner_scissors);
        result
    }

    /// Get the comment character used in the commit message
    ///
    /// # Returns
    ///
    /// The character used for comments in the commit message, or None if there are no comments
    ///
    /// # Examples
    ///
    /// ```
    /// use mit_commit::{CommitMessage, Subject};
    /// let commit = CommitMessage::from("No comment\n\n# Some Comment");
    ///
    /// assert_eq!(commit.get_comment_char().unwrap(), '#');
    /// ```
    ///
    /// We return none is there is no comments
    ///
    /// ```
    /// use mit_commit::{CommitMessage, Subject};
    /// let commit = CommitMessage::from("No comment");
    ///
    /// assert!(commit.get_comment_char().is_none());
    /// ```
    #[must_use]
    pub fn get_comment_char(&self) -> Option<char> {
        self.comments
            .iter()
            .next()
            .map(|comment| -> String { comment.clone().into() })
            .and_then(|comment| comment.chars().next())
    }
}

fn commit_message_to_string(commit_message: &CommitMessage<'_>) -> String {
    let basic_commit = commit_message
        .get_ast()
        .iter()
        .map(|item| match item {
            Fragment::Body(contents) => String::from(contents.clone()),
            Fragment::Comment(contents) => String::from(contents.clone()),
        })
        .collect::<Vec<_>>()
        .join("\n");

    if let Some(scissors) = commit_message.get_scissors() {
        format!("{basic_commit}\n{}", String::from(scissors))
    } else {
        basic_commit
    }
}

impl From<CommitMessage<'_>> for String {
    fn from(commit_message: CommitMessage<'_>) -> Self {
        commit_message_to_string(&commit_message)
    }
}

impl From<&CommitMessage<'_>> for String {
    fn from(commit_message: &CommitMessage<'_>) -> Self {
        commit_message_to_string(commit_message)
    }
}

/// Parse a commit message using parsers
impl CommitMessage<'_> {
    fn parse_commit_message(message: &str) -> Self {
        // Step 1: Split the message into body and scissors sections
        let (rest, scissors) = Scissors::parse_sections(message);

        // Step 2: Guess the comment character
        let comment_character = Self::guess_comment_character(message);

        // Step 3: Convert the body to a per-line AST
        let per_line_ast = Self::convert_to_per_line_ast(comment_character, &rest);

        // Step 4: Extract trailers before grouping to avoid cloning the entire AST
        let trailers = Trailers::from(per_line_ast.clone());

        // Step 5: Group consecutive fragments of the same type
        let mut ast: Vec<Fragment<'_>> = Self::group_ast(per_line_ast);

        // Step 6: Handle trailing newline case
        // Base this on the body portion (`rest`) rather than the whole `message`:
        // when a scissors section is present, `rest` still ends with the newline
        // that separates the body from the scissors marker (the blank line). Using
        // `rest` preserves that blank line through a round-trip.
        if rest.ends_with('\n') {
            ast.push(Body::default().into());
        }

        // Step 7: Create subject, comments, and bodies from the AST
        // We need to clone here because the From implementations require owned vectors
        let subject = Subject::from(ast.clone());
        let comments = Comments::from(ast.clone());
        let bodies = Bodies::from(ast.clone());

        // Step 8: Create and return the CommitMessage
        Self {
            scissors,
            ast,
            subject,
            trailers,
            comments,
            bodies,
        }
    }
}

impl<'a> From<Cow<'a, str>> for CommitMessage<'a> {
    /// Create a new [`CommitMessage`]
    ///
    /// Create a commit message from a string. It's expected that you'll be
    /// reading this during some sort of Git Hook
    ///
    /// # Examples
    ///
    /// ```
    /// use indoc::indoc;
    /// use mit_commit::{Bodies, CommitMessage, Subject};
    ///
    /// let message = CommitMessage::from(indoc!(
    ///     "
    ///     Update bashrc to include kubernetes completions
    ///
    ///     This should make it easier to deploy things for the developers.
    ///     Benchmarked with Hyperfine, no noticable performance decrease.
    ///
    ///     ; Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
    ///     ; die mit ';' beginnen, werden ignoriert, und eine leere Beschreibung
    ///     ; bricht den Commit ab.
    ///     ;
    ///     ; Datum:            Sat Jun 27 21:40:14 2020 +0200
    ///     ;
    ///     ; Auf Branch master
    ///     ;
    ///     ; Initialer Commit
    ///     ;
    ///     ; Zum Commit vorgemerkte \u{00E4}nderungen:
    ///     ;    neue Datei:     .bashrc
    ///     ;"
    /// ));
    /// assert_eq!(
    ///     message.get_subject(),
    ///     Subject::from("Update bashrc to include kubernetes completions")
    /// )
    /// ```
    ///
    ///  # Comment Character
    ///
    /// We load the comment character for the commit message
    ///
    /// Valid options are in [`crate::comment::LEGAL_CHARACTERS`], these are based on the auto-selection logic in the git codebase's [`adjust_comment_line_char` function](https://github.com/git/git/blob/master/builtin/commit.c#L667-L695).
    ///
    /// This does mean that we aren't making 100% of characters available, which
    /// is technically possible, but given we don't have access to the users git
    /// config this feels like a reasonable compromise, there are a lot of
    /// non-whitespace characters as options otherwise, and we don't want to
    /// confuse a genuine body with a comment
    fn from(message: Cow<'a, str>) -> Self {
        Self::parse_commit_message(&message)
    }
}

impl TryFrom<PathBuf> for CommitMessage<'_> {
    type Error = Error;

    /// Creates a `CommitMessage` from a file path.
    ///
    /// # Arguments
    ///
    /// * `value` - The path to the file containing the commit message
    ///
    /// # Returns
    ///
    /// A `CommitMessage` parsed from the file contents
    ///
    /// # Examples
    ///
    /// ```
    /// use std::path::PathBuf;
    /// use std::convert::TryFrom;
    /// use std::io::Write;
    /// use mit_commit::CommitMessage;
    ///
    /// // Create a temporary file for the example
    /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
    /// write!(temp_file.as_file(), "Example commit message").unwrap();
    ///
    /// // Use the temporary file path
    /// let path = temp_file.path().to_path_buf();
    /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
    /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
    /// ```
    ///
    /// # Errors
    ///
    /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
        let mut file = File::open(value)?;
        let mut buffer = String::new();

        file.read_to_string(&mut buffer)
            .map_err(Error::from)
            .map(move |_| Self::from(buffer))
    }
}

impl<'a> TryFrom<&'a Path> for CommitMessage<'a> {
    type Error = Error;

    /// Creates a `CommitMessage` from a file path reference.
    ///
    /// # Arguments
    ///
    /// * `value` - The path reference to the file containing the commit message
    ///
    /// # Returns
    ///
    /// A `CommitMessage` parsed from the file contents
    ///
    /// # Examples
    ///
    /// ```
    /// use std::path::Path;
    /// use std::convert::TryFrom;
    /// use std::io::Write;
    /// use mit_commit::CommitMessage;
    ///
    /// // Create a temporary file for the example
    /// let mut temp_file = tempfile::NamedTempFile::new().unwrap();
    /// write!(temp_file.as_file(), "Example commit message").unwrap();
    ///
    /// // Use the temporary file path
    /// let path = temp_file.path();
    /// let commit_message = CommitMessage::try_from(path).expect("Failed to read commit message");
    /// assert_eq!(commit_message.get_subject().to_string(), "Example commit message");
    /// ```
    ///
    /// # Errors
    ///
    /// Returns an Error if the file cannot be read or if the file contents cannot be parsed as UTF-8
    fn try_from(value: &'a Path) -> Result<Self, Self::Error> {
        let mut file = File::open(value)?;
        let mut buffer = String::new();

        file.read_to_string(&mut buffer)
            .map_err(Error::from)
            .map(move |_| Self::from(buffer))
    }
}

impl<'a> From<&'a str> for CommitMessage<'a> {
    fn from(message: &'a str) -> Self {
        CommitMessage::from(Cow::from(message))
    }
}

impl From<String> for CommitMessage<'_> {
    fn from(message: String) -> Self {
        Self::from(Cow::from(message))
    }
}

/// Errors on reading commit messages
#[derive(Error, Debug, Diagnostic)]
pub enum Error {
    /// Failed to read a commit message
    #[error("failed to read commit file {0}")]
    #[diagnostic(
        url(docsrs),
        code(mit_commit::commit_message::error::io),
        help("check the file is readable")
    )]
    Io(#[from] io::Error),
}

#[cfg(test)]
mod tests {
    use std::{convert::TryInto, io::Write};

    use indoc::indoc;
    use quickcheck::TestResult;
    use regex::Regex;
    use tempfile::NamedTempFile;

    use super::*;
    use crate::{
        Fragment, bodies::Bodies, body::Body, comment::Comment, scissors::Scissors,
        subject::Subject, trailer::Trailer,
    };

    #[test]
    fn test_default_returns_empty_string() {
        let commit = CommitMessage::default();
        let actual: String = commit.into();

        assert_eq!(
            actual,
            String::new(),
            "Default CommitMessage should convert to an empty string"
        );
    }

    #[test]
    fn test_matches_pattern_returns_correct_results() {
        let commit = CommitMessage::from(indoc!(
                "
                Example Commit Message

                This is an example commit message for linting

                Relates-to: #153
                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
                # bricht den Commit ab.
                #
                # Auf Branch main
                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
                #
                # Zum Commit vorgemerkte \u{00E4}nderungen:
                #	neue Datei:     file
                #
                "
            ));

        let re = Regex::new("[Bb]itte").unwrap();
        assert!(
            !commit.matches_pattern(&re),
            "Pattern should not match in comments"
        );

        let re = Regex::new("f[o\u{00FC}]r linting").unwrap();
        assert!(
            commit.matches_pattern(&re),
            "Pattern should match in body text"
        );

        let re = Regex::new("[Ee]xample Commit Message").unwrap();
        assert!(
            commit.matches_pattern(&re),
            "Pattern should match in subject"
        );

        let re = Regex::new("Relates[- ]to").unwrap();
        assert!(
            commit.matches_pattern(&re),
            "Pattern should match in trailers"
        );
    }

    #[test]
    fn test_parse_message_without_gutter_succeeds() {
        let commit = CommitMessage::from(indoc!(
                "
                Example Commit Message
                This is an example commit message for linting

                This is another line
                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
                # bricht den Commit ab.
                #
                # Auf Branch main
                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
                #
                # Zum Commit vorgemerkte \u{00E4}nderungen:
                #	neue Datei:     file
                #
                "
            ));

        assert_eq!(
            commit.get_subject(),
            Subject::from("Example Commit Message\nThis is an example commit message for linting"),
            "Subject should include both lines when there's no gutter"
        );
        assert_eq!(
            commit.get_body(),
            Bodies::from(vec![Body::default(), Body::from("This is another line")]),
            "Body should contain the line after the empty line"
        );
    }

    #[test]
    fn test_add_trailer_to_normal_commit_appends_correctly() {
        let commit = CommitMessage::from(indoc!(
            "
            Example Commit Message

            This is an example commit message for linting

            Relates-to: #153

            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
            # bricht den Commit ab.
            #
            # Auf Branch main
            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
            #
            # Zum Commit vorgemerkte \u{00E4}nderungen:
            #	neue Datei:     file
            #
            "
        ));

        let expected = CommitMessage::from(indoc!(
            "
            Example Commit Message

            This is an example commit message for linting

            Relates-to: #153
            Co-authored-by: Test Trailer <test@example.com>

            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
            # bricht den Commit ab.
            #
            # Auf Branch main
            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
            #
            # Zum Commit vorgemerkte \u{00E4}nderungen:
            #	neue Datei:     file
            #
            "
        ));

        let actual = commit.add_trailer(Trailer::new(
            "Co-authored-by".into(),
            "Test Trailer <test@example.com>".into(),
        ));

        assert_eq!(
            String::from(actual),
            String::from(expected),
            "Adding a trailer to a commit with existing trailers should append the new trailer after the last trailer"
        );
    }

    #[test]
    fn test_add_trailer_to_conventional_commit_appends_correctly() {
        let commit = CommitMessage::from(indoc!(
            "
            feat: Example Commit Message

            This is an example commit message for linting

            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
            # bricht den Commit ab.
            #
            # Auf Branch main
            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
            #
            # Zum Commit vorgemerkte \u{00E4}nderungen:
            #	neue Datei:     file
            #
            "
        ));

        let expected = CommitMessage::from(indoc!(
            "
            feat: Example Commit Message

            This is an example commit message for linting

            Co-authored-by: Test Trailer <test@example.com>

            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
            # bricht den Commit ab.
            #
            # Auf Branch main
            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
            #
            # Zum Commit vorgemerkte \u{00E4}nderungen:
            #	neue Datei:     file
            #
            "
        ));

        let actual = commit.add_trailer(Trailer::new(
            "Co-authored-by".into(),
            "Test Trailer <test@example.com>".into(),
        ));

        assert_eq!(
            String::from(actual),
            String::from(expected),
            "Adding a trailer to a conventional commit should append the trailer after the body"
        );
    }

    #[test]
    fn test_add_trailer_to_commit_without_trailers_creates_trailer_section() {
        let commit = CommitMessage::from(indoc!(
                "
                Example Commit Message

                This is an example commit message for linting

                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
                # bricht den Commit ab.
                #
                # Auf Branch main
                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
                #
                # Zum Commit vorgemerkte \u{00E4}nderungen:
                #	neue Datei:     file
                #
                "
            ));

        let expected = CommitMessage::from(indoc!(
                "
                Example Commit Message

                This is an example commit message for linting

                Co-authored-by: Test Trailer <test@example.com>

                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
                # bricht den Commit ab.
                #
                # Auf Branch main
                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
                #
                # Zum Commit vorgemerkte \u{00E4}nderungen:
                #	neue Datei:     file
                #
                "
            ));
        assert_eq!(
            String::from(commit.add_trailer(Trailer::new(
                "Co-authored-by".into(),
                "Test Trailer <test@example.com>".into(),
            ))),
            String::from(expected),
            "Adding a trailer to a commit without existing trailers should create a new trailer section after the body"
        );
    }

    #[test]
    fn test_add_trailer_to_empty_commit_creates_trailer_section() {
        let commit = CommitMessage::from(indoc!(
                "

                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
                # bricht den Commit ab.
                #
                # Auf Branch main
                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
                #
                # Zum Commit vorgemerkte \u{00E4}nderungen:
                #	neue Datei:     file
                #
                "
            ));

        let expected = CommitMessage::from(indoc!(
                "


                Co-authored-by: Test Trailer <test@example.com>

                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
                # bricht den Commit ab.
                #
                # Auf Branch main
                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
                #
                # Zum Commit vorgemerkte \u{00E4}nderungen:
                #	neue Datei:     file
                #
                "
            ));
        assert_eq!(
            String::from(commit.add_trailer(Trailer::new(
                "Co-authored-by".into(),
                "Test Trailer <test@example.com>".into(),
            ))),
            String::from(expected),
            "Adding a trailer to an empty commit should create a trailer section at the beginning"
        );
    }

    #[test]
    fn test_add_trailer_to_empty_commit_with_trailer_appends_correctly() {
        let commit = CommitMessage::from(indoc!(
                "


                Co-authored-by: Test Trailer <test@example.com>

                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
                # bricht den Commit ab.
                #
                # Auf Branch main
                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
                #
                # Zum Commit vorgemerkte \u{00E4}nderungen:
                #	neue Datei:     file
                #
                "
            ));

        let expected = CommitMessage::from(indoc!(
                "


                Co-authored-by: Test Trailer <test@example.com>
                Co-authored-by: Someone Else <someone@example.com>

                # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
                # bricht den Commit ab.
                #
                # Auf Branch main
                # Ihr Branch ist auf demselben Stand wie 'origin/main'.
                #
                # Zum Commit vorgemerkte \u{00E4}nderungen:
                #	neue Datei:     file
                #
                "
            ));
        assert_eq!(
            String::from(commit.add_trailer(Trailer::new(
                "Co-authored-by".into(),
                "Someone Else <someone@example.com>".into(),
            ))),
            String::from(expected),
            "Adding a trailer to an empty commit with an existing trailer should append the new trailer after the existing one"
        );
    }

    #[test]
    fn test_from_fragments_generates_correct_commit() {
        let message = CommitMessage::from_fragments(
            vec![
                Fragment::Body(Body::from("Example Commit")),
                Fragment::Body(Body::default()),
                Fragment::Body(Body::from("Here is a body")),
                Fragment::Comment(Comment::from("# Example Commit")),
            ],
            Some(Scissors::from(indoc!(
                "
                # ------------------------ >8 ------------------------
                # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
                # Alles unterhalb von ihr wird ignoriert.
                diff --git a/file b/file
                new file mode 100644
                index 0000000..e69de29
                "
            ))),
        );

        assert_eq!(
            String::from(message),
            String::from(indoc!(
                "
                Example Commit

                Here is a body
                # Example Commit
                # ------------------------ >8 ------------------------
                # \u{00E4}ndern oder entfernen Sie nicht die obige Zeile.
                # Alles unterhalb von ihr wird ignoriert.
                diff --git a/file b/file
                new file mode 100644
                index 0000000..e69de29
                "
            )),
            "Creating a CommitMessage from fragments should generate the correct string representation"
        );
    }

    #[test]
    fn test_insert_after_last_body_appends_correctly() {
        let ast: Vec<Fragment<'_>> = vec![
            Fragment::Body(Body::from("Add file")),
            Fragment::Body(Body::default()),
            Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
            Fragment::Body(Body::default()),
            Fragment::Body(Body::from(
                "This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests.",
            )),
            Fragment::Body(Body::default()),
            Fragment::Body(Body::from("Relates-to: #128")),
            Fragment::Body(Body::default()),
            Fragment::Comment(Comment::from(
                "# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here",
            )),
            Fragment::Body(Body::default()),
            Fragment::Comment(Comment::from(
                "# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#",
            )),
        ];
        let commit = CommitMessage::from_fragments(ast, None);

        assert_eq!(
            commit
                .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
                .get_ast(),
            vec![
                Fragment::Body(Body::from("Add file")),
                Fragment::Body(Body::default()),
                Fragment::Body(Body::from("Looks-like-a-trailer: But isn\'t")),
                Fragment::Body(Body::default()),
                Fragment::Body(Body::from(
                    "This adds file primarily for demonstration purposes. It might not be\nuseful as an actual commit, but it\'s very useful as a example to use in\ntests."
                )),
                Fragment::Body(Body::default()),
                Fragment::Body(Body::from("Relates-to: #128\nRelates-to: #656")),
                Fragment::Body(Body::default()),
                Fragment::Comment(Comment::from(
                    "# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here"
                )),
                Fragment::Body(Body::default()),
                Fragment::Comment(Comment::from(
                    "# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#"
                )),
            ],
            "Inserting after the last body should append the new fragment after the last non-empty body fragment"
        );
    }

    #[test]
    fn test_insert_after_last_body_with_no_body_inserts_at_beginning() {
        let ast: Vec<Fragment<'_>> = vec![
            Fragment::Comment(Comment::from(
                "# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here",
            )),
            Fragment::Body(Body::default()),
            Fragment::Comment(Comment::from(
                "# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#",
            )),
        ];
        let commit = CommitMessage::from_fragments(ast, None);

        assert_eq!(
            commit
                .insert_after_last_full_body(vec![Fragment::Body(Body::from("Relates-to: #656"))])
                .get_ast(),
            vec![
                Fragment::Body(Body::from("Relates-to: #656")),
                Fragment::Comment(Comment::from(
                    "# Short (50 chars or less) summary of changes\n#\n# More detailed explanatory text, if necessary.  Wrap it to\n# about 72 characters or so.  In some contexts, the first\n# line is treated as the subject of an email and the rest of\n# the text as the body.  The blank line separating the\n# summary from the body is critical (unless you omit the body\n# entirely); tools like rebase can get confused if you run\n# the two together.\n#\n# Further paragraphs come after blank lines.\n#\n#   - Bullet points are okay, too\n#\n#   - Typically a hyphen or asterisk is used for the bullet,\n#     preceded by a single space, with blank lines in\n#     between, but conventions vary here"
                )),
                Fragment::Body(Body::default()),
                Fragment::Comment(Comment::from(
                    "# Bitte geben Sie eine Commit-Beschreibung f\u{fc}r Ihre \u{e4}nderungen ein. Zeilen,\n# die mit \'#\' beginnen, werden ignoriert, und eine leere Beschreibung\n# bricht den Commit ab.\n#\n# Auf Branch main\n# Ihr Branch ist auf demselben Stand wie \'origin/main\'.\n#\n# Zum Commit vorgemerkte \u{e4}nderungen:\n#\tneue Datei:     file\n#"
                )),
            ],
            "When there is no body, inserting after the last body should insert at the beginning of the AST"
        );
    }

    #[allow(clippy::needless_pass_by_value)]
    #[quickcheck]
    fn test_with_subject_preserves_input_string(input: String) -> bool {
        let commit: CommitMessage<'_> = "Some Subject".into();
        let actual: String = commit
            .with_subject(input.clone().into())
            .get_subject()
            .into();
        // Property: The subject should be exactly the input string after setting it
        actual == input
    }

    #[test]
    fn test_with_subject_preserves_multiple_trailers() {
        // When multiple consecutive trailers exist, the grouped AST merges them
        // into a single Body (e.g. "Relates-to: #1\nRelates-to: #2"). Calling
        // with_subject must NOT collapse those into a single trailer.
        let commit = CommitMessage::from(indoc!(
            "
            feat: add login

            Body text

            Relates-to: #1
            Relates-to: #2
            "
        ));

        assert_eq!(
            commit.get_trailers().len(),
            2,
            "Initial commit should have two trailers"
        );

        let updated = commit.with_subject("fix: add signup".into());
        let trailers: Vec<_> = updated.get_trailers().into_iter().collect();

        assert_eq!(
            trailers.len(),
            2,
            "with_subject should preserve all trailers, got {}: {:?}",
            trailers.len(),
            trailers
        );
        assert_eq!(trailers[0], Trailer::new("Relates-to".into(), "#1".into()),);
        assert_eq!(trailers[1], Trailer::new("Relates-to".into(), "#2".into()),);
    }

    #[test]
    fn test_with_subject_on_default_commit_sets_subject_correctly() {
        let commit = CommitMessage::default().with_subject("Subject".into());
        assert_eq!(
            commit.get_subject(),
            Subject::from("Subject"),
            "Setting subject on default commit should update the subject correctly"
        );
    }

    #[test]
    fn test_with_subject_recomputes_derived_fields_from_new_ast() {
        // A message where the first fragment is a comment, not a body
        let commit = CommitMessage::from("# Comment\nOriginal Subject");

        // The original string contains the comment
        let original_string: String = commit.clone().into();
        assert!(original_string.contains("# Comment"));

        let updated = commit.with_subject("New Subject".into());

        // The new subject should be correct
        assert_eq!(updated.get_subject(), Subject::from("New Subject"));

        // The string representation should not contain the old comment
        let as_string: String = updated.clone().into();
        assert!(
            !as_string.contains("# Comment"),
            "Old comment should not appear in string representation: {as_string}"
        );

        // Verify that get_comments() returns fresh data recomputed from the new AST,
        // not stale data carried over from the old commit message
        assert!(
            updated.get_comments().iter().next().is_none(),
            "Comments should be recomputed from new AST, but got stale data: {:?}",
            updated.get_comments().iter().collect::<Vec<_>>()
        );

        // Verify that get_bodies() returns fresh data recomputed from the new AST.
        // After replacing the comment with a subject body, the old subject body
        // should now appear in bodies.
        assert!(
            updated
                .get_body()
                .into_iter()
                .any(|b| b.to_string() == "Original Subject"),
            "Bodies should be recomputed from new AST, but got stale data"
        );
    }

    #[allow(clippy::needless_pass_by_value)]
    #[quickcheck]
    fn test_with_body_contents_replaces_body_correctly(input: String) -> TestResult {
        if input.contains('\r') {
            return TestResult::discard();
        }

        let commit: CommitMessage<'_> = "Some Subject\n\nSome Body".into();
        let expected: String = format!("Some Subject\n\n{input}");
        let actual: String = commit.with_body_contents(&input).into();
        // Property: The body should be replaced with the input string while preserving the subject
        TestResult::from_bool(actual == expected)
    }

    #[allow(clippy::needless_pass_by_value)]
    #[quickcheck]
    fn test_with_body_contents_preserves_multiline_subject(input: String) -> TestResult {
        if input.contains('\r') {
            return TestResult::discard();
        }

        let commit: CommitMessage<'_> = "Some Subject\nSome More Subject\n\nBody".into();
        let expected: String = format!("Some Subject\nSome More Subject\n\n{input}");
        let actual: String = commit.with_body_contents(&input).into();
        // Property: The body should be replaced with the input string while preserving the multi-line subject
        TestResult::from_bool(actual == expected)
    }

    #[test]
    fn test_with_body_contents_preserves_content_containing_scissors_marker() {
        let input = "some line\n# ------------------------ >8 ------------------------\nmore";
        let commit: CommitMessage<'_> = "Some Subject\n\nSome Body".into();
        let expected: String = format!("Some Subject\n\n{input}");
        let actual: String = commit.with_body_contents(input).into();
        assert_eq!(
            actual, expected,
            "with_body_contents should preserve all content including scissors markers, got: {actual}"
        );
    }

    #[test]
    fn test_get_comment_char_returns_none_when_no_comments() {
        let commit_character = CommitMessage::from("Example Commit Message");
        assert!(
            commit_character.get_comment_char().is_none(),
            "Comment character should be None when there are no comments in the message"
        );
    }

    #[test]
    fn test_try_from_path_buf_reads_file_correctly() {
        let temp_file = NamedTempFile::new().expect("failed to create temp file");
        write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");

        let commit_character: CommitMessage<'_> = temp_file
            .path()
            .to_path_buf()
            .try_into()
            .expect("Could not read commit message");
        assert_eq!(
            commit_character.get_subject().to_string(),
            "Some Subject",
            "Reading from PathBuf should correctly parse the file contents into a CommitMessage"
        );
    }

    #[test]
    fn test_try_from_path_reads_file_correctly() {
        let temp_file = NamedTempFile::new().expect("failed to create temp file");
        write!(temp_file.as_file(), "Some Subject").expect("Failed to write file");

        let commit_character: CommitMessage<'_> = temp_file
            .path()
            .try_into()
            .expect("Could not read commit message");
        assert_eq!(
            commit_character.get_subject().to_string(),
            "Some Subject",
            "Reading from Path should correctly parse the file contents into a CommitMessage"
        );
    }

    #[test]
    fn test_from_reference_produces_same_output_as_from_owned() {
        let commit = CommitMessage::from(indoc!(
            "
            Example Commit Message

            This is an example commit message for linting

            Relates-to: #153

            # Bitte geben Sie eine Commit-Beschreibung f\u{00FC}r Ihre \u{00E4}nderungen ein. Zeilen,
            # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung
            # bricht den Commit ab.
            #
            # Auf Branch main
            # Ihr Branch ist auf demselben Stand wie 'origin/main'.
            #
            # Zum Commit vorgemerkte \u{00E4}nderungen:
            #	neue Datei:     file
            #
            "
        ));

        let from_ref = String::from(&commit);
        let from_owned = String::from(commit.clone());

        assert_eq!(
            from_ref, from_owned,
            "String::from(&commit) should produce the same result as String::from(commit)"
        );
    }

    #[test]
    fn test_from_reference_preserves_original() {
        let commit = CommitMessage::from(indoc!(
            "
            Example Commit Message

            This is an example commit message for linting
            "
        ));

        // Create a string from a reference to the commit
        let _string = String::from(&commit);

        // Verify we can still use the commit after creating a string from it
        assert_eq!(
            commit.get_subject(),
            Subject::from("Example Commit Message"),
            "Original CommitMessage should still be usable after String::from(&commit)"
        );
    }

    #[test]
    fn test_with_body_contents_preserves_scissors() {
        let commit = CommitMessage::from(indoc!(
            "
            Example Commit Message

            This is an example commit message

            # ------------------------ >8 ------------------------
            # Do not modify or remove the line above.
            # Everything below it will be ignored.
            diff --git a/file b/file
            new file mode 100644
            index 0000000..e69de29
            "
        ));

        let updated = commit.with_body_contents("New body content");
        let result: String = updated.into();

        assert!(
            result.contains("# ------------------------ >8 ------------------------"),
            "with_body_contents should preserve scissors section, got: {result}"
        );
        assert!(
            result.contains("New body content"),
            "with_body_contents should contain the new body, got: {result}"
        );
        assert!(
            result.contains("Example Commit Message"),
            "with_body_contents should preserve the subject, got: {result}"
        );
    }

    #[test]
    fn test_from_reference_with_scissors() {
        let commit = CommitMessage::from(indoc!(
            "
            Example Commit Message

            This is an example commit message

            # ------------------------ >8 ------------------------
            # Do not modify or remove the line above.
            # Everything below it will be ignored.
            diff --git a/file b/file
            new file mode 100644
            index 0000000..e69de29
            "
        ));

        let from_ref = String::from(&commit);

        assert!(
            from_ref.contains("# ------------------------ >8 ------------------------"),
            "String created from reference should include scissors section"
        );
        assert!(
            from_ref.contains("diff --git"),
            "String created from reference should include content after scissors"
        );
    }

    #[test]
    fn test_roundtrip_preserves_blank_line_before_scissors() {
        let input = "Subject\n\nBody text\n\n# ------------------------ >8 ------------------------\n# Everything below is ignored.\ndiff --git a/file b/file\n";
        let output: String = CommitMessage::from(input).into();

        assert_eq!(
            output, input,
            "Round-tripping a commit message with a body and scissors should preserve the blank line between the body and the scissors marker"
        );
    }
}
