/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyScriptEditOperations, extractScriptEditOps, filterUnappliedScriptOps } from './script-edit-ops.js';

test('extractScriptEditOps parses ops from ifc-script-edits block', () => {
  const text = `
Hello
\`\`\`ifc-script-edits
{"scriptEdits":[
  {"opId":"op-1","type":"replaceSelection","baseRevision":3,"text":"const x = 1;"},
  {"opId":"op-2","type":"append","baseRevision":3,"text":"\\nconsole.log(x)"}
]}
\`\`\`
`;
  const result = extractScriptEditOps(text);
  assert.equal(result.parseErrors.length, 0);
  assert.equal(result.operations.length, 2);
  assert.equal(result.operations[0].opId, 'op-1');
  assert.equal(result.operations[1].type, 'append');
});

test('extractScriptEditOps parses SEARCH/REPLACE blocks into replaceRange ops', () => {
  const baseContent = 'const width = 30;\nconst depth = 40;\n';
  const result = extractScriptEditOps(`
\`\`\`ifc-script-edits
<<<<<<< SEARCH
const width = 30;
=======
const width = 36;
>>>>>>> REPLACE
\`\`\`
`, {
    baseRevision: 7,
    baseContent,
    intent: 'repair',
    requestedRepairScope: 'local',
    targetRootCause: 'api_contract_mismatch',
  });

  assert.equal(result.parseErrors.length, 0);
  assert.equal(result.operations.length, 1);
  assert.equal(result.operations[0].type, 'replaceRange');
  assert.equal(result.operations[0].baseRevision, 7);
  assert.equal(result.operations[0].expectedText, 'const width = 30;');
  assert.equal(result.operations[0].text, 'const width = 36;');
});

test('extractScriptEditOps reports no-match SEARCH blocks with targeted diagnostics', () => {
  const result = extractScriptEditOps(`
\`\`\`ifc-script-edits
<<<<<<< SEARCH
const missing = true;
=======
const present = true;
>>>>>>> REPLACE
\`\`\`
`, {
    baseRevision: 4,
    baseContent: 'const width = 30;\n',
    intent: 'repair',
  });

  assert.equal(result.operations.length, 0);
  assert.match(result.parseErrors[0] ?? '', /does not match the current script/);
  assert.equal(result.parseDiagnostics[0]?.data?.failureKind, 'no_unique_match');
});

test('extractScriptEditOps reports ambiguous SEARCH blocks with targeted diagnostics', () => {
  const result = extractScriptEditOps(`
\`\`\`ifc-script-edits
<<<<<<< SEARCH
const value = 1;
=======
const value = 2;
>>>>>>> REPLACE
\`\`\`
`, {
    baseRevision: 4,
    baseContent: 'const value = 1;\nconst value = 1;\n',
    intent: 'repair',
  });

  assert.equal(result.operations.length, 0);
  assert.match(result.parseErrors[0] ?? '', /matches multiple locations/);
  assert.equal(result.parseDiagnostics[0]?.data?.failureKind, 'multiple_matches');
});

test('filterUnappliedScriptOps drops already applied op ids', () => {
  const result = extractScriptEditOps(`
\`\`\`ifc-script-edits
{"scriptEdits":[
  {"opId":"op-1","type":"append","baseRevision":1,"text":"a"},
  {"opId":"op-2","type":"append","baseRevision":1,"text":"b"}
]}
\`\`\`
`);
  const filtered = filterUnappliedScriptOps(result.operations, new Set(['op-1']));
  assert.equal(filtered.length, 1);
  assert.equal(filtered[0].opId, 'op-2');
});

test('applyScriptEditOperations applies replaceSelection and append in one revision', () => {
  const applied = applyScriptEditOperations({
    content: 'const a = 1;',
    selection: { from: 6, to: 7 },
    revision: 5,
    operations: [
      { opId: 'op-1', type: 'replaceSelection', baseRevision: 5, text: 'b' },
      { opId: 'op-2', type: 'append', baseRevision: 5, text: '\nconsole.log(b)' },
    ],
  });
  assert.equal(applied.ok, true);
  assert.equal(applied.status, 'ok');
  assert.equal(applied.revision, 6);
  assert.equal(applied.content, 'const b = 1;\nconsole.log(b)');
  assert.deepEqual(applied.appliedOpIds, ['op-1', 'op-2']);
});

test('applyScriptEditOperations rejects stale revision', () => {
  const applied = applyScriptEditOperations({
    content: 'const a = 1;',
    selection: { from: 0, to: 0 },
    revision: 2,
    operations: [
      { opId: 'op-1', type: 'append', baseRevision: 1, text: '\nconsole.log(a)' },
    ],
  });
  assert.equal(applied.ok, false);
  assert.equal(applied.status, 'revision_conflict');
  assert.match(applied.error ?? '', /expected base revision is 2/);
  assert.equal(applied.diagnostic?.code, 'patch_revision_conflict');
});

test('applyScriptEditOperations accepts fixed base revision across stream batches', () => {
  const baseContent = 'if (f > 0) {\n  facade();\n}';
  const first = applyScriptEditOperations({
    content: baseContent,
    selection: { from: 0, to: 0 },
    revision: 1,
    acceptedBaseRevision: 1,
    baseContentSnapshot: baseContent,
    operations: [
      {
        opId: 'remove-ground-skip',
        type: 'replaceRange',
        baseRevision: 1,
        from: 0,
        to: 11,
        text: '',
      },
    ],
  });
  assert.equal(first.ok, true);
  assert.equal(first.status, 'ok');
  assert.equal(first.revision, 2);

  const second = applyScriptEditOperations({
    content: first.content,
    selection: first.selection,
    revision: first.revision,
    priorAcceptedOps: [
      { opId: 'prefix', type: 'insert', baseRevision: 1, at: 0, text: '12' },
    ],
    acceptedBaseRevision: 1,
    baseContentSnapshot: baseContent,
    operations: [
      {
        opId: 'normalize-indent',
        type: 'replaceRange',
        baseRevision: 1,
        from: 0,
        to: 0,
        text: '// edited\n',
      },
    ],
  });
  assert.equal(second.ok, true);
  assert.equal(second.status, 'ok');
  assert.equal(second.revision, 3);
});

test('applyScriptEditOperations rebases positional ops against the original base snapshot', () => {
  const baseContent = 'abcdef';
  const first = applyScriptEditOperations({
    content: baseContent,
    selection: { from: 0, to: 0 },
    revision: 1,
    acceptedBaseRevision: 1,
    baseContentSnapshot: baseContent,
    operations: [
      { opId: 'prefix', type: 'insert', baseRevision: 1, at: 0, text: '12' },
    ],
  });
  assert.equal(first.ok, true);
  assert.equal(first.content, '12abcdef');

  const second = applyScriptEditOperations({
    content: first.content,
    selection: first.selection,
    revision: first.revision,
    priorAcceptedOps: [
      { opId: 'prefix', type: 'insert', baseRevision: 1, at: 0, text: '12' },
    ],
    acceptedBaseRevision: 1,
    baseContentSnapshot: baseContent,
    operations: [
      { opId: 'replace-cd', type: 'replaceRange', baseRevision: 1, from: 2, to: 4, text: 'ZZ' },
    ],
  });
  assert.equal(second.ok, true);
  assert.equal(second.content, '12abZZef');
});

test('applyScriptEditOperations rejects overlapping stale range ops before mutating content', () => {
  const baseContent = 'const width = 30;\nconst depth = 40;\n';
  const applied = applyScriptEditOperations({
    content: baseContent,
    selection: { from: 0, to: 0 },
    revision: 3,
    acceptedBaseRevision: 3,
    baseContentSnapshot: baseContent,
    operations: [
      { opId: 'rename-width', type: 'replaceRange', baseRevision: 3, from: 6, to: 11, text: 'span' },
      { opId: 'rename-width-again', type: 'replaceRange', baseRevision: 3, from: 8, to: 13, text: 'measure' },
    ],
  });

  assert.equal(applied.ok, false);
  assert.equal(applied.status, 'revision_conflict');
  assert.equal(applied.content, baseContent);
  assert.equal(applied.diagnostic?.code, 'patch_revision_conflict');
});

test('applyScriptEditOperations blocks replaceAll during repair turns', () => {
  const applied = applyScriptEditOperations({
    content: 'const h = bim.create.project({ Name: "Tower" });\nconst result = bim.create.toIfc(h);\n',
    selection: { from: 0, to: 0 },
    revision: 4,
    intent: 'repair',
    operations: [
      {
        opId: 'rewrite-fragment',
        type: 'replaceAll',
        baseRevision: 4,
        text: 'for (let x = 0; x < width; x += 3) {\n  facade(x);\n}\n',
      },
    ],
  });

  assert.equal(applied.ok, false);
  assert.equal(applied.status, 'semantic_error');
  assert.equal(applied.diagnostic?.code, 'unsafe_full_replacement');
});

test('applyScriptEditOperations rejects replaceSelection during repair turns', () => {
  const applied = applyScriptEditOperations({
    content: 'bim.create.addIfcDoor(h, ground, { Name: "Front Door" });',
    selection: { from: 0, to: 10 },
    revision: 6,
    intent: 'repair',
    operations: [
      {
        opId: 'repair-via-selection',
        type: 'replaceSelection',
        baseRevision: 6,
        text: '',
      },
    ],
  });

  assert.equal(applied.ok, false);
  assert.equal(applied.status, 'semantic_error');
  assert.match(applied.error ?? '', /not allowed for automated repair turns/);
});

test('applyScriptEditOperations rejects repair range ops when expectedText does not match', () => {
  const content = 'bim.create.addIfcDoor(h, ground, { Name: "Front Door" });';
  const applied = applyScriptEditOperations({
    content,
    selection: { from: 0, to: 0 },
    revision: 8,
    intent: 'repair',
    acceptedBaseRevision: 8,
    baseContentSnapshot: content,
    operations: [
      {
        opId: 'remove-door',
        type: 'replaceRange',
        baseRevision: 8,
        from: 0,
        to: content.length,
        expectedText: 'bim.create.addIfcDoor(h, ground, { Name: "Wrong" });',
        text: '// removed',
      },
    ],
  });

  assert.equal(applied.ok, false);
  assert.equal(applied.status, 'revision_conflict');
  assert.match(applied.error ?? '', /no longer matches the expected text/);
});

test('applyScriptEditOperations accepts coordinated structural repair ops with shared metadata', () => {
  const content = [
    'const width = 30;',
    'const depth = 40;',
    'bim.create.addIfcMember(h, storey, {',
    '  Start: [0, 0, 0],',
    '  End: [0, 0, 3],',
    '  Width: 0.2,',
    '  Height: 0.2,',
    '});',
  ].join('\n');
  const startLine = '  Start: [0, 0, 0],';
  const endLine = '  End: [0, 0, 3],';
  const startFrom = content.indexOf(startLine);
  const endFrom = content.indexOf(endLine);

  const applied = applyScriptEditOperations({
    content,
    selection: { from: 0, to: 0 },
    revision: 10,
    intent: 'repair',
    acceptedBaseRevision: 10,
    baseContentSnapshot: content,
    operations: [
      {
        opId: 'fix-start',
        type: 'replaceRange',
        baseRevision: 10,
        groupId: 'placement-fix',
        scope: 'structural',
        targetRootCause: 'placement_context_mismatch',
        from: startFrom,
        to: startFrom + startLine.length,
        expectedText: startLine,
        text: '  Start: [0, 0, elevation],',
      },
      {
        opId: 'fix-end',
        type: 'replaceRange',
        baseRevision: 10,
        groupId: 'placement-fix',
        scope: 'structural',
        targetRootCause: 'placement_context_mismatch',
        from: endFrom,
        to: endFrom + endLine.length,
        expectedText: endLine,
        text: '  End: [0, 0, elevation + 3],',
      },
    ],
  });

  assert.equal(applied.ok, true);
  assert.match(applied.content, /Start: \[0, 0, elevation\]/);
  assert.match(applied.content, /End: \[0, 0, elevation \+ 3\]/);
});

test('applyScriptEditOperations rejects grouped structural repair ops without shared metadata', () => {
  const content = 'const width = 30;\nconst depth = 40;\n';
  const applied = applyScriptEditOperations({
    content,
    selection: { from: 0, to: 0 },
    revision: 11,
    intent: 'repair',
    acceptedBaseRevision: 11,
    baseContentSnapshot: content,
    operations: [
      {
        opId: 'fix-width',
        type: 'replaceRange',
        baseRevision: 11,
        scope: 'structural',
        from: 0,
        to: 5,
        expectedText: 'const',
        text: 'let',
      },
      {
        opId: 'fix-depth',
        type: 'replaceRange',
        baseRevision: 11,
        scope: 'structural',
        from: 17,
        to: 22,
        expectedText: 'const',
        text: 'let',
      },
    ],
  });

  assert.equal(applied.ok, false);
  assert.equal(applied.status, 'semantic_error');
  assert.match(applied.error ?? '', /must declare `targetRootCause`/);
});
