// 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,
  zSegment,
  type TranscriptDraft,
  type TranscriptMeta,
  type Transcript,
  type TranscriptPatch,
  type Course,
  type CourseDraft,
} from './schema';
import type { StorageRepo, MediaInput } 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;
}

// ---------------------------------------------------------------------------
// 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;
}

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

class WispDexie extends Dexie {
  transcripts!: Dexie.Table<StoredTranscript, string>;
  courses!: Dexie.Table<Course, string>;
  media!: Dexie.Table<StoredMedia, 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);
          });
      });
  }
}

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);
    return toTranscript(updated);
  },

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

  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.transcripts.put({
      ...existing,
      courseId,
      updatedAt: Date.now(),
    });
  },

  // --- 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, 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 }),
        ),
      );
      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 });
      }
    });
  },
};
