use chrono::{DateTime, Utc};
use quick_xml::escape::unescape;
use quick_xml::events::Event;
use quick_xml::Reader;

use crate::model::{
    Block, BlockKind, RawRevisionIds, SpanChangeKind, SpanTrackedChange, Story, TextSpan,
    TrackedChange, TrackedChangeKind,
};
use crate::Error;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RevisionKind {
    Insert,
    Delete,
    MoveFrom,
    MoveTo,
}

#[derive(Clone, Debug)]
struct RevisionContext {
    kind: RevisionKind,
    span_kind: SpanChangeKind,
    change_id: String,
    author: Option<String>,
    date: Option<DateTime<Utc>>,
    revision_id: Option<String>,
    text: String,
    block_id: String,
}

struct RevisionAttributes {
    author: Option<String>,
    date: Option<DateTime<Utc>>,
    revision_id: Option<String>,
}

#[derive(Debug, Default)]
struct RevisionCounters {
    insert: usize,
    delete: usize,
    move_from: usize,
    move_to: usize,
    format: usize,
}

#[derive(Debug)]
struct CellContext {
    table_number: usize,
    row: usize,
    col: usize,
    text: String,
    spans: Vec<TextSpan>,
    footnote_refs: Vec<u32>,
    endnote_refs: Vec<u32>,
    visible_char_offset: usize,
    paragraph_count: usize,
}

#[derive(Debug, Default)]
struct ParagraphState {
    text: String,
    spans: Vec<TextSpan>,
    footnote_refs: Vec<u32>,
    endnote_refs: Vec<u32>,
    visible_char_offset: usize,
    list_level: Option<usize>,
    style: Option<String>,
}

impl ParagraphState {
    fn reset(&mut self) {
        self.text.clear();
        self.spans.clear();
        self.footnote_refs.clear();
        self.endnote_refs.clear();
        self.visible_char_offset = 0;
        self.list_level = None;
        self.style = None;
    }
}

#[derive(Debug, Default)]
struct TableState {
    depth: usize,
    current_table_number: usize,
    current_row: usize,
    current_col: usize,
}

impl TableState {
    fn enter_table(&mut self) {
        self.depth += 1;
        if self.depth == 1 {
            self.current_table_number += 1;
            self.current_row = 0;
            self.current_col = 0;
        }
    }

    fn leave_table(&mut self) {
        self.depth = self.depth.saturating_sub(1);
    }

    fn is_top_level(&self) -> bool {
        self.depth == 1
    }

    fn start_row(&mut self) {
        self.current_col = 0;
    }

    fn finish_row(&mut self) {
        self.current_row += 1;
    }

    fn finish_cell(&mut self) {
        self.current_col += 1;
    }
}

#[derive(Debug, Default)]
pub(crate) struct BodyParseResult {
    pub blocks: Vec<Block>,
    pub raw_tracked_changes: Vec<TrackedChange>,
    pub comment_anchors: Vec<CommentAnchorEvent>,
}

pub(crate) fn parse_document_body(xml: &str) -> Result<BodyParseResult, Error> {
    parse_story_part(xml, ParseMode::Body, Some(StoryContext::Body))
}

pub(crate) fn parse_footnotes_part(xml: &str) -> Result<BodyParseResult, Error> {
    parse_story_part(xml, ParseMode::Footnotes, None)
}

pub(crate) fn parse_endnotes_part(xml: &str) -> Result<BodyParseResult, Error> {
    parse_story_part(xml, ParseMode::Endnotes, None)
}

pub(crate) fn parse_header_part(xml: &str, index: u32) -> Result<BodyParseResult, Error> {
    parse_story_part(xml, ParseMode::Header, Some(StoryContext::Header { index }))
}

pub(crate) fn parse_footer_part(xml: &str, index: u32) -> Result<BodyParseResult, Error> {
    parse_story_part(xml, ParseMode::Footer, Some(StoryContext::Footer { index }))
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ParseMode {
    Body,
    Footnotes,
    Endnotes,
    Header,
    Footer,
}

#[derive(Clone, Debug, PartialEq, Eq)]
enum StoryContext {
    Body,
    Header { index: u32 },
    Footer { index: u32 },
    Footnote { index: u32 },
    Endnote { index: u32 },
}

impl StoryContext {
    fn prefix(&self) -> String {
        match self {
            Self::Body => "body:".to_string(),
            Self::Header { index } => format!("header:{index}:"),
            Self::Footer { index } => format!("footer:{index}:"),
            Self::Footnote { index } => format!("footnote:{index}:"),
            Self::Endnote { index } => format!("endnote:{index}:"),
        }
    }

    fn story(&self) -> Story {
        match self {
            Self::Body => Story::Body,
            Self::Header { index } => Story::Header { index: *index },
            Self::Footer { index } => Story::Footer { index: *index },
            Self::Footnote { index } => Story::Footnote { index: *index },
            Self::Endnote { index } => Story::Endnote { index: *index },
        }
    }

    fn block_id(&self, paragraph_index: usize) -> String {
        format!("{}block:{:06}", self.prefix(), paragraph_index + 1)
    }

    fn table_cell_id(&self, table_number: usize, row: usize, col: usize) -> String {
        format!("{}table:{table_number}:row:{row}:col:{col}", self.prefix())
    }

    fn table_id(&self, table_number: usize) -> String {
        match self {
            Self::Body => table_number.to_string(),
            Self::Header { index } => format!("header:{index}:table:{table_number}"),
            Self::Footer { index } => format!("footer:{index}:table:{table_number}"),
            Self::Footnote { index } => format!("footnote:{index}:table:{table_number}"),
            Self::Endnote { index } => format!("endnote:{index}:table:{table_number}"),
        }
    }
}

fn parse_story_part(
    xml: &str,
    mode: ParseMode,
    initial_story: Option<StoryContext>,
) -> Result<BodyParseResult, Error> {
    let mut reader = Reader::from_str(xml);
    reader.config_mut().trim_text(false);
    let mut buf = Vec::new();

    let mut blocks = Vec::new();
    let mut current_paragraph = ParagraphState::default();
    let mut in_paragraph = false;
    let mut in_num_pr = false;
    let mut in_p_pr = false;
    let mut in_run = false;
    let mut current_run_format_changes: Vec<SpanTrackedChange> = Vec::new();
    let mut revision_stack: Vec<RevisionContext> = Vec::new();
    let mut raw_tracked_changes = Vec::new();
    let mut comment_anchors = Vec::new();
    let mut revision_counters = RevisionCounters::default();
    let mut table_state = TableState::default();
    let mut current_cell: Option<CellContext> = None;
    let mut active_story = initial_story;
    let mut story_paragraph_index = 0usize;

    loop {
        match reader.read_event_into(&mut buf)? {
            Event::Start(ref event) => match event.local_name().as_ref() {
                b"footnote" if mode == ParseMode::Footnotes => {
                    active_story = parse_note_story(event, NoteKind::Footnote);
                    story_paragraph_index = 0;
                }
                b"endnote" if mode == ParseMode::Endnotes => {
                    active_story = parse_note_story(event, NoteKind::Endnote);
                    story_paragraph_index = 0;
                }
                b"p" => {
                    if active_story.is_none() {
                        buf.clear();
                        continue;
                    }
                    in_paragraph = true;
                    if let Some(cell) = current_cell.as_mut() {
                        if cell.paragraph_count > 0 && !cell.text.ends_with('\n') {
                            append_visible_char(
                                &mut revision_stack,
                                &mut cell.text,
                                &mut cell.spans,
                                &mut cell.visible_char_offset,
                                '\n',
                            );
                        }
                        cell.paragraph_count += 1;
                    } else {
                        current_paragraph.reset();
                    }
                }
                b"r" if in_paragraph => {
                    in_run = true;
                    current_run_format_changes.clear();
                }
                b"pPr" if in_paragraph => {
                    in_p_pr = true;
                }
                b"numPr" if in_paragraph => {
                    in_num_pr = true;
                }
                b"tbl" => {
                    if active_story.is_none() {
                        buf.clear();
                        continue;
                    }
                    table_state.enter_table();
                }
                b"tr" if table_state.is_top_level() => {
                    table_state.start_row();
                }
                b"tc" if table_state.is_top_level() => {
                    current_cell = Some(CellContext {
                        table_number: table_state.current_table_number,
                        row: table_state.current_row,
                        col: table_state.current_col,
                        text: String::new(),
                        spans: Vec::new(),
                        footnote_refs: Vec::new(),
                        endnote_refs: Vec::new(),
                        visible_char_offset: 0,
                        paragraph_count: 0,
                    });
                }
                b"ins" if in_paragraph => revision_stack.push(parse_revision_context(
                    event,
                    RevisionKind::Insert,
                    current_active_block_id(
                        &active_story,
                        story_paragraph_index,
                        current_cell.as_ref(),
                        "active story while in paragraph",
                    ),
                    &mut revision_counters,
                )),
                b"del" if in_paragraph => revision_stack.push(parse_revision_context(
                    event,
                    RevisionKind::Delete,
                    current_active_block_id(
                        &active_story,
                        story_paragraph_index,
                        current_cell.as_ref(),
                        "active story while in paragraph",
                    ),
                    &mut revision_counters,
                )),
                b"moveFrom" if in_paragraph => revision_stack.push(parse_revision_context(
                    event,
                    RevisionKind::MoveFrom,
                    current_active_block_id(
                        &active_story,
                        story_paragraph_index,
                        current_cell.as_ref(),
                        "active story while in paragraph",
                    ),
                    &mut revision_counters,
                )),
                b"moveTo" if in_paragraph => revision_stack.push(parse_revision_context(
                    event,
                    RevisionKind::MoveTo,
                    current_active_block_id(
                        &active_story,
                        story_paragraph_index,
                        current_cell.as_ref(),
                        "active story while in paragraph",
                    ),
                    &mut revision_counters,
                )),
                b"commentRangeStart" if in_paragraph => {
                    if let Some(comment_id) = parse_comment_marker_id(event) {
                        comment_anchors.push(CommentAnchorEvent {
                            comment_id,
                            block_id: current_active_block_id(
                                &active_story,
                                story_paragraph_index,
                                current_cell.as_ref(),
                                "active story while in paragraph",
                            ),
                            char_offset: current_visible_char_offset(
                                current_cell.as_ref(),
                                current_paragraph.visible_char_offset,
                            ),
                            kind: CommentAnchorEventKind::Start,
                        });
                    }
                }
                b"rPrChange" if in_paragraph && in_run => {
                    let change = parse_format_change(
                        &mut reader,
                        event,
                        false,
                        current_active_block_id(
                            &active_story,
                            story_paragraph_index,
                            current_cell.as_ref(),
                            "active story while in paragraph",
                        ),
                        &mut revision_counters,
                    )?;
                    current_run_format_changes.push(SpanTrackedChange {
                        id: change.id.clone(),
                        kind: SpanChangeKind::Format,
                    });
                    raw_tracked_changes.push(change);
                }
                b"t" | b"delText" if in_paragraph => {
                    let text = decode_xml_text(&reader.read_text(event.name())?)?;
                    if let Some(current_revision) = revision_stack.last_mut() {
                        current_revision.text.push_str(text.as_ref());
                    }

                    if should_include_text(&revision_stack) {
                        append_visible_text(
                            &text,
                            current_cell.as_mut(),
                            &mut current_paragraph,
                            active_span_changes(&revision_stack, &current_run_format_changes),
                        );
                    }
                }
                b"ilvl" if in_paragraph && in_num_pr => {
                    if let Some(level) = parse_list_level(event) {
                        current_paragraph.list_level = Some(level);
                    }
                }
                b"pStyle" if in_paragraph && in_p_pr => {
                    current_paragraph.style = parse_paragraph_style(event);
                }
                _ => {}
            },
            Event::Empty(ref event) => match event.local_name().as_ref() {
                b"commentRangeStart" if in_paragraph => {
                    if let Some(comment_id) = parse_comment_marker_id(event) {
                        comment_anchors.push(CommentAnchorEvent {
                            comment_id,
                            block_id: current_active_block_id(
                                &active_story,
                                story_paragraph_index,
                                current_cell.as_ref(),
                                "active story while in paragraph",
                            ),
                            char_offset: current_visible_char_offset(
                                current_cell.as_ref(),
                                current_paragraph.visible_char_offset,
                            ),
                            kind: CommentAnchorEventKind::Start,
                        });
                    }
                }
                b"commentRangeEnd" if in_paragraph => {
                    if let Some(comment_id) = parse_comment_marker_id(event) {
                        comment_anchors.push(CommentAnchorEvent {
                            comment_id,
                            block_id: current_active_block_id(
                                &active_story,
                                story_paragraph_index,
                                current_cell.as_ref(),
                                "active story while in paragraph",
                            ),
                            char_offset: current_visible_char_offset(
                                current_cell.as_ref(),
                                current_paragraph.visible_char_offset,
                            ),
                            kind: CommentAnchorEventKind::End,
                        });
                    }
                }
                b"commentReference" if in_paragraph => {
                    if let Some(comment_id) = parse_comment_marker_id(event) {
                        comment_anchors.push(CommentAnchorEvent {
                            comment_id,
                            block_id: current_active_block_id(
                                &active_story,
                                story_paragraph_index,
                                current_cell.as_ref(),
                                "active story while in paragraph",
                            ),
                            char_offset: current_visible_char_offset(
                                current_cell.as_ref(),
                                current_paragraph.visible_char_offset,
                            ),
                            kind: CommentAnchorEventKind::Reference,
                        });
                    }
                }
                b"tab" if in_paragraph => {
                    let include_text = should_include_text(&revision_stack);
                    append_visible_char_by_context(
                        &mut revision_stack,
                        &current_run_format_changes,
                        current_cell.as_mut(),
                        &mut current_paragraph,
                        in_paragraph,
                        include_text,
                        '\t',
                    );
                }
                b"br" if in_paragraph => {
                    if !should_emit_line_break(event) {
                        buf.clear();
                        continue;
                    }
                    let include_text = should_include_text(&revision_stack);
                    append_visible_char_by_context(
                        &mut revision_stack,
                        &current_run_format_changes,
                        current_cell.as_mut(),
                        &mut current_paragraph,
                        in_paragraph,
                        include_text,
                        '\n',
                    );
                }
                b"cr" if in_paragraph => {
                    let include_text = should_include_text(&revision_stack);
                    append_visible_char_by_context(
                        &mut revision_stack,
                        &current_run_format_changes,
                        current_cell.as_mut(),
                        &mut current_paragraph,
                        in_paragraph,
                        include_text,
                        '\n',
                    );
                }
                b"footnoteReference" if in_paragraph => {
                    if let Some(note_id) = parse_note_reference_id(event) {
                        append_note_reference(
                            current_cell.as_mut(),
                            &mut current_paragraph.footnote_refs,
                            note_id,
                            NoteReferenceKind::Footnote,
                        );
                    }
                }
                b"endnoteReference" if in_paragraph => {
                    if let Some(note_id) = parse_note_reference_id(event) {
                        append_note_reference(
                            current_cell.as_mut(),
                            &mut current_paragraph.endnote_refs,
                            note_id,
                            NoteReferenceKind::Endnote,
                        );
                    }
                }
                b"rPrChange" if in_paragraph && in_run => {
                    let change = parse_format_change(
                        &mut reader,
                        event,
                        true,
                        current_active_block_id(
                            &active_story,
                            story_paragraph_index,
                            current_cell.as_ref(),
                            "active story while in paragraph",
                        ),
                        &mut revision_counters,
                    )?;
                    current_run_format_changes.push(SpanTrackedChange {
                        id: change.id.clone(),
                        kind: SpanChangeKind::Format,
                    });
                    raw_tracked_changes.push(change);
                }
                b"ilvl" if in_paragraph && in_num_pr => {
                    if let Some(level) = parse_list_level(event) {
                        current_paragraph.list_level = Some(level);
                    }
                }
                b"pStyle" if in_paragraph && in_p_pr => {
                    current_paragraph.style = parse_paragraph_style(event);
                }
                _ => {}
            },
            Event::End(ref event) => match event.local_name().as_ref() {
                b"p" => {
                    if !in_paragraph {
                        buf.clear();
                        continue;
                    }
                    in_paragraph = false;
                    in_num_pr = false;
                    if current_cell.is_none() {
                        story_paragraph_index += 1;
                        if should_emit_paragraph_block(&current_paragraph) {
                            let story = active_story_ref(
                                &active_story,
                                "active story while finishing paragraph",
                            );
                            blocks.push(Block {
                                id: story.block_id(story_paragraph_index - 1),
                                story: story.story(),
                                kind: paragraph_block_kind(&current_paragraph),
                                text: std::mem::take(&mut current_paragraph.text),
                                style: current_paragraph.style.take(),
                                text_spans: Some(std::mem::take(&mut current_paragraph.spans)),
                                footnote_refs: std::mem::take(
                                    &mut current_paragraph.footnote_refs,
                                ),
                                endnote_refs: std::mem::take(
                                    &mut current_paragraph.endnote_refs,
                                ),
                            });
                        } else {
                            current_paragraph.reset();
                        }
                        while let Some(context) = revision_stack.pop() {
                            raw_tracked_changes.push(finalize_revision_context(context));
                        }
                        current_paragraph.visible_char_offset = 0;
                    }
                }
                b"ins" | b"del" | b"moveFrom" | b"moveTo" if in_paragraph => {
                    if let Some(context) = revision_stack.pop() {
                        raw_tracked_changes.push(finalize_revision_context(context));
                    }
                }
                b"r" if in_paragraph => {
                    in_run = false;
                    current_run_format_changes.clear();
                }
                b"numPr" if in_paragraph => {
                    in_num_pr = false;
                }
                b"pPr" if in_paragraph => {
                    in_p_pr = false;
                }
                b"tc" if table_state.is_top_level() => {
                    if let Some(cell) = current_cell.take() {
                        let story =
                            active_story_ref(&active_story, "active story while finishing table cell");
                        blocks.push(Block {
                            id: story.table_cell_id(cell.table_number, cell.row, cell.col),
                            story: story.story(),
                            kind: BlockKind::TableCell {
                                table_id: story.table_id(cell.table_number),
                                row: cell.row,
                                col: cell.col,
                            },
                            text: cell.text,
                            style: None,
                            text_spans: Some(cell.spans),
                            footnote_refs: cell.footnote_refs,
                            endnote_refs: cell.endnote_refs,
                        });
                        table_state.finish_cell();
                    }
                }
                b"tr" if table_state.is_top_level() => {
                    table_state.finish_row();
                }
                b"tbl" => {
                    table_state.leave_table();
                }
                b"footnote" if mode == ParseMode::Footnotes => {
                    active_story = None;
                }
                b"endnote" if mode == ParseMode::Endnotes => {
                    active_story = None;
                }
                _ => {}
            },
            Event::Eof => break,
            _ => {}
        }

        buf.clear();
    }

    Ok(BodyParseResult {
        blocks,
        raw_tracked_changes,
        comment_anchors,
    })
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CommentAnchorEvent {
    pub comment_id: String,
    pub block_id: String,
    pub char_offset: usize,
    pub kind: CommentAnchorEventKind,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum CommentAnchorEventKind {
    Start,
    End,
    Reference,
}

fn should_include_text(revision_stack: &[RevisionContext]) -> bool {
    !revision_stack
        .iter()
        .any(|ctx| matches!(ctx.kind, RevisionKind::Delete | RevisionKind::MoveFrom))
}

fn append_to_current_revision(revision_stack: &mut [RevisionContext], ch: char) {
    if let Some(current_revision) = revision_stack.last_mut() {
        current_revision.text.push(ch);
    }
}

fn append_visible_text(
    text: &str,
    current_cell: Option<&mut CellContext>,
    current_paragraph: &mut ParagraphState,
    span_changes: Vec<SpanTrackedChange>,
) {
    if let Some(cell) = current_cell {
        cell.text.push_str(text);
        append_text_span(&mut cell.spans, text, span_changes);
        cell.visible_char_offset += text.chars().count();
    } else {
        current_paragraph.text.push_str(text);
        append_text_span(&mut current_paragraph.spans, text, span_changes);
        current_paragraph.visible_char_offset += text.chars().count();
    }
}

fn append_visible_char(
    revision_stack: &mut [RevisionContext],
    text: &mut String,
    spans: &mut Vec<TextSpan>,
    visible_char_offset: &mut usize,
    ch: char,
) {
    append_to_current_revision(revision_stack, ch);
    text.push(ch);
    append_text_span(spans, &ch.to_string(), active_span_changes(revision_stack, &[]));
    *visible_char_offset += 1;
}

fn append_visible_char_by_context(
    revision_stack: &mut [RevisionContext],
    current_run_format_changes: &[SpanTrackedChange],
    current_cell: Option<&mut CellContext>,
    current_paragraph: &mut ParagraphState,
    in_paragraph: bool,
    include_text: bool,
    ch: char,
) {
    if !in_paragraph {
        return;
    }

    append_to_current_revision(revision_stack, ch);
    if !include_text {
        return;
    }

    if let Some(cell) = current_cell {
        cell.text.push(ch);
        append_text_span(
            &mut cell.spans,
            &ch.to_string(),
            active_span_changes(revision_stack, current_run_format_changes),
        );
        cell.visible_char_offset += 1;
    } else {
        current_paragraph.text.push(ch);
        append_text_span(
            &mut current_paragraph.spans,
            &ch.to_string(),
            active_span_changes(revision_stack, current_run_format_changes),
        );
        current_paragraph.visible_char_offset += 1;
    }
}

fn append_text_span(spans: &mut Vec<TextSpan>, text: &str, span_changes: Vec<SpanTrackedChange>) {
    if text.is_empty() {
        return;
    }

    if let Some(last) = spans.last_mut()
        && same_span_changes(&last.tracked_changes, &span_changes)
    {
        last.text.push_str(text);
        return;
    }

    spans.push(TextSpan {
        text: text.to_string(),
        tracked_changes: span_changes,
    });
}

fn same_span_changes(left: &[SpanTrackedChange], right: &[SpanTrackedChange]) -> bool {
    left.len() == right.len()
        && left
            .iter()
            .zip(right.iter())
            .all(|(left, right)| left.id == right.id && left.kind == right.kind)
}

fn current_block_id(
    story: &StoryContext,
    paragraph_index: usize,
    current_cell: Option<&CellContext>,
) -> String {
    if let Some(cell) = current_cell {
        story.table_cell_id(cell.table_number, cell.row, cell.col)
    } else {
        story.block_id(paragraph_index)
    }
}

fn current_active_block_id(
    active_story: &Option<StoryContext>,
    paragraph_index: usize,
    current_cell: Option<&CellContext>,
    context: &str,
) -> String {
    current_block_id(active_story_ref(active_story, context), paragraph_index, current_cell)
}

fn active_story_ref<'a>(active_story: &'a Option<StoryContext>, context: &str) -> &'a StoryContext {
    active_story.as_ref().expect(context)
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum NoteKind {
    Footnote,
    Endnote,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum NoteReferenceKind {
    Footnote,
    Endnote,
}

fn parse_note_story(
    event: &quick_xml::events::BytesStart<'_>,
    kind: NoteKind,
) -> Option<StoryContext> {
    let mut note_id = None;
    let mut note_type = None;

    for attribute in event.attributes().flatten() {
        match attribute.key.local_name().as_ref() {
            b"id" => {
                note_id = String::from_utf8_lossy(attribute.value.as_ref())
                    .parse::<i32>()
                    .ok();
            }
            b"type" => {
                note_type = Some(String::from_utf8_lossy(attribute.value.as_ref()).into_owned())
            }
            _ => {}
        }
    }

    if note_type.is_some() {
        return None;
    }

    let note_id = note_id?;
    if note_id < 0 {
        return None;
    }

    let index = note_id as u32;
    Some(match kind {
        NoteKind::Footnote => StoryContext::Footnote { index },
        NoteKind::Endnote => StoryContext::Endnote { index },
    })
}

fn current_visible_char_offset(
    current_cell: Option<&CellContext>,
    current_paragraph_visible_char_offset: usize,
) -> usize {
    current_cell
        .map(|cell| cell.visible_char_offset)
        .unwrap_or(current_paragraph_visible_char_offset)
}

fn active_span_changes(
    revision_stack: &[RevisionContext],
    current_run_format_changes: &[SpanTrackedChange],
) -> Vec<SpanTrackedChange> {
    let mut changes = Vec::new();

    if let Some(context) = revision_stack.last()
        && matches!(context.kind, RevisionKind::Insert | RevisionKind::MoveTo)
    {
        changes.push(SpanTrackedChange {
            id: context.change_id.clone(),
            kind: context.span_kind.clone(),
        });
    }

    changes.extend(current_run_format_changes.iter().cloned());
    changes
}

fn parse_comment_marker_id(event: &quick_xml::events::BytesStart<'_>) -> Option<String> {
    for attribute in event.attributes().flatten() {
        if attribute.key.local_name().as_ref() == b"id" {
            return Some(String::from_utf8_lossy(attribute.value.as_ref()).into_owned());
        }
    }

    None
}

fn parse_note_reference_id(event: &quick_xml::events::BytesStart<'_>) -> Option<u32> {
    for attribute in event.attributes().flatten() {
        if attribute.key.local_name().as_ref() == b"id" {
            return String::from_utf8_lossy(attribute.value.as_ref())
                .parse::<u32>()
                .ok();
        }
    }

    None
}

fn parse_list_level(event: &quick_xml::events::BytesStart<'_>) -> Option<usize> {
    for attribute in event.attributes().flatten() {
        if attribute.key.local_name().as_ref() == b"val" {
            return String::from_utf8_lossy(attribute.value.as_ref())
                .parse::<usize>()
                .ok();
        }
    }

    None
}

fn parse_paragraph_style(event: &quick_xml::events::BytesStart<'_>) -> Option<String> {
    for attribute in event.attributes().flatten() {
        if attribute.key.local_name().as_ref() == b"val" {
            return Some(String::from_utf8_lossy(attribute.value.as_ref()).into_owned());
        }
    }

    None
}

fn paragraph_block_kind(paragraph: &ParagraphState) -> BlockKind {
    if let Some(level) = heading_level(paragraph.style.as_deref()) {
        BlockKind::Heading { level }
    } else if let Some(level) = paragraph.list_level {
        BlockKind::ListItem { level }
    } else {
        BlockKind::Paragraph
    }
}

fn heading_level(style: Option<&str>) -> Option<u8> {
    let style = style?;
    let normalized = style.to_ascii_lowercase().replace(' ', "");

    normalized
        .strip_prefix("heading")
        .and_then(|suffix| suffix.parse::<u8>().ok())
}

fn should_emit_paragraph_block(paragraph: &ParagraphState) -> bool {
    !paragraph.text.trim().is_empty()
        || !paragraph.footnote_refs.is_empty()
        || !paragraph.endnote_refs.is_empty()
}

fn should_emit_line_break(event: &quick_xml::events::BytesStart<'_>) -> bool {
    for attribute in event.attributes().flatten() {
        if attribute.key.local_name().as_ref() == b"type" {
            return !matches!(
                attribute.value.as_ref(),
                b"page" | b"column"
            );
        }
    }

    true
}

fn decode_xml_text(text: &str) -> Result<String, Error> {
    Ok(unescape(text)?.into_owned())
}

fn parse_format_change(
    reader: &mut Reader<&[u8]>,
    event: &quick_xml::events::BytesStart<'_>,
    is_empty: bool,
    block_id: String,
    counters: &mut RevisionCounters,
) -> Result<TrackedChange, Error> {
    let RevisionAttributes {
        author,
        date,
        revision_id,
    } = parse_revision_attributes(event);

    let mut summary_parts = Vec::new();
    if !is_empty {
        let mut buf = Vec::new();
        let mut depth = 1usize;
        loop {
            match reader.read_event_into(&mut buf)? {
                Event::Start(ref child) => {
                    depth += 1;
                    let local = child.local_name();
                    let name = local.as_ref();
                    if name != b"rPr" && name != b"rPrChange" {
                        let part = String::from_utf8_lossy(name).into_owned();
                        if !summary_parts.contains(&part) {
                            summary_parts.push(part);
                        }
                    }
                }
                Event::Empty(ref child) => {
                    let local = child.local_name();
                    let name = local.as_ref();
                    if name != b"rPr" && name != b"rPrChange" {
                        let part = String::from_utf8_lossy(name).into_owned();
                        if !summary_parts.contains(&part) {
                            summary_parts.push(part);
                        }
                    }
                }
                Event::End(ref child) if child.local_name().as_ref() == b"rPrChange" => {
                    depth -= 1;
                    if depth == 0 {
                        break;
                    }
                }
                Event::End(_) => {
                    depth = depth.saturating_sub(1);
                }
                Event::Eof => break,
                _ => {}
            }
            buf.clear();
        }
    }

    let previous_props_summary = if summary_parts.is_empty() {
        "format change".to_string()
    } else {
        summary_parts.join(", ")
    };
    let change_id = next_format_change_id(revision_id.as_deref(), counters);

    Ok(TrackedChange {
        id: change_id,
        kind: TrackedChangeKind::Format {
            previous_props_summary,
        },
        author,
        date,
        block_ids: vec![block_id],
        excerpt: None,
        move_pair_id: None,
        raw_revision_ids: RawRevisionIds {
            format: revision_id,
            ..RawRevisionIds::default()
        },
    })
}

fn append_note_reference(
    current_cell: Option<&mut CellContext>,
    current_paragraph_refs: &mut Vec<u32>,
    note_id: u32,
    kind: NoteReferenceKind,
) {
    let refs = if let Some(cell) = current_cell {
        match kind {
            NoteReferenceKind::Footnote => &mut cell.footnote_refs,
            NoteReferenceKind::Endnote => &mut cell.endnote_refs,
        }
    } else {
        current_paragraph_refs
    };

    if !refs.contains(&note_id) {
        refs.push(note_id);
    }
}

fn parse_revision_context(
    event: &quick_xml::events::BytesStart<'_>,
    kind: RevisionKind,
    block_id: String,
    counters: &mut RevisionCounters,
) -> RevisionContext {
    let RevisionAttributes {
        author,
        date,
        revision_id,
    } = parse_revision_attributes(event);

    let change_id = next_change_id(kind, revision_id.as_deref(), counters);
    let span_kind = match kind {
        RevisionKind::Insert => SpanChangeKind::Insert,
        RevisionKind::Delete => SpanChangeKind::Delete,
        RevisionKind::MoveFrom => SpanChangeKind::MoveFrom,
        RevisionKind::MoveTo => SpanChangeKind::MoveTo,
    };

    RevisionContext {
        kind,
        span_kind,
        change_id,
        author,
        date,
        revision_id,
        text: String::new(),
        block_id,
    }
}

fn parse_revision_attributes(event: &quick_xml::events::BytesStart<'_>) -> RevisionAttributes {
    let mut author = None;
    let mut date = None;
    let mut revision_id = None;

    for attribute in event.attributes().flatten() {
        match attribute.key.local_name().as_ref() {
            b"author" => author = Some(String::from_utf8_lossy(attribute.value.as_ref()).into_owned()),
            b"date" => {
                let raw = String::from_utf8_lossy(attribute.value.as_ref()).into_owned();
                date = DateTime::parse_from_rfc3339(&raw)
                    .ok()
                    .map(|value| value.with_timezone(&Utc));
            }
            b"id" => revision_id = Some(String::from_utf8_lossy(attribute.value.as_ref()).into_owned()),
            _ => {}
        }
    }

    RevisionAttributes {
        author,
        date,
        revision_id,
    }
}

fn finalize_revision_context(context: RevisionContext) -> TrackedChange {
    let RevisionContext {
        kind,
        change_id,
        author,
        date,
        revision_id,
        text,
        block_id,
        ..
    } = context;

    let (raw_revision_ids, kind) = match kind {
        RevisionKind::Insert => {
            (
                RawRevisionIds {
                    insert: revision_id,
                    ..RawRevisionIds::default()
                },
                TrackedChangeKind::Insert { text },
            )
        }
        RevisionKind::Delete => {
            (
                RawRevisionIds {
                    delete: revision_id,
                    ..RawRevisionIds::default()
                },
                TrackedChangeKind::Delete { text },
            )
        }
        RevisionKind::MoveFrom => {
            (
                RawRevisionIds {
                    move_from: revision_id,
                    ..RawRevisionIds::default()
                },
                TrackedChangeKind::MoveFrom { text },
            )
        }
        RevisionKind::MoveTo => {
            (
                RawRevisionIds {
                    move_to: revision_id,
                    ..RawRevisionIds::default()
                },
                TrackedChangeKind::MoveTo { text },
            )
        }
    };

    TrackedChange {
        id: change_id,
        kind,
        author,
        date,
        block_ids: vec![block_id],
        excerpt: None,
        move_pair_id: None,
        raw_revision_ids,
    }
}

fn next_change_id(kind: RevisionKind, revision_id: Option<&str>, counters: &mut RevisionCounters) -> String {
    match kind {
        RevisionKind::Insert => {
            counters.insert += 1;
            revision_id
                .map(|id| format!("body:tc:ins:{id}"))
                .unwrap_or_else(|| format!("body:tc:ins:{:06}", counters.insert))
        }
        RevisionKind::Delete => {
            counters.delete += 1;
            revision_id
                .map(|id| format!("body:tc:del:{id}"))
                .unwrap_or_else(|| format!("body:tc:del:{:06}", counters.delete))
        }
        RevisionKind::MoveFrom => {
            counters.move_from += 1;
            revision_id
                .map(|id| format!("body:tc:mvf:{id}"))
                .unwrap_or_else(|| format!("body:tc:mvf:{:06}", counters.move_from))
        }
        RevisionKind::MoveTo => {
            counters.move_to += 1;
            revision_id
                .map(|id| format!("body:tc:mvt:{id}"))
                .unwrap_or_else(|| format!("body:tc:mvt:{:06}", counters.move_to))
        }
    }
}

fn next_format_change_id(revision_id: Option<&str>, counters: &mut RevisionCounters) -> String {
    counters.format += 1;
    revision_id
        .map(|id| format!("body:tc:fmt:{id}"))
        .unwrap_or_else(|| format!("body:tc:fmt:{:06}", counters.format))
}

#[cfg(test)]
mod tests {
    use chrono::{TimeZone, Utc};

    use crate::{BlockKind, SpanChangeKind, Story, TrackedChangeKind};

    use super::{
        CommentAnchorEventKind, parse_document_body, parse_endnotes_part, parse_footer_part,
        parse_footnotes_part, parse_header_part,
    };

    #[test]
    fn parses_plain_paragraph_text() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:r><w:t>Hello</w:t></w:r>
                  <w:r><w:t xml:space="preserve"> world</w:t></w:r>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 1);
        assert_eq!(result.blocks[0].id, "body:block:000001");
        assert_eq!(result.blocks[0].text, "Hello world");
    }

    #[test]
    fn includes_insertions_and_excludes_deletions() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:r><w:t>The price is </w:t></w:r>
                  <w:del><w:r><w:delText>100</w:delText></w:r></w:del>
                  <w:ins><w:r><w:t>200</w:t></w:r></w:ins>
                  <w:r><w:t> dollars</w:t></w:r>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 1);
        assert_eq!(result.blocks[0].text, "The price is 200 dollars");
    }

    #[test]
    fn handles_nested_revision_wrappers_with_stack_semantics() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:r><w:t>A </w:t></w:r>
                  <w:ins>
                    <w:r><w:t>new </w:t></w:r>
                    <w:del><w:r><w:delText>old</w:delText></w:r></w:del>
                    <w:r><w:t>text</w:t></w:r>
                  </w:ins>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 1);
        assert_eq!(result.blocks[0].text, "A new text");
    }

    #[test]
    fn excludes_move_from_and_includes_move_to_text() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:r><w:t>Keep </w:t></w:r>
                  <w:moveFrom><w:r><w:t>away</w:t></w:r></w:moveFrom>
                  <w:moveTo><w:r><w:t>this</w:t></w:r></w:moveTo>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 1);
        assert_eq!(result.blocks[0].text, "Keep this");
    }

    #[test]
    fn collects_raw_tracked_changes_with_metadata() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:del w:id="3" w:author="Alice" w:date="2026-05-29T10:00:00Z">
                    <w:r><w:delText>old</w:delText></w:r>
                  </w:del>
                  <w:ins w:id="4" w:author="Alice" w:date="2026-05-29T10:00:00Z">
                    <w:r><w:t>new</w:t></w:r>
                  </w:ins>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.raw_tracked_changes.len(), 2);
        assert_eq!(result.raw_tracked_changes[0].id, "body:tc:del:3");
        assert_eq!(result.raw_tracked_changes[1].id, "body:tc:ins:4");
        assert_eq!(result.raw_tracked_changes[0].author.as_deref(), Some("Alice"));
        assert_eq!(
            result.raw_tracked_changes[0].date,
            Some(Utc.with_ymd_and_hms(2026, 5, 29, 10, 0, 0).unwrap())
        );
        assert_eq!(
            result.raw_tracked_changes[0].raw_revision_ids.delete.as_deref(),
            Some("3")
        );
        assert_eq!(
            result.raw_tracked_changes[1].raw_revision_ids.insert.as_deref(),
            Some("4")
        );
    }

    #[test]
    fn emits_comment_anchor_events() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:r><w:t>Hello </w:t></w:r>
                  <w:commentRangeStart w:id="1"/>
                  <w:r><w:t>world</w:t></w:r>
                  <w:commentRangeEnd w:id="1"/>
                  <w:r><w:commentReference w:id="1"/></w:r>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.comment_anchors.len(), 3);
        assert_eq!(result.comment_anchors[0].comment_id, "1");
        assert_eq!(result.comment_anchors[0].char_offset, 6);
        assert!(matches!(
            result.comment_anchors[0].kind,
            CommentAnchorEventKind::Start
        ));
        assert_eq!(result.comment_anchors[1].char_offset, 11);
        assert!(matches!(
            result.comment_anchors[1].kind,
            CommentAnchorEventKind::End
        ));
        assert!(matches!(
            result.comment_anchors[2].kind,
            CommentAnchorEventKind::Reference
        ));
    }

    #[test]
    fn collects_inline_footnote_and_endnote_references() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:r><w:t>Hello</w:t></w:r>
                  <w:r><w:footnoteReference w:id="2"/></w:r>
                  <w:r><w:endnoteReference w:id="5"/></w:r>
                  <w:r><w:footnoteReference w:id="2"/></w:r>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 1);
        assert_eq!(result.blocks[0].footnote_refs, vec![2]);
        assert_eq!(result.blocks[0].endnote_refs, vec![5]);
    }

    #[test]
    fn recognizes_numbered_paragraphs_as_list_items() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:pPr>
                    <w:numPr>
                      <w:ilvl w:val="2"/>
                      <w:numId w:val="7"/>
                    </w:numPr>
                  </w:pPr>
                  <w:r><w:t>Nested list item</w:t></w:r>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 1);
        assert!(matches!(
            result.blocks[0].kind,
            BlockKind::ListItem { level: 2 }
        ));
        assert_eq!(result.blocks[0].text, "Nested list item");
    }

    #[test]
    fn recognizes_heading_paragraphs_from_style() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:pPr>
                    <w:pStyle w:val="Heading2"/>
                  </w:pPr>
                  <w:r><w:t>Section Title</w:t></w:r>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 1);
        assert!(matches!(
            result.blocks[0].kind,
            BlockKind::Heading { level: 2 }
        ));
        assert_eq!(result.blocks[0].style.as_deref(), Some("Heading2"));
        assert_eq!(result.blocks[0].text, "Section Title");
    }

    #[test]
    fn skips_empty_paragraph_blocks() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p/>
                <w:p><w:r><w:t>Kept</w:t></w:r></w:p>
                <w:p><w:r><w:t xml:space="preserve">   </w:t></w:r></w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 1);
        assert_eq!(result.blocks[0].text, "Kept");
    }

    #[test]
    fn emits_only_text_wrapping_breaks_into_visible_text() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:r><w:t>A</w:t></w:r>
                  <w:r><w:br/></w:r>
                  <w:r><w:t>B</w:t></w:r>
                  <w:r><w:br w:type="page"/></w:r>
                  <w:r><w:t>C</w:t></w:r>
                  <w:r><w:br w:type="column"/></w:r>
                  <w:r><w:t>D</w:t></w:r>
                  <w:r><w:cr/></w:r>
                  <w:r><w:t>E</w:t></w:r>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 1);
        assert_eq!(result.blocks[0].text, "A\nBCD\nE");
    }

    #[test]
    fn collects_format_changes_and_marks_spans() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p>
                  <w:r>
                    <w:rPr>
                      <w:rPrChange w:id="9" w:author="Alice" w:date="2026-05-29T10:00:00Z">
                        <w:rPr>
                          <w:b/>
                          <w:color w:val="FF0000"/>
                        </w:rPr>
                      </w:rPrChange>
                    </w:rPr>
                    <w:t>Styled</w:t>
                  </w:r>
                </w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.raw_tracked_changes.len(), 1);
        assert_eq!(result.raw_tracked_changes[0].id, "body:tc:fmt:9");
        assert_eq!(result.raw_tracked_changes[0].author.as_deref(), Some("Alice"));
        assert!(matches!(
            &result.raw_tracked_changes[0].kind,
            TrackedChangeKind::Format { previous_props_summary }
                if previous_props_summary == "b, color"
        ));
        assert_eq!(result.raw_tracked_changes[0].raw_revision_ids.format.as_deref(), Some("9"));
        assert_eq!(result.blocks[0].text, "Styled");
        assert_eq!(
            result.blocks[0].text_spans.as_ref().unwrap()[0].tracked_changes[0].kind,
            SpanChangeKind::Format
        );
    }

    #[test]
    fn creates_block_for_each_paragraph() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:p><w:r><w:t>First</w:t></w:r></w:p>
                <w:p><w:r><w:t>Second</w:t></w:r></w:p>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 2);
        assert_eq!(result.blocks[0].text, "First");
        assert_eq!(result.blocks[1].text, "Second");
        assert_eq!(result.blocks[1].id, "body:block:000002");
    }

    #[test]
    fn parses_footnotes_and_endnotes_as_separate_stories() {
        let footnotes = parse_footnotes_part(
            r#"
            <w:footnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:footnote w:type="separator" w:id="-1"><w:p><w:r><w:t>skip</w:t></w:r></w:p></w:footnote>
              <w:footnote w:id="3">
                <w:p><w:r><w:t>Footnote body</w:t></w:r></w:p>
              </w:footnote>
            </w:footnotes>
            "#,
        )
        .unwrap();
        let endnotes = parse_endnotes_part(
            r#"
            <w:endnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:endnote w:id="7">
                <w:p><w:r><w:t>Endnote body</w:t></w:r></w:p>
              </w:endnote>
            </w:endnotes>
            "#,
        )
        .unwrap();

        assert_eq!(footnotes.blocks.len(), 1);
        assert_eq!(footnotes.blocks[0].id, "footnote:3:block:000001");
        assert_eq!(footnotes.blocks[0].story, Story::Footnote { index: 3 });
        assert_eq!(footnotes.blocks[0].text, "Footnote body");

        assert_eq!(endnotes.blocks.len(), 1);
        assert_eq!(endnotes.blocks[0].id, "endnote:7:block:000001");
        assert_eq!(endnotes.blocks[0].story, Story::Endnote { index: 7 });
        assert_eq!(endnotes.blocks[0].text, "Endnote body");
    }

    #[test]
    fn parses_headers_and_footers_as_separate_stories() {
        let headers = parse_header_part(
            r#"
            <w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:p><w:r><w:t>Header text</w:t></w:r></w:p>
            </w:hdr>
            "#,
            1,
        )
        .unwrap();
        let footers = parse_footer_part(
            r#"
            <w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:p><w:r><w:t>Footer text</w:t></w:r></w:p>
            </w:ftr>
            "#,
            2,
        )
        .unwrap();

        assert_eq!(headers.blocks.len(), 1);
        assert_eq!(headers.blocks[0].id, "header:1:block:000001");
        assert_eq!(headers.blocks[0].story, Story::Header { index: 1 });
        assert_eq!(headers.blocks[0].text, "Header text");

        assert_eq!(footers.blocks.len(), 1);
        assert_eq!(footers.blocks[0].id, "footer:2:block:000001");
        assert_eq!(footers.blocks[0].story, Story::Footer { index: 2 });
        assert_eq!(footers.blocks[0].text, "Footer text");
    }

    #[test]
    fn emits_table_cells_as_flat_blocks() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:tbl>
                  <w:tr>
                    <w:tc><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc>
                    <w:tc><w:p><w:r><w:t>A2</w:t></w:r></w:p></w:tc>
                    <w:tc><w:p><w:r><w:t>A3</w:t></w:r></w:p></w:tc>
                  </w:tr>
                  <w:tr>
                    <w:tc><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc>
                    <w:tc><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc>
                    <w:tc><w:p><w:r><w:t>B3</w:t></w:r></w:p></w:tc>
                  </w:tr>
                </w:tbl>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 6);
        assert_eq!(result.blocks[0].id, "body:table:1:row:0:col:0");
        assert_eq!(result.blocks[5].id, "body:table:1:row:1:col:2");
        assert_eq!(result.blocks[4].text, "B2");
        assert!(matches!(
            result.blocks[0].kind,
            BlockKind::TableCell { row: 0, col: 0, .. }
        ));
    }

    #[test]
    fn captures_tracked_changes_inside_table_cells() {
        let xml = r#"
            <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
              <w:body>
                <w:tbl>
                  <w:tr>
                    <w:tc>
                      <w:p>
                        <w:r><w:t>Price </w:t></w:r>
                        <w:ins w:id="4" w:author="Alice" w:date="2026-05-29T10:00:00Z">
                          <w:r><w:t>200</w:t></w:r>
                        </w:ins>
                      </w:p>
                    </w:tc>
                  </w:tr>
                </w:tbl>
              </w:body>
            </w:document>
        "#;

        let result = parse_document_body(xml).unwrap();

        assert_eq!(result.blocks.len(), 1);
        assert_eq!(result.blocks[0].text, "Price 200");
        assert_eq!(result.raw_tracked_changes.len(), 1);
        assert_eq!(result.raw_tracked_changes[0].block_ids, vec!["body:table:1:row:0:col:0"]);
        assert!(matches!(
            result.raw_tracked_changes[0].kind,
            TrackedChangeKind::Insert { .. }
        ));
    }
}
