import archiver from "archiver";
import axios from "axios";
import { renderFile } from "ejs";
import { encodeXML } from "entities";
import {
  createReadStream,
  createWriteStream,
  existsSync,
  mkdirSync,
  readFileSync,
  rmSync,
  unlinkSync,
} from "fs";
import {
  copyFile as copyFileAsync,
  readFile as readFileAsync,
  writeFile as writeFileAsync,
} from "fs/promises";
import { Element } from "hast";
import { Agent as HttpsAgent } from "https";
import { imageSize } from "image-size";
import mime from "mime";
import pLimit from "p-limit";
import { basename, dirname, resolve } from "path";
import rehypeParse from "rehype-parse";
import rehypeStringify from "rehype-stringify";
import slugify from "slugify";
import { pipeline } from "stream/promises";
import { Plugin, unified } from "unified";
import { visit } from "unist-util-visit";
import { fileURLToPath } from "url";
import { v4 as uuidv4 } from "uuid";

// Max length of the slugified chapter title in a generated filename
// (`${index}_${slug}.xhtml`). Filesystem/zip entry names cap at 255
// bytes; slugify(strict) emits ASCII (1 byte/char), so 120 leaves ample
// room for the index prefix and `.xhtml` suffix while staying readable.
const MAX_SLUG_LENGTH = 120;

// Tags that ship interpretable / scriptable content; stripped from
// every chapter regardless of EPUB version. The library used to allow
// these via `allowedXhtml11Tags` (epub2 path) and via no filter at all
// (epub3 path), so an inline `<script>`, `<iframe>`, `<object>`,
// `<embed>` or `<applet>` from user HTML would survive into the
// produced EPUB. Most modern readers sandbox JS, but the safer posture
// is to refuse to ship them in the first place.
const FORBIDDEN_TAGS = new Set([
  "script",
  "iframe",
  "object",
  "embed",
  "applet",
]);

// Allowed HTML attributes & tags. Looked up per-attribute on every
// element of every chapter; using a Set turns the O(N) .includes()
// hot-path scan into O(1).
const allowedAttributes: ReadonlyArray<string> = [
  "content",
  "alt",
  "id",
  "title",
  "src",
  "href",
  "about",
  "accesskey",
  "aria-activedescendant",
  "aria-atomic",
  "aria-autocomplete",
  "aria-busy",
  "aria-checked",
  "aria-controls",
  "aria-describedat",
  "aria-describedby",
  "aria-disabled",
  "aria-dropeffect",
  "aria-expanded",
  "aria-flowto",
  "aria-grabbed",
  "aria-haspopup",
  "aria-hidden",
  "aria-invalid",
  "aria-label",
  "aria-labelledby",
  "aria-level",
  "aria-live",
  "aria-multiline",
  "aria-multiselectable",
  "aria-orientation",
  "aria-owns",
  "aria-posinset",
  "aria-pressed",
  "aria-readonly",
  "aria-relevant",
  "aria-required",
  "aria-selected",
  "aria-setsize",
  "aria-sort",
  "aria-valuemax",
  "aria-valuemin",
  "aria-valuenow",
  "aria-valuetext",
  "className",
  "content",
  "contenteditable",
  "contextmenu",
  "datatype",
  "dir",
  "draggable",
  "dropzone",
  "hidden",
  "hreflang",
  "id",
  "inlist",
  "itemid",
  "itemref",
  "itemscope",
  "itemtype",
  "lang",
  "media",
  "ns1:type",
  "ns2:alphabet",
  "ns2:ph",
  "prefix",
  "property",
  "rel",
  "resource",
  "rev",
  "role",
  "spellcheck",
  "style",
  "tabindex",
  "target",
  "title",
  "type",
  "typeof",
  "vocab",
  "xml:base",
  "xml:lang",
  "xml:space",
  // rehype camelCases the `colspan`/`rowspan` HTML attributes into the hast
  // property keys `colSpan`/`rowSpan` — the sanitiser compares against those
  // keys, so the canonical entries are camelCased. The lowercase forms are
  // kept alongside as a defensive net: a future tree built by hand, a
  // different parser, or any caller that sets `node.properties.colspan`
  // directly will still survive the sanitiser. Either form serialises back
  // to the correct lowercase HTML attribute.
  "colSpan",
  "rowSpan",
  "colspan",
  "rowspan",
  "epub:type",
  "epub:prefix",
  "start",
  "checked",
  "readonly",
  // `data-empty` survives the sanitiser. rehype camelCases `data-*`
  // attributes, so the property key is `dataEmpty`. Editor-rendered
  // chapter HTML marks blank-line paragraphs with `data-empty`, and the
  // CSS book-indent rule (`p:not([data-empty]) + p`) depends on it being
  // present in the EPUB output the same way it is in the PDF output.
  "dataEmpty",
];
const allowedXhtml11Tags = [
  "div",
  "p",
  "h1",
  "h2",
  "h3",
  "h4",
  "h5",
  "h6",
  "ul",
  "ol",
  "li",
  "dl",
  "dt",
  "dd",
  "address",
  "hr",
  "pre",
  "blockquote",
  "center",
  "ins",
  "del",
  "a",
  "span",
  "bdo",
  "br",
  "em",
  "strong",
  "dfn",
  "code",
  "samp",
  "kbd",
  "bar",
  "cite",
  "abbr",
  "acronym",
  "q",
  "sub",
  "sup",
  "tt",
  "i",
  "b",
  "big",
  "small",
  "u",
  "s",
  "strike",
  "basefont",
  "font",
  // `object`, `embed`, `applet`, `iframe`, `script` deliberately
  // omitted — they're in FORBIDDEN_TAGS and replaced with span at
  // the top of validateElements, before this list is consulted.
  "param",
  "img",
  "table",
  "caption",
  "colgroup",
  "col",
  "thead",
  "tfoot",
  "tbody",
  "tr",
  "th",
  "td",
  "input",
  "map",
  "noscript",
  "ns:svg",
  "var",
];
const allowedAttributesSet = new Set<string>(allowedAttributes);

// Current directory
const __dirname = fileURLToPath(new URL(".", import.meta.url));

export interface EpubContentOptions {
  title: string;
  data: string;
  url?: string;
  author?: Array<string> | string;
  filename?: string;
  excludeFromToc?: boolean;
  beforeToc?: boolean;
  ornamentalBreakElement?: string;
}

export interface EpubOptions {
  title: string;
  description: string;
  cover?: string;
  publisher?: string;
  author?: Array<string> | string;
  tocTitle?: string;
  appendChapterTitles?: boolean;
  includeToc?: boolean;
  date?: string;
  lang?: string;
  css?: string;
  fonts?: string[];
  content: EpubContentOptions[];
  customOpfTemplatePath?: string;
  customNcxTocTemplatePath?: string;
  customHtmlTocTemplatePath?: string;
  customHtmlCoverTemplatePath?: string;
  customHtmlContentTemplatePath?: string;
  version?: number;
  userAgent?: string;
  verbose?: boolean;
  tempDir?: string;
  /**
   * gzip compression level for the final EPUB ZIP (0 = store-only,
   * 9 = max). The previous default was 9, which is ~10x slower than
   * 6 for ~2-5% smaller output; EPUB payloads are mostly fonts and
   * already-compressed images, so the marginal saving is tiny.
   * Default 6.
   */
  zipCompressionLevel?: number;
  /**
   * Max concurrent image downloads. Inline images used to be fetched
   * one-at-a-time in a serial loop. For a 30-image book over 100-200ms
   * RTT to S3/CDN this added 3-6s of pure waiting. Now bounded-parallel
   * via a true semaphore (p-limit) so a slot opens as soon as any one
   * download finishes, not when the whole batch finishes.
   * Default 8.
   */
  imageDownloadConcurrency?: number;
  /**
   * What to do when an inline image or cover image fails to fetch.
   *
   *   "warn"  (default): log if verbose, push a message to `warnings`,
   *           continue producing the EPUB. Backwards-compatible with
   *           pre-0.1.0 silent-skip behaviour.
   *   "throw": reject `render()` on the first failure. Use this when
   *           a missing image means the EPUB is unfit to ship (which
   *           is most production cases). The previous default silently
   *           shipped broken EPUBs when the network was unreachable.
   */
  assetFailureMode?: "warn" | "throw";
  /**
   * Whether to honour `file://` URLs in inline images and cover. The
   * library used to accept them unconditionally, which meant any
   * Plate-rendered `<img src="file:///etc/passwd">` would be embedded
   * verbatim in the generated EPUB. Default is now `false` (reject).
   * Set `true` only if you control the rendered HTML and need local
   * file embedding (e.g. tests or offline tooling).
   */
  allowFileUrls?: boolean;
}

interface EpubContent {
  id: string;
  href: string;
  title: string;
  data: string;
  url: string | null;
  author: Array<string>;
  filePath: string;
  templatePath: string;
  excludeFromToc: boolean;
  beforeToc: boolean;
  ornamentalBreakElement: string | null;
  // Set on the cover.xhtml entry only. The OPF manifest already
  // declares cover.xhtml via the conditional <item id="cover"/> block,
  // so the per-content forEach loop must skip this entry to avoid
  // OPF-074 ("declared in several manifest item"). Set by the library
  // when it pushes the cover into `this.content`; never set by callers.
  isCover?: boolean;
}

interface EpubImage {
  id: string;
  url: string;
  dir: string;
  mediaType: string;
  extension: string;
}
export class EPub {
  uuid: string;
  title: string;
  description: string;
  cover: string | null;
  coverMediaType: string | null;
  coverExtension: string | null;
  coverDimensions = {
    width: 0,
    height: 0,
  };
  publisher: string;
  author: Array<string>;
  tocTitle: string;
  appendChapterTitles: boolean;
  includeToc: boolean;
  date: string;
  lang: string;
  css: string | null;
  fonts: Array<string>;
  content: Array<EpubContent>;
  images: Array<EpubImage>;
  customOpfTemplatePath: string | null;
  customNcxTocTemplatePath: string | null;
  customHtmlCoverTemplatePath: string | null;
  customHtmlContentTemplatePath: string | null;
  customHtmlTocTemplatePath: string | null;
  version: number;
  userAgent: string;
  verbose: boolean;
  tempDir: string;
  tempEpubDir: string;
  output: string;
  zipCompressionLevel: number;
  imageDownloadConcurrency: number;
  assetFailureMode: "warn" | "throw";
  allowFileUrls: boolean;
  /**
   * Populated during `render()` with one entry per inline-image or
   * cover-image fetch that failed in `"warn"` mode. Empty in `"throw"`
   * mode (the first failure short-circuits the render). Returned on the
   * `render()` result so callers can decide whether to ship the EPUB.
   */
  warnings: string[] = [];
  // Lookup index for processImgTags: O(1) by url instead of O(n)
  // .find() in the constructor's per-element callback. The `images`
  // array stays the source of truth (order matters for manifest IDs).
  private imagesByUrl = new Map<string, EpubImage>();
  // Shared axios instance with TLS keep-alive so the N image fetches
  // and the cover fetch reuse sockets across the parallel batches
  // instead of paying a fresh DNS + TCP + TLS handshake per request.
  // Big win when fetching N images from the same CDN/S3 origin.
  //
  // `timeout` and `maxContentLength` are non-default safety caps:
  // without them a hanging or huge image server can hold the caller
  // (e.g. a Lambda) until its own function timeout, then waste the
  // entire ephemeral storage budget on a single bloated download.
  // 30s + 100 MB is the sweet spot for legitimate book illustrations
  // while bounding the worst case.
  private httpClient = axios.create({
    httpsAgent: new HttpsAgent({ keepAlive: true, maxSockets: 16 }),
    timeout: 30_000,
    maxContentLength: 100 * 1024 * 1024,
  });

  constructor(options: EpubOptions, output: string) {
    // File ID
    this.uuid = uuidv4();

    // Required options
    this.title = options.title;
    this.description = options.description;
    this.output = output;

    // Options with defaults
    this.cover = options.cover ?? null;
    this.publisher = options.publisher ?? "anonymous";
    this.author = options.author
      ? typeof options.author === "string"
        ? [options.author]
        : options.author
      : ["anonymous"];
    if (this.author.length === 0) {
      this.author = ["anonymous"];
    }
    this.tocTitle = options.tocTitle ?? "Table Of Contents";
    this.appendChapterTitles = options.appendChapterTitles ?? true;
    this.includeToc = options.includeToc ?? true;
    this.date = options.date ?? new Date().toISOString();
    this.lang = options.lang ?? "en";
    this.css = options.css ?? null;
    this.fonts = options.fonts ?? [];
    this.customOpfTemplatePath = options.customOpfTemplatePath ?? null;
    this.customNcxTocTemplatePath = options.customNcxTocTemplatePath ?? null;
    this.customHtmlTocTemplatePath = options.customHtmlTocTemplatePath ?? null;
    this.customHtmlCoverTemplatePath = options.customHtmlCoverTemplatePath ?? null;
    this.customHtmlContentTemplatePath = options.customHtmlContentTemplatePath ?? null;
    this.version = options.version ?? 3;
    this.userAgent =
      options.userAgent ??
      "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36";
    this.verbose = options.verbose ?? false;
    this.zipCompressionLevel = options.zipCompressionLevel ?? 6;
    this.imageDownloadConcurrency = options.imageDownloadConcurrency ?? 8;
    this.assetFailureMode = options.assetFailureMode ?? "warn";
    this.allowFileUrls = options.allowFileUrls ?? false;

    // Temporary folder for work
    this.tempDir = options.tempDir ?? resolve(__dirname, "../tempDir/");
    this.tempEpubDir = resolve(this.tempDir, this.uuid);

    // Check the cover image
    if (this.cover !== null) {
      this.coverMediaType = mime.getType(this.cover);
      if (this.coverMediaType === null) {
        throw new Error(`The cover image can't be processed : ${this.cover}`);
      }
      this.coverExtension = mime.getExtension(this.coverMediaType);
      if (this.coverExtension === null) {
        throw new Error(`The cover image can't be processed : ${this.cover}`);
      }
    } else {
      this.coverMediaType = null;
      this.coverExtension = null;
    }

    const loadHtml = (content: string, plugins: Plugin[]) => {
      const html = unified()
        .use(rehypeParse, { fragment: true })
        .use(plugins)
        // Voids: [] is required for epub generation, and causes little/no harm for non-epub usage
        .use(rehypeStringify, { allowDangerousHtml: true, voids: [] })
        .processSync(content)
        .toString();

      // EPUB content files are XHTML (XML). rehype-stringify uses hast-util-to-html which
      // unconditionally serializes known HTML boolean attributes (checked, readonly, etc.)
      // as bare attributes (e.g. `checked`), which is invalid XML. Post-process to
      // produce the XHTML-compliant form (`checked="checked"`) instead.
      return html.replace(
        / (checked|readonly|disabled|multiple|selected)(?=[ />])/g,
        ' $1="$1"'
      );
    };

    this.images = [];
    this.content = [];

    // Insert cover in content
    if (this.cover) {
      const templatePath = this.customHtmlCoverTemplatePath || resolve(__dirname, "../templates/cover.xhtml.ejs");
      if (!existsSync(templatePath)) {
        throw new Error("Could not resolve path to cover template HTML.");
      }

      this.content.push({
        id: `item_${this.content.length}`,
        href: "cover.xhtml",
        title: "cover",
        data: "",
        url: null,
        author: [],
        filePath: resolve(this.tempEpubDir, `./OEBPS/cover.xhtml`),
        templatePath,
        excludeFromToc: true,
        beforeToc: true,
        ornamentalBreakElement: null,
        isCover: true,
      });
    }

    // Parse contents & save images
    const contentTemplatePath = this.customHtmlContentTemplatePath || resolve(__dirname, "../templates/content.xhtml.ejs");
    const contentOffset = this.content.length;
    this.content.push(
      ...options.content.map<EpubContent>((content, i) => {
        const index = contentOffset + i;

        // Get the content URL & path
        let href, filePath;
        if (content.filename === undefined) {
          // slugify (with strict + lower) handles diacritics
          // transliteration internally, so the old uslug(diacritics(...))
          // pipeline collapses into one call. "Café Résumé" still
          // produces "cafe-resume" — that contract is locked down by
          // the chapter-file-naming characterization test.
          // Cap the slug so `${index}_${slug}.xhtml` stays under the
          // 255-byte filename limit (ext4/tmpfs/zip entry). A chapter
          // whose title is a whole paragraph would otherwise blow the
          // limit and crash rendering with ENAMETOOLONG. The `${index}_`
          // prefix keeps filenames unique regardless of truncation.
          const titleSlug = slugify(content.title || "no title", {
            lower: true,
            strict: true,
          }).slice(0, MAX_SLUG_LENGTH);
          href = `${index}_${titleSlug}.xhtml`;
          filePath = resolve(this.tempEpubDir, `./OEBPS/${index}_${titleSlug}.xhtml`);
        } else {
          href = content.filename.match(/\.xhtml$/) ? content.filename : `${content.filename}.xhtml`;
          if (content.filename.match(/\.xhtml$/)) {
            filePath = resolve(this.tempEpubDir, `./OEBPS/${content.filename}`);
          } else {
            filePath = resolve(this.tempEpubDir, `./OEBPS/${content.filename}.xhtml`);
          }
        }

        const dir = dirname(filePath);
        const processImgTags = (node: Element) => {
          if (!["img", "input"].includes(node.tagName)) {
            return;
          }
          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
          const url = node.properties!.src as string | null | undefined;
          if (url === undefined || url === null) {
            return;
          }

          let extension, id;
          const image = this.imagesByUrl.get(url);
          if (image) {
            id = image.id;
            extension = image.extension;
          } else {
            id = uuidv4();
            const mediaType = mime.getType(url.replace(/\?.*/, ""));
            if (mediaType === null) {
              if (this.verbose) {
                console.error("[Image Error]", `The image can't be processed : ${url}`);
              }
              return;
            }
            extension = mime.getExtension(mediaType);
            if (extension === null) {
              if (this.verbose) {
                console.error("[Image Error]", `The image can't be processed : ${url}`);
              }
              return;
            }
            const epubImage: EpubImage = { id, url, dir, mediaType, extension };
            this.imagesByUrl.set(url, epubImage);
            this.images.push(epubImage);
          }
          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
          node.properties!.src = `images/${id}.${extension}`;
        };
        const validateElements = (node: Element) => {
          // Strip forbidden tags FIRST, before attribute filtering, so
          // their children (e.g. inline JS bytes inside <script>) are
          // also dropped. Replacing with <span> + empty children leaves
          // the surrounding flow intact and is valid in both XHTML 1.1
          // (epub2) and epub3.
          if (FORBIDDEN_TAGS.has(node.tagName)) {
            node.tagName = "span";
            node.children = [];
            node.properties = {};
            return;
          }
          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
          const attrs = node.properties!;
          if (["img", "br", "hr"].includes(node.tagName)) {
            if (node.tagName === "img") {
              // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
              node.properties!.alt = node.properties?.alt || "image-placeholder";
            }
          }

          for (const k of Object.keys(attrs)) {
            if (allowedAttributesSet.has(k)) {
              if (k === "type" && node.tagName === "script") {
                // Strip type from <script> elements to defang inline JS.
                // Keep type on other elements (e.g. type="checkbox" on <input>).
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                delete node.properties![k];
              }
            } else {
              // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
              delete node.properties![k];
            }
          }

          if (this.version === 2) {
            if (!allowedXhtml11Tags.includes(node.tagName)) {
              if (this.verbose) {
                console.log(
                  "Warning (content[" + index + "]):",
                  node.tagName,
                  "tag isn't allowed on EPUB 2/XHTML 1.1 DTD."
                );
              }
              node.tagName = "div";
            }
          }
        };

        // Content ID & directory
        const id = `item_${index}`;

        // Parse the content
        const html = loadHtml(content.data, [
          () => (tree) => {
            visit(tree, "element", validateElements);
          },
          () => (tree) => {
            visit(tree, "element", processImgTags);
          },
        ]);


        // Return the EpubContent
        return {
          id,
          href,
          title: content.title,
          data: html,
          url: content.url ?? null,
          author: content.author ? (typeof content.author === "string" ? [content.author] : content.author) : [],
          filePath,
          templatePath: contentTemplatePath,
          excludeFromToc: content.excludeFromToc === true, // Default to false
          beforeToc: content.beforeToc === true, // Default to false
          ornamentalBreakElement: content.ornamentalBreakElement ?? null,
        };
      })
    );
  }

  async render(): Promise<{ result: string; warnings: string[] }> {
    // Reset per-call so a reused EPub instance doesn't accumulate
    // warnings across renders (defensive; the common Lambda case is
    // one EPub per invocation).
    this.warnings = [];

    // Create directories
    if (!existsSync(this.tempDir)) {
      mkdirSync(this.tempDir);
    }
    mkdirSync(this.tempEpubDir);
    mkdirSync(resolve(this.tempEpubDir, "./OEBPS"));

    // downloadAllImage and makeCover are independent network operations
    // against the same CDN. Run them in parallel so they share the
    // keep-alive socket pool instead of serialising.
    if (this.verbose) {
      console.log("Downloading Images and Cover...");
    }
    await Promise.all([this.downloadAllImage(this.images), this.makeCover()]);

    if (this.verbose) {
      console.log("Generating Template Files.....");
    }
    await this.generateTempFile(this.content);

    if (this.verbose) {
      console.log("Generating Epub Files...");
    }
    await this.generate();

    if (this.verbose) {
      if (this.warnings.length > 0) {
        console.log(`Done with ${this.warnings.length} warning(s).`);
      } else {
        console.log("Done.");
      }
    }
    return { result: "ok", warnings: this.warnings };
  }

  private async generateTempFile(contents: EpubContent[]) {
    // Create the document's Header
    const docHeader =
      this.version === 2
        ? `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="${this.lang}">
`
        : `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${this.lang}">
`;

    // Copy the CSS style
    if (!this.css) {
      this.css = readFileSync(resolve(__dirname, "../templates/template.css"), { encoding: "utf8" });
    }
    await writeFileAsync(resolve(this.tempEpubDir, "./OEBPS/style.css"), this.css);

    // Copy fonts in parallel. Each font is a self-contained TTF, no
    // ordering dependency. Mutating `this.fonts` in-place would race
    // under Promise.all, so build the new filename array atomically.
    if (this.fonts.length) {
      mkdirSync(resolve(this.tempEpubDir, "./OEBPS/fonts"));
      this.fonts = await Promise.all(
        this.fonts.map(async (font) => {
          if (!existsSync(font)) {
            throw new Error(`Custom font not found at ${font}.`);
          }
          const filename = basename(font);
          await copyFileAsync(font, resolve(this.tempEpubDir, `./OEBPS/fonts/${filename}`));
          return filename;
        })
      );
    }

    // Render + write chapter files in parallel. Each chapter is a
    // standalone XHTML file with no dependency on sibling renders. The
    // previous serial loop waited synchronously for the EJS renderFile
    // and the writeFileSync of each chapter; on a 30-chapter book at
    // ~30-50ms each, that was 1-1.5s of pure serial work.
    //
    // `chapterIndex` is the entry's position in `contents` (cover, if
    // any, is index 0). Surfaced to templates so an empty chapter
    // title can fall back to "Chapter N" without the template having
    // to walk the array. Same pattern the TOC templates already use.
    await Promise.all(
      contents.map(async (content, chapterIndex) => {
        const result = await renderFile(
          content.templatePath,
          {
            ...this,
            ...content,
            bookTitle: this.title,
            chapterIndex,
            encodeXML,
            docHeader,
          },
          {
            escape: (markup) => markup,
          }
        );
        await writeFileAsync(content.filePath, result);
      })
    );

    // META-INF + OPF + NCX (+ optional XHTML TOC) — independent files
    // with no ordering dependency. The previous serial sequence ran
    // four awaited renderFile() + writeFileSync calls back-to-back;
    // parallelising shaves ~50-150ms on slow filesystems.
    mkdirSync(this.tempEpubDir + "/META-INF");

    const opfPath =
      this.customOpfTemplatePath || resolve(__dirname, `../templates/epub${this.version}/content.opf.ejs`);
    if (!existsSync(opfPath)) {
      throw new Error("Custom file to OPF template not found.");
    }
    const ncxTocPath = this.customNcxTocTemplatePath || resolve(__dirname, "../templates/toc.ncx.ejs");
    if (!existsSync(ncxTocPath)) {
      throw new Error("Custom file the NCX toc template not found.");
    }
    const htmlTocPath = this.includeToc
      ? this.customHtmlTocTemplatePath ||
        resolve(__dirname, `../templates/epub${this.version}/toc.xhtml.ejs`)
      : null;
    if (htmlTocPath && !existsSync(htmlTocPath)) {
      throw new Error("Custom file to HTML toc template not found.");
    }

    const writeJobs: Promise<unknown>[] = [
      writeFileAsync(
        `${this.tempEpubDir}/META-INF/container.xml`,
        '<?xml version="1.0" encoding="UTF-8" ?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>',
      ),
      (async () =>
        writeFileAsync(
          resolve(this.tempEpubDir, "./OEBPS/content.opf"),
          await renderFile(opfPath, this),
        ))(),
      (async () =>
        writeFileAsync(
          resolve(this.tempEpubDir, "./OEBPS/toc.ncx"),
          await renderFile(ncxTocPath, this),
        ))(),
    ];

    if (this.version === 2) {
      // write meta-inf/com.apple.ibooks.display-options.xml [from pedrosanta:xhtml#6]
      writeJobs.push(
        writeFileAsync(
          `${this.tempEpubDir}/META-INF/com.apple.ibooks.display-options.xml`,
          `
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<display_options>
  <platform name="*">
    <option name="specified-fonts">true</option>
  </platform>
</display_options>
`,
        ),
      );
    }

    if (htmlTocPath) {
      writeJobs.push(
        (async () =>
          writeFileAsync(
            resolve(this.tempEpubDir, "./OEBPS/toc.xhtml"),
            await renderFile(htmlTocPath, this),
          ))(),
      );
    }

    await Promise.all(writeJobs);
  }

  private async makeCover(): Promise<void> {
    if (this.cover === null) {
      return;
    }

    const destPath = resolve(this.tempEpubDir, `./OEBPS/cover.${this.coverExtension}`);

    // pipeline() resolves only when the destination's `finish` event
    // fires (i.e. all bytes are flushed to disk), unlike the old
    // pipe()+readable-`end` approach which could resolve before the
    // write actually completed. This matters because imageSize() runs
    // immediately afterwards and reading a partially-written file
    // either fails or returns wrong dimensions.
    try {
      if (this.cover.slice(0, 7) === "file://") {
        // See downloadImage: file:// is rejected by default.
        if (!this.allowFileUrls) {
          throw new Error("file:// URLs are not permitted (set allowFileUrls: true to opt in)");
        }
        await pipeline(
          createReadStream(fileURLToPath(this.cover)),
          createWriteStream(destPath),
        );
      } else if (this.cover.slice(0, 4) === "http" || this.cover.slice(0, 2) === "//") {
        const httpRequest = await this.httpClient.get(this.cover, {
          responseType: "stream",
          headers: { "User-Agent": this.userAgent },
        });
        await pipeline(httpRequest.data, createWriteStream(destPath));
      } else {
        await pipeline(createReadStream(this.cover), createWriteStream(destPath));
      }
    } catch (err) {
      if (this.verbose) {
        console.error(`The cover image can't be processed : ${this.cover}, ${err}`);
      }
      // Make sure no partial file is left behind to confuse later
      // imageSize / cover-asset embedding.
      try {
        unlinkSync(destPath);
      } catch {
        /* file may not exist; ignore */
      }
      const msg = `cover fetch failed: ${this.cover}: ${(err as Error).message}`;
      if (this.assetFailureMode === "throw") {
        throw new Error(msg);
      }
      this.warnings.push(msg);
      // Fully unwire the cover so the produced EPUB doesn't ship a
      // dangling reference. With the URL fetched but no bytes on disk,
      // the OPF used to keep declaring `<item id="image_cover" href="cover.png"/>`
      // and the spine kept `<itemref idref="cover"/>`, plus the cover.xhtml
      // got rendered with `<image href="cover.png"/>` — all of which trip
      // RSC-001 in epubcheck ("file could not be found"). Nulling the
      // cover URL drops every conditional `locals.cover` block, and
      // filtering the cover entry out of `this.content` prevents
      // generateTempFile from writing cover.xhtml at all.
      this.cover = null;
      this.coverMediaType = null;
      this.coverExtension = null;
      this.content = this.content.filter((c) => !c.isCover);
      return;
    }

    if (this.verbose) {
      console.log("[Success] cover image downloaded successfully!");
    }

    // image-size v2 dropped the file-path + callback overloads and
    // now takes a Buffer/Uint8Array synchronously. Read the cover
    // we just wrote and pass the bytes in. The read also eliminates
    // the previous library-internal disk re-open, so the cover bytes
    // are touched exactly twice on the hot path (download + size).
    const coverBytes = await readFileAsync(destPath);
    const result = imageSize(coverBytes);
    if (!result || !result.width || !result.height) {
      throw new Error(`Failed to retrieve cover image dimensions for "${destPath}"`);
    }

    this.coverDimensions.width = result.width;
    this.coverDimensions.height = result.height;

    if (this.verbose) {
      console.log(`cover image dimensions: ${this.coverDimensions.width} x ${this.coverDimensions.height}`);
    }
  }

  private async downloadImage(image: EpubImage): Promise<void> {
    const filename = resolve(this.tempEpubDir, `./OEBPS/images/${image.id}.${image.extension}`);

    // Uniform try/catch for all three branches (file://, http(s)://,
    // local relative path). Previous code only caught HTTP errors and
    // silently `return`-ed on failure; sync file:// throws blew up the
    // whole render. assetFailureMode now decides the policy.
    try {
      if (image.url.indexOf("file://") === 0) {
        // file:// URLs in user-controlled HTML are an arbitrary-file-
        // read vector: a Plate `<img src="file:///etc/passwd">` would
        // happily get embedded in the produced EPUB. Reject by default;
        // opt back in via `allowFileUrls: true` for trusted callers
        // (tests, offline tooling).
        if (!this.allowFileUrls) {
          throw new Error("file:// URLs are not permitted (set allowFileUrls: true to opt in)");
        }
        // fileURLToPath handles authority, percent-encoding and Windows
        // paths correctly; the old `substr(7)` broke on file:///C:/...
        // and on the deprecated String.substr API itself.
        await copyFileAsync(fileURLToPath(image.url), filename);
        return;
      }

      if (image.url.indexOf("http") === 0 || image.url.indexOf("//") === 0) {
        const httpRequest = await this.httpClient.get(image.url, {
          responseType: "stream",
          headers: { "User-Agent": this.userAgent },
        });
        await pipeline(httpRequest.data, createWriteStream(filename));
        if (this.verbose) {
          console.log("[Download Success]", image.url);
        }
        return;
      }

      // Local relative path (image.dir + image.url)
      await pipeline(
        createReadStream(resolve(image.dir, image.url)),
        createWriteStream(filename),
      );
      if (this.verbose) {
        console.log("[Download Success]", image.url);
      }
    } catch (err) {
      // Best-effort cleanup of any partial write.
      try {
        unlinkSync(filename);
      } catch {
        /* file may not exist */
      }
      if (this.verbose) {
        console.error("[Download Error]", image.url, err);
      }
      const msg = `image fetch failed: ${image.url}: ${(err as Error).message}`;
      if (this.assetFailureMode === "throw") {
        throw new Error(msg);
      }
      this.warnings.push(msg);
    }
  }

  private async downloadAllImage(images: Array<EpubImage>): Promise<void> {
    if (images.length === 0) {
      return;
    }

    mkdirSync(resolve(this.tempEpubDir, "./OEBPS/images"));

    // True semaphore via p-limit: a slot opens as soon as ANY download
    // finishes, not when the whole batch finishes. The previous batch
    // loop (Promise.all over slices) had head-of-line blocking; one
    // slow image in a batch idled up to N-1 sockets until it returned.
    // For 30 images with one 3-second outlier that wasted ~3s per
    // chunk; the semaphore amortises that cost.
    const limit = pLimit(Math.max(1, this.imageDownloadConcurrency));
    await Promise.all(images.map((img) => limit(() => this.downloadImage(img))));
  }

  private generate(): Promise<void> {
    // Thanks to Paul Bradley
    // http://www.bradleymedia.org/gzip-markdown-epub/ (404 as of 28.07.2016)
    // Web Archive URL:
    // http://web.archive.org/web/20150521053611/http://www.bradleymedia.org/gzip-markdown-epub
    // or Gist:
    // https://gist.github.com/cyrilis/8d48eef37fbc108869ac32eb3ef97bca

    const cwd = this.tempEpubDir;

    return new Promise((resolve, reject) => {
      // Default level 6 (was 9). Level 9 is ~10x slower than 6 for only
      // ~2-5% smaller output on EPUB content (mostly fonts and JPEG/PNG
      // which are already compressed). Configurable via constructor for
      // callers that genuinely want max compression.
      const archive = archiver("zip", { zlib: { level: this.zipCompressionLevel } });
      const output = createWriteStream(this.output);
      if (this.verbose) {
        console.log("Zipping temp dir to", this.output);
      }
      archive.append("application/epub+zip", { store: true, name: "mimetype" });
      archive.directory(cwd + "/META-INF", "META-INF");
      archive.directory(cwd + "/OEBPS", "OEBPS");
      archive.pipe(output);

      // Resolve on the writable's 'close' event, not the archiver's
      // 'end'. archiver fires 'end' when its readable side has emitted
      // its last chunk; the destination WriteStream may still be
      // buffering bytes to disk. Resolving early lets the caller read
      // a partially-written .epub (AdmZip throws "No END header" on
      // such files). 'close' on the WriteStream is the canonical
      // "fully flushed and closed" signal.
      output.on("close", () => {
        if (this.verbose) {
          console.log("Done zipping, clearing temp dir...");
        }
        rmSync(cwd, { recursive: true, force: true });
        resolve();
      });
      output.on("error", (err: unknown) => reject(err as Error));
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      archive.on("error", (err: any) => reject(err));
      archive.finalize();
    });
  }
}
