import { classifyParagraph, normalizeReadableText } from "../../domain/text.ts";
import { orderPageBlocks, type OcrBlock } from "../ocr/layout.ts";
import {
  BookParserError,
  type ParsedBlock,
  type ParsedBook,
  type ParsedChapter,
} from "./contracts.ts";

interface PdfTextItemLike {
  str: string;
  transform: ArrayLike<number>;
  width: number;
  height: number;
  hasEOL?: boolean;
}

interface PdfViewportLike {
  width: number;
  height: number;
}

export interface PdfLineBlocks extends Array<OcrBlock> {
  layout: "single" | "two-column";
}

interface PdfPageLike {
  getViewport(options: { scale: number }): PdfViewportLike;
  getTextContent(): Promise<{ items: unknown[] }>;
}

interface PdfOutlineItemLike {
  title: string;
  dest: unknown;
  items?: PdfOutlineItemLike[];
}

export interface PdfDocumentLike {
  numPages: number;
  getPage(pageNumber: number): Promise<PdfPageLike>;
  getMetadata(): Promise<{
    info?: Record<string, unknown>;
    metadata?: { get(name: string): unknown } | null;
  }>;
  getOutline(): Promise<PdfOutlineItemLike[] | null>;
  getPageIndex?(reference: unknown): Promise<number>;
  getDestination?(name: string): Promise<unknown>;
  destroy?(): Promise<void>;
}

function isTextItem(item: unknown): item is PdfTextItemLike {
  return Boolean(
    item &&
      typeof item === "object" &&
      "str" in item &&
      typeof item.str === "string" &&
      "transform" in item &&
      item.transform &&
      typeof item.transform === "object" &&
      "width" in item &&
      typeof item.width === "number",
  );
}

function toOcrBlock(item: PdfTextItemLike): OcrBlock {
  const transform = Array.from(item.transform);
  return {
    text: item.str,
    x: Number(transform[4] ?? 0),
    y: Number(transform[5] ?? 0),
    width: Math.abs(item.width),
    height: Math.abs(item.height || transform[3] || 12),
    confidence: 100,
  };
}

export function groupPdfTextItems(
  rawItems: readonly unknown[],
  viewport: PdfViewportLike,
): PdfLineBlocks {
  const items = rawItems.filter(isTextItem).map((item) => ({
    source: item,
    block: toOcrBlock(item),
  }));
  items.sort((left, right) => {
    const lineTolerance = Math.max(left.block.height, right.block.height) * 0.55;
    if (Math.abs(left.block.y - right.block.y) > lineTolerance) {
      return right.block.y - left.block.y;
    }
    return left.block.x - right.block.x;
  });

  const lines: OcrBlock[] = [];
  for (const item of items) {
    const last = lines.at(-1);
    const tolerance = Math.max(last?.height ?? 0, item.block.height) * 0.55;
    const sameLine = last && Math.abs(last.y - item.block.y) <= tolerance;
    const nearby = last && item.block.x <= last.x + last.width + item.block.height * 1.5;
    if (sameLine && nearby) {
      const needsSpace =
        !last.text.endsWith(" ") &&
        !item.block.text.startsWith(" ") &&
        /[\p{Script=Latin}\d]$/u.test(last.text) &&
        /^[\p{Script=Latin}\d]/u.test(item.block.text);
      last.text += `${needsSpace ? " " : ""}${item.block.text}`;
      last.width = Math.max(last.width, item.block.x + item.block.width - last.x);
      last.height = Math.max(last.height, item.block.height);
    } else {
      lines.push({ ...item.block });
    }
  }

  const pageCoordinates = lines.map((line) => ({
    ...line,
    y: viewport.height - line.y,
  }));
  const ordered = orderPageBlocks(pageCoordinates, viewport.width);
  const result = ordered
    .map((line) => ({
      ...line,
      text: normalizeReadableText(line.text),
    }))
    .filter((line) => line.text) as PdfLineBlocks;
  result.layout = ordered.layout;
  return result;
}

function parsedBlock(
  pageNumber: number,
  index: number,
  block: OcrBlock,
): ParsedBlock {
  const kind = classifyParagraph(block.text);
  const parsedKind = kind === "formula" || kind === "table" ? kind : "prose";
  return {
    id: `pdf-${pageNumber}-${index}`,
    text: block.text,
    kind: parsedKind,
    included: parsedKind === "prose",
    pageNumber,
    confidence: 100,
    warning: parsedKind === "prose" ? undefined : parsedKind,
  };
}

function infoText(
  info: Record<string, unknown> | undefined,
  metadata: { get(name: string): unknown } | null | undefined,
  field: "Title" | "Author",
): string {
  const raw = info?.[field] ?? metadata?.get(`dc:${field.toLowerCase()}`);
  return typeof raw === "string" ? normalizeReadableText(raw) : "";
}

async function destinationPage(
  document: PdfDocumentLike,
  destination: unknown,
): Promise<number | null> {
  let resolved = destination;
  if (typeof destination === "string" && document.getDestination) {
    resolved = await document.getDestination(destination);
  }
  if (!Array.isArray(resolved) || !resolved.length) return null;
  const reference = resolved[0];
  if (typeof reference === "number") return reference + 1;
  if (!document.getPageIndex) return null;
  try {
    return (await document.getPageIndex(reference)) + 1;
  } catch {
    return null;
  }
}

async function outlineStarts(document: PdfDocumentLike) {
  const outline = await document.getOutline();
  if (!outline) return [];
  const flattened: PdfOutlineItemLike[] = [];
  const visit = (items: PdfOutlineItemLike[]) => {
    for (const item of items) {
      flattened.push(item);
      if (item.items?.length) visit(item.items);
    }
  };
  visit(outline);
  const starts: Array<{ title: string; page: number }> = [];
  for (const item of flattened) {
    const page = await destinationPage(document, item.dest);
    const title = normalizeReadableText(item.title);
    if (page && title && page >= 1 && page <= document.numPages) {
      starts.push({ title, page });
    }
  }
  starts.sort((left, right) => left.page - right.page);
  return starts.filter((entry, index) => index === 0 || entry.page !== starts[index - 1].page);
}

export async function analyzePdfDocument(
  document: PdfDocumentLike,
): Promise<ParsedBook> {
  const metadata = await document.getMetadata();
  const pageBlocks = new Map<number, ParsedBlock[]>();
  let readableCharacters = 0;
  let pagesWithText = 0;

  for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
    const page = await document.getPage(pageNumber);
    const viewport = page.getViewport({ scale: 1 });
    const content = await page.getTextContent();
    const blocks = groupPdfTextItems(content.items, viewport).map((block, index) =>
      parsedBlock(pageNumber, index, block),
    );
    pageBlocks.set(pageNumber, blocks);
    const characters = blocks
      .filter((block) => block.included)
      .reduce((total, block) => total + block.text.replace(/\s/g, "").length, 0);
    readableCharacters += characters;
    if (characters >= 8) pagesWithText += 1;
  }

  const requiresOcr =
    readableCharacters < Math.max(24, document.numPages * 12) ||
    pagesWithText < Math.ceil(document.numPages * 0.35);
  const outline = await outlineStarts(document);
  const starts = outline.length
    ? outline
    : [{ title: requiresOcr ? "待 OCR 正文" : "正文", page: 1 }];
  if (starts[0].page !== 1) starts.unshift({ title: "前言", page: 1 });

  const chapters: ParsedChapter[] = starts.map((start, index) => {
    const pageEnd = (starts[index + 1]?.page ?? document.numPages + 1) - 1;
    const blocks = requiresOcr
      ? []
      : Array.from(
          { length: pageEnd - start.page + 1 },
          (_, offset) => pageBlocks.get(start.page + offset) ?? [],
        ).flat();
    return {
      id: `chapter-${index + 1}`,
      title: start.title,
      sourceHref: `pages:${start.page}-${pageEnd}`,
      pageStart: start.page,
      pageEnd,
      blocks,
    };
  });

  return {
    format: "pdf",
    title: infoText(metadata.info, metadata.metadata, "Title") || "未命名 PDF",
    author: infoText(metadata.info, metadata.metadata, "Author") || "未知作者",
    pageCount: document.numPages,
    requiresOcr,
    chapters,
  };
}

export async function parsePdf(
  input: ArrayBuffer | Uint8Array,
  loader?: (bytes: Uint8Array) => Promise<PdfDocumentLike>,
): Promise<ParsedBook> {
  let document: PdfDocumentLike | undefined;
  try {
    if (loader) {
      document = await loader(input instanceof Uint8Array ? input : new Uint8Array(input));
    } else {
      const pdfjs = await import("pdfjs-dist");
      pdfjs.GlobalWorkerOptions.workerSrc = new URL(
        "pdfjs-dist/build/pdf.worker.min.mjs",
        import.meta.url,
      ).toString();
      document = (await pdfjs.getDocument({
        data: input instanceof Uint8Array ? input.slice() : input.slice(0),
        useWorkerFetch: true,
      }).promise) as unknown as PdfDocumentLike;
    }
    return await analyzePdfDocument(document);
  } catch (error) {
    if (error instanceof BookParserError) throw error;
    const name = error instanceof Error ? error.name : "";
    const message = error instanceof Error ? error.message : "";
    if (name === "PasswordException" || /password/i.test(message)) {
      throw new BookParserError(
        "drm_or_encrypted",
        "PDF 受密码或权限保护，听阅不会尝试绕过保护。",
      );
    }
    throw new BookParserError("invalid_pdf", "PDF 已损坏或无法解析。");
  } finally {
    await document?.destroy?.();
  }
}
