// Contract tests for the storage layer, run against the web (Dexie) repo.
//
// fake-indexeddb/auto polyfills IndexedDB into the Node global so Dexie works
// under vitest. We test the Dexie repo as the reference implementation of the
// StorageRepo contract; the native expo-sqlite repo (native-only) is NOT
// imported here because expo-sqlite cannot load outside a device/simulator.
//
// IMPORTANT: repo.web.ts is *not* statically imported. To exercise the v1->v2
// migration we must first seed a v1-shaped DB with a raw Dexie connection, and
// only THEN import repo.web (whose module-eval opens the DB at v2, triggering
// the upgrade). The dynamically-imported repo is stashed in a module-level
// binding so all subsequent tests reuse it.
import 'fake-indexeddb/auto';

import Dexie from 'dexie';
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import {
  parseDraft,
  type TranscriptDraft,
  type StorageRepo,
  type SegmentVector,
} from './index';

// Populated by the migration beforeAll (which seeds v1 then imports repo.web).
let repo: StorageRepo;

// The v1 record we seed: NO segment ids, NO courseId, but WITH a searchText
// column (exactly the legacy shape the v1 repo persisted).
const V1_ID = 't_legacy';
const v1Row = {
  id: V1_ID,
  title: 'Legacy Lecture',
  createdAt: 1000,
  updatedAt: 1000,
  durationSec: 42.5,
  modelId: 'tiny.en' as const,
  segmentCount: 2,
  segments: [
    { start: 0, end: 2, text: 'legacy hello' },
    { start: 2, end: 5, text: 'legacy world', confidence: 0.8 },
  ],
  searchText: 'legacy lecture\nlegacy hello\nlegacy world',
};

beforeAll(async () => {
  // 1) Seed a v1 DB with a raw Dexie connection (only the v1 store/schema).
  const v1 = new Dexie('wisp');
  v1.version(1).stores({ transcripts: 'id, createdAt' });
  await v1.open();
  await v1.table('transcripts').put(v1Row);
  v1.close();

  // 2) Now import repo.web, which opens 'wisp' at v2 and runs the upgrade.
  const mod = await import('./repo.web');
  repo = mod.repo;

  // Stub object-URL creation so getMediaUrl returns a string under Node.
  globalThis.URL.createObjectURL = () => 'blob:mock';
});

// Helper: a minimal valid draft, overridable per test.
function makeDraft(over: Partial<TranscriptDraft> = {}): TranscriptDraft {
  return {
    title: 'My Recording',
    durationSec: 12.5,
    modelId: 'tiny.en',
    segments: [
      { start: 0, end: 2, text: 'hello world' },
      { start: 2, end: 5, text: 'second segment', confidence: 0.9 },
    ],
    ...over,
  };
}

describe('v1 -> v2 migration (no data loss)', () => {
  it('keeps the legacy transcript, backfills segment ids, leaves it Unsorted', async () => {
    const fetched = await repo.get(V1_ID);
    expect(fetched).toBeDefined();

    // Identity-preserving fields survive untouched.
    expect(fetched!.title).toBe('Legacy Lecture');
    expect(fetched!.durationSec).toBe(42.5);

    // Segment text/timing is identical to the v1 body...
    expect(fetched!.segments.map((s) => s.text)).toEqual([
      'legacy hello',
      'legacy world',
    ]);
    expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end }))).toEqual([
      { start: 0, end: 2 },
      { start: 2, end: 5 },
    ]);
    expect(fetched!.segments[1]!.confidence).toBe(0.8);

    // ...but every segment now carries a backfilled stable id.
    for (const s of fetched!.segments) {
      expect(s.id).toMatch(/^s_/);
    }

    // No courseId was set by v1 => the row is "Unsorted" (null/undefined).
    expect(fetched!.courseId == null).toBe(true);

    // And it shows up under the Unsorted bucket.
    const unsorted = await repo.listByCourse(null);
    expect(unsorted.map((m) => m.id)).toContain(V1_ID);
  });
});

describe('StorageRepo (Dexie web impl)', () => {
  // Wipe transcripts/courses/media between tests so ordering/search/course
  // assertions are deterministic. (Runs after the migration beforeAll.)
  beforeEach(async () => {
    const all = await repo.list();
    await Promise.all(all.map((m) => repo.remove(m.id)));
    const courses = await repo.listCourses();
    await Promise.all(courses.map((c) => repo.deleteCourse(c.id)));
  });

  it('create -> get round-trips segments and metadata, assigns segment ids', async () => {
    const created = await repo.create(makeDraft());

    expect(created.id).toMatch(/^t_/);
    expect(created.createdAt).toBeTypeOf('number');
    expect(created.updatedAt).toBe(created.createdAt);
    expect(created.segmentCount).toBe(2);
    // courseId defaults to null ("Unsorted") when absent.
    expect(created.courseId).toBeNull();
    expect(created.hasMedia).toBe(false);
    // Every segment got a stable id assigned on write.
    for (const s of created.segments) {
      expect(s.id).toMatch(/^s_/);
    }

    const fetched = await repo.get(created.id);
    expect(fetched).toBeDefined();
    expect(fetched!.title).toBe('My Recording');
    expect(fetched!.durationSec).toBe(12.5);
    expect(fetched!.modelId).toBe('tiny.en');
    // Text/timing survive the round-trip exactly.
    expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end, text: s.text }))).toEqual([
      { start: 0, end: 2, text: 'hello world' },
      { start: 2, end: 5, text: 'second segment' },
    ]);
    expect(fetched!.segments[1]!.confidence).toBe(0.9);
  });

  it('get returns undefined for an unknown id', async () => {
    expect(await repo.get('t_nope')).toBeUndefined();
  });

  it('list returns metadatas newest-first (without segments)', async () => {
    const a = await repo.create(makeDraft({ title: 'first' }));
    await new Promise((r) => setTimeout(r, 2));
    const b = await repo.create(makeDraft({ title: 'second' }));
    await new Promise((r) => setTimeout(r, 2));
    const c = await repo.create(makeDraft({ title: 'third' }));

    const metas = await repo.list();
    expect(metas.map((m) => m.id)).toEqual([c.id, b.id, a.id]);
    expect((metas[0] as unknown as Record<string, unknown>).segments).toBeUndefined();
    expect(metas[0]!.segmentCount).toBe(2);
  });

  it('update merges title/segments/courseId and re-ids segments + re-derives search', async () => {
    const course = await repo.createCourse({ name: 'Physics' });
    const created = await repo.create(
      makeDraft({ title: 'Old Title', segments: [{ start: 0, end: 1, text: 'apple' }] }),
    );
    const originalUpdatedAt = created.updatedAt;
    await new Promise((r) => setTimeout(r, 2));

    const updated = await repo.update(created.id, {
      title: 'Brand New Title',
      courseId: course.id,
      segments: [
        { start: 0, end: 1, text: 'banana' },
        { start: 1, end: 2, text: 'cherry' },
      ],
    });

    expect(updated.title).toBe('Brand New Title');
    expect(updated.courseId).toBe(course.id);
    expect(updated.segmentCount).toBe(2);
    expect(updated.updatedAt).toBeGreaterThan(originalUpdatedAt);
    // New segments got ids.
    for (const s of updated.segments) {
      expect(s.id).toMatch(/^s_/);
    }

    // Old text is no longer findable...
    expect(await repo.search('apple')).toHaveLength(0);
    // ...and the new text + new title are.
    expect((await repo.search('cherry')).map((m) => m.id)).toEqual([created.id]);
    expect((await repo.search('brand new')).map((m) => m.id)).toEqual([created.id]);
  });

  it('update throws for an unknown id', async () => {
    await expect(repo.update('t_missing', { title: 'x' })).rejects.toThrow();
  });

  it('search matches title and segment text case-insensitively', async () => {
    const t = await repo.create(
      makeDraft({
        title: 'Quarterly MEETING notes',
        segments: [{ start: 0, end: 3, text: 'Discuss the BUDGET forecast' }],
      }),
    );

    expect((await repo.search('meeting')).map((m) => m.id)).toEqual([t.id]);
    expect((await repo.search('budget')).map((m) => m.id)).toEqual([t.id]);
    expect(await repo.search('zzz-not-present')).toHaveLength(0);
  });

  it('remove deletes a transcript (and its media)', async () => {
    const created = await repo.create(makeDraft());
    await repo.putMedia(created.id, { data: new ArrayBuffer(4), mime: 'audio/mpeg' });
    expect(await repo.get(created.id)).toBeDefined();

    await repo.remove(created.id);
    expect(await repo.get(created.id)).toBeUndefined();
    expect(await repo.list()).toHaveLength(0);
    // Media is gone too: no playable URL remains.
    expect(await repo.getMediaUrl(created.id)).toBeUndefined();
  });

  // --- courses -------------------------------------------------------------

  it('listByCourse splits Unsorted vs a course', async () => {
    const course = await repo.createCourse({ name: 'Bio 101' });
    const inCourse = await repo.create(makeDraft({ title: 'lecture', courseId: course.id }));
    const unsorted = await repo.create(makeDraft({ title: 'loose note' }));

    const courseRows = await repo.listByCourse(course.id);
    expect(courseRows.map((m) => m.id)).toEqual([inCourse.id]);

    const unsortedRows = await repo.listByCourse(null);
    expect(unsortedRows.map((m) => m.id)).toEqual([unsorted.id]);
  });

  it('createCourse / listCourses (alphabetical) / updateCourse', async () => {
    const zebra = await repo.createCourse({ name: 'Zebra Studies', code: 'ZOO-1' });
    const apple = await repo.createCourse({ name: 'Apple Theory' });

    expect(apple.id).toMatch(/^c_/);
    expect(apple.createdAt).toBeTypeOf('number');

    // Alphabetical by name.
    const listed = await repo.listCourses();
    expect(listed.map((c) => c.name)).toEqual(['Apple Theory', 'Zebra Studies']);

    const renamed = await repo.updateCourse(zebra.id, { name: 'Aardvark Studies' });
    expect(renamed.name).toBe('Aardvark Studies');
    expect(renamed.code).toBe('ZOO-1'); // unspecified fields preserved
    expect(renamed.updatedAt).toBeGreaterThanOrEqual(zebra.updatedAt);

    const relisted = await repo.listCourses();
    expect(relisted.map((c) => c.name)).toEqual(['Aardvark Studies', 'Apple Theory']);
  });

  it('deleteCourse reassigns its transcripts to Unsorted, then removes the course', async () => {
    const course = await repo.createCourse({ name: 'Doomed' });
    const t = await repo.create(makeDraft({ title: 'orphan-to-be', courseId: course.id }));

    await repo.deleteCourse(course.id);

    // The course is gone.
    expect((await repo.listCourses()).map((c) => c.id)).not.toContain(course.id);
    // The transcript survives, now Unsorted.
    const fetched = await repo.get(t.id);
    expect(fetched).toBeDefined();
    expect(fetched!.courseId).toBeNull();
    expect((await repo.listByCourse(null)).map((m) => m.id)).toContain(t.id);
  });

  it('reassign moves a transcript between courses and to Unsorted', async () => {
    const course = await repo.createCourse({ name: 'Chemistry' });
    const t = await repo.create(makeDraft());
    expect(t.courseId).toBeNull();

    await repo.reassign(t.id, course.id);
    expect((await repo.listByCourse(course.id)).map((m) => m.id)).toEqual([t.id]);

    await repo.reassign(t.id, null);
    expect((await repo.listByCourse(course.id))).toHaveLength(0);
    expect((await repo.listByCourse(null)).map((m) => m.id)).toContain(t.id);
  });

  // --- media ---------------------------------------------------------------

  it('putMedia flips hasMedia true and getMediaUrl returns a url; removeMedia flips it false', async () => {
    const t = await repo.create(makeDraft());
    expect(t.hasMedia).toBe(false);

    await repo.putMedia(t.id, { data: new ArrayBuffer(8), mime: 'audio/mpeg' });

    const afterPut = await repo.get(t.id);
    expect(afterPut!.hasMedia).toBe(true);
    expect(await repo.getMediaUrl(t.id)).toBe('blob:mock');

    await repo.removeMedia(t.id);
    const afterRemove = await repo.get(t.id);
    expect(afterRemove!.hasMedia).toBe(false);
    expect(await repo.getMediaUrl(t.id)).toBeUndefined();
  });

  // --- validation gatekeeping ---------------------------------------------

  it('parseDraft rejects a NaN/Infinity duration', () => {
    expect(() => parseDraft(makeDraft({ durationSec: NaN }))).toThrow();
    expect(() => parseDraft(makeDraft({ durationSec: Infinity }))).toThrow();
  });

  it('parseDraft rejects a non-finite segment time', () => {
    expect(() =>
      parseDraft(makeDraft({ segments: [{ start: 0, end: Infinity, text: 'x' }] })),
    ).toThrow();
    expect(() =>
      parseDraft(makeDraft({ segments: [{ start: NaN, end: 1, text: 'x' }] })),
    ).toThrow();
  });

  it('create rejects an invalid draft (defense in depth)', async () => {
    await expect(repo.create(makeDraft({ durationSec: -1 }))).rejects.toThrow();
  });
});

// ---------------------------------------------------------------------------
// Phase 1: semantic-search vector store
// ---------------------------------------------------------------------------
//
// We exercise the brute-force cosine store with tiny hand-made UNIT vectors so
// the math is checkable by eye. The repo must not hard-code 384 dims — these
// tests use 3-d vectors throughout. Cosine of two unit vectors is their dot
// product, so [1,0,0]·[1,0,0] = 1 and [1,0,0]·[0,1,0] = 0.

const MODEL = 'Xenova/multilingual-e5-small';

// Three orthonormal basis vectors, used as stand-in segment embeddings.
const E0 = () => new Float32Array([1, 0, 0]);
const E1 = () => new Float32Array([0, 1, 0]);
const E2 = () => new Float32Array([0, 0, 1]);

describe('vectors (semantic search store)', () => {
  beforeEach(async () => {
    // Wipe transcripts (cascades drop their vectors) + courses between tests.
    const all = await repo.list();
    await Promise.all(all.map((m) => repo.remove(m.id)));
    const courses = await repo.listCourses();
    await Promise.all(courses.map((c) => repo.deleteCourse(c.id)));
  });

  // Create a transcript whose segments carry stable ids, then return it.
  async function makeTranscript(over: Partial<TranscriptDraft> = {}) {
    return repo.create(
      makeDraft({
        segments: [
          { start: 0, end: 1, text: 'alpha' },
          { start: 1, end: 2, text: 'beta' },
          { start: 2, end: 3, text: 'gamma' },
        ],
        ...over,
      }),
    );
  }

  // Build SegmentVector rows pairing each segment id with one of the vectors.
  function vecsFor(
    t: { segments: { id?: string; start: number; end: number; text: string }[] },
    vectors: Float32Array[],
  ): SegmentVector[] {
    return t.segments.map((s, i) => ({
      segmentId: s.id!,
      start: s.start,
      end: s.end,
      text: s.text,
      vector: vectors[i]!,
    }));
  }

  it('upsertVectors + searchVectors ranks the matching segment first with score ~1', async () => {
    const t = await makeTranscript();
    await repo.upsertVectors(t.id, MODEL, vecsFor(t, [E0(), E1(), E2()]));

    const hits = await repo.searchVectors(E0());
    expect(hits).toHaveLength(3);
    // The [1,0,0] segment ("alpha") ranks first with cosine ~1.
    expect(hits[0]!.segmentId).toBe(t.segments[0]!.id);
    expect(hits[0]!.text).toBe('alpha');
    expect(hits[0]!.score).toBeCloseTo(1, 6);
    // The orthogonal segments score ~0.
    expect(hits[1]!.score).toBeCloseTo(0, 6);
    expect(hits[2]!.score).toBeCloseTo(0, 6);
    // Hits carry the segment-jump anchors + denormalized courseId.
    expect(hits[0]!.transcriptId).toBe(t.id);
    expect(hits[0]!.start).toBe(0);
    expect(hits[0]!.end).toBe(1);
    expect(hits[0]!.courseId).toBeNull();
  });

  it('searchVectors honours the limit option', async () => {
    const t = await makeTranscript();
    await repo.upsertVectors(t.id, MODEL, vecsFor(t, [E0(), E1(), E2()]));

    const hits = await repo.searchVectors(E0(), { limit: 1 });
    expect(hits).toHaveLength(1);
    expect(hits[0]!.segmentId).toBe(t.segments[0]!.id);
  });

  it('upsertVectors replaces prior vectors for a transcript (no stale rows)', async () => {
    const t = await makeTranscript();
    await repo.upsertVectors(t.id, MODEL, vecsFor(t, [E0(), E1(), E2()]));
    // Re-embed with only the first two segments.
    await repo.upsertVectors(t.id, MODEL, [
      {
        segmentId: t.segments[0]!.id!,
        start: 0,
        end: 1,
        text: 'alpha',
        vector: E0(),
      },
      {
        segmentId: t.segments[1]!.id!,
        start: 1,
        end: 2,
        text: 'beta',
        vector: E1(),
      },
    ]);
    const hits = await repo.searchVectors(E0());
    expect(hits).toHaveLength(2);
  });

  it('courseId filter scopes search (course vs Unsorted vs all)', async () => {
    const course = await repo.createCourse({ name: 'Physics' });
    const inCourse = await makeTranscript({ courseId: course.id });
    const unsorted = await makeTranscript();

    await repo.upsertVectors(inCourse.id, MODEL, vecsFor(inCourse, [E0(), E1(), E2()]));
    await repo.upsertVectors(unsorted.id, MODEL, vecsFor(unsorted, [E0(), E1(), E2()]));

    // Scoped to the course: only that transcript's rows are searched.
    const courseHits = await repo.searchVectors(E0(), { courseId: course.id });
    expect(courseHits.every((h) => h.transcriptId === inCourse.id)).toBe(true);
    expect(courseHits.every((h) => h.courseId === course.id)).toBe(true);
    expect(courseHits).toHaveLength(3);

    // Scoped to Unsorted (null): only the unsorted transcript's rows.
    const unsortedHits = await repo.searchVectors(E0(), { courseId: null });
    expect(unsortedHits.every((h) => h.transcriptId === unsorted.id)).toBe(true);
    expect(unsortedHits.every((h) => h.courseId === null)).toBe(true);
    expect(unsortedHits).toHaveLength(3);

    // No scope => everything (both transcripts).
    const allHits = await repo.searchVectors(E0());
    expect(allHits).toHaveLength(6);
  });

  it('unembeddedIds excludes embedded transcripts and includes fresh ones', async () => {
    const embedded = await makeTranscript();
    const fresh = await makeTranscript();
    await repo.upsertVectors(embedded.id, MODEL, vecsFor(embedded, [E0(), E1(), E2()]));

    const pending = await repo.unembeddedIds(MODEL);
    expect(pending).not.toContain(embedded.id);
    expect(pending).toContain(fresh.id);

    // A different model id has no vectors at all => both are unembedded.
    const otherModel = await repo.unembeddedIds('some/other-model');
    expect(otherModel).toContain(embedded.id);
    expect(otherModel).toContain(fresh.id);
  });

  it('clearVectors empties a transcript and re-adds it to unembeddedIds', async () => {
    const t = await makeTranscript();
    await repo.upsertVectors(t.id, MODEL, vecsFor(t, [E0(), E1(), E2()]));
    expect(await repo.searchVectors(E0())).toHaveLength(3);

    await repo.clearVectors(t.id);
    expect(await repo.searchVectors(E0())).toHaveLength(0);
    expect(await repo.unembeddedIds(MODEL)).toContain(t.id);
  });

  it('reassign updates the denormalized courseId on existing hits', async () => {
    const course = await repo.createCourse({ name: 'Chemistry' });
    const t = await makeTranscript();
    await repo.upsertVectors(t.id, MODEL, vecsFor(t, [E0(), E1(), E2()]));

    // Initially Unsorted: hits carry courseId null and match the null scope.
    expect((await repo.searchVectors(E0(), { courseId: null }))).toHaveLength(3);

    await repo.reassign(t.id, course.id);

    // Now the hits carry the new courseId and match the course scope...
    const courseHits = await repo.searchVectors(E0(), { courseId: course.id });
    expect(courseHits).toHaveLength(3);
    expect(courseHits.every((h) => h.courseId === course.id)).toBe(true);
    // ...and no longer appear under Unsorted.
    expect(await repo.searchVectors(E0(), { courseId: null })).toHaveLength(0);
  });

  it('updating segments clears stale vectors (transcript becomes unembedded again)', async () => {
    const t = await makeTranscript();
    await repo.upsertVectors(t.id, MODEL, vecsFor(t, [E0(), E1(), E2()]));
    expect(await repo.unembeddedIds(MODEL)).not.toContain(t.id);

    // Patching segments invalidates the embeddings => repo drops them.
    await repo.update(t.id, {
      segments: [{ start: 0, end: 1, text: 'rewritten' }],
    });

    expect(await repo.searchVectors(E0())).toHaveLength(0);
    expect(await repo.unembeddedIds(MODEL)).toContain(t.id);
  });

  it('remove cascades to a transcript vectors', async () => {
    const t = await makeTranscript();
    await repo.upsertVectors(t.id, MODEL, vecsFor(t, [E0(), E1(), E2()]));
    expect(await repo.searchVectors(E0())).toHaveLength(3);

    await repo.remove(t.id);
    expect(await repo.searchVectors(E0())).toHaveLength(0);
  });
});

// ---------------------------------------------------------------------------
// Phase 3: flashcards + SM-2 spaced repetition store
// ---------------------------------------------------------------------------
//
// These exercise the storage-only flashcard methods: a brand-new card is due
// immediately (initial srs.due ~ now); listDueFlashcards honours the due cutoff
// and course scope; updateFlashcardSrs persists a recomputed schedule; counts,
// deletes, and the remove(transcript) cascade all behave.

describe('flashcards', () => {
  beforeEach(async () => {
    // Wipe transcripts (cascades drop their flashcards) + courses between tests.
    const all = await repo.list();
    await Promise.all(all.map((m) => repo.remove(m.id)));
    const courses = await repo.listCourses();
    await Promise.all(courses.map((c) => repo.deleteCourse(c.id)));
  });

  it('createFlashcards returns ids + an initial srs due ~ now', async () => {
    const t = await repo.create(makeDraft());
    const before = Date.now();
    const [card] = await repo.createFlashcards([
      {
        transcriptId: t.id,
        front: 'What is Q?',
        back: 'The answer.',
        segmentId: t.segments[0]!.id,
        start: 0,
      },
    ]);
    const after = Date.now();

    expect(card!.id).toMatch(/^f_/);
    expect(card!.transcriptId).toBe(t.id);
    expect(card!.front).toBe('What is Q?');
    expect(card!.back).toBe('The answer.');
    expect(card!.segmentId).toBe(t.segments[0]!.id);
    expect(card!.start).toBe(0);
    expect(card!.createdAt).toBeTypeOf('number');
    // Initial SM-2 state.
    expect(card!.srs.ease).toBe(2.5);
    expect(card!.srs.intervalDays).toBe(0);
    expect(card!.srs.reps).toBe(0);
    expect(card!.srs.lapses).toBe(0);
    // Due immediately: srs.due falls within the create() window.
    expect(card!.srs.due).toBeGreaterThanOrEqual(before);
    expect(card!.srs.due).toBeLessThanOrEqual(after);

    // It round-trips through listFlashcards.
    const listed = await repo.listFlashcards({ transcriptId: t.id });
    expect(listed.map((c) => c.id)).toEqual([card!.id]);
  });

  it('listDueFlashcards includes a due card and excludes a future-due one', async () => {
    const t = await repo.create(makeDraft());
    const [dueCard, futureCard] = await repo.createFlashcards([
      { transcriptId: t.id, front: 'due', back: 'now' },
      { transcriptId: t.id, front: 'later', back: 'future' },
    ]);

    // Push the second card's due far into the future via a recomputed schedule.
    const future = Date.now() + 7 * 24 * 60 * 60 * 1000;
    await repo.updateFlashcardSrs(futureCard!.id, {
      ease: 2.5,
      intervalDays: 7,
      reps: 1,
      lapses: 0,
      due: future,
    });

    const due = await repo.listDueFlashcards();
    expect(due.map((c) => c.id)).toContain(dueCard!.id);
    expect(due.map((c) => c.id)).not.toContain(futureCard!.id);
  });

  it('updateFlashcardSrs persists the recomputed schedule', async () => {
    const t = await repo.create(makeDraft());
    const [card] = await repo.createFlashcards([
      { transcriptId: t.id, front: 'q', back: 'a' },
    ]);

    const reviewed = Date.now();
    const next = {
      ease: 2.6,
      intervalDays: 1,
      reps: 1,
      lapses: 0,
      due: reviewed + 24 * 60 * 60 * 1000,
      lastReviewed: reviewed,
    };
    const updated = await repo.updateFlashcardSrs(card!.id, next);
    expect(updated.srs).toEqual(next);

    // The change is durable.
    const [reloaded] = await repo.listFlashcards({ transcriptId: t.id });
    expect(reloaded!.srs).toEqual(next);
  });

  it('updateFlashcardSrs throws for an unknown id', async () => {
    await expect(
      repo.updateFlashcardSrs('f_nope', {
        ease: 2.5,
        intervalDays: 0,
        reps: 0,
        lapses: 0,
        due: Date.now(),
      }),
    ).rejects.toThrow();
  });

  it('flashcardCounts reports total and due', async () => {
    const t = await repo.create(makeDraft());
    const [, b, c] = await repo.createFlashcards([
      { transcriptId: t.id, front: '1', back: 'a' },
      { transcriptId: t.id, front: '2', back: 'b' },
      { transcriptId: t.id, front: '3', back: 'c' },
    ]);

    // All three start due.
    expect(await repo.flashcardCounts()).toEqual({ total: 3, due: 3 });

    // Push two out into the future => only one remains due.
    const future = Date.now() + 60 * 60 * 1000;
    const futureSrs = {
      ease: 2.5,
      intervalDays: 1,
      reps: 1,
      lapses: 0,
      due: future,
    };
    await repo.updateFlashcardSrs(b!.id, futureSrs);
    await repo.updateFlashcardSrs(c!.id, futureSrs);

    expect(await repo.flashcardCounts()).toEqual({ total: 3, due: 1 });
  });

  it('deleteFlashcard removes a single card', async () => {
    const t = await repo.create(makeDraft());
    const [a, b] = await repo.createFlashcards([
      { transcriptId: t.id, front: '1', back: 'a' },
      { transcriptId: t.id, front: '2', back: 'b' },
    ]);

    await repo.deleteFlashcard(a!.id);
    const remaining = await repo.listFlashcards({ transcriptId: t.id });
    expect(remaining.map((c) => c.id)).toEqual([b!.id]);
  });

  it('remove(transcript) cascades to its flashcards', async () => {
    const t = await repo.create(makeDraft());
    await repo.createFlashcards([
      { transcriptId: t.id, front: '1', back: 'a' },
      { transcriptId: t.id, front: '2', back: 'b' },
    ]);
    expect(await repo.listFlashcards({ transcriptId: t.id })).toHaveLength(2);

    await repo.remove(t.id);
    expect(await repo.listFlashcards({ transcriptId: t.id })).toHaveLength(0);
    expect(await repo.flashcardCounts()).toEqual({ total: 0, due: 0 });
  });

  it('courseId scopes listing, due, and counts; deleteCourse moves cards to Unsorted', async () => {
    const course = await repo.createCourse({ name: 'Physics' });
    const inCourse = await repo.create(makeDraft({ courseId: course.id }));
    const unsorted = await repo.create(makeDraft());

    await repo.createFlashcards([
      { transcriptId: inCourse.id, courseId: course.id, front: 'c1', back: 'a' },
    ]);
    await repo.createFlashcards([
      { transcriptId: unsorted.id, front: 'u1', back: 'b' },
    ]);

    // Listing is scoped.
    expect((await repo.listFlashcards({ courseId: course.id })).map((c) => c.front)).toEqual(['c1']);
    expect((await repo.listFlashcards({ courseId: null })).map((c) => c.front)).toEqual(['u1']);

    // Counts + due are scoped.
    expect(await repo.flashcardCounts(course.id)).toEqual({ total: 1, due: 1 });
    expect(await repo.flashcardCounts(null)).toEqual({ total: 1, due: 1 });
    expect((await repo.listDueFlashcards({ courseId: course.id })).map((c) => c.front)).toEqual(['c1']);

    // Deleting the course moves its cards to Unsorted (courseId=null).
    await repo.deleteCourse(course.id);
    const movedToUnsorted = await repo.listFlashcards({ courseId: null });
    expect(movedToUnsorted.map((c) => c.front).sort()).toEqual(['c1', 'u1']);
    expect(await repo.listFlashcards({ courseId: course.id })).toHaveLength(0);
  });
});
