import { describe, expect } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { compareDocuments } from '@usejunior/docx-core';
import { parseXml } from '@usejunior/docx-core';

import { extractRevisions_tool } from './extract_revisions.js';
import { openDocument } from './open_document.js';
import { replaceText } from './replace_text.js';
import { MCP_TOOLS } from '../server.js';
import {
  type AllureBddContext,
  testAllure,
} from '../testing/allure-test.js';
import {
  assertSuccess,
  assertFailure,
  registerCleanup,
  createTestSessionManager,
  createTrackedTempDir,
  openSession,
} from '../testing/session-test-utils.js';
import { makeDocxWithDocumentXml } from '../testing/docx_test_utils.js';
import { type DocxSession } from '../session/manager.js';

const TEST_FEATURE = 'add-extract-revisions-tool';
const REPORT_FEATURE = 'extract-revisions-tool';
const W_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
const TEST_DIR = path.dirname(fileURLToPath(import.meta.url));
const SIMPLE_FIXTURE_DIR = path.resolve(
  TEST_DIR,
  '../../../docx-core/src/testing/fixtures/simple-word-change',
);

type RevisionSummary = {
  type?: string;
  text?: string;
  author?: string;
};

type ExtractedChange = {
  para_id?: string;
  before_text?: string;
  after_text?: string;
  revisions?: RevisionSummary[];
  comments?: unknown[];
};

type ExtractedComment = {
  author?: string;
  text?: string;
};

type ExtractRevisionsSuccess = Awaited<ReturnType<typeof extractRevisions_tool>> & {
  success: true;
  changes: ExtractedChange[];
  total_changes: number;
  has_more: boolean;
};

function asExtractRevisionsSuccess(
  result: Awaited<ReturnType<typeof extractRevisions_tool>>,
  label = 'extract_revisions',
): ExtractRevisionsSuccess {
  assertSuccess(result, label);
  return result as ExtractRevisionsSuccess;
}

function asChanges(value: unknown): ExtractedChange[] {
  return Array.isArray(value) ? (value as ExtractedChange[]) : [];
}

function asComments(value: unknown): ExtractedComment[] {
  return Array.isArray(value) ? (value as ExtractedComment[]) : [];
}

async function createRealTrackedChangesFixture(): Promise<string> {
  const [original, revised] = await Promise.all([
    fs.readFile(path.join(SIMPLE_FIXTURE_DIR, 'original.docx')),
    fs.readFile(path.join(SIMPLE_FIXTURE_DIR, 'revised.docx')),
  ]);
  const compared = await compareDocuments(original, revised, {
    engine: 'atomizer',
    reconstructionMode: 'rebuild',
  });
  const tmpDir = await createTrackedTempDir('extract-revisions-real-redline-');
  const outputPath = path.join(tmpDir, 'simple-word-change.tracked.docx');
  await fs.writeFile(outputPath, compared.document);
  return outputPath;
}

describe('extract_revisions tool', () => {
  const test = testAllure.epic('Document Reading').withLabels({ feature: REPORT_FEATURE });
  const humanReadableTest = test.allure({
    tags: ['human-readable'],
    parameters: { audience: 'non-technical' },
  });
  registerCleanup();

  // ── Insertion + deletion extraction ──────────────────────────────

  humanReadableTest
    .allure({
      title: 'Extract revisions from a paragraph with insertion and deletion',
      description: 'Extracting revisions from a document with insertions and deletions returns a response that correctly describes the inserted and deleted text and the author of each change.',
    })
    .openspec('[SDX-ER-001] extracting revisions from a document with insertions and deletions')(
    'Scenario: extracting revisions from a document with insertions and deletions',
    async ({
      given,
      when,
      then,
      and,
      attachXmlPreviews,
      attachJsonLastStep,
    }: AllureBddContext) => {
      const scenarioId = 'SDX-ER-001';
      const fixture = {
        baseText: 'Original',
        insertedText: 'added',
        insertedAuthor: 'Alice',
        deletedText: 'removed',
        deletedAuthor: 'Bob',
      } as const;

      const docXml = [
        '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
        `<w:document xmlns:w="${W_NS}">`,
        '  <w:body>',
        '    <w:p>',
        `      <w:r><w:t>${fixture.baseText}</w:t></w:r>`,
        `      <w:ins w:author="${fixture.insertedAuthor}" w:date="2024-01-01T00:00:00Z">`,
        `        <w:r><w:t> ${fixture.insertedText}</w:t></w:r>`,
        '      </w:ins>',
        `      <w:del w:author="${fixture.deletedAuthor}" w:date="2024-01-01T00:00:00Z">`,
        `        <w:r><w:delText> ${fixture.deletedText}</w:delText></w:r>`,
        '      </w:del>',
        '    </w:p>',
        '  </w:body>',
        '</w:document>',
      ].join('\n');
      const xmlDoc = parseXml(docXml);
      const paragraphCount = xmlDoc.getElementsByTagName('w:p').length;
      const insertionCount = xmlDoc.getElementsByTagName('w:ins').length;
      const deletionCount = xmlDoc.getElementsByTagName('w:del').length;
      const xmlRootElement = xmlDoc.documentElement?.nodeName ?? '';

      const expectedBefore = `${fixture.baseText} ${fixture.deletedText}`;
      const expectedAfter = `${fixture.baseText} ${fixture.insertedText}`;
      const debugContext = {
        scenario_id: scenarioId,
        base_text: fixture.baseText,
        insertion: { text: fixture.insertedText, author: fixture.insertedAuthor },
        deletion: { text: fixture.deletedText, author: fixture.deletedAuthor },
        expected_before: expectedBefore,
        expected_after: expectedAfter,
      } as const;

      let debugResult: ExtractRevisionsSuccess | null = null;
      try {
        await given(
          'a session containing a paragraph with tracked insertion and deletion',
          async () => {
            await attachXmlPreviews(docXml, {
              wordLikeName: '01 Word-like visual preview',
              xmlName: '02 Input XML fixture (pretty XML)',
              wordLike: {
                baseText: fixture.baseText,
                insertedText: fixture.insertedText,
                deletedText: fixture.deletedText,
                insertedAuthor: fixture.insertedAuthor,
                deletedAuthor: fixture.deletedAuthor,
              },
            });
          },
        );
        await and(
          `inserted_text is "${fixture.insertedText}" by "${fixture.insertedAuthor}"`,
          async () => {},
          {
            inserted_text: fixture.insertedText,
            inserted_author: fixture.insertedAuthor,
          },
        );
        await and(
          `deleted_text is "${fixture.deletedText}" by "${fixture.deletedAuthor}"`,
          async () => {},
          {
            deleted_text: fixture.deletedText,
            deleted_author: fixture.deletedAuthor,
          },
        );

        const { mgr, filePath } = await given(
          'the tracked-change paragraph is opened as an editable session',
          () => openSession([], { xml: docXml, trackOpenStep: false }),
        );
        await and(
          'the XML fixture is well-formed WordprocessingML',
          async () => {
            expect(xmlRootElement).toBe('w:document');
            expect(paragraphCount).toBeGreaterThan(0);
            expect(insertionCount).toBe(1);
            expect(deletionCount).toBe(1);
          },
        );

        const result = await when(
          'extract_revisions is run in the session',
          async () => asExtractRevisionsSuccess(await extractRevisions_tool(mgr, { file_path: filePath })),
        );
        debugResult = result;

        const changes = result.changes;
        const change = changes[0];
        const revisions = Array.isArray(change?.revisions) ? change.revisions : [];
        const insertionRevision = revisions.find((revision) => revision.type === 'INSERTION');
        const deletionRevision = revisions.find((revision) => revision.type === 'DELETION');

        await then(
          'the tool reports the correct number of changed paragraphs',
          async () => {
            expect(Number(result.total_changes ?? -1)).toBe(1);
          },
          {
            expected_changed_paragraph_count: 1,
            actual_changed_paragraph_count: Number(result.total_changes ?? -1),
          },
        );
        await then(
          'the tool reports the correct number of changed entries',
          async () => {
            expect(changes).toHaveLength(1);
          },
          {
            expected_entry_count: 1,
            actual_entry_count: changes.length,
          },
        );
        await then(
          'before_text equals expected_before_text',
          async () => {
            expect(String(change.before_text ?? '')).toBe(expectedBefore);
          },
          {
            expected_before: expectedBefore,
            actual_before: String(change.before_text ?? ''),
          },
        );
        await and(
          'before_text includes base_text',
          async () => {
            expect(String(change.before_text ?? '')).toContain(fixture.baseText);
          },
          {
            base_text: fixture.baseText,
            actual_before: String(change.before_text ?? ''),
          },
        );
        await and(
          'before_text omits inserted_text',
          async () => {
            expect(String(change.before_text ?? '')).not.toContain(fixture.insertedText);
          },
          {
            inserted_text: fixture.insertedText,
            actual_before: String(change.before_text ?? ''),
          },
        );
        await and(
          'before_text includes deleted_text',
          async () => {
            expect(String(change.before_text ?? '')).toContain(fixture.deletedText);
          },
          {
            deleted_text: fixture.deletedText,
            actual_before: String(change.before_text ?? ''),
          },
        );
        await then(
          'after_text equals expected_after_text',
          async () => {
            expect(String(change.after_text ?? '')).toBe(expectedAfter);
          },
          {
            expected_after: expectedAfter,
            actual_after: String(change.after_text ?? ''),
          },
        );
        await and(
          'after_text includes base_text',
          async () => {
            expect(String(change.after_text ?? '')).toContain(fixture.baseText);
          },
          {
            base_text: fixture.baseText,
            actual_after: String(change.after_text ?? ''),
          },
        );
        await and(
          'after_text includes inserted_text',
          async () => {
            expect(String(change.after_text ?? '')).toContain(fixture.insertedText);
          },
          {
            inserted_text: fixture.insertedText,
            actual_after: String(change.after_text ?? ''),
          },
        );
        await and(
          'after_text omits deleted_text',
          async () => {
            expect(String(change.after_text ?? '')).not.toContain(fixture.deletedText);
          },
          {
            deleted_text: fixture.deletedText,
            actual_after: String(change.after_text ?? ''),
          },
        );

        await and(
          'the insertion record shows correct type/text/author',
          async () => {
            expect(insertionRevision).toBeDefined();
            expect(String(insertionRevision?.text ?? '').trim()).toBe(fixture.insertedText);
            expect(insertionRevision?.author).toBe(fixture.insertedAuthor);
          },
          {
            expected_type: 'INSERTION',
            expected_text: fixture.insertedText,
            expected_author: fixture.insertedAuthor,
            actual_type: String(insertionRevision?.type ?? ''),
            actual_text: String(insertionRevision?.text ?? '').trim(),
            actual_author: String(insertionRevision?.author ?? ''),
          },
        );

        await and(
          'the deletion record shows correct type/text/author',
          async () => {
            expect(deletionRevision).toBeDefined();
            expect(String(deletionRevision?.text ?? '').trim()).toBe(fixture.deletedText);
            expect(deletionRevision?.author).toBe(fixture.deletedAuthor);
          },
          {
            expected_type: 'DELETION',
            expected_text: fixture.deletedText,
            expected_author: fixture.deletedAuthor,
            actual_type: String(deletionRevision?.type ?? ''),
            actual_text: String(deletionRevision?.text ?? '').trim(),
            actual_author: String(deletionRevision?.author ?? ''),
          },
        );
      } finally {
        await attachJsonLastStep({
          context: debugContext,
          result: debugResult,
          stepName: 'Attach debug JSON (context + result)',
        });
      }
    },
  );

  // ── No tracked changes ──────────────────────────────────────────

  humanReadableTest
    .allure({
      description: [
        'This test checks extraction behavior when a document contains no tracked changes.',
        'Expected outcome: no changed paragraphs are returned and pagination signals no more results.',
      ].join('\n'),
    })
    .openspec('[SDX-ER-002] extracting revisions from a document with no tracked changes')(
    'Scenario: [SDX-ER-002] extracting revisions from a document with no tracked changes',
    async ({ given, when, then, and, attachPrettyJson }: AllureBddContext) => {
      const scenarioId = 'SDX-ER-002';

      const readableInputSummary = {
        scenario_id: scenarioId,
        paragraphs: ['Hello world', 'Second paragraph'],
      };

      const { mgr, filePath } = await given(
        'a clean document with no tracked changes',
        () => openSession(['Hello world', 'Second paragraph']),
        { paragraph_count: readableInputSummary.paragraphs.length },
      );

      let result: ExtractRevisionsSuccess;
      await when('extract_revisions is run on the clean document', async () => {
        const raw = await extractRevisions_tool(mgr, { file_path: filePath });
        result = asExtractRevisionsSuccess(raw);
      });
      result = result!;

      await then('total_changes is 0', async () => {
        expect(Number(result.total_changes ?? -1)).toBe(0);
      }, { expected: 0, actual: Number(result.total_changes ?? -1) });
      await and('changes array length is 0', async () => {
        expect(result.changes.length).toBe(0);
      }, { expected: 0, actual: result.changes.length });
      await and('has_more is false', async () => {
        expect(Boolean(result.has_more)).toBe(false);
      }, { expected: false, actual: Boolean(result.has_more) });

      // Keep technical JSON artifacts at the bottom so the narrative steps stay contiguous.
      await attachPrettyJson('Readable input summary', readableInputSummary);
      await attachPrettyJson('Raw result (engineer view)', result);
    },
  );

  // ── Format changes ──────────────────────────────────────────────

  humanReadableTest.openspec('property-only changes are included in extraction')(
    'Scenario: property-only changes are included in extraction',
    async ({ given, when, then, attachPrettyJson }: AllureBddContext) => {
      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:p><w:r>` +
            `<w:rPr>` +
              `<w:b/>` +
              `<w:rPrChange w:author="Carol" w:date="2024-01-01T00:00:00Z">` +
                `<w:rPr><w:i/></w:rPr>` +
              `</w:rPrChange>` +
            `</w:rPr>` +
            `<w:t>Formatted text</w:t>` +
          `</w:r></w:p>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with a run property change tracked by Carol',
        () => openSession([], { xml: docXml }),
      );

      const result = await when('extract_revisions is called', () =>
        extractRevisions_tool(mgr, { file_path: filePath }),
      );
      assertSuccess(result, 'extract_revisions');
      await attachPrettyJson('result', result);

      await then('FORMAT_CHANGE is reported with correct author', () => {
        expect(result.total_changes).toBe(1);
        const changes = asChanges(result.changes);
        expect(changes).toHaveLength(1);
        const formatRevisions = (changes[0]?.revisions ?? []).filter((r) => r.type === 'FORMAT_CHANGE');
        expect(formatRevisions.length).toBeGreaterThanOrEqual(1);
        expect(formatRevisions[0].author).toBe('Carol');
      });
    },
  );

  // ── Pagination ──────────────────────────────────────────────────

  humanReadableTest.openspec('paginating through revisions with offset and limit')(
    'Scenario: paginating through revisions with offset and limit',
    async ({ given, when, then, and, attachPrettyJson }: AllureBddContext) => {
      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:p><w:r><w:t>A</w:t></w:r><w:ins w:author="X"><w:r><w:t>1</w:t></w:r></w:ins></w:p>` +
          `<w:p><w:r><w:t>B</w:t></w:r><w:ins w:author="X"><w:r><w:t>2</w:t></w:r></w:ins></w:p>` +
          `<w:p><w:r><w:t>C</w:t></w:r><w:ins w:author="X"><w:r><w:t>3</w:t></w:r></w:ins></w:p>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with 3 paragraphs each containing an insertion',
        () => openSession([], { xml: docXml }),
      );

      const page1 = await when('extract_revisions is called with offset=0, limit=2', () =>
        extractRevisions_tool(mgr, { file_path: filePath, offset: 0, limit: 2 }),
      );
      assertSuccess(page1, 'page1');
      await attachPrettyJson('page1', page1);

      const page2 = await and('extract_revisions is called with offset=2, limit=2', () =>
        extractRevisions_tool(mgr, { file_path: filePath, offset: 2, limit: 2 }),
      );
      assertSuccess(page2, 'page2');
      await attachPrettyJson('page2', page2);

      await then('page 1 returns 2 changes and has_more is true', () => {
        expect(page1.total_changes).toBe(3);
        expect((asChanges(page1.changes)).length).toBe(2);
        expect(page1.has_more).toBe(true);
      });
      await and('page 2 returns 1 change and has_more is false', () => {
        expect(page2.total_changes).toBe(3);
        expect((asChanges(page2.changes)).length).toBe(1);
        expect(page2.has_more).toBe(false);
      });
    },
  );

  // ── Session unchanged after extraction ──────────────────────────

  humanReadableTest.openspec('session document is unchanged after extraction')(
    'Scenario: session document is unchanged after extraction',
    async ({ given, when, then }: AllureBddContext) => {
      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:p><w:r><w:t>Text</w:t></w:r><w:ins w:author="X"><w:r><w:t> added</w:t></w:r></w:ins></w:p>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with a tracked insertion is open in a session',
        () => openSession([], { xml: docXml }),
      );

      let revisionBefore: unknown;
      await given('the edit_revision before extraction is recorded', async () => {
        const session = (await mgr.getSessionByFilePath(filePath))!;
        revisionBefore = session.editRevision;
      });

      const result = await when('extract_revisions is called', () =>
        extractRevisions_tool(mgr, { file_path: filePath }),
      );
      assertSuccess(result, 'extract_revisions');

      await then('the edit_revision is unchanged after extraction', async () => {
        const session = (await mgr.getSessionByFilePath(filePath))!;
        expect(session.editRevision).toBe(revisionBefore);
        expect(result.edit_revision).toBe(revisionBefore);
      });
    },
  );

  // ── Missing session context ─────────────────────────────────────

  humanReadableTest.openspec('missing session context returns error')(
    'Scenario: missing session context returns error',
    async ({ given, when, then, attachPrettyJson }: AllureBddContext) => {
      const mgr = await given('a session manager with no open sessions', () => createTestSessionManager());

      const result = await when('extract_revisions is called with no params', () =>
        extractRevisions_tool(mgr, {}),
      );

      await then('the tool returns a MISSING_FILE_PATH error', () => {
        assertFailure(result, 'MISSING_FILE_PATH', 'extract_revisions');
      });
      await attachPrettyJson('result', result);
    },
  );

  // ── Validation errors ───────────────────────────────────────────

  humanReadableTest.openspec('invalid limit is rejected')(
    'Scenario: invalid limit is rejected',
    async ({ given, when, then, and, attachPrettyJson }: AllureBddContext) => {
      const { mgr, filePath } = await given(
        'an open session with one paragraph',
        () => openSession(['Hello']),
      );

      const result = await when('extract_revisions is called with limit=0', () =>
        extractRevisions_tool(mgr, { file_path: filePath, limit: 0 }),
      );
      await then('the tool returns an INVALID_LIMIT error', () => {
        assertFailure(result, 'INVALID_LIMIT', 'extract_revisions');
      });
      await attachPrettyJson('result (limit=0)', result);

      const result2 = await when('extract_revisions is called with limit=501', () =>
        extractRevisions_tool(mgr, { file_path: filePath, limit: 501 }),
      );
      await and('the tool returns an INVALID_LIMIT error', () => {
        assertFailure(result2, 'INVALID_LIMIT', 'extract_revisions');
      });
    },
  );

  humanReadableTest.openspec('invalid offset is rejected')(
    'Scenario: invalid offset is rejected',
    async ({ given, when, then, attachPrettyJson }: AllureBddContext) => {
      const { mgr, filePath } = await given(
        'an open session with one paragraph',
        () => openSession(['Hello']),
      );

      const result = await when('extract_revisions is called with offset=-1', () =>
        extractRevisions_tool(mgr, { file_path: filePath, offset: -1 }),
      );
      await then('the tool returns an INVALID_OFFSET error', () => {
        assertFailure(result, 'INVALID_OFFSET', 'extract_revisions');
      });
      await attachPrettyJson('result', result);
    },
  );

  // ── Cache behavior ─────────────────────────────────────────────

  humanReadableTest.openspec('repeated extraction at same revision uses cache')(
    'Scenario: repeated extraction at same revision uses cache',
    async ({ given, when, then, and }: AllureBddContext) => {
      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:p><w:r><w:t>Text</w:t></w:r><w:ins w:author="X"><w:r><w:t> added</w:t></w:r></w:ins></w:p>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with a tracked insertion is open in a session',
        () => openSession([], { xml: docXml }),
      );

      const result1 = await when('extract_revisions is called the first time', () =>
        extractRevisions_tool(mgr, { file_path: filePath }),
      );
      assertSuccess(result1, 'first extraction');

      await then('the extraction cache is populated', async () => {
        const session = (await mgr.getSessionByFilePath(filePath)) as DocxSession;
        const cache = mgr.getExtractionCache(session);
        expect(cache).not.toBeNull();
      });

      const result2 = await when('extract_revisions is called again at the same revision', () =>
        extractRevisions_tool(mgr, { file_path: filePath }),
      );
      assertSuccess(result2, 'second extraction');

      await and('both extractions return the same total_changes', () => {
        expect(result2.total_changes).toBe(result1.total_changes);
      });
    },
  );

  // ── Empty structural paragraphs ─────────────────────────────────

  humanReadableTest.openspec('structurally-empty inserted paragraphs are filtered out')(
    'Scenario: structurally-empty inserted paragraphs are filtered out',
    async ({ given, when, then, attachPrettyJson }: AllureBddContext) => {
      // A paragraph with only pPr/rPr/ins (paragraph-level insertion marker)
      // and no text content — should be excluded from results.
      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:p><w:r><w:t>Normal paragraph</w:t></w:r></w:p>` +
          `<w:p><w:pPr><w:rPr><w:ins w:id="1" w:author="Comparison" w:date="2024-01-01T00:00:00Z"/></w:rPr></w:pPr></w:p>` +
          `<w:p><w:r><w:t>Another </w:t></w:r><w:ins w:author="X"><w:r><w:t>edited</w:t></w:r></w:ins></w:p>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with a structurally-empty inserted paragraph and a real content change',
        () => openSession([], { xml: docXml }),
      );

      const result = await when('extract_revisions is called', () =>
        extractRevisions_tool(mgr, { file_path: filePath }),
      );
      assertSuccess(result, 'extract_revisions');
      await attachPrettyJson('result', result);

      await then('only the paragraph with real content changes is returned', () => {
        // Only the third paragraph (with real content changes) should appear
        expect(result.total_changes).toBe(1);
        const changes = asChanges(result.changes);
        expect(changes).toHaveLength(1);
        expect(changes[0].after_text).toContain('edited');
      });
    },
  );

  // ── Real DOCX regression guard ────────────────────────────────

  humanReadableTest.openspec('real DOCX redline with tracked changes extracts correctly')(
    'Scenario: real DOCX redline with tracked changes extracts correctly',
    async ({ given, when, then, and, attachPrettyJson }: AllureBddContext) => {
      const fixturePath = await createRealTrackedChangesFixture();

      const mgr = createTestSessionManager();

      const opened = await given('a real DOCX redline fixture is opened as a session', () =>
        openDocument(mgr, { file_path: fixturePath }),
      );
      assertSuccess(opened, 'open fixture');

      const result = await when('extract_revisions is called', () =>
        extractRevisions_tool(mgr, { file_path: fixturePath }),
      );
      assertSuccess(result, 'extract_revisions');
      await attachPrettyJson('result', result);

      const changes = asChanges(result.changes);
      const totalChanges = result.total_changes as number;

      await then('at least one change is extracted', () => {
        expect(totalChanges).toBeGreaterThan(0);
      });

      await and('each change has a valid structure', () => {
        const validTypes = new Set(['INSERTION', 'DELETION', 'MOVE_FROM', 'MOVE_TO', 'FORMAT_CHANGE']);
        for (const c of changes) {
          expect(c.para_id).toBeTruthy();
          const revisions = c.revisions ?? [];
          expect(revisions.length).toBeGreaterThan(0);
          for (const r of revisions) {
            expect(typeof r.type).toBe('string');
            if (!r.type) continue;
            expect(validTypes.has(r.type)).toBe(true);
          }
        }
      });

      await and('the pagination metadata is consistent', () => {
        expect(totalChanges).toBeGreaterThanOrEqual(changes.length);
        if (result.has_more) {
          expect(changes.length).toBe(50); // default limit
        }
      });
    },
  );

  // ── Inserted-only paragraph ────────────────────────────────────

  humanReadableTest.openspec('inserted-only paragraph has empty before text')(
    'Scenario: inserted-only paragraph has empty before text',
    async ({ given, when, then }: AllureBddContext) => {
      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:p><w:r><w:t>Existing</w:t></w:r></w:p>` +
          `<w:p>` +
            `<w:pPr><w:rPr><w:ins w:id="1" w:author="A" w:date="2024-01-01T00:00:00Z"/></w:rPr></w:pPr>` +
            `<w:ins w:author="A"><w:r><w:t>New paragraph</w:t></w:r></w:ins>` +
          `</w:p>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with a fully-inserted paragraph is open in a session',
        () => openSession([], { xml: docXml }),
      );
      const result = await when('extract_revisions is called', () =>
        extractRevisions_tool(mgr, { file_path: filePath }),
      );
      assertSuccess(result, 'extract_revisions');

      await then('the inserted paragraph has empty before_text and correct after_text', () => {
        const changes = asChanges(result.changes);
        expect(changes).toHaveLength(1);
        expect(changes[0].before_text).toBe('');
        expect(changes[0].after_text).toBe('New paragraph');
      });
    },
  );

  // ── Deleted-only paragraph ────────────────────────────────────

  humanReadableTest.openspec('deleted-only paragraph has empty after text')(
    'Scenario: deleted-only paragraph has empty after text',
    async ({ given, when, then }: AllureBddContext) => {
      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:p><w:r><w:t>Kept</w:t></w:r></w:p>` +
          `<w:p>` +
            `<w:del w:author="A" w:date="2024-01-01T00:00:00Z">` +
              `<w:r><w:delText>Deleted paragraph</w:delText></w:r>` +
            `</w:del>` +
          `</w:p>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with a fully-deleted paragraph is open in a session',
        () => openSession([], { xml: docXml }),
      );
      const result = await when('extract_revisions is called', () =>
        extractRevisions_tool(mgr, { file_path: filePath }),
      );
      assertSuccess(result, 'extract_revisions');

      await then('the deleted paragraph has correct before_text and empty after_text', () => {
        const changes = asChanges(result.changes);
        expect(changes).toHaveLength(1);
        expect(changes[0].before_text).toBe('Deleted paragraph');
        expect(changes[0].after_text).toBe('');
      });
    },
  );

  // ── Table cells ───────────────────────────────────────────────

  humanReadableTest.openspec('changed paragraphs inside table cells are extracted')(
    'Scenario: changed paragraphs inside table cells are extracted',
    async ({ given, when, then }: AllureBddContext) => {
      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:tbl><w:tblPr/><w:tblGrid><w:gridCol/></w:tblGrid><w:tr><w:tc>` +
            `<w:p><w:r><w:t>Cell </w:t></w:r>` +
            `<w:ins w:author="X"><w:r><w:t>edited</w:t></w:r></w:ins></w:p>` +
          `</w:tc></w:tr></w:tbl>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with a tracked insertion inside a table cell is open in a session',
        () => openSession([], { xml: docXml }),
      );
      const result = await when('extract_revisions is called', () =>
        extractRevisions_tool(mgr, { file_path: filePath }),
      );
      assertSuccess(result, 'extract_revisions');

      await then('the table cell change is included in the results', () => {
        expect(result.total_changes).toBe(1);
        const changes = asChanges(result.changes);
        expect(changes[0].after_text).toContain('edited');
      });
    },
  );

  // ── Comments ──────────────────────────────────────────────────

  humanReadableTest.openspec('extracting revisions includes associated comments')(
    'Scenario: extracting revisions includes associated comments',
    async ({ given, when, then, attachPrettyJson }: AllureBddContext) => {
      const commentsXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:comments xmlns:w="${W_NS}" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml">` +
          `<w:comment w:id="1" w:author="Reviewer" w:date="2024-01-01T00:00:00Z">` +
            `<w:p><w:r><w:t>Please review this change</w:t></w:r></w:p>` +
          `</w:comment>` +
        `</w:comments>`;

      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:p>` +
            `<w:commentRangeStart w:id="1"/>` +
            `<w:r><w:t>Original</w:t></w:r>` +
            `<w:ins w:author="X"><w:r><w:t> added</w:t></w:r></w:ins>` +
            `<w:commentRangeEnd w:id="1"/>` +
            `<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>` +
          `</w:p>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with a tracked insertion and an associated comment from Reviewer is open in a session',
        () => openSession([], {
          xml: docXml,
          extraFiles: { 'word/comments.xml': commentsXml },
        }),
      );

      const result = await when('extract_revisions is called', () =>
        extractRevisions_tool(mgr, { file_path: filePath }),
      );
      assertSuccess(result, 'extract_revisions');
      await attachPrettyJson('result', result);

      await then('the comment from Reviewer is associated with the change', () => {
        const changes = asChanges(result.changes);
        expect(changes).toHaveLength(1);
        const comments = asComments(changes[0]?.comments);
        expect(comments.length).toBeGreaterThanOrEqual(1);
        expect(comments[0]?.author).toBe('Reviewer');
        expect(comments[0]?.text).toContain('Please review');
      });
    },
  );

  // ── Offset beyond total ───────────────────────────────────────

  humanReadableTest.openspec('offset beyond total returns empty page')(
    'Scenario: offset beyond total returns empty page',
    async ({ given, when, then }: AllureBddContext) => {
      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:p><w:r><w:t>A</w:t></w:r><w:ins w:author="X"><w:r><w:t>1</w:t></w:r></w:ins></w:p>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with one changed paragraph is open in a session',
        () => openSession([], { xml: docXml }),
      );
      const result = await when('extract_revisions is called with offset=100', () =>
        extractRevisions_tool(mgr, { file_path: filePath, offset: 100 }),
      );
      assertSuccess(result, 'extract_revisions');

      await then('an empty page is returned with total_changes still reported correctly', () => {
        expect(result.total_changes).toBe(1);
        expect((asChanges(result.changes)).length).toBe(0);
        expect(result.has_more).toBe(false);
      });
    },
  );

  // ── Subsequent pages ──────────────────────────────────────────

  humanReadableTest.openspec('retrieving subsequent pages with offset')(
    'Scenario: retrieving subsequent pages with offset',
    async ({ given, when, then, and }: AllureBddContext) => {
      const docXml =
        `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
        `<w:document xmlns:w="${W_NS}">` +
        `<w:body>` +
          `<w:p><w:r><w:t>A</w:t></w:r><w:ins w:author="X"><w:r><w:t>1</w:t></w:r></w:ins></w:p>` +
          `<w:p><w:r><w:t>B</w:t></w:r><w:ins w:author="X"><w:r><w:t>2</w:t></w:r></w:ins></w:p>` +
          `<w:p><w:r><w:t>C</w:t></w:r><w:ins w:author="X"><w:r><w:t>3</w:t></w:r></w:ins></w:p>` +
          `<w:p><w:r><w:t>D</w:t></w:r><w:ins w:author="X"><w:r><w:t>4</w:t></w:r></w:ins></w:p>` +
        `</w:body></w:document>`;

      const { mgr, filePath } = await given(
        'a document with 4 changed paragraphs is open in a session',
        () => openSession([], { xml: docXml }),
      );

      const page1 = await when('extract_revisions is called with offset=0, limit=2', () =>
        extractRevisions_tool(mgr, { file_path: filePath, offset: 0, limit: 2 }),
      );
      assertSuccess(page1, 'page1');

      const page2 = await and('extract_revisions is called with offset=2, limit=2', () =>
        extractRevisions_tool(mgr, { file_path: filePath, offset: 2, limit: 2 }),
      );
      assertSuccess(page2, 'page2');

      await then('page 1 and page 2 contain different paragraph IDs with no overlap', () => {
        const p1Ids = new Set((asChanges(page1.changes)).map((c) => c.para_id));
        for (const c of asChanges(page2.changes)) {
          expect(p1Ids.has(c.para_id)).toBe(false);
        }
      });
    },
  );

  // ── Cache invalidation on edit ────────────────────────────────

  humanReadableTest.openspec('new edit invalidates extraction cache')(
    'Scenario: new edit invalidates extraction cache',
    async ({
      given,
      when,
      then,
      and,
      attachJsonLastStep,
    }: AllureBddContext) => {
      const inputParagraphs = ['Hello world', 'Second paragraph'];
      const replaceInstruction = {
        old_string: 'Hello world',
        new_string: 'Hi world',
        instruction: 'cache invalidation test',
      } as const;
      const debugContext = {
        scenario: 'new edit invalidates extraction cache',
        input_paragraphs: inputParagraphs,
        replace_text: replaceInstruction,
        expected: {
          cache_after_first_extraction: 'present',
          cache_after_edit: 'null',
          second_edit_revision_differs: true,
        },
      } as const;

      let debugResult: Record<string, unknown> | null = null;
      try {
        const { mgr, filePath, firstParaId } = await given(
          'a clean two-paragraph document is open in a session',
          () => openSession(inputParagraphs, { trackOpenStep: false }),
          { paragraph_count: inputParagraphs.length },
        );

        const result1 = await when(
          'I run extract_revisions for the first time',
          () => extractRevisions_tool(mgr, { file_path: filePath }),
          { file_path: filePath },
        );
        assertSuccess(result1, 'first extraction');
        const rev1 = result1.edit_revision;

        await then(
          'the extraction cache is populated after the first run',
          async () => {
            const session = (await mgr.getSessionByFilePath(filePath)) as DocxSession;
            expect(mgr.getExtractionCache(session)).not.toBeNull();
          },
          { expected_cache: 'present' },
        );

        const editResult = await when(
          'I edit the first paragraph using replace_text',
          () => replaceText(mgr, {
            file_path: filePath,
            target_paragraph_id: firstParaId,
            old_string: replaceInstruction.old_string,
            new_string: replaceInstruction.new_string,
            instruction: replaceInstruction.instruction,
          }),
          {
            target_paragraph_id: firstParaId,
            old_string: replaceInstruction.old_string,
            new_string: replaceInstruction.new_string,
          },
        );
        assertSuccess(editResult, 'replace_text');

        await and(
          'the extraction cache is invalidated by that edit',
          async () => {
            const session = (await mgr.getSessionByFilePath(filePath)) as DocxSession;
            expect(mgr.getExtractionCache(session)).toBeNull();
          },
          { expected_cache: 'null' },
        );

        const result2 = await when(
          'I run extract_revisions again after the edit',
          () => extractRevisions_tool(mgr, { file_path: filePath }),
          { file_path: filePath },
        );
        assertSuccess(result2, 'second extraction');

        await and(
          'the second extraction has a newer edit revision',
          async () => {
            expect(result2.edit_revision).not.toBe(rev1);
          },
          { previous_edit_revision: String(rev1), current_edit_revision: String(result2.edit_revision) },
        );

        debugResult = {
          first_extraction: result1,
          edit_result: editResult,
          second_extraction: result2,
          first_edit_revision: rev1,
          second_edit_revision: result2.edit_revision,
        };
      } finally {
        await attachJsonLastStep({
          context: debugContext,
          result: debugResult,
          stepName: 'Attach debug JSON (context + result)',
        });
      }
    },
  );

  // ── Two-file comparison then extraction ────────────────────────

  humanReadableTest.openspec('two-file comparison then extraction workflow')(
    'Scenario: two-file comparison then extraction workflow',
    async ({ given, when, then }: AllureBddContext) => {
      const fixturePath = await createRealTrackedChangesFixture();

      const mgr = createTestSessionManager();

      const opened = await given('a redline DOCX is opened via file_path', () =>
        openDocument(mgr, { file_path: fixturePath }),
      );
      assertSuccess(opened, 'open');

      const result = await when('extract_revisions is called on the redline session', () =>
        extractRevisions_tool(mgr, { file_path: fixturePath }),
      );
      assertSuccess(result, 'extract_revisions');

      await then('a structured diff is returned with at least one change', () => {
        expect(result.total_changes).toBeGreaterThan(0);
        expect(result.changes).toBeDefined();
        expect(Array.isArray(result.changes)).toBe(true);
      });
    },
  );

  // ── Tool registration ───────────────────────────────────────────

  test(
    'extract_revisions tool is registered in MCP_TOOLS',
    async () => {
      const tool = MCP_TOOLS.find((t) => t.name === 'extract_revisions');
      expect(tool).toBeTruthy();
      expect(tool!.annotations.readOnlyHint).toBe(true);
      expect(tool!.annotations.destructiveHint).toBe(false);
      expect(tool!.inputSchema.properties).toHaveProperty('file_path');
      expect(tool!.inputSchema.properties).toHaveProperty('offset');
      expect(tool!.inputSchema.properties).toHaveProperty('limit');
    },
  );
});
