// Web storage implementation backed by Dexie (IndexedDB).
//
// Schema (v2):
//   transcripts: full Transcript + derived lowercase 'searchText' (title + all
//                segment text), keyed by id, indexed by createdAt + courseId.
//   courses:     stored Course rows, indexed by name.
//   media:       persisted source audio { transcriptId, blob, mime }.
//
// Search is done in-memory (filter over all rows) because IndexedDB has no
// native substring index; transcript counts are small enough on-device that
// this is fine. searchText / blobs are never returned to callers.

import Dexie from 'dexie';
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 {
  StorageRepo,
  MediaInput,
  SegmentVector,
  VectorHit,
} from './repo';

// The shape persisted in the 'transcripts' store: a full Transcript plus the
// derived searchText column. searchText is never returned to callers.
interface StoredTranscript extends Transcript {
  searchText: string;
}

// The shape persisted in the 'media' store. The blob/mime never leak to
// callers; only a hasMedia flag on the transcript + an object URL are exposed.
interface StoredMedia {
  transcriptId: string;
  blob: Blob;
  mime: string;
}

// One row in the 'segvecs' store: a single segment's embedding plus the
// denormalized courseId of its owning transcript (so course-scoped search can
// filter without joining back to transcripts) and the embedding model tag.
// The compound key [transcriptId+segmentId] keeps one row per segment; the
// transcriptId / courseId / model indexes drive the cascade + filter paths.
interface StoredSegVec {
  transcriptId: string;
  segmentId: string;
  start: number;
  end: number;
  courseId: string | null;
  text: string;
  vector: Float32Array;
  model: string;
}

// ---------------------------------------------------------------------------
// Pure derivation helpers (shared by create/update within this file)
// ---------------------------------------------------------------------------

/** Lowercased haystack of title + every segment's text, for substring search. */
function deriveSearchText(title: string, segments: Segment[]): string {
  // Join with newlines so adjacent segments don't accidentally fuse into a
  // word that matches a query spanning the boundary.
  return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase();
}

/** Assign a stable id to every segment that lacks one (mutating a fresh copy). */
function withSegmentIds(segments: Segment[]): Segment[] {
  return segments.map((s) => (s.id ? s : { ...s, id: newId('s_') }));
}

/** Strip the derived searchText, returning the public Transcript view. */
function toTranscript(row: StoredTranscript): Transcript {
  const { searchText: _searchText, ...rest } = row;
  return rest;
}

/** Strip both searchText and segments, returning the lightweight meta view. */
function toMeta(row: StoredTranscript): TranscriptMeta {
  const { searchText: _searchText, segments: _segments, ...meta } = row;
  return meta;
}

/**
 * Cosine similarity between two vectors. Both the query and stored vectors are
 * unit-normalized by contract, so this is just their dot product; we do not
 * re-normalize here. Mismatched lengths fall back to the common prefix so a
 * stale-dim row can never throw (it will simply score poorly).
 */
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;
}

// ---------------------------------------------------------------------------
// Dexie database
// ---------------------------------------------------------------------------

class WispDexie extends Dexie {
  transcripts!: Dexie.Table<StoredTranscript, string>;
  courses!: Dexie.Table<Course, string>;
  media!: Dexie.Table<StoredMedia, string>;
  // Keyed by the compound [transcriptId+segmentId]; secondary indexes on
  // transcriptId (cascade), courseId (scoped search) and model (backfill).
  segvecs!: Dexie.Table<StoredSegVec, [string, string]>;
  // Phase 3 flashcards, keyed by id; secondary indexes on transcriptId
  // (cascade on remove) and courseId (scoping + cascade on deleteCourse).
  flashcards!: Dexie.Table<Flashcard, string>;

  constructor() {
    super('wisp');
    // v1: original single store. Kept so an existing on-disk v1 DB upgrades
    // cleanly rather than being recreated (which would lose data).
    this.version(1).stores({
      transcripts: 'id, createdAt',
    });
    // v2: add courseId index + courses/media stores, and backfill segment ids.
    this.version(2)
      .stores({
        transcripts: 'id, createdAt, courseId',
        courses: 'id, name',
        media: 'transcriptId',
      })
      .upgrade(async (tx) => {
        // Backfill: every pre-existing transcript gets stable ids on any
        // segment that lacks one. courseId stays undefined => "Unsorted".
        // No other data is touched, so existing transcripts keep everything.
        await tx
          .table<StoredTranscript, string>('transcripts')
          .toCollection()
          .modify((row) => {
            row.segments = withSegmentIds(row.segments);
          });
      });
    // v3: add the segvecs store for Phase 1 on-device semantic search. The
    // existing transcripts/courses/media stores are re-declared unchanged so
    // Dexie keeps them; only the new store is added. No data backfill is needed
    // — vectors are produced lazily by the embedding pipeline.
    this.version(3).stores({
      transcripts: 'id, createdAt, courseId',
      courses: 'id, name',
      media: 'transcriptId',
      segvecs: '[transcriptId+segmentId], transcriptId, courseId, model',
    });
    // v4: add the flashcards store for Phase 3 spaced repetition. Existing
    // stores are re-declared unchanged so Dexie keeps them; only the new store
    // is added. No backfill — flashcards are produced lazily by the study UI.
    this.version(4).stores({
      transcripts: 'id, createdAt, courseId',
      courses: 'id, name',
      media: 'transcriptId',
      segvecs: '[transcriptId+segmentId], transcriptId, courseId, model',
      flashcards: 'id, transcriptId, courseId',
    });
  }
}

const db = new WispDexie();

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

export const repo: StorageRepo = {
  // --- transcripts ---------------------------------------------------------
  async list(): Promise<TranscriptMeta[]> {
    // Sort by createdAt then reverse for newest-first.
    const rows = await db.transcripts.orderBy('createdAt').reverse().toArray();
    return rows.map(toMeta);
  },

  async get(id: string): Promise<Transcript | undefined> {
    const row = await db.transcripts.get(id);
    return row ? toTranscript(row) : undefined;
  },

  async create(draft: TranscriptDraft): Promise<Transcript> {
    // Validate+throw before doing anything else.
    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 stored: StoredTranscript = {
      id: newId('t_'),
      title: valid.title,
      createdAt: now,
      updatedAt: now,
      durationSec: valid.durationSec,
      modelId: valid.modelId,
      // Preserve optional language only when present (avoid storing undefined).
      ...(valid.language !== undefined ? { language: valid.language } : {}),
      segmentCount: segments.length,
      segments,
      // Course/lecture metadata. courseId defaults to null ("Unsorted").
      courseId: valid.courseId ?? null,
      ...(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 }
        : {}),
      hasMedia: false,
      searchText: deriveSearchText(valid.title, segments),
    };
    await db.transcripts.put(stored);
    return toTranscript(stored);
  },

  async update(id: string, patch: TranscriptPatch): Promise<Transcript> {
    const existing = await db.transcripts.get(id);
    if (!existing) {
      throw new Error(`transcript not found: ${id}`);
    }

    // Re-validate segments on every write so an update can never persist
    // non-finite times that a create() would have rejected, and ensure every
    // segment carries a stable id.
    const nextSegments =
      patch.segments !== undefined
        ? withSegmentIds(patch.segments.map((s) => zSegment.parse(s)))
        : existing.segments;
    const nextTitle = patch.title !== undefined ? patch.title : existing.title;

    const updated: StoredTranscript = {
      ...existing,
      title: nextTitle,
      segments: nextSegments,
      // Apply only the provided course/lecture meta fields.
      ...(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 }
        : {}),
      // Re-derive denormalized fields so search() and list() stay correct.
      segmentCount: nextSegments.length,
      searchText: deriveSearchText(nextTitle, nextSegments),
      updatedAt: Date.now(),
    };
    await db.transcripts.put(updated);
    // Segments were patched => any stored vectors are now stale (they were
    // embedded from the old text/timing). Drop them so the backfill re-embeds.
    if (patch.segments !== undefined) {
      await db.segvecs.where('transcriptId').equals(id).delete();
    }
    return toTranscript(updated);
  },

  async remove(id: string): Promise<void> {
    // Also delete any persisted media + vectors + flashcards for this transcript.
    await db.transaction(
      'rw',
      db.transcripts,
      db.media,
      db.segvecs,
      db.flashcards,
      async () => {
        await db.transcripts.delete(id);
        await db.media.delete(id);
        await db.segvecs.where('transcriptId').equals(id).delete();
        await db.flashcards.where('transcriptId').equals(id).delete();
      },
    );
  },

  async search(query: string): Promise<TranscriptMeta[]> {
    const needle = query.toLowerCase().trim();
    const rows = await db.transcripts.orderBy('createdAt').reverse().toArray();
    // Empty query => return everything (newest first), matching common list UX.
    const matched = needle
      ? rows.filter((r) => r.searchText.includes(needle))
      : rows;
    return matched.map(toMeta);
  },

  async listByCourse(courseId: string | null): Promise<TranscriptMeta[]> {
    const rows = await db.transcripts.orderBy('createdAt').reverse().toArray();
    const matched = rows.filter((r) =>
      courseId === null
        ? // "Unsorted": courseId null or undefined.
          r.courseId == null
        : r.courseId === courseId,
    );
    return matched.map(toMeta);
  },

  async reassign(transcriptId: string, courseId: string | null): Promise<void> {
    const existing = await db.transcripts.get(transcriptId);
    if (!existing) return;
    await db.transaction('rw', db.transcripts, db.segvecs, async () => {
      await db.transcripts.put({
        ...existing,
        courseId,
        updatedAt: Date.now(),
      });
      // Keep the denormalized courseId on every vector row in sync so
      // course-scoped search keeps matching after a move.
      await db.segvecs
        .where('transcriptId')
        .equals(transcriptId)
        .modify({ courseId });
    });
  },

  // --- courses -------------------------------------------------------------
  async listCourses(): Promise<Course[]> {
    // Alphabetical by name (case-insensitive for stable UX).
    const rows = await db.courses.toArray();
    return rows.sort((a, b) =>
      a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
    );
  },

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

  async updateCourse(id: string, patch: Partial<CourseDraft>): Promise<Course> {
    const existing = await db.courses.get(id);
    if (!existing) {
      throw new Error(`course not found: ${id}`);
    }
    const updated: Course = {
      ...existing,
      // Apply only the provided fields.
      ...(patch.name !== undefined ? { name: patch.name } : {}),
      ...(patch.code !== undefined ? { code: patch.code } : {}),
      ...(patch.instructor !== undefined ? { instructor: patch.instructor } : {}),
      ...(patch.term !== undefined ? { term: patch.term } : {}),
      ...(patch.color !== undefined ? { color: patch.color } : {}),
      updatedAt: Date.now(),
    };
    await db.courses.put(updated);
    return updated;
  },

  async deleteCourse(id: string): Promise<void> {
    // First reassign this course's transcripts to "Unsorted" (courseId=null),
    // THEN delete the course row, so no transcript is ever orphaned.
    await db.transaction(
      'rw',
      db.transcripts,
      db.courses,
      db.segvecs,
      db.flashcards,
      async () => {
        const now = Date.now();
        const owned = await db.transcripts
          .filter((r) => r.courseId === id)
          .toArray();
        await Promise.all(
          owned.map((r) =>
            db.transcripts.put({ ...r, courseId: null, updatedAt: now }),
          ),
        );
        // Keep denormalized vector rows consistent with their now-Unsorted owners.
        await db.segvecs.where('courseId').equals(id).modify({ courseId: null });
        // Flashcards in this course follow their transcripts to Unsorted.
        await db.flashcards.where('courseId').equals(id).modify({ courseId: null });
        await db.courses.delete(id);
      },
    );
  },

  // --- media (persisted source audio) -------------------------------------
  async putMedia(transcriptId: string, input: MediaInput): Promise<void> {
    // Web import path: bytes arrive in input.data. Wrap them in a Blob tagged
    // with the declared mime so getMediaUrl can hand back an object URL.
    const blob = new Blob(input.data !== undefined ? [input.data] : [], {
      type: input.mime,
    });
    await db.transaction('rw', db.transcripts, db.media, async () => {
      await db.media.put({ transcriptId, blob, mime: input.mime });
      const existing = await db.transcripts.get(transcriptId);
      if (existing) {
        await db.transcripts.put({ ...existing, hasMedia: true });
      }
    });
  },

  async getMediaUrl(transcriptId: string): Promise<string | undefined> {
    const row = await db.media.get(transcriptId);
    if (!row) return undefined;
    return URL.createObjectURL(row.blob);
  },

  async removeMedia(transcriptId: string): Promise<void> {
    await db.transaction('rw', db.transcripts, db.media, async () => {
      await db.media.delete(transcriptId);
      const existing = await db.transcripts.get(transcriptId);
      if (existing) {
        await db.transcripts.put({ ...existing, hasMedia: false });
      }
    });
  },

  // --- semantic search vectors (Phase 1) ----------------------------------
  async upsertVectors(
    transcriptId: string,
    model: string,
    vectors: SegmentVector[],
  ): Promise<void> {
    await db.transaction('rw', db.transcripts, db.segvecs, async () => {
      // Denormalize the transcript's current courseId onto each row so
      // course-scoped search never has to join back to transcripts.
      const owner = await db.transcripts.get(transcriptId);
      const courseId = owner ? owner.courseId ?? null : null;
      // Replace, not merge: drop every existing row for this transcript first
      // so a re-embed with fewer segments can't leave stale rows behind.
      await db.segvecs.where('transcriptId').equals(transcriptId).delete();
      const rows: StoredSegVec[] = vectors.map((v) => ({
        transcriptId,
        segmentId: v.segmentId,
        start: v.start,
        end: v.end,
        courseId,
        text: v.text,
        vector: v.vector,
        model,
      }));
      await db.segvecs.bulkPut(rows);
    });
  },

  async searchVectors(
    query: Float32Array,
    opts?: { courseId?: string | null; limit?: number },
  ): Promise<VectorHit[]> {
    // Scope: when opts.courseId is provided we filter (null => Unsorted, i.e.
    // courseId == null); when it is omitted entirely we search every course.
    let rows: StoredSegVec[];
    if (opts && opts.courseId !== undefined) {
      const scope = opts.courseId;
      if (scope === null) {
        // IndexedDB cannot index null keys, so Unsorted is filtered in memory.
        const all = await db.segvecs.toArray();
        rows = all.filter((r) => r.courseId === null);
      } else {
        rows = await db.segvecs.where('courseId').equals(scope).toArray();
      }
    } else {
      rows = await db.segvecs.toArray();
    }

    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, r.vector),
    }));
    hits.sort((a, b) => b.score - a.score);
    return hits.slice(0, limit);
  },

  async clearVectors(transcriptId: string): Promise<void> {
    await db.segvecs.where('transcriptId').equals(transcriptId).delete();
  },

  async unembeddedIds(model: string): Promise<string[]> {
    // Transcript ids with ZERO segvecs rows for this model. We collect the set
    // of embedded ids for the model, then return every transcript id not in it.
    const embedded = new Set<string>();
    await db.segvecs
      .where('model')
      .equals(model)
      .each((row) => {
        embedded.add(row.transcriptId);
      });
    const ids = await db.transcripts.toCollection().primaryKeys();
    return ids.filter((id) => !embedded.has(id));
  },

  // --- flashcards + spaced repetition (Phase 3) ---------------------------
  async createFlashcards(drafts: FlashcardDraft[]): Promise<Flashcard[]> {
    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.flashcards.bulkPut(cards);
    return cards;
  },

  async listFlashcards(opts?: {
    transcriptId?: string;
    courseId?: string | null;
  }): Promise<Flashcard[]> {
    let rows = await db.flashcards.toArray();
    if (opts?.transcriptId !== undefined) {
      rows = rows.filter((c) => c.transcriptId === opts.transcriptId);
    }
    if (opts && opts.courseId !== undefined) {
      const scope = opts.courseId;
      rows = rows.filter((c) =>
        // null scope => Unsorted (courseId null or undefined).
        scope === null ? c.courseId == null : c.courseId === scope,
      );
    }
    return rows;
  },

  async listDueFlashcards(opts?: {
    courseId?: string | null;
    now?: number;
    limit?: number;
  }): Promise<Flashcard[]> {
    const now = opts?.now ?? Date.now();
    let rows = await db.flashcards.toArray();
    if (opts && opts.courseId !== undefined) {
      const scope = opts.courseId;
      rows = rows.filter((c) =>
        scope === null ? c.courseId == null : c.courseId === scope,
      );
    }
    // Due means the next-review time is now or in the past.
    rows = rows.filter((c) => c.srs.due <= now);
    // Soonest-due first.
    rows.sort((a, b) => a.srs.due - b.srs.due);
    if (opts?.limit !== undefined) {
      rows = rows.slice(0, opts.limit);
    }
    return rows;
  },

  async updateFlashcardSrs(id: string, srs: SrsState): Promise<Flashcard> {
    const card = await db.flashcards.get(id);
    if (!card) {
      throw new Error(`flashcard not found: ${id}`);
    }
    const updated: Flashcard = { ...card, srs };
    await db.flashcards.put(updated);
    return updated;
  },

  async deleteFlashcard(id: string): Promise<void> {
    await db.flashcards.delete(id);
  },

  async flashcardCounts(
    courseId?: string | null,
  ): Promise<{ total: number; due: number }> {
    const now = Date.now();
    let rows = await db.flashcards.toArray();
    if (courseId !== undefined) {
      rows = rows.filter((c) =>
        courseId === null ? c.courseId == null : c.courseId === courseId,
      );
    }
    const due = rows.filter((c) => c.srs.due <= now).length;
    return { total: rows.length, due };
  },
};
