import assert from 'node:assert/strict';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
import { ensureSchema } from '../storage.js';
import { cleanupDuplicateSubtitleLines } from '../duplicate-line-cleanup.js';

const DAY_MS = 86_400_000;
const BASE_MS = 1_700_000_000_000;
const WORD_ID = 1;

interface SeedLine {
  session: number;
  text: string;
  startMs: number;
  endMs: number;
  /** Recording wall-clock, i.e. what the lookback window filters on. */
  createdMs?: number;
}

/** One episode, two sessions of it, and one word occurrence per seeded line. */
function seed(db: DatabaseSync, lines: SeedLine[]): void {
  db.exec(`
    INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
      VALUES (1, 'show', 'Show', ${BASE_MS}, ${BASE_MS});
    INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
      VALUES (1, 'v1', 1, 'Ep 1', 1, 1, 1440000, ${BASE_MS}, ${BASE_MS});
    INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
      VALUES (1, 's1', 1, '${BASE_MS}', '${BASE_MS + 1000}', 2, ${BASE_MS}, ${BASE_MS}),
             (2, 's2', 1, '${BASE_MS + DAY_MS}', '${BASE_MS + DAY_MS + 1000}', 2, ${BASE_MS}, ${BASE_MS});
    INSERT INTO imm_words(id, headword, word, reading, part_of_speech, pos1, first_seen, last_seen, frequency)
      VALUES (${WORD_ID}, '飛び上がる', '飛び上がる', '', 'verb', '動詞', ${Math.floor(BASE_MS / 1000)}, ${Math.floor(BASE_MS / 1000)}, 0);
  `);

  const insertLine = db.prepare(
    `INSERT INTO imm_subtitle_lines(
       line_id, session_id, video_id, anime_id, line_index,
       segment_start_ms, segment_end_ms, text, CREATED_DATE, LAST_UPDATE_DATE)
     VALUES (?, ?, 1, 1, ?, ?, ?, ?, ?, ?)`,
  );
  const insertOccurrence = db.prepare(
    `INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms)
     VALUES (?, ?, 1, ?)`,
  );

  lines.forEach((line, index) => {
    const lineId = index + 1;
    const lineIndex = index + 1;
    const createdMs = line.createdMs ?? BASE_MS;
    insertLine.run(
      lineId,
      line.session,
      lineIndex,
      line.startMs,
      line.endMs,
      line.text,
      createdMs,
      createdMs,
    );
    insertOccurrence.run(lineId, WORD_ID, createdMs);
  });

  db.exec(`
    UPDATE imm_words SET frequency = (
      SELECT COALESCE(SUM(o.occurrence_count), 0)
      FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
    )
  `);
}

/**
 * These tests exercise the cleanup SQL, not durability. A fresh on-disk database per
 * test pays a schema-creation fsync that is cheap on a local NVMe but slow enough on CI
 * runners to blow the 5s per-test timeout, so the database stays in memory.
 */
function createDb(lines: SeedLine[]): { db: DatabaseSync } {
  const db = new Database(':memory:');
  ensureSchema(db);
  seed(db, lines);
  return { db };
}

/** A typeset line mpv reported once per animation frame. */
function karaokeFrames(
  session: number,
  text: string,
  startMs: number,
  frames: number,
  frameMs: number,
): SeedLine[] {
  return Array.from({ length: frames }, (_, index) => ({
    session,
    text,
    startMs: startMs + index * frameMs,
    endMs: startMs + (index + 1) * frameMs,
  }));
}

function countLines(db: DatabaseSync): number {
  return (db.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines').get() as { total: number })
    .total;
}

function wordFrequency(db: DatabaseSync): number {
  const row = db.prepare('SELECT frequency FROM imm_words WHERE id = ?').get(WORD_ID) as {
    frequency: number;
  } | null;
  return row?.frequency ?? 0;
}

test('a karaoke burst collapses to one line and gives back its word counts', () => {
  const { db } = createDb([
    ...karaokeFrames(1, '飛び上がる', 10_000, 40, 40),
    { session: 1, text: 'おはよう', startMs: 20_000, endMs: 22_000 },
  ]);

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 1);
    assert.equal(summary.removedLines, 39);
    assert.equal(summary.removedWordOccurrences, 39);
    assert.equal(countLines(db), 2);
    assert.equal(wordFrequency(db), 2);

    // The surviving line covers the whole run, the way the parsed cue would.
    const kept = db
      .prepare(
        'SELECT segment_start_ms AS startMs, segment_end_ms AS endMs FROM imm_subtitle_lines WHERE line_id = 1',
      )
      .get() as { startMs: number; endMs: number };
    assert.equal(kept.startMs, 10_000);
    assert.equal(kept.endMs, 10_000 + 40 * 40);

    assert.equal(summary.samples.length, 1);
    assert.equal(summary.samples[0]!.text, '飛び上がる');
    assert.equal(summary.samples[0]!.frames, 40);
    assert.equal(summary.samples[0]!.videoTitle, 'Ep 1');
  } finally {
    db.close();
  }
});

test('ordinary repeated dialogue survives', () => {
  // Six contiguous `飛び上がる`, each held for a normal beat rather than a frame.
  const lines = Array.from({ length: 6 }, (_, index) => ({
    session: 1,
    text: '飛び上がる',
    startMs: 5_000 + index * 800,
    endMs: 5_000 + (index + 1) * 800,
  }));
  const { db } = createDb(lines);

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 0);
    assert.equal(summary.removedLines, 0);
    assert.equal(countLines(db), 6);
    assert.equal(wordFrequency(db), 6);
  } finally {
    db.close();
  }
});

test('a long run of quarter-second frames is still a burst', () => {
  // Between the timing-only bound (0.1s) and the animation-frame bound (0.3s): heavier
  // typesetting lands here, and the run length is what makes it conclusive.
  const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 1);
    assert.equal(summary.removedLines, 5);
    assert.equal(countLines(db), 1);
    assert.equal(wordFrequency(db), 1);
  } finally {
    db.close();
  }
});

test('a qualifying short-frame burst may end with one long hold frame', () => {
  const { db } = createDb([
    ...karaokeFrames(1, '飛び上がる', 10_000, 8, 40),
    { session: 1, text: '飛び上がる', startMs: 10_320, endMs: 12_320 },
  ]);

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 1);
    assert.equal(summary.removedLines, 8);
    assert.equal(countLines(db), 1);
    assert.equal(wordFrequency(db), 1);
  } finally {
    db.close();
  }
});

test('a long event before the final frame prevents burst cleanup', () => {
  const { db } = createDb([
    ...karaokeFrames(1, '飛び上がる', 10_000, 5, 40),
    { session: 1, text: '飛び上がる', startMs: 10_200, endMs: 12_200 },
    { session: 1, text: '飛び上がる', startMs: 12_200, endMs: 12_240 },
  ]);

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 0);
    assert.equal(countLines(db), 7);
  } finally {
    db.close();
  }
});

test('a run of frames longer than the animation bound survives', () => {
  const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 0);
    assert.equal(countLines(db), 6);
  } finally {
    db.close();
  }
});

test('the four-frame residue the live gate stores is cleaned up', () => {
  // The streaming gate records the first four frames of a burst before the run is long
  // enough to recognise. Four contiguous identical events under the strict timing-only
  // bound are that residue, and no real dialogue.
  const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40));

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 1);
    assert.equal(summary.removedLines, 3);
    assert.equal(countLines(db), 1);
    assert.equal(wordFrequency(db), 1);
  } finally {
    db.close();
  }
});

test('a four-frame run above the strict frame bound survives', () => {
  // Long enough per event to be plausible dialogue; only a five-event run may use the
  // looser animation-frame bound.
  const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250));

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 0);
    assert.equal(countLines(db), 4);
  } finally {
    db.close();
  }
});

test('an explicit minRunLength raises the bar', () => {
  // Five quarter-second frames qualify under the defaults; a cautious run asking for six
  // leaves them alone. Above the strict bound, so the residue rule stays out of it.
  const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250));

  try {
    const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true });
    assert.equal(preview.burstGroups, 1);

    const summary = cleanupDuplicateSubtitleLines(db, { minRunLength: 6 });
    assert.equal(summary.burstGroups, 0);
    assert.equal(countLines(db), 5);
  } finally {
    db.close();
  }
});

test('an explicit maxFrameSeconds tightens the frame bound', () => {
  const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));

  try {
    const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: 0.2 });

    assert.equal(summary.burstGroups, 0);
    assert.equal(countLines(db), 6);
  } finally {
    db.close();
  }
});

test('a non-finite maxFrameSeconds falls back to the default bound', () => {
  // Six normal-beat lines: Infinity must not turn every event into a "short frame".
  const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800));

  try {
    const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: Infinity });

    assert.equal(summary.burstGroups, 0);
    assert.equal(countLines(db), 6);
  } finally {
    db.close();
  }
});

test('sampleLimit zero removes bursts but reports no samples', () => {
  const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));

  try {
    const summary = cleanupDuplicateSubtitleLines(db, { sampleLimit: 0 });

    assert.equal(summary.removedLines, 39);
    assert.deepEqual(summary.samples, []);
    assert.equal(countLines(db), 1);
  } finally {
    db.close();
  }
});

test('a short run below every threshold survives', () => {
  const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40));

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 0);
    assert.equal(countLines(db), 3);
  } finally {
    db.close();
  }
});

test('interleaved dual-line karaoke collapses each line to one row', () => {
  // Kanji and romaji lines frame-flipped together, the way fansub OPs are typeset. The
  // rows arrive interleaved in time order; each text must still chain into its own run.
  const kanji = karaokeFrames(1, '飛び上がる', 10_000, 20, 60);
  const romaji = karaokeFrames(1, 'tobiagaru', 10_001, 20, 60);
  const interleaved = [...kanji, ...romaji].sort((a, b) => a.startMs - b.startMs);
  const { db } = createDb(interleaved);

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 2);
    assert.equal(summary.removedLines, 38);
    assert.equal(countLines(db), 2);
    assert.equal(wordFrequency(db), 2);
  } finally {
    db.close();
  }
});

test('the same line in a rewatch session is never merged into the first watch', () => {
  const { db } = createDb([
    ...karaokeFrames(1, '飛び上がる', 10_000, 6, 40),
    ...karaokeFrames(2, '飛び上がる', 10_000, 6, 40),
  ]);

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 2);
    assert.equal(summary.removedLines, 10);
    // One surviving line per session, not one across both.
    assert.equal(countLines(db), 2);
    assert.equal(wordFrequency(db), 2);
  } finally {
    db.close();
  }
});

test('a gap between runs splits them', () => {
  const { db } = createDb([
    ...karaokeFrames(1, '飛び上がる', 10_000, 6, 40),
    ...karaokeFrames(1, '飛び上がる', 60_000, 6, 40),
  ]);

  try {
    const summary = cleanupDuplicateSubtitleLines(db);

    assert.equal(summary.burstGroups, 2);
    assert.equal(countLines(db), 2);
  } finally {
    db.close();
  }
});

test('a dry run reports what an apply would do and writes nothing', () => {
  const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));

  try {
    const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true });

    assert.equal(preview.dryRun, true);
    assert.equal(preview.removedLines, 39);
    assert.equal(countLines(db), 40);
    assert.equal(wordFrequency(db), 40);

    const applied = cleanupDuplicateSubtitleLines(db);
    assert.equal(applied.removedLines, preview.removedLines);
    assert.equal(applied.removedWordOccurrences, preview.removedWordOccurrences);
    assert.equal(countLines(db), 1);
  } finally {
    db.close();
  }
});

test('the lookback window leaves older bursts alone', () => {
  const recentMs = BASE_MS;
  const oldMs = BASE_MS - 40 * DAY_MS;
  const { db } = createDb([
    ...karaokeFrames(1, '飛び上がる', 10_000, 6, 40).map((line) => ({
      ...line,
      createdMs: oldMs,
    })),
    ...karaokeFrames(2, '飛び上がる', 10_000, 6, 40).map((line) => ({
      ...line,
      createdMs: recentMs,
    })),
  ]);

  globalThis.__subminerTestNowMs = BASE_MS;
  try {
    const summary = cleanupDuplicateSubtitleLines(db, { lookbackDays: 30 });

    assert.equal(summary.lookbackDays, 30);
    assert.equal(summary.scannedLines, 6);
    assert.equal(summary.burstGroups, 1);
    assert.equal(summary.removedLines, 5);
    // Six untouched old frames plus the one surviving recent line.
    assert.equal(countLines(db), 7);
    assert.equal(wordFrequency(db), 7);
  } finally {
    globalThis.__subminerTestNowMs = undefined;
    db.close();
  }
});
