/**
 * NVCA SPA regression tests — verify that the 6 representative edits
 * from a real redline (SPA-edited.redline) apply correctly to the
 * NVCA Stock Purchase Agreement source document.
 *
 * These tests exercise the full replace_text / insert_paragraph pipeline
 * against a production-quality OOXML document with mixed-format runs,
 * defined terms (bold), smart quotes, and multi-section structure.
 */
import { describe, expect, afterEach } from 'vitest';
import { testAllure as test, type AllureBddContext } from '../testing/allure-test.js';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getParagraphRuns } from '@usejunior/docx-core';

import { SessionManager } from '../session/manager.js';
import { openDocument } from './open_document.js';
import { getFootnotes } from './get_footnotes.js';
import { readFile } from './read_file.js';
import { replaceText } from './replace_text.js';
import { insertParagraph } from './insert_paragraph.js';
import { save } from './save.js';
import { parseOutputXml } from '../testing/session-test-utils.js';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SOURCE = path.resolve(__dirname, '../../../../tests/test_documents/nvca-regression/source.docx');
const BONTERMS_NDA_SOURCE = path.resolve(__dirname, '../../../../tests/test_documents/open-agreements/bonterms-mutual-nda.docx');

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

function createMgr(): SessionManager {
  return new SessionManager({ ttlMs: 60 * 60 * 1000 });
}

const tempDirs: string[] = [];

afterEach(async () => {
  for (const dir of tempDirs.splice(0)) {
    await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
  }
});

async function makeTempDir(prefix = 'nvca-spa-'): Promise<string> {
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
  tempDirs.push(dir);
  return dir;
}

async function openFixture(source: string): Promise<{ mgr: SessionManager; sid: string; filePath: string }> {
  const mgr = createMgr();
  const openRes = await openDocument(mgr, { file_path: source });
  expect(openRes.success).toBe(true);
  const filePath = (openRes.file_path as string) ?? source;
  return { mgr, sid: filePath, filePath };
}

async function openSPA(): Promise<{ mgr: SessionManager; sid: string; filePath: string }> {
  return openFixture(SOURCE);
}

function assertSuccess(result: { success: boolean }, label: string): void {
  expect(result.success, `${label} should succeed — got: ${JSON.stringify(result)}`).toBe(true);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('NVCA SPA regression: redline edits', () => {
  test('Change 1: title "SERIES A" → "SERIES A-1" preserves formatting', async ({ given, when, then }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let sid: string;
    let filePath: string;
    const pid = '_bk_8c71639f1440';
    let result: Awaited<ReturnType<typeof replaceText>>;

    await given('the NVCA SPA source document is open in a new session', async () => {
      ({ mgr, sid, filePath } = await openSPA());
    });

    await when('replaceText changes the series placeholder to SERIES A-1', async () => {
      result = await replaceText(mgr, {
        file_path: filePath,
        target_paragraph_id: pid,
        old_string: 'SERIES [___] PREFERRED STOCK PURCHASE AGREEMENT',
        new_string: 'SERIES A-1 PREFERRED STOCK PURCHASE AGREEMENT',
        instruction: 'Change series designation from placeholder to A-1',
      });
    });

    await then('the replacement succeeds and the paragraph text is updated', async () => {
      assertSuccess(result, 'title replacement');
      const session = (await mgr.getSessionByFilePath(filePath))!;
      const afterText = session.doc.getParagraphTextById(pid);
      expect(afterText).toBe('SERIES A-1 PREFERRED STOCK PURCHASE AGREEMENT');
    });
  });

  test('Change 2: extra colon insertion after "as follows:"', async ({ given, when, then }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let sid: string;
    let filePath: string;
    const pid = '_bk_00b3dd3a32dd';
    let result: Awaited<ReturnType<typeof replaceText>>;

    await given('the NVCA SPA source document is open in a new session', async () => {
      ({ mgr, sid, filePath } = await openSPA());
    });

    await when('replaceText appends an extra colon after "as follows:"', async () => {
      result = await replaceText(mgr, {
        file_path: filePath,
        target_paragraph_id: pid,
        old_string: 'The parties hereby agree as follows:',
        new_string: 'The parties hereby agree as follows::',
        instruction: 'Insert extra colon (redline test)',
      });
    });

    await then('the colon insertion succeeds and the paragraph contains the double colon', async () => {
      assertSuccess(result, 'colon insertion');
      const session = (await mgr.getSessionByFilePath(filePath))!;
      const afterText = session.doc.getParagraphTextById(pid);
      expect(afterText).toContain('follows::');
    });
  });

  test('Change 3: insert RECITALS paragraph before "as follows:"', async ({ given, when, then }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let sid: string;
    let filePath: string;
    const anchorPid = '_bk_00b3dd3a32dd';
    let result: Awaited<ReturnType<typeof insertParagraph>>;

    await given('the NVCA SPA source document is open in a new session', async () => {
      ({ mgr, sid, filePath } = await openSPA());
    });

    await when('insertParagraph inserts a WHEREAS recitals paragraph before "as follows:"', async () => {
      result = await insertParagraph(mgr, {
        file_path: filePath,
        positional_anchor_node_id: anchorPid,
        new_string: 'WHEREAS, the Company desires to sell shares of its Preferred Stock; and WHEREAS, the Purchasers desire to purchase such shares.',
        instruction: 'Insert RECITALS paragraph',
        position: 'BEFORE',
      });
    });

    await then('the insertion succeeds and the WHEREAS paragraph appears immediately before the anchor', async () => {
      assertSuccess(result, 'RECITALS insertion');
      const session = (await mgr.getSessionByFilePath(filePath))!;
      const view = session.doc.buildDocumentView({ includeSemanticTags: false });
      const followsIdx = view.nodes.findIndex((n: { id: string }) => n.id === anchorPid);
      expect(followsIdx).toBeGreaterThan(0);
      const inserted = view.nodes[followsIdx - 1];
      expect(inserted?.text).toContain('WHEREAS');
    });
  });

  test('Change 4: Code definition expanded — appends clause preserving bold "Code"', async ({ given, when, then, and }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let sid: string;
    let filePath: string;
    const pid = '_bk_edef90d4bf7c';
    let result: Awaited<ReturnType<typeof replaceText>>;

    await given('the NVCA SPA source document is open with the Code definition paragraph', async () => {
      ({ mgr, sid, filePath } = await openSPA());
    });

    await when('replaceText expands the Code definition to include Treasury regulations', async () => {
      result = await replaceText(mgr, {
        file_path: filePath,
        target_paragraph_id: pid,
        old_string: '\u201cCode\u201d means the Internal Revenue Code of 1986, as amended.',
        new_string: '\u201cCode\u201d means the Internal Revenue Code of 1986, as amended, and the Treasury regulations promulgated thereunder.',
        instruction: 'Expand Code definition to include Treasury regulations',
      });
    });

    await then('the replacement succeeds and the paragraph contains the new clause', async () => {
      assertSuccess(result, 'Code definition expansion');
      const session = (await mgr.getSessionByFilePath(filePath))!;
      const afterText = session.doc.getParagraphTextById(pid);
      expect(afterText).toContain('Treasury regulations');
      expect(afterText).toContain('\u201cCode\u201d');
    });
    await and('"Code" run remains bold and Treasury regulations text is not bold', async () => {
      const session = (await mgr.getSessionByFilePath(filePath))!;
      const pEl = session.doc.getParagraphElementById(pid)!;
      const runs = getParagraphRuns(pEl);

      const codeRun = runs.find(r => r.text.includes('Code') && !r.text.includes('Internal'));
      expect(codeRun, 'should find a run with just "Code"').toBeDefined();
      const rPr = codeRun!.r.getElementsByTagNameNS(W_NS, 'rPr').item(0) as Element | null;
      const hasBold = rPr ? rPr.getElementsByTagNameNS(W_NS, 'b').length > 0 : false;
      expect(hasBold, '"Code" run should remain bold').toBe(true);

      const treasuryRun = runs.find(r => r.text.includes('Treasury'));
      expect(treasuryRun, 'should find a run containing "Treasury"').toBeDefined();
      const tRPr = treasuryRun!.r.getElementsByTagNameNS(W_NS, 'rPr').item(0) as Element | null;
      const treasuryBold = tRPr ? tRPr.getElementsByTagNameNS(W_NS, 'b').length > 0 : false;
      expect(treasuryBold, '"Treasury regulations" should NOT be bold').toBe(false);
    });
  });

  test('Change 5: MAE definition expanded with carve-out — preserves bold defined term', async ({ given, when, then, and }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let sid: string;
    let filePath: string;
    const pid = '_bk_2b87998276ec';
    let result: Awaited<ReturnType<typeof replaceText>>;
    const oldText = '\u201cMaterial Adverse Effect\u201d means a material adverse effect on the business, assets (including intangible assets), liabilities, financial condition, property, or results of operations of the Company.';
    const newText = '\u201cMaterial Adverse Effect\u201d means a material adverse effect on the business, assets (including intangible assets), liabilities, financial condition, property, or results of operations of the Company; provided, however, that none of the following shall be deemed to constitute, and none of the following shall be taken into account in determining whether there has been, a Material Adverse Effect: (i) any adverse change, event, or effect arising from or relating to general business or economic conditions.';

    await given('the NVCA SPA source document is open with the MAE definition paragraph', async () => {
      ({ mgr, sid, filePath } = await openSPA());
    });

    await when('replaceText expands the MAE definition with a carve-out proviso', async () => {
      result = await replaceText(mgr, {
        file_path: filePath,
        target_paragraph_id: pid,
        old_string: oldText,
        new_string: newText,
        instruction: 'Expand MAE definition with carve-out provision',
      });
    });

    await then('the expansion succeeds and the paragraph contains the proviso', async () => {
      assertSuccess(result, 'MAE definition expansion');
      const session = (await mgr.getSessionByFilePath(filePath))!;
      const afterText = session.doc.getParagraphTextById(pid);
      expect(afterText).toContain('provided, however');
      expect(afterText).toContain('Material Adverse Effect');
    });
    await and('"Material Adverse Effect" run remains bold and proviso text is not bold', async () => {
      const session = (await mgr.getSessionByFilePath(filePath))!;
      const pEl = session.doc.getParagraphElementById(pid)!;
      const runs = getParagraphRuns(pEl);

      const maeRun = runs.find(r => r.text.includes('Material Adverse Effect') && !r.text.includes('means'));
      expect(maeRun, 'should find a run with just "Material Adverse Effect"').toBeDefined();
      const rPr = maeRun!.r.getElementsByTagNameNS(W_NS, 'rPr').item(0) as Element | null;
      const hasBold = rPr ? rPr.getElementsByTagNameNS(W_NS, 'b').length > 0 : false;
      expect(hasBold, '"Material Adverse Effect" should remain bold').toBe(true);

      const provisoRun = runs.find(r => r.text.includes('provided, however'));
      expect(provisoRun, 'should find a run with "provided, however"').toBeDefined();
      const pRPr = provisoRun!.r.getElementsByTagNameNS(W_NS, 'rPr').item(0) as Element | null;
      const provisoBold = pRPr ? pRPr.getElementsByTagNameNS(W_NS, 'b').length > 0 : false;
      expect(provisoBold, 'proviso text should NOT be bold').toBe(false);
    });
  });

  test('Change 6: Oxford comma — "Corporate Power and" → "Corporate Power, and"', async ({ given, when, then }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let sid: string;
    let filePath: string;
    const pid = '_bk_36e982f3906a';
    let result: Awaited<ReturnType<typeof replaceText>>;

    await given('the NVCA SPA source document is open with the qualification paragraph', async () => {
      ({ mgr, sid, filePath } = await openSPA());
    });

    await when('replaceText inserts an Oxford comma before "and Qualification"', async () => {
      result = await replaceText(mgr, {
        file_path: filePath,
        target_paragraph_id: pid,
        old_string: 'Organization, Good Standing, Corporate Power and Qualification',
        new_string: 'Organization, Good Standing, Corporate Power, and Qualification',
        instruction: 'Add Oxford comma before "and Qualification"',
      });
    });

    await then('the paragraph text has the Oxford comma inserted', async () => {
      assertSuccess(result, 'Oxford comma insertion');
      const session = (await mgr.getSessionByFilePath(filePath))!;
      const afterText = session.doc.getParagraphTextById(pid);
      expect(afterText).toBe('Organization, Good Standing, Corporate Power, and Qualification');
    });
  });
});

describe('NVCA SPA regression: batch edit + save round-trip', { timeout: 30_000 }, () => {
  test('applies all 5 replacements in batch, saves clean output, formatting preserved', async ({ given, when, then, and }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let sid: string;
    let filePath: string;
    let tmpDir: string;
    let savedCleanPath: string;
    let parsed: Awaited<ReturnType<typeof parseOutputXml>>;

    await given('the NVCA SPA source document is open in a new session', async () => {
      ({ mgr, sid, filePath } = await openSPA());
      tmpDir = await makeTempDir();
    });

    await when('all 5 replacement edits are applied sequentially and the clean output is saved', async () => {
      // Apply all 5 replacement edits sequentially (insert tested separately)
      const edits = [
        {
          pid: '_bk_8c71639f1440',
          old: 'SERIES [___] PREFERRED STOCK PURCHASE AGREEMENT',
          new_: 'SERIES A-1 PREFERRED STOCK PURCHASE AGREEMENT',
          instruction: 'Change series to A-1',
        },
        {
          pid: '_bk_00b3dd3a32dd',
          old: 'The parties hereby agree as follows:',
          new_: 'The parties hereby agree as follows::',
          instruction: 'Extra colon',
        },
        {
          pid: '_bk_edef90d4bf7c',
          old: '\u201cCode\u201d means the Internal Revenue Code of 1986, as amended.',
          new_: '\u201cCode\u201d means the Internal Revenue Code of 1986, as amended, and the Treasury regulations promulgated thereunder.',
          instruction: 'Expand Code definition',
        },
        {
          pid: '_bk_2b87998276ec',
          old: '\u201cMaterial Adverse Effect\u201d means a material adverse effect on the business, assets (including intangible assets), liabilities, financial condition, property, or results of operations of the Company.',
          new_: '\u201cMaterial Adverse Effect\u201d means a material adverse effect on the business, assets (including intangible assets), liabilities, financial condition, property, or results of operations of the Company; provided, however, that none of the following shall be deemed to constitute a Material Adverse Effect: (i) general business or economic conditions.',
          instruction: 'Expand MAE with carve-out',
        },
        {
          pid: '_bk_36e982f3906a',
          old: 'Organization, Good Standing, Corporate Power and Qualification',
          new_: 'Organization, Good Standing, Corporate Power, and Qualification',
          instruction: 'Oxford comma',
        },
      ];

      for (const edit of edits) {
        const result = await replaceText(mgr, {
          file_path: filePath,
          target_paragraph_id: edit.pid,
          old_string: edit.old,
          new_string: edit.new_,
          instruction: edit.instruction,
        });
        assertSuccess(result, edit.instruction);
      }

      // Save clean output
      savedCleanPath = path.join(tmpDir, 'nvca-edited-clean.docx');
      const saveRes = await save(mgr, {
        file_path: filePath,
        save_to_local_path: savedCleanPath,
        save_format: 'clean',
      });
      assertSuccess(saveRes, 'save clean');
      parsed = await parseOutputXml(savedCleanPath);
    });

    await then('the saved clean file exists and is non-empty', async () => {
      const stat = await fs.stat(savedCleanPath);
      expect(stat.size).toBeGreaterThan(50_000);
    });
    await and('"Code" defined-term runs remain bold in the output', () => {
      // Find the bold "Code" defined term run.
      const codeRuns = parsed.runs.filter(r => {
        const text = parsed.runText(r).trim();
        return text === 'Code';
      });
      const codeRunsToCheck = codeRuns.length > 0
        ? codeRuns
        : parsed.runs.filter(r => {
            const text = parsed.runText(r).trim();
            return text.includes('Code') && text.length < 20 && !text.includes('Internal');
          });
      expect(codeRunsToCheck.length).toBeGreaterThanOrEqual(1);
      for (const r of codeRunsToCheck) {
        expect(parsed.hasBold(r), `"Code" run "${parsed.runText(r)}" should be bold`).toBe(true);
      }
    });
    await and('"Material Adverse Effect" runs remain bold and proviso/Treasury runs are not bold', () => {
      const maeRuns = parsed.runs.filter(r => {
        const text = parsed.runText(r).trim();
        return text === 'Material Adverse Effect';
      });
      const maeRunsToCheck = maeRuns.length > 0
        ? maeRuns
        : parsed.runs.filter(r => {
            const text = parsed.runText(r).trim();
            return text.includes('Material Adverse Effect') && text.length < 40;
          });
      expect(maeRunsToCheck.length).toBeGreaterThanOrEqual(1);
      for (const r of maeRunsToCheck) {
        expect(parsed.hasBold(r), `"Material Adverse Effect" run "${parsed.runText(r)}" should be bold`).toBe(true);
      }

      const treasuryRuns = parsed.runs.filter(r => parsed.runText(r).includes('Treasury'));
      expect(treasuryRuns.length).toBeGreaterThanOrEqual(1);
      for (const r of treasuryRuns) {
        expect(parsed.hasBold(r), '"Treasury" run should NOT be bold').toBe(false);
      }

      const provisoRuns = parsed.runs.filter(r => parsed.runText(r).includes('provided, however'));
      expect(provisoRuns.length).toBeGreaterThanOrEqual(1);
      for (const r of provisoRuns) {
        expect(parsed.hasBold(r), '"provided, however" run should NOT be bold').toBe(false);
      }
    });
  });

  test('saves tracked-changes output with correct redlines', async ({ given, when, then, and }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let sid: string;
    let filePath: string;
    let trackedPath: string;
    let saveRes: Awaited<ReturnType<typeof save>>;

    await given('the NVCA SPA source document is open and the Code definition has been expanded', async () => {
      ({ mgr, sid, filePath } = await openSPA());
      const tmpDir = await makeTempDir();
      const result = await replaceText(mgr, {
        file_path: filePath,
        target_paragraph_id: '_bk_edef90d4bf7c',
        old_string: '\u201cCode\u201d means the Internal Revenue Code of 1986, as amended.',
        new_string: '\u201cCode\u201d means the Internal Revenue Code of 1986, as amended, and the Treasury regulations promulgated thereunder.',
        instruction: 'Expand Code definition',
      });
      assertSuccess(result, 'replace');
      trackedPath = path.join(tmpDir, 'nvca-edited-tracked.docx');
    });

    await when('the document is saved with tracked-changes output', async () => {
      saveRes = await save(mgr, {
        file_path: filePath,
        save_to_local_path: trackedPath,
        save_format: 'tracked',
        tracked_changes_author: 'NVCA Test',
      });
    });

    await then('the save succeeds', () => {
      assertSuccess(saveRes, 'save tracked');
    });
    await and('the tracked file contains w:ins and w:del revision markup with the inserted text', async () => {
      const { readDocumentXmlFromPath } = await import('../testing/docx_test_utils.js');
      const trackedXml = await readDocumentXmlFromPath(trackedPath);
      expect(trackedXml).toContain('w:ins');
      expect(trackedXml).toContain('w:del');
      expect(trackedXml).toContain('Treasury');
    });
  });
});

describe('NVCA SPA regression: heading detection (#179)', () => {
  test('read_file(format=json) surfaces the SPA title via node.heading and omits heading on body paragraphs', async ({ given, when, then, and }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let filePath: string;
    let titleNode: {
      id: string;
      heading?: { text: string; source: string; level: number | null };
      list_metadata: { header_text: string | null; header_style: string | null };
    };
    let allNodes: Array<{
      id: string;
      clean_text: string;
      heading?: { text: string; source: string; level: number | null };
    }>;
    const titlePid = '_bk_8c71639f1440';

    await given('the NVCA SPA source document is open in a new session', async () => {
      ({ mgr, filePath } = await openSPA());
    });

    await when('read_file is called with format=json for the title paragraph and the broader document', async () => {
      const titleRes = await readFile(mgr, {
        file_path: filePath,
        node_ids: [titlePid],
        format: 'json',
      });
      assertSuccess(titleRes, 'read_file json title');
      titleNode = JSON.parse(titleRes.content as string)[0]!;

      const allRes = await readFile(mgr, {
        file_path: filePath,
        format: 'json',
        limit: 100000,
        offset: 1,
      });
      assertSuccess(allRes, 'read_file json full');
      allNodes = JSON.parse(allRes.content as string);
    });

    await then('the title paragraph is returned', async () => {
      expect(titleNode.id).toBe(titlePid);
    });

    await and('the title paragraph exposes node.heading with title_caps_centered and null level', async () => {
      expect(titleNode.heading).toEqual({
        text: 'SERIES [___] PREFERRED STOCK PURCHASE AGREEMENT',
        source: 'title_caps_centered',
        level: null,
      });
      expect(titleNode.list_metadata.header_style).toBe('title_caps_centered');
      expect(titleNode.list_metadata.header_text).toContain('PREFERRED STOCK PURCHASE AGREEMENT');
    });

    await and('a body paragraph omits the heading key entirely', async () => {
      const bodyNode = allNodes.find((node) => !Object.prototype.hasOwnProperty.call(node, 'heading'));
      expect(bodyNode).toBeDefined();
      expect(bodyNode!.heading).toBeUndefined();
    });
  });

  test('Bonterms NDA table-cell paragraphs do not emit heading styles (#187)', async ({ given, when, then, and }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let filePath: string;
    let parsed: Array<{
      id: string;
      table_context?: Record<string, unknown>;
      list_metadata: { header_style: string | null };
    }>;

    await given('the Bonterms NDA fixture is open in a new session', async () => {
      ({ mgr, filePath } = await openFixture(BONTERMS_NDA_SOURCE));
    });

    await when('read_file is called with format=json for the full document', async () => {
      const res = await readFile(mgr, {
        file_path: filePath,
        format: 'json',
      });
      assertSuccess(res, 'read_file bonterms json');
      parsed = JSON.parse(res.content as string);
    });

    await then('the fixture includes table-context paragraphs', async () => {
      expect(parsed.some((node) => Boolean(node.table_context))).toBe(true);
    });

    await and('every table-context paragraph has a null header style', async () => {
      const tableNodes = parsed.filter((node) => Boolean(node.table_context));
      expect(tableNodes.every((node) => node.list_metadata.header_style === null)).toBe(true);
    });
  });

  test('NVCA SPA signature-block paragraphs 312-321 have null header styles (#187)', async ({ given, when, then, and }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let filePath: string;
    let parsed: Array<{
      clean_text: string;
      list_metadata: { header_style: string | null };
    }>;

    await given('the NVCA SPA source document is open in a new session', async () => {
      ({ mgr, filePath } = await openSPA());
    });

    await when('read_file is called for paragraphs 312 through 321', async () => {
      const res = await readFile(mgr, {
        file_path: filePath,
        format: 'json',
        offset: 312,
        limit: 10,
      });
      assertSuccess(res, 'read_file spa signature block');
      parsed = JSON.parse(res.content as string);
    });

    await then('the signature-block window is returned', async () => {
      expect(parsed).toHaveLength(10);
    });

    await and('every paragraph in the window has a null header style', async () => {
      expect(parsed.every((node) => node.list_metadata.header_style === null)).toBe(true);
    });
  });
});

describe('NVCA SPA regression: footnote anchor surfacing (#185)', () => {
  test('every anchored NVCA SPA footnote is reachable through the document view', async ({ given, when, then, and }: AllureBddContext) => {
    let mgr: ReturnType<typeof createMgr>;
    let filePath: string;
    let eligible: Array<{ id: number; display_number: number; anchored_paragraph_id: string }>;
    let nodes: Array<{ id: string; text: string }>;

    await given('the NVCA SPA source document is open and its 109 footnotes are listed', async () => {
      ({ mgr, filePath } = await openSPA());
      const notes = await getFootnotes(mgr, { file_path: filePath });
      assertSuccess(notes, 'get_footnotes');
      const all = notes.footnotes as Array<{ id: number; display_number: number; text: string; anchored_paragraph_id: string | null }>;
      expect(all).toHaveLength(109);
      eligible = all.filter(
        (n): n is typeof n & { anchored_paragraph_id: string } =>
          n.display_number > 0 && n.text.trim().length > 0 && n.anchored_paragraph_id !== null,
      );
      expect(eligible).toHaveLength(108);
    });

    await when('read_file renders the full document view as JSON', async () => {
      const res = await readFile(mgr, { file_path: filePath, format: 'json', offset: 1, limit: 100000 });
      assertSuccess(res, 'read_file full json walk');
      nodes = JSON.parse(res.content as string);
    });

    await then('every eligible footnote anchor paragraph is present in the view', async () => {
      const viewIds = new Set(nodes.map((n) => n.id));
      const unsurfaced = eligible.filter((n) => !viewIds.has(n.anchored_paragraph_id));
      expect(unsurfaced, `footnotes whose anchor paragraph is missing from the view: ${JSON.stringify(unsurfaced.map((n) => ({ id: n.id, display: n.display_number })))}`).toHaveLength(0);
    });

    await and('the footnote-only paragraph anchoring note 47 renders its [^46] marker', async () => {
      // Footnote id=47 (display 46) anchors to a paragraph whose only content
      // is the footnote-reference run; before the #185 fix the view dropped it.
      const probe = await readFile(mgr, { file_path: filePath, format: 'json', node_ids: ['_bk_6d177a97f7e6'] });
      assertSuccess(probe, 'read_file node_ids probe');
      const probed = JSON.parse(probe.content as string) as Array<{ id: string; text: string }>;
      expect(probed).toHaveLength(1);
      // Pure-marker node; exactly one [^46] — the view-level injection used to
      // be doubled by a read_file suffix pass. @see #382
      expect(probed[0]!.text).toBe('[^46]');
    });

    await and('no footnote marker anywhere in the walk is doubled', async () => {
      // Adjacent same-number markers ([^45][^45], with or without intervening
      // whitespace) are the #382 signature; distinct adjacent references
      // ([^1][^2]) remain legal.
      const doubled = nodes.filter((n) => /\[\^(\d+)\]\s*\[\^\1\]/.test(n.text));
      expect(doubled.map((n) => n.id)).toEqual([]);
    });
  });
});
