//! Shared chunker contract: one trait, one config, one output shape, used by
//! the markdown chunker, the code chunkers, and the line-window fallback.

use mnm_core::types::{DocumentKind, SymbolSegment};

/// One chunk emitted by any [`Chunker`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chunk {
    /// Verbatim chunk content.
    pub content: String,
    /// Ancestor heading path (markdown). Empty for code/plaintext.
    pub heading_path: Vec<String>,
    /// Structured symbol path (code). Empty for markdown/plaintext.
    pub symbol_path: Vec<SymbolSegment>,
    /// Byte offset of the chunk's first character in the source.
    pub start_byte: usize,
    /// Byte offset just past the chunk's last character.
    pub end_byte: usize,
    /// Token count of this chunk (BPE, via [`crate::tokens::count`]).
    pub token_count: u32,
    /// 0-indexed position among the document's chunks.
    pub chunk_index: u32,
    /// True iff produced by the line-window fallback.
    pub fallback_used: bool,
}

/// Canonical default per-file size ceiling (10 MiB).
///
/// Single source of truth for the value shared by [`ChunkerConfig::default`],
/// the walker ([`crate::ingest::walker::Walker`]), and the CLI
/// `--max-file-size` default.
pub const DEFAULT_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;

/// Configuration shared by all chunkers. Token-budgeted.
#[derive(Debug, Clone, Copy)]
pub struct ChunkerConfig {
    /// Max chunk size in BPE tokens before splitting. Greedy coalescing in
    /// every chunker packs units up to [`coalesce_target`] (90% of this).
    pub max_tokens: u32,
    /// Per-file size ceiling. Enforced by the *walker* (the caller), which
    /// skips files larger than this before they ever reach a chunker (EC-52) —
    /// see [`crate::ingest::walker::Walker::with_max_file_bytes`]. Chunkers
    /// themselves never read this field.
    pub max_file_bytes: u64,
}

impl Default for ChunkerConfig {
    fn default() -> Self {
        Self {
            max_tokens: 1024,
            max_file_bytes: DEFAULT_MAX_FILE_BYTES,
        }
    }
}

/// Greedy coalescing target: 90% of `max_tokens` (D2). Both the markdown and
/// code coalescers pack sibling units up to this; only a single unit larger
/// than `max_tokens` is ever split.
#[must_use]
pub const fn coalesce_target(cfg: &ChunkerConfig) -> u32 {
    cfg.max_tokens.saturating_mul(9) / 10
}

/// Errors a chunker can surface for one file. Never panics; the planner maps
/// these to a per-file warning (default) or a run failure (`--strict`).
#[derive(Debug, thiserror::Error)]
pub enum ChunkError {
    /// The parser failed badly enough that we fell back to line-window.
    /// Carries the reason for the warning message.
    #[error("parser fell back to line-window: {0}")]
    ParserFallback(String),
}

/// The chunking contract. Implementations: markdown, each code language,
/// and the line-window fallback.
pub trait Chunker {
    /// Chunk `body`. Returns at least one chunk for non-empty, non-whitespace
    /// input; an empty vec for empty/whitespace input.
    ///
    /// # Errors
    /// Returns [`ChunkError`] only when the caller asked for strict behavior;
    /// the default implementations recover internally and return `Ok`.
    fn chunk(&self, body: &str, cfg: &ChunkerConfig) -> Result<Vec<Chunk>, ChunkError>;
}

/// Chunk one document body with the chunker that matches its [`DocumentKind`].
///
/// Single source of truth for the kind → chunker dispatch, shared by the ingest
/// [`PlanBuilder`](crate::ingest::PlanBuilder) (new-document path) and the CLI
/// carried-document path (which re-chunks carry-forward docs only to recover
/// their document-level token total — no embedding is performed on them). Both
/// callers MUST route through this function so the dispatch can never drift
/// between them.
///
/// `ext` is the lowercased file extension (no leading dot); it selects the
/// per-language code chunker and is ignored for markdown/plaintext. A parser
/// failure inside any chunker is recovered internally (line-window fallback),
/// so this never errors and an empty/whitespace body yields no chunks.
#[must_use]
pub fn chunk_document(
    kind: DocumentKind,
    ext: &str,
    body: &str,
    cfg: &ChunkerConfig,
) -> Vec<Chunk> {
    match kind {
        DocumentKind::Markdown => crate::markdown::MarkdownChunker
            .chunk(body, cfg)
            .unwrap_or_default(),
        DocumentKind::Code => {
            let lang = crate::code::language::Language::for_extension(ext);
            crate::code::chunker_for_ext(lang, ext)
                .chunk(body, cfg)
                .unwrap_or_default()
        }
        DocumentKind::Plaintext => crate::code::line_window::LineWindowChunker
            .chunk(body, cfg)
            .unwrap_or_default(),
    }
}

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

    #[test]
    fn default_config_is_token_budgeted() {
        let c = ChunkerConfig::default();
        assert_eq!(c.max_tokens, 1024);
        assert_eq!(c.max_file_bytes, 10 * 1024 * 1024);
    }

    #[test]
    fn coalesce_target_is_90_pct_of_max_tokens() {
        // 921 == 1024 * 9 / 10 (integer division).
        assert_eq!(coalesce_target(&ChunkerConfig::default()), 921);
    }
}
