//! P3 — projecting an org document into Google Docs `batchUpdate` requests.
//!
//! This module is a **pure, total** transform (engineering invariant EI-4): a
//! [`kb::ast::Document`] becomes an ordered [`Vec`] of the generated typed
//! [`google_docs1::api::Request`] structs plus a [`PositionMap`] recording where
//! each anchorable element begins. It performs no IO, never panics, and produces
//! identical output for identical input.
//!
//! Two invariants shape the design:
//!
//! - **DI-7 (UTF-16 indexing).** Every index is a UTF-16 code-unit offset computed
//!   through [`crate::google::utf16::len_utf16`] — never a byte or `char` count.
//! - **Typed requests only.** Requests are built from the generated structs, never
//!   from `serde_json::json!` literals, so a malformed request fails to compile.
//!
//! The projection assumes the cursor starts at index 1 — the first writable
//! position of a freshly created (or freshly cleared) Google Doc body. Creating
//! the doc, clearing an existing one, and issuing the batch are the caller's job
//! (P4a/P7); this module only decides *what* to send.
//!
//! ## Position map and `CUSTOM_ID`s
//!
//! Headings key the map by their `:CUSTOM_ID:` (assigned in the body by P2,
//! [`crate::custom_id`]); other block elements get a hierarchical
//! `<parent>/<type>-<n>` id (DI-8). The map lets pull (Q1) resolve a Google
//! comment's index back to the containing structural element.
//!
//! ## Known degradations (v1)
//!
//! - Table cells carry **plain text only**; inline formatting inside a cell is
//!   dropped (cell indices shift on insert, making in-cell styling fragile).
//! - A horizontal rule renders as a line of `─` glyphs (the Docs API has no
//!   first-class rule insertable via `batchUpdate`).
//! - Soft line breaks inside a paragraph become hard paragraph breaks.

use std::collections::BTreeMap;
use std::fmt::Write as _;

use google_docs1::api::{
    CreateParagraphBulletsRequest, Dimension, InsertTableRequest, InsertTextRequest, Link,
    Location, ParagraphStyle, Range, Request, TextStyle, UpdateParagraphStyleRequest,
    UpdateTextStyleRequest, WeightedFontFamily,
};
use google_docs1::common::FieldMask;
use kb::ast::{Block, Checkbox, Document, Inline, ListItem, ListType, TableCell, Title};
use sha2::{Digest, Sha256};

use crate::custom_id::section_id;
use crate::google::utf16::len_utf16;

/// The structural kind of a projected element, stored alongside its index in the
/// [`PositionMap`] so pull can report what a comment is anchored to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ElementKind {
    /// An org heading (`*`…), projected as a `HEADING_n` named style.
    Heading,
    /// A body paragraph.
    Paragraph,
    /// An ordered, unordered, or checkbox list.
    List,
    /// A table.
    Table,
    /// A `#+begin_src` block, rendered monospace.
    SrcBlock,
    /// A `#+begin_example` block, rendered monospace.
    ExampleBlock,
    /// A block quote.
    Quote,
    /// A horizontal rule.
    HorizontalRule,
}

impl ElementKind {
    /// The slug used when minting a hierarchical id for a non-heading element,
    /// and as this kind's symbol in the sync-state s-expression ([`crate::syncstate`]).
    #[must_use]
    pub const fn slug(self) -> &'static str {
        match self {
            Self::Heading => "heading",
            Self::Paragraph => "paragraph",
            Self::List => "list",
            Self::Table => "table",
            Self::SrcBlock => "src",
            Self::ExampleBlock => "example",
            Self::Quote => "quote",
            Self::HorizontalRule => "hr",
        }
    }

    /// The kind for a [`slug`](Self::slug), or `None` if unrecognized.
    #[must_use]
    pub fn from_slug(slug: &str) -> Option<Self> {
        match slug {
            "heading" => Some(Self::Heading),
            "paragraph" => Some(Self::Paragraph),
            "list" => Some(Self::List),
            "table" => Some(Self::Table),
            "src" => Some(Self::SrcBlock),
            "example" => Some(Self::ExampleBlock),
            "quote" => Some(Self::Quote),
            "hr" => Some(Self::HorizontalRule),
            _ => None,
        }
    }
}

/// Where an anchorable element begins in the projected document.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
    /// The element's first UTF-16 code-unit index in the document body.
    pub index: u32,
    /// The element's structural kind.
    pub kind: ElementKind,
}

/// Map from `CUSTOM_ID` to the projected [`Position`] of that element.
///
/// Keyed by id (a [`BTreeMap`]) so emission is deterministic; pull (Q1) scans the
/// values to find the largest index at or below a comment's anchor.
pub type PositionMap = BTreeMap<String, Position>;

/// The output of [`project`]: the ordered batch requests and the position map.
///
/// [`google_docs1::api::Request`] does not implement `PartialEq`; compare two
/// projections by serializing `requests` rather than via `==`.
#[derive(Debug, Clone)]
pub struct Projection {
    /// The `batchUpdate` requests, in application order.
    pub requests: Vec<Request>,
    /// Where each anchorable element landed.
    pub positions: PositionMap,
}

impl Projection {
    /// A stable fingerprint of the projected requests (a hex SHA-256 of their
    /// serialized form).
    ///
    /// Push records this in `** Sync State`; a re-push whose projection produces
    /// the same fingerprint can skip the full-replace and so preserve the anchors
    /// of existing Google comments (a full-replace deletes the text they anchor
    /// to). Identical input AST ⇒ identical requests ⇒ identical fingerprint.
    #[must_use]
    pub fn fingerprint(&self) -> String {
        // Request serialization is deterministic (struct field order); a Vec writer
        // does not perform IO, so this does not fail in practice — an empty digest
        // input on the impossible error path still yields a stable value.
        let serialized = serde_json::to_vec(&self.requests).unwrap_or_default();
        let mut hasher = Sha256::new();
        hasher.update(&serialized);
        hasher
            .finalize()
            .iter()
            .fold(String::new(), |mut acc, byte| {
                // Writing a byte as two hex digits into a `String` cannot fail.
                let _ = write!(acc, "{byte:02x}");
                acc
            })
    }
}

/// Parent id used for elements that precede the first heading.
///
/// Exposed to [`crate::comments`] (Q1) so anchor resolution can recognize a
/// document-level (pre-heading) position and report it as such rather than as a
/// spurious section.
pub(crate) const DOCUMENT_PARENT: &str = "doc";

/// Font family used for inline code, verbatim spans, and source/example blocks.
const MONOSPACE_FONT: &str = "Courier New";

/// Glyph run a horizontal rule degrades to.
const RULE_GLYPHS: &str = "────────────────────";

/// Checkbox markers prefixed to list items so checkbox state survives projection.
const MARK_UNCHECKED: &str = "\u{2610} ";
const MARK_CHECKED: &str = "\u{2611} ";

/// Project an org document into ordered Docs requests and a position map.
#[must_use]
pub fn project(document: &Document) -> Projection {
    let mut builder = Builder::new();
    builder.blocks(&document.blocks, DOCUMENT_PARENT);
    Projection {
        requests: builder.requests,
        positions: builder.positions,
    }
}

/// Mutable projection state: the request list, the position map, the running
/// UTF-16 cursor, and per-`(parent, kind)` id counters.
struct Builder {
    requests: Vec<Request>,
    positions: PositionMap,
    cursor: u32,
    counters: BTreeMap<String, u32>,
}

impl Builder {
    fn new() -> Self {
        Self {
            requests: Vec::new(),
            positions: PositionMap::new(),
            cursor: 1,
            counters: BTreeMap::new(),
        }
    }

    /// Project a sequence of sibling blocks under `parent`.
    fn blocks(&mut self, blocks: &[Block], parent: &str) {
        for block in blocks {
            self.block(block, parent);
        }
    }

    /// Project one block. Org metadata blocks (drawers, planning, keywords,
    /// comments, blank lines) carry no projected content and are skipped.
    fn block(&mut self, block: &Block, parent: &str) {
        match block {
            Block::Heading {
                level,
                title,
                children,
                ..
            } => self.heading(*level, title, children),
            Block::Paragraph { inlines } => self.paragraph(inlines, parent),
            Block::SrcBlock { content, .. } => {
                self.monospace_block(content, parent, ElementKind::SrcBlock);
            }
            Block::ExampleBlock { content } => {
                self.monospace_block(content, parent, ElementKind::ExampleBlock);
            }
            Block::QuoteBlock { children } => self.quote(children, parent),
            Block::List { list_type, items } => self.list(list_type, items, parent),
            Block::Table { rows } => self.table(rows, parent),
            Block::HorizontalRule => self.horizontal_rule(parent),
            Block::PropertyDrawer { .. }
            | Block::LogbookDrawer { .. }
            | Block::Planning { .. }
            | Block::Comment { .. }
            | Block::Keyword { .. }
            | Block::BlankLine => {}
        }
    }

    /// Project a heading line, then its nested children under the heading's id.
    fn heading(&mut self, level: u8, title: &Title, children: &[Block]) {
        let id = heading_id(title, children);
        let start = self.cursor;
        self.record(&id, ElementKind::Heading, start);
        let (range_start, range_end) = self.insert_line(title.as_str());
        self.push(named_style_request(
            range_start,
            range_end,
            &heading_style(level),
        ));
        self.blocks(children, &id);
    }

    /// Project a normal-text paragraph with its inline formatting.
    fn paragraph(&mut self, inlines: &[Inline], parent: &str) {
        let id = self.next_id(parent, ElementKind::Paragraph);
        let start = self.cursor;
        self.record(&id, ElementKind::Paragraph, start);
        let (text, spans) = flatten(inlines);
        let (range_start, range_end) = self.insert_line(&text);
        self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
        self.styles(range_start, &spans);
    }

    /// Project a source or example block as a single monospace paragraph.
    fn monospace_block(&mut self, content: &str, parent: &str, kind: ElementKind) {
        let id = self.next_id(parent, kind);
        let start = self.cursor;
        self.record(&id, kind, start);
        let (range_start, range_end) = self.insert_line(content);
        self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
        self.push(text_style_request(
            range_start,
            range_end,
            &Style::Monospace,
        ));
    }

    /// Project a block quote as indented normal-text paragraphs.
    fn quote(&mut self, children: &[Block], parent: &str) {
        let id = self.next_id(parent, ElementKind::Quote);
        let start = self.cursor;
        self.record(&id, ElementKind::Quote, start);
        for child in children {
            if let Block::Paragraph { inlines } = child {
                let (text, spans) = flatten(inlines);
                let (range_start, range_end) = self.insert_line(&text);
                self.push(indented_style_request(range_start, range_end));
                self.styles(range_start, &spans);
            } else {
                self.block(child, &id);
            }
        }
    }

    /// Project a list: one paragraph per item, with bullets (plain lists) or a
    /// checkbox-marker prefix (checkbox lists, so checked state is visible).
    fn list(&mut self, list_type: &ListType, items: &[ListItem], parent: &str) {
        let id = self.next_id(parent, ElementKind::List);
        let start = self.cursor;
        self.record(&id, ElementKind::List, start);

        let has_checkbox = items
            .iter()
            .any(|item| !matches!(item.checkbox, Checkbox::NoCheckbox));

        let mut joined = String::new();
        let mut spans: Vec<Span> = Vec::new();
        for item in items {
            let base = len_u32(&joined);
            let prefix = checkbox_marker(&item.checkbox);
            joined.push_str(prefix);
            let item_base = base + len_u32(prefix);
            let (text, item_spans) = flatten_blocks(&item.content);
            for span in item_spans {
                spans.push(span.shifted(item_base));
            }
            joined.push_str(&text);
            joined.push('\n');
        }

        let range_start = self.cursor;
        let range_end = self.emit_text(joined);
        if !has_checkbox {
            self.push(bullets_request(range_start, range_end, list_type));
        }
        self.styles(range_start, &spans);
    }

    /// Project a table: an `InsertTable` skeleton followed by per-cell text
    /// inserts. Cell indices follow the empty-table layout and are issued in
    /// descending order so earlier (lower-index) inserts do not shift later ones.
    fn table(&mut self, rows: &[Vec<TableCell>], parent: &str) {
        let id = self.next_id(parent, ElementKind::Table);
        let start = self.cursor;
        self.record(&id, ElementKind::Table, start);

        let row_count = len_u32_of(rows.len());
        let col_count = len_u32_of(rows.iter().map(Vec::len).max().unwrap_or(0));
        if row_count == 0 || col_count == 0 {
            return;
        }

        self.push(insert_table_request(start, row_count, col_count));

        let mut cells: Vec<(u32, String)> = Vec::new();
        let mut text_units = 0u32;
        for (row_index, row) in rows.iter().enumerate() {
            for (col_index, cell) in row.iter().enumerate() {
                let text = flatten(&cell.inlines).0;
                if text.is_empty() {
                    continue;
                }
                let index = cell_content_index(
                    start,
                    len_u32_of(row_index),
                    len_u32_of(col_index),
                    col_count,
                );
                text_units = text_units.saturating_add(len_u32(&text));
                cells.push((index, text));
            }
        }
        cells.sort_by_key(|cell| core::cmp::Reverse(cell.0));
        for (index, text) in cells {
            self.push(insert_text_request(index, text));
        }

        self.cursor =
            index_after_empty_table(start, row_count, col_count).saturating_add(text_units);
    }

    /// Project a horizontal rule as a degraded glyph paragraph.
    fn horizontal_rule(&mut self, parent: &str) {
        let id = self.next_id(parent, ElementKind::HorizontalRule);
        let start = self.cursor;
        self.record(&id, ElementKind::HorizontalRule, start);
        let (range_start, range_end) = self.insert_line(RULE_GLYPHS);
        self.push(named_style_request(range_start, range_end, "NORMAL_TEXT"));
    }

    /// Insert `text` followed by a newline at the cursor; return the `[start, end)`
    /// range the inserted line occupies and advance the cursor.
    fn insert_line(&mut self, text: &str) -> (u32, u32) {
        let start = self.cursor;
        let mut line = String::with_capacity(text.len() + 1);
        line.push_str(text);
        line.push('\n');
        let end = self.emit_text(line);
        (start, end)
    }

    /// Push an `InsertText` of `text` at the cursor, advance the cursor past it,
    /// and return the new cursor position.
    fn emit_text(&mut self, text: String) -> u32 {
        let start = self.cursor;
        let end = start.saturating_add(len_u32(&text));
        self.push(insert_text_request(start, text));
        self.cursor = end;
        end
    }

    /// Emit an `UpdateTextStyle` request for each inline span, offsetting span
    /// positions by `base` (the absolute index of the span text's start).
    fn styles(&mut self, base: u32, spans: &[Span]) {
        for span in spans {
            let start = base.saturating_add(span.start);
            let end = base.saturating_add(span.end);
            if end <= start {
                continue;
            }
            self.push(text_style_request(start, end, &span.style));
        }
    }

    fn record(&mut self, id: &str, kind: ElementKind, index: u32) {
        self.positions
            .insert(id.to_owned(), Position { index, kind });
    }

    /// Mint the next hierarchical id for a non-heading element under `parent`.
    fn next_id(&mut self, parent: &str, kind: ElementKind) -> String {
        let key = format!("{parent}/{}", kind.slug());
        let count = self.counters.entry(key).or_insert(0);
        *count = count.saturating_add(1);
        format!("{parent}/{}-{count}", kind.slug())
    }

    fn push(&mut self, request: Request) {
        self.requests.push(request);
    }
}

// ── Inline flattening ───────────────────────────────────────────────────────

/// A run of styled text within a paragraph, with UTF-16 offsets relative to the
/// start of the flattened text.
struct Span {
    style: Style,
    start: u32,
    end: u32,
}

impl Span {
    /// Return a copy of this span shifted right by `offset` code units.
    fn shifted(self, offset: u32) -> Self {
        Self {
            style: self.style,
            start: self.start.saturating_add(offset),
            end: self.end.saturating_add(offset),
        }
    }
}

/// A text style applied to a [`Span`].
enum Style {
    Bold,
    Italic,
    Strikethrough,
    Monospace,
    Link(String),
}

/// The visible projected text of an inline sequence (styling discarded).
///
/// Shared with [`crate::comments`] (Q1): the quoted-text fallback matches a
/// Google comment's `quotedFileContent` against the same characters projection
/// sends to the doc, so it must flatten inlines identically.
pub(crate) fn inline_text(inlines: &[Inline]) -> String {
    flatten(inlines).0
}

/// Flatten inline nodes into their visible text plus the styled spans over it.
fn flatten(inlines: &[Inline]) -> (String, Vec<Span>) {
    let mut text = String::new();
    let mut spans = Vec::new();
    flatten_into(inlines, &mut text, &mut spans);
    (text, spans)
}

/// Flatten the inline content of a list item's blocks into one line of text.
/// Multiple paragraphs are space-joined; nested lists contribute their items'
/// text in order.
fn flatten_blocks(blocks: &[Block]) -> (String, Vec<Span>) {
    let mut text = String::new();
    let mut spans = Vec::new();
    collect_block_inlines(blocks, &mut text, &mut spans);
    (text, spans)
}

fn collect_block_inlines(blocks: &[Block], text: &mut String, spans: &mut Vec<Span>) {
    for block in blocks {
        match block {
            Block::Paragraph { inlines } => {
                if !text.is_empty() {
                    text.push(' ');
                }
                flatten_into(inlines, text, spans);
            }
            Block::List { items, .. } => {
                for item in items {
                    collect_block_inlines(&item.content, text, spans);
                }
            }
            _ => {}
        }
    }
}

fn flatten_into(inlines: &[Inline], text: &mut String, spans: &mut Vec<Span>) {
    for inline in inlines {
        match inline {
            Inline::Plain(value) => text.push_str(value),
            Inline::Bold(children) => wrap(children, Style::Bold, text, spans),
            Inline::Italic(children) => wrap(children, Style::Italic, text, spans),
            Inline::Strikethrough(children) => wrap(children, Style::Strikethrough, text, spans),
            Inline::InlineCode(value) | Inline::Verbatim(value) => {
                push_span(value, Style::Monospace, text, spans);
            }
            Inline::LineBreak => text.push('\n'),
            Inline::Link {
                target,
                description,
            } => {
                let visible = description.as_deref().unwrap_or(target);
                push_span(visible, Style::Link(target.clone()), text, spans);
            }
        }
    }
}

/// Append `children`'s flattened text, recording a span of `style` over it.
fn wrap(children: &[Inline], style: Style, text: &mut String, spans: &mut Vec<Span>) {
    let start = len_u32(text);
    flatten_into(children, text, spans);
    let end = len_u32(text);
    spans.push(Span { style, start, end });
}

/// Append literal `value`, recording a span of `style` over it.
fn push_span(value: &str, style: Style, text: &mut String, spans: &mut Vec<Span>) {
    let start = len_u32(text);
    text.push_str(value);
    let end = len_u32(text);
    spans.push(Span { style, start, end });
}

// ── Id and style helpers ────────────────────────────────────────────────────

/// The id for a heading: its `:CUSTOM_ID:` property when present (P2 inserts it
/// in the body), else the deterministic `section_id` of its title.
///
/// Shared with [`crate::comments`] (Q1) so the section keys it builds for the
/// quoted-text fallback match the keys [`project`] records in the position map.
pub(crate) fn heading_id(title: &Title, children: &[Block]) -> String {
    children
        .iter()
        .find_map(|block| match block {
            Block::PropertyDrawer { entries } => entries.iter().find_map(|(key, value)| {
                key.eq_ignore_ascii_case("CUSTOM_ID")
                    .then(|| value.trim().to_owned())
            }),
            _ => None,
        })
        .filter(|id| !id.is_empty())
        .unwrap_or_else(|| section_id(title.as_str()))
}

/// The `HEADING_n` named style for an org heading level, clamped to 1..=6.
fn heading_style(level: u8) -> String {
    format!("HEADING_{}", level.clamp(1, 6))
}

/// The checkbox marker prefixed to a list item, or `""` for a plain item.
const fn checkbox_marker(checkbox: &Checkbox) -> &'static str {
    match checkbox {
        Checkbox::Unchecked => MARK_UNCHECKED,
        Checkbox::Checked => MARK_CHECKED,
        Checkbox::NoCheckbox => "",
    }
}

// ── Table index arithmetic ──────────────────────────────────────────────────
//
// Inserting an `R×C` table at location index `L` yields the empty-table layout
// (Google Docs API, confirmed against a live round-trip — see the
// `live_probe_table` test notes): Google inserts a newline paragraph *before* the
// table (table start = L+1, first cell paragraph = L+4), each empty cell costs two
// index units (cell boundary + empty paragraph), each row adds a one-unit
// boundary, and a trailing paragraph follows the table.

/// The index at which to insert text into the empty cell at `(row, col)` of a
/// `cols`-wide table inserted at `table_loc`.
///
/// The `+ 4` absorbs the pre-table newline plus the table/row/cell openings;
/// verified live (a 3×2 probe placed cell `(0,0)` at `L + 4`).
const fn cell_content_index(table_loc: u32, row: u32, col: u32, cols: u32) -> u32 {
    table_loc + 4 + row * (1 + 2 * cols) + col * 2
}

/// The index of the paragraph immediately after an *empty* `rows`×`cols` table
/// inserted at `table_loc` (before any cell text is inserted).
///
/// The constant is `+ 3`, not `+ 2`: the empty table spans `[L+1, L+2+R(1+2C))`,
/// and Google's pre-table newline shifts the trailing paragraph one further. A
/// live 3×2 probe (`L = 1`) reported the post-table paragraph at index 19
/// (`1 + 3 + 3·5`); the earlier `+ 2` (→ 18) mis-indexed every request after a
/// table, which only a live push surfaced.
const fn index_after_empty_table(table_loc: u32, rows: u32, cols: u32) -> u32 {
    table_loc + 3 + rows * (1 + 2 * cols)
}

// ── Typed request constructors ──────────────────────────────────────────────

fn location(index: u32) -> Location {
    Location {
        index: Some(to_i32(index)),
        segment_id: None,
        tab_id: None,
    }
}

fn range(start: u32, end: u32) -> Range {
    Range {
        start_index: Some(to_i32(start)),
        end_index: Some(to_i32(end)),
        segment_id: None,
        tab_id: None,
    }
}

fn insert_text_request(index: u32, text: String) -> Request {
    Request {
        insert_text: Some(InsertTextRequest {
            location: Some(location(index)),
            text: Some(text),
            end_of_segment_location: None,
        }),
        ..Request::default()
    }
}

fn insert_table_request(location_index: u32, rows: u32, cols: u32) -> Request {
    Request {
        insert_table: Some(InsertTableRequest {
            location: Some(location(location_index)),
            rows: Some(to_i32(rows)),
            columns: Some(to_i32(cols)),
            end_of_segment_location: None,
        }),
        ..Request::default()
    }
}

fn named_style_request(start: u32, end: u32, named_style: &str) -> Request {
    Request {
        update_paragraph_style: Some(UpdateParagraphStyleRequest {
            range: Some(range(start, end)),
            paragraph_style: Some(ParagraphStyle {
                named_style_type: Some(named_style.to_owned()),
                ..ParagraphStyle::default()
            }),
            fields: Some(FieldMask::new(&["namedStyleType"])),
        }),
        ..Request::default()
    }
}

fn indented_style_request(start: u32, end: u32) -> Request {
    Request {
        update_paragraph_style: Some(UpdateParagraphStyleRequest {
            range: Some(range(start, end)),
            paragraph_style: Some(ParagraphStyle {
                named_style_type: Some("NORMAL_TEXT".to_owned()),
                indent_start: Some(Dimension {
                    magnitude: Some(36.0),
                    unit: Some("PT".to_owned()),
                }),
                ..ParagraphStyle::default()
            }),
            fields: Some(FieldMask::new(&["namedStyleType", "indentStart"])),
        }),
        ..Request::default()
    }
}

fn bullets_request(start: u32, end: u32, list_type: &ListType) -> Request {
    let preset = match list_type {
        ListType::Ordered(_) => "NUMBERED_DECIMAL_ALPHA_ROMAN",
        ListType::Unordered => "BULLET_DISC_CIRCLE_SQUARE",
    };
    Request {
        create_paragraph_bullets: Some(CreateParagraphBulletsRequest {
            range: Some(range(start, end)),
            bullet_preset: Some(preset.to_owned()),
        }),
        ..Request::default()
    }
}

fn text_style_request(start: u32, end: u32, style: &Style) -> Request {
    let (text_style, fields) = match style {
        Style::Bold => (
            TextStyle {
                bold: Some(true),
                ..TextStyle::default()
            },
            "bold",
        ),
        Style::Italic => (
            TextStyle {
                italic: Some(true),
                ..TextStyle::default()
            },
            "italic",
        ),
        Style::Strikethrough => (
            TextStyle {
                strikethrough: Some(true),
                ..TextStyle::default()
            },
            "strikethrough",
        ),
        Style::Monospace => (
            TextStyle {
                weighted_font_family: Some(WeightedFontFamily {
                    font_family: Some(MONOSPACE_FONT.to_owned()),
                    weight: Some(400),
                }),
                ..TextStyle::default()
            },
            "weightedFontFamily",
        ),
        Style::Link(url) => (
            TextStyle {
                link: Some(Link {
                    url: Some(url.clone()),
                    ..Link::default()
                }),
                ..TextStyle::default()
            },
            "link",
        ),
    };
    Request {
        update_text_style: Some(UpdateTextStyleRequest {
            range: Some(range(start, end)),
            text_style: Some(text_style),
            fields: Some(FieldMask::new(&[fields])),
        }),
        ..Request::default()
    }
}

// ── Numeric helpers (UTF-16, saturating, panic-free per EI-2) ────────────────

/// UTF-16 length of `text` as a `u32`, saturating (documents never approach the
/// limit; saturation avoids a panic on the impossible case).
fn len_u32(text: &str) -> u32 {
    u32::try_from(len_utf16(text)).unwrap_or(u32::MAX)
}

/// A collection length as a `u32`, saturating.
fn len_u32_of(value: usize) -> u32 {
    u32::try_from(value).unwrap_or(u32::MAX)
}

/// A `u32` index as the API's `i32`, saturating.
fn to_i32(value: u32) -> i32 {
    i32::try_from(value).unwrap_or(i32::MAX)
}

#[cfg(test)]
mod tests {
    use super::{ElementKind, Style, project, text_style_request};
    use kb::ast::{Block, Checkbox, Document, Inline, ListItem, ListType, TableCell, Title};

    fn heading(level: u8, title: &str, children: Vec<Block>) -> Block {
        Block::Heading {
            level,
            title: Title(title.to_owned()),
            tags: vec![],
            children,
        }
    }

    fn custom_id_drawer(id: &str) -> Block {
        Block::PropertyDrawer {
            entries: vec![("CUSTOM_ID".to_owned(), format!(" {id}"))],
        }
    }

    fn paragraph(inlines: Vec<Inline>) -> Block {
        Block::Paragraph { inlines }
    }

    fn plain(text: &str) -> Inline {
        Inline::Plain(text.to_owned())
    }

    fn doc(blocks: Vec<Block>) -> Document {
        Document { blocks }
    }

    #[test]
    fn heading_emits_insert_then_named_style() {
        let projection = project(&doc(vec![heading(2, "Hi", vec![])]));
        assert_eq!(projection.requests.len(), 2);

        let insert = projection.requests[0].insert_text.as_ref().expect("insert");
        assert_eq!(insert.text.as_deref(), Some("Hi\n"));
        assert_eq!(insert.location.as_ref().and_then(|l| l.index), Some(1));

        let style = projection.requests[1]
            .update_paragraph_style
            .as_ref()
            .expect("style");
        assert_eq!(
            style
                .paragraph_style
                .as_ref()
                .and_then(|p| p.named_style_type.as_deref()),
            Some("HEADING_2")
        );
        let range = style.range.as_ref().expect("range");
        assert_eq!((range.start_index, range.end_index), (Some(1), Some(4)));
    }

    #[test]
    fn heading_level_clamped_to_six() {
        let projection = project(&doc(vec![heading(9, "Deep", vec![])]));
        let style = projection.requests[1]
            .update_paragraph_style
            .as_ref()
            .unwrap();
        assert_eq!(
            style
                .paragraph_style
                .as_ref()
                .and_then(|p| p.named_style_type.as_deref()),
            Some("HEADING_6")
        );
    }

    #[test]
    fn paragraph_nested_bold_italic_and_link() {
        // "a " + bold(italic("bc")) + " " + link("d")  => "a bc d\n"
        let para = paragraph(vec![
            plain("a "),
            Inline::Bold(vec![Inline::Italic(vec![plain("bc")])]),
            plain(" "),
            Inline::Link {
                target: "https://x".to_owned(),
                description: Some("d".to_owned()),
            },
        ]);
        let projection = project(&doc(vec![para]));

        let insert = projection.requests[0].insert_text.as_ref().unwrap();
        assert_eq!(insert.text.as_deref(), Some("a bc d\n"));
        assert_eq!(
            projection.requests[1]
                .update_paragraph_style
                .as_ref()
                .and_then(|s| s.paragraph_style.as_ref())
                .and_then(|p| p.named_style_type.as_deref()),
            Some("NORMAL_TEXT")
        );

        // Inner italic span pushed before the enclosing bold span; both cover "bc"
        // at indices 2..4. The link covers "d" at 5..6.
        let italic = projection.requests[2].update_text_style.as_ref().unwrap();
        assert_eq!(
            italic.text_style.as_ref().and_then(|t| t.italic),
            Some(true)
        );
        let italic_range = italic.range.as_ref().unwrap();
        assert_eq!(
            (italic_range.start_index, italic_range.end_index),
            (Some(3), Some(5))
        );

        let bold = projection.requests[3].update_text_style.as_ref().unwrap();
        assert_eq!(bold.text_style.as_ref().and_then(|t| t.bold), Some(true));
        let bold_range = bold.range.as_ref().unwrap();
        assert_eq!(
            (bold_range.start_index, bold_range.end_index),
            (Some(3), Some(5))
        );

        let link = projection.requests[4].update_text_style.as_ref().unwrap();
        assert_eq!(
            link.text_style
                .as_ref()
                .and_then(|t| t.link.as_ref())
                .and_then(|l| l.url.as_deref()),
            Some("https://x")
        );
        let link_range = link.range.as_ref().unwrap();
        assert_eq!(
            (link_range.start_index, link_range.end_index),
            (Some(6), Some(7))
        );
    }

    #[test]
    fn inline_code_is_monospace() {
        let projection = project(&doc(vec![paragraph(vec![Inline::InlineCode(
            "x".to_owned(),
        )])]));
        let style = projection.requests[2].update_text_style.as_ref().unwrap();
        assert_eq!(
            style
                .text_style
                .as_ref()
                .and_then(|t| t.weighted_font_family.as_ref())
                .and_then(|f| f.font_family.as_deref()),
            Some("Courier New")
        );
    }

    #[test]
    fn unordered_list_inserts_joined_text_and_bullets() {
        let list = Block::List {
            list_type: ListType::Unordered,
            items: vec![
                ListItem {
                    content: vec![paragraph(vec![plain("A")])],
                    checkbox: Checkbox::NoCheckbox,
                },
                ListItem {
                    content: vec![paragraph(vec![plain("B")])],
                    checkbox: Checkbox::NoCheckbox,
                },
            ],
        };
        let projection = project(&doc(vec![list]));
        assert_eq!(
            projection.requests[0]
                .insert_text
                .as_ref()
                .unwrap()
                .text
                .as_deref(),
            Some("A\nB\n")
        );
        let bullets = projection.requests[1]
            .create_paragraph_bullets
            .as_ref()
            .unwrap();
        assert_eq!(
            bullets.bullet_preset.as_deref(),
            Some("BULLET_DISC_CIRCLE_SQUARE")
        );
    }

    #[test]
    fn ordered_list_uses_numbered_preset() {
        let list = Block::List {
            list_type: ListType::Ordered(1),
            items: vec![ListItem {
                content: vec![paragraph(vec![plain("only")])],
                checkbox: Checkbox::NoCheckbox,
            }],
        };
        let projection = project(&doc(vec![list]));
        assert_eq!(
            projection.requests[1]
                .create_paragraph_bullets
                .as_ref()
                .unwrap()
                .bullet_preset
                .as_deref(),
            Some("NUMBERED_DECIMAL_ALPHA_ROMAN")
        );
    }

    #[test]
    fn checkbox_list_marks_state_and_omits_bullets() {
        let list = Block::List {
            list_type: ListType::Unordered,
            items: vec![
                ListItem {
                    content: vec![paragraph(vec![plain("done")])],
                    checkbox: Checkbox::Checked,
                },
                ListItem {
                    content: vec![paragraph(vec![plain("todo")])],
                    checkbox: Checkbox::Unchecked,
                },
            ],
        };
        let projection = project(&doc(vec![list]));
        assert_eq!(
            projection.requests[0]
                .insert_text
                .as_ref()
                .unwrap()
                .text
                .as_deref(),
            Some("\u{2611} done\n\u{2610} todo\n")
        );
        // No bullets request follows the insert for a checkbox list.
        assert!(
            projection
                .requests
                .iter()
                .all(|r| r.create_paragraph_bullets.is_none())
        );
    }

    #[test]
    fn table_inserts_skeleton_then_cells_descending() {
        let cell = |text: &str| TableCell {
            inlines: vec![plain(text)],
        };
        let table = Block::Table {
            rows: vec![vec![cell("A1"), cell("B1")], vec![cell("A2"), cell("B2")]],
        };
        // A paragraph follows the table so the post-table cursor is asserted: the
        // bug a live push caught (every request after a table was off by one) was
        // invisible while the table was the only block.
        let projection = project(&doc(vec![
            table,
            Block::Paragraph {
                inlines: vec![plain("after")],
            },
        ]));

        let insert_table = projection.requests[0].insert_table.as_ref().unwrap();
        assert_eq!(
            (insert_table.rows, insert_table.columns),
            (Some(2), Some(2))
        );
        assert_eq!(
            insert_table.location.as_ref().and_then(|l| l.index),
            Some(1)
        );

        // Cell inserts in descending index order: B2@12, A2@10, B1@7, A1@5.
        let cells = &projection.requests[1..5];
        let cell_indices: Vec<(Option<i32>, Option<&str>)> = cells
            .iter()
            .map(|r| {
                let insert = r.insert_text.as_ref().unwrap();
                (
                    insert.location.as_ref().and_then(|l| l.index),
                    insert.text.as_deref(),
                )
            })
            .collect();
        assert_eq!(
            cell_indices,
            vec![
                (Some(12), Some("B2")),
                (Some(10), Some("A2")),
                (Some(7), Some("B1")),
                (Some(5), Some("A1")),
            ]
        );

        // The paragraph after the table inserts at the post-table cursor:
        // empty 2×2 table ends at 1 + 3 + 2·5 = 14, plus 8 units of cell text → 22.
        // (Live-validated geometry; the pre-fix `+ 2` would have put this at 21.)
        let after = projection.requests[5].insert_text.as_ref().unwrap();
        assert_eq!(after.text.as_deref(), Some("after\n"));
        assert_eq!(after.location.as_ref().and_then(|l| l.index), Some(22));

        // Position recorded at the table start.
        let position = projection.positions.get("doc/table-1").unwrap();
        assert_eq!((position.index, position.kind), (1, ElementKind::Table));
    }

    #[test]
    fn src_block_is_monospace() {
        let projection = project(&doc(vec![Block::SrcBlock {
            language: "rust".to_owned(),
            content: "fn main() {}".to_owned(),
        }]));
        assert_eq!(
            projection.requests[0]
                .insert_text
                .as_ref()
                .unwrap()
                .text
                .as_deref(),
            Some("fn main() {}\n")
        );
        assert_eq!(
            projection.requests[2]
                .update_text_style
                .as_ref()
                .and_then(|s| s.text_style.as_ref())
                .and_then(|t| t.weighted_font_family.as_ref())
                .and_then(|f| f.font_family.as_deref()),
            Some("Courier New")
        );
    }

    #[test]
    fn horizontal_rule_degrades_to_glyphs() {
        let projection = project(&doc(vec![Block::HorizontalRule]));
        let text = projection.requests[0]
            .insert_text
            .as_ref()
            .unwrap()
            .text
            .as_deref();
        assert!(text.is_some_and(|t| t.starts_with('\u{2500}')));
        assert!(projection.positions.contains_key("doc/hr-1"));
    }

    #[test]
    fn quote_paragraphs_are_indented() {
        let quote = Block::QuoteBlock {
            children: vec![paragraph(vec![plain("quoted")])],
        };
        let projection = project(&doc(vec![quote]));
        let style = projection.requests[1]
            .update_paragraph_style
            .as_ref()
            .unwrap();
        assert!(
            style
                .paragraph_style
                .as_ref()
                .and_then(|p| p.indent_start.as_ref())
                .is_some()
        );
        assert!(projection.positions.contains_key("doc/quote-1"));
    }

    #[test]
    fn position_map_uses_custom_id_and_hierarchical_ids() {
        let body = heading(
            1,
            "Intro",
            vec![
                custom_id_drawer("sec-intro"),
                paragraph(vec![plain("first")]),
                paragraph(vec![plain("second")]),
            ],
        );
        let projection = project(&doc(vec![body]));

        let head = projection.positions.get("sec-intro").expect("heading id");
        assert_eq!((head.index, head.kind), (1, ElementKind::Heading));

        // Paragraphs under the heading are keyed by <parent>/<type>-<n>.
        assert!(projection.positions.contains_key("sec-intro/paragraph-1"));
        assert!(projection.positions.contains_key("sec-intro/paragraph-2"));

        // Indices advance monotonically through the document.
        let p1 = projection.positions["sec-intro/paragraph-1"].index;
        let p2 = projection.positions["sec-intro/paragraph-2"].index;
        assert!(head.index < p1 && p1 < p2);
    }

    #[test]
    fn metadata_blocks_produce_no_requests() {
        let blocks = vec![
            Block::BlankLine,
            Block::Comment {
                text: "hidden".to_owned(),
            },
            Block::Keyword {
                name: "TITLE".to_owned(),
                value: " Doc".to_owned(),
            },
        ];
        let projection = project(&doc(blocks));
        assert!(projection.requests.is_empty());
        assert!(projection.positions.is_empty());
    }

    #[test]
    fn empty_table_emits_no_requests() {
        let projection = project(&doc(vec![Block::Table { rows: vec![] }]));
        assert!(projection.requests.is_empty());
    }

    #[test]
    fn text_style_request_sets_strikethrough() {
        let request = text_style_request(1, 3, &Style::Strikethrough);
        assert_eq!(
            request
                .update_text_style
                .and_then(|s| s.text_style)
                .and_then(|t| t.strikethrough),
            Some(true)
        );
    }
}
