use toml_edit::DocumentMut;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatError {
    pub message: String,
}

impl std::fmt::Display for FormatError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for FormatError {}

pub fn format_source(source: &str) -> Result<String, FormatError> {
    let lines = collect_lines(source);
    let mut output = String::new();
    let mut index = 0;

    if lines
        .first()
        .is_some_and(|line| is_plus_delimiter(line.content))
    {
        write_toml_section(source, &lines, &mut index, &mut output)?;
    }

    while index < lines.len() {
        let line = &lines[index];
        if let Some(opener) = parse_opener(line.content) {
            output.push_str(&format!(
                "{} {}\n",
                ":".repeat(opener.fence_length),
                opener.block_type
            ));
            index += 1;

            if lines
                .get(index)
                .is_some_and(|line| is_plus_delimiter(line.content))
            {
                write_toml_section(source, &lines, &mut index, &mut output)?;
            }
            continue;
        }

        if let Some(length) = parse_closer(line.content) {
            output.push_str(&format!("{}\n", ":".repeat(length)));
            index += 1;
            continue;
        }

        if is_plus_delimiter(line.content) {
            output.push_str("+++\n");
            index += 1;
            continue;
        }

        output.push_str(line.raw);
        index += 1;
    }

    Ok(output)
}

pub fn needs_format(source: &str) -> Result<bool, FormatError> {
    Ok(format_source(source)? != source)
}

fn write_toml_section(
    source: &str,
    lines: &[Line<'_>],
    index: &mut usize,
    output: &mut String,
) -> Result<(), FormatError> {
    let open_line = &lines[*index];
    output.push_str("+++\n");
    *index += 1;

    let Some(close_index) = lines
        .iter()
        .enumerate()
        .skip(*index)
        .find_map(|(candidate, line)| is_plus_delimiter(line.content).then_some(candidate))
    else {
        return Err(FormatError {
            message: format!(
                "unterminated TOML metadata section at line {}",
                open_line.number
            ),
        });
    };

    let toml_start = lines
        .get(*index)
        .map(|line| line.start)
        .unwrap_or(open_line.end);
    let toml_end = lines[close_index].start;
    output.push_str(&format_toml(&source[toml_start..toml_end])?);
    output.push_str("+++\n");
    *index = close_index + 1;
    Ok(())
}

fn format_toml(source: &str) -> Result<String, FormatError> {
    let document = source.parse::<DocumentMut>().map_err(|error| FormatError {
        message: format!("invalid TOML metadata: {error}"),
    })?;
    let mut formatted = document.to_string();
    if !formatted.ends_with('\n') {
        formatted.push('\n');
    }
    Ok(formatted)
}

#[derive(Debug, Clone, Copy)]
struct Line<'a> {
    raw: &'a str,
    content: &'a str,
    start: usize,
    end: usize,
    number: usize,
}

fn collect_lines(source: &str) -> Vec<Line<'_>> {
    let mut lines = Vec::new();
    let mut start = 0;
    let mut number = 1;

    for raw in source.split_inclusive('\n') {
        let end = start + raw.len();
        lines.push(Line {
            raw,
            content: strip_line_ending(raw),
            start,
            end,
            number,
        });
        start = end;
        number += 1;
    }

    if start < source.len() {
        let raw = &source[start..];
        lines.push(Line {
            raw,
            content: strip_line_ending(raw),
            start,
            end: source.len(),
            number,
        });
    }

    lines
}

fn strip_line_ending(line: &str) -> &str {
    let without_lf = line.strip_suffix('\n').unwrap_or(line);
    without_lf.strip_suffix('\r').unwrap_or(without_lf)
}

fn is_plus_delimiter(content: &str) -> bool {
    trim_horizontal(content) == "+++"
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Opener<'a> {
    fence_length: usize,
    block_type: &'a str,
}

fn parse_opener(content: &str) -> Option<Opener<'_>> {
    let trimmed = trim_horizontal(content);
    let fence_length = trimmed.bytes().take_while(|byte| *byte == b':').count();
    if fence_length < 3 {
        return None;
    }

    let block_type = trim_horizontal(&trimmed[fence_length..]);
    if block_type.is_empty() || block_type.bytes().all(|byte| byte == b':') {
        return None;
    }

    Some(Opener {
        fence_length,
        block_type,
    })
}

fn parse_closer(content: &str) -> Option<usize> {
    let trimmed = trim_horizontal(content);
    let length = trimmed.bytes().take_while(|byte| *byte == b':').count();
    (length >= 3 && length == trimmed.len()).then_some(length)
}

fn trim_horizontal(value: &str) -> &str {
    value.trim_matches(|ch| ch == ' ' || ch == '\t')
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::{Path, PathBuf};

    #[test]
    fn already_formatted_fixture_is_stable() {
        let source = fs::read_to_string(repo_path("fixtures/valid/todo-basic.hmd")).unwrap();
        let formatted = format_source(&source).unwrap();
        assert_eq!(formatted, source);
        assert!(!needs_format(&source).unwrap());
    }

    #[test]
    fn formatter_preserves_markdown_body() {
        let source = "+++\nhmd = \"0.1\"\nprofile = \"general@0.1\"\n+++\n\n# Title\n\n::: callout\n+++\nid = \"C\"\n+++\n\nThis  body   keeps spacing.\n\n- and lists\n\n:::\n";
        let formatted = format_source(source).unwrap();
        assert!(formatted.contains("This  body   keeps spacing.\n\n- and lists"));
        assert_eq!(format_source(&formatted).unwrap(), formatted);
    }

    #[test]
    fn formatter_normalizes_fence_spacing() {
        let source = "+++   \nhmd = \"0.1\"\nprofile = \"general@0.1\"\n  +++\n\n:::   callout   \n+++\nid = \"C\"\n+++\nbody\n   :::   \n";
        let formatted = format_source(source).unwrap();
        assert!(formatted.contains("+++\n"));
        assert!(formatted.contains("::: callout\n"));
        assert!(formatted.ends_with(":::\n"));
    }

    fn repo_path(path: impl AsRef<Path>) -> PathBuf {
        Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../..")
            .join(path)
    }
}
