import test from 'node:test';
import assert from 'node:assert/strict';
import { findDuplicateNote, findDuplicateNoteIds, type NoteInfo } from './duplicate';

function createFieldResolver(noteInfo: NoteInfo, preferredName: string): string | null {
  const names = Object.keys(noteInfo.fields);
  const exact = names.find((name) => name === preferredName);
  if (exact) return exact;
  const lower = preferredName.toLowerCase();
  return names.find((name) => name.toLowerCase() === lower) ?? null;
}

test('findDuplicateNote matches duplicate when candidate uses alternate word/expression field name', async () => {
  const currentNote: NoteInfo = {
    noteId: 100,
    fields: {
      Expression: { value: '食べる' },
    },
  };

  const duplicateId = await findDuplicateNote('食べる', 100, currentNote, {
    findNotes: async () => [100, 200],
    notesInfo: async () => [
      {
        noteId: 200,
        fields: {
          Word: { value: '食べる' },
        },
      },
    ],
    getDeck: () => 'Japanese::Mining',
    resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
    logWarn: () => {},
  });

  assert.equal(duplicateId, 200);
});

test('findDuplicateNote falls back to alias field query when primary field query returns no candidates', async () => {
  const currentNote: NoteInfo = {
    noteId: 100,
    fields: {
      Expression: { value: '食べる' },
    },
  };

  const seenQueries: string[] = [];
  const duplicateId = await findDuplicateNote('食べる', 100, currentNote, {
    findNotes: async (query) => {
      seenQueries.push(query);
      if (query.includes('"Expression:')) {
        return [];
      }
      if (query.includes('"word:') || query.includes('"Word:') || query.includes('"expression:')) {
        return [200];
      }
      return [];
    },
    notesInfo: async () => [
      {
        noteId: 200,
        fields: {
          Word: { value: '食べる' },
        },
      },
    ],
    getDeck: () => 'Japanese::Mining',
    resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
    logWarn: () => {},
  });

  assert.equal(duplicateId, 200);
  assert.equal(seenQueries.length, 2);
});

test('findDuplicateNote checks both source expression/word values when both fields are present', async () => {
  const currentNote: NoteInfo = {
    noteId: 100,
    fields: {
      Expression: { value: '昨日は雨だった。' },
      Word: { value: '雨' },
    },
  };

  const seenQueries: string[] = [];
  const duplicateId = await findDuplicateNote('昨日は雨だった。', 100, currentNote, {
    findNotes: async (query) => {
      seenQueries.push(query);
      if (query.includes('昨日は雨だった。')) {
        return [];
      }
      if (
        query.includes('"Word:雨"') ||
        query.includes('"word:雨"') ||
        query.includes('"Expression:雨"')
      ) {
        return [200];
      }
      return [];
    },
    notesInfo: async () => [
      {
        noteId: 200,
        fields: {
          Word: { value: '雨' },
        },
      },
    ],
    getDeck: () => 'Japanese::Mining',
    resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
    logWarn: () => {},
  });

  assert.equal(duplicateId, 200);
  assert.ok(seenQueries.some((query) => query.includes('昨日は雨だった。')));
  assert.ok(seenQueries.some((query) => query.includes('雨')));
});

test('findDuplicateNote falls back to collection-wide query when deck-scoped query has no matches', async () => {
  const currentNote: NoteInfo = {
    noteId: 100,
    fields: {
      Expression: { value: '貴様' },
    },
  };

  const seenQueries: string[] = [];
  const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
    findNotes: async (query) => {
      seenQueries.push(query);
      if (query.includes('deck:Japanese')) {
        return [];
      }
      if (query.includes('"Expression:貴様"') || query.includes('"Word:貴様"')) {
        return [200];
      }
      return [];
    },
    notesInfo: async () => [
      {
        noteId: 200,
        fields: {
          Expression: { value: '貴様' },
        },
      },
    ],
    getDeck: () => 'Japanese::Mining',
    resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
    logWarn: () => {},
  });

  assert.equal(duplicateId, 200);
  assert.ok(seenQueries.some((query) => query.includes('deck:Japanese')));
  assert.ok(seenQueries.some((query) => !query.includes('deck:Japanese')));
});

test('findDuplicateNote falls back to plain text query when field queries miss', async () => {
  const currentNote: NoteInfo = {
    noteId: 100,
    fields: {
      Expression: { value: '貴様' },
    },
  };

  const seenQueries: string[] = [];
  const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
    findNotes: async (query) => {
      seenQueries.push(query);
      if (query.includes('Expression:') || query.includes('Word:')) {
        return [];
      }
      if (query.includes('"貴様"')) {
        return [200];
      }
      return [];
    },
    notesInfo: async () => [
      {
        noteId: 200,
        fields: {
          Expression: { value: '貴様' },
        },
      },
    ],
    getDeck: () => 'Japanese::Mining',
    resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
    logWarn: () => {},
  });

  assert.equal(duplicateId, 200);
  assert.ok(seenQueries.some((query) => query.includes('Expression:')));
  assert.ok(seenQueries.some((query) => query.endsWith('"貴様"')));
});

test('findDuplicateNote exact compare tolerates furigana bracket markup in candidate field', async () => {
  const currentNote: NoteInfo = {
    noteId: 100,
    fields: {
      Expression: { value: '貴様' },
    },
  };

  const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
    findNotes: async () => [200],
    notesInfo: async () => [
      {
        noteId: 200,
        fields: {
          Expression: { value: '貴様[きさま]' },
        },
      },
    ],
    getDeck: () => 'Japanese::Mining',
    resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
    logWarn: () => {},
  });

  assert.equal(duplicateId, 200);
});

test('findDuplicateNote exact compare tolerates html wrappers in candidate field', async () => {
  const currentNote: NoteInfo = {
    noteId: 100,
    fields: {
      Expression: { value: '貴様' },
    },
  };

  const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
    findNotes: async () => [200],
    notesInfo: async () => [
      {
        noteId: 200,
        fields: {
          Expression: { value: '<span data-x="1">貴様</span>' },
        },
      },
    ],
    getDeck: () => 'Japanese::Mining',
    resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
    logWarn: () => {},
  });

  assert.equal(duplicateId, 200);
});

test('findDuplicateNote does not disable retries on findNotes calls', async () => {
  const currentNote: NoteInfo = {
    noteId: 100,
    fields: {
      Expression: { value: '貴様' },
    },
  };

  const seenOptions: Array<{ maxRetries?: number } | undefined> = [];
  await findDuplicateNote('貴様', 100, currentNote, {
    findNotes: async (_query, options) => {
      seenOptions.push(options);
      return [];
    },
    notesInfo: async () => [],
    getDeck: () => 'Japanese::Mining',
    resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
    logWarn: () => {},
  });

  assert.ok(seenOptions.length > 0);
  assert.ok(seenOptions.every((options) => options?.maxRetries !== 0));
});

test('findDuplicateNote stops after the first exact-match chunk', async () => {
  const currentNote: NoteInfo = {
    noteId: 100,
    fields: {
      Expression: { value: '貴様' },
    },
  };

  let notesInfoCalls = 0;
  const candidateIds = Array.from({ length: 51 }, (_, index) => 200 + index);
  const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
    findNotes: async () => candidateIds,
    notesInfo: async (noteIds) => {
      notesInfoCalls += 1;
      return noteIds.map((noteId) => ({
        noteId,
        fields: {
          Expression: { value: noteId === 200 ? '貴様' : `別単語-${noteId}` },
        },
      }));
    },
    getDeck: () => 'Japanese::Mining',
    resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
    logWarn: () => {},
  });

  assert.equal(duplicateId, 200);
  assert.equal(notesInfoCalls, 1);
});

test('findDuplicateNoteIds returns no matches when maxMatches is zero', async () => {
  const currentNote: NoteInfo = {
    noteId: 100,
    fields: {
      Expression: { value: '貴様' },
    },
  };

  let notesInfoCalls = 0;
  const duplicateIds = await findDuplicateNoteIds(
    '貴様',
    100,
    currentNote,
    {
      findNotes: async () => [200],
      notesInfo: async (noteIds) => {
        notesInfoCalls += 1;
        return noteIds.map((noteId) => ({
          noteId,
          fields: {
            Expression: { value: '貴様' },
          },
        }));
      },
      getDeck: () => 'Japanese::Mining',
      resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
      logWarn: () => {},
    },
    0,
  );

  assert.deepEqual(duplicateIds, []);
  assert.equal(notesInfoCalls, 0);
});
