//! YAML frontmatter parsing/rendering for wiki pages (plan §4).
//!
//! Parsing is deliberately tolerant: agents (and humans) often write Obsidian
//! inline wikilinks like `related: [[A]], [[B]]`, which is invalid YAML. A
//! strict parser would discard the whole block (and with it `sources:`, which
//! drives incremental updates), so we parse line-by-line and never let one bad
//! field nuke the rest. Rendering is strict/clean via serde.

use serde::{Deserialize, Serialize};
use std::iter::Peekable;
use std::str::Lines;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Frontmatter {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    /// Serialized as `type:` (a Rust keyword, so the field is `kind`).
    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub sources: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub related: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub updated: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_commit: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
}

/// Split a page into (frontmatter, body). Always succeeds.
pub fn parse(content: &str) -> (Frontmatter, String) {
    if let Some(rest) = content.strip_prefix("---\n") {
        if let Some(end) = rest.find("\n---") {
            let yaml = &rest[..end];
            let after = &rest[end + 4..];
            let body = after.trim_start_matches('\n').to_string();
            return (parse_tolerant(yaml), body);
        }
    }
    (Frontmatter::default(), content.to_string())
}

fn parse_tolerant(yaml: &str) -> Frontmatter {
    let mut fm = Frontmatter::default();
    let mut lines = yaml.lines().peekable();
    while let Some(line) = lines.next() {
        // Only top-level (non-indented) `key: ...` lines start a field.
        if line.trim().is_empty() || line.starts_with([' ', '\t', '-', '#']) {
            continue;
        }
        let Some((key, rest)) = line.split_once(':') else {
            continue;
        };
        let val = rest.trim();
        match key.trim() {
            "title" => fm.title = scalar(val),
            "type" => fm.kind = scalar(val),
            "summary" => fm.summary = scalar(val),
            "updated" => fm.updated = scalar(val),
            "source_commit" => fm.source_commit = scalar(val),
            "sources" => fm.sources = list(val, &mut lines),
            "related" => fm.related = list(val, &mut lines),
            _ => {}
        }
    }
    fm
}

fn scalar(val: &str) -> Option<String> {
    let v = clean(val);
    if v.is_empty() {
        None
    } else {
        Some(v)
    }
}

/// Parse a YAML list value: either an inline `[a, b]`/`[[a]], [[b]]` form on the
/// same line, or a following block of `  - item` lines.
fn list(val: &str, lines: &mut Peekable<Lines>) -> Vec<String> {
    if val.is_empty() {
        let mut out = Vec::new();
        // Block items may be indented (`  - x`) or flush (`- x`, which is what
        // serde_yaml emits) — accept either; stop at the next non-item line.
        while let Some(peek) = lines.peek() {
            let trimmed = peek.trim_start();
            if trimmed.starts_with('-') {
                let item = clean(trimmed.trim_start_matches('-'));
                if !item.is_empty() {
                    out.push(item);
                }
                lines.next();
            } else {
                break;
            }
        }
        out
    } else {
        val.split(',')
            .map(clean)
            .filter(|s| !s.is_empty())
            .collect()
    }
}

/// Strip quotes and surrounding `[`/`]` (handles `[[wikilinks]]` and flow lists).
fn clean(s: &str) -> String {
    let s = s.trim().trim_matches(['"', '\'']);
    s.trim_start_matches('[')
        .trim_end_matches(']')
        .trim()
        .to_string()
}

/// Render frontmatter + body back into a page string.
pub fn render(fm: &Frontmatter, body: &str) -> String {
    let yaml = serde_yaml::to_string(fm).unwrap_or_default();
    let yaml = yaml.trim_end();
    if yaml.is_empty() || yaml == "{}" {
        body.to_string()
    } else {
        format!("---\n{yaml}\n---\n\n{}", body.trim_start())
    }
}

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

    #[test]
    fn parses_block_sources_despite_invalid_related() {
        // `related` here is invalid YAML (Obsidian inline wikilinks). A strict
        // parser would drop everything; the tolerant parser must keep sources.
        let page = [
            "---",
            "title: Auth Module",
            "type: module",
            "sources:",
            "  - src/auth.rs",
            "  - src/middleware/session.ts",
            "related: [[Session Handling]], [[User Model]]",
            "summary: The auth surface.",
            "updated: 2026-06-22",
            "---",
            "",
            "# Auth Module",
            "Body.",
        ]
        .join("\n");
        let (fm, body) = parse(&page);
        assert_eq!(fm.title.as_deref(), Some("Auth Module"));
        assert_eq!(fm.kind.as_deref(), Some("module"));
        assert_eq!(
            fm.sources,
            vec![
                "src/auth.rs".to_string(),
                "src/middleware/session.ts".to_string()
            ]
        );
        assert_eq!(
            fm.related,
            vec!["Session Handling".to_string(), "User Model".to_string()]
        );
        assert_eq!(fm.summary.as_deref(), Some("The auth surface."));
        assert!(body.starts_with("# Auth Module"));
    }

    #[test]
    fn no_frontmatter_returns_body() {
        let (fm, body) = parse("# Just a body\ntext");
        assert!(fm.title.is_none());
        assert_eq!(body, "# Just a body\ntext");
    }

    #[test]
    fn round_trips_via_render() {
        let fm = Frontmatter {
            title: Some("X".into()),
            kind: Some("answer".into()),
            sources: vec!["a.rs".into()],
            ..Default::default()
        };
        let page = render(&fm, "# X\nbody");
        let (parsed, _) = parse(&page);
        assert_eq!(parsed.title.as_deref(), Some("X"));
        assert_eq!(parsed.sources, vec!["a.rs".to_string()]);
    }
}
