import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database, type DatabaseSync } from './sqlite';
import { getStatsExcludedWords, replaceStatsExcludedWords } from './query-lexical';
import { finalizeSessionRecord, startSessionRecord } from './session';
import {
  applyPragmas,
  createTrackerPreparedStatements,
  ensureSchema,
  executeQueuedWrite,
  normalizeCoverBlobBytes,
  parseCoverBlobReference,
  getOrCreateAnimeRecord,
  getOrCreateVideoRecord,
  linkVideoToAnimeRecord,
  linkYoutubeVideoToAnimeRecord,
} from './storage';
import {
  EVENT_SUBTITLE_LINE,
  SCHEMA_VERSION,
  SESSION_STATUS_ENDED,
  SOURCE_TYPE_LOCAL,
  SOURCE_TYPE_REMOTE,
} from './types';

function makeDbPath(): string {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-'));
  return path.join(dir, 'immersion.sqlite');
}

function cleanupDbPath(dbPath: string): void {
  const dir = path.dirname(dbPath);
  if (!fs.existsSync(dir)) {
    return;
  }

  const bunRuntime = globalThis as typeof globalThis & {
    Bun?: {
      gc?: (force?: boolean) => void;
    };
  };
  for (let attempt = 0; attempt < 3; attempt += 1) {
    try {
      fs.rmSync(dir, { recursive: true, force: true });
      return;
    } catch (error) {
      const err = error as NodeJS.ErrnoException;
      if (process.platform !== 'win32' || err.code !== 'EBUSY') {
        throw error;
      }
      bunRuntime.Bun?.gc?.(true);
      Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
    }
  }

  // libsql keeps Windows file handles alive after close when prepared statements were used.
}

test('applyPragmas sets the SQLite tuning defaults used by immersion tracking', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    applyPragmas(db);

    const journalModeRow = db.prepare('PRAGMA journal_mode').get() as {
      journal_mode: string;
    };
    const synchronousRow = db.prepare('PRAGMA synchronous').get() as { synchronous: number };
    const foreignKeysRow = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
    const busyTimeoutRow = db.prepare('PRAGMA busy_timeout').get() as { timeout: number };
    const journalSizeLimitRow = db.prepare('PRAGMA journal_size_limit').get() as {
      journal_size_limit: number;
    };

    assert.equal(journalModeRow.journal_mode, 'wal');
    assert.equal(synchronousRow.synchronous, 1);
    assert.equal(foreignKeysRow.foreign_keys, 1);
    assert.equal(busyTimeoutRow.timeout, 2500);
    assert.equal(journalSizeLimitRow.journal_size_limit, 67_108_864);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('applyPragmas installs the busy timeout before WAL negotiation', () => {
  const statements: string[] = [];
  const db: DatabaseSync = {
    exec(source) {
      statements.push(source);
      return db;
    },
    prepare() {
      throw new Error('not used');
    },
    close() {
      return db;
    },
  };

  applyPragmas(db);

  assert.deepEqual(statements.slice(0, 2), [
    'PRAGMA busy_timeout = 2500',
    'PRAGMA journal_mode = WAL',
  ]);
});

test('ensureSchema creates immersion core tables', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    const rows = db
      .prepare(
        `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%' ORDER BY name`,
      )
      .all() as Array<{ name: string }>;
    const tableNames = new Set(rows.map((row) => row.name));

    assert.ok(tableNames.has('imm_videos'));
    assert.ok(tableNames.has('imm_anime'));
    assert.ok(tableNames.has('imm_sessions'));
    assert.ok(tableNames.has('imm_session_telemetry'));
    assert.ok(tableNames.has('imm_session_events'));
    assert.ok(tableNames.has('imm_daily_rollups'));
    assert.ok(tableNames.has('imm_monthly_rollups'));
    assert.ok(tableNames.has('imm_words'));
    assert.ok(tableNames.has('imm_kanji'));
    assert.ok(tableNames.has('imm_subtitle_lines'));
    assert.ok(tableNames.has('imm_word_line_occurrences'));
    assert.ok(tableNames.has('imm_kanji_line_occurrences'));
    assert.ok(tableNames.has('imm_rollup_state'));
    assert.ok(tableNames.has('imm_cover_art_blobs'));
    assert.ok(tableNames.has('imm_youtube_videos'));
    assert.ok(tableNames.has('imm_stats_excluded_words'));

    const videoColumns = new Set(
      (
        db.prepare('PRAGMA table_info(imm_videos)').all() as Array<{
          name: string;
        }>
      ).map((row) => row.name),
    );

    assert.ok(videoColumns.has('anime_id'));
    assert.ok(videoColumns.has('parsed_basename'));
    assert.ok(videoColumns.has('parsed_title'));
    assert.ok(videoColumns.has('parsed_season'));
    assert.ok(videoColumns.has('parsed_episode'));
    assert.ok(videoColumns.has('parser_source'));
    assert.ok(videoColumns.has('parser_confidence'));
    assert.ok(videoColumns.has('parse_metadata_json'));
    assert.ok(videoColumns.has('anime_assignment_locked'));

    const mediaArtColumns = new Set(
      (
        db.prepare('PRAGMA table_info(imm_media_art)').all() as Array<{
          name: string;
        }>
      ).map((row) => row.name),
    );
    assert.ok(mediaArtColumns.has('cover_blob_hash'));

    const rollupStateRow = db
      .prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
      .get('last_rollup_sample_ms') as {
      state_value: string;
    } | null;
    assert.ok(rollupStateRow);
    assert.equal(Number(rollupStateRow?.state_value ?? 0), 0);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('ensureSchema adds manual assignment locks when upgrading the previous schema', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    db.exec('ALTER TABLE imm_videos DROP COLUMN anime_assignment_locked');
    db.prepare('UPDATE imm_schema_version SET schema_version = ?').run(SCHEMA_VERSION - 1);

    ensureSchema(db);

    const columns = new Set(
      (db.prepare('PRAGMA table_info(imm_videos)').all() as Array<{ name: string }>).map(
        (row) => row.name,
      ),
    );
    assert.ok(columns.has('anime_assignment_locked'));
    const version = db
      .prepare('SELECT MAX(schema_version) AS version FROM imm_schema_version')
      .get() as { version: number };
    assert.equal(version.version, SCHEMA_VERSION);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('ensureSchema preserves durable session rollups across unrelated schema upgrades', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    db.exec(`
      INSERT INTO imm_videos (
        video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
      ) VALUES (1, 'local:/tmp/preserved.mkv', 'Preserved', 1, 0, '1', '1');
      INSERT INTO imm_daily_rollups (
        rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
        total_tokens_seen, total_cards
      ) VALUES (20000, 1, 2, 30, 40, 50, 3);
      INSERT INTO imm_monthly_rollups (
        rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
        total_tokens_seen, total_cards
      ) VALUES (202410, 1, 2, 30, 40, 50, 3);
      UPDATE imm_rollup_state
      SET state_value = '123'
      WHERE state_key = 'last_rollup_sample_ms';
      UPDATE imm_schema_version SET schema_version = 21;
    `);

    ensureSchema(db);

    const daily = db
      .prepare('SELECT total_sessions AS totalSessions FROM imm_daily_rollups')
      .get() as { totalSessions: number } | null;
    const monthly = db
      .prepare('SELECT total_sessions AS totalSessions FROM imm_monthly_rollups')
      .get() as { totalSessions: number } | null;
    const rollupState = db
      .prepare(`SELECT state_value AS value FROM imm_rollup_state WHERE state_key = ?`)
      .get('last_rollup_sample_ms') as { value: string } | null;

    assert.equal(daily?.totalSessions, 2);
    assert.equal(monthly?.totalSessions, 2);
    assert.equal(rollupState?.value, '123');
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('stats excluded words are replaced and read from sqlite storage', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);

    replaceStatsExcludedWords(db, [
      { headword: '猫', word: '猫', reading: 'ねこ' },
      { headword: 'する', word: 'する', reading: 'する' },
    ]);
    assert.deepEqual(getStatsExcludedWords(db), [
      { headword: 'する', word: 'する', reading: 'する' },
      { headword: '猫', word: '猫', reading: 'ねこ' },
    ]);

    replaceStatsExcludedWords(db, [{ headword: '犬', word: '犬', reading: 'いぬ' }]);
    assert.deepEqual(getStatsExcludedWords(db), [{ headword: '犬', word: '犬', reading: 'いぬ' }]);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('ensureSchema adds youtube metadata table to existing schema version 15 databases', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    db.exec(`
      CREATE TABLE imm_schema_version (
        schema_version INTEGER PRIMARY KEY,
        applied_at_ms INTEGER NOT NULL
      );
      INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (15, 1000);

      CREATE TABLE imm_rollup_state(
        state_key TEXT PRIMARY KEY,
        state_value INTEGER NOT NULL
      );
      INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 123);

      CREATE TABLE imm_anime(
        anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
        normalized_title_key TEXT NOT NULL UNIQUE,
        canonical_title TEXT NOT NULL,
        anilist_id INTEGER UNIQUE,
        title_romaji TEXT,
        title_english TEXT,
        title_native TEXT,
        episodes_total INTEGER,
        description TEXT,
        metadata_json TEXT,
        CREATED_DATE INTEGER,
        LAST_UPDATE_DATE INTEGER
      );

      CREATE TABLE imm_videos(
        video_id INTEGER PRIMARY KEY AUTOINCREMENT,
        video_key TEXT NOT NULL UNIQUE,
        anime_id INTEGER,
        canonical_title TEXT NOT NULL,
        source_type INTEGER NOT NULL,
        source_path TEXT,
        source_url TEXT,
        parsed_basename TEXT,
        parsed_title TEXT,
        parsed_season INTEGER,
        parsed_episode INTEGER,
        parser_source TEXT,
        parser_confidence REAL,
        parse_metadata_json TEXT,
        watched INTEGER NOT NULL DEFAULT 0,
        duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
        file_size_bytes INTEGER CHECK(file_size_bytes>=0),
        codec_id INTEGER, container_id INTEGER,
        width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
        bitrate_kbps INTEGER, audio_codec_id INTEGER,
        hash_sha256 TEXT, screenshot_path TEXT,
        metadata_json TEXT,
        CREATED_DATE INTEGER,
        LAST_UPDATE_DATE INTEGER,
        FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
      );
    `);

    ensureSchema(db);

    const tables = new Set(
      (
        db
          .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`)
          .all() as Array<{
          name: string;
        }>
      ).map((row) => row.name),
    );
    assert.ok(tables.has('imm_youtube_videos'));

    const columns = new Set(
      (
        db.prepare('PRAGMA table_info(imm_youtube_videos)').all() as Array<{
          name: string;
        }>
      ).map((row) => row.name),
    );

    assert.deepEqual(
      columns,
      new Set([
        'video_id',
        'youtube_video_id',
        'video_url',
        'video_title',
        'video_thumbnail_url',
        'channel_id',
        'channel_name',
        'channel_url',
        'channel_thumbnail_url',
        'uploader_id',
        'uploader_url',
        'description',
        'metadata_json',
        'fetched_at_ms',
        'CREATED_DATE',
        'LAST_UPDATE_DATE',
      ]),
    );
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('ensureSchema migrates session event timestamps to text and repairs libsql-truncated wall-clock values', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    db.exec(`
      CREATE TABLE imm_schema_version (
        schema_version INTEGER PRIMARY KEY,
        applied_at_ms INTEGER NOT NULL
      );
      INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (16, 1000);

      CREATE TABLE imm_rollup_state(
        state_key TEXT PRIMARY KEY,
        state_value INTEGER NOT NULL
      );
      INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 0);

      CREATE TABLE imm_anime(
        anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
        normalized_title_key TEXT NOT NULL UNIQUE,
        canonical_title TEXT NOT NULL,
        anilist_id INTEGER UNIQUE,
        title_romaji TEXT,
        title_english TEXT,
        title_native TEXT,
        episodes_total INTEGER,
        description TEXT,
        metadata_json TEXT,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT
      );

      CREATE TABLE imm_videos(
        video_id INTEGER PRIMARY KEY AUTOINCREMENT,
        video_key TEXT NOT NULL UNIQUE,
        anime_id INTEGER,
        canonical_title TEXT NOT NULL,
        source_type INTEGER NOT NULL,
        source_path TEXT,
        source_url TEXT,
        parsed_basename TEXT,
        parsed_title TEXT,
        parsed_season INTEGER,
        parsed_episode INTEGER,
        parser_source TEXT,
        parser_confidence REAL,
        parse_metadata_json TEXT,
        watched INTEGER NOT NULL DEFAULT 0,
        duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
        file_size_bytes INTEGER CHECK(file_size_bytes>=0),
        codec_id INTEGER, container_id INTEGER,
        width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
        bitrate_kbps INTEGER, audio_codec_id INTEGER,
        hash_sha256 TEXT, screenshot_path TEXT,
        metadata_json TEXT,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
      );

      CREATE TABLE imm_sessions(
        session_id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_uuid TEXT NOT NULL UNIQUE,
        video_id INTEGER NOT NULL,
        started_at_ms TEXT NOT NULL,
        ended_at_ms TEXT,
        status INTEGER NOT NULL,
        locale_id INTEGER,
        target_lang_id INTEGER,
        difficulty_tier INTEGER,
        subtitle_mode INTEGER,
        ended_media_ms INTEGER,
        total_watched_ms INTEGER NOT NULL DEFAULT 0,
        active_watched_ms INTEGER NOT NULL DEFAULT 0,
        lines_seen INTEGER NOT NULL DEFAULT 0,
        tokens_seen INTEGER NOT NULL DEFAULT 0,
        cards_mined INTEGER NOT NULL DEFAULT 0,
        lookup_count INTEGER NOT NULL DEFAULT 0,
        lookup_hits INTEGER NOT NULL DEFAULT 0,
        yomitan_lookup_count INTEGER NOT NULL DEFAULT 0,
        pause_count INTEGER NOT NULL DEFAULT 0,
        pause_ms INTEGER NOT NULL DEFAULT 0,
        seek_forward_count INTEGER NOT NULL DEFAULT 0,
        seek_backward_count INTEGER NOT NULL DEFAULT 0,
        media_buffer_events INTEGER NOT NULL DEFAULT 0,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
      );

      CREATE TABLE imm_session_telemetry(
        telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_id INTEGER NOT NULL,
        sample_ms TEXT NOT NULL,
        total_watched_ms INTEGER NOT NULL DEFAULT 0,
        active_watched_ms INTEGER NOT NULL DEFAULT 0,
        lines_seen INTEGER NOT NULL DEFAULT 0,
        tokens_seen INTEGER NOT NULL DEFAULT 0,
        cards_mined INTEGER NOT NULL DEFAULT 0,
        lookup_count INTEGER NOT NULL DEFAULT 0,
        lookup_hits INTEGER NOT NULL DEFAULT 0,
        yomitan_lookup_count INTEGER NOT NULL DEFAULT 0,
        pause_count INTEGER NOT NULL DEFAULT 0,
        pause_ms INTEGER NOT NULL DEFAULT 0,
        seek_forward_count INTEGER NOT NULL DEFAULT 0,
        seek_backward_count INTEGER NOT NULL DEFAULT 0,
        media_buffer_events INTEGER NOT NULL DEFAULT 0,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
      );

      CREATE TABLE imm_session_events(
        event_id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_id INTEGER NOT NULL,
        ts_ms INTEGER NOT NULL,
        event_type INTEGER NOT NULL,
        line_index INTEGER,
        segment_start_ms INTEGER,
        segment_end_ms INTEGER,
        tokens_delta INTEGER NOT NULL DEFAULT 0,
        cards_delta INTEGER NOT NULL DEFAULT 0,
        payload_json TEXT,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
      );

      CREATE TABLE imm_daily_rollups(
        rollup_day INTEGER NOT NULL,
        video_id INTEGER,
        total_sessions INTEGER NOT NULL DEFAULT 0,
        total_active_min REAL NOT NULL DEFAULT 0,
        total_lines_seen INTEGER NOT NULL DEFAULT 0,
        total_tokens_seen INTEGER NOT NULL DEFAULT 0,
        total_cards INTEGER NOT NULL DEFAULT 0,
        cards_per_hour REAL,
        tokens_per_min REAL,
        lookup_hit_rate REAL,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        PRIMARY KEY (rollup_day, video_id)
      );

      CREATE TABLE imm_monthly_rollups(
        rollup_month INTEGER NOT NULL,
        video_id INTEGER,
        total_sessions INTEGER NOT NULL DEFAULT 0,
        total_active_min REAL NOT NULL DEFAULT 0,
        total_lines_seen INTEGER NOT NULL DEFAULT 0,
        total_tokens_seen INTEGER NOT NULL DEFAULT 0,
        total_cards INTEGER NOT NULL DEFAULT 0,
        cards_per_hour REAL,
        tokens_per_min REAL,
        lookup_hit_rate REAL,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        PRIMARY KEY (rollup_month, video_id)
      );

      CREATE TABLE imm_words(
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        headword TEXT NOT NULL,
        word TEXT NOT NULL,
        reading TEXT NOT NULL,
        part_of_speech TEXT,
        pos1 TEXT,
        pos2 TEXT,
        pos3 TEXT,
        first_seen INTEGER NOT NULL,
        last_seen INTEGER NOT NULL,
        frequency INTEGER NOT NULL DEFAULT 0,
        frequency_rank INTEGER,
        UNIQUE(headword, word, reading)
      );

      CREATE TABLE imm_kanji(
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        kanji TEXT NOT NULL UNIQUE,
        first_seen INTEGER NOT NULL,
        last_seen INTEGER NOT NULL,
        frequency INTEGER NOT NULL DEFAULT 0
      );

      CREATE TABLE imm_subtitle_lines(
        line_id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_id INTEGER NOT NULL,
        event_id INTEGER,
        video_id INTEGER NOT NULL,
        anime_id INTEGER,
        line_index INTEGER NOT NULL,
        segment_start_ms INTEGER,
        segment_end_ms INTEGER,
        text TEXT NOT NULL,
        secondary_text TEXT,
        CREATED_DATE INTEGER,
        LAST_UPDATE_DATE INTEGER,
        FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE,
        FOREIGN KEY(event_id) REFERENCES imm_session_events(event_id) ON DELETE SET NULL,
        FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE,
        FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
      );

      CREATE TABLE imm_word_line_occurrences(
        line_id INTEGER NOT NULL,
        word_id INTEGER NOT NULL,
        occurrence_count INTEGER NOT NULL,
        PRIMARY KEY(line_id, word_id),
        FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
        FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
      );

      CREATE TABLE imm_kanji_line_occurrences(
        line_id INTEGER NOT NULL,
        kanji_id INTEGER NOT NULL,
        occurrence_count INTEGER NOT NULL,
        PRIMARY KEY(line_id, kanji_id),
        FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
        FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
      );

      CREATE TABLE imm_lifetime_global(
        global_id INTEGER PRIMARY KEY CHECK(global_id = 1),
        total_sessions INTEGER NOT NULL DEFAULT 0,
        total_active_ms INTEGER NOT NULL DEFAULT 0,
        total_cards INTEGER NOT NULL DEFAULT 0,
        active_days INTEGER NOT NULL DEFAULT 0,
        episodes_started INTEGER NOT NULL DEFAULT 0,
        episodes_completed INTEGER NOT NULL DEFAULT 0,
        anime_completed INTEGER NOT NULL DEFAULT 0,
        last_rebuilt_ms TEXT,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT
      );

      CREATE TABLE imm_lifetime_anime(
        anime_id INTEGER PRIMARY KEY,
        total_sessions INTEGER NOT NULL DEFAULT 0,
        total_active_ms INTEGER NOT NULL DEFAULT 0,
        total_cards INTEGER NOT NULL DEFAULT 0,
        total_lines_seen INTEGER NOT NULL DEFAULT 0,
        total_tokens_seen INTEGER NOT NULL DEFAULT 0,
        episodes_started INTEGER NOT NULL DEFAULT 0,
        episodes_completed INTEGER NOT NULL DEFAULT 0,
        first_watched_ms TEXT,
        last_watched_ms TEXT,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
      );

      CREATE TABLE imm_lifetime_media(
        video_id INTEGER PRIMARY KEY,
        total_sessions INTEGER NOT NULL DEFAULT 0,
        total_active_ms INTEGER NOT NULL DEFAULT 0,
        total_cards INTEGER NOT NULL DEFAULT 0,
        total_lines_seen INTEGER NOT NULL DEFAULT 0,
        total_tokens_seen INTEGER NOT NULL DEFAULT 0,
        completed INTEGER NOT NULL DEFAULT 0,
        first_watched_ms TEXT,
        last_watched_ms TEXT,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
      );

      CREATE TABLE imm_lifetime_applied_sessions(
        session_id INTEGER PRIMARY KEY,
        applied_at_ms TEXT NOT NULL,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
      );

      CREATE TABLE imm_media_art(
        video_id INTEGER PRIMARY KEY,
        anilist_id INTEGER,
        cover_url TEXT,
        cover_blob BLOB,
        cover_blob_hash TEXT,
        fetched_at_ms TEXT,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
      );

      CREATE TABLE imm_cover_art_blobs(
        blob_hash TEXT PRIMARY KEY,
        cover_blob BLOB NOT NULL,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT
      );

      CREATE TABLE imm_youtube_videos(
        video_id INTEGER PRIMARY KEY,
        youtube_video_id TEXT,
        video_url TEXT,
        video_title TEXT,
        video_thumbnail_url TEXT,
        channel_id TEXT,
        channel_name TEXT,
        channel_url TEXT,
        channel_thumbnail_url TEXT,
        uploader_id TEXT,
        uploader_url TEXT,
        description TEXT,
        metadata_json TEXT,
        fetched_at_ms TEXT NOT NULL,
        CREATED_DATE TEXT,
        LAST_UPDATE_DATE TEXT,
        FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
      );

      INSERT INTO imm_videos (
        video_id, video_key, canonical_title, source_type, source_path, source_url, watched, duration_ms,
        CREATED_DATE, LAST_UPDATE_DATE
      ) VALUES (
        1, 'local:/tmp/repaired-event.mkv', 'Repaired Event', 1, '/tmp/repaired-event.mkv', NULL, 0, 0, '1000', '1000'
      );

      INSERT INTO imm_sessions (
        session_id, session_uuid, video_id, started_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
      ) VALUES (
        1, 'session-1', 1, '1775940000000', 1, '1775940000000', '1775940000000'
      );

      INSERT INTO imm_session_events (
        event_id, session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms,
        tokens_delta, cards_delta, payload_json, CREATED_DATE, LAST_UPDATE_DATE
      ) VALUES (
        1, 1, -2147483648, 4, NULL, NULL, NULL, 0, 1, '{\"noteIds\":[1]}', '1775943304128', '1775943304128'
      );
    `);

    ensureSchema(db);

    const column = db.prepare(`PRAGMA table_info(imm_session_events)`).all() as Array<{
      name: string;
      type: string;
    }>;
    assert.equal(column.find((entry) => entry.name === 'ts_ms')?.type, 'TEXT');

    const row = db
      .prepare(
        `
        SELECT ts_ms AS tsMs, typeof(ts_ms) AS tsType, CREATED_DATE AS createdDate
        FROM imm_session_events
        WHERE event_id = 1
      `,
      )
      .get() as {
      tsMs: string;
      tsType: string;
      createdDate: string;
    };

    assert.equal(row.tsType, 'text');
    assert.equal(row.tsMs, '1775943304128');
    assert.equal(row.createdDate, '1775943304128');
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('ensureSchema creates large-history performance indexes', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    const indexNames = new Set(
      (
        db
          .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_%'`)
          .all() as Array<{
          name: string;
        }>
      ).map((row) => row.name),
    );

    assert.ok(indexNames.has('idx_telemetry_sample_ms'));
    assert.ok(indexNames.has('idx_sessions_started_at'));
    assert.ok(indexNames.has('idx_sessions_ended_at'));
    assert.ok(indexNames.has('idx_words_frequency'));
    assert.ok(indexNames.has('idx_kanji_frequency'));
    assert.ok(indexNames.has('idx_media_art_anilist_id'));
    assert.ok(indexNames.has('idx_media_art_cover_url'));
    assert.ok(indexNames.has('idx_youtube_videos_channel_id'));
    assert.ok(indexNames.has('idx_youtube_videos_youtube_video_id'));
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('ensureSchema adds the subtitle event index to current-version databases', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    db.exec('DROP INDEX idx_subtitle_lines_event_id');

    ensureSchema(db);

    const indexes = db.prepare("PRAGMA index_list('imm_subtitle_lines')").all() as Array<{
      name: string;
      partial: number;
    }>;
    const eventIndex = indexes.find(({ name }) => name === 'idx_subtitle_lines_event_id');
    assert.ok(eventIndex);
    assert.equal(eventIndex.partial, 1);

    const columns = db.prepare("PRAGMA index_info('idx_subtitle_lines_event_id')").all() as Array<{
      name: string;
    }>;
    assert.deepEqual(
      columns.map(({ name }) => name),
      ['event_id'],
    );
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('ensureSchema migrates legacy videos and backfills anime metadata from filenames', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    db.exec(`
      CREATE TABLE imm_schema_version (
        schema_version INTEGER PRIMARY KEY,
        applied_at_ms INTEGER NOT NULL
      );
      INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (4, 1);

      CREATE TABLE imm_videos(
        video_id INTEGER PRIMARY KEY AUTOINCREMENT,
        video_key TEXT NOT NULL UNIQUE,
        canonical_title TEXT NOT NULL,
        source_type INTEGER NOT NULL,
        source_path TEXT,
        source_url TEXT,
        duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
        file_size_bytes INTEGER CHECK(file_size_bytes>=0),
        codec_id INTEGER, container_id INTEGER,
        width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
        bitrate_kbps INTEGER, audio_codec_id INTEGER,
        hash_sha256 TEXT, screenshot_path TEXT,
        metadata_json TEXT,
        CREATED_DATE INTEGER,
        LAST_UPDATE_DATE INTEGER
      );
    `);

    const insertLegacyVideo = db.prepare(`
      INSERT INTO imm_videos (
        video_key, canonical_title, source_type, source_path, source_url,
        duration_ms, file_size_bytes, codec_id, container_id, width_px, height_px,
        fps_x100, bitrate_kbps, audio_codec_id, hash_sha256, screenshot_path,
        metadata_json, CREATED_DATE, LAST_UPDATE_DATE
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `);

    insertLegacyVideo.run(
      'local:/library/Little Witch Academia S02E05.mkv',
      'Episode 5',
      SOURCE_TYPE_LOCAL,
      '/library/Little Witch Academia S02E05.mkv',
      null,
      0,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      1,
      1,
    );
    insertLegacyVideo.run(
      'local:/library/Little Witch Academia S02E06.mkv',
      'Episode 6',
      SOURCE_TYPE_LOCAL,
      '/library/Little Witch Academia S02E06.mkv',
      null,
      0,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      1,
      1,
    );
    insertLegacyVideo.run(
      'local:/library/[SubsPlease] Frieren - 03 - Departure.mkv',
      'Episode 3',
      SOURCE_TYPE_LOCAL,
      '/library/[SubsPlease] Frieren - 03 - Departure.mkv',
      null,
      0,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      1,
      1,
    );

    ensureSchema(db);

    const videoColumns = new Set(
      (
        db.prepare('PRAGMA table_info(imm_videos)').all() as Array<{
          name: string;
        }>
      ).map((row) => row.name),
    );
    assert.ok(videoColumns.has('anime_id'));
    assert.ok(videoColumns.has('parsed_basename'));
    assert.ok(videoColumns.has('parsed_title'));
    assert.ok(videoColumns.has('parsed_season'));
    assert.ok(videoColumns.has('parsed_episode'));
    assert.ok(videoColumns.has('parser_source'));
    assert.ok(videoColumns.has('parser_confidence'));
    assert.ok(videoColumns.has('parse_metadata_json'));
    assert.ok(videoColumns.has('anime_assignment_locked'));

    const animeRows = db
      .prepare('SELECT canonical_title FROM imm_anime ORDER BY canonical_title')
      .all() as Array<{ canonical_title: string }>;
    assert.deepEqual(
      animeRows.map((row) => row.canonical_title),
      ['Frieren', 'Little Witch Academia Season 2'],
    );

    const littleWitchRows = db
      .prepare(
        `
          SELECT
            a.canonical_title AS anime_title,
            v.parsed_title,
            v.parsed_basename,
            v.parsed_season,
            v.parsed_episode,
            v.parser_source,
            v.parser_confidence
          FROM imm_videos v
          JOIN imm_anime a ON a.anime_id = v.anime_id
          WHERE v.video_key LIKE 'local:/library/Little Witch Academia%'
          ORDER BY v.video_key
        `,
      )
      .all() as Array<{
      anime_title: string;
      parsed_title: string | null;
      parsed_basename: string | null;
      parsed_season: number | null;
      parsed_episode: number | null;
      parser_source: string | null;
      parser_confidence: number | null;
    }>;

    assert.equal(littleWitchRows.length, 2);
    assert.deepEqual(
      littleWitchRows.map((row) => ({
        animeTitle: row.anime_title,
        parsedTitle: row.parsed_title,
        parsedBasename: row.parsed_basename,
        parsedSeason: row.parsed_season,
        parsedEpisode: row.parsed_episode,
        parserSource: row.parser_source,
      })),
      [
        {
          animeTitle: 'Little Witch Academia Season 2',
          parsedTitle: 'Little Witch Academia',
          parsedBasename: 'Little Witch Academia S02E05.mkv',
          parsedSeason: 2,
          parsedEpisode: 5,
          parserSource: 'fallback',
        },
        {
          animeTitle: 'Little Witch Academia Season 2',
          parsedTitle: 'Little Witch Academia',
          parsedBasename: 'Little Witch Academia S02E06.mkv',
          parsedSeason: 2,
          parsedEpisode: 6,
          parserSource: 'fallback',
        },
      ],
    );
    assert.ok(
      littleWitchRows.every(
        (row) => typeof row.parser_confidence === 'number' && row.parser_confidence > 0,
      ),
    );

    const frierenRow = db
      .prepare(
        `
          SELECT
            a.canonical_title AS anime_title,
            v.parsed_title,
            v.parsed_episode,
            v.parser_source
          FROM imm_videos v
          JOIN imm_anime a ON a.anime_id = v.anime_id
          WHERE v.video_key = ?
        `,
      )
      .get('local:/library/[SubsPlease] Frieren - 03 - Departure.mkv') as {
      anime_title: string;
      parsed_title: string | null;
      parsed_episode: number | null;
      parser_source: string | null;
    } | null;

    assert.ok(frierenRow);
    assert.equal(frierenRow?.anime_title, 'Frieren');
    assert.equal(frierenRow?.parsed_title, 'Frieren');
    assert.equal(frierenRow?.parsed_episode, 3);
    assert.equal(frierenRow?.parser_source, 'fallback');
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('ensureSchema adds subtitle-line occurrence tables to schema version 6 databases', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    db.exec(`
      CREATE TABLE imm_schema_version (
        schema_version INTEGER PRIMARY KEY,
        applied_at_ms INTEGER NOT NULL
      );
      INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (6, 1);

      CREATE TABLE imm_videos(
        video_id INTEGER PRIMARY KEY AUTOINCREMENT,
        video_key TEXT NOT NULL UNIQUE,
        anime_id INTEGER,
        canonical_title TEXT NOT NULL,
        source_type INTEGER NOT NULL,
        source_path TEXT,
        source_url TEXT,
        parsed_basename TEXT,
        parsed_title TEXT,
        parsed_season INTEGER,
        parsed_episode INTEGER,
        parser_source TEXT,
        parser_confidence REAL,
        parse_metadata_json TEXT,
        duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
        file_size_bytes INTEGER CHECK(file_size_bytes>=0),
        codec_id INTEGER, container_id INTEGER,
        width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
        bitrate_kbps INTEGER, audio_codec_id INTEGER,
        hash_sha256 TEXT, screenshot_path TEXT,
        metadata_json TEXT,
        CREATED_DATE INTEGER,
        LAST_UPDATE_DATE INTEGER
      );
      CREATE TABLE imm_sessions(
        session_id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_uuid TEXT NOT NULL UNIQUE,
        video_id INTEGER NOT NULL,
        started_at_ms INTEGER NOT NULL,
        ended_at_ms INTEGER,
        status INTEGER NOT NULL,
        locale_id INTEGER,
        target_lang_id INTEGER,
        difficulty_tier INTEGER,
        subtitle_mode INTEGER,
        CREATED_DATE INTEGER,
        LAST_UPDATE_DATE INTEGER
      );
      CREATE TABLE imm_session_events(
        event_id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_id INTEGER NOT NULL,
        ts_ms INTEGER NOT NULL,
        event_type INTEGER NOT NULL,
        line_index INTEGER,
        segment_start_ms INTEGER,
        segment_end_ms INTEGER,
        words_delta INTEGER NOT NULL DEFAULT 0,
        cards_delta INTEGER NOT NULL DEFAULT 0,
        payload_json TEXT,
        CREATED_DATE INTEGER,
        LAST_UPDATE_DATE INTEGER
      );
      CREATE TABLE imm_words(
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        headword TEXT,
        word TEXT,
        reading TEXT,
        part_of_speech TEXT,
        pos1 TEXT,
        pos2 TEXT,
        pos3 TEXT,
        first_seen REAL,
        last_seen REAL,
        frequency INTEGER,
        UNIQUE(headword, word, reading)
      );
      CREATE TABLE imm_kanji(
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        kanji TEXT,
        first_seen REAL,
        last_seen REAL,
        frequency INTEGER,
        UNIQUE(kanji)
      );
      CREATE TABLE imm_rollup_state(
        state_key TEXT PRIMARY KEY,
        state_value INTEGER NOT NULL
      );
    `);

    ensureSchema(db);

    const tableNames = new Set(
      (
        db
          .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%'`)
          .all() as Array<{ name: string }>
      ).map((row) => row.name),
    );

    assert.ok(tableNames.has('imm_subtitle_lines'));
    assert.ok(tableNames.has('imm_word_line_occurrences'));
    assert.ok(tableNames.has('imm_kanji_line_occurrences'));
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('ensureSchema migrates legacy cover art blobs into the shared blob store', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    db.prepare('UPDATE imm_schema_version SET schema_version = 12').run();

    const videoId = getOrCreateVideoRecord(db, 'local:/tmp/legacy-cover-art.mkv', {
      canonicalTitle: 'Legacy Cover Art',
      sourcePath: '/tmp/legacy-cover-art.mkv',
      sourceUrl: null,
      sourceType: SOURCE_TYPE_LOCAL,
    });
    const legacyBlob = Uint8Array.from([0xde, 0xad, 0xbe, 0xef]);

    db.prepare(
      `
        INSERT INTO imm_media_art (
          video_id,
          anilist_id,
          cover_url,
          cover_blob,
          cover_blob_hash,
          title_romaji,
          title_english,
          episodes_total,
          fetched_at_ms,
          CREATED_DATE,
          LAST_UPDATE_DATE
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      `,
    ).run(videoId, null, null, legacyBlob, null, null, null, null, 1, 1, 1);

    assert.doesNotThrow(() => ensureSchema(db));

    const mediaArtRow = db
      .prepare(
        'SELECT cover_blob AS coverBlob, cover_blob_hash AS coverBlobHash FROM imm_media_art',
      )
      .get() as {
      coverBlob: ArrayBuffer | Uint8Array | Buffer | null;
      coverBlobHash: string | null;
    } | null;

    assert.ok(mediaArtRow);
    assert.ok(mediaArtRow?.coverBlobHash);
    assert.equal(
      parseCoverBlobReference(normalizeCoverBlobBytes(mediaArtRow?.coverBlob)),
      mediaArtRow?.coverBlobHash,
    );

    const sharedBlobRow = db
      .prepare('SELECT cover_blob AS coverBlob FROM imm_cover_art_blobs WHERE blob_hash = ?')
      .get(mediaArtRow?.coverBlobHash) as {
      coverBlob: ArrayBuffer | Uint8Array | Buffer;
    } | null;

    assert.ok(sharedBlobRow);
    assert.equal(normalizeCoverBlobBytes(sharedBlobRow?.coverBlob)?.toString('hex'), 'deadbeef');
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('anime rows are reused by normalized parsed title and upgraded with AniList metadata', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);

    const firstVideoId = getOrCreateVideoRecord(db, 'local:/tmp/lwa-s02e05.mkv', {
      canonicalTitle: 'Episode 5',
      sourcePath: '/tmp/Little Witch Academia S02E05.mkv',
      sourceUrl: null,
      sourceType: SOURCE_TYPE_LOCAL,
    });
    const secondVideoId = getOrCreateVideoRecord(db, 'local:/tmp/lwa-s02e06.mkv', {
      canonicalTitle: 'Episode 6',
      sourcePath: '/tmp/Little Witch Academia S02E06.mkv',
      sourceUrl: null,
      sourceType: SOURCE_TYPE_LOCAL,
    });

    const provisionalAnimeId = getOrCreateAnimeRecord(db, {
      parsedTitle: 'Little Witch Academia',
      canonicalTitle: 'Little Witch Academia',
      anilistId: null,
      titleRomaji: null,
      titleEnglish: null,
      titleNative: null,
      metadataJson: '{"source":"parsed"}',
    });
    linkVideoToAnimeRecord(db, firstVideoId, {
      animeId: provisionalAnimeId,
      parsedBasename: 'Little Witch Academia S02E05.mkv',
      parsedTitle: 'Little Witch Academia',
      parsedSeason: 2,
      parsedEpisode: 5,
      parserSource: 'fallback',
      parserConfidence: 0.6,
      parseMetadataJson: '{"source":"parsed","episode":5}',
    });

    const reusedAnimeId = getOrCreateAnimeRecord(db, {
      parsedTitle: ' little  witch academia ',
      canonicalTitle: 'Little Witch Academia',
      anilistId: null,
      titleRomaji: null,
      titleEnglish: null,
      titleNative: null,
      metadataJson: '{"source":"parsed"}',
    });
    linkVideoToAnimeRecord(db, secondVideoId, {
      animeId: reusedAnimeId,
      parsedBasename: 'Little Witch Academia S02E06.mkv',
      parsedTitle: 'Little Witch Academia',
      parsedSeason: 2,
      parsedEpisode: 6,
      parserSource: 'fallback',
      parserConfidence: 0.6,
      parseMetadataJson: '{"source":"parsed","episode":6}',
    });

    assert.equal(reusedAnimeId, provisionalAnimeId);

    const upgradedAnimeId = getOrCreateAnimeRecord(db, {
      parsedTitle: 'Little Witch Academia',
      canonicalTitle: 'Little Witch Academia TV',
      anilistId: 33_435,
      titleRomaji: 'Little Witch Academia',
      titleEnglish: 'Little Witch Academia',
      titleNative: 'リトルウィッチアカデミア',
      metadataJson: '{"source":"anilist"}',
    });

    assert.equal(upgradedAnimeId, provisionalAnimeId);

    const animeRows = db.prepare('SELECT * FROM imm_anime').all() as Array<{
      anime_id: number;
      normalized_title_key: string;
      canonical_title: string;
      anilist_id: number | null;
      title_romaji: string | null;
      title_english: string | null;
      title_native: string | null;
      metadata_json: string | null;
    }>;
    assert.equal(animeRows.length, 1);
    assert.equal(animeRows[0]?.anime_id, provisionalAnimeId);
    assert.equal(animeRows[0]?.normalized_title_key, 'little witch academia');
    assert.equal(animeRows[0]?.canonical_title, 'Little Witch Academia TV');
    assert.equal(animeRows[0]?.anilist_id, 33_435);
    assert.equal(animeRows[0]?.title_romaji, 'Little Witch Academia');
    assert.equal(animeRows[0]?.title_english, 'Little Witch Academia');
    assert.equal(animeRows[0]?.title_native, 'リトルウィッチアカデミア');
    assert.equal(animeRows[0]?.metadata_json, '{"source":"anilist"}');

    const linkedVideos = db
      .prepare(
        `
          SELECT anime_id, parsed_title, parsed_season, parsed_episode
          FROM imm_videos
          WHERE video_id IN (?, ?)
          ORDER BY video_id
        `,
      )
      .all(firstVideoId, secondVideoId) as Array<{
      anime_id: number | null;
      parsed_title: string | null;
      parsed_season: number | null;
      parsed_episode: number | null;
    }>;

    assert.deepEqual(linkedVideos, [
      {
        anime_id: provisionalAnimeId,
        parsed_title: 'Little Witch Academia',
        parsed_season: 2,
        parsed_episode: 5,
      },
      {
        anime_id: provisionalAnimeId,
        parsed_title: 'Little Witch Academia',
        parsed_season: 2,
        parsed_episode: 6,
      },
    ]);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('youtube videos can be regrouped under a shared channel anime identity', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);

    const firstVideoId = getOrCreateVideoRecord(
      db,
      'remote:https://www.youtube.com/watch?v=video-1',
      {
        canonicalTitle: 'watch?v video-1',
        sourcePath: null,
        sourceUrl: 'https://www.youtube.com/watch?v=video-1',
        sourceType: SOURCE_TYPE_REMOTE,
      },
    );
    const secondVideoId = getOrCreateVideoRecord(
      db,
      'remote:https://www.youtube.com/watch?v=video-2',
      {
        canonicalTitle: 'watch?v video-2',
        sourcePath: null,
        sourceUrl: 'https://www.youtube.com/watch?v=video-2',
        sourceType: SOURCE_TYPE_REMOTE,
      },
    );

    const firstAnimeId = getOrCreateAnimeRecord(db, {
      parsedTitle: 'watch?v video-1',
      canonicalTitle: 'watch?v video-1',
      anilistId: null,
      titleRomaji: null,
      titleEnglish: null,
      titleNative: null,
      metadataJson: null,
    });
    linkVideoToAnimeRecord(db, firstVideoId, {
      animeId: firstAnimeId,
      parsedBasename: null,
      parsedTitle: 'watch?v video-1',
      parsedSeason: null,
      parsedEpisode: null,
      parserSource: 'fallback',
      parserConfidence: 0.2,
      parseMetadataJson: '{"source":"fallback"}',
    });

    const secondAnimeId = getOrCreateAnimeRecord(db, {
      parsedTitle: 'watch?v video-2',
      canonicalTitle: 'watch?v video-2',
      anilistId: null,
      titleRomaji: null,
      titleEnglish: null,
      titleNative: null,
      metadataJson: null,
    });
    linkVideoToAnimeRecord(db, secondVideoId, {
      animeId: secondAnimeId,
      parsedBasename: null,
      parsedTitle: 'watch?v video-2',
      parsedSeason: null,
      parsedEpisode: null,
      parserSource: 'fallback',
      parserConfidence: 0.2,
      parseMetadataJson: '{"source":"fallback"}',
    });

    linkYoutubeVideoToAnimeRecord(db, firstVideoId, {
      youtubeVideoId: 'video-1',
      videoUrl: 'https://www.youtube.com/watch?v=video-1',
      videoTitle: 'Video One',
      videoThumbnailUrl: 'https://i.ytimg.com/vi/video-1/hqdefault.jpg',
      channelId: 'UC123',
      channelName: 'Channel Name',
      channelUrl: 'https://www.youtube.com/channel/UC123',
      channelThumbnailUrl:
        'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj',
      uploaderId: '@channelname',
      uploaderUrl: 'https://www.youtube.com/@channelname',
      description: null,
      metadataJson: '{"id":"video-1"}',
    });
    linkYoutubeVideoToAnimeRecord(db, secondVideoId, {
      youtubeVideoId: 'video-2',
      videoUrl: 'https://www.youtube.com/watch?v=video-2',
      videoTitle: 'Video Two',
      videoThumbnailUrl: 'https://i.ytimg.com/vi/video-2/hqdefault.jpg',
      channelId: 'UC123',
      channelName: 'Channel Name',
      channelUrl: 'https://www.youtube.com/channel/UC123',
      channelThumbnailUrl:
        'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj',
      uploaderId: '@channelname',
      uploaderUrl: 'https://www.youtube.com/@channelname',
      description: null,
      metadataJson: '{"id":"video-2"}',
    });

    const animeRows = db.prepare('SELECT anime_id, canonical_title FROM imm_anime').all() as Array<{
      anime_id: number;
      canonical_title: string;
    }>;
    const videoRows = db
      .prepare('SELECT video_id, anime_id, parsed_title FROM imm_videos ORDER BY video_id ASC')
      .all() as Array<{ video_id: number; anime_id: number | null; parsed_title: string | null }>;

    const channelAnimeRows = animeRows.filter((row) => row.canonical_title === 'Channel Name');
    assert.equal(channelAnimeRows.length, 1);
    assert.equal(videoRows[0]?.anime_id, channelAnimeRows[0]?.anime_id);
    assert.equal(videoRows[1]?.anime_id, channelAnimeRows[0]?.anime_id);
    assert.equal(videoRows[0]?.parsed_title, 'Channel Name');
    assert.equal(videoRows[1]?.parsed_title, 'Channel Name');
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('youtube channel relinking preserves a locked manual assignment', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    const videoId = getOrCreateVideoRecord(db, 'remote:https://www.youtube.com/watch?v=locked', {
      canonicalTitle: 'Locked Video',
      sourcePath: null,
      sourceUrl: 'https://www.youtube.com/watch?v=locked',
      sourceType: SOURCE_TYPE_REMOTE,
    });
    const manualAnimeId = getOrCreateAnimeRecord(db, {
      parsedTitle: 'Manual Collection',
      canonicalTitle: 'Manual Collection',
      anilistId: null,
      titleRomaji: null,
      titleEnglish: null,
      titleNative: null,
      metadataJson: null,
    });
    linkVideoToAnimeRecord(db, videoId, {
      animeId: manualAnimeId,
      parsedBasename: null,
      parsedTitle: 'Manual Collection',
      parsedSeason: null,
      parsedEpisode: null,
      parserSource: 'manual-test',
      parserConfidence: 1,
      parseMetadataJson: null,
    });
    db.prepare('UPDATE imm_videos SET anime_assignment_locked = 1 WHERE video_id = ?').run(videoId);

    const linkedAnimeId = linkYoutubeVideoToAnimeRecord(db, videoId, {
      youtubeVideoId: 'locked',
      videoUrl: 'https://www.youtube.com/watch?v=locked',
      videoTitle: 'Locked Video',
      videoThumbnailUrl: null,
      channelId: 'UC-locked',
      channelName: 'Automatic Channel',
      channelUrl: null,
      channelThumbnailUrl: null,
      uploaderId: null,
      uploaderUrl: null,
      description: null,
      metadataJson: null,
    });

    assert.equal(linkedAnimeId, manualAnimeId);
    const video = db
      .prepare('SELECT anime_id, parsed_title FROM imm_videos WHERE video_id = ?')
      .get(videoId) as { anime_id: number | null; parsed_title: string | null };
    assert.equal(video.anime_id, manualAnimeId);
    assert.equal(video.parsed_title, 'Manual Collection');
    const automaticAnime = db
      .prepare(`SELECT anime_id FROM imm_anime WHERE canonical_title = 'Automatic Channel'`)
      .get();
    assert.equal(automaticAnime, undefined);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('start/finalize session updates ended_at and status', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    const videoId = getOrCreateVideoRecord(db, 'local:/tmp/slice-a.mkv', {
      canonicalTitle: 'Slice A Episode',
      sourcePath: '/tmp/slice-a.mkv',
      sourceUrl: null,
      sourceType: SOURCE_TYPE_LOCAL,
    });
    const startedAtMs = 1_234_567_000;
    const endedAtMs = startedAtMs + 8_500;
    const { sessionId, state } = startSessionRecord(db, videoId, startedAtMs);

    finalizeSessionRecord(db, state, endedAtMs);

    const row = db
      .prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?')
      .get(sessionId) as {
      ended_at_ms: string | null;
      status: number;
    } | null;

    assert.ok(row);
    assert.equal(Number(row?.ended_at_ms ?? 0), endedAtMs);
    assert.equal(row?.status, SESSION_STATUS_ENDED);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('finalize session persists ended media position', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    const videoId = getOrCreateVideoRecord(db, 'local:/tmp/slice-a-ended-media.mkv', {
      canonicalTitle: 'Slice A Ended Media',
      sourcePath: '/tmp/slice-a-ended-media.mkv',
      sourceUrl: null,
      sourceType: SOURCE_TYPE_LOCAL,
    });
    const startedAtMs = 1_234_567_000;
    const endedAtMs = startedAtMs + 8_500;
    const { sessionId, state } = startSessionRecord(db, videoId, startedAtMs);
    state.lastMediaMs = 91_000;

    finalizeSessionRecord(db, state, endedAtMs);

    const row = db
      .prepare('SELECT ended_media_ms FROM imm_sessions WHERE session_id = ?')
      .get(sessionId) as {
      ended_media_ms: number | null;
    } | null;

    assert.ok(row);
    assert.equal(row?.ended_media_ms, 91_000);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('executeQueuedWrite inserts event and telemetry rows', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    const stmts = createTrackerPreparedStatements(db);
    const videoId = getOrCreateVideoRecord(db, 'local:/tmp/slice-a-events.mkv', {
      canonicalTitle: 'Slice A Events',
      sourcePath: '/tmp/slice-a-events.mkv',
      sourceUrl: null,
      sourceType: SOURCE_TYPE_LOCAL,
    });
    const { sessionId } = startSessionRecord(db, videoId, 5_000);

    executeQueuedWrite(
      {
        kind: 'telemetry',
        sessionId,
        sampleMs: 6_000,
        totalWatchedMs: 1_000,
        activeWatchedMs: 900,
        linesSeen: 3,
        tokensSeen: 6,
        cardsMined: 1,
        lookupCount: 2,
        lookupHits: 1,
        yomitanLookupCount: 0,
        pauseCount: 1,
        pauseMs: 50,
        seekForwardCount: 0,
        seekBackwardCount: 0,
        mediaBufferEvents: 0,
      },
      stmts,
    );
    executeQueuedWrite(
      {
        kind: 'event',
        sessionId,
        sampleMs: 6_100,
        eventType: EVENT_SUBTITLE_LINE,
        lineIndex: 1,
        segmentStartMs: 0,
        segmentEndMs: 800,
        tokensDelta: 2,
        cardsDelta: 0,
        payloadJson: '{"event":"subtitle-line"}',
      },
      stmts,
    );

    const telemetryCount = db
      .prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry WHERE session_id = ?')
      .get(sessionId) as { total: number };
    const eventCount = db
      .prepare('SELECT COUNT(*) AS total FROM imm_session_events WHERE session_id = ?')
      .get(sessionId) as { total: number };

    assert.equal(telemetryCount.total, 1);
    assert.equal(eventCount.total, 1);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('executeQueuedWrite rejects partial telemetry writes instead of zero-filling', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    const stmts = createTrackerPreparedStatements(db);
    const videoId = getOrCreateVideoRecord(db, 'local:/tmp/partial-telemetry.mkv', {
      canonicalTitle: 'Partial Telemetry',
      sourcePath: '/tmp/partial-telemetry.mkv',
      sourceUrl: null,
      sourceType: SOURCE_TYPE_LOCAL,
    });
    const { sessionId } = startSessionRecord(db, videoId, 5_000);

    assert.throws(
      () =>
        executeQueuedWrite(
          {
            kind: 'telemetry',
            sessionId,
            sampleMs: 6_000,
            totalWatchedMs: 1_000,
            activeWatchedMs: 900,
            linesSeen: 3,
            cardsMined: 1,
            lookupCount: 2,
            lookupHits: 1,
            yomitanLookupCount: 0,
            pauseCount: 1,
            pauseMs: 50,
            seekForwardCount: 0,
            seekBackwardCount: 0,
            mediaBufferEvents: 0,
          },
          stmts,
        ),
      /Incomplete telemetry write/,
    );

    const telemetryCount = db
      .prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry WHERE session_id = ?')
      .get(sessionId) as { total: number };
    assert.equal(telemetryCount.total, 0);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('executeQueuedWrite inserts and upserts word and kanji rows', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    const stmts = createTrackerPreparedStatements(db);

    stmts.wordUpsertStmt.run('猫', '猫', '', 'noun', '名詞', '一般', '', 10.0, 10.0);
    stmts.wordUpsertStmt.run('猫', '猫', '', 'noun', '名詞', '一般', '', 5.0, 15.0);
    stmts.kanjiUpsertStmt.run('日', 9.0, 9.0);
    stmts.kanjiUpsertStmt.run('日', 8.0, 11.0);

    const wordRow = db
      .prepare(
        `SELECT headword, frequency, part_of_speech, pos1, pos2, first_seen, last_seen
         FROM imm_words WHERE headword = ?`,
      )
      .get('猫') as {
      headword: string;
      frequency: number;
      part_of_speech: string;
      pos1: string;
      pos2: string;
      first_seen: number;
      last_seen: number;
    } | null;
    const kanjiRow = db
      .prepare('SELECT kanji, frequency, first_seen, last_seen FROM imm_kanji WHERE kanji = ?')
      .get('日') as {
      kanji: string;
      frequency: number;
      first_seen: number;
      last_seen: number;
    } | null;

    assert.ok(wordRow);
    assert.ok(kanjiRow);
    assert.equal(wordRow?.frequency, 2);
    assert.equal(wordRow?.part_of_speech, 'noun');
    assert.equal(wordRow?.pos1, '名詞');
    assert.equal(wordRow?.pos2, '一般');
    assert.equal(kanjiRow?.frequency, 2);
    assert.equal(wordRow?.first_seen, 5);
    assert.equal(wordRow?.last_seen, 15);
    assert.equal(kanjiRow?.first_seen, 8);
    assert.equal(kanjiRow?.last_seen, 11);
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});

test('word upsert replaces legacy other part_of_speech when better POS metadata arrives later', () => {
  const dbPath = makeDbPath();
  const db = new Database(dbPath);

  try {
    ensureSchema(db);
    const stmts = createTrackerPreparedStatements(db);

    stmts.wordUpsertStmt.run(
      '知っている',
      '知っている',
      'しっている',
      'other',
      '動詞',
      '自立',
      '',
      10,
      10,
    );
    stmts.wordUpsertStmt.run(
      '知っている',
      '知っている',
      'しっている',
      'verb',
      '動詞',
      '自立',
      '',
      11,
      12,
    );

    const row = db
      .prepare('SELECT frequency, part_of_speech, pos1, pos2 FROM imm_words WHERE headword = ?')
      .get('知っている') as {
      frequency: number;
      part_of_speech: string;
      pos1: string;
      pos2: string;
    } | null;

    assert.ok(row);
    assert.equal(row?.frequency, 2);
    assert.equal(row?.part_of_speech, 'verb');
    assert.equal(row?.pos1, '動詞');
    assert.equal(row?.pos2, '自立');
  } finally {
    db.close();
    cleanupDbPath(dbPath);
  }
});
