//! Line-type detection for CalcPad's notepad UX.
//!
//! Classifies raw input lines into semantic types that the UI layer uses
//! to decide rendering (styling, gutter icons, whether to show an answer).
//!
//! Ported from the cross-platform spec in Epic 10-1 (Headers, Comments &
//! Labels). The **lexer** already produces `TokenKind::Comment` and
//! `TokenKind::Text` tokens, and `variables::aggregators::is_heading` detects
//! heading lines. This module provides a **higher-level** classification that
//! the UI layer can consume directly, without touching tokens.
//!
//! # Design note — no duplication
//!
//! Comment detection (`//`) is intentionally compatible with the lexer's
//! full-line comment check (see `lexer.rs` lines 28-40). Heading detection
//! uses the same rules as `variables::aggregators::is_heading` (1-6 `#`
//! characters followed by a space) but is implemented locally to avoid a
//! module dependency and to also extract the heading level and text. This
//! module adds *label detection* (`Label: expr`) and the unified `LineType`
//! enum that were not previously available in the engine.

use serde::{Deserialize, Serialize};

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/// The semantic type of a line in a CalcPad sheet.
///
/// Determined purely from the text content. The order of classification is:
///
/// 1. Blank / whitespace-only
/// 2. Heading (`# ...`, `## ...`, up to `######`)
/// 3. Comment (`// ...`)
/// 4. Label with expression (`Label: expr`)
/// 5. Expression (anything else that might evaluate)
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum LineType {
    /// Empty or whitespace-only line.
    Blank,
    /// Heading line. Contains the heading text (without leading `#` characters
    /// and surrounding whitespace). `level` is 1--6.
    Heading { level: u8, text: String },
    /// Comment line. Contains the comment body (without leading `//`).
    Comment { text: String },
    /// Labeled expression. `label` includes the trailing colon
    /// (e.g. `"Rent:"`), `expression` is the text after the colon.
    Label { label: String, expression: String },
    /// A calculable expression (or text that the evaluator should attempt).
    Expression,
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Classify a single line of input into its [`LineType`].
///
/// This is a **pure function** — it depends only on the text content and
/// performs no evaluation.
///
/// # Examples
///
/// ```
/// use calcpad_engine::formatting::line_types::{classify_line, LineType};
///
/// assert_eq!(classify_line(""), LineType::Blank);
/// assert_eq!(
///     classify_line("# Budget"),
///     LineType::Heading { level: 1, text: "Budget".to_string() },
/// );
/// assert_eq!(
///     classify_line("// a note"),
///     LineType::Comment { text: " a note".to_string() },
/// );
/// assert_eq!(
///     classify_line("Rent: 1500"),
///     LineType::Label {
///         label: "Rent:".to_string(),
///         expression: "1500".to_string(),
///     },
/// );
/// assert_eq!(classify_line("2 + 3"), LineType::Expression);
/// ```
pub fn classify_line(input: &str) -> LineType {
    let trimmed = input.trim();

    // 1. Blank
    if trimmed.is_empty() {
        return LineType::Blank;
    }

    // 2. Heading — delegate to the existing aggregators utility so we don't
    //    duplicate the `#` logic. Re-implement the level + text extraction
    //    here since `is_heading` only returns bool.
    if let Some(heading) = detect_heading(trimmed) {
        return heading;
    }

    // 3. Comment
    if trimmed.starts_with("//") {
        let text = trimmed[2..].to_string();
        return LineType::Comment { text };
    }

    // 4. Label
    if let Some(label_type) = detect_label(trimmed) {
        return label_type;
    }

    // 5. Expression (fallback)
    LineType::Expression
}

/// Returns `true` if the line is non-calculable (blank, heading, or comment).
///
/// Useful for quickly deciding whether to show an answer column entry.
pub fn is_non_calculable(input: &str) -> bool {
    matches!(
        classify_line(input),
        LineType::Blank | LineType::Heading { .. } | LineType::Comment { .. }
    )
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/// Detect a markdown-style heading (`# Title` through `###### Title`).
fn detect_heading(trimmed: &str) -> Option<LineType> {
    let bytes = trimmed.as_bytes();
    if bytes.is_empty() || bytes[0] != b'#' {
        return None;
    }

    let mut level: u8 = 0;
    let mut i = 0;
    while i < bytes.len() && bytes[i] == b'#' {
        level += 1;
        i += 1;
    }

    // Max 6 levels, and must be followed by a space or end of string.
    // A `#` followed immediately by a digit (e.g. `#1`) is a line reference,
    // not a heading.
    if level == 0 || level > 6 {
        return None;
    }
    if i < bytes.len() && bytes[i] != b' ' {
        return None;
    }

    let text = if i < bytes.len() {
        trimmed[i..].trim().to_string()
    } else {
        String::new()
    };

    Some(LineType::Heading { level, text })
}

/// Detect a label pattern: `Label: expression`.
///
/// A label colon must:
/// - Not be at the very end of the string
/// - Have non-empty text before it that contains at least one alphabetic char
///   (to distinguish from time expressions like `12:30`)
/// - Have non-empty content after it
fn detect_label(trimmed: &str) -> Option<LineType> {
    for (i, ch) in trimmed.char_indices() {
        if ch == ':' {
            let before = trimmed[..i].trim();
            let after = trimmed[i + 1..].trim();

            if !before.is_empty()
                && !after.is_empty()
                && before.chars().any(|c| c.is_alphabetic())
            {
                // Include the colon in the label (matches 10-1 spec).
                let label = format!("{}:", before);
                return Some(LineType::Label {
                    label,
                    expression: after.to_string(),
                });
            }
        }
    }
    None
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    // ── Blank ────────────────────────────────────────────────────────

    #[test]
    fn blank_empty() {
        assert_eq!(classify_line(""), LineType::Blank);
    }

    #[test]
    fn blank_spaces() {
        assert_eq!(classify_line("   "), LineType::Blank);
    }

    #[test]
    fn blank_tab() {
        assert_eq!(classify_line("\t"), LineType::Blank);
    }

    // ── Headings ─────────────────────────────────────────────────────

    #[test]
    fn heading_h1() {
        assert_eq!(
            classify_line("# Monthly Budget"),
            LineType::Heading { level: 1, text: "Monthly Budget".to_string() },
        );
    }

    #[test]
    fn heading_h2() {
        assert_eq!(
            classify_line("## Expenses"),
            LineType::Heading { level: 2, text: "Expenses".to_string() },
        );
    }

    #[test]
    fn heading_h3_indented() {
        assert_eq!(
            classify_line("  ### Indented"),
            LineType::Heading { level: 3, text: "Indented".to_string() },
        );
    }

    #[test]
    fn heading_no_space_after_hash_is_not_heading() {
        // `#Section` without space is not a heading (could be a reference).
        // This aligns with the aggregators::is_heading behaviour.
        assert_ne!(
            classify_line("#Section"),
            LineType::Heading { level: 1, text: "Section".to_string() },
        );
    }

    #[test]
    fn heading_hash_only() {
        // A single `# ` with trailing space should be heading with empty text.
        // `#` alone followed by nothing — also heading with empty text.
        assert_eq!(
            classify_line("# "),
            LineType::Heading { level: 1, text: String::new() },
        );
    }

    #[test]
    fn heading_not_line_ref() {
        // `#1` is a line reference, not a heading.
        assert_eq!(classify_line("#1 * 2"), LineType::Expression);
    }

    #[test]
    fn heading_six_levels() {
        assert_eq!(
            classify_line("###### Deep"),
            LineType::Heading { level: 6, text: "Deep".to_string() },
        );
    }

    #[test]
    fn heading_too_many_hashes() {
        // 7 hashes is not a valid heading.
        assert_eq!(classify_line("####### TooDeep"), LineType::Expression);
    }

    #[test]
    fn heading_expression_not_evaluated() {
        assert_eq!(
            classify_line("# 2 + 3"),
            LineType::Heading { level: 1, text: "2 + 3".to_string() },
        );
    }

    // ── Comments ─────────────────────────────────────────────────────

    #[test]
    fn comment_basic() {
        assert_eq!(
            classify_line("// This is a note"),
            LineType::Comment { text: " This is a note".to_string() },
        );
    }

    #[test]
    fn comment_no_space() {
        assert_eq!(
            classify_line("//note"),
            LineType::Comment { text: "note".to_string() },
        );
    }

    #[test]
    fn comment_empty() {
        assert_eq!(
            classify_line("//"),
            LineType::Comment { text: String::new() },
        );
    }

    #[test]
    fn comment_not_evaluated() {
        assert_eq!(
            classify_line("// 100 * 2"),
            LineType::Comment { text: " 100 * 2".to_string() },
        );
    }

    #[test]
    fn comment_indented() {
        assert_eq!(
            classify_line("   // indented comment"),
            LineType::Comment { text: " indented comment".to_string() },
        );
    }

    // ── Labels ───────────────────────────────────────────────────────

    #[test]
    fn label_simple() {
        assert_eq!(
            classify_line("Rent: 1500"),
            LineType::Label {
                label: "Rent:".to_string(),
                expression: "1500".to_string(),
            },
        );
    }

    #[test]
    fn label_with_math() {
        assert_eq!(
            classify_line("Total: 100 + 200 + 300"),
            LineType::Label {
                label: "Total:".to_string(),
                expression: "100 + 200 + 300".to_string(),
            },
        );
    }

    #[test]
    fn label_multiword() {
        assert_eq!(
            classify_line("Monthly Rent: 1500"),
            LineType::Label {
                label: "Monthly Rent:".to_string(),
                expression: "1500".to_string(),
            },
        );
    }

    #[test]
    fn label_colon_at_end_is_not_label() {
        // Nothing after the colon — not a label.
        assert_eq!(classify_line("Rent:"), LineType::Expression);
    }

    #[test]
    fn no_label_for_time_expression() {
        // `12:30` has no alpha chars before the colon, so it's not a label.
        assert_eq!(classify_line("12:30"), LineType::Expression);
    }

    // ── Expression ───────────────────────────────────────────────────

    #[test]
    fn expression_simple() {
        assert_eq!(classify_line("2 + 3"), LineType::Expression);
    }

    #[test]
    fn expression_complex() {
        assert_eq!(classify_line("(10 + 5) * 2"), LineType::Expression);
    }

    #[test]
    fn expression_single_number() {
        assert_eq!(classify_line("42"), LineType::Expression);
    }

    // ── is_non_calculable ────────────────────────────────────────────

    #[test]
    fn non_calculable_blank() {
        assert!(is_non_calculable(""));
        assert!(is_non_calculable("   "));
    }

    #[test]
    fn non_calculable_heading() {
        assert!(is_non_calculable("# Title"));
    }

    #[test]
    fn non_calculable_comment() {
        assert!(is_non_calculable("// note"));
    }

    #[test]
    fn calculable_expression() {
        assert!(!is_non_calculable("2 + 3"));
    }

    #[test]
    fn calculable_label() {
        assert!(!is_non_calculable("Price: 42"));
    }

    // ── Deterministic parsing (cross-platform consistency) ───────────

    #[test]
    fn deterministic_classification() {
        let inputs = vec![
            "# Budget",
            "// notes here",
            "Rent: 1500",
            "2 + 3",
            "",
        ];
        let results_a: Vec<_> = inputs.iter().map(|l| classify_line(l)).collect();
        let results_b: Vec<_> = inputs.iter().map(|l| classify_line(l)).collect();
        assert_eq!(results_a, results_b);
    }

    #[test]
    fn mixed_sheet_scenario() {
        let lines = vec![
            "# Monthly Budget",
            "// Income section",
            "Salary: 5000",
            "Bonus: 500",
            "",
            "# Expenses",
            "Rent: 1500",
            "Food: 300 + 200",
            "// Utilities estimated",
            "Utilities: 150",
            "",
            "5000 + 500 - 1500 - 500 - 150",
        ];
        let types: Vec<_> = lines.iter().map(|l| classify_line(l)).collect();

        assert!(matches!(&types[0], LineType::Heading { level: 1, text } if text == "Monthly Budget"));
        assert!(matches!(&types[1], LineType::Comment { .. }));
        assert!(matches!(&types[2], LineType::Label { label, .. } if label == "Salary:"));
        assert!(matches!(&types[3], LineType::Label { label, .. } if label == "Bonus:"));
        assert_eq!(types[4], LineType::Blank);
        assert!(matches!(&types[5], LineType::Heading { level: 1, text } if text == "Expenses"));
        assert!(matches!(&types[6], LineType::Label { label, .. } if label == "Rent:"));
        assert!(matches!(&types[7], LineType::Label { label, expression } if label == "Food:" && expression == "300 + 200"));
        assert!(matches!(&types[8], LineType::Comment { .. }));
        assert!(matches!(&types[9], LineType::Label { label, .. } if label == "Utilities:"));
        assert_eq!(types[10], LineType::Blank);
        assert_eq!(types[11], LineType::Expression);
    }
}
