import { describe, expect } from 'vitest';
import { testAllure, type AllureBddContext } from './helpers/allure-test.js';
import {
  buildDocumentView,
  buildNodesForDocumentView,
  discoverStyles,
  renderToon,
  type DocumentViewNode,
} from '../src/primitives/document_view.js';
import { LabelType } from '../src/primitives/list_labels.js';
import { parseXml } from '../src/primitives/xml.js';

const test = testAllure.epic('DOCX Primitives').withLabels({ feature: 'Document View' });

const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';

function wrapDoc(bodyXml: string): string {
  return (
    `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
    `<w:document xmlns:w="${W_NS}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
    `<w:body>${bodyXml}</w:body>` +
    `</w:document>`
  );
}

function makeParagraphs(bodyXml: string): Array<{ id: string; p: Element }> {
  const doc = parseXml(wrapDoc(bodyXml));
  const ps = Array.from(doc.getElementsByTagNameNS(W_NS, 'p'));
  return ps.map((p, idx) => ({ id: `_bk_${idx + 1}`, p }));
}

function makeStylesXml(styles: string): Document {
  return parseXml(
    `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
    `<w:styles xmlns:w="${W_NS}">${styles}</w:styles>`,
  );
}

function buildTestNodes(bodyXml: string, stylesXml: Document | null = null): DocumentViewNode[] {
  return buildNodesForDocumentView({
    paragraphs: makeParagraphs(bodyXml),
    stylesXml,
    numberingXml: null,
    include_semantic_tags: false,
    show_formatting: false,
  }).nodes;
}

function makeCellTableContext(paraIndex: number) {
  return {
    table_id: '_tbl_0',
    table_index: 0,
    row_index: 0,
    col_index: paraIndex,
    col_header: '',
    total_rows: 1,
    total_cols: 1,
    is_header_row: false,
    para_in_cell: 0,
    cell_para_count: 1,
  };
}

function buildTestNodesInCells(bodyXml: string, stylesXml: Document | null = null): DocumentViewNode[] {
  const paragraphs = makeParagraphs(bodyXml).map((entry, idx) => ({
    ...entry,
    tableContext: makeCellTableContext(idx),
  }));
  return buildNodesForDocumentView({
    paragraphs,
    stylesXml,
    numberingXml: null,
    include_semantic_tags: false,
    show_formatting: false,
  }).nodes;
}

function makeNode(overrides: Partial<DocumentViewNode>): DocumentViewNode {
  return {
    id: '_bk_1',
    list_label: '',
    header: '',
    style: 'body',
    text: '',
    clean_text: '',
    tagged_text: '',
    list_metadata: {
      list_level: -1,
      label_type: null,
      label_string: '',
      header_text: null,
      header_style: null,
      header_formatting: null,
      is_auto_numbered: false,
    },
    style_fingerprint: {
      list_level: -1,
      left_indent_pt: 0,
      first_line_indent_pt: 0,
      style_name: 'Body Text',
      alignment: 'LEFT',
    },
    paragraph_style_id: null,
    paragraph_style_name: 'Body Text',
    paragraph_alignment: 'LEFT',
    paragraph_indents_pt: { left: 0, first_line: 0 },
    numbering: { num_id: null, ilvl: null, is_auto_numbered: false },
    header_formatting: null,
    body_run_formatting: null,
    ...overrides,
  };
}

describe('document_view branch coverage', () => {
  test('buildDocumentView returns empty output when w:body is absent', async ({ given, when, then }: AllureBddContext) => {
    let doc: Document;
    let result: ReturnType<typeof buildDocumentView>;

    await given('a document XML without w:body', async () => {
      doc = parseXml(
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}"></w:document>`,
      );
    });

    await when('buildDocumentView is called', async () => {
      result = buildDocumentView({
        documentXml: doc!,
        stylesXml: null,
        numberingXml: null,
      });
    });

    await then('nodes and styles are empty', async () => {
      expect(result!.nodes).toEqual([]);
      expect(result!.styles.styles.size).toBe(0);
    });
  });

  test('legacy semantic path emits highlight tags but ignores w:highlight w:val="none"', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a paragraph with none-highlight and yellow-highlight runs', async () => {
      bodyXml =
        `<w:p>` +
          `<w:r><w:rPr><w:highlight w:val="none"/></w:rPr><w:t>plain</w:t></w:r>` +
          `<w:r><w:rPr><w:highlight w:val="yellow"/></w:rPr><w:t>hot</w:t></w:r>` +
          `<w:r><w:t>end</w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called with semantic tags', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: true,
        show_formatting: false,
      }));
    });

    await then('highlight tags wrap only yellow-highlighted text', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.tagged_text).toContain('plain<highlight>hot</highlight>end');
    });

    await and('none-highlighted text has no highlight tag', async () => {
      expect(nodes[0]!.tagged_text).not.toContain('<highlight>plain');
    });
  });

  test('header fallback extracts long run-in titles and rejects overlong header candidates', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('paragraphs with a valid colon-title and an overlong header candidate', async () => {
      const valid = 'Governing Law and Venue: this agreement is governed as stated.';
      const tooLong =
        'This Header Text Is Deliberately More Than Sixty Characters Long For Rejection: body content follows.';
      bodyXml =
        `<w:p><w:r><w:t>${valid}</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>${tooLong}</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the valid colon-title is extracted as header', async () => {
      expect(nodes).toHaveLength(2);
      expect(nodes[0]!.header).toBe('Governing Law and Venue');
      expect(nodes[0]!.list_metadata.header_style).toBe('title_with_colon');
    });

    await and('the overlong candidate is rejected', async () => {
      expect(nodes[1]!.header).toBe('');
      expect(nodes[1]!.list_metadata.header_text).toBeNull();
    });
  });

  test('injects [^N] markers from footnote refs, skipping reserved IDs and field-code text', async ({ given, when, then, and }: AllureBddContext) => {
    let documentXml: Document;
    let paragraphs: Array<{ id: string; p: Element }>;
    let footnotesXml: Document;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a paragraph with footnote refs, field codes, and reserved IDs', async () => {
      const bodyXml =
        `<w:p>` +
          `<w:r><w:t>A</w:t></w:r>` +
          `<w:r><w:footnoteReference w:id="2"/></w:r>` +
          `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
          `<w:r><w:t>IGNORED</w:t></w:r>` +
          `<w:r><w:fldChar w:fldCharType="separate"/></w:r>` +
          `<w:r><w:t>B</w:t></w:r>` +
          `<w:r><w:fldChar w:fldCharType="end"/></w:r>` +
          `<w:r><w:footnoteReference w:id="-1"/></w:r>` +
          `<w:r><w:t>C</w:t></w:r>` +
          `<w:r><w:footnoteReference w:id="3"/></w:r>` +
        `</w:p>`;

      documentXml = parseXml(wrapDoc(bodyXml));
      paragraphs = Array.from(documentXml.getElementsByTagNameNS(W_NS, 'p'))
        .map((p, idx) => ({ id: `_bk_${idx + 1}`, p }));
      footnotesXml = parseXml(
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:footnotes xmlns:w="${W_NS}">` +
          `<w:footnote w:type="separator" w:id="-1"><w:p/></w:footnote>` +
          `<w:footnote w:id="2"><w:p><w:r><w:t>Two</w:t></w:r></w:p></w:footnote>` +
          `<w:footnote w:id="3"><w:p><w:r><w:t>Three</w:t></w:r></w:p></w:footnote>` +
        `</w:footnotes>`,
      );
    });

    await when('buildNodesForDocumentView is called with footnotes', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: paragraphs!,
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
        documentXml: documentXml!,
        footnotesXml: footnotesXml!,
      }));
    });

    await then('footnote markers are injected correctly', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.tagged_text).toContain('A[^1]');
      expect(nodes[0]!.tagged_text).toContain('C[^2]');
    });

    await and('field-code text and reserved IDs are excluded', async () => {
      expect(nodes[0]!.tagged_text).not.toContain('IGNORED');
      expect(nodes[0]!.tagged_text).not.toContain('[^0]');
    });
  });

  test('trims manual list-label boundary whitespace in formatting mode', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a paragraph with manual list label and extra whitespace', async () => {
      bodyXml = `<w:p><w:r><w:t>(a)   obligation survives termination.</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called with formatting', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: true,
        show_formatting: true,
      }));
    });

    await then('list label is extracted without extra whitespace', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_label).toBe('(a)');
    });

    await and('tagged text starts with the body content', async () => {
      expect(nodes[0]!.tagged_text.startsWith('obligation')).toBe(true);
    });
  });

  test('discoverStyles assigns deterministic suffixes for multiple groups sharing same semantic base', async ({ given, when, then }: AllureBddContext) => {
    let nodes: DocumentViewNode[];
    let styles: ReturnType<typeof discoverStyles>;

    await given('two nodes with same style name but different alignment', async () => {
      nodes = [
        makeNode({
          id: '_bk_1',
          clean_text: 'Body A',
          tagged_text: 'Body A',
          style_fingerprint: {
            list_level: -1,
            left_indent_pt: 0,
            first_line_indent_pt: 0,
            style_name: 'Body Text',
            alignment: 'LEFT',
          },
        }),
        makeNode({
          id: '_bk_2',
          clean_text: 'Body B',
          tagged_text: 'Body B',
          style_fingerprint: {
            list_level: -1,
            left_indent_pt: 0,
            first_line_indent_pt: 0,
            style_name: 'Body Text',
            alignment: 'CENTER',
          },
        }),
      ];
    });

    await when('discoverStyles is called', async () => {
      styles = discoverStyles(nodes!);
    });

    await then('style IDs have deterministic numeric suffixes', async () => {
      const ids = [...styles!.styles.keys()].sort();
      expect(ids).toEqual(['body', 'body_1']);
    });
  });

  test('discoverStyles covers list-level and non-list semantic classification branches', async ({ given, when, then }: AllureBddContext) => {
    let styles: ReturnType<typeof discoverStyles>;

    await given('nodes covering all semantic classification branches', async () => {
      // setup done inline in when step
    });

    await when('discoverStyles is called with diverse nodes', async () => {
      styles = discoverStyles([
        makeNode({
          id: '_bk_article',
          style_fingerprint: { list_level: 0, left_indent_pt: 0, first_line_indent_pt: 0, style_name: 'List Paragraph', alignment: 'LEFT' },
          list_metadata: { ...makeNode({}).list_metadata, label_type: LabelType.ARTICLE, list_level: 0 },
        }),
        makeNode({
          id: '_bk_section',
          style_fingerprint: { list_level: 0, left_indent_pt: 4, first_line_indent_pt: 0, style_name: 'List Paragraph', alignment: 'LEFT' },
          list_metadata: { ...makeNode({}).list_metadata, label_type: LabelType.SECTION, list_level: 0 },
        }),
        makeNode({
          id: '_bk_subsection_letter',
          style_fingerprint: { list_level: 1, left_indent_pt: 8, first_line_indent_pt: 0, style_name: 'List Paragraph', alignment: 'LEFT' },
          list_metadata: { ...makeNode({}).list_metadata, label_type: LabelType.LETTER, list_level: 1 },
        }),
        makeNode({
          id: '_bk_subsection_number',
          style_fingerprint: { list_level: 1, left_indent_pt: 12, first_line_indent_pt: 0, style_name: 'List Paragraph', alignment: 'LEFT' },
          list_metadata: { ...makeNode({}).list_metadata, label_type: LabelType.NUMBER, list_level: 1 },
        }),
        makeNode({
          id: '_bk_level2_roman',
          style_fingerprint: { list_level: 2, left_indent_pt: 16, first_line_indent_pt: 0, style_name: 'List Paragraph', alignment: 'LEFT' },
          list_metadata: { ...makeNode({}).list_metadata, label_type: LabelType.ROMAN, list_level: 2 },
        }),
        makeNode({
          id: '_bk_indent',
          style_fingerprint: { list_level: -1, left_indent_pt: 10, first_line_indent_pt: 0, style_name: 'Body Text', alignment: 'LEFT' },
        }),
        makeNode({
          id: '_bk_heading',
          style_fingerprint: { list_level: -1, left_indent_pt: 0, first_line_indent_pt: 0, style_name: 'Heading 2', alignment: 'LEFT' },
        }),
        makeNode({
          id: '_bk_quote',
          style_fingerprint: { list_level: -1, left_indent_pt: 0, first_line_indent_pt: 0, style_name: 'Quote Block', alignment: 'RIGHT' },
        }),
      ]);
    });

    await then('all semantic style IDs are assigned correctly', async () => {
      const ids = new Set(styles!.styles.keys());
      expect(ids.has('article')).toBe(true);
      expect(ids.has('section')).toBe(true);
      expect(ids.has('subsection')).toBe(true);
      expect(ids.has('subsection_number')).toBe(true);
      expect(ids.has('level_2_roman')).toBe(true);
      expect(ids.has('indent_block')).toBe(true);
      expect(ids.has('heading')).toBe(true);
      expect(ids.has('block_quote')).toBe(true);
    });
  });

  test('renderToon supports compact mode with style fingerprints', async ({ given, when, then }: AllureBddContext) => {
    let node: any;
    let toon: string;

    await given('a node with style fingerprint and indent', async () => {
      node = {
        id: '_bk_1',
        style: 'body',
        tagged_text: 'Hello',
        style_fingerprint: {
          list_level: -1,
          left_indent_pt: 10,
          first_line_indent_pt: 0,
          style_name: 'Normal',
          alignment: 'LEFT',
        },
      };
    });

    await when('renderToon is called with compact mode', async () => {
      toon = renderToon([node!], { compact: true });
    });

    await then('output includes compact fingerprint and text', async () => {
      // Debug: console.log('TOON:', toon);
      expect(toon).toContain('body:L-1:LEFT:I10:H0');
      expect(toon).toContain('Hello');
    });
  });

  test('long-paragraph regex no longer truncates body prose into a bare-title header (#157)', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('paragraphs that begin with a capitalized word followed by body prose', async () => {
      // Real false positives observed against tests/test_documents/nvca-regression/source.docx.
      const termination = 'Termination of Section 2.2(d)(i) shall not affect any other provision of this Agreement.';
      const exceptAs = 'Except as described in Section 2.2(d)(i), the Company has no obligation to repurchase the shares.';
      bodyXml =
        `<w:p><w:r><w:t>${termination}</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>${exceptAs}</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('"Termination of Section ..." is not classified as a header', async () => {
      expect(nodes).toHaveLength(2);
      expect(nodes[0]!.list_metadata.header_text).toBeNull();
      expect(nodes[0]!.list_metadata.header_style).toBeNull();
    });

    await and('"Except as described ..." is not classified as a header', async () => {
      expect(nodes[1]!.list_metadata.header_text).toBeNull();
      expect(nodes[1]!.list_metadata.header_style).toBeNull();
    });
  });

  test('short standalone table-cell paragraphs do not classify as bare headers (#187)', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let paragraphs: Array<{ id: string; p: Element; tableContext?: {
      table_id: string;
      table_index: number;
      row_index: number;
      col_index: number;
      col_header: string;
      total_rows: number;
      total_cols: number;
      is_header_row: boolean;
      para_in_cell: number;
      cell_para_count: number;
    } }>;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a short standalone paragraph in a table cell context', async () => {
      bodyXml = `<w:p><w:r><w:t>Purpose</w:t></w:r></w:p>`;
      const [paragraph] = makeParagraphs(bodyXml!);
      paragraphs = [{
        ...paragraph!,
        tableContext: {
          table_id: '_tbl_0',
          table_index: 0,
          row_index: 1,
          col_index: 0,
          col_header: 'Section',
          total_rows: 2,
          total_cols: 1,
          is_header_row: false,
          para_in_cell: 0,
          cell_para_count: 1,
        },
      }];
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: paragraphs!,
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the table-cell paragraph does not emit header metadata', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_metadata.header_text).toBeNull();
      expect(nodes[0]!.list_metadata.header_style).toBeNull();
    });

    await and('table_context is preserved on the node', async () => {
      expect(nodes[0]!.table_context?.table_id).toBe('_tbl_0');
    });
  });

  test('signature-label clusters are suppressed in a post-pass (#187)', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('five consecutive signature-label paragraphs', async () => {
      const labels = ['By:', 'Name:', 'Title:', 'Address:', 'Email:'];
      bodyXml = labels.map((label) => `<w:p><w:r><w:t>${label}</w:t></w:r></w:p>`).join('');
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('every paragraph in the cluster has null header metadata', async () => {
      expect(nodes).toHaveLength(5);
      for (const node of nodes) {
        expect(node.list_metadata.header_text).toBeNull();
        expect(node.list_metadata.header_style).toBeNull();
        expect(node.list_metadata.header_formatting).toBeNull();
      }
    });

    await and('the duplicated header fields are cleared for rendering consistency', async () => {
      for (const node of nodes) {
        expect(node.header).toBe('');
        expect(node.header_formatting).toBeNull();
      }
    });
  });

  test('all-caps signature-label cluster (COMPANY: / PURCHASER:) is suppressed (#187)', async ({ given, when, then }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('an NVCA-style all-caps signature cluster', async () => {
      // Mirrors the NVCA SPA paragraphs 312-321 shape: alternating
      // PARTY-name lines with COMPANY:/PURCHASER: prefix and By:/Name:/Title: rows.
      const labels = [
        'COMPANY: [Insert Company Name]',
        'By:',
        'Name:',
        'Title:',
        'Address:',
        'PURCHASER: [Insert Investor Name]',
        'By:',
        'Name:',
        'Title:',
      ];
      bodyXml = labels.map((label) => `<w:p><w:r><w:t>${label}</w:t></w:r></w:p>`).join('');
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('every paragraph in the all-caps cluster has null header metadata', async () => {
      expect(nodes).toHaveLength(9);
      for (const node of nodes) {
        expect(node.list_metadata.header_style).toBeNull();
        expect(node.list_metadata.header_text).toBeNull();
      }
    });
  });

  test('signature cluster does not erase a non-label company-name lead-in (#187)', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a non-label company-name paragraph followed by signature-label paragraphs', async () => {
      // `ACME CORP` does not match either signature-label regex (no trailing
      // colon, multi-word all-caps). The density gate fires for the
      // window, but suppression must NOT mutate non-label paragraphs.
      // The real NVCA shape `COMPANY: [Insert Company Name]` matches the
      // prefix regex and is exercised by the test above.
      bodyXml =
        `<w:p><w:r><w:t>ACME CORP</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>By:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Name:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Title:</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the three label paragraphs are suppressed', async () => {
      expect(nodes).toHaveLength(4);
      for (let i = 1; i < 4; i++) {
        expect(nodes[i]!.list_metadata.header_style).toBeNull();
        expect(nodes[i]!.list_metadata.header_text).toBeNull();
      }
    });

    await and('the non-label lead-in keeps its heuristic classification', async () => {
      expect(nodes[0]!.clean_text).toBe('ACME CORP');
      expect(nodes[0]!.list_metadata.header_text).not.toBeNull();
    });
  });

  test('signature cluster preserves a non-label body neighbor (#187, regression for over-suppression)', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('four signature labels followed by an unstyled short paragraph', async () => {
      // `Product` would independently classify as title_bare via the
      // short-paragraph fallback. Earlier revisions of suppressSignatureClusters
      // erased every paragraph inside a qualifying window, which would have
      // cleared `Product` here. The fix narrows suppression to label
      // paragraphs only.
      bodyXml =
        `<w:p><w:r><w:t>By:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Name:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Title:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Address:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Product</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the four signature labels are suppressed', async () => {
      expect(nodes).toHaveLength(5);
      for (let i = 0; i < 4; i++) {
        expect(nodes[i]!.list_metadata.header_style).toBeNull();
      }
    });

    await and('the non-label neighbor keeps its heuristic header_text', async () => {
      expect(nodes[4]!.clean_text).toBe('Product');
      expect(nodes[4]!.list_metadata.header_text).not.toBeNull();
    });
  });

  test('signature cluster preserves custom-style headings (Heading7 / Title / Subtitle / Article5L1) (#187)', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('four signature labels followed by four non-Heading1-6 styled headings', async () => {
      // The fixture corpus uses heading-ish styles beyond Heading1-6
      // (Heading7-9, Title, Subtitle, Article5L1-8). They are not signature
      // labels, so they must survive suppression even though they sit
      // adjacent to a label-dense window.
      bodyXml =
        `<w:p><w:r><w:t>By:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Name:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Title:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Address:</w:t></w:r></w:p>` +
        `<w:p><w:pPr><w:pStyle w:val="Heading7"/></w:pPr><w:r><w:t>Execution</w:t></w:r></w:p>` +
        `<w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr><w:r><w:t>Schedule A</w:t></w:r></w:p>` +
        `<w:p><w:pPr><w:pStyle w:val="Subtitle"/></w:pPr><w:r><w:t>Preliminary</w:t></w:r></w:p>` +
        `<w:p><w:pPr><w:pStyle w:val="Article5L1"/></w:pPr><w:r><w:t>Definitions</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the four signature labels are suppressed', async () => {
      expect(nodes).toHaveLength(8);
      for (let i = 0; i < 4; i++) {
        expect(nodes[i]!.list_metadata.header_style).toBeNull();
      }
    });

    await and('each custom-styled heading keeps its detected header_text', async () => {
      for (let i = 4; i < 8; i++) {
        expect(nodes[i]!.list_metadata.header_text).not.toBeNull();
      }
    });
  });

  test('consecutive WHEREAS recitals are not classified as signature-cluster labels (#187 regression)', async ({ given, when, then }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('four consecutive WHEREAS recital paragraphs', async () => {
      // WHEREAS clauses use a comma after the lead-in word, not a colon,
      // so they do not match SIGNATURE_LABEL_PREFIX_RE. They are also
      // long, so they do not pick up title_bare/title_with_colon. Lock
      // in that suppression does not touch them.
      bodyXml =
        `<w:p><w:r><w:t>WHEREAS, the parties desire to enter into this Agreement on the terms set forth herein.</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>WHEREAS, each party has the requisite authority to execute and deliver this Agreement.</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>WHEREAS, the parties acknowledge the mutual covenants and agreements stated below.</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>WHEREAS, this Agreement is intended to be legally binding and enforceable.</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('no WHEREAS recital is mutated by signature-cluster suppression', async () => {
      expect(nodes).toHaveLength(4);
      for (const node of nodes) {
        expect(node.list_metadata.header_style).toBeNull();
        expect(node.list_metadata.header_text).toBeNull();
      }
    });
  });

  test('real heading immediately after a signature cluster is preserved (#187)', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('four signature labels followed by a Heading1-styled section title', async () => {
      // Boundary case: signature page followed by the next section heading.
      // The cluster window covering all 5 paragraphs has 4/5 = 80% match,
      // which would have erased the Heading1 paragraph under the original
      // "any covering window" semantics.
      bodyXml =
        `<w:p><w:r><w:t>By:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Name:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Title:</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Address:</w:t></w:r></w:p>` +
        `<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Product</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the four signature labels are suppressed', async () => {
      expect(nodes).toHaveLength(5);
      for (let i = 0; i < 4; i++) {
        expect(nodes[i]!.list_metadata.header_style).toBeNull();
      }
    });

    await and('the adjacent Heading1 paragraph still emits a header_text', async () => {
      const heading = nodes[4]!;
      expect(heading.clean_text).toBe('Product');
      expect(heading.list_metadata.header_text).not.toBeNull();
    });
  });

  test('detectRunInHeader rejects whole-paragraph bold blocks (#157)', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a long fully-bold paragraph that ends with a colon', async () => {
      // Mirrors all-bold defined-term lead-ins in the NVCA fixture. The
      // paragraph is over SHORT_HEADER_MAX_LENGTH (50 chars) so the
      // short-paragraph inline-style fallback does not fire — the only path
      // that could classify it as a header is detectRunInHeader, which now
      // requires a real header-prefix / body transition.
      const text = 'This entire paragraph is bold and ends with a colon to mimic an all-bold defined term lead-in:';
      bodyXml = `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>${text}</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the whole-paragraph bold block is not a run-in header', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_metadata.header_style).not.toBe('run_in_header');
    });

    await and('the rendered header field stays empty (no toon stripping)', async () => {
      expect(nodes[0]!.header).toBe('');
    });
  });

  test('detectRunInHeader still fires on a real bold-prefix + body transition (no regression)', async ({ given, when, then }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a paragraph with a bold "Indemnification." prefix followed by non-bold body', async () => {
      bodyXml =
        `<w:p>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t>Indemnification.</w:t></w:r>` +
          `<w:r><w:t> The Company shall indemnify each Investor as set forth herein.</w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the bold prefix is classified as a run_in_header', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_metadata.header_style).toBe('run_in_header');
      expect(nodes[0]!.list_metadata.header_text).toBe('Indemnification');
    });
  });

  test('detects centered ALL-CAPS bold standalone title as title_caps_centered (#157)', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a center-aligned, fully bold, ALL-CAPS standalone title', async () => {
      // Mirrors the NVCA SPA's primary title.
      bodyXml =
        `<w:p>` +
          `<w:pPr><w:jc w:val="center"/></w:pPr>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t>SERIES A PREFERRED STOCK PURCHASE AGREEMENT</w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the title is surfaced with header_style title_caps_centered', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_metadata.header_style).toBe('title_caps_centered');
      expect(nodes[0]!.list_metadata.header_text).toBe('SERIES A PREFERRED STOCK PURCHASE AGREEMENT');
    });

    await and('header_formatting reflects the bold run', async () => {
      expect(nodes[0]!.list_metadata.header_formatting?.bold).toBe(true);
    });
  });

  test('title_caps_centered does not fire on centered text containing lowercase (#157)', async ({ given, when, then }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a centered, bold, mixed-case standalone paragraph', async () => {
      bodyXml =
        `<w:p>` +
          `<w:pPr><w:jc w:val="center"/></w:pPr>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t>Series A Preferred Stock Purchase Agreement</w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the title_caps_centered rule does not fire', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_metadata.header_style).not.toBe('title_caps_centered');
    });
  });

  test('title_caps_centered rejects bracketed single-token placeholders like [COMPANY] (#178 review)', async ({ given, when, then }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a centered, bold, single-token bracketed placeholder', async () => {
      bodyXml =
        `<w:p>` +
          `<w:pPr><w:jc w:val="center"/></w:pPr>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t>[COMPANY]</w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the placeholder is not classified as title_caps_centered', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_metadata.header_style).not.toBe('title_caps_centered');
    });
  });

  test('title_caps_centered rejects punctuation/underscore-only signature lines (#178 review)', async ({ given, when, then }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a centered, bold underscore signature line (real ILPA fixture pattern)', async () => {
      bodyXml =
        `<w:p>` +
          `<w:pPr><w:jc w:val="center"/></w:pPr>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t>____________________________________________</w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the underscore line is not classified as title_caps_centered', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_metadata.header_style).not.toBe('title_caps_centered');
      expect(nodes[0]!.list_metadata.header_text).toBeNull();
    });
  });

  test('title_caps_centered admits long ALL-CAPS corporate titles up to 120 chars (#178 review)', async ({ given, when, then }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a centered, bold ALL-CAPS title between 60 and 120 chars (NVCA COI fixture pattern)', async () => {
      // 87 chars — exceeds the old MAX_HEADER_TEXT_LENGTH of 60 but fits the
      // new MAX_CENTERED_TITLE_LENGTH of 120.
      const text = 'AMENDED AND RESTATED CERTIFICATE OF INCORPORATION OF FOO HOLDINGS INC.';
      bodyXml =
        `<w:p>` +
          `<w:pPr><w:jc w:val="center"/></w:pPr>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t>${text}</w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the title is classified as title_caps_centered', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_metadata.header_style).toBe('title_caps_centered');
      expect(nodes[0]!.list_metadata.header_text).toContain('AMENDED AND RESTATED');
    });
  });

  test('title_caps_centered outranks short title_bare for short ALL-CAPS centered titles (#178 review)', async ({ given, when, then }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a short centered, bold ALL-CAPS title that would also match the short-paragraph fallback', async () => {
      bodyXml =
        `<w:p>` +
          `<w:pPr><w:jc w:val="center"/></w:pPr>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t>SCHEDULE OF PURCHASERS</w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the title is classified as title_caps_centered, not title_bare', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_metadata.header_style).toBe('title_caps_centered');
    });
  });

  test('derives word_style headings only from exact Heading1-Heading6 paragraph style IDs', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let stylesXml: Document;
    let nodes: DocumentViewNode[];

    await given('built-in heading styles, an inheriting HeadingPara1 style, and a normal body paragraph', async () => {
      stylesXml = makeStylesXml(
        `<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/></w:style>` +
        `<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/></w:style>` +
        `<w:style w:type="paragraph" w:styleId="Heading6"><w:name w:val="heading 6"/></w:style>` +
        `<w:style w:type="paragraph" w:styleId="HeadingPara1">` +
          `<w:name w:val="Heading Para 1"/>` +
          `<w:basedOn w:val="Heading1"/>` +
          `<w:next w:val="Normal"/>` +
        `</w:style>`,
      );
      bodyXml =
        `<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Board Approval</w:t></w:r></w:p>` +
        `<w:p><w:pPr><w:pStyle w:val="Heading6"/></w:pPr><w:r><w:t>Minor Conditions</w:t></w:r></w:p>` +
        `<w:p><w:pPr><w:pStyle w:val="HeadingPara1"/></w:pPr><w:r><w:t>this inherited style stays body prose.</w:t></w:r></w:p>` +
        `<w:p><w:pPr><w:pStyle w:val="Normal"/></w:pPr><w:r><w:t>this is ordinary body text.</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      nodes = buildTestNodes(bodyXml!, stylesXml!);
    });

    await then('Heading1 and Heading6 paragraphs emit word_style headings with exact levels', async () => {
      expect(nodes).toHaveLength(4);
      expect(nodes[0]!.heading).toEqual({ text: 'Board Approval', source: 'word_style', level: 1 });
      expect(nodes[1]!.heading).toEqual({ text: 'Minor Conditions', source: 'word_style', level: 6 });
    });

    await and('HeadingPara1 inheritance does not produce a heading object', async () => {
      expect(nodes[2]!.heading).toBeUndefined();
      expect(Object.prototype.hasOwnProperty.call(nodes[2]!, 'heading')).toBe(false);
    });

    await and('body paragraphs omit the heading field entirely', async () => {
      expect(nodes[3]!.heading).toBeUndefined();
      expect(Object.prototype.hasOwnProperty.call(nodes[3]!, 'heading')).toBe(false);
    });
  });

  test('derives heuristic heading objects for title_caps_centered and run_in_header paragraphs', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: DocumentViewNode[];

    await given('a centered standalone title and a bold-prefix run-in header paragraph', async () => {
      bodyXml =
        `<w:p>` +
          `<w:pPr><w:jc w:val="center"/></w:pPr>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t>SERIES A PREFERRED STOCK PURCHASE AGREEMENT</w:t></w:r>` +
        `</w:p>` +
        `<w:p>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t>Indemnification.</w:t></w:r>` +
          `<w:r><w:t> The Company shall indemnify each Investor as set forth herein.</w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      nodes = buildTestNodes(bodyXml!);
    });

    await then('the centered title emits a null-level heading object', async () => {
      expect(nodes).toHaveLength(2);
      expect(nodes[0]!.heading).toEqual({
        text: 'SERIES A PREFERRED STOCK PURCHASE AGREEMENT',
        source: 'title_caps_centered',
        level: null,
      });
    });

    await and('the run-in header emits a null-level heading object', async () => {
      expect(nodes[1]!.heading).toEqual({
        text: 'Indemnification',
        source: 'run_in_header',
        level: null,
      });
    });
  });

  test('prefers word_style heading derivation over heuristic header detectors when both match', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let stylesXml: Document;
    let nodes: DocumentViewNode[];

    await given('a Heading2-styled paragraph that also matches the title_with_colon heuristic', async () => {
      stylesXml = makeStylesXml(
        `<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/></w:style>`,
      );
      bodyXml =
        `<w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:t>Governing Law: this agreement is governed as stated.</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      nodes = buildTestNodes(bodyXml!, stylesXml!);
    });

    await then('the heading object uses word_style with level 2 and clean_text', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.heading).toEqual({
        text: 'Governing Law: this agreement is governed as stated.',
        source: 'word_style',
        level: 2,
      });
    });

    await and('the heuristic signal remains explanatory only', async () => {
      expect(nodes[0]!.list_metadata.header_style).toBe('title_with_colon');
      expect(nodes[0]!.list_metadata.header_text).toBe('Governing Law');
    });
  });

  test('derives heuristic heading objects for title_with_period and title_bare body paragraphs', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: DocumentViewNode[];

    await given('a short bare-title paragraph and a short title-with-period paragraph', async () => {
      bodyXml =
        `<w:p><w:r><w:t>Governing Law</w:t></w:r></w:p>` +
        `<w:p><w:r><w:t>Definitions.</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      nodes = buildTestNodes(bodyXml!);
    });

    await then('the bare-title paragraph emits a title_bare heading', async () => {
      expect(nodes[0]!.heading).toEqual({
        text: 'Governing Law',
        source: 'title_bare',
        level: null,
      });
    });

    await and('the title-with-period paragraph emits a title_with_period heading', async () => {
      expect(nodes[1]!.heading).toEqual({
        text: 'Definitions',
        source: 'title_with_period',
        level: null,
      });
    });
  });

  test('suppresses heuristic heading promotion for paragraphs inside table cells (peer review #190)', async ({ given, when, then, and }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: DocumentViewNode[];

    await given('table cell paragraphs that match each heuristic detector', async () => {
      bodyXml =
        // title_bare candidate (short ALL-CAPS-leading title, no terminator)
        `<w:p><w:r><w:t>Purchase Price</w:t></w:r></w:p>` +
        // title_with_colon candidate (short paragraph ending in ":")
        `<w:p><w:r><w:t>Notice Address:</w:t></w:r></w:p>` +
        // title_with_period candidate (short paragraph ending in ".")
        `<w:p><w:r><w:t>Closing.</w:t></w:r></w:p>` +
        // run_in_header candidate (bold prefix + non-bold body)
        `<w:p>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t>Indemnification.</w:t></w:r>` +
          `<w:r><w:t xml:space="preserve"> The Company shall indemnify each Investor.</w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called with tableContext on every paragraph', async () => {
      nodes = buildTestNodesInCells(bodyXml!);
    });

    await then('none of the table-cell paragraphs emit a top-level heading', async () => {
      expect(nodes).toHaveLength(4);
      for (const node of nodes) {
        expect(node.heading).toBeUndefined();
        expect(Object.prototype.hasOwnProperty.call(node, 'heading')).toBe(false);
      }
    });

    await and('list_metadata.header_style is null inside table cells (upstream !tableContext gates from #188)', async () => {
      // #188 added !tableContext gates at all three heuristic call sites
      // (detectRunInHeader, detectTitleCapsCentered, extractHeaderInfo). The
      // deriveHeading() isInTableCell gate added in #190 is defense-in-depth:
      // even if a future change drops one of those upstream gates, the
      // canonical `node.heading` predicate still won't promote a heuristic
      // match inside a cell.
      for (const node of nodes) {
        expect(node.list_metadata.header_style).toBeNull();
      }
    });
  });

  test('still promotes word_style headings inside table cells (Heading1..6 wins over the cell gate)', async ({ given, when, then }: AllureBddContext) => {
    let bodyXml: string;
    let stylesXml: Document;
    let nodes: DocumentViewNode[];

    await given('a Heading3-styled paragraph injected as a table cell', async () => {
      stylesXml = makeStylesXml(
        `<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/></w:style>`,
      );
      bodyXml = `<w:p><w:pPr><w:pStyle w:val="Heading3"/></w:pPr><w:r><w:t>Schedule A</w:t></w:r></w:p>`;
    });

    await when('buildNodesForDocumentView is called with tableContext', async () => {
      nodes = buildTestNodesInCells(bodyXml!, stylesXml!);
    });

    await then('the word_style heading still fires inside the cell', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.heading).toEqual({
        text: 'Schedule A',
        source: 'word_style',
        level: 3,
      });
    });
  });

  test('detectRunInHeader rejects whole-paragraph bold blocks even with trailing whitespace (#178 review)', async ({ given, when, then }: AllureBddContext) => {
    let bodyXml: string;
    let nodes: ReturnType<typeof buildNodesForDocumentView>['nodes'];

    await given('a fully-bold short paragraph with a trailing space in a separate non-bold run', async () => {
      // Real false positive observed against ~/Downloads Co-Founder Agreement.docx:
      // a wholly-bold "Compensation & Benefits." with a trailing space-only run.
      bodyXml =
        `<w:p>` +
          `<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">Compensation &amp; Benefits.</w:t></w:r>` +
          `<w:r><w:t xml:space="preserve"> </w:t></w:r>` +
        `</w:p>`;
    });

    await when('buildNodesForDocumentView is called', async () => {
      ({ nodes } = buildNodesForDocumentView({
        paragraphs: makeParagraphs(bodyXml!),
        stylesXml: null,
        numberingXml: null,
        include_semantic_tags: false,
        show_formatting: false,
      }));
    });

    await then('the trailing-whitespace-only "body" does not satisfy the header→body transition', async () => {
      expect(nodes).toHaveLength(1);
      expect(nodes[0]!.list_metadata.header_style).not.toBe('run_in_header');
    });
  });
});
