// the link preview card every page gets (og:image): the window on its desk in the default
// palette's light half, with the page's icon, title and one line about it. drawn as svg here
// and turned into a png by resvg at build time, set in the site's own fonts.

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Resvg, type ResvgRenderOptions } from "@resvg/resvg-js";
import { decompress } from "wawoff2";
import { blurbs, firstParagraph } from "./blurbs";
import type { Doc } from "./docs";
import { standalone } from "./icons";
import { colours } from "./palettes";
import { DEFAULT_PALETTE } from "./themes";

export const OG_WIDTH = 1200;
export const OG_HEIGHT = 630;

export interface Card {
  icon: string;
  title: string;
  /** the section it's in, above the title */
  kicker?: string;
  /** plain text, wrapped to three lines at most */
  blurb?: string;
}

// resvg only reads ttf/otf, and fontsource ships woff2
let loaded: Promise<ResvgRenderOptions> | undefined;
const fonts = () => (loaded ??= loadFonts());

async function loadFonts(): Promise<ResvgRenderOptions> {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hydrant-og-"));
  const faces = [
    ["hanken-grotesk", "latin-400-normal"],
    ["hanken-grotesk", "latin-700-normal"],
    ["geist-mono", "latin-400-normal"],
  ];
  // one at a time: wawoff2 decodes in one shared wasm instance, and overlapping calls hand
  // each other's output back
  const fontFiles: string[] = [];
  for (const [pkg, face] of faces) {
    const woff2 = fs.readFileSync(path.join(process.cwd(), `node_modules/@fontsource/${pkg}/files/${pkg}-${face}.woff2`));
    const file = path.join(dir, `${pkg}-${face}.ttf`);
    fs.writeFileSync(file, await decompress(woff2));
    fontFiles.push(file);
  }
  return { font: { loadSystemFonts: false, fontFiles, defaultFontFamily: "Hanken Grotesk" } };
}

const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);

/** greedy word wrap, measuring each candidate line with resvg itself */
const measure = (s: string, size: number, opts: ResvgRenderOptions, weight = 400) =>
  new Resvg(
    `<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="200"><text x="0" y="100" font-size="${size}" font-weight="${weight}">${esc(s)}</text></svg>`,
    opts,
  ).getBBox()?.width ?? 0;

function wrap(text: string, size: number, width: number, maxLines: number, opts: ResvgRenderOptions): string[] {
  const fits = (s: string) => measure(s, size, opts) <= width;
  const lines: string[] = [];
  let line = "";
  for (const word of text.split(/\s+/)) {
    const next = line ? `${line} ${word}` : word;
    if (!line || fits(next)) line = next;
    else {
      lines.push(line);
      line = word;
    }
  }
  if (line) lines.push(line);
  if (lines.length <= maxLines) return lines;
  const cut = lines.slice(0, maxLines);
  while (!fits(`${cut[maxLines - 1]}…`)) cut[maxLines - 1] = cut[maxLines - 1].replace(/\s*\S+$/, "");
  cut[maxLines - 1] += "…";
  return cut;
}

export async function card({ icon, title, kicker, blurb }: Card): Promise<Buffer> {
  const opts = await fonts();
  const c = colours(DEFAULT_PALETTE, "light");
  const textX = 372, textW = 690;
  const blurbLines = blurb ? wrap(blurb, 28, textW, 3, opts) : [];
  // a long title shrinks to fit on one line rather than wrap
  const titleSize = Math.min(60, Math.floor((60 * textW) / measure(title, 60, opts, 700)));
  // the text block is centred on the paper, like the icon beside it
  const height = (kicker ? 40 : 0) + 64 + (blurbLines.length ? 18 + blurbLines.length * 40 : 0);
  let y = 352 - height / 2;
  const parts: string[] = [];
  if (kicker) {
    parts.push(`<text x="${textX}" y="${y + 26}" font-size="26" fill="${c["--dim"]}">${esc(kicker)}</text>`);
    y += 40;
  }
  parts.push(`<text x="${textX}" y="${y + 54}" font-size="${titleSize}" font-weight="700" fill="${c["--heading"]}">${esc(title)}</text>`);
  y += 64 + 18;
  for (const line of blurbLines) {
    parts.push(`<text x="${textX}" y="${y + 30}" font-size="28" fill="${c["--text-2"]}">${esc(line)}</text>`);
    y += 40;
  }
  const place = (spec: string, x: number, y: number, size: number) =>
    standalone(spec).replace("<svg ", `<svg x="${x}" y="${y}" width="${size}" height="${size}" `);

  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${OG_WIDTH}" height="${OG_HEIGHT}" viewBox="0 0 ${OG_WIDTH} ${OG_HEIGHT}">
  <defs>
    <radialGradient id="desk" cx="0.3" cy="0" r="1.1"><stop offset="0" stop-color="${c["--desk-glow"]}"/><stop offset="0.55" stop-color="${c["--desk"]}"/><stop offset="1" stop-color="${c["--desk-deep"]}"/></radialGradient>
    <linearGradient id="tab" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c["--tab-hi"]}"/><stop offset="0.45" stop-color="${c["--tab"]}"/><stop offset="1" stop-color="${c["--tab-mid"]}"/></linearGradient>
    <filter id="drop" x="-10%" y="-10%" width="130%" height="140%"><feDropShadow dx="8" dy="14" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/></filter>
    <filter id="lift" x="-10%" y="-10%" width="130%" height="130%"><feDropShadow dx="5" dy="5" stdDeviation="0" flood-color="#000" flood-opacity="0.18"/></filter>
  </defs>
  <rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#desk)"/>
  <g filter="url(#drop)">
    <path d="M70 100 V64 a4 4 0 0 1 4 -4 H330 a4 4 0 0 1 4 4 V100 Z" fill="url(#tab)" stroke="${c["--edge"]}" stroke-width="1.5"/>
    <rect x="70" y="99" width="1060" height="481" rx="3" fill="${c["--chrome"]}" stroke="${c["--edge"]}" stroke-width="1.5"/>
  </g>
  <path d="M72 98 V65 a3 3 0 0 1 3 -3 H329" fill="none" stroke="${c["--tab-hi"]}" stroke-width="1.5"/>
  ${place("hydrant", 84, 64, 32)}
  <text x="124" y="89" font-size="22" font-weight="700" fill="${c["--tab-text"]}">hydrant docs</text>
  <path d="M71.5 578 V100.5 H1128.5" fill="none" stroke="${c["--hi"]}" stroke-width="1.5"/>
  <path d="M1128.5 100.5 V578.5 H71.5" fill="none" stroke="${c["--lo"]}" stroke-width="1.5"/>
  <rect x="96" y="124" width="1008" height="432" rx="2" fill="${c["--paper"]}" stroke="${c["--edge"]}" stroke-width="1.5"/>
  <path d="M97.5 554 V125.5 H1102.5" fill="none" stroke="${c["--lo-deep"]}" stroke-width="1.5" opacity="0.6"/>
  <g filter="url(#lift)">${place(icon, 140, 260, 184)}</g>
  ${parts.join("\n  ")}
  <text x="1080" y="532" font-size="20" font-family="Geist Mono" text-anchor="end" fill="${c["--dim"]}">hydrant.klbr.net</text>
</svg>`;
  return new Resvg(svg, { ...opts, fitTo: { mode: "original" } }).render().asPng();
}

const plain = (html: string) =>
  html
    .replace(/<[^>]+>/g, "")
    .replace(/&(amp|lt|gt|quot|#39);/g, (_, e) => ({ amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" })[e as string]!)
    .replace(/\s+/g, " ")
    .trim();

/** a doc's card, and the plain line about it that is also its meta description */
export function cardFor(doc: Doc, docs: Map<string, Doc>): Card & { description?: string } {
  const html = blurbs(docs).get(doc.id) ?? firstParagraph(doc);
  const blurb = html && plain(html);
  const { section, label, icon, isIndex } = doc.place;
  return {
    icon,
    title: doc.id === "README" ? "hydrant" : isIndex ? `${section.title} overview` : label,
    kicker: doc.id === "README" || isIndex ? undefined : section.title,
    blurb,
    description: blurb,
  };
}

/** where a page's card is served, next to the page */
export const ogPath = (doc?: Doc) => (doc ? `/og${doc.url.slice(0, -1)}.png` : "/og.png");
