//! Review-oriented DOCX extraction for Rust.
//!
//! `docx-review-core` reads `.docx` files directly from OOXML and returns a
//! normalized [`Document`] model with structural blocks, tracked changes,
//! comments, and optional raw revision metadata.
//!
//! The crate is intended for automation, review workflows, and downstream
//! tooling that needs more than plain text extraction.

use std::fs::File;
use std::io::{Cursor, Read, Seek};
use std::path::Path;

use extractor::anchors::apply_comment_anchors;
use extractor::apply_text_spans;
use extractor::changes::resolve_tracked_changes;
use parser::body::{
    BodyParseResult, parse_document_body, parse_endnotes_part, parse_footer_part,
    parse_footnotes_part, parse_header_part,
};
use parser::comments::{apply_comment_metadata, parse_comments_part};
use parser::relationships::Relationships;
use zip::{MAIN_DOCUMENT_PART, require_part, unpack_docx};

pub mod error;
pub mod extractor;
pub mod model;
pub mod parser;
pub mod zip;

pub use error::Error;
pub use model::{
    Block, BlockId, BlockKind, ChangeId, Comment, CommentId, DocMetadata, Document, RawRevisionIds,
    SpanChangeKind, SpanTrackedChange, Story, TextAnchor, TextSpan, TrackedChange,
    TrackedChangeKind,
};

#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct ExtractOptions {
    /// How tracked changes should be represented in the output.
    pub track_changes_mode: TrackChangesMode,
    /// Whether comments and their anchor metadata should be extracted.
    pub include_comments: bool,
    /// Whether blocks should include [`TextSpan`] markers.
    pub include_text_spans: bool,
    /// Whether raw OOXML revision ids should be preserved.
    pub include_raw_ids: bool,
}

#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub enum TrackChangesMode {
    /// Prefer review-oriented paired changes when possible.
    ///
    /// In this mode adjacent delete/insert pairs may become a single
    /// replacement change.
    #[default]
    Paired,
    /// Emit raw tracked changes as they appear in the OOXML stream.
    Raw,
    /// Emit paired tracked changes and also retain raw tracked changes in
    /// [`Document::raw_changes`].
    Both,
}

/// Extract a [`Document`] from any seekable DOCX reader using default options.
pub fn extract(input: impl Read + Seek) -> Result<Document, Error> {
    extract_with_opts(input, ExtractOptions::default())
}

/// Extract a [`Document`] from any seekable DOCX reader with explicit options.
pub fn extract_with_opts(input: impl Read + Seek, opts: ExtractOptions) -> Result<Document, Error> {
    let files = unpack_docx(input)?;
    let relationships = Relationships::from_files(&files)?;
    let main_document_path = relationships.main_document_path().unwrap_or(MAIN_DOCUMENT_PART);
    let document_xml = require_part(&files, main_document_path)?;
    let document_xml =
        std::str::from_utf8(document_xml).map_err(|_| Error::Unsupported("non-utf8 document xml"))?;
    let mut parsed = parse_document_body(document_xml)?;

    if let Some(footnotes_path) = relationships.find_footnotes_part() {
        if let Some(footnotes_xml) = get_utf8_part(&files, footnotes_path)? {
            merge_parse_results(&mut parsed, parse_footnotes_part(&footnotes_xml)?);
        }
    } else {
        tracing::debug!("no footnotes part in document");
    }

    if let Some(endnotes_path) = relationships.find_endnotes_part() {
        if let Some(endnotes_xml) = get_utf8_part(&files, endnotes_path)? {
            merge_parse_results(&mut parsed, parse_endnotes_part(&endnotes_xml)?);
        }
    } else {
        tracing::debug!("no endnotes part in document");
    }

    for (index, header_path) in relationships.find_header_parts().into_iter().enumerate() {
        if let Some(header_xml) = get_utf8_part(&files, header_path)? {
            merge_parse_results(&mut parsed, parse_header_part(&header_xml, index as u32 + 1)?);
        }
    }

    for (index, footer_path) in relationships.find_footer_parts().into_iter().enumerate() {
        if let Some(footer_xml) = get_utf8_part(&files, footer_path)? {
            merge_parse_results(&mut parsed, parse_footer_part(&footer_xml, index as u32 + 1)?);
        }
    }

    let parser_raw_changes = parsed.raw_tracked_changes.clone();
    let (tracked_changes, raw_changes) = resolve_tracked_changes(parsed.raw_tracked_changes, &opts);
    let mut blocks = parsed.blocks;
    apply_text_spans(
        &mut blocks,
        &tracked_changes,
        &parser_raw_changes,
        &opts.track_changes_mode,
        opts.include_text_spans,
    );
    let mut comments = if opts.include_comments {
        if let Some(comments_path) = relationships.find_comments_part() {
            let comments_xml = require_part(&files, comments_path)?;
            let comments_xml = std::str::from_utf8(comments_xml)
                .map_err(|_| Error::Unsupported("non-utf8 comments xml"))?;
            let parsed_comments = parse_comments_part(comments_xml)?;
            let mut comments = parsed_comments.comments;

            let comments_extended_xml = relationships
                .find_comments_extended_part()
                .and_then(|path| get_utf8_part(&files, path).transpose())
                .transpose()?;
            if comments_extended_xml.is_none() {
                tracing::debug!("no commentsExtended part in document");
            }

            apply_comment_metadata(
                &mut comments,
                &parsed_comments.para_id_by_comment_id,
                comments_extended_xml.as_deref(),
            )?;
            comments
        } else {
            tracing::debug!("no comments part in document");
            Vec::new()
        }
    } else {
        Vec::new()
    };
    if opts.include_comments {
        apply_comment_anchors(&mut comments, &parsed.comment_anchors, &blocks);
    }

    Ok(Document {
        metadata: DocMetadata::default(),
        blocks,
        tracked_changes,
        comments,
        raw_changes,
    })
}

fn get_utf8_part(
    files: &zip::FileRegistry,
    path: &str,
) -> Result<Option<String>, Error> {
    match zip::get_part(files, path) {
        Some(bytes) => {
            let xml =
                std::str::from_utf8(bytes).map_err(|_| Error::Unsupported("non-utf8 xml part"))?;
            Ok(Some(xml.to_string()))
        }
        None => Ok(None),
    }
}

fn merge_parse_results(into: &mut BodyParseResult, mut other: BodyParseResult) {
    into.blocks.append(&mut other.blocks);
    into.raw_tracked_changes.append(&mut other.raw_tracked_changes);
    into.comment_anchors.append(&mut other.comment_anchors);
}

/// Extract a [`Document`] from a DOCX file path using default options.
pub fn extract_from_path(path: impl AsRef<Path>) -> Result<Document, Error> {
    extract_from_path_with_opts(path, ExtractOptions::default())
}

/// Extract a [`Document`] from a DOCX file path with explicit options.
pub fn extract_from_path_with_opts(
    path: impl AsRef<Path>,
    opts: ExtractOptions,
) -> Result<Document, Error> {
    let file = File::open(path)?;
    extract_with_opts(file, opts)
}

/// Extract a [`Document`] from in-memory DOCX bytes using default options.
pub fn extract_from_bytes(bytes: &[u8]) -> Result<Document, Error> {
    extract_from_bytes_with_opts(bytes, ExtractOptions::default())
}

/// Extract a [`Document`] from in-memory DOCX bytes with explicit options.
pub fn extract_from_bytes_with_opts(bytes: &[u8], opts: ExtractOptions) -> Result<Document, Error> {
    let cursor = Cursor::new(bytes);
    extract_with_opts(cursor, opts)
}

impl Default for ExtractOptions {
    fn default() -> Self {
        Self {
            track_changes_mode: TrackChangesMode::Paired,
            include_comments: true,
            include_text_spans: true,
            include_raw_ids: false,
        }
    }
}

#[cfg(test)]
mod tests {
    use chrono::{TimeZone, Utc};
    use std::io::Write;
    use ::zip::ZipWriter;
    use ::zip::write::SimpleFileOptions;

    use super::*;

    #[test]
    fn document_json_roundtrip() {
        let document = Document {
            metadata: DocMetadata {
                title: Some("Example".to_string()),
                author: Some("OpenAI".to_string()),
                created: Some(Utc.with_ymd_and_hms(2026, 5, 29, 10, 0, 0).unwrap()),
                modified: None,
                revision: Some(3),
            },
            blocks: vec![Block {
                id: "body:block:000001".to_string(),
                story: Story::Body,
                kind: BlockKind::Paragraph,
                text: "Hello".to_string(),
                style: Some("Normal".to_string()),
                text_spans: Some(vec![TextSpan {
                    text: "Hello".to_string(),
                    tracked_changes: vec![SpanTrackedChange {
                        id: "body:tc:ins:000001".to_string(),
                        kind: SpanChangeKind::Insert,
                    }],
                }]),
                footnote_refs: Vec::new(),
                endnote_refs: Vec::new(),
            }],
            tracked_changes: vec![TrackedChange {
                id: "body:tc:ins:000001".to_string(),
                kind: TrackedChangeKind::Insert {
                    text: "Hello".to_string(),
                },
                author: Some("OpenAI".to_string()),
                date: Some(Utc.with_ymd_and_hms(2026, 5, 29, 10, 0, 0).unwrap()),
                block_ids: vec!["body:block:000001".to_string()],
                excerpt: Some("Hello".to_string()),
                move_pair_id: None,
                raw_revision_ids: RawRevisionIds::default(),
            }],
            comments: vec![Comment {
                id: "comment:1".to_string(),
                imported_id: Some("1".to_string()),
                author: Some("Reviewer".to_string()),
                date: None,
                text: "Looks good".to_string(),
                anchors: vec![TextAnchor {
                    block_id: "body:block:000001".to_string(),
                    char_start: 0,
                    char_end: 5,
                }],
                anchored_text: Some("Hello".to_string()),
                resolved: Some(false),
                parent_id: None,
                replies: Vec::new(),
            }],
            raw_changes: Vec::new(),
        };

        let json = serde_json::to_string(&document).unwrap();
        let reparsed: Document = serde_json::from_str(&json).unwrap();

        assert_eq!(reparsed.blocks[0].text, "Hello");
        assert_eq!(reparsed.blocks[0].text_spans.as_ref().unwrap()[0].tracked_changes.len(), 1);
        assert_eq!(reparsed.comments[0].anchored_text.as_deref(), Some("Hello"));
        assert!(matches!(
            reparsed.tracked_changes[0].kind,
            TrackedChangeKind::Insert { .. }
        ));
    }

    #[test]
    fn extracts_blocks_from_docx_bytes() {
        let mut buffer = Cursor::new(Vec::new());
        let mut writer = ZipWriter::new(&mut buffer);
        let options = SimpleFileOptions::default();

        writer
            .start_file("_rels/.rels", options)
            .unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
                  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
                </Relationships>"#,
            )
            .unwrap();
        writer
            .start_file("word/document.xml", options)
            .unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <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:del><w:r><w:delText>old</w:delText></w:r></w:del>
                      <w:ins><w:r><w:t>new</w:t></w:r></w:ins>
                    </w:p>
                  </w:body>
                </w:document>"#,
            )
            .unwrap();
        writer.finish().unwrap();

        let document = extract_from_bytes(&buffer.into_inner()).unwrap();

        assert_eq!(document.blocks.len(), 1);
        assert_eq!(document.blocks[0].text, "Hello new");
        assert_eq!(
            document.blocks[0].text_spans.as_ref().unwrap().iter().map(|span| span.text.as_str()).collect::<String>(),
            document.blocks[0].text
        );
        assert_eq!(document.tracked_changes.len(), 1);
        assert!(matches!(
            document.tracked_changes[0].kind,
            TrackedChangeKind::Replacement { .. }
        ));
        assert!(matches!(
            document.blocks[0].text_spans.as_ref().unwrap()[1].tracked_changes[0].kind,
            SpanChangeKind::Replacement
        ));
        assert!(document.comments.is_empty());
    }

    #[test]
    fn extracts_comments_with_anchors_from_docx_bytes() {
        let mut buffer = Cursor::new(Vec::new());
        let mut writer = ZipWriter::new(&mut buffer);
        let options = SimpleFileOptions::default();

        writer.start_file("_rels/.rels", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
                  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
                </Relationships>"#,
            )
            .unwrap();
        writer.start_file("word/_rels/document.xml.rels", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
                  <Relationship Id="rIdComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>
                </Relationships>"#,
            )
            .unwrap();
        writer.start_file("word/document.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <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>"#,
            )
            .unwrap();
        writer.start_file("word/comments.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
                  <w:comment w:id="1" w:author="Reviewer" w:date="2026-05-29T10:00:00Z">
                    <w:p><w:r><w:t>Looks good</w:t></w:r></w:p>
                  </w:comment>
                </w:comments>"#,
            )
            .unwrap();
        writer.finish().unwrap();

        let document = extract_from_bytes(&buffer.into_inner()).unwrap();

        assert_eq!(document.comments.len(), 1);
        assert_eq!(document.comments[0].text, "Looks good");
        assert_eq!(document.comments[0].anchors.len(), 1);
        assert_eq!(document.comments[0].anchors[0].char_start, 6);
        assert_eq!(document.comments[0].anchors[0].char_end, 11);
        assert_eq!(document.comments[0].anchored_text.as_deref(), Some("world"));
    }

    #[test]
    fn extracts_footnote_and_endnote_blocks_from_docx_bytes() {
        let mut buffer = Cursor::new(Vec::new());
        let mut writer = ZipWriter::new(&mut buffer);
        let options = SimpleFileOptions::default();

        writer.start_file("_rels/.rels", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
                  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
                </Relationships>"#,
            )
            .unwrap();
        writer.start_file("word/_rels/document.xml.rels", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
                  <Relationship Id="rIdFootnotes" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes" Target="footnotes.xml"/>
                  <Relationship Id="rIdEndnotes" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes" Target="endnotes.xml"/>
                </Relationships>"#,
            )
            .unwrap();
        writer.start_file("word/document.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
                  <w:body>
                    <w:p>
                      <w:r><w:t>Body text</w:t></w:r>
                      <w:r><w:footnoteReference w:id="2"/></w:r>
                      <w:r><w:endnoteReference w:id="5"/></w:r>
                    </w:p>
                  </w:body>
                </w:document>"#,
            )
            .unwrap();
        writer.start_file("word/footnotes.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <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="2">
                    <w:p><w:r><w:t>Footnote text</w:t></w:r></w:p>
                  </w:footnote>
                </w:footnotes>"#,
            )
            .unwrap();
        writer.start_file("word/endnotes.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <w:endnotes xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
                  <w:endnote w:id="5">
                    <w:p><w:r><w:t>Endnote text</w:t></w:r></w:p>
                  </w:endnote>
                </w:endnotes>"#,
            )
            .unwrap();
        writer.finish().unwrap();

        let document = extract_from_bytes(&buffer.into_inner()).unwrap();

        assert_eq!(document.blocks.len(), 3);
        assert_eq!(document.blocks[0].story, Story::Body);
        assert_eq!(document.blocks[0].footnote_refs, vec![2]);
        assert_eq!(document.blocks[0].endnote_refs, vec![5]);
        assert_eq!(document.blocks[1].story, Story::Footnote { index: 2 });
        assert_eq!(document.blocks[1].id, "footnote:2:block:000001");
        assert_eq!(document.blocks[1].text, "Footnote text");
        assert_eq!(document.blocks[2].story, Story::Endnote { index: 5 });
        assert_eq!(document.blocks[2].id, "endnote:5:block:000001");
        assert_eq!(document.blocks[2].text, "Endnote text");
    }

    #[test]
    fn extracts_header_and_footer_blocks_from_docx_bytes() {
        let mut buffer = Cursor::new(Vec::new());
        let mut writer = ZipWriter::new(&mut buffer);
        let options = SimpleFileOptions::default();

        writer.start_file("_rels/.rels", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
                  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
                </Relationships>"#,
            )
            .unwrap();
        writer.start_file("word/_rels/document.xml.rels", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
                  <Relationship Id="rIdHeader" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>
                  <Relationship Id="rIdFooter" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>
                </Relationships>"#,
            )
            .unwrap();
        writer.start_file("word/document.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
                  <w:body>
                    <w:p><w:r><w:t>Body text</w:t></w:r></w:p>
                  </w:body>
                </w:document>"#,
            )
            .unwrap();
        writer.start_file("word/header1.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <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>"#,
            )
            .unwrap();
        writer.start_file("word/footer1.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <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>"#,
            )
            .unwrap();
        writer.finish().unwrap();

        let document = extract_from_bytes(&buffer.into_inner()).unwrap();

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

    #[test]
    fn raw_mode_keeps_insert_span_kind() {
        let mut buffer = Cursor::new(Vec::new());
        let mut writer = ZipWriter::new(&mut buffer);
        let options = SimpleFileOptions::default();

        writer.start_file("_rels/.rels", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
                  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
                </Relationships>"#,
            )
            .unwrap();
        writer.start_file("word/document.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <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:ins w:id="4"><w:r><w:t>new</w:t></w:r></w:ins>
                    </w:p>
                  </w:body>
                </w:document>"#,
            )
            .unwrap();
        writer.finish().unwrap();

        let document = extract_from_bytes_with_opts(
            &buffer.into_inner(),
            ExtractOptions {
                track_changes_mode: TrackChangesMode::Raw,
                ..ExtractOptions::default()
            },
        )
        .unwrap();

        assert!(matches!(
            document.blocks[0].text_spans.as_ref().unwrap()[1].tracked_changes[0].kind,
            SpanChangeKind::Insert
        ));
    }

    #[test]
    fn extracts_format_changes_from_docx_bytes() {
        let mut buffer = Cursor::new(Vec::new());
        let mut writer = ZipWriter::new(&mut buffer);
        let options = SimpleFileOptions::default();

        writer.start_file("_rels/.rels", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
                  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
                </Relationships>"#,
            )
            .unwrap();
        writer.start_file("word/document.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <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:rPr>
                          </w:rPrChange>
                        </w:rPr>
                        <w:t>Styled</w:t>
                      </w:r>
                    </w:p>
                  </w:body>
                </w:document>"#,
            )
            .unwrap();
        writer.finish().unwrap();

        let document = extract_from_bytes(&buffer.into_inner()).unwrap();

        assert_eq!(document.tracked_changes.len(), 1);
        assert!(matches!(
            &document.tracked_changes[0].kind,
            TrackedChangeKind::Format { previous_props_summary }
                if previous_props_summary == "b"
        ));
        assert_eq!(document.tracked_changes[0].excerpt.as_deref(), Some("b"));
        assert_eq!(
            document.blocks[0].text_spans.as_ref().unwrap()[0].tracked_changes[0].kind,
            SpanChangeKind::Format
        );
    }

    #[test]
    fn no_text_spans_option_omits_spans() {
        let mut buffer = Cursor::new(Vec::new());
        let mut writer = ZipWriter::new(&mut buffer);
        let options = SimpleFileOptions::default();

        writer.start_file("_rels/.rels", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
                  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
                </Relationships>"#,
            )
            .unwrap();
        writer.start_file("word/document.xml", options).unwrap();
        writer
            .write_all(
                br#"<?xml version="1.0" encoding="UTF-8"?>
                <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:p>
                  </w:body>
                </w:document>"#,
            )
            .unwrap();
        writer.finish().unwrap();

        let document = extract_from_bytes_with_opts(
            &buffer.into_inner(),
            ExtractOptions {
                include_text_spans: false,
                ..ExtractOptions::default()
            },
        )
        .unwrap();

        assert!(document.blocks[0].text_spans.is_none());
    }
}
