//! MD021: Multiple spaces inside hashes on closed ATX heading
//!
//! This rule checks for multiple spaces inside hash characters on closed ATX style headings.
//! Only one space should be used between the content and the closing hashes.

use mdbook_lint_core::error::Result;
use mdbook_lint_core::rule::{Rule, RuleCategory, RuleMetadata};
use mdbook_lint_core::{
    Document,
    violation::{Fix, Position, Severity, Violation},
};

/// Rule to check for multiple spaces inside hashes on closed ATX style headings
pub struct MD021;

impl Rule for MD021 {
    fn id(&self) -> &'static str {
        "MD021"
    }

    fn name(&self) -> &'static str {
        "no-multiple-space-closed-atx"
    }

    fn description(&self) -> &'static str {
        "Multiple spaces inside hashes on closed atx style heading"
    }

    fn metadata(&self) -> RuleMetadata {
        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
    }

    fn can_fix(&self) -> bool {
        true
    }

    fn check_with_ast<'a>(
        &self,
        document: &Document,
        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
    ) -> Result<Vec<Violation>> {
        let mut violations = Vec::new();

        for (line_number, line) in document.lines.iter().enumerate() {
            let line_num = line_number + 1; // Convert to 1-based line numbers

            // Check if this is an ATX-style heading (starts with #)
            // Skip shebang lines (#!/...)
            let trimmed = line.trim_start();
            if trimmed.starts_with('#') && !trimmed.starts_with("#!") {
                // Check if this is a closed ATX heading (ends with #)
                if trimmed.ends_with('#') {
                    let opening_hash_count = trimmed.chars().take_while(|&c| c == '#').count();
                    let closing_hash_count =
                        trimmed.chars().rev().take_while(|&c| c == '#').count();

                    // Extract the content between opening and closing hashes
                    if trimmed.len() > opening_hash_count + closing_hash_count {
                        let content_with_spaces =
                            &trimmed[opening_hash_count..trimmed.len() - closing_hash_count];

                        // Check for multiple whitespace at the beginning
                        let leading_whitespace_count = content_with_spaces
                            .chars()
                            .take_while(|c| c.is_whitespace())
                            .count();

                        // Check for multiple whitespace at the end
                        let trailing_whitespace_count = content_with_spaces
                            .chars()
                            .rev()
                            .take_while(|c| c.is_whitespace())
                            .count();

                        if leading_whitespace_count > 1 || trailing_whitespace_count > 1 {
                            // Create fixed line with single spaces
                            let indent = &line[..line.len() - trimmed.len()];
                            let opening_hashes = &trimmed[..opening_hash_count];
                            let closing_hashes = &trimmed[trimmed.len() - closing_hash_count..];
                            let content = content_with_spaces.trim();
                            let fixed_line = format!(
                                "{}{} {} {}\n",
                                indent, opening_hashes, content, closing_hashes
                            );

                            let fix = Fix {
                                description:
                                    "Replace multiple spaces with single spaces inside hashes"
                                        .to_string(),
                                replacement: Some(fixed_line),
                                start: Position {
                                    line: line_num,
                                    column: 1,
                                },
                                end: Position {
                                    line: line_num,
                                    column: line.len() + 1,
                                },
                            };

                            if leading_whitespace_count > 1 {
                                violations.push(self.create_violation_with_fix(
                                    format!("Multiple spaces after opening hashes in closed ATX heading: found {leading_whitespace_count} whitespace characters, expected 1"),
                                    line_num,
                                    opening_hash_count + 1,
                                    Severity::Warning,
                                    fix.clone(),
                                ));
                            }

                            if trailing_whitespace_count > 1 {
                                violations.push(self.create_violation_with_fix(
                                    format!("Multiple spaces before closing hashes in closed ATX heading: found {trailing_whitespace_count} whitespace characters, expected 1"),
                                    line_num,
                                    trimmed.len() - closing_hash_count - trailing_whitespace_count + 1,
                                    Severity::Warning,
                                    fix,
                                ));
                            }
                        }
                    }
                }
            }
        }

        Ok(violations)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mdbook_lint_core::Document;
    use mdbook_lint_core::rule::Rule;
    use std::path::PathBuf;

    #[test]
    fn test_md021_no_violations() {
        let content = r#"# Open ATX heading (not checked)

## Another open heading

# Single space inside #

## Single space here ##

### Valid closed heading ###

#### Multiple words single space ####

##### Another valid closed heading #####

###### Level 6 valid ######

Regular paragraph text.

Not a heading: # this has text before it #

Also not a heading:
# this is indented #

Shebang line should be ignored:
#!/bin/bash
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 0);
    }

    #[test]
    fn test_md021_multiple_spaces_at_beginning() {
        let content = r#"# Open heading is fine

##  Two spaces after opening ##

###   Three spaces after opening ###

####    Four spaces after opening ####

Regular text.
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 3);
        assert!(
            violations[0]
                .message
                .contains("found 2 whitespace characters, expected 1")
        );
        assert!(
            violations[1]
                .message
                .contains("found 3 whitespace characters, expected 1")
        );
        assert!(
            violations[2]
                .message
                .contains("found 4 whitespace characters, expected 1")
        );
        assert_eq!(violations[0].line, 3);
        assert_eq!(violations[1].line, 5);
        assert_eq!(violations[2].line, 7);
    }

    #[test]
    fn test_md021_multiple_spaces_at_end() {
        let content = r#"# Open heading is fine

## Content with two spaces  ##

### Content with three spaces   ###

#### Content with four spaces    ####

Regular text.
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 3);
        assert!(
            violations[0]
                .message
                .contains("found 2 whitespace characters, expected 1")
        );
        assert!(
            violations[1]
                .message
                .contains("found 3 whitespace characters, expected 1")
        );
        assert!(
            violations[2]
                .message
                .contains("found 4 whitespace characters, expected 1")
        );
        assert_eq!(violations[0].line, 3);
        assert_eq!(violations[1].line, 5);
        assert_eq!(violations[2].line, 7);
    }

    #[test]
    fn test_md021_multiple_spaces_both_sides() {
        let content = r#"# Open heading is fine

##  Two spaces both sides  ##

###   Three spaces both sides   ###

####    Four spaces both sides    ####

Regular text.
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        // Should detect violations on both sides
        assert_eq!(violations.len(), 6);
        // Each heading should generate 2 violations (beginning and end)
        assert_eq!(violations[0].line, 3); // Two spaces after opening
        assert_eq!(violations[1].line, 3); // Two spaces before closing
        assert_eq!(violations[2].line, 5); // Three spaces after opening
        assert_eq!(violations[3].line, 5); // Three spaces before closing
        assert_eq!(violations[4].line, 7); // Four spaces after opening
        assert_eq!(violations[5].line, 7); // Four spaces before closing
    }

    #[test]
    fn test_md021_mixed_valid_invalid() {
        let content = r#"# Valid closed heading #

##  Invalid: two spaces after ##

### Valid closed heading ###

####  Invalid: two spaces both sides  ####

##### Valid closed heading #####

######   Invalid: three spaces after ######
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 4);
        assert_eq!(violations[0].line, 3); // Two spaces after opening
        assert_eq!(violations[1].line, 7); // Two spaces after opening
        assert_eq!(violations[2].line, 7); // Two spaces before closing
        assert_eq!(violations[3].line, 11); // Three spaces after opening
    }

    #[test]
    fn test_md021_tabs_and_mixed_whitespace() {
        let content = "#\t\tTwo tabs after opening##\n\n##Content with tab at end\t\t##\n\n###\t Content tab space mix \t###\n";
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        // Should detect multiple whitespace characters (spaces and tabs)
        assert_eq!(violations.len(), 4);
    }

    #[test]
    fn test_md021_empty_closed_heading() {
        let content = r#"# Valid open heading

## ##

### ###

#### ####

Regular text.
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        // Empty closed headings with single space should be valid
        assert_eq!(violations.len(), 0);
    }

    #[test]
    fn test_md021_no_space_inside() {
        let content = r#"# Valid open heading

##No space inside##

###Content###

####Text####

Regular text.
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        // No spaces inside is handled by MD020, not this rule
        assert_eq!(violations.len(), 0);
    }

    #[test]
    fn test_md021_indented_headings() {
        let content = r#"# Valid open heading

    ##  Indented with multiple spaces  ##

Regular text.

  ###   Another indented with multiple spaces   ###
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        // Should detect multiple spaces in indented closed headings
        assert_eq!(violations.len(), 4);
        assert_eq!(violations[0].line, 3); // Two spaces after opening
        assert_eq!(violations[1].line, 3); // Two spaces before closing
        assert_eq!(violations[2].line, 7); // Three spaces after opening
        assert_eq!(violations[3].line, 7); // Three spaces before closing
    }

    #[test]
    fn test_md021_asymmetric_hashes() {
        let content = r#"# Open heading with one hash

##  Content with multiple spaces  ####

###   More content   #####

####    Even more    ######

Regular text.
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        // Should detect multiple spaces regardless of hash count symmetry
        assert_eq!(violations.len(), 6);
    }

    #[test]
    fn test_md021_all_heading_levels() {
        let content = r#"#  Content with multiple spaces  #
##  Content with multiple spaces  ##
###  Content with multiple spaces  ###
####  Content with multiple spaces  ####
#####  Content with multiple spaces  #####
######  Content with multiple spaces  ######
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        // Each heading should generate 2 violations (beginning and end)
        assert_eq!(violations.len(), 12);
        for (i, violation) in violations.iter().enumerate() {
            let line_num = (i / 2) + 1; // Two violations per line
            assert_eq!(violation.line, line_num);
            assert!(
                violation
                    .message
                    .contains("found 2 whitespace characters, expected 1")
            );
        }
    }

    #[test]
    fn test_md021_single_space_valid() {
        let content = r#"# Content with single space #
## Content with single space ##
### Content with single space ###
#### Content with single space ####
##### Content with single space #####
###### Content with single space ######
"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        // Single spaces should be valid
        assert_eq!(violations.len(), 0);
    }

    #[test]
    fn test_md021_fix_multiple_spaces_at_beginning() {
        let content = "##  Multiple spaces at beginning ##\n";
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 1);
        assert!(violations[0].fix.is_some());

        let fix = violations[0].fix.as_ref().unwrap();
        assert_eq!(
            fix.description,
            "Replace multiple spaces with single spaces inside hashes"
        );
        assert_eq!(
            fix.replacement,
            Some("## Multiple spaces at beginning ##\n".to_string())
        );
        assert_eq!(fix.start.line, 1);
        assert_eq!(fix.start.column, 1);
    }

    #[test]
    fn test_md021_fix_multiple_spaces_at_end() {
        let content = "## Content with multiple spaces  ##\n";
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 1);
        assert!(violations[0].fix.is_some());

        let fix = violations[0].fix.as_ref().unwrap();
        assert_eq!(
            fix.replacement,
            Some("## Content with multiple spaces ##\n".to_string())
        );
    }

    #[test]
    fn test_md021_fix_multiple_spaces_both_sides() {
        let content = "##  Multiple spaces on both sides  ##\n";
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        // Should generate two violations, one for each side
        assert_eq!(violations.len(), 2);

        // Both violations should have the same fix
        assert!(violations[0].fix.is_some());
        assert!(violations[1].fix.is_some());

        let fix = violations[0].fix.as_ref().unwrap();
        assert_eq!(
            fix.replacement,
            Some("## Multiple spaces on both sides ##\n".to_string())
        );
    }

    #[test]
    fn test_md021_fix_many_spaces() {
        let content = "#     Five spaces at beginning     #\n";
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 2);
        assert!(violations[0].fix.is_some());

        let fix = violations[0].fix.as_ref().unwrap();
        assert_eq!(
            fix.replacement,
            Some("# Five spaces at beginning #\n".to_string())
        );
    }

    #[test]
    fn test_md021_fix_tabs() {
        let content = "##\t\tTabs at beginning\t\t##\n";
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 2);
        assert!(violations[0].fix.is_some());

        let fix = violations[0].fix.as_ref().unwrap();
        assert_eq!(
            fix.replacement,
            Some("## Tabs at beginning ##\n".to_string())
        );
    }

    #[test]
    fn test_md021_fix_mixed_whitespace() {
        let content = "### \t Mixed spaces and tabs \t ###\n";
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 2);
        assert!(violations[0].fix.is_some());

        let fix = violations[0].fix.as_ref().unwrap();
        assert_eq!(
            fix.replacement,
            Some("### Mixed spaces and tabs ###\n".to_string())
        );
    }

    #[test]
    fn test_md021_fix_preserves_indentation() {
        let content = "    ##  Indented with multiple spaces  ##\n";
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 2);
        assert!(violations[0].fix.is_some());

        let fix = violations[0].fix.as_ref().unwrap();
        assert_eq!(
            fix.replacement,
            Some("    ## Indented with multiple spaces ##\n".to_string())
        );
    }

    #[test]
    fn test_md021_fix_all_levels() {
        let content = r#"#  Level 1  #
##   Level 2   ##
###    Level 3    ###
####     Level 4     ####
#####      Level 5      #####
######       Level 6       ######"#;
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        // Two violations per line (beginning and end)
        assert_eq!(violations.len(), 12);

        // Check first line fix
        let fix = violations[0].fix.as_ref().unwrap();
        assert_eq!(fix.replacement, Some("# Level 1 #\n".to_string()));
    }

    #[test]
    fn test_md021_fix_asymmetric_hashes() {
        let content = "###  Content  ####\n";
        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
        let rule = MD021;
        let violations = rule.check(&document).unwrap();

        assert_eq!(violations.len(), 2);
        assert!(violations[0].fix.is_some());

        let fix = violations[0].fix.as_ref().unwrap();
        // Should preserve the asymmetric hash count but fix spaces
        assert_eq!(fix.replacement, Some("### Content ####\n".to_string()));
    }
}
