const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");

const sanitizeHtml = require("sanitize-html");
const MarkdownIt = require("markdown-it");
const puppeteer = require("puppeteer");

const { ERROR, asFailure } = require("./errors");

function safeThrow(errorDef, detailsCode) {
  const e = new Error(errorDef.error_id);
  e.safeError = asFailure(errorDef, detailsCode);
  throw e;
}

function sha256Hex(text) {
  return crypto.createHash("sha256").update(text).digest("hex");
}

function normalizeMarkdown(md) {
  return md.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}

function markdownToHTML(markdown) {
  const md = new MarkdownIt({
    html: false,
    linkify: true
  });

  const html = md.render(markdown);

  // Sanitize and remove images/external resources for v0.1.
  const cleaned = sanitizeHtml(html, {
    allowedTags: sanitizeHtml.defaults.allowedTags.filter((t) => t !== "img"),
    allowedAttributes: {
      a: ["href", "title"],
      code: ["class"],
      pre: ["class"],
      span: ["class"],
      p: ["class"],
      h1: ["id"],
      h2: ["id"],
      h3: ["id"],
      h4: ["id"],
      h5: ["id"],
      h6: ["id"]
    },
    allowVulnerableTags: false,
    transformTags: {
      a: (tagName, attribs) => {
        const href = attribs.href || "";
        const lower = href.trim().toLowerCase();
        const forbidden =
          lower.startsWith("http:") ||
          lower.startsWith("https:") ||
          lower.startsWith("ws:") ||
          lower.startsWith("wss:") ||
          lower.startsWith("mailto:") ||
          lower.startsWith("javascript:") ||
          lower.startsWith("data:") ||
          lower.startsWith("file:");
        if (forbidden) return { tagName: "a", attribs: { rel: "nofollow" } };
        return { tagName, attribs: { href, rel: "nofollow" } };
      }
    }
  });

  return cleaned;
}

function htmlTemplate({ bodyHTML, assetsDir, cssName, footerCSS }) {
  const cssPath = `file://${path.join(assetsDir, "css", cssName)}`;
  const runtimePath = `file://${path.join(assetsDir, "js", "runtime.js")}`;
  const mermaidPath = `file://${path.join(assetsDir, "js", "mermaid.min.js")}`;
  const pagedPath = `file://${path.join(assetsDir, "js", "paged.polyfill.js")}`;

  return `<!doctype html>
<html>
  <head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <link rel="stylesheet" href="${cssPath}"/>
    <style>${footerCSS}</style>
  </head>
  <body>
    <main id="content">${bodyHTML}</main>
    <script src="${mermaidPath}"></script>
    <script>window.PagedConfig = { auto: false };</script>
    <script src="${pagedPath}"></script>
    <script src="${runtimePath}"></script>
  </body>
</html>`;
}

function footerCSSFor(input) {
  const pdf = input.config.pdf;
  if (!pdf.footer.enabled) return "";

  const repo = `${input.repoMeta.owner}/${input.repoMeta.repo}`;
  const sha = input.repoMeta.commitSHA.slice(0, 12);

  let date = "";
  if (pdf.timestamp === "commit_time") {
    date = input.repoMeta.commitTimeRFC3339;
  } else {
    date = new Date().toISOString();
  }

  const left = cssString(repo);
  const center = cssString(`${sha} • ${date}`);

  return `
@page {
  @bottom-left { content: "${left}"; }
  @bottom-center { content: "${center}"; }
  @bottom-right { content: counter(page); }
}`;
}

function cssString(s) {
  return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}

async function renderToPDF(input) {
  const assetsDir = "/opt/forgejo-pdf/assets";
  const pdf = input.config.pdf;
  const cssName = pdf.typography === "basic" ? "basic.css" : "professional.css";

  const bodyHTML = markdownToHTML(normalizeMarkdown(input.markdown));
  const html = htmlTemplate({ bodyHTML, assetsDir, cssName, footerCSS: footerCSSFor(input) });

  const userDataDir = fs.mkdtempSync("/tmp/chrome-profile-");
  const pageDir = fs.mkdtempSync("/tmp/pdf-page-");
  const htmlPath = path.join(pageDir, "index.html");
  fs.writeFileSync(htmlPath, html, "utf8");

  const browser = await puppeteer.launch({
    headless: "new",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--allow-file-access-from-files",
      `--user-data-dir=${userDataDir}`
    ]
  });

  try {
    const page = await browser.newPage();
    let lastPageError = null;
    page.on("pageerror", (err) => {
      lastPageError = err && typeof err.message === "string" ? err.message : String(err);
    });
    page.on("console", (msg) => {
      if (msg.type && msg.type() === "error") {
        lastPageError = msg.text ? msg.text() : String(msg);
      }
    });
    await page.setRequestInterception(true);
    let blockedRequests = 0;
    let blockedURL = null;
    page.on("request", (req) => {
      const u = req.url();
      if (u.startsWith("file:")) return req.continue();
      blockedRequests++;
      if (!blockedURL) blockedURL = u;
      return req.abort();
    });

    await page.goto(`file://${htmlPath}`, { waitUntil: "load" });
    if (blockedRequests > 0) safeThrow(ERROR.NETWORK_ATTEMPT, `blocked:${blockedURL || "non_file_request"}`);

    // Mermaid + Paged.js rendering happens in-page.
    const renderResult = await page.evaluate(async (payload) => {
      try {
      // Minimal SHA-256 (hex) implementation for deterministic IDs.
      // Avoids relying on SubtleCrypto availability for file:// contexts.
      function sha256HexLocal(ascii) {
        function rightRotate(value, amount) {
          return (value >>> amount) | (value << (32 - amount));
        }
        // Normalize to a byte-string (UTF-8) for consistent hashing across unicode input.
        ascii = unescape(encodeURIComponent(ascii));
        const maxWord = Math.pow(2, 32);
        let result = "";

        const words = [];
        const asciiBitLength = ascii.length * 8;

        let hash = sha256HexLocal.h || [];
        let k = sha256HexLocal.k || [];

        let primeCounter = k.length;
        const isComposite = {};
        for (let candidate = 2; primeCounter < 64; candidate++) {
          if (!isComposite[candidate]) {
            for (let i = 0; i < 313; i += candidate) isComposite[i] = candidate;
            hash[primeCounter] = (Math.pow(candidate, 0.5) * maxWord) | 0;
            k[primeCounter++] = (Math.pow(candidate, 1 / 3) * maxWord) | 0;
          }
        }
        sha256HexLocal.h = hash;
        sha256HexLocal.k = k;

        ascii += "\x80";
        while ((ascii.length % 64) - 56) ascii += "\x00";
        for (let i = 0; i < ascii.length; i++) {
          const j = ascii.charCodeAt(i);
          words[i >> 2] |= j << ((3 - i) % 4) * 8;
        }
        words[words.length] = (asciiBitLength / maxWord) | 0;
        words[words.length] = asciiBitLength;

        for (let j = 0; j < words.length; ) {
          const w = words.slice(j, (j += 16));
          const oldHash = hash.slice(0);

          for (let i = 0; i < 64; i++) {
            const w15 = w[i - 15];
            const w2 = w[i - 2];

            const a = hash[0];
            const e = hash[4];
            const temp1 =
              hash[7] +
              (rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25)) +
              ((e & hash[5]) ^ (~e & hash[6])) +
              k[i] +
              (w[i] =
                i < 16
                  ? w[i]
                  : (w[i - 16] +
                      (rightRotate(w15, 7) ^ rightRotate(w15, 18) ^ (w15 >>> 3)) +
                      w[i - 7] +
                      (rightRotate(w2, 17) ^ rightRotate(w2, 19) ^ (w2 >>> 10))) |
                    0);
            const temp2 =
              (rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22)) +
              ((a & hash[1]) ^ (a & hash[2]) ^ (hash[1] & hash[2]));

            hash = [(temp1 + temp2) | 0].concat(hash);
            hash[4] = (hash[4] + temp1) | 0;
            hash.pop();
          }

          for (let i = 0; i < 8; i++) {
            hash[i] = (hash[i] + oldHash[i]) | 0;
          }
        }

        for (let i = 0; i < 8; i++) {
          for (let j = 3; j + 1; j--) {
            const b = (hash[i] >> (j * 8)) & 255;
            result += (b < 16 ? "0" : "") + b.toString(16);
          }
        }
        return result;
      }

      function sanitizeSVG(svgText) {
        const parser = new DOMParser();
        const doc = parser.parseFromString(svgText, "image/svg+xml");
        const svg = doc.documentElement;

        const forbidden = svg.querySelector("foreignObject");
        if (forbidden) throw { error_id: "ERR_MERMAID_FOREIGN_OBJECT", message: "Mermaid output contains forbidden elements." };

        const scripts = svg.querySelectorAll("script");
        for (const s of scripts) s.remove();

        for (const el of svg.querySelectorAll("*")) {
          for (const attr of Array.from(el.attributes)) {
            const name = attr.name;
            const value = attr.value || "";
            if (name.startsWith("on")) el.removeAttribute(name);
            if ((name === "href" || name === "xlink:href") && !value.startsWith("#")) el.removeAttribute(name);
          }
        }

        const hasText = svg.querySelector("text, tspan");
        if (!hasText) throw { error_id: "ERR_MERMAID_NO_TEXT", message: "Mermaid output must contain selectable text." };

        return svg.outerHTML;
      }

      function shouldLandscape(svgEl) {
        const vb = svgEl.getAttribute("viewBox");
        if (!vb) return false;
        const parts = vb.trim().split(/\s+/).map(Number);
        if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return false;
        const w = parts[2];
        const h = parts[3];
        if (h <= 0) return false;
        const aspect = w / h;
        return aspect > 1.15;
      }

      // Determinism guards.
      Math.random = () => 0.5;
      if (payload.pdf.determinism === "strict") {
        const fixed = Date.parse(payload.repoMeta.commitTimeRFC3339);
        if (Number.isFinite(fixed)) Date.now = () => fixed;
      }

      // Render Mermaid fences found in code blocks.
      const blocks = Array.from(document.querySelectorAll("pre > code"));
      const mermaidBlocks = blocks.filter((c) => (c.className || "").includes("language-mermaid"));

      if (typeof mermaid === "undefined" || !mermaid) throw { error_id: "ERR_INTERNAL", message: "Render failed." };
      mermaid.initialize({
        startOnLoad: false,
        securityLevel: "strict",
        htmlLabels: false,
        flowchart: { htmlLabels: false },
        sequence: { htmlLabels: false },
        state: { htmlLabels: false },
        class: { htmlLabels: false },
        fontFamily: "IBM Plex Sans",
        theme: "base",
        themeVariables: {
          fontFamily: "IBM Plex Sans",
          primaryColor: "#ffffff",
          primaryTextColor: "#111827",
          lineColor: "#6b7280",
          secondaryColor: "#f3f4f6",
          tertiaryColor: "#ffffff"
        }
      });

      for (let i = 0; i < mermaidBlocks.length; i++) {
        const codeEl = mermaidBlocks[i];
        const diagramText = codeEl.textContent || "";
        const id = `m-${sha256HexLocal(payload.repoMeta.commitSHA + "|" + payload.repoMeta.path + "|" + i + "|" + diagramText)}`;

        const { svg } = await mermaid.render(id, diagramText);
        const clean = sanitizeSVG(svg);

        const container = document.createElement("figure");
        container.className = "mermaid-figure";
        container.innerHTML = clean;

        const svgEl = container.querySelector("svg");
        if (svgEl && shouldLandscape(svgEl)) {
          container.classList.add("mermaid-landscape");
        }

        const pre = codeEl.parentElement;
        pre.replaceWith(container);
      }

      if (window.PagedPolyfill && window.PagedPolyfill.preview) {
        await window.PagedPolyfill.preview();
      }

      return { ok: true, mermaid_count: mermaidBlocks.length };
      } catch (err) {
        if (err && typeof err === "object" && err.error_id && err.message) {
          return { ok: false, error_id: err.error_id, message: err.message };
        }
        return { ok: false, error_id: "ERR_INTERNAL", message: "Render failed." };
      }
    }, { pdf: input.config.pdf, repoMeta: input.repoMeta });

    if (!renderResult || renderResult.ok !== true || typeof renderResult.mermaid_count !== "number") {
      if (renderResult && renderResult.ok === false && renderResult.error_id && renderResult.message) {
        const err = new Error(renderResult.error_id);
        err.safeError = { error_id: renderResult.error_id, message: renderResult.message };
        throw err;
      }
      safeThrow(ERROR.INTERNAL, "render_result_invalid");
    }
    const mermaidCount = renderResult.mermaid_count;

    const pdfBuf = await page.pdf({
      format: "A4",
      printBackground: true,
      preferCSSPageSize: true
    });

    return { pdf: Buffer.from(pdfBuf), blockedRequests, mermaidCount };
  } catch (e) {
    if (e && typeof e === "object" && e.error_id && e.message) {
      const err = new Error(e.error_id);
      err.safeError = { error_id: e.error_id, message: e.message };
      throw err;
    }
    if (e && typeof e === "object" && e.safeError) throw e;
    const msg = e && typeof e === "object" && typeof e.message === "string" ? e.message : "";
    const hint = msg || (typeof lastPageError === "string" ? lastPageError : "");
    const details = hint
      ? `render_failed:${hint.slice(0, 160).replace(/[^A-Za-z0-9_.:/\\-]+/g, "_")}`
      : "render_failed";
    safeThrow(ERROR.INTERNAL, details);
  } finally {
    try {
      await browser.close();
    } catch {}
    try {
      fs.rmSync(userDataDir, { recursive: true, force: true });
      fs.rmSync(pageDir, { recursive: true, force: true });
    } catch {}
  }
}

module.exports = { renderToPDF };
