import { describe, test, expect } from 'bun:test';
import type {
  Document,
  DocumentBody,
  Paragraph,
  ParagraphContent,
  Run,
  Table,
  Hyperlink,
  Insertion,
  Deletion,
  MoveFrom,
  MoveTo,
  StyleDefinitions,
} from '@eigenpal/docx-editor-core/headless';
import { DocxReviewer } from '../DocxReviewer';
import { createReviewerBridge } from '../reviewerBridge';
import { CommentNotFoundError } from '../errors';

// ── Helpers ────────────────────────────────────────────────────────────────

function makeRun(text: string): Run {
  return { type: 'run', content: [{ type: 'text', text }] } as Run;
}

function makeParagraph(text: string, paraId?: string): Paragraph {
  return {
    type: 'paragraph',
    content: [makeRun(text)] as ParagraphContent[],
    formatting: {},
    paraId,
  } as Paragraph;
}

function makeTable(cells: string[][]): Table {
  return {
    type: 'table',
    rows: cells.map((row) => ({
      cells: row.map((text) => ({ content: [makeParagraph(text)] })),
    })),
  } as unknown as Table;
}

function makeReviewer(content: (Paragraph | Table)[], styles?: StyleDefinitions): DocxReviewer {
  const doc = {
    package: {
      document: { content, comments: [] } as DocumentBody,
      styles,
    },
  } as Document;
  return new DocxReviewer(doc, 'TestAgent');
}

function makeInsertion(text: string, id: number): Insertion {
  return {
    type: 'insertion',
    info: { id, author: 'A', date: '2024-01-01T00:00:00Z' },
    content: [makeRun(text)],
  };
}

function makeDeletion(text: string, id: number): Deletion {
  return {
    type: 'deletion',
    info: { id, author: 'A', date: '2024-01-01T00:00:00Z' },
    content: [makeRun(text)],
  };
}

function makeMoveFrom(text: string, id: number): MoveFrom {
  return {
    type: 'moveFrom',
    info: { id, author: 'A', date: '2024-01-01T00:00:00Z' },
    content: [makeRun(text)],
  };
}

function makeMoveTo(text: string, id: number): MoveTo {
  return {
    type: 'moveTo',
    info: { id, author: 'A', date: '2024-01-01T00:00:00Z' },
    content: [makeRun(text)],
  };
}

function makeMixedParagraph(content: ParagraphContent[], paraId: string): Paragraph {
  return { type: 'paragraph', content, formatting: {}, paraId } as Paragraph;
}

function makeHyperlink(text: string): Hyperlink {
  return {
    type: 'hyperlink',
    href: 'https://example.com',
    children: [makeRun(text)],
  } as Hyperlink;
}

function reviewerParagraph(reviewer: DocxReviewer, index = 0): Paragraph {
  return reviewer.toDocument().package.document.content[index] as Paragraph;
}

// ── Suite ──────────────────────────────────────────────────────────────────

describe('createReviewerBridge — read paths', () => {
  test('getContentAsText delegates to the reviewer', () => {
    const reviewer = makeReviewer([makeParagraph('First', 'p_a'), makeParagraph('Second', 'p_b')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.getContentAsText()).toContain('First');
    expect(bridge.getContentAsText()).toContain('Second');
  });

  test('getContent emits paraIds when present', () => {
    const reviewer = makeReviewer([makeParagraph('First', 'p_a'), makeParagraph('Second', 'p_b')]);
    const bridge = createReviewerBridge(reviewer);
    const blocks = bridge.getContent();
    // Both blocks should be paragraph type (only paragraph/heading/list-item carry paraId).
    if (blocks[0].type !== 'table') expect(blocks[0].paraId).toBe('p_a');
    if (blocks[1].type !== 'table') expect(blocks[1].paraId).toBe('p_b');
  });

  test('getSelection returns null in headless mode', () => {
    const reviewer = makeReviewer([makeParagraph('Hello', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.getSelection()).toBeNull();
  });
});

describe('createReviewerBridge — findText', () => {
  test('returns paraId-anchored handles for unique substrings', () => {
    const reviewer = makeReviewer([
      makeParagraph('The quick brown fox jumps over the lazy dog.', 'p_a'),
      makeParagraph('Nothing relevant here.', 'p_b'),
    ]);
    const bridge = createReviewerBridge(reviewer);
    const matches = bridge.findText('quick brown fox');
    expect(matches).toHaveLength(1);
    expect(matches[0].paraId).toBe('p_a');
    expect(matches[0].match).toBe('quick brown fox');
    expect(matches[0].before).toContain('The');
    expect(matches[0].after).toContain('jumps');
  });

  test('anchors paraId-less paragraphs by ordinal index (matches buildParaIdMap)', () => {
    const reviewer = makeReviewer([makeParagraph('orphan paragraph')]);
    const bridge = createReviewerBridge(reviewer);
    const matches = bridge.findText('orphan');
    expect(matches).toHaveLength(1);
    expect(matches[0].paraId).toBe('0');
  });

  test('skips ambiguous matches inside a single paragraph', () => {
    const reviewer = makeReviewer([
      makeParagraph('the the the the', 'p_a'),
      makeParagraph('only one here', 'p_b'),
    ]);
    const bridge = createReviewerBridge(reviewer);
    const matches = bridge.findText('the');
    // p_a is ambiguous → skipped; p_b doesn't contain "the" → not in results.
    expect(matches.find((m) => m.paraId === 'p_a')).toBeUndefined();
  });

  test('case-insensitive by default; case-sensitive when asked', () => {
    const reviewer = makeReviewer([makeParagraph('Hello WORLD', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.findText('world')).toHaveLength(1);
    expect(bridge.findText('world', { caseSensitive: true })).toHaveLength(0);
  });

  test('limit caps result count', () => {
    const reviewer = makeReviewer(
      Array.from({ length: 10 }, (_, i) => makeParagraph(`uniq${i} marker`, `p_${i}`))
    );
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.findText('marker', { limit: 3 })).toHaveLength(3);
  });

  test('empty query returns no matches', () => {
    const reviewer = makeReviewer([makeParagraph('whatever', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.findText('')).toEqual([]);
  });
});

// ────────────────────────────────────────────────────────────────────────────
// findText vanilla-view alignment
//
// findText must surface the same text the agent reads via read_document and
// can anchor via addComment. Pre-fix, findText skipped ALL tracked changes
// (both insertions and deletions). Post-fix, deletions/moveFrom are visible
// and matchable so an agent that picks a phrase containing deletion text can
// successfully locate it.
// ────────────────────────────────────────────────────────────────────────────

describe('createReviewerBridge — findText (vanilla view)', () => {
  test('finds a phrase that includes deletion text', () => {
    const reviewer = makeReviewer([
      makeMixedParagraph(
        [makeRun('cap is '), makeDeletion('$50k', 1), makeRun(' per year')],
        'p_a'
      ),
    ]);
    const bridge = createReviewerBridge(reviewer);
    const matches = bridge.findText('cap is $50k per year');
    expect(matches).toHaveLength(1);
    expect(matches[0].paraId).toBe('p_a');
  });

  test('does not find a phrase that exists only inside an insertion', () => {
    const reviewer = makeReviewer([
      makeMixedParagraph(
        [makeRun('cap is '), makeInsertion('$500k', 1), makeRun(' per year')],
        'p_a'
      ),
    ]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.findText('$500k')).toHaveLength(0);
  });

  test('does not find a phrase that straddles plain → insertion', () => {
    // Vanilla haystack drops the insertion, so the phrase is broken.
    const reviewer = makeReviewer([
      makeMixedParagraph(
        [makeRun('cap is '), makeInsertion('$500k', 1), makeRun(' per year')],
        'p_a'
      ),
    ]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.findText('cap is $500k')).toHaveLength(0);
  });

  test('finds a phrase entirely inside a deletion', () => {
    const reviewer = makeReviewer([
      makeMixedParagraph(
        [makeRun('start '), makeDeletion('hidden gem text', 1), makeRun(' end')],
        'p_a'
      ),
    ]);
    const bridge = createReviewerBridge(reviewer);
    const matches = bridge.findText('hidden gem');
    expect(matches).toHaveLength(1);
    expect(matches[0].paraId).toBe('p_a');
  });

  test('moveFrom text is findable (vanilla-visible)', () => {
    const reviewer = makeReviewer([
      makeMixedParagraph(
        [makeRun('here is '), makeMoveFrom('movefrom payload', 1), makeRun(' end')],
        'p_a'
      ),
    ]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.findText('movefrom payload')).toHaveLength(1);
  });

  test('moveTo text is not findable (vanilla-hidden)', () => {
    const reviewer = makeReviewer([
      makeMixedParagraph(
        [makeRun('here is '), makeMoveTo('moveto payload', 1), makeRun(' end')],
        'p_a'
      ),
    ]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.findText('moveto payload')).toHaveLength(0);
  });

  test('finds top-level hyperlink text', () => {
    const reviewer = makeReviewer([
      makeMixedParagraph([makeRun('see '), makeHyperlink('docs portal'), makeRun('.')], 'p_a'),
    ]);
    const bridge = createReviewerBridge(reviewer);
    const matches = bridge.findText('docs portal');
    expect(matches).toHaveLength(1);
    expect(matches[0].paraId).toBe('p_a');
  });

  test('finds hyperlink text inside a deletion (vanilla-visible)', () => {
    const deletionWithHyperlink: Deletion = {
      type: 'deletion',
      info: { id: 1, author: 'A', date: '2024-01-01T00:00:00Z' },
      content: [makeHyperlink('removed link payload')],
    };
    const reviewer = makeReviewer([
      makeMixedParagraph([makeRun('start '), deletionWithHyperlink, makeRun(' end')], 'p_a'),
    ]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.findText('removed link payload')).toHaveLength(1);
  });

  test('does not find hyperlink text inside an insertion (vanilla-hidden)', () => {
    const insertionWithHyperlink: Insertion = {
      type: 'insertion',
      info: { id: 1, author: 'A', date: '2024-01-01T00:00:00Z' },
      content: [makeHyperlink('inserted link payload')],
    };
    const reviewer = makeReviewer([
      makeMixedParagraph([makeRun('start '), insertionWithHyperlink, makeRun(' end')], 'p_a'),
    ]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.findText('inserted link payload')).toHaveLength(0);
  });

  test('cross-cut: any phrase findText returns is anchorable via addComment', () => {
    // Top-level invariant of the agent surface — keep find/anchor in sync.
    const reviewer = makeReviewer([
      makeMixedParagraph([makeRun('alpha '), makeDeletion('beta', 1), makeRun(' gamma')], 'p_a'),
    ]);
    const bridge = createReviewerBridge(reviewer);
    const matches = bridge.findText('alpha beta gamma');
    expect(matches).toHaveLength(1);
    const id = bridge.addComment({
      paraId: matches[0].paraId,
      text: 'note',
      search: matches[0].match,
    });
    expect(typeof id).toBe('number');
  });
});

describe('createReviewerBridge — addComment', () => {
  test('adds a comment on a paraId paragraph and returns id', () => {
    const reviewer = makeReviewer([makeParagraph('Pay $50k.', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    const id = bridge.addComment({
      paraId: 'p_a',
      text: 'Cap is too low.',
      author: 'AI',
    });
    expect(id).not.toBeNull();
    expect(typeof id).toBe('number');
    const comments = reviewer.getComments();
    expect(comments).toHaveLength(1);
    expect(comments[0].text).toContain('Cap is too low');
  });

  test('returns null when the paraId does not exist', () => {
    const reviewer = makeReviewer([makeParagraph('Hello', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.addComment({ paraId: 'p_missing', text: 'no', author: 'AI' })).toBeNull();
  });

  test('returns null when search text is missing', () => {
    const reviewer = makeReviewer([makeParagraph('Pay $50k.', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    expect(
      bridge.addComment({
        paraId: 'p_a',
        text: 'note',
        search: 'NOT IN PARAGRAPH',
        author: 'AI',
      })
    ).toBeNull();
  });
});

describe('createReviewerBridge — proposeChange (3 modes)', () => {
  test('replacement mode → tracked change recorded', () => {
    const reviewer = makeReviewer([makeParagraph('Pay $50k within 30 days.', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    const ok = bridge.proposeChange({
      paraId: 'p_a',
      search: '$50k',
      replaceWith: '$500k',
      author: 'AI',
    });
    expect(ok).toBe(true);
    const changes = reviewer.getChanges();
    expect(changes.length).toBeGreaterThan(0);
  });

  test('deletion mode → tracked deletion recorded', () => {
    const reviewer = makeReviewer([makeParagraph('Important: this clause is unnecessary.', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    const ok = bridge.proposeChange({
      paraId: 'p_a',
      search: 'this clause is unnecessary',
      replaceWith: '',
      author: 'AI',
    });
    expect(ok).toBe(true);
    const changes = reviewer.getChanges();
    expect(changes.find((c) => c.type === 'deletion')).toBeDefined();
  });

  test('insertion mode → tracked insertion at paragraph end', () => {
    const reviewer = makeReviewer([makeParagraph('Original text.', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    const ok = bridge.proposeChange({
      paraId: 'p_a',
      search: '',
      replaceWith: ' Appended note.',
      author: 'AI',
    });
    expect(ok).toBe(true);
    const changes = reviewer.getChanges();
    expect(changes.find((c) => c.type === 'insertion')).toBeDefined();
  });

  test('returns false on unknown paraId', () => {
    const reviewer = makeReviewer([makeParagraph('Hello', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    expect(
      bridge.proposeChange({
        paraId: 'p_missing',
        search: 'x',
        replaceWith: 'y',
        author: 'AI',
      })
    ).toBe(false);
  });

  test('returns false when both search and replaceWith are empty', () => {
    const reviewer = makeReviewer([makeParagraph('Hello', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.proposeChange({ paraId: 'p_a', search: '', replaceWith: '', author: 'AI' })).toBe(
      false
    );
  });
});

describe('createReviewerBridge — applyFormatting', () => {
  test('applies character formatting to a whole paragraph', () => {
    const paragraph = makeParagraph('Format me.', 'p_a');
    const reviewer = makeReviewer([paragraph]);
    const bridge = createReviewerBridge(reviewer);

    const ok = bridge.applyFormatting({
      paraId: 'p_a',
      marks: {
        bold: true,
        italic: true,
        color: { rgb: 'FF0000' },
        fontSize: 14,
        fontFamily: { ascii: 'Aptos' },
      },
    });

    expect(ok).toBe(true);
    const run = reviewerParagraph(reviewer).content[0] as Run;
    expect(run.formatting?.bold).toBe(true);
    expect(run.formatting?.italic).toBe(true);
    expect(run.formatting?.color?.rgb).toBe('FF0000');
    expect(run.formatting?.fontSize).toBe(28);
    expect(run.formatting?.fontFamily?.hAnsi).toBe('Aptos');
  });

  test('formats only a unique phrase by splitting runs', () => {
    const paragraph = makeParagraph('Alpha Beta Gamma', 'p_a');
    const reviewer = makeReviewer([paragraph]);
    const bridge = createReviewerBridge(reviewer);

    const ok = bridge.applyFormatting({
      paraId: 'p_a',
      search: 'Beta',
      marks: { underline: { style: 'double' }, highlight: 'yellow' },
    });

    expect(ok).toBe(true);
    const updated = reviewerParagraph(reviewer);
    expect(
      updated.content.map((item) =>
        item.type === 'run' && item.content[0].type === 'text' ? item.content[0].text : ''
      )
    ).toEqual(['Alpha ', 'Beta', ' Gamma']);
    const target = updated.content[1] as Run;
    expect(target.formatting?.underline?.style).toBe('double');
    expect(target.formatting?.highlight).toBe('yellow');
    expect((updated.content[0] as Run).formatting?.underline).toBeUndefined();
  });

  test('returns false for ambiguous or missing search text', () => {
    const reviewer = makeReviewer([makeParagraph('Repeat Repeat', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);

    expect(bridge.applyFormatting({ paraId: 'p_a', search: 'Repeat', marks: { bold: true } })).toBe(
      false
    );
    expect(
      bridge.applyFormatting({ paraId: 'p_a', search: 'Missing', marks: { bold: true } })
    ).toBe(false);
  });

  test('clears requested marks without removing unrelated formatting', () => {
    const paragraph = makeParagraph('Format me.', 'p_a');
    const run = paragraph.content[0] as Run;
    run.formatting = { bold: true, italic: true, underline: { style: 'single' } };
    const reviewer = makeReviewer([paragraph]);
    const bridge = createReviewerBridge(reviewer);

    const ok = bridge.applyFormatting({
      paraId: 'p_a',
      marks: { bold: false, underline: false },
    });

    expect(ok).toBe(true);
    const updated = reviewerParagraph(reviewer).content[0] as Run;
    expect(updated.formatting?.bold).toBeUndefined();
    expect(updated.formatting?.underline).toBeUndefined();
    expect(updated.formatting?.italic).toBe(true);
  });
});

describe('createReviewerBridge — setParagraphStyle', () => {
  test('sets a defined paragraph style', () => {
    const paragraph = makeParagraph('Heading text', 'p_a');
    const styles: StyleDefinitions = {
      styles: [{ styleId: 'Heading1', type: 'paragraph', name: 'Heading 1' }],
    };
    const reviewer = makeReviewer([paragraph], styles);
    const bridge = createReviewerBridge(reviewer);

    expect(bridge.setParagraphStyle({ paraId: 'p_a', styleId: 'Heading1' })).toBe(true);
    expect(reviewerParagraph(reviewer).formatting?.styleId).toBe('Heading1');
  });

  test('returns false for unknown paraId or undefined styleId', () => {
    const paragraph = makeParagraph('Text', 'p_a');
    const styles: StyleDefinitions = {
      styles: [{ styleId: 'Heading1', type: 'paragraph', name: 'Heading 1' }],
    };
    const reviewer = makeReviewer([paragraph], styles);
    const bridge = createReviewerBridge(reviewer);

    expect(bridge.setParagraphStyle({ paraId: 'missing', styleId: 'Heading1' })).toBe(false);
    expect(bridge.setParagraphStyle({ paraId: 'p_a', styleId: 'NoSuchStyle' })).toBe(false);
    expect(reviewerParagraph(reviewer).formatting?.styleId).toBeUndefined();
  });
});

describe('createReviewerBridge — insertBreak', () => {
  test('sectionNextPage / sectionContinuous set the section start on the target', () => {
    const reviewer = makeReviewer([makeParagraph('First', 'p_a'), makeParagraph('Second', 'p_b')]);
    const bridge = createReviewerBridge(reviewer);

    expect(bridge.insertBreak({ paraId: 'p_a', type: 'sectionNextPage' })).toBe(true);
    expect(reviewerParagraph(reviewer, 0).sectionProperties?.sectionStart).toBe('nextPage');

    expect(bridge.insertBreak({ paraId: 'p_b', type: 'sectionContinuous' })).toBe(true);
    expect(reviewerParagraph(reviewer, 1).sectionProperties?.sectionStart).toBe('continuous');
  });

  test('page break inserts a break-run paragraph after the target', () => {
    const reviewer = makeReviewer([makeParagraph('First', 'p_a'), makeParagraph('Second', 'p_b')]);
    const bridge = createReviewerBridge(reviewer);

    expect(bridge.insertBreak({ paraId: 'p_a', type: 'page' })).toBe(true);

    const content = reviewer.toDocument().package.document.content;
    expect(content).toHaveLength(3);
    const inserted = content[1] as Paragraph;
    const run = inserted.content[0] as Run;
    expect(run.content[0]).toEqual({ type: 'break', breakType: 'page' });
    // Second paragraph still resolvable after the inserted block (cache rebuilt):
    // content is now [p_a, pageBreak, p_b], so p_b sits at index 2.
    expect(bridge.insertBreak({ paraId: 'p_b', type: 'sectionNextPage' })).toBe(true);
    expect((content[2] as Paragraph).sectionProperties?.sectionStart).toBe('nextPage');
  });

  test('returns false for an unknown paraId', () => {
    const reviewer = makeReviewer([makeParagraph('First', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.insertBreak({ paraId: 'missing', type: 'page' })).toBe(false);
  });
});

describe('createReviewerBridge — comments lifecycle', () => {
  test('replyTo adds a threaded reply', () => {
    const reviewer = makeReviewer([makeParagraph('First.', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    const parentId = bridge.addComment({
      paraId: 'p_a',
      text: 'Top-level',
      author: 'AI',
    });
    expect(parentId).not.toBeNull();
    const replyId = bridge.replyTo(parentId!, { text: 'Acknowledged.', author: 'AI' });
    expect(replyId).not.toBeNull();
    const comment = reviewer.getComments().find((c) => c.id === parentId);
    expect(comment?.replies).toHaveLength(1);
  });

  test('resolveComment marks the comment as done', () => {
    const reviewer = makeReviewer([makeParagraph('First.', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    const id = bridge.addComment({ paraId: 'p_a', text: 'Note', author: 'AI' });
    bridge.resolveComment(id!);
    const comment = reviewer.getComments().find((c) => c.id === id);
    expect(comment?.done).toBe(true);
  });

  test('resolveComment throws CommentNotFoundError for an unknown id', () => {
    const reviewer = makeReviewer([makeParagraph('First.', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    // No comment with id 9999 exists — a miss must be a loud failure, not a
    // silent no-op that reports success (regression guard for fail-quiet resolve).
    expect(() => bridge.resolveComment(9999)).toThrow(CommentNotFoundError);
  });
});

describe('createReviewerBridge — scrollTo & selection', () => {
  test('scrollTo returns true for a known paraId, false for unknown', () => {
    const reviewer = makeReviewer([makeParagraph('Hello', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.scrollTo('p_a')).toBe(true);
    expect(bridge.scrollTo('p_missing')).toBe(false);
  });
});

describe('createReviewerBridge — events', () => {
  test('onContentChange fires after addComment, payload includes counts', () => {
    const reviewer = makeReviewer([makeParagraph('Hello', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    const events: Array<{ commentCount: number; changeCount: number }> = [];
    bridge.onContentChange((e) =>
      events.push({ commentCount: e.commentCount, changeCount: e.changeCount })
    );
    bridge.addComment({ paraId: 'p_a', text: 'Note', author: 'AI' });
    expect(events).toHaveLength(1);
    expect(events[0].commentCount).toBe(1);
  });

  test('onContentChange unsubscribe stops further events', () => {
    const reviewer = makeReviewer([makeParagraph('Hello', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    let count = 0;
    const off = bridge.onContentChange(() => {
      count++;
    });
    bridge.addComment({ paraId: 'p_a', text: 'Note', author: 'AI' });
    off();
    bridge.addComment({ paraId: 'p_a', text: 'Note 2', author: 'AI' });
    expect(count).toBe(1);
  });

  test('onSelectionChange listeners never fire in headless mode', () => {
    const reviewer = makeReviewer([makeParagraph('Hello', 'p_a')]);
    const bridge = createReviewerBridge(reviewer);
    let fired = false;
    bridge.onSelectionChange(() => {
      fired = true;
    });
    bridge.addComment({ paraId: 'p_a', text: 'Note', author: 'AI' });
    expect(fired).toBe(false);
  });
});

describe('createReviewerBridge — table indexing', () => {
  test('top-level paragraphs after a table remain addressable by paraId', () => {
    const before = makeParagraph('Before', 'p_before');
    const table = makeTable([
      ['A', 'B'],
      ['C', 'D'],
    ]);
    const after = makeParagraph('After', 'p_after');
    const reviewer = makeReviewer([before, table, after]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.scrollTo('p_before')).toBe(true);
    expect(bridge.scrollTo('p_after')).toBe(true);
    // Mutating after a table should still work.
    expect(bridge.addComment({ paraId: 'p_after', text: 'OK', author: 'AI' })).not.toBeNull();
  });
});

// `read_document` (formatContentForLLM) labels a paragraph with no w14:paraId by
// its ordinal index — `[0]`, `[1]`, … — and Word does not always emit paraIds.
// The bridge map must therefore resolve those ordinal-string ids, or every id
// read_document hands the agent for a paraId-less doc is rejected by the mutate
// tools. `find_text` mirrors the same convention: it emits `String(index)` for a
// paraId-less paragraph, so a phrase it surfaces is anchorable by the mutate tools
// on a doc with no paraIds (its index counting matches buildParaIdMap exactly).
describe('createReviewerBridge — paraId-less paragraphs addressable by ordinal index', () => {
  test('addComment resolves the ordinal-index id read_document shows', () => {
    const reviewer = makeReviewer([makeParagraph('first'), makeParagraph('second')]);
    const bridge = createReviewerBridge(reviewer);
    const id = bridge.addComment({ paraId: '1', text: 'on the second', author: 'AI' });
    expect(id).not.toBeNull();
    const comments = reviewer.getComments();
    expect(comments).toHaveLength(1);
    expect(comments[0].paragraphIndex).toBe(1);
  });

  test('proposeChange resolves an ordinal-index id', () => {
    const reviewer = makeReviewer([makeParagraph('alpha'), makeParagraph('beta')]);
    const bridge = createReviewerBridge(reviewer);
    const ok = bridge.proposeChange({
      paraId: '0',
      search: '',
      replaceWith: ' [ins]',
      author: 'AI',
    });
    expect(ok).toBe(true);
    expect(reviewer.getChanges()).toHaveLength(1);
  });

  test('a real paraId still wins; the paraId-less sibling is reached by ordinal', () => {
    const reviewer = makeReviewer([makeParagraph('has id', 'p_x'), makeParagraph('no id')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.addComment({ paraId: 'p_x', text: 'a', author: 'AI' })).not.toBeNull();
    expect(bridge.addComment({ paraId: '1', text: 'b', author: 'AI' })).not.toBeNull();
    expect(reviewer.getComments()).toHaveLength(2);
  });

  test('the ordinal index counts across a table, matching read_document', () => {
    // before(0), table(cells advance the index), after — `after` is index
    // 1 + (#cell-paragraphs). 2x2 table = 4 cell paragraphs → after is [5].
    const before = makeParagraph('before');
    const table = makeTable([
      ['A', 'B'],
      ['C', 'D'],
    ]);
    const after = makeParagraph('after');
    const reviewer = makeReviewer([before, table, after]);
    const bridge = createReviewerBridge(reviewer);
    const id = bridge.addComment({ paraId: '5', text: 'on after', author: 'AI' });
    expect(id).not.toBeNull();
    expect(reviewer.getComments()[0].paragraphIndex).toBe(5);
  });

  test('applyFormatting resolves an ordinal-index id (all map consumers, not just comments)', () => {
    const reviewer = makeReviewer([makeParagraph('format me')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.applyFormatting({ paraId: '0', marks: { bold: true } })).toBe(true);
  });

  test('a genuinely unknown id still returns null', () => {
    const reviewer = makeReviewer([makeParagraph('only')]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.addComment({ paraId: '99', text: 'x', author: 'AI' })).toBeNull();
  });

  test('find_text → addComment round-trips on a paraId-less doc', () => {
    // The end-to-end invariant: a phrase find_text surfaces must be anchorable
    // by the mutate tools even when the paragraph carries no w14:paraId.
    const reviewer = makeReviewer([makeParagraph('alpha'), makeParagraph('unique beta phrase')]);
    const bridge = createReviewerBridge(reviewer);
    const matches = bridge.findText('unique beta phrase');
    expect(matches).toHaveLength(1);
    expect(matches[0].paraId).toBe('1');
    const id = bridge.addComment({
      paraId: matches[0].paraId,
      text: 'note',
      search: matches[0].match,
    });
    expect(typeof id).toBe('number');
    expect(reviewer.getComments()[0].paragraphIndex).toBe(1);
  });

  test('find_text ordinal index counts across a table, matching buildParaIdMap', () => {
    // before(0), 2x2 table (4 cell paragraphs advance the index), after → [5].
    const before = makeParagraph('before');
    const table = makeTable([
      ['A', 'B'],
      ['C', 'D'],
    ]);
    const after = makeParagraph('locate me after the table');
    const reviewer = makeReviewer([before, table, after]);
    const bridge = createReviewerBridge(reviewer);
    const matches = bridge.findText('locate me after the table');
    expect(matches).toHaveLength(1);
    expect(matches[0].paraId).toBe('5');
    // …and the emitted id resolves through the mutate path.
    expect(
      bridge.addComment({ paraId: matches[0].paraId, text: 'x', author: 'AI' })
    ).not.toBeNull();
  });

  test('find_text prefers a real paraId over the ordinal when present', () => {
    const reviewer = makeReviewer([
      makeParagraph('no id here'),
      makeParagraph('tagged text', 'p_z'),
    ]);
    const bridge = createReviewerBridge(reviewer);
    expect(bridge.findText('no id here')[0].paraId).toBe('0');
    expect(bridge.findText('tagged text')[0].paraId).toBe('p_z');
  });
});
