import { DOMParser } from "@xmldom/xmldom";
import { strFromU8, unzipSync } from "fflate";
import { classifyParagraph, normalizeReadableText } from "../../domain/text.ts";
import {
  BookParserError,
  type ParsedBlock,
  type ParsedBook,
} from "./contracts.ts";

function asBytes(input: ArrayBuffer | Uint8Array): Uint8Array {
  return input instanceof Uint8Array ? input : new Uint8Array(input);
}

function assertSafeArchivePath(path: string): void {
  const normalized = path.replace(/\\/g, "/");
  if (
    normalized.startsWith("/") ||
    /^[a-z]:/i.test(normalized) ||
    normalized.split("/").some((part) => part === "..")
  ) {
    throw new BookParserError(
      "unsafe_archive_path",
      "EPUB 包含不安全的文件路径。",
    );
  }
}

function directory(path: string): string {
  const index = path.lastIndexOf("/");
  return index < 0 ? "" : path.slice(0, index + 1);
}

function resolveArchivePath(baseFile: string, href: string): string {
  const withoutFragment = href.split("#", 1)[0].split("?", 1)[0];
  if (/^[a-z][a-z0-9+.-]*:/i.test(withoutFragment) || withoutFragment.startsWith("//")) {
    throw new BookParserError("invalid_epub", "EPUB 引用了外部正文资源。");
  }
  const parts = `${directory(baseFile)}${decodeURIComponent(withoutFragment)}`
    .replace(/\\/g, "/")
    .split("/");
  const resolved: string[] = [];
  for (const part of parts) {
    if (!part || part === ".") continue;
    if (part === "..") {
      if (!resolved.length) {
        throw new BookParserError("unsafe_archive_path", "EPUB 资源路径越界。");
      }
      resolved.pop();
    } else {
      resolved.push(part);
    }
  }
  return resolved.join("/");
}

function parseXml(xml: string, kind: "invalid_epub" = "invalid_epub") {
  const document = new DOMParser().parseFromString(xml, "application/xml");
  if (!document?.documentElement || document.getElementsByTagName("parsererror").length) {
    throw new BookParserError(kind, "EPUB 中的 XML 无法解析。");
  }
  return document;
}

function firstText(document: Document, localName: string): string {
  const nodes = document.getElementsByTagNameNS("*", localName);
  return normalizeReadableText(nodes.item(0)?.textContent ?? "");
}

function elements(document: Document, localName: string): Element[] {
  return Array.from(document.getElementsByTagNameNS("*", localName));
}

function elementAttribute(element: Element, name: string): string {
  return element.getAttribute(name)?.trim() ?? "";
}

function removeElementsByName(document: Document, names: readonly string[]): void {
  for (const name of names) {
    const nodes = Array.from(document.getElementsByTagNameNS("*", name));
    for (const node of nodes) node.parentNode?.removeChild(node);
  }
}

function collectBlocks(document: Document, chapterIndex: number): ParsedBlock[] {
  removeElementsByName(document, [
    "script",
    "style",
    "noscript",
    "iframe",
    "object",
    "embed",
    "svg",
    "audio",
    "video",
    "nav",
  ]);

  const candidates: Element[] = [];
  const blockNames = new Set([
    "h1",
    "h2",
    "h3",
    "h4",
    "h5",
    "h6",
    "p",
    "blockquote",
    "li",
    "table",
    "math",
  ]);
  const visit = (node: Node) => {
    if (node.nodeType === 1) {
      const element = node as Element;
      if (blockNames.has(element.localName.toLowerCase())) {
        candidates.push(element);
        return;
      }
    }
    const children = node.childNodes;
    if (!children) return;
    for (let index = 0; index < children.length; index += 1) {
      const child = children.item(index);
      if (child) visit(child);
    }
  };
  visit(document.documentElement);

  const blocks: ParsedBlock[] = [];
  for (const element of candidates) {
    const text = normalizeReadableText(element.textContent ?? "");
    if (!text) continue;
    const localName = element.localName.toLowerCase();
    const classified = classifyParagraph(text);
    const kind = localName.startsWith("h")
      ? "heading"
      : localName === "table" || classified === "table"
        ? "table"
        : localName === "math" || classified === "formula"
          ? "formula"
          : "prose";
    blocks.push({
      id: `epub-${chapterIndex}-${blocks.length}`,
      text,
      kind,
      included: kind === "prose" || kind === "heading",
      warning: kind === "table" || kind === "formula" ? kind : undefined,
    });
  }
  return blocks;
}

function tocTitles(document: Document, navPath: string): Map<string, string> {
  const titles = new Map<string, string>();
  for (const anchor of elements(document, "a")) {
    const href = elementAttribute(anchor, "href");
    const title = normalizeReadableText(anchor.textContent ?? "");
    if (!href || !title || /^[a-z][a-z0-9+.-]*:/i.test(href)) continue;
    try {
      titles.set(resolveArchivePath(navPath, href), title);
    } catch {
      // Ignore invalid navigation entries; the spine still controls readable content.
    }
  }
  return titles;
}

export async function parseEpub(
  input: ArrayBuffer | Uint8Array,
): Promise<ParsedBook> {
  let archive: Record<string, Uint8Array>;
  try {
    archive = unzipSync(asBytes(input));
  } catch {
    throw new BookParserError("invalid_epub", "EPUB 压缩包无法打开。");
  }
  for (const path of Object.keys(archive)) assertSafeArchivePath(path);

  if (strFromU8(archive.mimetype ?? new Uint8Array()).trim() !== "application/epub+zip") {
    throw new BookParserError("invalid_epub", "文件不是有效的 EPUB。");
  }
  if (archive["META-INF/encryption.xml"]) {
    const encryption = strFromU8(archive["META-INF/encryption.xml"]);
    const nonFontObfuscation = /EncryptedData|CipherReference/i.test(encryption);
    if (nonFontObfuscation) {
      throw new BookParserError(
        "drm_or_encrypted",
        "EPUB 含有加密资源，听阅不会尝试绕过保护。",
      );
    }
  }

  const containerBytes = archive["META-INF/container.xml"];
  if (!containerBytes) {
    throw new BookParserError("invalid_epub", "EPUB 缺少 container.xml。");
  }
  const container = parseXml(strFromU8(containerBytes));
  const rootfile = elements(container, "rootfile")[0];
  const packagePath = rootfile ? elementAttribute(rootfile, "full-path") : "";
  if (!packagePath || !archive[packagePath]) {
    throw new BookParserError("invalid_epub", "EPUB 缺少内容清单。");
  }

  const packageDocument = parseXml(strFromU8(archive[packagePath]));
  const manifest = new Map<string, { path: string; mediaType: string; properties: string }>();
  for (const item of elements(packageDocument, "item")) {
    const id = elementAttribute(item, "id");
    const href = elementAttribute(item, "href");
    if (!id || !href) continue;
    manifest.set(id, {
      path: resolveArchivePath(packagePath, href),
      mediaType: elementAttribute(item, "media-type"),
      properties: elementAttribute(item, "properties"),
    });
  }

  let titles = new Map<string, string>();
  const navEntry = [...manifest.values()].find((item) =>
    item.properties.split(/\s+/).includes("nav"),
  );
  if (navEntry && archive[navEntry.path]) {
    titles = tocTitles(parseXml(strFromU8(archive[navEntry.path])), navEntry.path);
  }

  const chapters = [];
  for (const itemref of elements(packageDocument, "itemref")) {
    const idref = elementAttribute(itemref, "idref");
    const item = manifest.get(idref);
    if (!item || !/xhtml|html/i.test(item.mediaType) || !archive[item.path]) continue;
    const chapterDocument = parseXml(strFromU8(archive[item.path]));
    const blocks = collectBlocks(chapterDocument, chapters.length);
    if (!blocks.length) continue;
    const heading = blocks.find((block) => block.kind === "heading")?.text;
    chapters.push({
      id: `chapter-${chapters.length + 1}`,
      title: titles.get(item.path) ?? heading ?? item.path.split("/").at(-1)?.replace(/\.[^.]+$/, "") ?? `第 ${chapters.length + 1} 章`,
      sourceHref: item.path,
      blocks,
    });
  }
  if (!chapters.length) {
    throw new BookParserError("no_readable_content", "EPUB 中没有可朗读的正文。");
  }

  return {
    format: "epub",
    title: firstText(packageDocument, "title") || "未命名书籍",
    author: firstText(packageDocument, "creator") || "未知作者",
    chapters,
  };
}
