//! Auto-memory harvest parser (Lot E1) — pure domain.
//!
//! When an agent ends a `Response` turn it may embed one or more fenced
//! ` ```idea-memory ` blocks asking IdeA to persist a memory note. This module
//! owns the **pure** parsing of that directive: it turns the raw turn text into a
//! [`MemoryHarvestReport`] of validated [`MemoryHarvestDirective`]s plus per-block
//! parse errors. It performs no I/O and never fails as a whole — invalid blocks
//! are skipped individually so the valid ones can still be persisted by the
//! application layer (best-effort harvest).
//!
//! ## Directive format
//!
//! ````markdown
//! ```idea-memory
//! slug: kebab-case-slug
//! title: Human title
//! type: project|reference|feedback|user
//! description: One-line recall hook
//! ---
//! Markdown body to persist.
//! ```
//! ````
//!
//! `slug`, `title`, `type`, `description` are mandatory frontmatter keys; the
//! `---` line separates them from a non-empty Markdown body (preserved verbatim
//! save for outer whitespace). Fences with any other info string are ignored.

use crate::markdown::MarkdownDoc;
use crate::memory::{MemorySlug, MemoryType};

/// The fence info string that marks a harvest directive block.
const FENCE_INFO: &str = "idea-memory";

/// Maximum number of directive blocks honoured per response. Extra blocks are
/// reported as errors and skipped (a single response should not dump dozens of
/// notes).
pub const MAX_BLOCKS: usize = 3;

/// Maximum raw size (bytes) of a single directive block's inner content.
pub const MAX_BLOCK_BYTES: usize = 8 * 1024;

/// Maximum length (characters) of a directive's `description` (the index hook).
pub const MAX_DESCRIPTION_CHARS: usize = 200;

/// A validated memory-harvest directive parsed from one ` ```idea-memory ` block.
///
/// Pure value object. Note that `title` is captured (it is a mandatory key) even
/// though the persisted [`crate::memory::Memory`] entity has no dedicated title
/// field today — the application maps the rest onto a `Memory` and the title is
/// kept here for callers/forward use.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryHarvestDirective {
    /// Validated kebab-case slug (the note identity / file stem).
    pub slug: MemorySlug,
    /// Human-readable title (mandatory in the directive).
    pub title: String,
    /// The note kind.
    pub r#type: MemoryType,
    /// One-line recall hook (bounded, non-empty).
    pub description: String,
    /// The Markdown body to persist (non-empty).
    pub body: MarkdownDoc,
}

/// Outcome of parsing a turn's text for harvest directives.
///
/// `found` counts every ` ```idea-memory ` fence detected (valid or not), so the
/// application can report how many directives the agent emitted versus how many
/// were usable. `directives` are the valid ones (in document order); `errors`
/// carries one human-readable message per skipped block.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MemoryHarvestReport {
    /// Number of `idea-memory` fences detected in the text.
    pub found: usize,
    /// Successfully parsed, validated directives (document order).
    pub directives: Vec<MemoryHarvestDirective>,
    /// One message per skipped (invalid / over-limit) block.
    pub errors: Vec<String>,
}

/// Parses `text` for ` ```idea-memory ` directive blocks, best-effort.
///
/// Never fails: malformed blocks land in [`MemoryHarvestReport::errors`] and valid
/// ones in [`MemoryHarvestReport::directives`]. Fences with a different info string
/// are skipped entirely. At most [`MAX_BLOCKS`] blocks are honoured; further blocks
/// are reported as errors.
#[must_use]
pub fn parse_memory_directives(text: &str) -> MemoryHarvestReport {
    let mut report = MemoryHarvestReport::default();
    let lines: Vec<&str> = text.lines().collect();
    let mut i = 0;
    while i < lines.len() {
        let line = lines[i];
        let Some(info) = fence_info(line) else {
            i += 1;
            continue;
        };
        // A fenced block opens here. Find its closing fence (a line whose trimmed
        // content is exactly ```), tracking the inner content lines.
        let mut j = i + 1;
        while j < lines.len() && lines[j].trim_end() != "```" {
            j += 1;
        }
        let inner = if j <= lines.len() {
            lines[i + 1..j.min(lines.len())].join("\n")
        } else {
            String::new()
        };
        // Advance past the closing fence (or to EOF for an unterminated block).
        let next = if j < lines.len() { j + 1 } else { lines.len() };

        if info == FENCE_INFO {
            report.found += 1;
            if report.found > MAX_BLOCKS {
                report.errors.push(format!(
                    "skipped idea-memory block #{}: exceeds the max of {MAX_BLOCKS} blocks per response",
                    report.found
                ));
            } else {
                match parse_block(&inner) {
                    Ok(directive) => report.directives.push(directive),
                    Err(err) => report.errors.push(err),
                }
            }
        }
        i = next;
    }
    report
}

/// Returns the (trimmed) info string of a fence opener line, or `None` if `line`
/// is not a ```` ``` ```` fence opener.
fn fence_info(line: &str) -> Option<&str> {
    let trimmed = line.trim();
    let rest = trimmed.strip_prefix("```")?;
    // A bare ``` (no info string) is a generic fence opener, not ours.
    Some(rest.trim())
}

/// Parses one block's inner content into a validated directive, or an error
/// message describing why it was skipped.
fn parse_block(inner: &str) -> Result<MemoryHarvestDirective, String> {
    if inner.len() > MAX_BLOCK_BYTES {
        return Err(format!(
            "block exceeds the max size of {MAX_BLOCK_BYTES} bytes ({} bytes)",
            inner.len()
        ));
    }

    // Split frontmatter (until a `---` line) from the body.
    let mut sep = None;
    for (idx, line) in inner.lines().enumerate() {
        if line.trim() == "---" {
            sep = Some(idx);
            break;
        }
    }
    let Some(sep) = sep else {
        return Err("missing '---' separator between frontmatter and body".to_owned());
    };
    let block_lines: Vec<&str> = inner.lines().collect();
    let frontmatter = &block_lines[..sep];
    let body_raw = block_lines[sep + 1..].join("\n");

    // Collect the known frontmatter keys (unknown keys are ignored leniently).
    let mut slug_raw = None;
    let mut title = None;
    let mut type_raw = None;
    let mut description = None;
    for line in frontmatter {
        let Some((key, value)) = line.split_once(':') else {
            continue;
        };
        let value = value.trim().to_owned();
        match key.trim() {
            "slug" => slug_raw = Some(value),
            "title" => title = Some(value),
            "type" => type_raw = Some(value),
            "description" => description = Some(value),
            _ => {}
        }
    }

    let slug_raw = required(slug_raw, "slug")?;
    let title = required(title, "title")?;
    let type_raw = required(type_raw, "type")?;
    let description = required(description, "description")?;

    let slug = MemorySlug::new(&slug_raw).map_err(|_| format!("invalid slug: {slug_raw:?}"))?;
    let r#type = parse_type(&type_raw)?;

    if description.chars().count() > MAX_DESCRIPTION_CHARS {
        return Err(format!(
            "description exceeds {MAX_DESCRIPTION_CHARS} characters"
        ));
    }

    let body = body_raw.trim();
    if body.is_empty() {
        return Err("empty body".to_owned());
    }

    Ok(MemoryHarvestDirective {
        slug,
        title,
        r#type,
        description,
        body: MarkdownDoc::new(body.to_owned()),
    })
}

/// Requires a non-empty frontmatter value for `field`.
fn required(value: Option<String>, field: &str) -> Result<String, String> {
    match value {
        Some(v) if !v.trim().is_empty() => Ok(v.trim().to_owned()),
        Some(_) => Err(format!("empty required field: {field}")),
        None => Err(format!("missing required field: {field}")),
    }
}

/// Maps the `type` value to a [`MemoryType`].
fn parse_type(raw: &str) -> Result<MemoryType, String> {
    match raw {
        "project" => Ok(MemoryType::Project),
        "reference" => Ok(MemoryType::Reference),
        "feedback" => Ok(MemoryType::Feedback),
        "user" => Ok(MemoryType::User),
        other => Err(format!("invalid type: {other:?}")),
    }
}

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

    fn block(slug: &str, title: &str, ty: &str, desc: &str, body: &str) -> String {
        format!(
            "```idea-memory\nslug: {slug}\ntitle: {title}\ntype: {ty}\ndescription: {desc}\n---\n{body}\n```"
        )
    }

    #[test]
    fn parses_a_single_valid_block() {
        let text = format!(
            "Here is what I learned.\n\n{}\n\nDone.",
            block(
                "api-base-url",
                "API base URL",
                "project",
                "Where the API lives",
                "Use `https://api`."
            )
        );
        let report = parse_memory_directives(&text);
        assert_eq!(report.found, 1);
        assert!(report.errors.is_empty(), "{:?}", report.errors);
        assert_eq!(report.directives.len(), 1);
        let d = &report.directives[0];
        assert_eq!(d.slug.as_str(), "api-base-url");
        assert_eq!(d.title, "API base URL");
        assert_eq!(d.r#type, MemoryType::Project);
        assert_eq!(d.description, "Where the API lives");
        assert_eq!(d.body.as_str(), "Use `https://api`.");
    }

    #[test]
    fn parses_multiple_valid_blocks_in_order() {
        let text = format!(
            "{}\n{}",
            block("first-note", "First", "project", "one", "Body one"),
            block("second-note", "Second", "reference", "two", "Body two")
        );
        let report = parse_memory_directives(&text);
        assert_eq!(report.found, 2);
        assert_eq!(report.directives.len(), 2);
        assert_eq!(report.directives[0].slug.as_str(), "first-note");
        assert_eq!(report.directives[1].slug.as_str(), "second-note");
    }

    #[test]
    fn invalid_slug_is_skipped_with_error() {
        let text = block("Not Kebab!", "Bad", "project", "hook", "Body");
        let report = parse_memory_directives(&text);
        assert_eq!(report.found, 1);
        assert!(report.directives.is_empty());
        assert_eq!(report.errors.len(), 1);
        assert!(
            report.errors[0].contains("invalid slug"),
            "{:?}",
            report.errors
        );
    }

    #[test]
    fn empty_body_is_skipped() {
        let text = block("ok-slug", "Title", "project", "hook", "   ");
        let report = parse_memory_directives(&text);
        assert_eq!(report.found, 1);
        assert!(report.directives.is_empty());
        assert!(report.errors[0].contains("empty body"));
    }

    #[test]
    fn missing_required_field_is_skipped() {
        // No `title:` key.
        let text =
            "```idea-memory\nslug: ok-slug\ntype: project\ndescription: hook\n---\nBody\n```";
        let report = parse_memory_directives(text);
        assert_eq!(report.found, 1);
        assert!(report.directives.is_empty());
        assert!(report.errors[0].contains("title"), "{:?}", report.errors);
    }

    #[test]
    fn invalid_type_is_skipped() {
        let text = block("ok-slug", "Title", "wisdom", "hook", "Body");
        let report = parse_memory_directives(&text);
        assert!(report.directives.is_empty());
        assert!(report.errors[0].contains("invalid type"));
    }

    #[test]
    fn missing_separator_is_skipped() {
        let text = "```idea-memory\nslug: ok-slug\ntitle: T\ntype: project\ndescription: hook\nBody without separator\n```";
        let report = parse_memory_directives(text);
        assert_eq!(report.found, 1);
        assert!(report.directives.is_empty());
        assert!(
            report.errors[0].contains("separator"),
            "{:?}",
            report.errors
        );
    }

    #[test]
    fn caps_at_max_blocks() {
        let mut text = String::new();
        for n in 0..(MAX_BLOCKS + 2) {
            text.push_str(&block(&format!("note-{n}"), "T", "project", "hook", "Body"));
            text.push('\n');
        }
        let report = parse_memory_directives(&text);
        assert_eq!(report.found, MAX_BLOCKS + 2);
        assert_eq!(
            report.directives.len(),
            MAX_BLOCKS,
            "only the first MAX_BLOCKS honoured"
        );
        assert_eq!(report.errors.len(), 2, "the surplus blocks are reported");
        assert!(report.errors[0].contains("exceeds the max"));
    }

    #[test]
    fn oversize_block_is_skipped() {
        let huge_body = "x".repeat(MAX_BLOCK_BYTES + 1);
        let text = block("big-note", "T", "project", "hook", &huge_body);
        let report = parse_memory_directives(&text);
        assert_eq!(report.found, 1);
        assert!(report.directives.is_empty());
        assert!(report.errors[0].contains("max size"), "{:?}", report.errors);
    }

    #[test]
    fn over_long_description_is_skipped() {
        let long_desc = "d".repeat(MAX_DESCRIPTION_CHARS + 1);
        let text = block("ok-slug", "T", "project", &long_desc, "Body");
        let report = parse_memory_directives(&text);
        assert!(report.directives.is_empty());
        assert!(report.errors[0].contains("description exceeds"));
    }

    #[test]
    fn non_idea_memory_fences_are_ignored() {
        let text = "```rust\nslug: looks-like-one\ntitle: T\ntype: project\ndescription: hook\n---\nfn main() {}\n```\n```\nplain\n```";
        let report = parse_memory_directives(text);
        assert_eq!(report.found, 0);
        assert!(report.directives.is_empty());
        assert!(report.errors.is_empty());
    }

    #[test]
    fn markdown_body_is_preserved() {
        let body = "# Heading\n\n- bullet **bold**\n- second\n\n1. step\n\n`inline code`";
        let text = block("md-note", "Markdown", "reference", "hook", body);
        let report = parse_memory_directives(&text);
        assert_eq!(report.directives.len(), 1, "{:?}", report.errors);
        assert_eq!(report.directives[0].body.as_str(), body);
    }

    #[test]
    fn empty_text_yields_empty_report() {
        let report = parse_memory_directives("");
        assert_eq!(report.found, 0);
        assert!(report.directives.is_empty());
        assert!(report.errors.is_empty());
    }

    #[test]
    fn valid_and_invalid_blocks_coexist() {
        let text = format!(
            "{}\n{}",
            block("good-one", "Good", "project", "hook", "Body"),
            block("Bad Slug", "Bad", "project", "hook", "Body")
        );
        let report = parse_memory_directives(&text);
        assert_eq!(report.found, 2);
        assert_eq!(report.directives.len(), 1);
        assert_eq!(report.directives[0].slug.as_str(), "good-one");
        assert_eq!(report.errors.len(), 1);
    }
}
