import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import type { AddressInfo } from 'node:net';
import { createStatsApp, startStatsServer } from '../stats-server.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
  clearRetimedSecondarySubtitleCache,
  resolveRetimedSecondarySubtitleTextFromSidecar,
} from '../secondary-subtitle-sidecar.js';

const SESSION_SUMMARIES = [
  {
    sessionId: 1,
    canonicalTitle: 'Test',
    videoId: 1,
    animeId: null,
    animeTitle: null,
    startedAtMs: Date.now(),
    endedAtMs: null,
    totalWatchedMs: 60_000,
    activeWatchedMs: 50_000,
    linesSeen: 10,
    tokensSeen: 80,
    cardsMined: 2,
    lookupCount: 5,
    lookupHits: 4,
    yomitanLookupCount: 5,
  },
];

const DAILY_ROLLUPS = [
  {
    rollupDayOrMonth: Math.floor(Date.now() / 86_400_000),
    videoId: 1,
    totalSessions: 1,
    totalActiveMin: 10,
    totalLinesSeen: 10,
    totalTokensSeen: 80,
    totalCards: 2,
    cardsPerHour: 12,
    tokensPerMin: 10,
    lookupHitRate: 0.8,
  },
];

const VOCABULARY_STATS = [
  {
    wordId: 1,
    headword: 'する',
    word: 'する',
    reading: 'する',
    partOfSpeech: 'verb',
    pos1: '動詞',
    pos2: '自立',
    pos3: null,
    frequency: 100,
    frequencyRank: 42,
    animeCount: 2,
    firstSeen: Date.now(),
    lastSeen: Date.now(),
  },
];

const KANJI_STATS = [
  {
    kanjiId: 1,
    kanji: '日',
    frequency: 50,
    firstSeen: Date.now(),
    lastSeen: Date.now(),
  },
];

const OCCURRENCES = [
  {
    animeId: 1,
    animeTitle: 'Little Witch Academia',
    videoId: 2,
    videoTitle: 'Episode 4',
    sourcePath: '/media/anime/lwa/ep04.mkv',
    secondaryText: null,
    sessionId: 3,
    lineIndex: 7,
    segmentStartMs: 12_000,
    segmentEndMs: 14_500,
    text: '猫 猫 日 日 は 知っている',
    occurrenceCount: 2,
  },
];

const ANIME_LIBRARY = [
  {
    animeId: 1,
    canonicalTitle: 'Little Witch Academia',
    anilistId: 21858,
    totalSessions: 3,
    totalActiveMs: 180_000,
    totalCards: 5,
    totalTokensSeen: 300,
    episodeCount: 2,
    episodesTotal: 25,
    lastWatchedMs: Date.now(),
  },
];

const ANIME_DETAIL = {
  animeId: 1,
  canonicalTitle: 'Little Witch Academia',
  anilistId: 21858,
  titleRomaji: 'Little Witch Academia',
  titleEnglish: 'Little Witch Academia',
  titleNative: 'リトルウィッチアカデミア',
  totalSessions: 3,
  totalActiveMs: 180_000,
  totalCards: 5,
  totalTokensSeen: 300,
  totalLinesSeen: 50,
  totalLookupCount: 20,
  totalLookupHits: 15,
  episodeCount: 2,
  lastWatchedMs: Date.now(),
};

const ANIME_WORDS = [
  {
    wordId: 1,
    headword: '魔法',
    word: '魔法',
    reading: 'まほう',
    partOfSpeech: 'noun',
    frequency: 42,
  },
];

const EPISODES_PER_DAY = [
  { epochDay: Math.floor(Date.now() / 86_400_000) - 1, episodeCount: 3 },
  { epochDay: Math.floor(Date.now() / 86_400_000), episodeCount: 1 },
];

const NEW_ANIME_PER_DAY = [{ epochDay: Math.floor(Date.now() / 86_400_000) - 2, newAnimeCount: 2 }];

const WATCH_TIME_PER_ANIME = [
  {
    epochDay: Math.floor(Date.now() / 86_400_000) - 1,
    animeId: 1,
    animeTitle: 'Little Witch Academia',
    totalActiveMin: 25,
  },
];

const TRENDS_DASHBOARD = {
  activity: {
    watchTime: [{ label: 'Mar 1', value: 25 }],
    cards: [{ label: 'Mar 1', value: 5 }],
    words: [{ label: 'Mar 1', value: 300 }],
    sessions: [{ label: 'Mar 1', value: 3 }],
  },
  progress: {
    watchTime: [{ label: 'Mar 1', value: 25 }],
    sessions: [{ label: 'Mar 1', value: 3 }],
    words: [{ label: 'Mar 1', value: 300 }],
    newWords: [{ label: 'Mar 1', value: 12 }],
    cards: [{ label: 'Mar 1', value: 5 }],
    episodes: [{ label: 'Mar 1', value: 2 }],
    lookups: [{ label: 'Mar 1', value: 15 }],
  },
  ratios: {
    lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
    cardsPerHour: [{ label: 'Mar 1', value: 12 }],
    readingSpeed: [{ label: 'Mar 1', value: 180 }],
  },
  librarySummary: [
    {
      title: 'Little Witch Academia',
      watchTimeMin: 25,
      videos: 1,
      sessions: 1,
      cards: 5,
      words: 300,
      lookups: 15,
      lookupsPerHundred: 5,
      firstWatched: 20_000,
      lastWatched: 20_000,
    },
  ],
  animeCumulative: {
    watchTime: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 25 }],
    episodes: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 1 }],
    cards: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 5 }],
    words: [{ epochDay: 20_000, animeTitle: 'Little Witch Academia', value: 300 }],
  },
  patterns: {
    watchTimeByDayOfWeek: [{ label: 'Sun', value: 25 }],
    watchTimeByHour: [{ label: '12:00', value: 25 }],
  },
};

const ANIME_EPISODES = [
  {
    animeId: 1,
    videoId: 1,
    canonicalTitle: 'Episode 1',
    parsedTitle: 'Little Witch Academia',
    season: 1,
    episode: 1,
    totalSessions: 1,
    totalActiveMs: 90_000,
    totalCards: 3,
    totalTokensSeen: 150,
    lastWatchedMs: Date.now(),
  },
];

const WORD_DETAIL = {
  wordId: 1,
  headword: '猫',
  word: '猫',
  reading: 'ねこ',
  partOfSpeech: 'noun',
  pos1: '名詞',
  pos2: '一般',
  pos3: null,
  frequency: 42,
  firstSeen: Date.now() - 100_000,
  lastSeen: Date.now(),
};

const WORD_ANIME_APPEARANCES = [
  { animeId: 1, animeTitle: 'Little Witch Academia', occurrenceCount: 12 },
];

const SIMILAR_WORDS = [
  { wordId: 2, headword: '猫耳', word: '猫耳', reading: 'ねこみみ', frequency: 5 },
];

const KANJI_DETAIL = {
  kanjiId: 1,
  kanji: '日',
  frequency: 50,
  firstSeen: Date.now() - 100_000,
  lastSeen: Date.now(),
};

const KANJI_ANIME_APPEARANCES = [
  { animeId: 1, animeTitle: 'Little Witch Academia', occurrenceCount: 30 },
];

const KANJI_WORDS = [
  { wordId: 3, headword: '日本', word: '日本', reading: 'にほん', frequency: 20 },
];

const EPISODE_CARD_EVENTS = [
  { eventId: 1, sessionId: 1, tsMs: Date.now(), cardsDelta: 1, noteIds: [12345] },
];

function createMockTracker(
  overrides: Partial<ImmersionTrackerService> = {},
): ImmersionTrackerService {
  return {
    getSessionSummaries: async () => SESSION_SUMMARIES,
    getDailyRollups: async () => DAILY_ROLLUPS,
    getMonthlyRollups: async () => [],
    getQueryHints: async () => ({
      totalSessions: 5,
      activeSessions: 1,
      episodesToday: 2,
      activeAnimeCount: 3,
      totalEpisodesWatched: 0,
      totalAnimeCompleted: 0,
      totalActiveMin: 120,
      totalCards: 0,
      activeDays: 7,
      totalTokensSeen: 80,
      totalLookupCount: 5,
      totalLookupHits: 4,
      totalYomitanLookupCount: 5,
      newWordsToday: 0,
      newWordsThisWeek: 0,
    }),
    getSessionTimeline: async () => [],
    getSessionEvents: async () => [],
    getVocabularyStats: async () => VOCABULARY_STATS,
    getVocabularySummary: async () => ({
      uniqueWords: 501,
      uniqueWordsWithoutNames: 500,
      uniqueKanji: 201,
      newThisWeek: 7,
      newThisWeekWithoutNames: 6,
      knownWordCount: 250,
      knownWordCountWithoutNames: 249,
    }),
    getVocabularyChartData: async () => ({
      ready: true,
      topWords: [{ wordId: 1, headword: 'する', frequency: 50 }],
      topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }],
      newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }],
      newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }],
    }),
    getStatsExcludedWords: async () => [],
    replaceStatsExcludedWords: async () => {},
    getKanjiStats: async () => KANJI_STATS,
    getWordOccurrences: async () => OCCURRENCES,
    getKanjiOccurrences: async () => OCCURRENCES,
    getAnimeLibrary: async () => ANIME_LIBRARY,
    getAnimeDetail: async (animeId: number) => (animeId === 1 ? ANIME_DETAIL : null),
    getAnimeEpisodes: async () => ANIME_EPISODES,
    getAnimeAnilistEntries: async () => [],
    getAnimeWords: async () => ANIME_WORDS,
    getAnimeDailyRollups: async () => DAILY_ROLLUPS,
    getEpisodesPerDay: async () => EPISODES_PER_DAY,
    getNewAnimePerDay: async () => NEW_ANIME_PER_DAY,
    getWatchTimePerAnime: async () => WATCH_TIME_PER_ANIME,
    getTrendsDashboard: async () => TRENDS_DASHBOARD,
    getStreakCalendar: async () => [
      { epochDay: Math.floor(Date.now() / 86_400_000) - 1, totalActiveMin: 30 },
      { epochDay: Math.floor(Date.now() / 86_400_000), totalActiveMin: 45 },
    ],
    ensureAnimeCoverArt: async () => false,
    getAnimeCoverArt: async (animeId: number) =>
      animeId === 1
        ? {
            videoId: 1,
            anilistId: 21858,
            coverUrl: 'https://example.com/cover.jpg',
            coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]),
            titleRomaji: 'Little Witch Academia',
            titleEnglish: 'Little Witch Academia',
            episodesTotal: 25,
            fetchedAtMs: Date.now(),
          }
        : null,
    getWordDetail: async (wordId: number) => (wordId === 1 ? WORD_DETAIL : null),
    getWordAnimeAppearances: async () => WORD_ANIME_APPEARANCES,
    getSimilarWords: async () => SIMILAR_WORDS,
    getKanjiDetail: async (kanjiId: number) => (kanjiId === 1 ? KANJI_DETAIL : null),
    getKanjiAnimeAppearances: async () => KANJI_ANIME_APPEARANCES,
    getKanjiWords: async () => KANJI_WORDS,
    getEpisodeWords: async () => ANIME_WORDS,
    getEpisodeSessions: async () => SESSION_SUMMARIES,
    getEpisodeCardEvents: async () => EPISODE_CARD_EVENTS,
    ...overrides,
  } as unknown as ImmersionTrackerService;
}

function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> | T {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-test-'));
  const result = fn(dir);
  if (result instanceof Promise) {
    return result.finally(() => {
      fs.rmSync(dir, { recursive: true, force: true });
    });
  }
  fs.rmSync(dir, { recursive: true, force: true });
  return result;
}

type CapturedAnkiRequest = {
  action?: string;
  params?: {
    cards?: number[];
    deck?: string;
    notes?: number[];
    query?: string;
    note?: {
      id?: number;
      deckName?: string;
      modelName?: string;
      fields?: Record<string, string>;
      tags?: string[];
    };
  };
};

async function withFakeAnkiConnect<T>(
  fn: (requests: CapturedAnkiRequest[], url: string) => Promise<T>,
  options?: { notesInfoFields?: Record<string, { value: string }> | null },
): Promise<T> {
  const requests: CapturedAnkiRequest[] = [];
  const server = http.createServer((req, res) => {
    const chunks: Buffer[] = [];
    req.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
    req.on('end', () => {
      const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as CapturedAnkiRequest;
      requests.push(payload);

      let body: unknown = { result: null, error: null };
      if (payload.action === 'addNote') {
        if (!payload.params?.note?.deckName) {
          body = { result: null, error: 'deck was not found: ' };
        } else {
          body = { result: 12345, error: null };
        }
      } else if (payload.action === 'notesInfo') {
        const noteIds = payload.params?.notes ?? [];
        body = {
          result:
            options?.notesInfoFields === null
              ? []
              : noteIds.map((noteId) => ({
                  noteId,
                  fields: options?.notesInfoFields ?? {
                    Expression: { value: '猫' },
                    ExpressionAudio: { value: '[sound:word.mp3]' },
                    Sentence: { value: '' },
                    SentenceAudio: { value: '' },
                    Picture: { value: '' },
                    MiscInfo: { value: '' },
                    SelectionText: { value: '' },
                  },
                })),
          error: null,
        };
      } else if (payload.action === 'findCards') {
        body = { result: [9001], error: null };
      } else if (payload.action === 'changeDeck') {
        body = { result: null, error: null };
      }

      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(body));
    });
  });

  await new Promise<void>((resolve) => {
    server.listen(0, '127.0.0.1', resolve);
  });

  try {
    const address = server.address() as AddressInfo;
    return await fn(requests, `http://127.0.0.1:${address.port}`);
  } finally {
    await new Promise<void>((resolve, reject) => {
      server.close((err) => (err ? reject(err) : resolve()));
    });
  }
}

describe('stats server API routes', () => {
  it('GET /api/stats/overview returns overview data', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/overview');
    assert.equal(res.status, 200);
    assert.equal(res.headers.get('access-control-allow-origin'), null);
    const body = await res.json();
    assert.ok(body.sessions);
    assert.ok(body.rollups);
    assert.ok(body.hints);
    assert.equal(body.hints.totalSessions, 5);
    assert.equal(body.hints.activeSessions, 1);
    assert.equal(body.hints.episodesToday, 2);
    assert.equal(body.hints.activeAnimeCount, 3);
    assert.equal(body.hints.totalEpisodesWatched, 0);
    assert.equal(body.hints.totalAnimeCompleted, 0);
    assert.equal(body.hints.totalActiveMin, 120);
    assert.equal(body.hints.activeDays, 7);
    assert.equal(body.hints.totalTokensSeen, 80);
    assert.equal(body.hints.totalYomitanLookupCount, 5);
  });

  it('GET /api/stats/sessions returns session list', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/sessions?limit=5');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
  });

  it('GET /api/stats/sentences/search resolves headword candidates by default', async () => {
    const seen: Array<{
      query: string;
      limit: number;
      options: unknown;
    }> = [];
    const resolvedTerms: string[] = [];
    const app = createStatsApp(
      createMockTracker({
        searchSubtitleSentences: async (query: string, limit: number, options: unknown) => {
          seen.push({ query, limit, options });
          return [];
        },
      }),
      {
        resolveSentenceSearchHeadwords: async (term: string) => {
          resolvedTerms.push(term);
          return term === '知らない' ? ['知る'] : [];
        },
      },
    );

    const res = await app.request(
      '/api/stats/sentences/search?q=%E7%9F%A5%E3%82%89%E3%81%AA%E3%81%84&limit=12',
    );

    assert.equal(res.status, 200);
    assert.deepEqual(resolvedTerms, ['知らない']);
    assert.deepEqual(seen, [
      {
        query: '知らない',
        limit: 12,
        options: { headwordTerms: [{ term: '知らない', headwords: ['知る'] }] },
      },
    ]);
  });

  it('GET /api/stats/sessions enriches known-word metrics using filtered persisted totals', async () => {
    await withTempDir(async (dir) => {
      const cachePath = path.join(dir, 'known-words.json');
      fs.writeFileSync(
        cachePath,
        JSON.stringify({
          version: 1,
          refreshedAtMs: 1,
          scope: 'deck:test',
          words: ['する'],
        }),
      );

      const app = createStatsApp(
        createMockTracker({
          getSessionWordsByLine: async (sessionId: number) =>
            sessionId === 1
              ? [
                  { lineIndex: 1, headword: 'する', occurrenceCount: 2 },
                  { lineIndex: 2, headword: '未知', occurrenceCount: 1 },
                ]
              : [],
        }),
        { knownWordCachePath: cachePath },
      );

      const res = await app.request('/api/stats/sessions?limit=5');
      assert.equal(res.status, 200);
      const body = await res.json();
      const first = body[0];
      assert.equal(first.knownWordsSeen, 2);
      assert.equal(first.knownWordRate, 66.7);
    });
  });

  it('GET /api/stats/sessions enriches known-word metrics from a v3 reading-aware cache', async () => {
    await withTempDir(async (dir) => {
      const cachePath = path.join(dir, 'known-words.json');
      fs.writeFileSync(
        cachePath,
        JSON.stringify({
          version: 3,
          refreshedAtMs: 1,
          scope: 'deck:test',
          notes: {
            '101': [{ word: 'する', reading: 'する' }],
            '102': [{ word: '猫', reading: null }],
          },
        }),
      );

      const app = createStatsApp(
        createMockTracker({
          getSessionWordsByLine: async (sessionId: number) =>
            sessionId === 1
              ? [
                  { lineIndex: 1, headword: 'する', occurrenceCount: 2 },
                  { lineIndex: 2, headword: '未知', occurrenceCount: 1 },
                ]
              : [],
        }),
        { knownWordCachePath: cachePath },
      );

      const res = await app.request('/api/stats/sessions?limit=5');
      assert.equal(res.status, 200);
      const body = await res.json();
      const first = body[0];
      assert.equal(first.knownWordsSeen, 2);
      assert.equal(first.knownWordRate, 66.7);
    });
  });

  it('GET /api/stats/sessions enriches known-word metrics from a v4 maturity cache', async () => {
    await withTempDir(async (dir) => {
      const cachePath = path.join(dir, 'known-words.json');
      fs.writeFileSync(
        cachePath,
        JSON.stringify({
          version: 4,
          refreshedAtMs: 1,
          scope: 'deck:test',
          notes: {
            '101': [{ word: 'する', reading: 'する' }],
            '102': [{ word: '猫', reading: null }],
          },
          tiers: { '101': 'mature', '102': 'young' },
        }),
      );

      const app = createStatsApp(
        createMockTracker({
          getSessionWordsByLine: async (sessionId: number) =>
            sessionId === 1
              ? [
                  { lineIndex: 1, headword: 'する', occurrenceCount: 2 },
                  { lineIndex: 2, headword: '未知', occurrenceCount: 1 },
                ]
              : [],
        }),
        { knownWordCachePath: cachePath },
      );

      const res = await app.request('/api/stats/sessions?limit=5');
      assert.equal(res.status, 200);
      const body = await res.json();
      const first = body[0];
      assert.equal(first.knownWordsSeen, 2);
      assert.equal(first.knownWordRate, 66.7);
    });
  });

  it('GET /api/stats/sessions reports no known words when the cache format is unrecognized', async () => {
    await withTempDir(async (dir) => {
      const cachePath = path.join(dir, 'known-words.json');
      fs.writeFileSync(
        cachePath,
        JSON.stringify({ version: 99, refreshedAtMs: 1, scope: 'deck:test', notes: {} }),
      );

      const app = createStatsApp(
        createMockTracker({
          getSessionWordsByLine: async () => [
            { lineIndex: 1, headword: 'する', occurrenceCount: 2 },
          ],
        }),
        { knownWordCachePath: cachePath },
      );

      const res = await app.request('/api/stats/sessions?limit=5');
      assert.equal(res.status, 200);
      const body = await res.json();
      assert.equal(body[0].knownWordsSeen, 0);
    });
  });

  it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
    let seenSessionId = 0;
    let seenLimit = 0;
    let seenTypes: number[] | undefined;
    const app = createStatsApp(
      createMockTracker({
        getSessionEvents: async (sessionId: number, limit?: number, eventTypes?: number[]) => {
          seenSessionId = sessionId;
          seenLimit = limit ?? 0;
          seenTypes = eventTypes;
          return [];
        },
      }),
    );

    const res = await app.request('/api/stats/sessions/7/events?limit=12&types=4,5,9');
    assert.equal(res.status, 200);
    assert.equal(seenSessionId, 7);
    assert.equal(seenLimit, 12);
    assert.deepEqual(seenTypes, [4, 5, 9]);
  });

  it('GET /api/stats/sessions/:id/timeline requests the full session when no limit is provided', async () => {
    let seenSessionId = 0;
    let seenLimit: number | undefined;
    const app = createStatsApp(
      createMockTracker({
        getSessionTimeline: async (sessionId: number, limit?: number) => {
          seenSessionId = sessionId;
          seenLimit = limit;
          return [];
        },
      }),
    );

    const res = await app.request('/api/stats/sessions/7/timeline');
    assert.equal(res.status, 200);
    assert.equal(seenSessionId, 7);
    assert.equal(seenLimit, undefined);
  });

  it('GET /api/stats/sessions/:id/known-words-timeline preserves line positions and counts filtered totals', async () => {
    await withTempDir(async (dir) => {
      const cachePath = path.join(dir, 'known-words.json');
      fs.writeFileSync(
        cachePath,
        JSON.stringify({
          version: 1,
          refreshedAtMs: 1,
          scope: 'deck:test',
          words: ['知る', '猫'],
        }),
      );

      const app = createStatsApp(
        createMockTracker({
          getSessionWordsByLine: async () => [
            { lineIndex: 1, headword: '知る', occurrenceCount: 2 },
            { lineIndex: 3, headword: '猫', occurrenceCount: 1 },
            { lineIndex: 3, headword: '見る', occurrenceCount: 4 },
          ],
        }),
        { knownWordCachePath: cachePath },
      );

      const res = await app.request('/api/stats/sessions/1/known-words-timeline');
      assert.equal(res.status, 200);
      assert.deepEqual(await res.json(), [
        { linesSeen: 0, knownWordsSeen: 0, totalWordsSeen: 0 },
        { linesSeen: 1, knownWordsSeen: 2, totalWordsSeen: 2 },
        { linesSeen: 2, knownWordsSeen: 2, totalWordsSeen: 2 },
        { linesSeen: 3, knownWordsSeen: 3, totalWordsSeen: 7 },
      ]);
    });
  });

  it('GET /api/stats/vocabulary returns word frequency data', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/vocabulary');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body[0].headword, 'する');
  });

  it('GET /api/stats/vocabulary/summary returns database-wide card totals', async () => {
    const app = createStatsApp(createMockTracker());

    const res = await app.request('/api/stats/vocabulary/summary');

    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), {
      uniqueWords: 501,
      uniqueWordsWithoutNames: 500,
      uniqueKanji: 201,
      newThisWeek: 7,
      newThisWeekWithoutNames: 6,
      knownWordCount: 250,
      knownWordCountWithoutNames: 249,
    });
  });

  it('GET /api/stats/vocabulary/charts returns complete chart datasets', async () => {
    const app = createStatsApp(createMockTracker());

    const res = await app.request('/api/stats/vocabulary/charts');

    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), {
      ready: true,
      topWords: [{ wordId: 1, headword: 'する', frequency: 50 }],
      topWordsWithoutNames: [{ wordId: 1, headword: 'する', frequency: 50 }],
      newWordsTimeline: [{ epochDay: 20_000, wordCount: 3 }],
      newWordsTimelineWithoutNames: [{ epochDay: 20_000, wordCount: 3 }],
    });
  });

  it('GET /api/stats/kanji returns kanji frequency data', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/kanji');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body[0].kanji, '日');
  });

  it('GET /api/stats/streak-calendar returns streak calendar rows', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/streak-calendar');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body.length, 2);
    assert.equal(body[0].totalActiveMin, 30);
    assert.equal(body[1].totalActiveMin, 45);
  });

  it('GET /api/stats/streak-calendar clamps oversized days', async () => {
    let seenDays = 0;
    const app = createStatsApp(
      createMockTracker({
        getStreakCalendar: async (days?: number) => {
          seenDays = days ?? 0;
          return [];
        },
      }),
    );

    const res = await app.request('/api/stats/streak-calendar?days=999999');
    assert.equal(res.status, 200);
    assert.equal(seenDays, 365);
  });

  it('GET /api/stats/trends/episodes-per-day returns episode count rows', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/trends/episodes-per-day');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body.length, 2);
    assert.equal(body[0].episodeCount, 3);
  });

  it('GET /api/stats/trends/episodes-per-day clamps oversized limits', async () => {
    let seenLimit = 0;
    const app = createStatsApp(
      createMockTracker({
        getEpisodesPerDay: async (limit?: number) => {
          seenLimit = limit ?? 0;
          return EPISODES_PER_DAY;
        },
      }),
    );
    const res = await app.request('/api/stats/trends/episodes-per-day?limit=999999');
    assert.equal(res.status, 200);
    assert.equal(seenLimit, 365);
  });

  it('GET /api/stats/trends/new-anime-per-day returns new anime rows', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/trends/new-anime-per-day');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body.length, 1);
    assert.equal(body[0].newAnimeCount, 2);
  });

  it('GET /api/stats/trends/new-anime-per-day clamps oversized limits', async () => {
    let seenLimit = 0;
    const app = createStatsApp(
      createMockTracker({
        getNewAnimePerDay: async (limit?: number) => {
          seenLimit = limit ?? 0;
          return NEW_ANIME_PER_DAY;
        },
      }),
    );
    const res = await app.request('/api/stats/trends/new-anime-per-day?limit=999999');
    assert.equal(res.status, 200);
    assert.equal(seenLimit, 365);
  });

  it('GET /api/stats/trends/watch-time-per-anime returns watch time rows', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/trends/watch-time-per-anime');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body.length, 1);
    assert.equal(body[0].animeTitle, 'Little Witch Academia');
    assert.equal(body[0].totalActiveMin, 25);
  });

  it('GET /api/stats/trends/watch-time-per-anime clamps oversized limits', async () => {
    let seenLimit = 0;
    const app = createStatsApp(
      createMockTracker({
        getWatchTimePerAnime: async (limit?: number) => {
          seenLimit = limit ?? 0;
          return WATCH_TIME_PER_ANIME;
        },
      }),
    );
    const res = await app.request('/api/stats/trends/watch-time-per-anime?limit=999999');
    assert.equal(res.status, 200);
    assert.equal(seenLimit, 365);
  });

  it('GET /api/stats/trends/dashboard returns chart-ready trends data', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getTrendsDashboard: async (...args: unknown[]) => {
          seenArgs = args;
          return TRENDS_DASHBOARD;
        },
      }),
    );

    const res = await app.request('/api/stats/trends/dashboard?range=90d&groupBy=month');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.deepEqual(seenArgs, ['90d', 'month', true]);
    assert.deepEqual(body.activity.watchTime, TRENDS_DASHBOARD.activity.watchTime);
    assert.deepEqual(body.librarySummary, TRENDS_DASHBOARD.librarySummary);
  });

  it('GET /api/stats/trends/dashboard accepts 365d range', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getTrendsDashboard: async (...args: unknown[]) => {
          seenArgs = args;
          return TRENDS_DASHBOARD;
        },
      }),
    );

    const res = await app.request('/api/stats/trends/dashboard?range=365d&groupBy=month');
    assert.equal(res.status, 200);
    assert.deepEqual(seenArgs, ['365d', 'month', true]);
  });

  it('GET /api/stats/trends/dashboard falls back to safe defaults for invalid params', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getTrendsDashboard: async (...args: unknown[]) => {
          seenArgs = args;
          return TRENDS_DASHBOARD;
        },
      }),
    );

    const res = await app.request('/api/stats/trends/dashboard?range=weird&groupBy=year');
    assert.equal(res.status, 200);
    assert.deepEqual(seenArgs, ['30d', 'day', true]);
  });

  it('GET /api/stats/trends/dashboard forwards fillEmpty=false to disable zero-fill', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getTrendsDashboard: async (...args: unknown[]) => {
          seenArgs = args;
          return TRENDS_DASHBOARD;
        },
      }),
    );

    const res = await app.request(
      '/api/stats/trends/dashboard?range=30d&groupBy=day&fillEmpty=false',
    );
    assert.equal(res.status, 200);
    assert.deepEqual(seenArgs, ['30d', 'day', false]);
  });

  it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getWordOccurrences: async (...args: unknown[]) => {
          seenArgs = args;
          return OCCURRENCES;
        },
      }),
    );

    const res = await app.request(
      '/api/stats/vocabulary/occurrences?headword=%E7%8C%AB&word=%E7%8C%AB&reading=%E3%81%AD%E3%81%93&limit=999999&offset=25',
    );
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body[0].animeTitle, 'Little Witch Academia');
    assert.deepEqual(seenArgs, ['猫', '猫', 'ねこ', 500, 25]);
  });

  it('GET /api/stats/kanji/occurrences returns recent occurrence rows for a kanji', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getKanjiOccurrences: async (...args: unknown[]) => {
          seenArgs = args;
          return OCCURRENCES;
        },
      }),
    );

    const res = await app.request(
      '/api/stats/kanji/occurrences?kanji=%E6%97%A5&limit=999999&offset=10',
    );
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body[0].occurrenceCount, 2);
    assert.deepEqual(seenArgs, ['日', 500, 10]);
  });

  it('GET /api/stats/vocabulary/occurrences rejects missing required params', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/vocabulary/occurrences?headword=%E7%8C%AB');
    assert.equal(res.status, 400);
  });

  it('GET /api/stats/vocabulary clamps oversized limits', async () => {
    let seenLimit = 0;
    const app = createStatsApp(
      createMockTracker({
        getVocabularyStats: async (limit?: number, _excludePos?: string[]) => {
          seenLimit = limit ?? 0;
          return VOCABULARY_STATS;
        },
      }),
    );

    const res = await app.request('/api/stats/vocabulary?limit=999999');
    assert.equal(res.status, 200);
    assert.equal(seenLimit, 500);
  });

  it('GET /api/stats/vocabulary passes excludePos to tracker', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getVocabularyStats: async (...args: unknown[]) => {
          seenArgs = args;
          return VOCABULARY_STATS;
        },
      }),
    );

    const res = await app.request(
      '/api/stats/vocabulary?excludePos=particle,%20auxiliary,%20,%20noun%20',
    );
    assert.equal(res.status, 200);
    assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary', 'noun']]);
  });

  it('GET /api/stats/vocabulary returns POS fields', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/vocabulary');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body[0].partOfSpeech, 'verb');
    assert.equal(body[0].pos1, '動詞');
    assert.equal(body[0].pos2, '自立');
    assert.equal(body[0].pos3, null);
  });

  it('GET /api/stats/excluded-words returns tracker exclusion rows', async () => {
    const app = createStatsApp(
      createMockTracker({
        getStatsExcludedWords: async () => [
          { headword: '猫', word: '猫', reading: 'ねこ' },
          { headword: 'する', word: 'する', reading: 'する' },
        ],
      }),
    );

    const res = await app.request('/api/stats/excluded-words');
    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), [
      { headword: '猫', word: '猫', reading: 'ねこ' },
      { headword: 'する', word: 'する', reading: 'する' },
    ]);
  });

  it('PUT /api/stats/excluded-words replaces tracker exclusion rows', async () => {
    let seenWords: unknown = null;
    const app = createStatsApp(
      createMockTracker({
        replaceStatsExcludedWords: async (words: unknown) => {
          seenWords = words;
        },
      }),
    );

    const res = await app.request('/api/stats/excluded-words', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        words: [
          { headword: '猫', word: '猫', reading: 'ねこ' },
          { headword: 'する', word: 'する', reading: 'する' },
        ],
      }),
    });

    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), { ok: true });
    assert.deepEqual(seenWords, [
      { headword: '猫', word: '猫', reading: 'ねこ' },
      { headword: 'する', word: 'する', reading: 'する' },
    ]);
  });

  it('POST /api/stats/maintenance/duplicate-lines forwards the window and dry-run flag', async () => {
    let seenOptions: unknown = null;
    const summary = {
      dryRun: true,
      lookbackDays: 30,
      scannedLines: 900,
      burstGroups: 2,
      removedLines: 180,
      removedWordOccurrences: 540,
      removedKanjiOccurrences: 120,
      samples: [],
    };
    const app = createStatsApp(
      createMockTracker({
        cleanupDuplicateSubtitleLines: async (options: unknown) => {
          seenOptions = options;
          return summary;
        },
      }),
    );

    const res = await app.request('/api/stats/maintenance/duplicate-lines', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ dryRun: true, lookbackDays: 30 }),
    });

    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), summary);
    assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 30 });
  });

  it('POST /api/stats/maintenance/duplicate-lines rejects cross-origin simple requests', async () => {
    let cleanupCalls = 0;
    const app = createStatsApp(
      createMockTracker({
        cleanupDuplicateSubtitleLines: async () => {
          cleanupCalls += 1;
          throw new Error('cleanup must not run');
        },
      }),
    );

    const res = await app.request('/api/stats/maintenance/duplicate-lines', {
      method: 'POST',
      headers: {
        'Content-Type': 'text/plain',
        Origin: 'https://attacker.example',
      },
      body: JSON.stringify({ dryRun: false, lookbackDays: null }),
    });

    assert.equal(res.status, 415);
    assert.equal(cleanupCalls, 0);
  });

  it('POST /api/stats/maintenance/duplicate-lines rejects a window shorter than a day', async () => {
    let cleanupCalls = 0;
    const app = createStatsApp(
      createMockTracker({
        cleanupDuplicateSubtitleLines: async () => {
          cleanupCalls += 1;
          return {
            dryRun: true,
            lookbackDays: null,
            scannedLines: 0,
            burstGroups: 0,
            removedLines: 0,
            removedWordOccurrences: 0,
            removedKanjiOccurrences: 0,
            samples: [],
          };
        },
      }),
    );

    const res = await app.request('/api/stats/maintenance/duplicate-lines', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ dryRun: true, lookbackDays: 0.5 }),
    });

    assert.equal(res.status, 400);
    assert.equal(cleanupCalls, 0);
  });

  it('POST /api/stats/maintenance/duplicate-lines floors a fractional multi-day window', async () => {
    let seenOptions: unknown = null;
    const app = createStatsApp(
      createMockTracker({
        cleanupDuplicateSubtitleLines: async (options: unknown) => {
          seenOptions = options;
          return {
            dryRun: true,
            lookbackDays: 1,
            scannedLines: 0,
            burstGroups: 0,
            removedLines: 0,
            removedWordOccurrences: 0,
            removedKanjiOccurrences: 0,
            samples: [],
          };
        },
      }),
    );

    const res = await app.request('/api/stats/maintenance/duplicate-lines', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ dryRun: true, lookbackDays: 1.5 }),
    });

    assert.equal(res.status, 200);
    assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 1 });
  });

  it('POST /api/stats/maintenance/duplicate-lines accepts an explicit empty object for all history', async () => {
    let seenOptions: unknown = null;
    const app = createStatsApp(
      createMockTracker({
        cleanupDuplicateSubtitleLines: async (options: unknown) => {
          seenOptions = options;
          return {
            dryRun: false,
            lookbackDays: null,
            scannedLines: 0,
            burstGroups: 0,
            removedLines: 0,
            removedWordOccurrences: 0,
            removedKanjiOccurrences: 0,
            samples: [],
          };
        },
      }),
    );

    const res = await app.request('/api/stats/maintenance/duplicate-lines', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: '{}',
    });

    assert.equal(res.status, 200);
    assert.deepEqual(seenOptions, { dryRun: false, lookbackDays: null });
  });

  for (const malformed of [
    { name: 'a missing body', body: undefined },
    { name: 'malformed JSON', body: '{' },
    { name: 'JSON null', body: 'null' },
    { name: 'a JSON array', body: '[]' },
  ]) {
    it(`POST /api/stats/maintenance/duplicate-lines rejects ${malformed.name}`, async () => {
      let cleanupCalls = 0;
      const app = createStatsApp(
        createMockTracker({
          cleanupDuplicateSubtitleLines: async () => {
            cleanupCalls += 1;
            throw new Error('cleanup must not run');
          },
        }),
      );

      const res = await app.request('/api/stats/maintenance/duplicate-lines', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: malformed.body,
      });

      assert.equal(res.status, 400);
      assert.equal(cleanupCalls, 0);
    });
  }

  it('PUT /api/stats/excluded-words rejects malformed rows', async () => {
    const app = createStatsApp(createMockTracker());

    const res = await app.request('/api/stats/excluded-words', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ words: [{ headword: '猫', word: 7, reading: 'ねこ' }] }),
    });

    assert.equal(res.status, 400);
  });

  it('GET /api/stats/anime returns anime library', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/anime');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body[0].canonicalTitle, 'Little Witch Academia');
  });

  it('GET /api/stats/anime/merge-recommendations returns pending duplicate pairs', async () => {
    const app = createStatsApp(
      createMockTracker({
        getAnimeMergeRecommendations: async () => [{ recommendationId: 4, animeIds: [1, 2] }],
      } as Partial<ImmersionTrackerService>),
    );

    const res = await app.request('/api/stats/anime/merge-recommendations');

    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), {
      recommendations: [{ recommendationId: 4, animeIds: [1, 2] }],
    });
  });

  it('DELETE /api/stats/anime/merge-recommendations/:id dismisses a pending pair', async () => {
    let dismissedId: number | null = null;
    const app = createStatsApp(
      createMockTracker({
        dismissAnimeMergeRecommendation: async (recommendationId: number) => {
          dismissedId = recommendationId;
          return true;
        },
      } as Partial<ImmersionTrackerService>),
    );

    const res = await app.request('/api/stats/anime/merge-recommendations/4', {
      method: 'DELETE',
    });

    assert.equal(res.status, 200);
    assert.equal(dismissedId, 4);
    assert.deepEqual(await res.json(), { ok: true });
  });

  it('DELETE /api/stats/anime/merge-recommendations/:id reports missing recommendations', async () => {
    const app = createStatsApp(
      createMockTracker({
        dismissAnimeMergeRecommendation: async () => false,
      } as Partial<ImmersionTrackerService>),
    );

    const res = await app.request('/api/stats/anime/merge-recommendations/99', {
      method: 'DELETE',
    });

    assert.equal(res.status, 404);
  });

  it('GET /api/stats/anime/:animeId returns anime detail with episodes', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/anime/1');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(body.detail);
    assert.equal(body.detail.canonicalTitle, 'Little Witch Academia');
    assert.ok(Array.isArray(body.episodes));
    assert.equal(body.episodes[0].videoId, 1);
  });

  it('GET /api/stats/anime/:animeId returns 404 for missing anime', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/anime/99999');
    assert.equal(res.status, 404);
  });

  it('PATCH /api/stats/anime/:animeId/anilist accepts only positive integer AniList ids', async () => {
    const assignments: Array<{ animeId: number; body: unknown }> = [];
    const app = createStatsApp(
      createMockTracker({
        reassignAnimeAnilist: async (animeId: number, body: unknown) => {
          assignments.push({ animeId, body });
        },
      }),
    );

    for (const anilistId of [-1, 0, 1.5, '12', true, undefined]) {
      const res = await app.request('/api/stats/anime/1/anilist', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ anilistId }),
      });
      assert.equal(res.status, 400, `accepted invalid AniList id: ${String(anilistId)}`);
    }
    assert.deepEqual(assignments, []);

    const body = { anilistId: 21_802, titleRomaji: 'Little Witch Academia' };
    const res = await app.request('/api/stats/anime/1/anilist', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    assert.equal(res.status, 200);
    assert.deepEqual(assignments, [{ animeId: 1, body }]);
  });

  it('GET /api/stats/anime/:animeId/cover returns revalidated cover art', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/anime/1/cover');
    assert.equal(res.status, 200);
    assert.equal(res.headers.get('content-type'), 'image/jpeg');
    assert.equal(res.headers.get('cache-control'), 'no-cache');
    assert.ok(res.headers.get('etag'));
  });

  it('GET /api/stats/anime/:animeId/cover answers 304 for a matching ETag', async () => {
    const app = createStatsApp(createMockTracker());
    const first = await app.request('/api/stats/anime/1/cover');
    const etag = first.headers.get('etag');
    assert.ok(etag);

    const cached = await app.request('/api/stats/anime/1/cover', {
      headers: { 'If-None-Match': `W/${etag}, "other"` },
    });
    assert.equal(cached.status, 304);
    assert.equal(cached.headers.get('etag'), etag);
  });

  it('GET /api/stats/anime/:animeId/cover resends art when the ETag no longer matches', async () => {
    // A relinked AniList entry swaps the bytes behind the same cover URL.
    let coverBlob = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
    const app = createStatsApp(
      createMockTracker({
        getAnimeCoverArt: async () => ({
          videoId: 1,
          anilistId: 21858,
          coverUrl: 'https://example.com/cover.jpg',
          coverBlob,
          titleRomaji: 'Little Witch Academia',
          titleEnglish: 'Little Witch Academia',
          episodesTotal: 25,
          fetchedAtMs: Date.now(),
        }),
      }),
    );
    const first = await app.request('/api/stats/anime/1/cover');
    const staleEtag = first.headers.get('etag');
    assert.ok(staleEtag);

    coverBlob = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
    const res = await app.request('/api/stats/anime/1/cover', {
      headers: { 'If-None-Match': staleEtag },
    });
    assert.equal(res.status, 200);
    assert.equal(res.headers.get('content-type'), 'image/png');
    assert.notEqual(res.headers.get('etag'), staleEtag);
  });

  it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => {
    const app = createStatsApp(
      createMockTracker({
        getAnimeCoverArt: async () => ({
          videoId: 1,
          anilistId: 21858,
          coverUrl: 'https://example.com/cover.png',
          coverBlob: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
          titleRomaji: 'Little Witch Academia',
          titleEnglish: 'Little Witch Academia',
          episodesTotal: 25,
          fetchedAtMs: Date.now(),
        }),
      }),
    );
    const res = await app.request('/api/stats/anime/1/cover');
    assert.equal(res.status, 200);
    assert.equal(res.headers.get('content-type'), 'image/png');
  });

  it('GET /api/stats/anime/:animeId/cover returns 404 for missing anime', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/anime/99999/cover');
    assert.equal(res.status, 404);
  });

  it('POST /api/stats/covers batches stored cover art and backfills missing anime art in the background', async () => {
    let ensureCoverArtCalls = 0;
    const ensureAnimeCoverArtCalls: number[] = [];
    const app = createStatsApp(
      createMockTracker({
        getCoverArt: async (videoId: number) =>
          videoId === 7
            ? {
                videoId,
                anilistId: null,
                coverUrl: null,
                coverBlob: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
                titleRomaji: null,
                titleEnglish: null,
                episodesTotal: null,
                fetchedAtMs: Date.now(),
              }
            : null,
        ensureCoverArt: async () => {
          ensureCoverArtCalls += 1;
          return true;
        },
        ensureAnimeCoverArt: async (animeId: number) => {
          ensureAnimeCoverArtCalls.push(animeId);
          return false;
        },
      }),
    );

    const res = await app.request('/api/stats/covers', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ animeIds: [1, 99999], videoIds: [7, 99999] }),
    });

    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), {
      anime: {
        1: {
          contentType: 'image/jpeg',
          dataUrl: 'data:image/jpeg;base64,/9j/2Q==',
        },
        99999: null,
      },
      media: {
        7: {
          contentType: 'image/png',
          dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
        },
        99999: null,
      },
    });
    assert.equal(ensureCoverArtCalls, 0);
    assert.deepEqual(ensureAnimeCoverArtCalls, [99999]);
  });

  it('POST /api/stats/covers limits concurrent missing anime cover backfills', async () => {
    let activeBackfills = 0;
    let maxActiveBackfills = 0;
    const pendingBackfills: Array<() => void> = [];
    const app = createStatsApp(
      createMockTracker({
        getAnimeCoverArt: async () => null,
        ensureAnimeCoverArt: async () => {
          activeBackfills += 1;
          maxActiveBackfills = Math.max(maxActiveBackfills, activeBackfills);
          await new Promise<void>((resolve) => {
            pendingBackfills.push(resolve);
          });
          activeBackfills -= 1;
          return false;
        },
      }),
    );

    const res = await app.request('/api/stats/covers', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ animeIds: [101, 102, 103, 104, 105] }),
    });

    assert.equal(res.status, 200);
    assert.equal(maxActiveBackfills, 3);
    for (const resolveBackfill of pendingBackfills) {
      resolveBackfill();
    }
  });

  it('GET /api/stats/anime/:animeId/cover fetches missing art before serving', async () => {
    let fetched = false;
    const app = createStatsApp(
      createMockTracker({
        getAnimeCoverArt: async () =>
          fetched
            ? {
                videoId: 1,
                anilistId: 21858,
                coverUrl: 'https://example.com/cover.jpg',
                coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]),
                titleRomaji: 'Little Witch Academia',
                titleEnglish: 'Little Witch Academia',
                episodesTotal: 25,
                fetchedAtMs: Date.now(),
              }
            : null,
        ensureAnimeCoverArt: async () => {
          fetched = true;
          return true;
        },
      }),
    );

    const res = await app.request('/api/stats/anime/1/cover');
    assert.equal(res.status, 200);
    assert.equal(res.headers.get('content-type'), 'image/jpeg');
  });

  it('GET /api/stats/anime/:animeId/words returns top words for an anime', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getAnimeWords: async (...args: unknown[]) => {
          seenArgs = args;
          return ANIME_WORDS;
        },
      }),
    );

    const res = await app.request('/api/stats/anime/1/words?limit=25');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body[0].headword, '魔法');
    assert.deepEqual(seenArgs, [1, 25]);
  });

  it('GET /api/stats/anime/:animeId/words rejects invalid animeId', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/anime/0/words');
    assert.equal(res.status, 400);
  });

  it('GET /api/stats/anime/:animeId/words clamps oversized limits', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getAnimeWords: async (...args: unknown[]) => {
          seenArgs = args;
          return ANIME_WORDS;
        },
      }),
    );

    const res = await app.request('/api/stats/anime/1/words?limit=999999');
    assert.equal(res.status, 200);
    assert.deepEqual(seenArgs, [1, 200]);
  });

  it('GET /api/stats/anime/:animeId/rollups returns daily rollups for an anime', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getAnimeDailyRollups: async (...args: unknown[]) => {
          seenArgs = args;
          return DAILY_ROLLUPS;
        },
      }),
    );

    const res = await app.request('/api/stats/anime/1/rollups?limit=30');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
    assert.equal(body[0].totalSessions, 1);
    assert.deepEqual(seenArgs, [1, 30]);
  });

  it('GET /api/stats/anime/:animeId/rollups rejects invalid animeId', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/anime/-1/rollups');
    assert.equal(res.status, 400);
  });

  it('GET /api/stats/anime/:animeId/rollups clamps oversized limits', async () => {
    let seenArgs: unknown[] = [];
    const app = createStatsApp(
      createMockTracker({
        getAnimeDailyRollups: async (...args: unknown[]) => {
          seenArgs = args;
          return DAILY_ROLLUPS;
        },
      }),
    );

    const res = await app.request('/api/stats/anime/1/rollups?limit=999999');
    assert.equal(res.status, 200);
    assert.deepEqual(seenArgs, [1, 365]);
  });

  it('GET /api/stats/vocabulary/:wordId/detail returns word detail', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/vocabulary/1/detail');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(body.detail);
    assert.equal(body.detail.headword, '猫');
    assert.equal(body.detail.wordId, 1);
    assert.ok(Array.isArray(body.animeAppearances));
    assert.equal(body.animeAppearances[0].animeTitle, 'Little Witch Academia');
    assert.ok(Array.isArray(body.similarWords));
    assert.equal(body.similarWords[0].headword, '猫耳');
  });

  it('GET /api/stats/vocabulary/:wordId/detail returns 404 for missing word', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/vocabulary/99999/detail');
    assert.equal(res.status, 404);
  });

  it('GET /api/stats/vocabulary/:wordId/detail returns 400 for invalid wordId', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/vocabulary/0/detail');
    assert.equal(res.status, 400);
  });

  it('GET /api/stats/kanji/:kanjiId/detail returns kanji detail', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/kanji/1/detail');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(body.detail);
    assert.equal(body.detail.kanji, '日');
    assert.equal(body.detail.kanjiId, 1);
    assert.ok(Array.isArray(body.animeAppearances));
    assert.equal(body.animeAppearances[0].animeTitle, 'Little Witch Academia');
    assert.ok(Array.isArray(body.words));
    assert.equal(body.words[0].headword, '日本');
  });

  it('GET /api/stats/kanji/:kanjiId/detail returns 404 for missing kanji', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/kanji/99999/detail');
    assert.equal(res.status, 404);
  });

  it('GET /api/stats/kanji/:kanjiId/detail returns 400 for invalid kanjiId', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/kanji/0/detail');
    assert.equal(res.status, 400);
  });

  it('GET /api/stats/vocabulary/occurrences still works with detail routes present', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request(
      '/api/stats/vocabulary/occurrences?headword=%E7%8C%AB&word=%E7%8C%AB&reading=%E3%81%AD%E3%81%93',
    );
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
  });

  it('GET /api/stats/kanji/occurrences still works with detail routes present', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/kanji/occurrences?kanji=%E6%97%A5');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body));
  });

  it('POST /api/stats/mine-card rejects non-positive source timing before media generation', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');
      let generatedAudio = false;

      const app = createStatsApp(createMockTracker(), {
        createMediaGenerator: () => ({
          generateAudio: async () => {
            generatedAudio = true;
            return Buffer.from('audio');
          },
          generateScreenshot: async () => Buffer.from('image'),
          generateAnimatedImage: async () => null,
        }),
        ankiConnectConfig: {
          deck: 'Mining',
          media: {
            generateAudio: true,
            generateImage: true,
          },
        },
      });

      const res = await app.request('/api/stats/mine-card?mode=sentence', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          sourcePath,
          startMs: 953_991,
          endMs: 953_891,
          sentence: '猫を見た',
          word: '猫',
          videoTitle: 'Episode 1',
        }),
      });

      const body = await res.json();
      assert.equal(res.status, 400, JSON.stringify(body));
      assert.deepEqual(body, { error: 'endMs must be greater than startMs' });
      assert.equal(generatedAudio, false);
    });
  });

  it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');
      let yomitanCalls = 0;
      let mediaCalls = 0;
      const app = createStatsApp(createMockTracker(), {
        addYomitanNote: async () => {
          yomitanCalls += 1;
          return 777;
        },
        createMediaGenerator: () => ({
          generateAudio: async () => {
            mediaCalls += 1;
            return Buffer.from('audio');
          },
          generateScreenshot: async () => {
            mediaCalls += 1;
            return Buffer.from('image');
          },
          generateAnimatedImage: async () => null,
        }),
        ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
      });

      const res = await app.request('/api/stats/mine-card?mode=word', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          sourcePath,
          startMs: 1_000,
          endMs: 2_000,
          sentence: '猫を見た',
          word: '   ',
        }),
      });

      assert.equal(res.status, 400);
      assert.equal(yomitanCalls, 0);
      assert.equal(mediaCalls, 0);
    });
  });

  it('POST /api/stats/mine-card does not start word media without a Yomitan bridge', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');
      let mediaCalls = 0;
      const app = createStatsApp(createMockTracker(), {
        createMediaGenerator: () => ({
          generateAudio: async () => {
            mediaCalls += 1;
            return Buffer.from('audio');
          },
          generateScreenshot: async () => {
            mediaCalls += 1;
            return Buffer.from('image');
          },
          generateAnimatedImage: async () => null,
        }),
        ankiConnectConfig: { media: { generateAudio: true, generateImage: true } },
      });

      const res = await app.request('/api/stats/mine-card?mode=word', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          sourcePath,
          startMs: 1_000,
          endMs: 2_000,
          sentence: '猫を見た',
          word: '猫',
        }),
      });

      assert.equal(res.status, 500);
      assert.equal(mediaCalls, 0);
    });
  });

  it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          ankiConnectConfig: {
            url,
            deck: '',
            tags: ['SubMiner'],
            fields: {
              word: 'Expression',
              audio: 'ExpressionAudio',
              image: 'Picture',
              sentence: 'Sentence',
              miscInfo: 'MiscInfo',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
            isLapis: {
              enabled: true,
              sentenceCardModel: 'Lapis Morph',
            },
            isKiku: {
              enabled: true,
              fieldGrouping: 'manual',
              deleteDuplicateInAuto: true,
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=sentence', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            secondaryText: 'I saw a cat',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));
        assert.equal(body.noteId, 12345);

        const addNoteRequest = requests.find((request) => request.action === 'addNote');
        assert.equal(addNoteRequest?.params?.note?.deckName, 'Default');
        assert.equal(addNoteRequest?.params?.note?.modelName, 'Lapis Morph');
        assert.equal(addNoteRequest?.params?.note?.fields?.Sentence, '猫を見た');
        assert.equal(addNoteRequest?.params?.note?.fields?.IsSentenceCard, 'x');
      });
    });
  });

  it('POST /api/stats/mine-card uses Yomitan deck for direct sentence cards when config deck is empty', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          getYomitanAnkiDeckName: async () => 'Minecraft',
          ankiConnectConfig: {
            url,
            deck: '',
            tags: ['SubMiner'],
            fields: {
              word: 'Expression',
              sentence: 'Sentence',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
            isLapis: {
              enabled: true,
              sentenceCardModel: 'Lapis Morph',
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=sentence', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            secondaryText: 'I saw a cat',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));
        const addNoteRequest = requests.find((request) => request.action === 'addNote');
        assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft');
      });
    });
  });

  it('POST /api/stats/mine-card resolves Anki config at request time', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          getAnkiConnectConfig: () => ({
            url,
            deck: 'Mining',
            tags: ['SubMiner'],
            fields: {
              word: 'Expression',
              sentence: 'Sentence',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
            isLapis: {
              enabled: true,
              sentenceCardModel: 'Lapis Morph',
            },
          }),
        } as Parameters<typeof createStatsApp>[1]);

        const res = await app.request('/api/stats/mine-card?mode=sentence', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            secondaryText: 'I saw a cat',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));
        const addNoteRequest = requests.find((request) => request.action === 'addNote');
        assert.equal(addNoteRequest?.params?.note?.deckName, 'Mining');
        assert.equal(addNoteRequest?.params?.note?.modelName, 'Lapis Morph');
        assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat');
      });
    });
  });

  it('POST /api/stats/mine-card prefers request secondary text over retimed fallback', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        let retimedCalls = 0;
        const options = {
          resolveRetimedSecondarySubtitleText: async () => {
            retimedCalls += 1;
            return 'Aligned English subtitle';
          },
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            fields: {
              sentence: 'Sentence',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
            isLapis: {
              enabled: true,
              sentenceCardModel: 'Lapis Morph',
            },
          },
        } as Parameters<typeof createStatsApp>[1] & {
          resolveRetimedSecondarySubtitleText: () => Promise<string>;
        };
        const app = createStatsApp(createMockTracker(), options);

        const res = await app.request('/api/stats/mine-card?mode=sentence', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            secondaryText: 'Stale stored English subtitle',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));

        const addNoteRequest = requests.find((request) => request.action === 'addNote');
        assert.equal(
          addNoteRequest?.params?.note?.fields?.SelectionText,
          'Stale stored English subtitle',
        );
        assert.equal(retimedCalls, 0);
      });
    });
  });

  it('retimes secondary sidecar subtitles against the Japanese sidecar and caches the output', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      const japanesePath = path.join(dir, 'episode.ja.srt');
      const englishPath = path.join(dir, 'episode.en.srt');
      const alassPath = path.join(dir, 'alass-cli');
      const originalEnglish = `1
00:00:09,000 --> 00:00:10,000
Stale English subtitle
`;
      fs.writeFileSync(sourcePath, 'fake media');
      fs.writeFileSync(alassPath, 'fake alass');
      fs.writeFileSync(
        japanesePath,
        `1
00:00:01,000 --> 00:00:02,000
猫を見た
`,
      );
      fs.writeFileSync(englishPath, originalEnglish);

      let alassRuns = 0;
      try {
        const first = await resolveRetimedSecondarySubtitleTextFromSidecar({
          sourcePath,
          startMs: 1_000,
          endMs: 2_000,
          alassPath,
          runAlass: async (_alassPath, referencePath, inputPath, outputPath) => {
            alassRuns += 1;
            assert.equal(referencePath, japanesePath);
            assert.equal(inputPath, englishPath);
            fs.writeFileSync(
              outputPath,
              `1
00:00:01,000 --> 00:00:02,000
Aligned English subtitle
`,
            );
            return { ok: true, code: 0, stdout: '', stderr: '' };
          },
        });

        const second = await resolveRetimedSecondarySubtitleTextFromSidecar({
          sourcePath,
          startMs: 1_000,
          endMs: 2_000,
          alassPath,
          runAlass: async () => {
            alassRuns += 1;
            return { ok: false, code: 1, stdout: '', stderr: 'should use cache' };
          },
        });

        assert.equal(first, 'Aligned English subtitle');
        assert.equal(second, 'Aligned English subtitle');
        assert.equal(alassRuns, 1);
        assert.equal(fs.readFileSync(englishPath, 'utf8'), originalEnglish);
      } finally {
        clearRetimedSecondarySubtitleCache();
      }
    });
  });

  it('shares in-flight retimed secondary subtitle work for concurrent requests', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      const japanesePath = path.join(dir, 'episode.ja.srt');
      const englishPath = path.join(dir, 'episode.en.srt');
      const alassPath = path.join(dir, 'alass-cli');
      fs.writeFileSync(sourcePath, 'fake media');
      fs.writeFileSync(alassPath, 'fake alass');
      fs.writeFileSync(
        japanesePath,
        `1
00:00:01,000 --> 00:00:02,000
猫を見た
`,
      );
      fs.writeFileSync(
        englishPath,
        `1
00:00:09,000 --> 00:00:10,000
Stale English subtitle
`,
      );

      let alassRuns = 0;
      let releaseAlass!: () => void;
      const alassGate = new Promise<void>((resolve) => {
        releaseAlass = resolve;
      });
      const input = {
        sourcePath,
        startMs: 1_000,
        endMs: 2_000,
        alassPath,
        runAlass: async (
          _alassPath: string,
          _referencePath: string,
          _inputPath: string,
          outputPath: string,
        ) => {
          alassRuns += 1;
          await alassGate;
          fs.writeFileSync(
            outputPath,
            `1
00:00:01,000 --> 00:00:02,000
Aligned English subtitle
`,
          );
          return { ok: true, code: 0, stdout: '', stderr: '' };
        },
      };

      try {
        const first = resolveRetimedSecondarySubtitleTextFromSidecar(input);
        const second = resolveRetimedSecondarySubtitleTextFromSidecar(input);
        releaseAlass();

        assert.deepEqual(await Promise.all([first, second]), [
          'Aligned English subtitle',
          'Aligned English subtitle',
        ]);
        assert.equal(alassRuns, 1);
      } finally {
        clearRetimedSecondarySubtitleCache();
      }
    });
  });

  it('POST /api/stats/mine-card adds direct sentence cards before slow media finishes', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const mediaRelease: {
          audio?: () => void;
          image?: () => void;
        } = {};
        const app = createStatsApp(createMockTracker(), {
          createMediaGenerator: () => ({
            generateAudio: async () =>
              await new Promise<Buffer>((resolve) => {
                mediaRelease.audio = () => resolve(Buffer.from('audio'));
              }),
            generateScreenshot: async () =>
              await new Promise<Buffer>((resolve) => {
                mediaRelease.image = () => resolve(Buffer.from('image'));
              }),
            generateAnimatedImage: async () => null,
          }),
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            tags: ['SubMiner'],
            fields: {
              word: 'Expression',
              audio: 'ExpressionAudio',
              image: 'Picture',
              sentence: 'Sentence',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: true,
              generateImage: true,
              imageType: 'static',
            },
            isLapis: {
              enabled: true,
              sentenceCardModel: 'Lapis Morph',
            },
          },
        });

        const pendingResponse = app.request('/api/stats/mine-card?mode=sentence', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            secondaryText: 'I saw a cat',
            videoTitle: 'Episode 1',
          }),
        });

        for (let attempt = 0; attempt < 20; attempt += 1) {
          if (requests.some((request) => request.action === 'addNote')) break;
          await new Promise((resolve) => setTimeout(resolve, 1));
        }
        const addedBeforeMediaFinished = requests.some((request) => request.action === 'addNote');
        mediaRelease.audio?.();
        mediaRelease.image?.();

        const res = await pendingResponse;
        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));
        assert.equal(addedBeforeMediaFinished, true);
      });
    });
  });

  it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          addYomitanNote: async () => 777,
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            fields: {
              audio: 'ExpressionAudio',
              image: 'Picture',
              sentence: 'Sentence',
              miscInfo: 'MiscInfo',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=word', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            secondaryText: 'I saw a cat',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));
        assert.equal(body.noteId, 777);

        const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
        assert.equal(updateRequest?.params?.note?.id, 777);
        assert.equal(updateRequest?.params?.note?.fields?.Sentence, '<b>猫</b>を見た');
        assert.equal(updateRequest?.params?.note?.fields?.SelectionText, undefined);
      });
    });
  });

  it('POST /api/stats/mine-card moves Yomitan-created word notes to the configured deck', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          addYomitanNote: async () => 777,
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            fields: {
              sentence: 'Sentence',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=word', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));

        const findCardsRequest = requests.find((request) => request.action === 'findCards');
        assert.equal(findCardsRequest?.params?.query, 'nid:777');
        const changeDeckRequest = requests.find((request) => request.action === 'changeDeck');
        assert.deepEqual(changeDeckRequest?.params?.cards, [9001]);
        assert.equal(changeDeckRequest?.params?.deck, 'Mining');
      });
    });
  });

  it('POST /api/stats/mine-card moves Yomitan-created word notes to Yomitan deck when config deck is empty', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          getYomitanAnkiDeckName: async () => 'Minecraft',
          addYomitanNote: async () => 777,
          ankiConnectConfig: {
            url,
            deck: '',
            fields: {
              sentence: 'Sentence',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=word', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));

        const findCardsRequest = requests.find((request) => request.action === 'findCards');
        assert.equal(findCardsRequest?.params?.query, 'nid:777');
        const changeDeckRequest = requests.find((request) => request.action === 'changeDeck');
        assert.deepEqual(changeDeckRequest?.params?.cards, [9001]);
        assert.equal(changeDeckRequest?.params?.deck, 'Minecraft');
      });
    });
  });

  it('POST /api/stats/mine-card uses the full sentence as sentence-card expression', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          ankiConnectConfig: {
            url,
            deck: 'Minecraft',
            tags: ['SubMiner'],
            fields: {
              word: 'Expression',
              sentence: 'Sentence',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
            isLapis: {
              enabled: true,
              sentenceCardModel: 'Lapis Morph',
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=sentence', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            secondaryText: 'I saw a cat',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));

        const addNoteRequest = requests.find((request) => request.action === 'addNote');
        assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft');
        assert.equal(addNoteRequest?.params?.note?.fields?.Expression, '猫を見た');
        assert.equal(addNoteRequest?.params?.note?.fields?.Sentence, '猫を見た');
        assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat');
        assert.equal(addNoteRequest?.params?.note?.fields?.IsSentenceCard, 'x');
      });
    });
  });

  it('POST /api/stats/mine-card fills selection text from a matching secondary sidecar subtitle', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');
      fs.writeFileSync(
        path.join(dir, 'episode.en.srt'),
        [
          '1',
          '00:00:00,800 --> 00:00:02,500',
          'I saw a cat.',
          '',
          '2',
          '00:00:03,000 --> 00:00:04,000',
          'Not this line.',
          '',
        ].join('\n'),
      );

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          ankiConnectConfig: {
            url,
            deck: 'Minecraft',
            tags: ['SubMiner'],
            fields: {
              word: 'Expression',
              sentence: 'Sentence',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
            isLapis: {
              enabled: true,
              sentenceCardModel: 'Lapis Morph',
            },
          },
          secondarySubtitleLanguages: ['en'],
        } as Parameters<typeof createStatsApp>[1]);

        const res = await app.request('/api/stats/mine-card?mode=sentence', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));

        const addNoteRequest = requests.find((request) => request.action === 'addNote');
        assert.equal(addNoteRequest?.params?.note?.deckName, 'Minecraft');
        assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat.');
      });
    });
  });

  it('POST /api/stats/mine-card does not append the next sidecar cue near a timing boundary', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');
      fs.writeFileSync(
        path.join(dir, 'episode.en.srt'),
        [
          '1',
          '00:00:00,800 --> 00:00:01,500',
          "I don't give a damn what family she's from.",
          '',
          '2',
          '00:00:01,700 --> 00:00:03,000',
          'That snobby attitude just pisses me off!',
          '',
        ].join('\n'),
      );

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          ankiConnectConfig: {
            url,
            deck: 'Minecraft',
            tags: ['SubMiner'],
            fields: {
              word: 'Expression',
              sentence: 'Sentence',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
            isLapis: {
              enabled: true,
              sentenceCardModel: 'Lapis Morph',
            },
          },
          secondarySubtitleLanguages: ['en'],
        } as Parameters<typeof createStatsApp>[1]);

        const res = await app.request('/api/stats/mine-card?mode=sentence', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '名門か何だか知らねえが',
            word: '名門',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));

        const addNoteRequest = requests.find((request) => request.action === 'addNote');
        assert.equal(
          addNoteRequest?.params?.note?.fields?.SelectionText,
          "I don't give a damn what family she's from.",
        );
      });
    });
  });

  it('POST /api/stats/mine-card writes word mining audio to SentenceAudio when present', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          addYomitanNote: async () => 777,
          createMediaGenerator: () => ({
            generateAudio: async () => Buffer.from('audio'),
            generateScreenshot: async () => null,
            generateAnimatedImage: async () => null,
          }),
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            fields: {
              audio: 'ExpressionAudio',
              image: 'Picture',
              sentence: 'Sentence',
              miscInfo: 'MiscInfo',
            },
            media: {
              generateAudio: true,
              generateImage: false,
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=word', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));

        const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
        const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
        assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
        assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
      });
    });
  });

  it('POST /api/stats/mine-card marks Kiku word mining notes as word-and-sentence cards when enabled', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(
        async (requests, url) => {
          const app = createStatsApp(createMockTracker(), {
            addYomitanNote: async () => 777,
            createMediaGenerator: () => ({
              generateAudio: async () => null,
              generateScreenshot: async () => null,
              generateAnimatedImage: async () => null,
            }),
            ankiConnectConfig: {
              url,
              deck: 'Mining',
              fields: {
                image: 'Picture',
                sentence: 'Sentence',
              },
              media: {
                generateAudio: false,
                generateImage: false,
              },
              isKiku: {
                enabled: true,
                fieldGrouping: 'disabled',
                deleteDuplicateInAuto: true,
              },
            },
          });

          const res = await app.request('/api/stats/mine-card?mode=word', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              sourcePath,
              startMs: 1_000,
              endMs: 2_000,
              sentence: '猫を見た',
              word: '猫',
              videoTitle: 'Episode 1',
            }),
          });

          const body = await res.json();
          assert.equal(res.status, 200, JSON.stringify(body));

          const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
          const fields = updateRequest?.params?.note?.fields ?? {};
          assert.equal(fields.Sentence, '<b>猫</b>を見た');
          assert.equal(fields.IsWordAndSentenceCard, 'x');
          assert.equal(fields.IsSentenceCard, '');
          assert.equal(fields.IsAudioCard, '');
        },
        {
          notesInfoFields: {
            Expression: { value: '猫' },
            Sentence: { value: '' },
            Picture: { value: '' },
            IsWordAndSentenceCard: { value: '' },
            IsSentenceCard: { value: '' },
            IsAudioCard: { value: '' },
          },
        },
      );
    });
  });

  it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(
        async (requests, url) => {
          const app = createStatsApp(createMockTracker(), {
            addYomitanNote: async () => 777,
            createMediaGenerator: () => ({
              generateAudio: async () => null,
              generateScreenshot: async () => null,
              generateAnimatedImage: async () => null,
            }),
            ankiConnectConfig: {
              url,
              deck: 'Mining',
              fields: {
                image: 'Picture',
                sentence: 'Sentence',
              },
              media: {
                generateAudio: false,
                generateImage: false,
              },
              isKiku: {
                enabled: true,
                fieldGrouping: 'disabled',
                deleteDuplicateInAuto: true,
              },
              lapisKiku: {
                wordCardKind: 'click',
              },
            },
          });

          const res = await app.request('/api/stats/mine-card?mode=word', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              sourcePath,
              startMs: 1_000,
              endMs: 2_000,
              sentence: '猫を見た',
              word: '猫',
              videoTitle: 'Episode 1',
            }),
          });

          const body = await res.json();
          assert.equal(res.status, 200, JSON.stringify(body));

          const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
          const fields = updateRequest?.params?.note?.fields ?? {};
          assert.equal(fields.IsClickCard, 'x');
          assert.equal(fields.IsWordAndSentenceCard, '');
          assert.equal(fields.IsSentenceCard, '');
          assert.equal(fields.IsAudioCard, '');
        },
        {
          notesInfoFields: {
            Expression: { value: '猫' },
            Sentence: { value: '' },
            Picture: { value: '' },
            IsWordAndSentenceCard: { value: '' },
            IsClickCard: { value: '' },
            IsSentenceCard: { value: '' },
            IsAudioCard: { value: '' },
          },
        },
      );
    });
  });

  it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          addYomitanNote: async () => 777,
          createMediaGenerator: () => ({
            generateAudio: async () => Buffer.from('audio'),
            generateScreenshot: async () => Buffer.from('image'),
            generateAnimatedImage: async () => null,
          }),
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            fields: {
              audio: 'ExpressionAudio',
              image: 'Picture',
              sentence: 'Sentence',
            },
            media: {
              generateAudio: true,
              generateImage: true,
              imageType: 'static',
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=word', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));
        assert.equal(body.errors, undefined);

        const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
        const fields = updateRequest?.params?.note?.fields ?? {};
        assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
        assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.jpg">$/);
        assert.equal(fields.ExpressionAudio, undefined);
        assert.equal(fields.SelectionText, undefined);
      });
    });
  });

  it('POST /api/stats/mine-card writes word mining sentence audio and animated image together', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          addYomitanNote: async () => 777,
          createMediaGenerator: () => ({
            generateAudio: async () => Buffer.from('audio'),
            generateScreenshot: async () => null,
            generateAnimatedImage: async () => Buffer.from('animated'),
          }),
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            fields: {
              audio: 'ExpressionAudio',
              image: 'Picture',
              sentence: 'Sentence',
            },
            media: {
              generateAudio: true,
              generateImage: true,
              imageType: 'avif',
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=word', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));
        assert.equal(body.errors, undefined);

        const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
        const fields = updateRequest?.params?.note?.fields ?? {};
        assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+_777\.mp3\]$/);
        assert.match(fields.Picture ?? '', /^<img src="subminer_image_\d+_777\.avif">$/);
        assert.equal(fields.ExpressionAudio, undefined);
        assert.equal(fields.SelectionText, undefined);
      });
    });
  });

  it('POST /api/stats/mine-card reports an error when requested word image generation returns no image', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (_requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          addYomitanNote: async () => 777,
          createMediaGenerator: () => ({
            generateAudio: async () => Buffer.from('audio'),
            generateScreenshot: async () => null,
            generateAnimatedImage: async () => null,
          }),
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            fields: {
              audio: 'ExpressionAudio',
              image: 'Picture',
              sentence: 'Sentence',
            },
            media: {
              generateAudio: true,
              generateImage: true,
              imageType: 'static',
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=word', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));
        assert.deepEqual(body.errors, ['image: no image generated']);
      });
    });
  });

  it('POST /api/stats/mine-card reports an error when requested word audio generation returns no audio', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (_requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          addYomitanNote: async () => 777,
          createMediaGenerator: () => ({
            generateAudio: async () => null,
            generateScreenshot: async () => Buffer.from('image'),
            generateAnimatedImage: async () => null,
          }),
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            fields: {
              audio: 'ExpressionAudio',
              image: 'Picture',
              sentence: 'Sentence',
            },
            media: {
              generateAudio: true,
              generateImage: true,
              imageType: 'static',
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=word', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));
        assert.deepEqual(body.errors, ['audio: no audio generated']);
      });
    });
  });

  it('POST /api/stats/mine-card writes audio cards to configured audio field when note info is missing', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(
        async (requests, url) => {
          const app = createStatsApp(createMockTracker(), {
            createMediaGenerator: () => ({
              generateAudio: async () => Buffer.from('audio'),
              generateScreenshot: async () => null,
              generateAnimatedImage: async () => null,
            }),
            ankiConnectConfig: {
              url,
              deck: 'Mining',
              fields: {
                audio: 'Voice',
                image: 'Picture',
                sentence: 'Sentence',
              },
              media: {
                generateAudio: true,
                generateImage: false,
              },
              isLapis: {
                enabled: true,
                sentenceCardModel: 'Lapis Morph',
              },
            },
          });

          const res = await app.request('/api/stats/mine-card?mode=audio', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              sourcePath,
              startMs: 1_000,
              endMs: 2_000,
              sentence: '猫を見た',
              word: '猫',
              videoTitle: 'Episode 1',
            }),
          });

          const body = await res.json();
          assert.equal(res.status, 200, JSON.stringify(body));

          const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
          assert.match(
            updateRequest?.params?.note?.fields?.Voice ?? '',
            /^\[sound:subminer_audio_\d+_12345\.mp3\]$/,
          );
        },
        { notesInfoFields: null },
      );
    });
  });

  it('POST /api/stats/mine-card records timing for slow sentence mining phases', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        let now = 0;
        const timings: Array<{ mode: string; phase: string; elapsedMs: number; noteId?: number }> =
          [];
        const app = createStatsApp(createMockTracker(), {
          nowMs: () => {
            now += 10;
            return now;
          },
          onMiningTiming: (event) => {
            timings.push(event);
          },
          createMediaGenerator: () => ({
            generateAudio: async () => Buffer.from('audio'),
            generateScreenshot: async () => Buffer.from('image'),
            generateAnimatedImage: async () => Buffer.from('animated'),
          }),
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            tags: ['SubMiner'],
            fields: {
              word: 'Expression',
              audio: 'ExpressionAudio',
              image: 'Picture',
              sentence: 'Sentence',
              miscInfo: 'MiscInfo',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: true,
              generateImage: true,
              imageType: 'static',
            },
            isLapis: {
              enabled: true,
              sentenceCardModel: 'Lapis Morph',
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=sentence', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            secondaryText: 'I saw a cat',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));
        assert.deepEqual(
          timings.map((entry) => entry.phase),
          [
            'generateAudio',
            'generateScreenshot',
            'addNote',
            'findCards',
            'changeDeck',
            'uploadAudio',
            'uploadImage',
            'updateNoteFields',
          ],
        );
        assert.ok(timings.every((entry) => entry.mode === 'sentence' && entry.elapsedMs >= 0));
        assert.equal(timings.find((entry) => entry.phase === 'addNote')?.noteId, 12345);

        const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
        const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
        assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+_12345\.mp3\]$/);
        assert.match(
          updateRequest?.params?.note?.fields?.Picture ?? '',
          /^<img src="subminer_image_\d+_12345\.jpg">$/,
        );
        assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
      });
    });
  });

  it('POST /api/stats/mine-card only writes selection text for sentence cards', async () => {
    await withTempDir(async (dir) => {
      const sourcePath = path.join(dir, 'episode.mkv');
      fs.writeFileSync(sourcePath, 'fake media');

      await withFakeAnkiConnect(async (requests, url) => {
        const app = createStatsApp(createMockTracker(), {
          ankiConnectConfig: {
            url,
            deck: 'Mining',
            fields: {
              word: 'Expression',
              sentence: 'Sentence',
              translation: 'SelectionText',
            },
            media: {
              generateAudio: false,
              generateImage: false,
            },
            isLapis: {
              enabled: true,
              sentenceCardModel: 'Lapis Morph',
            },
          },
        });

        const res = await app.request('/api/stats/mine-card?mode=audio', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            sourcePath,
            startMs: 1_000,
            endMs: 2_000,
            sentence: '猫を見た',
            word: '猫',
            secondaryText: 'I saw a cat',
            videoTitle: 'Episode 1',
          }),
        });

        const body = await res.json();
        assert.equal(res.status, 200, JSON.stringify(body));

        const addNoteRequest = requests.find((request) => request.action === 'addNote');
        assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, undefined);
        assert.equal(addNoteRequest?.params?.note?.fields?.IsAudioCard, 'x');
      });
    });
  });

  it('GET /api/stats/episode/:videoId/detail returns episode detail', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/episode/1/detail');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.ok(Array.isArray(body.sessions));
    assert.ok(Array.isArray(body.words));
    assert.ok(Array.isArray(body.cardEvents));
    assert.equal(body.cardEvents[0].noteIds[0], 12345);
  });

  it('GET /api/stats/episode/:videoId/detail returns 400 for invalid videoId', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/episode/0/detail');
    assert.equal(res.status, 400);
  });

  it('DELETE /api/stats/sessions/:sessionId deletes a session', async () => {
    let deletedSessionId = 0;
    const app = createStatsApp(
      createMockTracker({
        deleteSession: async (sessionId: number) => {
          deletedSessionId = sessionId;
        },
      }),
    );

    const res = await app.request('/api/stats/sessions/42', { method: 'DELETE' });

    assert.equal(res.status, 200);
    assert.equal(deletedSessionId, 42);
    assert.deepEqual(await res.json(), { ok: true });
  });

  it('DELETE /api/stats/sessions rejects non-safe or fractional session ids', async () => {
    let deleteCalls = 0;
    const app = createStatsApp(
      createMockTracker({
        deleteSessions: async () => {
          deleteCalls += 1;
        },
      }),
    );

    const res = await app.request('/api/stats/sessions', {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json' },
      body: '{"sessionIds":[1.5,1e309,9007199254740992]}',
    });

    assert.equal(res.status, 400);
    assert.equal(deleteCalls, 0);
  });

  it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => {
    let deletedAnimeId: number | null = null;
    const app = createStatsApp(
      createMockTracker({
        deleteAnime: async (animeId: number) => {
          deletedAnimeId = animeId;
        },
      } as Partial<ImmersionTrackerService>),
    );

    const res = await app.request('/api/stats/anime/7', { method: 'DELETE' });

    assert.equal(res.status, 200);
    assert.equal(deletedAnimeId, 7);
    assert.deepEqual(await res.json(), { ok: true });
  });

  it('DELETE /api/stats/anime/:animeId rejects non-positive anime ids', async () => {
    let deleteCalls = 0;
    const app = createStatsApp(
      createMockTracker({
        deleteAnime: async () => {
          deleteCalls += 1;
        },
      } as Partial<ImmersionTrackerService>),
    );

    const res = await app.request('/api/stats/anime/0', { method: 'DELETE' });

    assert.equal(res.status, 400);
    assert.equal(deleteCalls, 0);
  });

  it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
    let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
    const app = createStatsApp(
      createMockTracker({
        mergeAnime: async (targetAnimeId: number, sourceAnimeIds: number[]) => {
          merged = { targetAnimeId, sourceAnimeIds };
          return {
            survivingAnimeId: targetAnimeId,
            mergedAnimeIds: sourceAnimeIds,
            movedVideos: 3,
          };
        },
      } as Partial<ImmersionTrackerService>),
    );

    const res = await app.request('/api/stats/anime/7/merge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      // The target repeated in the sources must not delete the entry we keep.
      body: '{"sourceAnimeIds":[8,9,8,7]}',
    });

    assert.equal(res.status, 200);
    assert.deepEqual(merged, { targetAnimeId: 7, sourceAnimeIds: [8, 9] });
    assert.deepEqual(await res.json(), {
      ok: true,
      animeId: 7,
      mergedAnimeIds: [8, 9],
      movedVideos: 3,
    });
  });

  it('POST /api/stats/anime/:animeId/merge rejects an empty or malformed source list', async () => {
    let mergeCalls = 0;
    const app = createStatsApp(
      createMockTracker({
        mergeAnime: async () => {
          mergeCalls += 1;
          return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
        },
      } as Partial<ImmersionTrackerService>),
    );

    for (const body of [
      '{"sourceAnimeIds":[]}',
      '{"sourceAnimeIds":[7]}',
      '{"sourceAnimeIds":0}',
    ]) {
      const res = await app.request('/api/stats/anime/7/merge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body,
      });
      assert.equal(res.status, 400);
    }
    assert.equal(mergeCalls, 0);
  });

  it('PATCH /api/stats/media/:videoId/anime moves the episode to another entry', async () => {
    let moved: { videoId: number; animeId: number } | null = null;
    const app = createStatsApp(
      createMockTracker({
        moveVideoToAnime: async (videoId: number, animeId: number) => {
          moved = { videoId, animeId };
          return { targetAnimeId: animeId, previousAnimeId: 4, removedPreviousAnime: true };
        },
      } as Partial<ImmersionTrackerService>),
    );

    const res = await app.request('/api/stats/media/12/anime', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: '{"animeId":7}',
    });

    assert.equal(res.status, 200);
    assert.deepEqual(moved, { videoId: 12, animeId: 7 });
    assert.deepEqual(await res.json(), {
      ok: true,
      animeId: 7,
      previousAnimeId: 4,
      removedPreviousAnime: true,
    });
  });

  it('POST /api/stats/anime/:animeId/merge reports a merge that folded nothing as 404', async () => {
    const app = createStatsApp(
      createMockTracker({
        mergeAnime: async (targetAnimeId: number) => ({
          survivingAnimeId: targetAnimeId,
          mergedAnimeIds: [],
          movedVideos: 0,
        }),
      } as Partial<ImmersionTrackerService>),
    );

    const res = await app.request('/api/stats/anime/7/merge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: '{"sourceAnimeIds":[8]}',
    });

    assert.equal(res.status, 404);
  });

  it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => {
    const app = createStatsApp(
      createMockTracker({
        moveVideoToAnime: async () => {
          throw new Error('Unknown episode or target library entry');
        },
      } as Partial<ImmersionTrackerService>),
    );

    const res = await app.request('/api/stats/media/12/anime', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: '{"animeId":99}',
    });

    assert.equal(res.status, 404);
  });

  it('PATCH /api/stats/media/:videoId/anime does not disguise storage failures as 404', async () => {
    const app = createStatsApp(
      createMockTracker({
        moveVideoToAnime: async () => {
          throw new Error('database is locked');
        },
      } as Partial<ImmersionTrackerService>),
    );

    const res = await app.request('/api/stats/media/12/anime', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: '{"animeId":7}',
    });

    assert.notEqual(res.status, 404);
    assert.equal(res.status >= 500, true);
  });

  it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
    const app = createStatsApp(createMockTracker());
    const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
    assert.equal(res.status, 400);
  });

  it('POST /api/stats/anki/browse uses configured AnkiConnect URL', async () => {
    await withFakeAnkiConnect(async (requests, url) => {
      const app = createStatsApp(createMockTracker(), {
        ankiConnectConfig: { url },
      });

      const res = await app.request('/api/stats/anki/browse?noteId=12345', { method: 'POST' });

      assert.equal(res.status, 200);
      assert.equal(requests[0]?.action, 'guiBrowse');
      assert.deepEqual(requests[0]?.params, { query: 'nid:12345' });
    });
  });

  it('GET /api/stats/anilist/search uses the configured AniList rate limiter', async () => {
    const originalFetch = globalThis.fetch;
    let acquireCalls = 0;
    let recordCalls = 0;
    let requestSignal: AbortSignal | null | undefined;
    globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
      requestSignal = init?.signal;
      return new Response(
        JSON.stringify({
          data: {
            Page: {
              media: [{ id: 21858, title: { romaji: 'Little Witch Academia' } }],
            },
          },
        }),
        {
          status: 200,
          headers: { 'Content-Type': 'application/json', 'X-RateLimit-Remaining': '29' },
        },
      );
    }) as typeof fetch;

    try {
      const app = createStatsApp(createMockTracker(), {
        anilistRateLimiter: {
          acquire: async () => {
            acquireCalls += 1;
          },
          recordResponse: () => {
            recordCalls += 1;
          },
        },
      });
      const res = await app.request('/api/stats/anilist/search?q=Little%20Witch%20Academia');

      assert.equal(res.status, 200);
      assert.equal(acquireCalls, 1);
      assert.equal(recordCalls, 1);
      assert.ok(requestSignal instanceof AbortSignal);
    } finally {
      globalThis.fetch = originalFetch;
    }
  });

  it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => {
    const originalFetch = globalThis.fetch;
    const requests: Array<{ input: string; body: unknown }> = [];
    globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
      requests.push({
        input: String(input),
        body: init?.body ? JSON.parse(String(init.body)) : null,
      });
      return new Response(
        JSON.stringify({
          result: [
            {
              noteId: 222,
              fields: {
                Expression: { value: '呪い' },
              },
            },
          ],
        }),
        {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        },
      );
    }) as typeof fetch;

    try {
      const app = createStatsApp(createMockTracker(), {
        ankiConnectConfig: { url: 'http://127.0.0.1:9876' },
        resolveAnkiNoteId: (noteId) => (noteId === 111 ? 222 : noteId),
      });
      const res = await app.request('/api/stats/anki/notesInfo', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ noteIds: [111] }),
      });

      assert.equal(res.status, 200);
      assert.deepEqual(requests, [
        {
          input: 'http://127.0.0.1:9876',
          body: {
            action: 'notesInfo',
            version: 6,
            params: { notes: [222] },
          },
        },
      ]);
      assert.deepEqual(await res.json(), [
        {
          noteId: 222,
          fields: {
            Expression: { value: '呪い' },
          },
          preview: {
            word: '呪い',
            sentence: '',
            translation: '',
          },
        },
      ]);
    } finally {
      globalThis.fetch = originalFetch;
    }
  });

  it('POST /api/stats/anki/notesInfo returns preview fields using configured word and sentence field names', async () => {
    const originalFetch = globalThis.fetch;
    globalThis.fetch = (async () =>
      new Response(
        JSON.stringify({
          result: [
            {
              noteId: 333,
              fields: {
                TargetWord: { value: '<span>連れる</span>' },
                Quote: { value: '<div>このまま<b>連れてって</b></div>' },
                SelectionText: { value: 'to take along' },
              },
            },
          ],
        }),
        {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        },
      )) as typeof fetch;

    try {
      const app = createStatsApp(createMockTracker(), {
        ankiConnectConfig: {
          fields: {
            word: 'TargetWord',
            sentence: 'Quote',
            translation: 'SelectionText',
          },
        },
      });
      const res = await app.request('/api/stats/anki/notesInfo', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ noteIds: [333] }),
      });

      assert.equal(res.status, 200);
      assert.deepEqual(await res.json(), [
        {
          noteId: 333,
          fields: {
            TargetWord: { value: '<span>連れる</span>' },
            Quote: { value: '<div>このまま<b>連れてって</b></div>' },
            SelectionText: { value: 'to take along' },
          },
          preview: {
            word: '連れる',
            sentence: 'このまま 連れてって',
            translation: 'to take along',
          },
        },
      ]);
    } finally {
      globalThis.fetch = originalFetch;
    }
  });

  it('POST /api/stats/anki/notesInfo builds previews with the same config used for fetch', async () => {
    const originalFetch = globalThis.fetch;
    globalThis.fetch = (async () =>
      new Response(
        JSON.stringify({
          result: [
            {
              noteId: 444,
              fields: {
                TargetWord: { value: '一致' },
                OtherWord: { value: '不一致' },
              },
            },
          ],
        }),
        {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        },
      )) as typeof fetch;

    try {
      let configCalls = 0;
      const app = createStatsApp(createMockTracker(), {
        getAnkiConnectConfig: () => {
          configCalls += 1;
          return {
            url: 'http://127.0.0.1:8765',
            fields: { word: configCalls === 1 ? 'TargetWord' : 'OtherWord' },
          };
        },
      });
      const res = await app.request('/api/stats/anki/notesInfo', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ noteIds: [444] }),
      });

      assert.equal(res.status, 200);
      assert.equal(configCalls, 1);
      assert.equal((await res.json())[0].preview.word, '一致');
    } finally {
      globalThis.fetch = originalFetch;
    }
  });

  it('serves stats index and asset files from absolute static dir paths', async () => {
    await withTempDir(async (dir) => {
      const assetDir = path.join(dir, 'assets');
      fs.mkdirSync(assetDir, { recursive: true });
      fs.writeFileSync(
        path.join(dir, 'index.html'),
        '<!doctype html><html><body><div id="root"></div><script src="./assets/app.js"></script></body></html>',
      );
      fs.writeFileSync(path.join(assetDir, 'app.js'), 'console.log("stats ok");');

      const app = createStatsApp(createMockTracker(), { staticDir: dir });
      const indexRes = await app.request('/');
      assert.equal(indexRes.status, 200);
      assert.match(await indexRes.text(), /assets\/app\.js/);

      const assetRes = await app.request('/assets/app.js');
      assert.equal(assetRes.status, 200);
      assert.equal(assetRes.headers.get('content-type'), 'text/javascript; charset=utf-8');
      assert.match(await assetRes.text(), /stats ok/);
    });
  });

  it('returns not found for malformed percent-encoded asset paths', async () => {
    await withTempDir(async (dir) => {
      const app = createStatsApp(createMockTracker(), { staticDir: dir });
      const res = await app.request('/assets/%');

      assert.equal(res.status, 404);
    });
  });

  it('fetches and serves missing cover art on demand', async () => {
    let ensureCalls = 0;
    let hasCover = false;
    const app = createStatsApp(
      createMockTracker({
        getCoverArt: async () =>
          hasCover
            ? {
                videoId: 1,
                anilistId: 1,
                coverUrl: 'https://example.com/cover.jpg',
                coverBlob: Buffer.from([
                  0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
                ]),
                titleRomaji: 'Test',
                titleEnglish: 'Test',
                episodesTotal: 12,
                fetchedAtMs: Date.now(),
              }
            : null,
        ensureCoverArt: async () => {
          ensureCalls += 1;
          hasCover = true;
          return true;
        },
      }),
    );

    const res = await app.request('/api/stats/media/1/cover');
    assert.equal(res.status, 200);
    assert.equal(res.headers.get('content-type'), 'image/webp');
    assert.equal(ensureCalls, 1);
  });

  it('starts the stats server with Bun.serve', () => {
    type BunRuntime = {
      Bun: {
        serve: (options: { fetch: unknown; port: number; hostname: string }) => {
          stop: () => void;
        };
      };
    };

    const bun = globalThis as typeof globalThis & BunRuntime;
    const originalServe = bun.Bun.serve;
    let servedWith: { fetch: unknown; port: number; hostname: string } | null = null;
    let stopCalls = 0;

    bun.Bun.serve = (options: { fetch: unknown; port: number; hostname: string }) => {
      servedWith = options;
      return {
        stop: () => {
          stopCalls += 1;
        },
      };
    };

    try {
      const server = startStatsServer({
        port: 3210,
        staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-start-')),
        tracker: createMockTracker(),
      });

      if (servedWith === null) {
        throw new Error('expected Bun.serve to be called');
      }

      const servedOptions = servedWith as {
        fetch: unknown;
        port: number;
        hostname: string;
      };
      assert.equal(servedOptions.port, 3210);
      assert.equal(servedOptions.hostname, '127.0.0.1');
      assert.equal(typeof servedOptions.fetch, 'function');

      server.close();
      assert.equal(stopCalls, 1);
    } finally {
      bun.Bun.serve = originalServe;
    }
  });

  it('falls back to node:http when Bun.serve is unavailable', () => {
    type BunRuntime = {
      Bun: {
        serve?: (options: { fetch: unknown; port: number; hostname: string }) => {
          stop: () => void;
        };
      };
    };

    const bun = globalThis as typeof globalThis & BunRuntime;
    const originalServe = bun.Bun.serve;
    const originalCreateServer = http.createServer;
    let listenedWith: { port: number; hostname: string } | null = null;
    let closeCalls = 0;
    bun.Bun.serve = undefined;
    (
      http as typeof http & {
        createServer: typeof http.createServer;
      }
    ).createServer = (() =>
      ({
        listen: (port: number, hostname: string) => {
          listenedWith = { port, hostname };
        },
        close: () => {
          closeCalls += 1;
        },
      }) as unknown as ReturnType<typeof http.createServer>) as typeof http.createServer;

    try {
      const server = startStatsServer({
        port: 0,
        staticDir: fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-node-')),
        tracker: createMockTracker(),
      });

      assert.deepEqual(listenedWith, { port: 0, hostname: '127.0.0.1' });
      server.close();
      assert.equal(closeCalls, 1);
    } finally {
      bun.Bun.serve = originalServe;
      (
        http as typeof http & {
          createServer: typeof http.createServer;
        }
      ).createServer = originalCreateServer;
    }
  });
});
