// Native storage implementation backed by expo-sqlite (SDK 56 async API).
//
// Mirrors the web/Dexie repo's contract exactly. The full transcript body is
// stored as JSON in the 'json' column; metadata lives in dedicated columns so
// list()/search() can be answered from columns alone without parsing every
// body. Search uses a LIKE over the lowercased 'searchText' column.
//
// Schema evolution is handled by a real PRAGMA user_version migration runner
// (see MIGRATIONS below), so devices created by the old single-table code
// migrate forward in place rather than being wiped.
//
// API reference (read the EXACT versioned docs before touching this file):
//   https://docs.expo.dev/versions/v56.0.0/sdk/sqlite/
//   https://docs.expo.dev/versions/v56.0.0/sdk/filesystem/
// We use openDatabaseAsync + the async methods (execAsync/runAsync/getAllAsync)
// and the new object-oriented expo-file-system API (File/Directory/Paths).

import * as SQLite from 'expo-sqlite';
import { Directory, File, Paths } from 'expo-file-system';
import type { Segment } from '../types';
import { newId } from '../ids';
import {
  parseDraft,
  parseCourseDraft,
  parseFlashcardDraft,
  zSegment,
  type TranscriptDraft,
  type TranscriptMeta,
  type Transcript,
  type TranscriptPatch,
  type Course,
  type CourseDraft,
  type Flashcard,
  type FlashcardDraft,
  type SrsState,
} from './schema';
import type {
  MediaInput,
  StorageRepo,
  SegmentVector,
  VectorHit,
} from './repo';

// ---------------------------------------------------------------------------
// Row shapes (as returned by getAllAsync). SQLite has no boolean/undefined:
// optional text columns come back as string | null, booleans as 0/1 integers.
// ---------------------------------------------------------------------------

interface MetaRow {
  id: string;
  title: string;
  createdAt: number;
  updatedAt: number;
  durationSec: number;
  segmentCount: number;
  modelId: string;
  language: string | null;
  // v2 columns
  courseId: string | null;
  lectureDate: number | null;
  instructor: string | null;
  location: string | null;
  /** JSON-encoded string[] or null. */
  tags: string | null;
  lectureNumber: number | null;
  /** 0/1 integer. */
  hasMedia: number | null;
}

// A meta row plus the JSON body, for get()/update().
interface FullRow extends MetaRow {
  json: string;
}

interface CourseRow {
  id: string;
  name: string;
  code: string | null;
  instructor: string | null;
  term: string | null;
  color: string | null;
  createdAt: number;
  updatedAt: number;
}

interface MediaRow {
  transcriptId: string;
  path: string;
  mime: string;
}

// One stored segment embedding. `vector` is a SQLite BLOB; on read expo-sqlite
// hands it back as a Uint8Array whose underlying bytes we reinterpret as a
// Float32Array. start/end are REAL, courseId is nullable text.
interface SegVecRow {
  transcriptId: string;
  segmentId: string;
  start: number;
  end: number;
  courseId: string | null;
  text: string;
  vector: Uint8Array;
  model: string;
}

// One stored flashcard. srs is JSON-encoded SrsState; dueAt is a denormalized
// copy of srs.due kept as an INTEGER column so due-card queries can be indexed.
// segmentId/start are nullable (cards may not anchor to a segment).
interface FlashcardRow {
  id: string;
  transcriptId: string;
  courseId: string | null;
  segmentId: string | null;
  start: number | null;
  front: string;
  back: string;
  createdAt: number;
  /** JSON-encoded SrsState. */
  srs: string;
  /** Denormalized srs.due (ms since epoch) for indexed due queries. */
  dueAt: number;
}

// ---------------------------------------------------------------------------
// Pure derivation helpers
// ---------------------------------------------------------------------------

/** Lowercased haystack of title + every segment's text, for LIKE search. */
function deriveSearchText(title: string, segments: Segment[]): string {
  return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase();
}

/**
 * Ensure every segment has a stable id, assigning newId('s_') to any that
 * lack one. Returns a new array (does not mutate the input segments).
 */
function withSegmentIds(segments: Segment[]): Segment[] {
  return segments.map((s) => (s.id ? s : { ...s, id: newId('s_') }));
}

/** Map a metadata row to the public TranscriptMeta. */
function rowToMeta(r: MetaRow): TranscriptMeta {
  const meta: TranscriptMeta = {
    id: r.id,
    title: r.title,
    createdAt: r.createdAt,
    updatedAt: r.updatedAt,
    durationSec: r.durationSec,
    modelId: r.modelId as TranscriptMeta['modelId'],
    segmentCount: r.segmentCount,
    // Keep courseId as null (the "Unsorted" sentinel) rather than dropping it.
    courseId: r.courseId,
    hasMedia: r.hasMedia === 1,
  };
  if (r.language !== null) meta.language = r.language;
  if (r.lectureDate !== null) meta.lectureDate = r.lectureDate;
  if (r.instructor !== null) meta.instructor = r.instructor;
  if (r.location !== null) meta.location = r.location;
  if (r.lectureNumber !== null) meta.lectureNumber = r.lectureNumber;
  if (r.tags !== null) {
    try {
      const parsed = JSON.parse(r.tags) as unknown;
      if (Array.isArray(parsed)) meta.tags = parsed as string[];
    } catch {
      // Corrupt tags JSON => treat as absent rather than throwing on read.
    }
  }
  return meta;
}

function rowToCourse(r: CourseRow): Course {
  const course: Course = {
    id: r.id,
    name: r.name,
    createdAt: r.createdAt,
    updatedAt: r.updatedAt,
  };
  if (r.code !== null) course.code = r.code;
  if (r.instructor !== null) course.instructor = r.instructor;
  if (r.term !== null) course.term = r.term;
  if (r.color !== null) course.color = r.color;
  return course;
}

/** Map a flashcard row to the public Flashcard (parsing the JSON srs). */
function rowToFlashcard(r: FlashcardRow): Flashcard {
  const card: Flashcard = {
    id: r.id,
    transcriptId: r.transcriptId,
    // Keep courseId as null (the "Unsorted" sentinel) rather than dropping it.
    courseId: r.courseId,
    front: r.front,
    back: r.back,
    createdAt: r.createdAt,
    srs: JSON.parse(r.srs) as SrsState,
  };
  if (r.segmentId !== null) card.segmentId = r.segmentId;
  if (r.start !== null) card.start = r.start;
  return card;
}

// ---- vector <-> BLOB codecs --------------------------------------------------
// SQLite stores embeddings as raw little-endian float32 bytes. We bind a
// Uint8Array view of the Float32Array's buffer on write and reconstruct a
// Float32Array from the returned bytes on read. We copy on read so the result
// is 4-byte aligned (a Uint8Array sub-view may start at an unaligned offset,
// which `new Float32Array(buffer)` requires to be a multiple of 4).
function vectorToBlob(vector: Float32Array): Uint8Array {
  return new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength);
}

function blobToVector(blob: Uint8Array): Float32Array {
  // Copy the bytes into a fresh, aligned ArrayBuffer, then view as float32.
  const copy = new Uint8Array(blob.length);
  copy.set(blob);
  return new Float32Array(copy.buffer);
}

/** Cosine similarity = dot product of two unit-normalized vectors. */
function dot(a: Float32Array, b: Float32Array): number {
  const n = Math.min(a.length, b.length);
  let sum = 0;
  for (let i = 0; i < n; i++) {
    sum += a[i]! * b[i]!;
  }
  return sum;
}

// ---------------------------------------------------------------------------
// Migration runner
// ---------------------------------------------------------------------------
//
// Each entry migrates the DB from version (index) to (index + 1). On first
// getDb() we read PRAGMA user_version and apply pending steps in order, each
// wrapped in a transaction, then bump user_version. Steps are written to be
// idempotent (IF NOT EXISTS / pragma_table_info guards) so they also no-op on
// DBs the original single-table code already created at version 0.

const TARGET_VERSION = 4;

type Migration = (db: SQLite.SQLiteDatabase) => Promise<void>;

const MIGRATIONS: Migration[] = [
  // ---- v0 -> v1: the original transcripts table -------------------------
  async (db) => {
    await db.execAsync(`
      CREATE TABLE IF NOT EXISTS transcripts (
        id TEXT PRIMARY KEY NOT NULL,
        json TEXT NOT NULL,
        searchText TEXT NOT NULL,
        createdAt INTEGER NOT NULL,
        updatedAt INTEGER NOT NULL,
        durationSec REAL NOT NULL,
        segmentCount INTEGER NOT NULL,
        modelId TEXT NOT NULL,
        language TEXT
      );
      CREATE INDEX IF NOT EXISTS idx_transcripts_createdAt
        ON transcripts (createdAt);
    `);
  },

  // ---- v1 -> v2: course/lecture metadata, courses + media tables --------
  async (db) => {
    // Add the new transcript metadata columns. We guard each ADD COLUMN with a
    // pragma_table_info check so this step is safe even if a column already
    // exists (e.g. a DB that the old code happened to create with extras).
    const existing = await db.getAllAsync<{ name: string }>(
      `SELECT name FROM pragma_table_info('transcripts')`,
    );
    const have = new Set(existing.map((c) => c.name));
    const addColumn = async (name: string, decl: string): Promise<void> => {
      if (!have.has(name)) {
        await db.execAsync(`ALTER TABLE transcripts ADD COLUMN ${decl}`);
      }
    };
    await addColumn('courseId', 'courseId TEXT');
    await addColumn('lectureDate', 'lectureDate INTEGER');
    await addColumn('instructor', 'instructor TEXT');
    await addColumn('location', 'location TEXT');
    await addColumn('tags', 'tags TEXT');
    await addColumn('lectureNumber', 'lectureNumber REAL');
    await addColumn('hasMedia', 'hasMedia INTEGER DEFAULT 0');

    // Courses table + name index for alphabetical listing.
    await db.execAsync(`
      CREATE TABLE IF NOT EXISTS courses (
        id TEXT PRIMARY KEY NOT NULL,
        name TEXT NOT NULL,
        code TEXT,
        instructor TEXT,
        term TEXT,
        color TEXT,
        createdAt INTEGER,
        updatedAt INTEGER
      );
      CREATE INDEX IF NOT EXISTS courses_name ON courses (name);
    `);

    // Media table: one persisted source-audio file per transcript.
    await db.execAsync(`
      CREATE TABLE IF NOT EXISTS media (
        transcriptId TEXT PRIMARY KEY NOT NULL,
        path TEXT NOT NULL,
        mime TEXT NOT NULL
      );
    `);

    // Backfill: assign stable ids to any segments in existing bodies that lack
    // one, so older transcripts gain the durable anchors the new code expects.
    const rows = await db.getAllAsync<{ id: string; json: string }>(
      `SELECT id, json FROM transcripts`,
    );
    for (const row of rows) {
      let transcript: Transcript;
      try {
        transcript = JSON.parse(row.json) as Transcript;
      } catch {
        continue; // Skip unparseable bodies rather than aborting the migration.
      }
      const segments = transcript.segments ?? [];
      const needsIds = segments.some((s) => !s.id);
      if (!needsIds) continue;
      const next: Transcript = {
        ...transcript,
        segments: withSegmentIds(segments),
      };
      await db.runAsync(`UPDATE transcripts SET json = ? WHERE id = ?`, [
        JSON.stringify(next),
        row.id,
      ]);
    }
  },

  // ---- v2 -> v3: segment-vector store for Phase 1 semantic search -------
  async (db) => {
    // One row per (transcript, segment) embedding. courseId is denormalized
    // from the owning transcript so course-scoped search needs no join; model
    // tags the embedding model so a model change can be detected/backfilled.
    await db.execAsync(`
      CREATE TABLE IF NOT EXISTS segvecs (
        transcriptId TEXT NOT NULL,
        segmentId TEXT NOT NULL,
        start REAL NOT NULL,
        end REAL NOT NULL,
        courseId TEXT,
        text TEXT NOT NULL,
        vector BLOB NOT NULL,
        model TEXT NOT NULL,
        PRIMARY KEY (transcriptId, segmentId)
      );
      CREATE INDEX IF NOT EXISTS idx_segvecs_model ON segvecs (model);
      CREATE INDEX IF NOT EXISTS idx_segvecs_courseId ON segvecs (courseId);
    `);
  },

  // ---- v3 -> v4: flashcards store for Phase 3 spaced repetition ----------
  async (db) => {
    // One row per flashcard. srs is JSON; dueAt is a denormalized copy of
    // srs.due as an INTEGER so due-card queries can use an index. The
    // transcriptId/courseId indexes drive the cascade + scoping paths.
    await db.execAsync(`
      CREATE TABLE IF NOT EXISTS flashcards (
        id TEXT PRIMARY KEY NOT NULL,
        transcriptId TEXT,
        courseId TEXT,
        segmentId TEXT,
        start REAL,
        front TEXT,
        back TEXT,
        createdAt INTEGER,
        srs TEXT,
        dueAt INTEGER
      );
      CREATE INDEX IF NOT EXISTS idx_flashcards_transcriptId ON flashcards (transcriptId);
      CREATE INDEX IF NOT EXISTS idx_flashcards_courseId ON flashcards (courseId);
      CREATE INDEX IF NOT EXISTS idx_flashcards_dueAt ON flashcards (dueAt);
    `);
  },
];

async function runMigrations(db: SQLite.SQLiteDatabase): Promise<void> {
  const result = await db.getFirstAsync<{ user_version: number }>(
    `PRAGMA user_version`,
  );
  let current = result?.user_version ?? 0;
  while (current < TARGET_VERSION) {
    const migrate = MIGRATIONS[current];
    if (!migrate) break; // Defensive: no step for this version.
    await db.withTransactionAsync(async () => {
      await migrate(db);
    });
    current += 1;
    // PRAGMA user_version does not accept bound params, so interpolate the
    // integer directly (it is a controlled loop counter, never user input).
    await db.execAsync(`PRAGMA user_version = ${current}`);
  }
}

// ---------------------------------------------------------------------------
// Lazy, memoized database handle + schema bootstrap
// ---------------------------------------------------------------------------

let dbPromise: Promise<SQLite.SQLiteDatabase> | undefined;

function getDb(): Promise<SQLite.SQLiteDatabase> {
  // Open once and reuse; run migrations on first open. The async open returns a
  // promise we cache so concurrent callers share a single connection.
  if (!dbPromise) {
    dbPromise = (async () => {
      const db = await SQLite.openDatabaseAsync('wisp.db');
      await runMigrations(db);
      return db;
    })();
  }
  return dbPromise;
}

// The metadata columns we select for list()/search(), kept in one place.
const META_COLUMNS =
  'id, title, createdAt, updatedAt, durationSec, segmentCount, modelId, language, ' +
  'courseId, lectureDate, instructor, location, tags, lectureNumber, hasMedia';

// ---------------------------------------------------------------------------
// Media filesystem helpers (expo-file-system, SDK 56 object API)
// ---------------------------------------------------------------------------
//
// TODO(native-untested): the media filesystem paths below cannot be exercised
// off-device (expo-file-system has no Node/jsdom backend), so they are
// type-checked and modeled on the SDK 56 docs but NOT runtime-tested here.

/** The <documentDirectory>/media/ directory, created on demand. */
function mediaDir(): Directory {
  return new Directory(Paths.document, 'media');
}

/** The persisted media file for a transcript (named by its id). */
function mediaFile(transcriptId: string): File {
  return new File(mediaDir(), transcriptId);
}

// ---------------------------------------------------------------------------
// Repo implementation
// ---------------------------------------------------------------------------

export const repo: StorageRepo = {
  // --- transcripts ---------------------------------------------------------
  async list(): Promise<TranscriptMeta[]> {
    const db = await getDb();
    const rows = await db.getAllAsync<MetaRow>(
      `SELECT ${META_COLUMNS} FROM transcripts ORDER BY createdAt DESC`,
    );
    return rows.map(rowToMeta);
  },

  async get(id: string): Promise<Transcript | undefined> {
    const db = await getDb();
    const row = await db.getFirstAsync<FullRow>(
      `SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`,
      [id],
    );
    if (!row) return undefined;
    // The JSON body is the authoritative full Transcript (already validated and
    // segment-id-stamped on write), so we return it directly.
    return JSON.parse(row.json) as Transcript;
  },

  async create(draft: TranscriptDraft): Promise<Transcript> {
    const valid = parseDraft(draft);
    const now = Date.now();
    // Assign a stable id to every segment that lacks one before storing.
    const segments = withSegmentIds(valid.segments);
    const courseId = valid.courseId ?? null;

    const transcript: Transcript = {
      id: newId('t_'),
      title: valid.title,
      createdAt: now,
      updatedAt: now,
      durationSec: valid.durationSec,
      modelId: valid.modelId,
      segmentCount: segments.length,
      segments,
      courseId,
      hasMedia: false,
      ...(valid.language !== undefined ? { language: valid.language } : {}),
      ...(valid.lectureDate !== undefined ? { lectureDate: valid.lectureDate } : {}),
      ...(valid.instructor !== undefined ? { instructor: valid.instructor } : {}),
      ...(valid.location !== undefined ? { location: valid.location } : {}),
      ...(valid.tags !== undefined ? { tags: valid.tags } : {}),
      ...(valid.lectureNumber !== undefined
        ? { lectureNumber: valid.lectureNumber }
        : {}),
    };

    const db = await getDb();
    await db.runAsync(
      `INSERT INTO transcripts
         (id, json, searchText, createdAt, updatedAt, durationSec, segmentCount,
          modelId, language, courseId, lectureDate, instructor, location, tags,
          lectureNumber, hasMedia)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [
        transcript.id,
        JSON.stringify(transcript),
        deriveSearchText(transcript.title, transcript.segments),
        transcript.createdAt,
        transcript.updatedAt,
        transcript.durationSec,
        transcript.segmentCount,
        transcript.modelId,
        // expo-sqlite accepts null but not undefined for params.
        transcript.language ?? null,
        courseId,
        transcript.lectureDate ?? null,
        transcript.instructor ?? null,
        transcript.location ?? null,
        transcript.tags !== undefined ? JSON.stringify(transcript.tags) : null,
        transcript.lectureNumber ?? null,
        transcript.hasMedia ? 1 : 0,
      ],
    );
    return transcript;
  },

  async update(id: string, patch: TranscriptPatch): Promise<Transcript> {
    const db = await getDb();
    const existingRow = await db.getFirstAsync<FullRow>(
      `SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`,
      [id],
    );
    if (!existingRow) {
      throw new Error(`transcript not found: ${id}`);
    }
    const existing = JSON.parse(existingRow.json) as Transcript;

    // Re-validate segments on update (so non-finite times can never slip in)
    // and stamp ids onto any that are missing one.
    const nextSegments =
      patch.segments !== undefined
        ? withSegmentIds(patch.segments.map((s) => zSegment.parse(s)))
        : existing.segments;
    const nextTitle = patch.title !== undefined ? patch.title : existing.title;

    // Apply only provided patch fields; leave everything else as-is.
    const updated: Transcript = {
      ...existing,
      title: nextTitle,
      segments: nextSegments,
      segmentCount: nextSegments.length,
      updatedAt: Date.now(),
      ...(patch.courseId !== undefined ? { courseId: patch.courseId } : {}),
      ...(patch.lectureDate !== undefined ? { lectureDate: patch.lectureDate } : {}),
      ...(patch.instructor !== undefined ? { instructor: patch.instructor } : {}),
      ...(patch.location !== undefined ? { location: patch.location } : {}),
      ...(patch.tags !== undefined ? { tags: patch.tags } : {}),
      ...(patch.lectureNumber !== undefined
        ? { lectureNumber: patch.lectureNumber }
        : {}),
    };

    await db.runAsync(
      `UPDATE transcripts
         SET json = ?, searchText = ?, updatedAt = ?, segmentCount = ?, title = ?,
             courseId = ?, lectureDate = ?, instructor = ?, location = ?, tags = ?,
             lectureNumber = ?
       WHERE id = ?`,
      [
        JSON.stringify(updated),
        deriveSearchText(nextTitle, nextSegments),
        updated.updatedAt,
        updated.segmentCount,
        updated.title,
        updated.courseId ?? null,
        updated.lectureDate ?? null,
        updated.instructor ?? null,
        updated.location ?? null,
        updated.tags !== undefined ? JSON.stringify(updated.tags) : null,
        updated.lectureNumber ?? null,
        id,
      ],
    );
    // Segments were patched => stored vectors are stale; drop them so the
    // backfill re-embeds from the new text/timing.
    if (patch.segments !== undefined) {
      await db.runAsync(`DELETE FROM segvecs WHERE transcriptId = ?`, [id]);
    }
    return updated;
  },

  async remove(id: string): Promise<void> {
    const db = await getDb();
    // Delete persisted media first so we never orphan a file.
    await this.removeMedia(id);
    await db.runAsync(`DELETE FROM transcripts WHERE id = ?`, [id]);
    // Cascade: drop this transcript's vectors + flashcards too.
    await db.runAsync(`DELETE FROM segvecs WHERE transcriptId = ?`, [id]);
    await db.runAsync(`DELETE FROM flashcards WHERE transcriptId = ?`, [id]);
  },

  async search(query: string): Promise<TranscriptMeta[]> {
    const db = await getDb();
    const needle = query.toLowerCase().trim();
    if (!needle) {
      // Empty query => everything, newest first (matches the web repo).
      const rows = await db.getAllAsync<MetaRow>(
        `SELECT ${META_COLUMNS} FROM transcripts ORDER BY createdAt DESC`,
      );
      return rows.map(rowToMeta);
    }
    // Escape LIKE wildcards (% and _) and the escape char itself so user input
    // is matched literally, then use ESCAPE to declare the escape character.
    const escaped = needle.replace(/[\\%_]/g, (ch) => `\\${ch}`);
    const rows = await db.getAllAsync<MetaRow>(
      `SELECT ${META_COLUMNS} FROM transcripts
         WHERE searchText LIKE ? ESCAPE '\\'
       ORDER BY createdAt DESC`,
      [`%${escaped}%`],
    );
    return rows.map(rowToMeta);
  },

  async listByCourse(courseId: string | null): Promise<TranscriptMeta[]> {
    const db = await getDb();
    if (courseId === null) {
      // "Unsorted": courseId IS NULL covers both null and never-set (the column
      // defaults to NULL for rows written before v2).
      const rows = await db.getAllAsync<MetaRow>(
        `SELECT ${META_COLUMNS} FROM transcripts
           WHERE courseId IS NULL
         ORDER BY createdAt DESC`,
      );
      return rows.map(rowToMeta);
    }
    const rows = await db.getAllAsync<MetaRow>(
      `SELECT ${META_COLUMNS} FROM transcripts
         WHERE courseId = ?
       ORDER BY createdAt DESC`,
      [courseId],
    );
    return rows.map(rowToMeta);
  },

  async reassign(transcriptId: string, courseId: string | null): Promise<void> {
    const db = await getDb();
    const row = await db.getFirstAsync<FullRow>(
      `SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`,
      [transcriptId],
    );
    if (!row) {
      throw new Error(`transcript not found: ${transcriptId}`);
    }
    const transcript = JSON.parse(row.json) as Transcript;
    const updatedAt = Date.now();
    const next: Transcript = { ...transcript, courseId, updatedAt };
    await db.withTransactionAsync(async () => {
      await db.runAsync(
        `UPDATE transcripts SET courseId = ?, updatedAt = ?, json = ? WHERE id = ?`,
        [courseId, updatedAt, JSON.stringify(next), transcriptId],
      );
      // Keep the denormalized courseId on every vector row in sync so
      // course-scoped search keeps matching after a move.
      await db.runAsync(`UPDATE segvecs SET courseId = ? WHERE transcriptId = ?`, [
        courseId,
        transcriptId,
      ]);
    });
  },

  // --- courses -------------------------------------------------------------
  async listCourses(): Promise<Course[]> {
    const db = await getDb();
    const rows = await db.getAllAsync<CourseRow>(
      `SELECT id, name, code, instructor, term, color, createdAt, updatedAt
         FROM courses ORDER BY name ASC`,
    );
    return rows.map(rowToCourse);
  },

  async createCourse(draft: CourseDraft): Promise<Course> {
    const valid = parseCourseDraft(draft);
    const now = Date.now();
    const course: Course = {
      id: newId('c_'),
      name: valid.name,
      createdAt: now,
      updatedAt: now,
      ...(valid.code !== undefined ? { code: valid.code } : {}),
      ...(valid.instructor !== undefined ? { instructor: valid.instructor } : {}),
      ...(valid.term !== undefined ? { term: valid.term } : {}),
      ...(valid.color !== undefined ? { color: valid.color } : {}),
    };
    const db = await getDb();
    await db.runAsync(
      `INSERT INTO courses
         (id, name, code, instructor, term, color, createdAt, updatedAt)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
      [
        course.id,
        course.name,
        course.code ?? null,
        course.instructor ?? null,
        course.term ?? null,
        course.color ?? null,
        course.createdAt,
        course.updatedAt,
      ],
    );
    return course;
  },

  async updateCourse(id: string, patch: Partial<CourseDraft>): Promise<Course> {
    const db = await getDb();
    const existing = await db.getFirstAsync<CourseRow>(
      `SELECT id, name, code, instructor, term, color, createdAt, updatedAt
         FROM courses WHERE id = ?`,
      [id],
    );
    if (!existing) {
      throw new Error(`course not found: ${id}`);
    }
    const current = rowToCourse(existing);
    // Validate only the patched fields by merging onto the existing draft view.
    const merged = parseCourseDraft({
      name: patch.name ?? current.name,
      ...(patch.code !== undefined ? { code: patch.code } : current.code !== undefined ? { code: current.code } : {}),
      ...(patch.instructor !== undefined
        ? { instructor: patch.instructor }
        : current.instructor !== undefined
          ? { instructor: current.instructor }
          : {}),
      ...(patch.term !== undefined ? { term: patch.term } : current.term !== undefined ? { term: current.term } : {}),
      ...(patch.color !== undefined ? { color: patch.color } : current.color !== undefined ? { color: current.color } : {}),
    });
    const updated: Course = {
      id: current.id,
      name: merged.name,
      createdAt: current.createdAt,
      updatedAt: Date.now(),
      ...(merged.code !== undefined ? { code: merged.code } : {}),
      ...(merged.instructor !== undefined ? { instructor: merged.instructor } : {}),
      ...(merged.term !== undefined ? { term: merged.term } : {}),
      ...(merged.color !== undefined ? { color: merged.color } : {}),
    };
    await db.runAsync(
      `UPDATE courses
         SET name = ?, code = ?, instructor = ?, term = ?, color = ?, updatedAt = ?
       WHERE id = ?`,
      [
        updated.name,
        updated.code ?? null,
        updated.instructor ?? null,
        updated.term ?? null,
        updated.color ?? null,
        updated.updatedAt,
        id,
      ],
    );
    return updated;
  },

  async deleteCourse(id: string): Promise<void> {
    const db = await getDb();
    // First reassign this course's transcripts to Unsorted, THEN delete the
    // course row, so we never leave dangling courseId references.
    await db.withTransactionAsync(async () => {
      await db.runAsync(
        `UPDATE transcripts SET courseId = NULL WHERE courseId = ?`,
        [id],
      );
      // Keep denormalized vector rows consistent: their transcripts are now
      // Unsorted, so their courseId must follow.
      await db.runAsync(`UPDATE segvecs SET courseId = NULL WHERE courseId = ?`, [
        id,
      ]);
      // Flashcards in this course follow their transcripts to Unsorted.
      await db.runAsync(
        `UPDATE flashcards SET courseId = NULL WHERE courseId = ?`,
        [id],
      );
      await db.runAsync(`DELETE FROM courses WHERE id = ?`, [id]);
    });
  },

  // --- media (persisted source audio) -------------------------------------
  // TODO(native-untested): these filesystem paths are type-checked against the
  // SDK 56 expo-file-system API but cannot be run off-device here.
  async putMedia(transcriptId: string, input: MediaInput): Promise<void> {
    const db = await getDb();

    // Ensure <documentDirectory>/media/ exists.
    const dir = mediaDir();
    if (!dir.exists) {
      dir.create({ intermediates: true });
    }

    // Write the bytes into <media>/<transcriptId>, replacing any prior file.
    const dest = mediaFile(transcriptId);
    if (dest.exists) {
      dest.delete();
    }
    if (input.data !== undefined) {
      // Raw bytes: create then write the buffer.
      dest.create();
      dest.write(new Uint8Array(input.data));
    } else if (input.uri !== undefined) {
      // Copy the source file into app storage at the destination path.
      const src = new File(input.uri);
      await src.copy(dest);
    } else {
      throw new Error('putMedia requires either input.data or input.uri');
    }

    // Upsert the media row and flag the transcript as having media.
    await db.runAsync(
      `INSERT INTO media (transcriptId, path, mime) VALUES (?, ?, ?)
         ON CONFLICT(transcriptId) DO UPDATE SET path = excluded.path, mime = excluded.mime`,
      [transcriptId, dest.uri, input.mime],
    );
    await db.runAsync(`UPDATE transcripts SET hasMedia = 1 WHERE id = ?`, [
      transcriptId,
    ]);
  },

  async getMediaUrl(transcriptId: string): Promise<string | undefined> {
    const db = await getDb();
    const row = await db.getFirstAsync<MediaRow>(
      `SELECT transcriptId, path, mime FROM media WHERE transcriptId = ?`,
      [transcriptId],
    );
    return row ? row.path : undefined;
  },

  async removeMedia(transcriptId: string): Promise<void> {
    const db = await getDb();
    const row = await db.getFirstAsync<MediaRow>(
      `SELECT transcriptId, path, mime FROM media WHERE transcriptId = ?`,
      [transcriptId],
    );
    if (!row) return; // No-op if absent.

    // Delete the file on disk (best-effort: tolerate an already-missing file).
    try {
      const file = new File(row.path);
      if (file.exists) file.delete();
    } catch {
      // Ignore filesystem errors so the DB row is still cleaned up below.
    }

    await db.runAsync(`DELETE FROM media WHERE transcriptId = ?`, [transcriptId]);
    await db.runAsync(`UPDATE transcripts SET hasMedia = 0 WHERE id = ?`, [
      transcriptId,
    ]);
  },

  // --- semantic search vectors (Phase 1) ----------------------------------
  async upsertVectors(
    transcriptId: string,
    model: string,
    vectors: SegmentVector[],
  ): Promise<void> {
    const db = await getDb();
    // Denormalize the transcript's current courseId onto each row so
    // course-scoped search never has to join back to transcripts.
    const owner = await db.getFirstAsync<{ courseId: string | null }>(
      `SELECT courseId FROM transcripts WHERE id = ?`,
      [transcriptId],
    );
    const courseId = owner ? owner.courseId : null;
    await db.withTransactionAsync(async () => {
      // Replace, not merge: clear existing rows so a re-embed with fewer
      // segments can't leave stale rows behind.
      await db.runAsync(`DELETE FROM segvecs WHERE transcriptId = ?`, [
        transcriptId,
      ]);
      for (const v of vectors) {
        await db.runAsync(
          `INSERT INTO segvecs
             (transcriptId, segmentId, start, end, courseId, text, vector, model)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
          [
            transcriptId,
            v.segmentId,
            v.start,
            v.end,
            courseId,
            v.text,
            vectorToBlob(v.vector),
            model,
          ],
        );
      }
    });
  },

  async searchVectors(
    query: Float32Array,
    opts?: { courseId?: string | null; limit?: number },
  ): Promise<VectorHit[]> {
    const db = await getDb();
    // Scope: opts.courseId provided => filter (null => Unsorted via IS NULL);
    // omitted => search every course.
    let rows: SegVecRow[];
    if (opts && opts.courseId !== undefined) {
      if (opts.courseId === null) {
        rows = await db.getAllAsync<SegVecRow>(
          `SELECT transcriptId, segmentId, start, end, courseId, text, vector, model
             FROM segvecs WHERE courseId IS NULL`,
        );
      } else {
        rows = await db.getAllAsync<SegVecRow>(
          `SELECT transcriptId, segmentId, start, end, courseId, text, vector, model
             FROM segvecs WHERE courseId = ?`,
          [opts.courseId],
        );
      }
    } else {
      rows = await db.getAllAsync<SegVecRow>(
        `SELECT transcriptId, segmentId, start, end, courseId, text, vector, model
           FROM segvecs`,
      );
    }

    const limit = opts?.limit ?? 30;
    const hits: VectorHit[] = rows.map((r) => ({
      transcriptId: r.transcriptId,
      segmentId: r.segmentId,
      start: r.start,
      end: r.end,
      courseId: r.courseId,
      text: r.text,
      score: dot(query, blobToVector(r.vector)),
    }));
    hits.sort((a, b) => b.score - a.score);
    return hits.slice(0, limit);
  },

  async clearVectors(transcriptId: string): Promise<void> {
    const db = await getDb();
    await db.runAsync(`DELETE FROM segvecs WHERE transcriptId = ?`, [
      transcriptId,
    ]);
  },

  async unembeddedIds(model: string): Promise<string[]> {
    const db = await getDb();
    // Transcript ids with ZERO segvecs rows for this model: a LEFT-anti-join
    // against the subset of segvecs tagged with the given model.
    const rows = await db.getAllAsync<{ id: string }>(
      `SELECT t.id AS id FROM transcripts t
         WHERE NOT EXISTS (
           SELECT 1 FROM segvecs s
             WHERE s.transcriptId = t.id AND s.model = ?
         )`,
      [model],
    );
    return rows.map((r) => r.id);
  },

  // --- flashcards + spaced repetition (Phase 3) ---------------------------
  async createFlashcards(drafts: FlashcardDraft[]): Promise<Flashcard[]> {
    const db = await getDb();
    const now = Date.now();
    const cards: Flashcard[] = drafts.map((draft) => {
      const valid = parseFlashcardDraft(draft);
      // Initial SM-2 state for a brand-new card: due immediately (now). We
      // compute the SRS inline here rather than importing learn/srs to keep the
      // repo storage-only (no dependency on the study logic).
      const srs: SrsState = {
        ease: 2.5,
        intervalDays: 0,
        reps: 0,
        lapses: 0,
        due: now,
      };
      return {
        id: newId('f_'),
        transcriptId: valid.transcriptId,
        // courseId defaults to null ("Unsorted") when absent.
        courseId: valid.courseId ?? null,
        ...(valid.segmentId !== undefined ? { segmentId: valid.segmentId } : {}),
        ...(valid.start !== undefined ? { start: valid.start } : {}),
        front: valid.front,
        back: valid.back,
        createdAt: now,
        srs,
      };
    });
    await db.withTransactionAsync(async () => {
      for (const card of cards) {
        await db.runAsync(
          `INSERT INTO flashcards
             (id, transcriptId, courseId, segmentId, start, front, back,
              createdAt, srs, dueAt)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
          [
            card.id,
            card.transcriptId,
            card.courseId ?? null,
            card.segmentId ?? null,
            card.start ?? null,
            card.front,
            card.back,
            card.createdAt,
            JSON.stringify(card.srs),
            card.srs.due,
          ],
        );
      }
    });
    return cards;
  },

  async listFlashcards(opts?: {
    transcriptId?: string;
    courseId?: string | null;
  }): Promise<Flashcard[]> {
    const db = await getDb();
    const where: string[] = [];
    const params: (string | null)[] = [];
    if (opts?.transcriptId !== undefined) {
      where.push('transcriptId = ?');
      params.push(opts.transcriptId);
    }
    if (opts && opts.courseId !== undefined) {
      if (opts.courseId === null) {
        where.push('courseId IS NULL');
      } else {
        where.push('courseId = ?');
        params.push(opts.courseId);
      }
    }
    const clause = where.length ? ` WHERE ${where.join(' AND ')}` : '';
    const rows = await db.getAllAsync<FlashcardRow>(
      `SELECT id, transcriptId, courseId, segmentId, start, front, back,
              createdAt, srs, dueAt
         FROM flashcards${clause}`,
      params,
    );
    return rows.map(rowToFlashcard);
  },

  async listDueFlashcards(opts?: {
    courseId?: string | null;
    now?: number;
    limit?: number;
  }): Promise<Flashcard[]> {
    const db = await getDb();
    const now = opts?.now ?? Date.now();
    // Due means dueAt <= now (the denormalized srs.due column). Soonest first.
    const where: string[] = ['dueAt <= ?'];
    const params: (string | number)[] = [now];
    if (opts && opts.courseId !== undefined) {
      if (opts.courseId === null) {
        where.push('courseId IS NULL');
      } else {
        where.push('courseId = ?');
        params.push(opts.courseId);
      }
    }
    let sql = `SELECT id, transcriptId, courseId, segmentId, start, front, back,
                      createdAt, srs, dueAt
                 FROM flashcards
                WHERE ${where.join(' AND ')}
                ORDER BY dueAt ASC`;
    if (opts?.limit !== undefined) {
      sql += ` LIMIT ?`;
      params.push(opts.limit);
    }
    const rows = await db.getAllAsync<FlashcardRow>(sql, params);
    return rows.map(rowToFlashcard);
  },

  async updateFlashcardSrs(id: string, srs: SrsState): Promise<Flashcard> {
    const db = await getDb();
    const row = await db.getFirstAsync<FlashcardRow>(
      `SELECT id, transcriptId, courseId, segmentId, start, front, back,
              createdAt, srs, dueAt
         FROM flashcards WHERE id = ?`,
      [id],
    );
    if (!row) {
      throw new Error(`flashcard not found: ${id}`);
    }
    // Persist the new schedule, keeping the denormalized dueAt column in sync.
    await db.runAsync(`UPDATE flashcards SET srs = ?, dueAt = ? WHERE id = ?`, [
      JSON.stringify(srs),
      srs.due,
      id,
    ]);
    return rowToFlashcard({ ...row, srs: JSON.stringify(srs), dueAt: srs.due });
  },

  async deleteFlashcard(id: string): Promise<void> {
    const db = await getDb();
    await db.runAsync(`DELETE FROM flashcards WHERE id = ?`, [id]);
  },

  async flashcardCounts(
    courseId?: string | null,
  ): Promise<{ total: number; due: number }> {
    const db = await getDb();
    const now = Date.now();
    const scopeWhere: string[] = [];
    const scopeParams: (string | null)[] = [];
    if (courseId !== undefined) {
      if (courseId === null) {
        scopeWhere.push('courseId IS NULL');
      } else {
        scopeWhere.push('courseId = ?');
        scopeParams.push(courseId);
      }
    }
    const scopeClause = scopeWhere.length ? ` WHERE ${scopeWhere.join(' AND ')}` : '';
    const totalRow = await db.getFirstAsync<{ n: number }>(
      `SELECT COUNT(*) AS n FROM flashcards${scopeClause}`,
      scopeParams,
    );
    const dueClause = scopeWhere.length
      ? ` WHERE ${scopeWhere.join(' AND ')} AND dueAt <= ?`
      : ` WHERE dueAt <= ?`;
    const dueRow = await db.getFirstAsync<{ n: number }>(
      `SELECT COUNT(*) AS n FROM flashcards${dueClause}`,
      [...scopeParams, now],
    );
    return { total: totalRow?.n ?? 0, due: dueRow?.n ?? 0 };
  },
};
