import {
  chunkScientificBlocks,
  isLikelyScannedPdf,
  scientificBlocksFromPages,
  type ExtractedPaperMetadata,
  type ExtractedPdfPage,
  type PaperPreparedIndex,
  type PdfTextLine,
} from '@learnitive/paper-memory';
import * as pdfjs from 'pdfjs-dist';

pdfjs.GlobalWorkerOptions.workerSrc = new URL(
  'pdfjs-dist/build/pdf.worker.min.mjs',
  import.meta.url,
).toString();

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

type TextItemLike = {
  str: string;
  transform: readonly number[];
  width: number;
  height: number;
};

const scope = globalThis as unknown as {
  postMessage(message: WorkerResponse): void;
  onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
};
const controllers = new Map<string, AbortController>();

function clean(value: unknown): string | undefined {
  if (typeof value !== 'string') return undefined;
  const normalized = value.replace(/\s+/gu, ' ').trim();
  return normalized || undefined;
}

function linesFromItems(items: readonly TextItemLike[]): PdfTextLine[] {
  const sorted = items
    .filter((item) => clean(item.str))
    .map((item) => ({
      text: clean(item.str)!,
      x: item.transform[4] ?? 0,
      y: item.transform[5] ?? 0,
      width: Math.max(0, item.width || 0),
      height: Math.max(0, item.height || 0),
      fontSize: Math.max(1, Math.hypot(item.transform[2] ?? 0, item.transform[3] ?? 0) || item.height || 10),
    }))
    .sort((a, b) => b.y - a.y || a.x - b.x);

  const lines: PdfTextLine[] = [];
  for (const item of sorted) {
    const threshold = Math.max(2, item.fontSize * 0.3);
    // PDF.js text items arrive in approximate reading order. Scan only the
    // most recent lines instead of repeatedly walking every line on the page.
    let line: PdfTextLine | undefined;
    for (let index = lines.length - 1; index >= 0; index -= 1) {
      const candidate = lines[index];
      if (!candidate) continue;
      const delta = candidate.y - item.y;
      if (delta > Math.max(threshold, candidate.fontSize * 0.45) + 4) break;
      if (Math.abs(delta) <= Math.max(threshold, candidate.fontSize * 0.3)) {
        line = candidate;
        break;
      }
    }
    if (!line) {
      lines.push({ ...item });
      continue;
    }
    const left = line.x <= item.x ? line : item;
    const right = left === line ? item : line;
    const gap = right.x - (left.x + left.width);
    const separator = gap > Math.max(1.5, Math.min(left.fontSize, right.fontSize) * 0.18) ? ' ' : '';
    const text = left.text.endsWith('-') && separator ? `${left.text.slice(0, -1)}${right.text}` : `${left.text}${separator}${right.text}`;
    line.text = text;
    const lineRight = line.x + line.width;
    const itemRight = item.x + item.width;
    line.x = Math.min(line.x, item.x);
    line.width = Math.max(lineRight, itemRight) - line.x;
    line.height = Math.max(line.height, item.height);
    line.fontSize = Math.max(line.fontSize, item.fontSize);
    line.y = Math.max(line.y, item.y);
  }
  return lines.sort((a, b) => b.y - a.y || a.x - b.x);
}

function doiFrom(text: string): string | undefined {
  const match = text.match(/\b10\.\d{4,9}\/[A-Z0-9._;()/:+-]+/iu)?.[0];
  return match?.replace(/[),.;:]+$/u, '');
}

function arxivFrom(text: string): string | undefined {
  return text.match(/\barXiv\s*:\s*(\d{4}\.\d{4,5}(?:v\d+)?)/iu)?.[1];
}

function yearFromInfo(info: Record<string, unknown>): number | undefined {
  const raw = clean(info.CreationDate) ?? clean(info.ModDate);
  const match = raw?.match(/(?:D:)?((?:19|20)\d{2})/u)?.[1];
  if (!match) return undefined;
  const year = Number(match);
  return year >= 1900 && year <= new Date().getFullYear() + 1 ? year : undefined;
}

function authorsFromInfo(info: Record<string, unknown>): string[] | undefined {
  const author = clean(info.Author);
  if (!author) return undefined;
  const parts = author.split(/\s*;\s*|\s+and\s+/iu).map((value) => value.trim()).filter(Boolean);
  return parts.length ? parts : undefined;
}

async function extract(paperId: string, bytes: ArrayBuffer, signal: AbortSignal, requestId: string): Promise<PaperPreparedIndex> {
  signal.throwIfAborted();
  const loadingTask = pdfjs.getDocument({ data: bytes.slice(0) });
  const abort = () => loadingTask.destroy();
  signal.addEventListener('abort', abort, { once: true });
  try {
    const document = await loadingTask.promise;
    const pages: ExtractedPdfPage[] = [];
    for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
      signal.throwIfAborted();
      const page = await document.getPage(pageNumber);
      const viewport = page.getViewport({ scale: 1 });
      const content = await page.getTextContent();
      const items = content.items
        .filter((item) => 'str' in item)
        .map((item) => item as unknown as TextItemLike);
      const lines = linesFromItems(items);
      pages.push({
        pageNumber,
        width: viewport.width,
        height: viewport.height,
        lines,
        text: lines.map((line) => line.text).join('\n'),
      });
      scope.postMessage({ id: requestId, type: 'progress', phase: 'extracting', completed: pageNumber, total: document.numPages });
      page.cleanup();
    }
    const textForIds = pages.slice(0, 2).map((page) => page.text).join('\n');
    let info: Record<string, unknown> = {};
    try {
      const metadata = await document.getMetadata();
      info = (metadata.info ?? {}) as unknown as Record<string, unknown>;
    } catch {
      // Metadata is advisory; extraction remains usable without it.
    }
    const scanned = isLikelyScannedPdf(pages);
    const metadata: ExtractedPaperMetadata = {
      ...(clean(info.Title) ? { title: clean(info.Title) } : {}),
      ...(authorsFromInfo(info) ? { authors: authorsFromInfo(info) } : {}),
      ...(yearFromInfo(info) ? { year: yearFromInfo(info) } : {}),
      ...(doiFrom(textForIds) ? { doi: doiFrom(textForIds) } : {}),
      ...(arxivFrom(textForIds) ? { arxivId: arxivFrom(textForIds) } : {}),
    };
    const blocks = scanned ? [] : scientificBlocksFromPages(pages);
    scope.postMessage({ id: requestId, type: 'progress', phase: 'chunking', completed: 0, total: blocks.length });
    const chunks = scanned ? [] : await chunkScientificBlocks(paperId, blocks);
    scope.postMessage({ id: requestId, type: 'progress', phase: 'chunking', completed: blocks.length, total: blocks.length });
    return {
      extraction: { pageCount: pages.length, pages, blocks, metadata, scanned },
      chunks,
    };
  } finally {
    signal.removeEventListener('abort', abort);
    await loadingTask.destroy().catch(() => undefined);
  }
}

scope.onmessage = (event) => {
  const request = event.data;
  if (request.type === 'cancel') {
    controllers.get(request.id)?.abort();
    return;
  }
  const controller = new AbortController();
  controllers.set(request.id, controller);
  void extract(request.paperId, request.bytes, controller.signal, request.id)
    .then((value) => scope.postMessage({ id: request.id, type: 'response', ok: true, value }))
    .catch((error) => scope.postMessage({
      id: request.id,
      type: 'response',
      ok: false,
      error: error instanceof Error ? error.message : 'Unable to extract PDF',
    }))
    .finally(() => controllers.delete(request.id));
};
