//! Text shaping — turns a sequence of [`TextRun`]s into positioned glyphs,
//! laid out on baselines, wrapped to a width, and aligned.
//!
//! The shaper does **not** rasterize: it only consults [`Font`](super::font::Font)
//! metrics (ascender, descender, advance width). Each output [`ShapedGlyph`]
//! carries a [`GlyphKey`] the renderer (piece 4) feeds into the atlas to
//! resolve to a textured quad. This split keeps the shaper purely
//! deterministic and CPU-cheap — every test in this module runs without a
//! GPU and most without a font.
//!
//! # Algorithm
//!
//! 1. **Tokenize** each run into items: a `Word` (maximal run of non-
//!    whitespace), a `Whitespace` stretch, or a `Break` (`\n`). Each
//!    word/whitespace item caches its own width, computed once from the
//!    font's per-glyph advance.
//! 2. **Greedy line break**: keep adding items to the current line; on a
//!    word that would overflow `max_width`, flush the line and start a new
//!    one. Pending inter-word whitespace at the wrap point is **discarded**
//!    (it was the gap between lines, not part of either line); leading
//!    whitespace on a wrapped line is dropped for the same reason. `\n`
//!    forces a flush regardless of width.
//! 3. **Position**: for each line, find the line's `max_ascent` (across the
//!    fonts used on it) — that's the baseline offset from the line's top
//!    edge — then walk items left-to-right, emitting `ShapedGlyph`s at
//!    `(pen_x, baseline_y)` and advancing `pen_x` by each glyph's advance.
//! 4. **Align**: per line, shift glyphs by `align_offset(max_width −
//!    line_width)` — Left/Center/Right. Without a `max_width`, alignment
//!    is degenerate (everything is left-aligned).
//!
//! # Multi-font runs
//!
//! Lines may mix items from different runs (and therefore different fonts).
//! Line metrics (ascent, descent, line height) are taken from the *largest*
//! contribution among the line's items. This is the CSS behavior: a small
//! superscript run on the same line as body text doesn't collapse the
//! baseline.
//!
//! # Limitations (deliberate, scoped to piece 3)
//!
//! - One glyph per `char` (no ligatures, no combining marks, no shaping).
//!   ab_glyph does not shape; full Unicode shaping is a `rustybuzz` /
//!   `harfbuzz` follow-up.
//! - No BiDi or RTL — text flows left-to-right.
//! - No hyphenation or character-level fallback inside an overflowing word.
//! - Whitespace is ASCII (` `, `\t`, `\r`). `\t` and `\r` are treated as
//!   regular spaces.

use glam::Vec2;
use serde::{Deserialize, Serialize};

use super::atlas::GlyphKey;
use super::font::{FontId, FontStore};

/// Per-run style — which font and what point size.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TextStyle {
    pub font: FontId,
    /// Logical font size in pixels. Multiplied by [`ShapeParams::scale`] at
    /// shape time, so the same `TextStyle` produces correctly-sized output
    /// at 1×, 2×, or any other DPI factor.
    pub size_px: f32,
}

/// One run of text with a single [`TextStyle`].
///
/// `shape` takes a single run; `shape_runs` takes many for mixed styles
/// (different fonts/sizes/etc. on the same line).
#[derive(Debug, Clone, Copy)]
pub struct TextRun<'a> {
    pub text: &'a str,
    pub style: TextStyle,
}

/// Horizontal alignment of each line within `max_width`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TextAlign {
    #[default]
    Left,
    Center,
    Right,
}

/// Parameters that apply to the whole shape call: wrapping width, alignment,
/// line-height factor, and the DPI scale factor.
#[derive(Debug, Clone, Copy)]
pub struct ShapeParams {
    /// Maximum line width in **post-scale** pixels. `None` disables
    /// wrapping (and makes alignment a no-op).
    pub max_width: Option<f32>,
    /// Horizontal alignment within `max_width`.
    pub align: TextAlign,
    /// Multiplier applied to each line's natural line height. `1.0` is the
    /// font's own recommendation; `1.4` is a comfortable reading default.
    pub line_height: f32,
    /// DPI scale factor — multiplies every logical `size_px` from the
    /// runs. Same role as [`super::super::layout::layout`]'s `scale`.
    pub scale: f32,
}

impl Default for ShapeParams {
    fn default() -> Self {
        Self {
            max_width: None,
            align: TextAlign::Left,
            line_height: 1.0,
            scale: 1.0,
        }
    }
}

/// One positioned glyph in the shaped output.
///
/// `position` is the **pen position at the baseline** — the renderer adds
/// the atlas's per-glyph bearing to convert it into the top-left of the
/// glyph quad. Keeping it at the baseline (rather than at the top-left) is
/// what makes hit testing and caret positioning straightforward in pieces
/// 5–6.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShapedGlyph {
    pub key: GlyphKey,
    pub position: Vec2,
}

/// One shaped line — the glyphs, the line's content width (trailing
/// whitespace excluded), and the line's baseline / total height.
#[derive(Debug, Clone, PartialEq)]
pub struct ShapedLine {
    pub glyphs: Vec<ShapedGlyph>,
    pub width: f32,
    pub baseline_y: f32,
    pub line_height: f32,
}

/// Full shaped output — `lines` in vertical order and the overall bounding
/// box `size`. `size.x` is the widest line's width (not `max_width`);
/// `size.y` is the sum of line heights, which equals the height of the
/// rectangle the text fits in.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ShapedText {
    pub lines: Vec<ShapedLine>,
    pub size: Vec2,
}

/// Shape a single run of text. Convenience wrapper around [`shape_runs`].
pub fn shape(text: &str, style: TextStyle, params: &ShapeParams, fonts: &FontStore) -> ShapedText {
    shape_runs(&[TextRun { text, style }], params, fonts)
}

/// Shape one or more runs into a single output. Items from different runs
/// share lines and share alignment, just as if they were one continuous
/// string with mixed styles.
pub fn shape_runs(runs: &[TextRun], params: &ShapeParams, fonts: &FontStore) -> ShapedText {
    let mut items: Vec<Item> = Vec::new();
    for run in runs {
        tokenize_run(run, params.scale, fonts, &mut items);
    }

    let raw_lines = break_lines(items, params.max_width);
    position_lines(raw_lines, params, fonts)
}

// ---------- internals ----------

#[derive(Debug, Clone)]
enum Item {
    Word {
        font: FontId,
        size_px: f32,
        width: f32,
        // (char, glyph id, advance) — kept so the positioner doesn't have to
        // re-walk the source string.
        glyphs: Vec<GlyphAdvance>,
    },
    Whitespace {
        font: FontId,
        size_px: f32,
        width: f32,
    },
    Break,
}

#[derive(Debug, Clone, Copy)]
struct GlyphAdvance {
    glyph: super::font::GlyphId,
    advance: f32,
}

impl Item {
    fn width(&self) -> f32 {
        match self {
            Item::Word { width, .. } | Item::Whitespace { width, .. } => *width,
            Item::Break => 0.0,
        }
    }

    fn font_size(&self) -> Option<(FontId, f32)> {
        match self {
            Item::Word { font, size_px, .. } | Item::Whitespace { font, size_px, .. } => {
                Some((*font, *size_px))
            }
            Item::Break => None,
        }
    }

    fn is_whitespace(&self) -> bool {
        matches!(self, Item::Whitespace { .. })
    }
}

fn is_break(c: char) -> bool {
    c == '\n'
}

fn is_space_like(c: char) -> bool {
    matches!(c, ' ' | '\t' | '\r')
}

fn tokenize_run(run: &TextRun, scale: f32, fonts: &FontStore, out: &mut Vec<Item>) {
    let style = run.style;
    let size_px = style.size_px * scale;
    let Some(font) = fonts.get(style.font) else {
        // Unknown font id — skip the run rather than panicking. Tests in
        // piece 4 will catch missing fonts before rendering; for piece 3
        // we want shape to remain a total function.
        return;
    };

    let mut buf_word: Vec<GlyphAdvance> = Vec::new();
    let mut buf_word_width: f32 = 0.0;
    let mut buf_ws_width: f32 = 0.0;
    let mut state = TokState::Empty;

    for c in run.text.chars() {
        if is_break(c) {
            flush_buffers(
                &mut state,
                &mut buf_word,
                &mut buf_word_width,
                &mut buf_ws_width,
                style.font,
                size_px,
                out,
            );
            out.push(Item::Break);
            continue;
        }
        if is_space_like(c) {
            if matches!(state, TokState::Word) {
                out.push(Item::Word {
                    font: style.font,
                    size_px,
                    width: buf_word_width,
                    glyphs: std::mem::take(&mut buf_word),
                });
                buf_word_width = 0.0;
            }
            state = TokState::Whitespace;
            let glyph = font.glyph_id(' ');
            buf_ws_width += font.h_advance_px(glyph, size_px);
            continue;
        }
        // Non-whitespace.
        if matches!(state, TokState::Whitespace) {
            out.push(Item::Whitespace {
                font: style.font,
                size_px,
                width: buf_ws_width,
            });
            buf_ws_width = 0.0;
        }
        state = TokState::Word;
        let glyph = font.glyph_id(c);
        let advance = font.h_advance_px(glyph, size_px);
        buf_word.push(GlyphAdvance { glyph, advance });
        buf_word_width += advance;
    }

    flush_buffers(
        &mut state,
        &mut buf_word,
        &mut buf_word_width,
        &mut buf_ws_width,
        style.font,
        size_px,
        out,
    );
}

#[derive(PartialEq)]
enum TokState {
    Empty,
    Word,
    Whitespace,
}

fn flush_buffers(
    state: &mut TokState,
    word: &mut Vec<GlyphAdvance>,
    word_width: &mut f32,
    ws_width: &mut f32,
    font: FontId,
    size_px: f32,
    out: &mut Vec<Item>,
) {
    match state {
        TokState::Word => {
            out.push(Item::Word {
                font,
                size_px,
                width: *word_width,
                glyphs: std::mem::take(word),
            });
            *word_width = 0.0;
        }
        TokState::Whitespace => {
            out.push(Item::Whitespace {
                font,
                size_px,
                width: *ws_width,
            });
            *ws_width = 0.0;
        }
        TokState::Empty => {}
    }
    *state = TokState::Empty;
}

fn break_lines(items: Vec<Item>, max_width: Option<f32>) -> Vec<Vec<Item>> {
    let mut raw_lines: Vec<Vec<Item>> = Vec::new();
    let mut current: Vec<Item> = Vec::new();
    let mut current_width: f32 = 0.0;
    let mut pending_ws: Vec<Item> = Vec::new();
    let mut pending_ws_width: f32 = 0.0;

    for item in items {
        match item {
            Item::Break => {
                raw_lines.push(std::mem::take(&mut current));
                current_width = 0.0;
                pending_ws.clear();
                pending_ws_width = 0.0;
            }
            Item::Whitespace { width, .. } => {
                pending_ws_width += width;
                pending_ws.push(item);
            }
            Item::Word { width, .. } => {
                let fits = match max_width {
                    Some(max) => {
                        current.is_empty() || current_width + pending_ws_width + width <= max
                    }
                    None => true,
                };
                if fits {
                    current.append(&mut pending_ws);
                    current_width += pending_ws_width;
                    current_width += width;
                    current.push(item);
                } else {
                    raw_lines.push(std::mem::take(&mut current));
                    // Leading whitespace on a wrapped line is dropped.
                    pending_ws.clear();
                    current_width = width;
                    current.push(item);
                }
                pending_ws_width = 0.0;
            }
        }
    }
    if !current.is_empty() {
        raw_lines.push(current);
    }
    raw_lines
}

fn position_lines(
    raw_lines: Vec<Vec<Item>>,
    params: &ShapeParams,
    fonts: &FontStore,
) -> ShapedText {
    let mut lines: Vec<ShapedLine> = Vec::new();
    let mut cursor_y: f32 = 0.0;
    let mut widest: f32 = 0.0;

    for line_items in raw_lines {
        // Line metrics from the largest contributing item.
        let mut max_ascent: f32 = 0.0;
        let mut min_descent: f32 = 0.0;
        let mut max_line_height: f32 = 0.0;
        for item in &line_items {
            if let Some((font_id, size_px)) = item.font_size() {
                if let Some(font) = fonts.get(font_id) {
                    max_ascent = max_ascent.max(font.ascent_px(size_px));
                    min_descent = min_descent.min(font.descent_px(size_px));
                    max_line_height = max_line_height.max(font.line_height_px(size_px));
                }
            }
        }
        let _ = min_descent; // descent reserved for vertical-extent queries later
        let line_height = max_line_height * params.line_height;

        // Trailing whitespace is excluded from line width.
        let mut content_width: f32 = 0.0;
        let last_non_ws = line_items
            .iter()
            .enumerate()
            .rev()
            .find(|(_, it)| !it.is_whitespace())
            .map(|(i, _)| i);
        if let Some(end) = last_non_ws {
            for it in &line_items[..=end] {
                content_width += it.width();
            }
        }

        // Horizontal alignment offset.
        let align_pad = match params.max_width {
            Some(max) => {
                let extra = (max - content_width).max(0.0);
                match params.align {
                    TextAlign::Left => 0.0,
                    TextAlign::Center => extra * 0.5,
                    TextAlign::Right => extra,
                }
            }
            None => 0.0,
        };

        let baseline_y = cursor_y + max_ascent;
        let mut pen_x = align_pad;
        let mut glyphs: Vec<ShapedGlyph> = Vec::new();
        for item in &line_items {
            match item {
                Item::Word {
                    font,
                    size_px,
                    glyphs: g,
                    ..
                } => {
                    for ga in g {
                        glyphs.push(ShapedGlyph {
                            key: GlyphKey::new(*font, ga.glyph, *size_px),
                            position: Vec2::new(pen_x, baseline_y),
                        });
                        pen_x += ga.advance;
                    }
                }
                Item::Whitespace { width, .. } => {
                    pen_x += *width;
                }
                Item::Break => {}
            }
        }

        lines.push(ShapedLine {
            glyphs,
            width: content_width,
            baseline_y,
            line_height,
        });
        cursor_y += line_height;
        widest = widest.max(content_width);
    }

    ShapedText {
        lines,
        size: Vec2::new(widest, cursor_y),
    }
}

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

    fn make_store_and_style(size_px: f32) -> Option<(FontStore, TextStyle)> {
        let font = try_load_system_font()?;
        let mut store = FontStore::new();
        let id = store.insert(font);
        Some((store, TextStyle { font: id, size_px }))
    }

    #[test]
    fn empty_text_produces_no_lines() {
        let Some((store, style)) = make_store_and_style(16.0) else {
            return;
        };
        let out = shape("", style, &ShapeParams::default(), &store);
        assert!(out.lines.is_empty());
        assert_eq!(out.size, Vec2::ZERO);
    }

    #[test]
    fn single_word_emits_one_line_with_correct_glyph_count() {
        let Some((store, style)) = make_store_and_style(16.0) else {
            return;
        };
        let out = shape("Hello", style, &ShapeParams::default(), &store);
        assert_eq!(out.lines.len(), 1);
        assert_eq!(out.lines[0].glyphs.len(), 5);
        // Glyphs are at the same baseline.
        let baseline = out.lines[0].baseline_y;
        for g in &out.lines[0].glyphs {
            assert_eq!(g.position.y, baseline);
        }
        // x positions are monotonically increasing.
        for w in out.lines[0].glyphs.windows(2) {
            assert!(w[1].position.x > w[0].position.x);
        }
        // Line width matches the last glyph's pen-end (advance sum).
        assert!(out.lines[0].width > 0.0);
        assert!(out.size.x >= out.lines[0].width);
    }

    #[test]
    fn explicit_newline_starts_new_line() {
        let Some((store, style)) = make_store_and_style(16.0) else {
            return;
        };
        let out = shape("a\nb", style, &ShapeParams::default(), &store);
        assert_eq!(out.lines.len(), 2);
        assert_eq!(out.lines[0].glyphs.len(), 1);
        assert_eq!(out.lines[1].glyphs.len(), 1);
        // Second baseline is below the first by one line height.
        assert!(out.lines[1].baseline_y > out.lines[0].baseline_y);
    }

    #[test]
    fn word_wrap_splits_into_multiple_lines() {
        let Some((store, style)) = make_store_and_style(16.0) else {
            return;
        };
        // A line wide enough for "Hello" but not "Hello world".
        let one_word_width = shape("Hello", style, &ShapeParams::default(), &store).lines[0].width;
        let params = ShapeParams {
            max_width: Some(one_word_width + 2.0),
            ..ShapeParams::default()
        };
        let out = shape("Hello world", style, &params, &store);
        assert_eq!(out.lines.len(), 2);
        // First line is just "Hello" (5 glyphs).
        assert_eq!(out.lines[0].glyphs.len(), 5);
        // Second line is "world" (5 glyphs); leading whitespace dropped.
        assert_eq!(out.lines[1].glyphs.len(), 5);
        // Second line starts at x = 0 (Left align by default; no leading
        // whitespace consumed pen space).
        assert_eq!(out.lines[1].glyphs[0].position.x, 0.0);
    }

    #[test]
    fn trailing_whitespace_excluded_from_line_width() {
        let Some((store, style)) = make_store_and_style(16.0) else {
            return;
        };
        let bare = shape("Hi", style, &ShapeParams::default(), &store);
        let trailing = shape("Hi   ", style, &ShapeParams::default(), &store);
        assert_eq!(bare.lines[0].width, trailing.lines[0].width);
    }

    #[test]
    fn alignment_shifts_glyph_positions_within_max_width() {
        let Some((store, style)) = make_store_and_style(16.0) else {
            return;
        };
        let left = shape(
            "Hi",
            style,
            &ShapeParams {
                max_width: Some(200.0),
                align: TextAlign::Left,
                ..ShapeParams::default()
            },
            &store,
        );
        let center = shape(
            "Hi",
            style,
            &ShapeParams {
                max_width: Some(200.0),
                align: TextAlign::Center,
                ..ShapeParams::default()
            },
            &store,
        );
        let right = shape(
            "Hi",
            style,
            &ShapeParams {
                max_width: Some(200.0),
                align: TextAlign::Right,
                ..ShapeParams::default()
            },
            &store,
        );
        let l = left.lines[0].glyphs[0].position.x;
        let c = center.lines[0].glyphs[0].position.x;
        let r = right.lines[0].glyphs[0].position.x;
        assert_eq!(l, 0.0);
        assert!(c > l && c < r);
        // Centered + right cases place the line within `max_width = 200`.
        let width = left.lines[0].width;
        assert!((c - (200.0 - width) * 0.5).abs() < 0.001);
        assert!((r - (200.0 - width)).abs() < 0.001);
    }

    #[test]
    fn dpi_scale_doubles_advance_widths_and_baseline_drop() {
        let Some((store, style)) = make_store_and_style(16.0) else {
            return;
        };
        let at_1x = shape("Hello", style, &ShapeParams::default(), &store);
        let at_2x = shape(
            "Hello",
            style,
            &ShapeParams {
                scale: 2.0,
                ..ShapeParams::default()
            },
            &store,
        );
        // Line width at 2× is ~2× at 1×.
        let ratio = at_2x.lines[0].width / at_1x.lines[0].width;
        assert!((ratio - 2.0).abs() < 0.05, "ratio = {ratio}");
        // First glyph's baseline drops at 2× by ~2× the 1× drop.
        let baseline_ratio = at_2x.lines[0].baseline_y / at_1x.lines[0].baseline_y;
        assert!((baseline_ratio - 2.0).abs() < 0.1);
    }

    #[test]
    fn line_height_multiplier_increases_vertical_spacing() {
        let Some((store, style)) = make_store_and_style(16.0) else {
            return;
        };
        let single = shape("a\nb", style, &ShapeParams::default(), &store);
        let spaced = shape(
            "a\nb",
            style,
            &ShapeParams {
                line_height: 2.0,
                ..ShapeParams::default()
            },
            &store,
        );
        let gap_1 = single.lines[1].baseline_y - single.lines[0].baseline_y;
        let gap_2 = spaced.lines[1].baseline_y - spaced.lines[0].baseline_y;
        // Doubling the line-height factor roughly doubles inter-baseline
        // distance — exact ratio depends on the font's gap fraction.
        assert!(
            (gap_2 / gap_1 - 2.0).abs() < 0.05,
            "gap_2/gap_1 = {}",
            gap_2 / gap_1
        );
    }

    #[test]
    fn glyph_keys_are_stable_across_calls() {
        let Some((store, style)) = make_store_and_style(16.0) else {
            return;
        };
        let a = shape("X", style, &ShapeParams::default(), &store);
        let b = shape("X", style, &ShapeParams::default(), &store);
        assert_eq!(a.lines[0].glyphs[0].key, b.lines[0].glyphs[0].key);
    }

    #[test]
    fn multi_font_run_takes_max_ascent_from_largest_size() {
        let Some(font) = try_load_system_font() else {
            return;
        };
        let mut store = FontStore::new();
        let id = store.insert(font);
        let small = TextStyle {
            font: id,
            size_px: 12.0,
        };
        let big = TextStyle {
            font: id,
            size_px: 32.0,
        };
        let mixed = shape_runs(
            &[
                TextRun {
                    text: "Hi ",
                    style: small,
                },
                TextRun {
                    text: "X",
                    style: big,
                },
            ],
            &ShapeParams::default(),
            &store,
        );
        let small_only = shape("Hi", small, &ShapeParams::default(), &store);
        // The big-size baseline must be at least as deep as the small-size
        // baseline because the line's ascent is the max of contributions.
        assert!(mixed.lines[0].baseline_y >= small_only.lines[0].baseline_y);
    }

    #[test]
    fn unknown_font_id_does_not_panic() {
        // No font registered → shape returns no lines instead of panicking.
        let store = FontStore::new();
        let style = TextStyle {
            font: FontId(99),
            size_px: 16.0,
        };
        let out = shape("Hello", style, &ShapeParams::default(), &store);
        assert!(out.lines.is_empty());
    }

    #[test]
    fn no_wrap_when_max_width_is_none() {
        let Some((store, style)) = make_store_and_style(16.0) else {
            return;
        };
        let out = shape(
            "one two three four five",
            style,
            &ShapeParams::default(),
            &store,
        );
        assert_eq!(out.lines.len(), 1);
    }
}
