import {
  PAPER_MEMORY_SCHEMA_VERSION,
  type PaperIndexGeneration,
  type PaperMemoryCapabilities,
  type PaperMemoryExtractor,
  type PaperMemoryManifest,
  type PaperMemoryPlatform,
  type PaperMemoryStore,
  type PaperPreparedIndex,
  type PaperRecord,
} from '@learnitive/paper-memory';

const DB_NAME = 'interactive-paper-memory-v1';
const DB_VERSION = 1;
const MANIFESTS = 'manifests';
const INDICES = 'indices';
const PDF_FALLBACK = 'pdf-fallback';
const OPFS_ROOT = 'interactive-paper-memory';
const OPFS_VERSION = 'v1';

type WorkerRequest =
  | { id: string; type: 'extract'; paperId: string; bytes: ArrayBuffer }
  | { id: string; type: 'cancel' };

type WorkerResponse =
  | { id: string; type: 'progress'; phase: 'extracting' | 'chunking'; completed: number; total: number }
  | { id: string; type: 'response'; ok: true; value: PaperPreparedIndex }
  | { id: string; type: 'response'; ok: false; error: string };

function openDatabase(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);
    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains(MANIFESTS)) db.createObjectStore(MANIFESTS);
      if (!db.objectStoreNames.contains(INDICES)) db.createObjectStore(INDICES);
      if (!db.objectStoreNames.contains(PDF_FALLBACK)) db.createObjectStore(PDF_FALLBACK);
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error ?? new Error('Unable to open Paper Memory database'));
  });
}

async function requestValue<T>(request: IDBRequest<T>, transaction?: IDBTransaction): Promise<T> {
  return new Promise<T>((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error ?? new Error('Paper Memory storage request failed'));
    if (transaction) transaction.onabort = () => reject(transaction.error ?? new Error('Paper Memory storage transaction aborted'));
  });
}

function emptyManifest(memoryId: string): PaperMemoryManifest {
  return { schemaVersion: PAPER_MEMORY_SCHEMA_VERSION, memoryId, updatedAt: Date.now(), papers: [] };
}

function indexKey(memoryId: string, paperId: string, generationId: string): string {
  return `${memoryId}:${paperId}:${generationId}`;
}

function pdfKey(memoryId: string, paperId: string): string {
  return `${memoryId}:${paperId}`;
}

async function opfsPaperDirectory(memoryId: string, paperId: string, create: boolean): Promise<FileSystemDirectoryHandle | undefined> {
  if (!navigator.storage?.getDirectory) return undefined;
  try {
    const root = await navigator.storage.getDirectory();
    const namespace = await root.getDirectoryHandle(OPFS_ROOT, { create });
    const version = await namespace.getDirectoryHandle(OPFS_VERSION, { create });
    const memory = await version.getDirectoryHandle(memoryId, { create });
    const papers = await memory.getDirectoryHandle('papers', { create });
    return papers.getDirectoryHandle(paperId, { create });
  } catch (error) {
    if (!create && error instanceof DOMException && error.name === 'NotFoundError') return undefined;
    return undefined;
  }
}

async function removeOpfsPaper(memoryId: string, paperId: string): Promise<void> {
  if (!navigator.storage?.getDirectory) return;
  try {
    const root = await navigator.storage.getDirectory();
    const namespace = await root.getDirectoryHandle(OPFS_ROOT);
    const version = await namespace.getDirectoryHandle(OPFS_VERSION);
    const memory = await version.getDirectoryHandle(memoryId);
    const papers = await memory.getDirectoryHandle('papers');
    await papers.removeEntry(paperId, { recursive: true });
  } catch (error) {
    if (!(error instanceof DOMException && error.name === 'NotFoundError')) throw error;
  }
}

async function clearOpfsMemory(memoryId: string): Promise<void> {
  if (!navigator.storage?.getDirectory) return;
  try {
    const root = await navigator.storage.getDirectory();
    const namespace = await root.getDirectoryHandle(OPFS_ROOT);
    const version = await namespace.getDirectoryHandle(OPFS_VERSION);
    await version.removeEntry(memoryId, { recursive: true });
  } catch (error) {
    if (!(error instanceof DOMException && error.name === 'NotFoundError')) throw error;
  }
}

class BrowserPaperMemoryStore implements PaperMemoryStore {
  async loadManifest(memoryId: string): Promise<PaperMemoryManifest> {
    const db = await openDatabase();
    try {
      const transaction = db.transaction(MANIFESTS, 'readonly');
      const value = await requestValue<unknown>(transaction.objectStore(MANIFESTS).get(memoryId), transaction);
      if (value === undefined) return emptyManifest(memoryId);
      if (!value || typeof value !== 'object') throw new Error('Paper Memory metadata is corrupted. Clear Paper Memory or restore from a valid browser profile.');
      const schemaVersion = (value as { schemaVersion?: unknown }).schemaVersion;
      if (schemaVersion !== PAPER_MEMORY_SCHEMA_VERSION) {
        // Never silently replace an unknown/newer manifest with an empty one: that
        // would orphan locally stored research PDFs. Future migrations must be
        // explicit and lossless.
        throw new Error(`Paper Memory schema ${String(schemaVersion)} is not supported by this Learnitive build (expected ${PAPER_MEMORY_SCHEMA_VERSION}).`);
      }
      const manifest = value as PaperMemoryManifest;
      if (manifest.memoryId !== memoryId || !Array.isArray(manifest.papers)) {
        throw new Error('Paper Memory metadata is invalid for this project.');
      }
      return manifest;
    } finally {
      db.close();
    }
  }

  async saveManifest(manifest: PaperMemoryManifest): Promise<void> {
    const db = await openDatabase();
    try {
      const transaction = db.transaction(MANIFESTS, 'readwrite');
      await requestValue(transaction.objectStore(MANIFESTS).put({ ...manifest, updatedAt: Date.now() }, manifest.memoryId), transaction);
    } finally {
      db.close();
    }
  }

  async storeSourcePdf(memoryId: string, paperId: string, bytes: ArrayBuffer): Promise<void> {
    const directory = await opfsPaperDirectory(memoryId, paperId, true);
    if (directory) {
      const file = await directory.getFileHandle('source.pdf', { create: true });
      const writable = await file.createWritable();
      try {
        await writable.write(new Blob([bytes], { type: 'application/pdf' }));
        await writable.close();
        return;
      } catch (error) {
        await writable.abort().catch(() => undefined);
        throw error;
      }
    }
    const db = await openDatabase();
    try {
      const transaction = db.transaction(PDF_FALLBACK, 'readwrite');
      await requestValue(transaction.objectStore(PDF_FALLBACK).put(bytes.slice(0), pdfKey(memoryId, paperId)), transaction);
    } finally {
      db.close();
    }
  }

  async readSourcePdf(memoryId: string, paperId: string): Promise<ArrayBuffer | undefined> {
    const directory = await opfsPaperDirectory(memoryId, paperId, false);
    if (directory) {
      try {
        const file = await (await directory.getFileHandle('source.pdf')).getFile();
        return file.arrayBuffer();
      } catch (error) {
        if (!(error instanceof DOMException && error.name === 'NotFoundError')) throw error;
      }
    }
    const db = await openDatabase();
    try {
      const transaction = db.transaction(PDF_FALLBACK, 'readonly');
      return requestValue<ArrayBuffer | undefined>(transaction.objectStore(PDF_FALLBACK).get(pdfKey(memoryId, paperId)), transaction);
    } finally {
      db.close();
    }
  }

  async loadActiveIndex(memoryId: string, paperId: string): Promise<PaperIndexGeneration | undefined> {
    const manifest = await this.loadManifest(memoryId);
    const record = manifest.papers.find((paper) => paper.id === paperId);
    if (!record?.activeGenerationId) return undefined;
    const db = await openDatabase();
    try {
      const transaction = db.transaction(INDICES, 'readonly');
      return requestValue<PaperIndexGeneration | undefined>(transaction.objectStore(INDICES).get(indexKey(memoryId, paperId, record.activeGenerationId)), transaction);
    } finally {
      db.close();
    }
  }

  async commitIndex(memoryId: string, record: PaperRecord, generation: PaperIndexGeneration): Promise<void> {
    const db = await openDatabase();
    try {
      const transaction = db.transaction([MANIFESTS, INDICES], 'readwrite');
      const manifests = transaction.objectStore(MANIFESTS);
      const indices = transaction.objectStore(INDICES);
      const current = await requestValue<PaperMemoryManifest | undefined>(manifests.get(memoryId), transaction) ?? emptyManifest(memoryId);
      const nextRecord = { ...record, activeGenerationId: generation.generationId, indexingStatus: 'ready' as const };
      const papers = current.papers.some((paper) => paper.id === record.id)
        ? current.papers.map((paper) => paper.id === record.id ? nextRecord : paper)
        : [...current.papers, nextRecord];
      const embedding = generation.embedding
        ? { engine: generation.embedding.engine, model: generation.embedding.model, modelVersion: generation.embedding.modelVersion, dimensions: generation.embedding.dimensions }
        : current.embedding;
      await requestValue(indices.put(generation, indexKey(memoryId, record.id, generation.generationId)), transaction);
      // A generation becomes active in the same transaction as its data. Once
      // that atomic swap is committed, older generations are unnecessary and
      // can be removed without ever exposing a half-written active index.
      const keys = await requestValue<IDBValidKey[]>(indices.getAllKeys(), transaction);
      for (const key of keys) {
        if (typeof key !== 'string') continue;
        if (!key.startsWith(`${memoryId}:${record.id}:`)) continue;
        if (key === indexKey(memoryId, record.id, generation.generationId)) continue;
        indices.delete(key);
      }
      await requestValue(manifests.put({ ...current, papers, ...(embedding ? { embedding } : {}), updatedAt: Date.now() }, memoryId), transaction);
    } finally {
      db.close();
    }
  }

  async removePaper(memoryId: string, paperId: string): Promise<void> {
    const db = await openDatabase();
    try {
      const transaction = db.transaction([MANIFESTS, INDICES, PDF_FALLBACK], 'readwrite');
      const manifests = transaction.objectStore(MANIFESTS);
      const current = await requestValue<PaperMemoryManifest | undefined>(manifests.get(memoryId), transaction) ?? emptyManifest(memoryId);
      await requestValue(manifests.put({
        ...current,
        papers: current.papers.filter((paper) => paper.id !== paperId),
        collections: current.collections?.map((collection) => ({ ...collection, paperIds: collection.paperIds.filter((id) => id !== paperId), updatedAt: Date.now() })),
        notes: current.notes?.filter((note) => note.paperId !== paperId),
        systematicReviews: current.systematicReviews?.map((review) => ({ ...review, studies: review.studies.filter((study) => study.paperId !== paperId), updatedAt: Date.now() })),
        updatedAt: Date.now(),
      }, memoryId), transaction);
      const indexStore = transaction.objectStore(INDICES);
      const keys = await requestValue<IDBValidKey[]>(indexStore.getAllKeys(), transaction);
      for (const key of keys) if (typeof key === 'string' && key.startsWith(`${memoryId}:${paperId}:`)) indexStore.delete(key);
      transaction.objectStore(PDF_FALLBACK).delete(pdfKey(memoryId, paperId));
    } finally {
      db.close();
    }
    await removeOpfsPaper(memoryId, paperId);
  }

  async clear(memoryId: string): Promise<void> {
    const manifest = await this.loadManifest(memoryId);
    const db = await openDatabase();
    try {
      const transaction = db.transaction([MANIFESTS, INDICES, PDF_FALLBACK], 'readwrite');
      transaction.objectStore(MANIFESTS).delete(memoryId);
      const indexStore = transaction.objectStore(INDICES);
      for (const key of await requestValue<IDBValidKey[]>(indexStore.getAllKeys(), transaction)) {
        if (typeof key === 'string' && key.startsWith(`${memoryId}:`)) indexStore.delete(key);
      }
      const pdfStore = transaction.objectStore(PDF_FALLBACK);
      for (const paper of manifest.papers) pdfStore.delete(pdfKey(memoryId, paper.id));
    } finally {
      db.close();
    }
    await clearOpfsMemory(memoryId);
  }

  async estimateUsage(memoryId: string): Promise<{ sourceBytes: number; metadataBytes: number; vectorBytes: number }> {
    const manifest = await this.loadManifest(memoryId);
    let sourceBytes = 0;
    let metadataBytes = new TextEncoder().encode(JSON.stringify(manifest)).byteLength;
    let vectorBytes = 0;
    for (const paper of manifest.papers) {
      const source = await this.readSourcePdf(memoryId, paper.id);
      sourceBytes += source?.byteLength ?? 0;
      const index = await this.loadActiveIndex(memoryId, paper.id);
      if (index) {
        vectorBytes += index.embedding?.vectors.byteLength ?? 0;
        const metadataOnly = index.embedding ? { ...index, embedding: { ...index.embedding, vectors: undefined } } : index;
        metadataBytes += new TextEncoder().encode(JSON.stringify(metadataOnly)).byteLength;
      }
    }
    return { sourceBytes, metadataBytes, vectorBytes };
  }
}

class PaperMemoryWorkerExtractor implements PaperMemoryExtractor {
  private worker?: Worker;
  private pending = new Map<string, {
    resolve(value: PaperPreparedIndex): void;
    reject(error: Error): void;
    onProgress?: (progress: { phase: 'extracting' | 'chunking'; completed: number; total: number }) => void;
  }>();

  private ensureWorker(): Worker {
    if (this.worker) return this.worker;
    const worker = new Worker(new URL('./index.worker.ts', import.meta.url), {
      type: 'module',
      name: 'interactive-paper-memory-index',
    });
    worker.addEventListener('message', (event: MessageEvent<WorkerResponse>) => {
      const message = event.data;
      const pending = this.pending.get(message.id);
      if (!pending) return;
      if (message.type === 'progress') {
        pending.onProgress?.({ phase: message.phase, completed: message.completed, total: message.total });
        return;
      }
      this.pending.delete(message.id);
      if (message.ok) pending.resolve(message.value);
      else pending.reject(new Error(message.error));
    });
    worker.addEventListener('error', (event) => {
      const error = new Error(event.message || 'Paper Memory index worker failed');
      for (const pending of this.pending.values()) pending.reject(error);
      this.pending.clear();
      worker.terminate();
      if (this.worker === worker) this.worker = undefined;
    });
    this.worker = worker;
    return worker;
  }

  extractAndChunk(
    paperId: string,
    bytes: ArrayBuffer,
    signal?: AbortSignal,
    onProgress?: (progress: { phase: 'extracting' | 'chunking'; completed: number; total: number }) => void,
  ): Promise<PaperPreparedIndex> {
    const id = crypto.randomUUID();
    const worker = this.ensureWorker();
    const transferable = bytes.slice(0);
    return new Promise((resolve, reject) => {
      const onAbort = () => {
        worker.postMessage({ id, type: 'cancel' } satisfies WorkerRequest);
        this.pending.delete(id);
        reject(new DOMException('The operation was aborted', 'AbortError'));
      };
      if (signal?.aborted) {
        onAbort();
        return;
      }
      signal?.addEventListener('abort', onAbort, { once: true });
      this.pending.set(id, {
        resolve: (value) => {
          signal?.removeEventListener('abort', onAbort);
          resolve(value);
        },
        reject: (error) => {
          signal?.removeEventListener('abort', onAbort);
          reject(error);
        },
        onProgress,
      });
      worker.postMessage({ id, type: 'extract', paperId, bytes: transferable } satisfies WorkerRequest, [transferable]);
    });
  }

  dispose(): void {
    const error = new Error('Paper Memory extractor disposed');
    for (const pending of this.pending.values()) pending.reject(error);
    this.pending.clear();
    this.worker?.terminate();
    this.worker = undefined;
  }
}

export function createBrowserPaperMemoryPlatform(): PaperMemoryPlatform {
  const store = new BrowserPaperMemoryStore();
  const extractors = new Set<PaperMemoryWorkerExtractor>();
  return {
    store,
    capabilities: async (): Promise<PaperMemoryCapabilities> => {
      const indexedDb = typeof indexedDB !== 'undefined';
      const opfs = Boolean(globalThis.navigator?.storage?.getDirectory);
      const workers = typeof Worker !== 'undefined';
      const storageEstimate = Boolean(globalThis.navigator?.storage?.estimate);
      const persistenceMayPrompt = /Firefox\/|FxiOS\//u.test(globalThis.navigator?.userAgent || '');
      const persistentStorage = Boolean(globalThis.navigator?.storage?.persist) && !persistenceMayPrompt;
      const [estimate, persisted] = await Promise.all([
        storageEstimate ? globalThis.navigator.storage.estimate().catch(() => undefined) : Promise.resolve(undefined),
        globalThis.navigator?.storage?.persisted ? globalThis.navigator.storage.persisted().catch(() => undefined) : Promise.resolve(undefined),
      ]);
      return {
        indexedDb,
        opfs,
        workers,
        storageEstimate,
        persistentStorage,
        preferredPdfBackend: opfs ? 'opfs' : 'indexeddb',
        ...(persisted !== undefined ? { persisted } : {}),
        ...(typeof estimate?.usage === 'number' ? { browserUsageBytes: estimate.usage } : {}),
        ...(typeof estimate?.quota === 'number' ? { quotaBytes: estimate.quota } : {}),
      };
    },
    requestPersistence: async () => {
      if (!globalThis.navigator?.storage?.persist || /Firefox\/|FxiOS\//u.test(globalThis.navigator?.userAgent || '')) return undefined;
      return globalThis.navigator.storage.persist();
    },
    createExtractor: () => {
      const extractor = new PaperMemoryWorkerExtractor();
      extractors.add(extractor);
      return extractor;
    },
    dispose: () => {
      for (const extractor of extractors) extractor.dispose();
      extractors.clear();
    },
  };
}
