use std::collections::BTreeMap;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrontmatterShape {
    Scalar,
    List,
}

#[derive(Debug, Clone)]
enum FmValue {
    Scalar(String),
    List(Vec<String>),
}

/// A parsed frontmatter block. Read fields via `get` (scalar) / `get_list` (list).
#[derive(Debug, Default, Clone)]
pub struct Frontmatter {
    map: BTreeMap<String, FmValue>,
}

impl Frontmatter {
    pub fn get(&self, key: &str) -> Option<&str> {
        match self.map.get(key) {
            Some(FmValue::Scalar(s)) => Some(s.as_str()),
            _ => None,
        }
    }

    pub fn get_list(&self, key: &str) -> Vec<String> {
        match self.map.get(key) {
            Some(FmValue::List(v)) => v.clone(),
            Some(FmValue::Scalar(s)) => vec![s.clone()],
            None => vec![],
        }
    }

    pub fn get_canonical_list(&self, key: &str) -> Vec<String> {
        let mut out = Vec::new();
        for item in self.get_list(key) {
            let item = item.trim();
            if item.is_empty() || out.iter().any(|existing| existing == item) {
                continue;
            }
            out.push(item.to_string());
        }
        out
    }

    pub fn keys(&self) -> impl Iterator<Item = &str> {
        self.map.keys().map(String::as_str)
    }

    pub fn shape(&self, key: &str) -> Option<FrontmatterShape> {
        match self.map.get(key) {
            Some(FmValue::Scalar(_)) => Some(FrontmatterShape::Scalar),
            Some(FmValue::List(_)) => Some(FrontmatterShape::List),
            None => None,
        }
    }
}

fn strip_quotes(s: &str) -> String {
    let t = s.trim();
    let bytes = t.as_bytes();
    if t.len() >= 2
        && ((bytes[0] == b'"' && bytes[t.len() - 1] == b'"')
            || (bytes[0] == b'\'' && bytes[t.len() - 1] == b'\''))
    {
        t[1..t.len() - 1].to_string()
    } else {
        t.to_string()
    }
}

fn parse_flow_list(s: &str) -> Vec<String> {
    s.trim()
        .trim_start_matches('[')
        .trim_end_matches(']')
        .split(',')
        .map(strip_quotes)
        .filter(|x| !x.is_empty())
        .collect()
}

/// Split + parse frontmatter. Returns `(frontmatter, body)`. Never panics; a file with no
/// `---` fence (or an unterminated one) yields empty frontmatter and the original text.
pub fn parse_frontmatter(text: &str) -> (Frontmatter, &str) {
    let mut fm = Frontmatter::default();
    // Accept both LF (`---\n`) and CRLF (`---\r\n`) opening fences — a Windows-authored file
    // must not silently lose all its frontmatter (audit M8). `.lines()` already handles the
    // trailing `\r` on each block line.
    let rest = match text
        .strip_prefix("---\n")
        .or_else(|| text.strip_prefix("---\r\n"))
    {
        Some(r) => r,
        None => return (fm, text),
    };
    let (block, body) = match rest.find("\n---") {
        Some(i) => (&rest[..i], &rest[i + 4..]),
        None => return (fm, text),
    };
    let body = body.strip_prefix('\n').unwrap_or(body);

    let mut current_key: Option<String> = None;
    for line in block.lines() {
        let trimmed = line.trim_start();
        // block list item ("  - foo") belonging to the current key
        if line.starts_with([' ', '\t']) && trimmed.starts_with("- ") {
            if let Some(k) = &current_key {
                let item = strip_quotes(trimmed[2..].trim());
                fm.map
                    .entry(k.clone())
                    .and_modify(|v| match v {
                        FmValue::List(l) => l.push(item.clone()),
                        slot => *slot = FmValue::List(vec![item.clone()]),
                    })
                    .or_insert_with(|| FmValue::List(vec![item.clone()]));
            }
            continue;
        }
        // top-level "key: value"
        if !line.starts_with([' ', '\t'])
            && let Some((k, rest)) = line.split_once(':')
        {
            let key = k.trim().to_string();
            let val = rest.trim();
            if val.starts_with('[') && val.ends_with(']') {
                fm.map.insert(key, FmValue::List(parse_flow_list(val)));
                current_key = None;
            } else if val.is_empty() {
                fm.map.insert(key.clone(), FmValue::List(vec![]));
                current_key = Some(key);
            } else {
                fm.map
                    .insert(key.clone(), FmValue::Scalar(strip_quotes(val)));
                current_key = Some(key);
            }
        }
        // anything else is ignored -- tolerant by design
    }
    (fm, body)
}

pub(super) fn has_malformed_frontmatter(text: &str) -> bool {
    text.strip_prefix("---\n")
        .or_else(|| text.strip_prefix("---\r\n"))
        .is_some_and(|rest| rest.find("\n---").is_none())
}

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

    #[test]
    fn crlf_frontmatter_is_parsed_not_dropped() {
        // A Windows-authored file (CRLF) must yield the same frontmatter as its LF twin.
        let lf = "---\ntitle: Retry Policy\ntier: gold\n---\n\nBody.\n";
        let crlf = "---\r\ntitle: Retry Policy\r\ntier: gold\r\n---\r\n\r\nBody.\r\n";
        let (fm_lf, _) = parse_frontmatter(lf);
        let (fm_crlf, _) = parse_frontmatter(crlf);
        assert_eq!(
            fm_crlf.get("title"),
            Some("Retry Policy"),
            "CRLF frontmatter must parse the title"
        );
        assert_eq!(fm_lf.get("title"), fm_crlf.get("title"));
        assert_eq!(fm_crlf.get("tier"), Some("gold"));
    }
}
