import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import type { AnkiConnectConfig } from '../types/anki';
import { setLogLevel } from '../logger';
import { KnownWordCacheManager, getKnownWordCacheLifecycleConfig } from './known-word-cache';

interface HarnessNoteInfo {
  noteId: number;
  fields: Record<string, { value: string }>;
}

function createMaturityHarness(config: AnkiConnectConfig): {
  manager: KnownWordCacheManager;
  calls: { findNotes: number; notesInfo: number; queries: string[] };
  statePath: string;
  clientState: {
    findNotesResult: number[];
    notesInfoResult: HarnessNoteInfo[];
    findNotesByQuery: Map<string, number[]>;
    failedQueries: Set<string>;
  };
  createSiblingManager: () => KnownWordCacheManager;
  cleanup: () => void;
} {
  const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-known-word-maturity-'));
  const statePath = path.join(stateDir, 'known-words-cache.json');
  const calls = { findNotes: 0, notesInfo: 0, queries: [] as string[] };
  const clientState = {
    findNotesResult: [] as number[],
    notesInfoResult: [] as HarnessNoteInfo[],
    findNotesByQuery: new Map<string, number[]>(),
    failedQueries: new Set<string>(),
  };
  const deps = {
    client: {
      findNotes: async (query: string) => {
        calls.findNotes += 1;
        calls.queries.push(query);
        if (clientState.failedQueries.has(query)) {
          throw new Error(`Anki unavailable for query: ${query}`);
        }
        if (clientState.findNotesByQuery.has(query)) {
          return clientState.findNotesByQuery.get(query) ?? [];
        }
        return clientState.findNotesResult;
      },
      notesInfo: async (noteIds: number[]) => {
        calls.notesInfo += 1;
        return clientState.notesInfoResult.filter((note) => noteIds.includes(note.noteId));
      },
    },
    getConfig: () => config,
    knownWordCacheStatePath: statePath,
    showStatusNotification: () => undefined,
  };
  return {
    manager: new KnownWordCacheManager(deps),
    calls,
    statePath,
    clientState,
    createSiblingManager: () => new KnownWordCacheManager(deps),
    cleanup: () => {
      fs.rmSync(stateDir, { recursive: true, force: true });
    },
  };
}

// The four queries a maturity refresh issues, in one place so a query-string
// change lands in a single spot.
function setTierQueries(
  clientState: { findNotesByQuery: Map<string, number[]> },
  tiers: { all: number[]; mature: number[]; young: number[]; learning: number[] },
): void {
  clientState.findNotesByQuery.set('deck:"Mining"', tiers.all);
  clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', tiers.mature);
  clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', tiers.young);
  clientState.findNotesByQuery.set('deck:"Mining" is:learn', tiers.learning);
}

function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
  return {
    deck: 'Mining',
    fields: { word: 'Word' },
    knownWords: {
      highlightEnabled: true,
      maturityEnabled: true,
      refreshMinutes: 60,
    },
    ...overrides,
  };
}

test('lifecycle config key is unchanged when maturity is disabled', () => {
  const disabled: AnkiConnectConfig = {
    knownWords: { highlightEnabled: true, refreshMinutes: 60 },
  };
  // Upgrading users keep their persisted cache: the key must not gain fields
  // while maturity is off.
  assert.equal(
    getKnownWordCacheLifecycleConfig(disabled),
    '{"refreshMinutes":60,"scope":"all","fieldsWord":""}',
  );

  const enabled: AnkiConnectConfig = {
    knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
  };
  assert.equal(
    getKnownWordCacheLifecycleConfig(enabled),
    '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21,"maturityRules":2}',
  );

  const customThreshold: AnkiConnectConfig = {
    knownWords: {
      highlightEnabled: true,
      maturityEnabled: true,
      matureThresholdDays: 30,
      refreshMinutes: 60,
    },
  };
  assert.equal(
    getKnownWordCacheLifecycleConfig(customThreshold),
    '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30,"maturityRules":2}',
  );
});

test('a cache built under the old tier rules is invalidated', () => {
  const config: AnkiConnectConfig = {
    knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
  };
  // v1 rules put lapsed cards in young because the interval queries did not
  // exclude is:learn; those persisted tiers must not be served under v2.
  assert.notEqual(
    getKnownWordCacheLifecycleConfig(config),
    '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
  );
});

test('refresh fetches tier sets and getKnownWordTier classifies notes', async () => {
  const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());

  try {
    setTierQueries(clientState, { all: [1, 2, 3, 4], mature: [1], young: [2], learning: [3] });
    clientState.notesInfoResult = [
      { noteId: 1, fields: { Word: { value: '猫' } } },
      { noteId: 2, fields: { Word: { value: '犬' } } },
      { noteId: 3, fields: { Word: { value: '鳥' } } },
      { noteId: 4, fields: { Word: { value: '魚' } } },
    ];

    await manager.refresh(true);

    assert.equal(calls.findNotes, 4);
    assert.equal(manager.getKnownWordTier('猫'), 'mature');
    assert.equal(manager.getKnownWordTier('犬'), 'young');
    assert.equal(manager.getKnownWordTier('鳥'), 'learning');
    assert.equal(manager.getKnownWordTier('魚'), 'new');
    assert.equal(manager.getKnownWordTier('馬'), null);
    // Boolean matching still works alongside tiers.
    assert.equal(manager.isKnownWord('猫'), true);
    assert.equal(manager.isKnownWord('魚'), true);
  } finally {
    cleanup();
  }
});

test('a note with cards in several tiers counts as its most mature card', async () => {
  const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());

  try {
    setTierQueries(clientState, { all: [1], mature: [1], young: [1], learning: [1] });
    clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];

    await manager.refresh(true);

    assert.equal(manager.getKnownWordTier('猫'), 'mature');
  } finally {
    cleanup();
  }
});

test('a word matched by several notes takes the most mature note tier', async () => {
  const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());

  try {
    setTierQueries(clientState, { all: [1, 2], mature: [], young: [2], learning: [1] });
    clientState.notesInfoResult = [
      { noteId: 1, fields: { Word: { value: '猫' } } },
      { noteId: 2, fields: { Word: { value: '猫' } } },
    ];

    await manager.refresh(true);

    assert.equal(manager.getKnownWordTier('猫'), 'young');
  } finally {
    cleanup();
  }
});

test('tiers are reading-aware for words with several readings', async () => {
  const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());

  try {
    setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
    clientState.notesInfoResult = [
      { noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
      { noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
    ];

    await manager.refresh(true);

    assert.equal(manager.getKnownWordTier('床', 'とこ'), 'mature');
    assert.equal(manager.getKnownWordTier('床', 'ゆか'), 'learning');
    // No reading given: fail-open across readings, most mature wins.
    assert.equal(manager.getKnownWordTier('床'), 'mature');
    // Unknown reading for a reading-locked word: no match, no tier.
    assert.equal(manager.getKnownWordTier('床', 'しょう'), null);
  } finally {
    cleanup();
  }
});

test('reading-only fallback resolves tiers unless opted out', async () => {
  const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());

  try {
    setTierQueries(clientState, { all: [1], mature: [1], young: [], learning: [] });
    clientState.notesInfoResult = [
      { noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
    ];

    await manager.refresh(true);

    assert.equal(manager.getKnownWordTier('けいこく'), 'mature');
    assert.equal(
      manager.getKnownWordTier('けいこく', undefined, { allowReadingOnlyMatch: false }),
      null,
    );
  } finally {
    cleanup();
  }
});

test('getKnownWordTier returns null and skips tier queries when maturity is disabled', async () => {
  const config = maturityConfig();
  config.knownWords = { ...config.knownWords, maturityEnabled: false };
  const { manager, calls, clientState, cleanup } = createMaturityHarness(config);

  try {
    clientState.findNotesByQuery.set('deck:"Mining"', [1]);
    clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];

    await manager.refresh(true);

    assert.equal(calls.findNotes, 1);
    assert.equal(manager.isKnownWord('猫'), true);
    assert.equal(manager.getKnownWordTier('猫'), null);
  } finally {
    cleanup();
  }
});

test('refresh preserves known-word cache when maturity lookup fails', async () => {
  const { manager, statePath, clientState, cleanup } = createMaturityHarness(maturityConfig());
  const originalInfo = console.info;
  const infoLogs: string[] = [];
  setLogLevel('info');

  try {
    console.info = (...args: unknown[]) => {
      infoLogs.push(args.map((value) => String(value)).join(' '));
    };
    clientState.findNotesByQuery.set('deck:"Mining"', [1]);
    clientState.failedQueries.add('deck:"Mining" prop:ivl>=21 -is:learn');
    clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];

    await manager.refresh(true);

    assert.equal(manager.isKnownWord('猫'), true);
    assert.equal(manager.getKnownWordTier('猫'), null);
    const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
      version: number;
      tiers: Record<string, string>;
    };
    assert.equal(persisted.version, 4);
    assert.deepEqual(persisted.tiers, {});
    assert.match(infoLogs.join('\n'), /maturityTiers=fetch-failed/);
  } finally {
    console.info = originalInfo;
    setLogLevel(undefined);
    cleanup();
  }
});

test('tiers persist to v4 state and reload without refetching', async () => {
  const { manager, calls, statePath, clientState, createSiblingManager, cleanup } =
    createMaturityHarness(maturityConfig());
  const originalDateNow = Date.now;

  try {
    Date.now = () => 120_000;
    setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
    clientState.notesInfoResult = [
      { noteId: 1, fields: { Word: { value: '猫' } } },
      { noteId: 2, fields: { Word: { value: '犬' } } },
    ];

    await manager.refresh(true);

    const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
      version: number;
      tiers: Record<string, string>;
    };
    assert.equal(persisted.version, 4);
    assert.deepEqual(persisted.tiers, { '1': 'mature', '2': 'learning' });

    const callsBeforeReload = calls.findNotes;
    const reloaded = createSiblingManager();
    reloaded.startLifecycle();
    try {
      assert.equal(reloaded.getKnownWordTier('猫'), 'mature');
      assert.equal(reloaded.getKnownWordTier('犬'), 'learning');
      assert.equal(calls.findNotes, callsBeforeReload);
    } finally {
      reloaded.stopLifecycle();
    }
  } finally {
    Date.now = originalDateNow;
    cleanup();
  }
});

test('appendFromNoteInfo marks freshly mined notes as new tier', async () => {
  const { manager, cleanup } = createMaturityHarness(maturityConfig());

  try {
    manager.appendFromNoteInfo({
      noteId: 7,
      fields: { Word: { value: '猫' } },
    });

    assert.equal(manager.isKnownWord('猫'), true);
    assert.equal(manager.getKnownWordTier('猫'), 'new');
  } finally {
    cleanup();
  }
});

test('appendFromNoteInfo preserves an existing maturity tier', async () => {
  const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());

  try {
    setTierQueries(clientState, { all: [7, 8], mature: [7], young: [], learning: [8] });
    clientState.notesInfoResult = [
      { noteId: 7, fields: { Word: { value: '猫' } } },
      { noteId: 8, fields: { Word: { value: '犬' } } },
    ];
    await manager.refresh(true);

    manager.appendFromNoteInfo({
      noteId: 7,
      fields: { Word: { value: '子猫' } },
    });
    manager.appendFromNoteInfo({
      noteId: 8,
      fields: { Word: { value: '子犬' } },
    });

    assert.equal(manager.getKnownWordTier('子猫'), 'mature');
    assert.equal(manager.getKnownWordTier('子犬'), 'learning');
  } finally {
    cleanup();
  }
});

test('getKnownWordMatchNoteIds reports the notes behind a tier', async () => {
  const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());

  try {
    setTierQueries(clientState, { all: [1, 2, 3], mature: [1], young: [2], learning: [3] });
    clientState.notesInfoResult = [
      { noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
      { noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
      { noteId: 3, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
    ];

    await manager.refresh(true);

    // Same matching rules as getKnownWordTier, so an audit can re-derive the
    // rendered tier from the exact notes that produced it.
    assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'とこ')], [1]);
    assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'ゆか')], [2]);
    assert.deepEqual([...manager.getKnownWordMatchNoteIds('床')].sort(), [1, 2]);
    assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'しょう')], []);
    assert.deepEqual([...manager.getKnownWordMatchNoteIds('けいこく')], [3]);
    assert.deepEqual(
      [
        ...manager.getKnownWordMatchNoteIds('けいこく', undefined, {
          allowReadingOnlyMatch: false,
        }),
      ],
      [],
    );
    assert.deepEqual([...manager.getKnownWordMatchNoteIds('馬')], []);
  } finally {
    cleanup();
  }
});
