import { fileURLToPath } from "node:url";
import path from "node:path";
import { readFileSync, existsSync } from "node:fs";
import bidiFactory from "bidi-js";
import PDFDocument from "pdfkit";
// CommonJS module — pulled in via createRequire so the TS->ESM build
// doesn't try to statically resolve a default export that doesn't exist.
import { createRequire } from "node:module";

import sanitizeHtml from "sanitize-html";

const bidi = bidiFactory();

const reshaperRequire = createRequire(import.meta.url);
const { ArabicShaper } = reshaperRequire("arabic-persian-reshaper") as {
  ArabicShaper: { convertArabic: (input: string) => string };
};

// Convert base Arabic letters (U+0600..U+06FF) to their contextual
// presentation forms (Arabic Presentation Forms-A/B).
//
// HISTORICAL NOTE: this used to run unconditionally before drawing,
// because the original PDFKit (without fontkit GSUB features) had no
// shaper. The current renderer hands raw Unicode + ARABIC_FEATURES to
// pdfkit so fontkit performs shaping AND visual reorder itself; doing
// both this pre-shape AND the in-pdfkit shaping double-processes the
// string and renders it visually mirrored. The function is retained
// only as a public export for downstream callers (tests, ad-hoc
// scripts) that explicitly want pre-shaped output. The renderer no
// longer calls it.
export function shapeArabic(text: string): string {
  if (!text) return text;
  // Fast skip: nothing to shape if there are no base-form Arabic chars.
  // Covers Arabic (0600-06FF), Arabic Supplement (0750-077F), and
  // Arabic Extended-A (08A0-08FF) so non-standard Arabic-script letters
  // also trigger the shaping pass.
  let hasBaseArabic = false;
  for (const ch of text) {
    const c = ch.codePointAt(0) ?? 0;
    if (
      (c >= 0x0600 && c <= 0x06ff) ||
      (c >= 0x0750 && c <= 0x077f) ||
      (c >= 0x08a0 && c <= 0x08ff)
    ) {
      hasBaseArabic = true;
      break;
    }
  }
  if (!hasBaseArabic) return text;
  return ArabicShaper.convertArabic(text);
}

export type PdfFontPrefs = {
  fontFamily: string;
  fontSize: number;
  fontWeight: "regular" | "bold";
  alignment: "start" | "center";
  // Body-text fill; header chrome ignores this.
  fontColor: string;
};

// Keep in sync with ROW_COLOR_OPTIONS in tx-os executive-meetings.tsx.
const ROW_COLOR_FILL: Record<string, string> = {
  red: "#fee2e2",
  amber: "#fef3c7",
  green: "#dcfce7",
  blue: "#dbeafe",
  violet: "#ede9fe",
  gray: "#f3f4f6",
};

export type PdfMeetingAttendee = {
  name: string;
  title: string | null;
  attendanceType?: string | null;
  // "person" (default) or "subheading". The renderer skips subheadings
  // for the running attendee number and prints them as "— label —".
  // Typed as a plain string (rather than the literal union) because the
  // DB column is just a varchar; the renderer normalises unknown values
  // back to "person" at the comparison site.
  kind?: string | null;
};

export type PdfMeeting = {
  dailyNumber: number;
  titleAr: string;
  titleEn: string | null;
  startTime: string | null;
  endTime: string | null;
  location: string | null;
  isHighlighted?: number;
  rowColor?: string | null;
  attendees: PdfMeetingAttendee[];
  mergeStartColumn?: string | null;
  mergeEndColumn?: string | null;
  mergeText?: string | null;
};

export type RenderPdfInput = {
  date: string;
  lang: "ar" | "en";
  font: PdfFontPrefs;
  meetings: PdfMeeting[];
  labels: {
    title: string;
    no: string;
    meeting: string;
    attendees: string;
    time: string;
    none: string;
    recordedBy: string;
  };
  // PNG/JPEG bytes for the top-left header logo; undefined = no logo.
  logo?: Buffer | null;
};

// Resolve fonts relative to the bundled dist file (esbuild rewrites
// __dirname via the build banner so this works in both dev and prod).
function fontsDir(): string {
  const here =
    typeof __dirname === "string"
      ? __dirname
      : path.dirname(fileURLToPath(import.meta.url));
  // The fonts are shipped as raw assets next to dist/ — see
  // artifacts/api-server/assets/fonts. From dist/ we go up one level.
  const candidates = [
    path.resolve(here, "..", "assets", "fonts"),
    path.resolve(here, "assets", "fonts"),
    path.resolve(here, "..", "..", "assets", "fonts"),
  ];
  for (const c of candidates) {
    if (existsSync(path.join(c, "NotoNaskhArabic-Regular.ttf"))) return c;
  }
  throw new Error(
    `Could not locate bundled PDF fonts. Tried: ${candidates.join(", ")}`,
  );
}

// All font files bundled in artifacts/api-server/assets/fonts. We only
// load the ones each render needs, but list every file here so the
// build step (and font-family mapping below) stays declarative.
const FONT_FILES = {
  naskhRegular: "NotoNaskhArabic-Regular.ttf",
  naskhBold: "NotoNaskhArabic-Bold.ttf",
  arabicSansRegular: "NotoSansArabic-Regular.ttf",
  arabicSansBold: "NotoSansArabic-Bold.ttf",
  latinRegular: "DejaVuSans.ttf",
  latinBold: "DejaVuSans-Bold.ttf",
  dinNextRegular: "DINNextLTArabic-Regular.ttf",
  dinNextBold: "DINNextLTArabic-Bold.ttf",
  tajawalRegular: "Tajawal-Regular.ttf",
  tajawalBold: "Tajawal-Bold.ttf",
  helvNeueLTArRegular: "HelveticaNeueLTArabic-Regular.ttf",
  helvNeueLTArBold: "HelveticaNeueLTArabic-Bold.ttf",
  helvNeueRegular: "HelveticaNeue-Regular.ttf",
  helvNeueBold: "HelveticaNeue-Bold.ttf",
  majallaRegular: "Majalla-Regular.ttf",
  majallaBold: "Majalla-Bold.ttf",
} as const;

type FontFileKey = keyof typeof FONT_FILES;

// Map every supported user-facing font family to a concrete pair of
// bundled fonts (one for Arabic glyphs, one for Latin glyphs). Naskh
// families (Noto Naskh Arabic, Amiri, system default) use the Naskh
// face; sans families (Cairo, Tajawal) use Noto Sans Arabic — visually
// the closest sans-serif Arabic typeface we can ship without bundling
// every requested vendor font. Latin glyphs always render in DejaVu
// Sans so that mixed-script cells share consistent metrics.
type FamilyMapping = {
  arabicRegular: FontFileKey;
  arabicBold: FontFileKey;
  latinRegular: FontFileKey;
  latinBold: FontFileKey;
};

// Every user-facing font family now maps to its actual bundled font
// files. The user uploaded all font assets so the PDF renders with the
// exact typeface chosen in settings — no more Noto stand-ins.
const FAMILY_MAP: Record<string, FamilyMapping> = {
  system: {
    arabicRegular: "naskhRegular",
    arabicBold: "naskhBold",
    latinRegular: "latinRegular",
    latinBold: "latinBold",
  },
  "DIN Next LT Arabic": {
    arabicRegular: "dinNextRegular",
    arabicBold: "dinNextBold",
    latinRegular: "dinNextRegular",
    latinBold: "dinNextBold",
  },
  Tajawal: {
    arabicRegular: "tajawalRegular",
    arabicBold: "tajawalBold",
    latinRegular: "tajawalRegular",
    latinBold: "tajawalBold",
  },
  "Helvetica Neue LT Arabic": {
    arabicRegular: "helvNeueLTArRegular",
    arabicBold: "helvNeueLTArBold",
    latinRegular: "helvNeueLTArRegular",
    latinBold: "helvNeueLTArBold",
  },
  "Helvetica Neue": {
    arabicRegular: "naskhRegular",
    arabicBold: "naskhBold",
    latinRegular: "helvNeueRegular",
    latinBold: "helvNeueBold",
  },
  Majalla: {
    arabicRegular: "majallaRegular",
    arabicBold: "majallaBold",
    latinRegular: "majallaRegular",
    latinBold: "majallaBold",
  },
};

function familyMappingFor(family: string): FamilyMapping {
  return FAMILY_MAP[family] ?? FAMILY_MAP.system;
}

// Family names that primarily target Arabic script. Used by
// `fontFamilyIsArabic` for downstream callers that branch on
// script — kept aligned with FAMILY_MAP. "Helvetica Neue" (the Latin
// face) is intentionally excluded; the others all ship Arabic glyphs.
const ARABIC_FONT_NAMES: ReadonlyArray<string> = [
  "DIN Next LT Arabic",
  "Tajawal",
  "Helvetica Neue LT Arabic",
  "Majalla",
];

// Bytes are loaded lazily on first render and reused.
let cachedFontBytes: Partial<Record<FontFileKey, Buffer>> = {};
function loadFontBytes(keys: ReadonlyArray<FontFileKey>): Record<FontFileKey, Buffer> {
  const dir = fontsDir();
  const out = {} as Record<FontFileKey, Buffer>;
  for (const k of keys) {
    if (!cachedFontBytes[k]) {
      cachedFontBytes[k] = readFileSync(path.join(dir, FONT_FILES[k]));
    }
    out[k] = cachedFontBytes[k] as Buffer;
  }
  return out;
}

// Fast Arabic-script test (Arabic, Arabic Supplement, Arabic Extended-A,
// Arabic Presentation Forms-A/B). We use it to decide which bundled font
// covers a given character so mixed Arabic/Latin cells can be rendered
// without missing-glyph boxes.
function isArabicChar(ch: string): boolean {
  const c = ch.codePointAt(0) ?? 0;
  return (
    (c >= 0x0600 && c <= 0x06ff) ||
    (c >= 0x0750 && c <= 0x077f) ||
    (c >= 0x08a0 && c <= 0x08ff) ||
    (c >= 0xfb50 && c <= 0xfdff) ||
    (c >= 0xfe70 && c <= 0xfeff)
  );
}

function isLatinChar(ch: string): boolean {
  const c = ch.codePointAt(0) ?? 0;
  // Latin, digits, common punctuation, Latin-1 supplement.
  return (
    (c >= 0x0020 && c <= 0x024f) ||
    (c >= 0x2000 && c <= 0x206f) ||
    (c >= 0x2070 && c <= 0x209f)
  );
}

type Run = { text: string; isArabic: boolean };

// OpenType feature tags fontkit needs to (a) shape Arabic into contextual
// joining forms and (b) reorder Arabic glyphs visually right-to-left
// inside a single LTR draw call. We pass these via PDFKit's `features`
// option whenever we draw a run that contains Arabic — pdfkit forwards
// them straight to fontkit's GSUB layout. Crucially, this makes pdfkit
// the SOLE owner of shaping + RTL reorder; if we also pre-shape or
// pre-reorder the string ourselves, fontkit applies a second pass and
// the result is visually mirrored (the bug this module used to ship).
const ARABIC_FEATURES: PDFKit.Mixins.OpenTypeFeatures[] = [
  "rtla", // right-to-left alternates: flips digits and mirrored glyphs
  "rclt", // required contextual alternates (Arabic joining)
  "calt", // contextual alternates
  "liga", // standard ligatures (lam-alef, etc.)
  "init",
  "medi",
  "fina",
  "isol",
];

// Split a string into runs of consecutive same-script characters so we
// can pick the Arabic font for Arabic glyphs and the Latin font for the
// rest. Whitespace sticks to the surrounding run to avoid a stream of
// 1-char runs.
function splitRunsByScript(text: string): Run[] {
  const runs: Run[] = [];
  let current: Run | null = null;
  for (const ch of text) {
    const arabic = isArabicChar(ch);
    const latin = isLatinChar(ch);
    if (!arabic && !latin) {
      // Glyph that neither font may cover — bias toward whatever the
      // current run is so we don't fragment unnecessarily.
      if (current) {
        current.text += ch;
        continue;
      }
    }
    const want = arabic;
    if (!current || current.isArabic !== want) {
      current = { text: ch, isArabic: want };
      runs.push(current);
    } else {
      current.text += ch;
    }
  }
  return runs;
}

// Build draw-order runs for a single line of mixed Arabic/Latin text.
//
// Returns an array of runs whose ORDER matches the visual left-to-right
// painting order. For each run, `text` is the *raw, logical-order*
// substring (Arabic in U+06xx code points, NOT pre-shaped presentation
// forms) so that pdfkit/fontkit can run shaping + intra-run RTL reorder
// itself via the `features` option when we draw.
//
// Algorithm:
//   1. Compute Unicode Bidi embedding levels on the raw text.
//   2. Walk the text in LOGICAL order, slicing at every level boundary.
//      Each slice is internally consistent direction-wise, so fontkit can
//      reorder it correctly with `rtla` features.
//   3. If the paragraph base direction is RTL, REVERSE the run array.
//      For RTL bases the visual L-to-R sequence is the logical sequence
//      read backwards (with each LTR-level slice keeping its own LTR
//      internal order — which is exactly what we want).
function visualOrderRuns(text: string, baseDirection: "ltr" | "rtl"): Run[] {
  if (!text) return [];
  const levels = bidi.getEmbeddingLevels(text, baseDirection);
  const lvlArr = levels.levels as ArrayLike<number>;
  // bidi-js returns one level per code unit — text and lvlArr share length.
  const out: Run[] = [];
  let i = 0;
  const n = text.length;
  while (i < n) {
    const startLevel = lvlArr[i];
    let j = i + 1;
    while (j < n && lvlArr[j] === startLevel) j++;
    const slice = text.slice(i, j);
    // Detect Arabic content for font + features selection. A level-1+
    // odd run is RTL but might still be punctuation/digits; only true
    // Arabic chars need the Arabic font and shaping features.
    const hasArabic = [...slice].some(isArabicChar);
    out.push({ text: slice, isArabic: hasArabic });
    i = j;
  }
  if (baseDirection === "rtl") out.reverse();
  return out;
}

// Returns the pdfkit `features` option appropriate for a run. We want
// shaping ON for any run containing Arabic glyphs and OFF otherwise so
// Latin runs (e.g. "9:00 — 10:00") don't get treated as RTL.
function featuresFor(
  isArabic: boolean,
): PDFKit.Mixins.OpenTypeFeatures[] | undefined {
  return isArabic ? ARABIC_FEATURES : undefined;
}

function fontKeyFor(
  isArabic: boolean,
  weight: "regular" | "bold",
  mapping: FamilyMapping,
): FontFileKey {
  if (isArabic) {
    return weight === "bold" ? mapping.arabicBold : mapping.arabicRegular;
  }
  return weight === "bold" ? mapping.latinBold : mapping.latinRegular;
}

function alignment(
  font: PdfFontPrefs,
  isRtl: boolean,
): "left" | "center" | "right" {
  if (font.alignment === "center") return "center";
  // "start" maps to the writing-direction start.
  return isRtl ? "right" : "left";
}

function htmlToPlain(input: string | null | undefined): string {
  if (!input) return "";
  // Strip every tag so we render plain text — table cells already have
  // their own font/weight/size from the user's font settings, and PDFKit
  // doesn't render arbitrary inline HTML. We DO keep <br> as a newline
  // because attendees often use line breaks.
  const withBreaks = input
    .replace(/<\s*br\s*\/?>/gi, "\n")
    .replace(/<\/(p|div|li)>/gi, "\n")
    .replace(/<li>/gi, "• ");
  const sanitized = sanitizeHtml(withBreaks, {
    allowedTags: [],
    allowedAttributes: {},
  });
  // Decode common entities that sanitize-html leaves intact when no tags
  // are kept (it returns the inner text).
  return sanitized
    .replace(/&nbsp;/g, " ")
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/[ \t]+\n/g, "\n")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

function formatTimeRange(
  startTime: string | null,
  endTime: string | null,
  lang: "ar" | "en",
): string {
  const fmt = (t: string): string => {
    const hhmm = t.slice(0, 5);
    const parsed = new Date(`1970-01-01T${hhmm}:00`);
    if (Number.isNaN(parsed.getTime())) return hhmm;
    // 12-hour with NO AM/PM (en) or ص/م (ar) suffix — matches the
    // on-screen schedule. We `formatToParts` and drop the `dayPeriod`,
    // then trim trailing/leading whitespace literals (Intl may use
    // U+00A0 / U+202F — String.prototype.trim handles them all).
    // Force Latin digits via -u-nu-latn so the PDF Time column renders
    // consistently regardless of language.
    const locale = lang === "ar" ? "ar-u-nu-latn" : "en-US-u-nu-latn";
    const parts = new Intl.DateTimeFormat(locale, {
      hour: "numeric",
      minute: "2-digit",
      hour12: true,
      numberingSystem: "latn",
    } as Intl.DateTimeFormatOptions).formatToParts(parsed);
    return parts
      .filter((p) => p.type !== "dayPeriod")
      .map((p) => p.value)
      .join("")
      .trim();
  };
  if (startTime && endTime) return `${fmt(startTime)}-${fmt(endTime)}`;
  if (startTime) return fmt(startTime);
  return "—";
}

// Long-form date for the footer block ("3 May 2026"). We always emit
// Latin digits and an English month name — even on the AR PDF — to mirror
// the user's reference layout (rrr1) and keep DIN Next LT Arabic
// rendering consistent across locales.
function formatLongDate(iso: string): string {
  // `iso` is the YYYY-MM-DD route param; build a UTC date so the day
  // doesn't drift by host timezone.
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
  if (!m) return iso;
  const [, y, mo, d] = m;
  const dt = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)));
  if (Number.isNaN(dt.getTime())) return iso;
  return new Intl.DateTimeFormat("en-GB", {
    day: "numeric",
    month: "long",
    year: "numeric",
    timeZone: "UTC",
  }).format(dt);
}

type DrawOpts = {
  x: number;
  y: number;
  width: number;
  fontSize: number;
  weight: "regular" | "bold";
  align: "left" | "center" | "right";
  baseDirection: "ltr" | "rtl";
  mapping: FamilyMapping;
  color?: string;
  lineHeight?: number;
  maxY?: number;
};

const BIDI_CONTROL_RE = /[\u200E\u200F\u200B\u202A-\u202E\u2066-\u2069]/g;
function stripBidiControls(s: string): string {
  return s.replace(BIDI_CONTROL_RE, "");
}

function measureBidiLineWidth(
  doc: PDFKit.PDFDocument,
  text: string,
  opts: Pick<DrawOpts, "fontSize" | "weight" | "baseDirection" | "mapping">,
): number {
  const runs = visualOrderRuns(text, opts.baseDirection);
  let w = 0;
  for (const r of runs) {
    const clean = stripBidiControls(r.text);
    if (!clean) continue;
    const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
    const features = featuresFor(r.isArabic);
    doc.font(fk).fontSize(opts.fontSize);
    w += doc.widthOfString(clean, features ? { features } : {});
  }
  return w;
}

function drawBidiLine(
  doc: PDFKit.PDFDocument,
  text: string,
  opts: DrawOpts,
): void {
  const runs = visualOrderRuns(text, opts.baseDirection);
  if (runs.length === 0) return;
  let totalWidth = 0;
  const measured: { clean: string; isArabic: boolean; width: number; fontKey: string; features: PDFKit.Mixins.OpenTypeFeatures[] | undefined }[] = [];
  for (const r of runs) {
    const clean = stripBidiControls(r.text);
    if (!clean) continue;
    const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
    const features = featuresFor(r.isArabic);
    doc.font(fk).fontSize(opts.fontSize);
    const w = doc.widthOfString(clean, features ? { features } : {});
    totalWidth += w;
    measured.push({ clean, isArabic: r.isArabic, width: w, fontKey: fk, features });
  }
  let cursorX = opts.x;
  if (opts.align === "center") {
    cursorX = opts.x + Math.max(0, (opts.width - totalWidth) / 2);
  } else if (opts.align === "right") {
    cursorX = opts.x + Math.max(0, opts.width - totalWidth);
  }
  for (const r of measured) {
    doc.font(r.fontKey).fontSize(opts.fontSize).fillColor(opts.color ?? "black");
    doc.text(r.clean, cursorX, opts.y, {
      lineBreak: false,
      width: r.width + 1,
      ...(r.features ? { features: [...r.features] } : {}),
    });
    cursorX += r.width;
  }
}

function drawMixedLine(
  doc: PDFKit.PDFDocument,
  text: string,
  opts: DrawOpts,
): number {
  const lh = opts.lineHeight ?? opts.fontSize * 1.2;
  const runs = visualOrderRuns(text, opts.baseDirection);
  if (runs.length === 0) {
    return lh;
  }
  let totalWidth = 0;
  const measured = runs.map((r) => {
    const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
    const features = featuresFor(r.isArabic);
    doc.font(fk).fontSize(opts.fontSize);
    const w = doc.widthOfString(r.text, features ? { features } : {});
    totalWidth += w;
    return { ...r, width: w, fontKey: fk, features };
  });
  if (totalWidth > opts.width + 4) {
    return drawWrappingLine(doc, text, opts);
  }
  let cursorX = opts.x;
  if (opts.align === "center") {
    cursorX = opts.x + Math.max(0, (opts.width - totalWidth) / 2);
  } else if (opts.align === "right") {
    cursorX = opts.x + Math.max(0, opts.width - totalWidth);
  }
  for (const r of measured) {
    doc.font(r.fontKey).fontSize(opts.fontSize).fillColor(opts.color ?? "black");
    doc.text(r.text, cursorX, opts.y, {
      lineBreak: false,
      width: r.width + 1,
      ...(r.features ? { features: [...r.features] } : {}),
    });
    cursorX += r.width;
  }
  return lh;
}

type WrapOpts = Pick<DrawOpts, "fontSize" | "weight" | "baseDirection" | "mapping"> & { width: number };

function computeVisualLines(
  doc: PDFKit.PDFDocument,
  text: string,
  opts: WrapOpts,
): string[] {
  if (!text.trim()) return [""];
  const words = text.split(/\s+/).filter((w) => w.length > 0);
  if (words.length === 0) return [""];
  const mOpts = {
    fontSize: opts.fontSize,
    weight: opts.weight,
    baseDirection: opts.baseDirection,
    mapping: opts.mapping,
  };
  const hardBreak = (word: string): string[] => {
    if (measureBidiLineWidth(doc, word, mOpts) <= opts.width) return [word];
    const chars = [...word];
    const pieces: string[] = [];
    let cur = "";
    for (const ch of chars) {
      const test = cur + ch;
      if (cur && measureBidiLineWidth(doc, test, mOpts) > opts.width) {
        pieces.push(cur);
        cur = ch;
      } else {
        cur = test;
      }
    }
    if (cur) pieces.push(cur);
    return pieces;
  };

  const result: string[] = [];
  let current = "";
  for (let i = 0; i < words.length; i++) {
    if (measureBidiLineWidth(doc, words[i], mOpts) > opts.width) {
      if (current) { result.push(current); current = ""; }
      for (const piece of hardBreak(words[i])) result.push(piece);
      continue;
    }
    const test = current ? current + " " + words[i] : words[i];
    if (measureBidiLineWidth(doc, test, mOpts) > opts.width) {
      result.push(current);
      current = words[i];
    } else {
      current = test;
    }
  }
  if (current) result.push(current);
  return result.length > 0 ? result : [""];
}

function drawWrappingLine(
  doc: PDFKit.PDFDocument,
  text: string,
  opts: DrawOpts,
): number {
  const lh = opts.lineHeight ?? opts.fontSize * 1.2;
  const lines = computeVisualLines(doc, text, {
    fontSize: opts.fontSize,
    weight: opts.weight,
    baseDirection: opts.baseDirection,
    mapping: opts.mapping,
    width: opts.width,
  });
  let totalH = 0;
  for (const vl of lines) {
    if (opts.maxY !== undefined && opts.y + totalH + lh > opts.maxY + 0.5) break;
    if (vl) drawBidiLine(doc, vl, { ...opts, y: opts.y + totalH });
    totalH += lh;
  }
  return Math.max(lh, totalH);
}

export async function renderSchedulePdf(input: RenderPdfInput): Promise<Buffer> {
  const { renderSchedulePdfHtml } = await import("./pdf-html-renderer.js");
  return renderSchedulePdfHtml(input);
}

async function renderSchedulePdf_LEGACY(input: RenderPdfInput): Promise<Buffer> {
  const isRtl = input.lang === "ar";
  const baseDir: "ltr" | "rtl" = isRtl ? "rtl" : "ltr";
  const doc = new PDFDocument({
    size: "A4",
    layout: "portrait",
    margin: 24, // ~8.5mm — tightened from 36 so a typical day fits on one A4 page
    info: {
      Title: `${input.labels.title} — ${input.date}`,
      Producer: "Tx OS Executive Meetings",
    },
  });
  const mapping = familyMappingFor(input.font.fontFamily);
  // Load only the four files this family needs (deduped by key).
  const neededKeys = Array.from(
    new Set<FontFileKey>([
      mapping.arabicRegular,
      mapping.arabicBold,
      mapping.latinRegular,
      mapping.latinBold,
    ]),
  );
  const bytes = loadFontBytes(neededKeys);
  for (const k of neededKeys) {
    doc.registerFont(k, bytes[k]);
  }

  const chunks: Buffer[] = [];
  doc.on("data", (c: Buffer) => chunks.push(c));
  const done = new Promise<void>((resolve, reject) => {
    doc.on("end", resolve);
    doc.on("error", reject);
  });

  // Header: logo (if any) on the left, centered title, date below.
  // Title size capped tighter (22 vs 28) so the header band doesn't eat
  // a row of table space — keeps a typical day on one page.
  const titleSize = Math.min(22, input.font.fontSize + 6);
  const headerLeftEdge = doc.page.margins.left;
  const headerWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
  const headerTopY = doc.y;
  const logoBoxSize = titleSize * 2.8;
  let titleX = headerLeftEdge;
  let titleWidth = headerWidth;
  if (input.logo && input.logo.length > 0) {
    try {
      doc.image(input.logo, headerLeftEdge, headerTopY, {
        fit: [logoBoxSize, logoBoxSize],
      });
      // Symmetric reservation keeps the title geometrically centered.
      const gap = 12;
      titleX = headerLeftEdge + logoBoxSize + gap;
      titleWidth = headerWidth - 2 * (logoBoxSize + gap);
      if (titleWidth < headerWidth * 0.4) {
        // Fallback: too narrow for symmetric reservation.
        titleX = headerLeftEdge + logoBoxSize + gap;
        titleWidth = headerWidth - logoBoxSize - gap;
      }
      // Reset doc.y; image() may have advanced it.
      doc.y = headerTopY;
    } catch (err) {
      // Bad image bytes: log and render without the logo.
      console.warn("[pdf-renderer] failed to embed logo, continuing without", err);
    }
  }
  drawMixedLine(doc, input.labels.title, {
    x: titleX,
    y: headerTopY,
    width: titleWidth,
    fontSize: titleSize,
    weight: "bold",
    align: "center",
    baseDirection: baseDir,
    mapping,
  });
  doc.y = headerTopY + Math.max(titleSize * 1.2, input.logo ? logoBoxSize : 0);
  doc.moveDown(0.3);

  if (input.meetings.length === 0) {
    drawMixedLine(doc, input.labels.none, {
      x: doc.page.margins.left,
      y: doc.y,
      width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
      fontSize: input.font.fontSize,
      weight: input.font.fontWeight,
      align: "center",
      baseDirection: baseDir,
      mapping,
    });
    drawFooter();
    doc.end();
    await done;
    return Buffer.concat(chunks);
  }

  function drawFooter(): void {
    const longDate = formatLongDate(input.date);
    const sz = Math.max(11, Math.round(input.font.fontSize * 0.95));
    const lineStep = sz * 1.5;
    const footerHeight = lineStep * 2 + 4;
    const footerWidth =
      doc.page.width - doc.page.margins.left - doc.page.margins.right;
    const footerY = doc.page.height - doc.page.margins.bottom - footerHeight;
    const footerAlign: "left" | "right" = baseDir === "rtl" ? "right" : "left";

    // Temporarily zero-out the bottom margin AND save/restore doc.y so
    // PDFKit never auto-paginates when we render near the page bottom.
    const savedBottom = doc.page.margins.bottom;
    const savedY = doc.y;
    doc.page.margins.bottom = 0;

    // --- Label line ("مقيد" / "Recorded by") ---
    const labelIsArabic = [...input.labels.recordedBy].filter(isArabicChar).length * 2 > input.labels.recordedBy.length;
    const labelFont = fontKeyFor(labelIsArabic, "bold", mapping);
    const features = featuresFor(labelIsArabic);
    doc.font(labelFont).fontSize(sz).fillColor("black");
    doc.text(input.labels.recordedBy, doc.page.margins.left, footerY, {
      width: footerWidth,
      align: footerAlign,
      lineBreak: false,
      ...(features ? { features } : {}),
    });

    // --- Date line ("3 May 2026") ---
    // Reset doc.y BEFORE the second call so PDFKit doesn't think
    // we've exceeded the page boundary.
    doc.y = footerY;
    const dateFont = fontKeyFor(false, "regular", mapping);
    doc.font(dateFont).fontSize(sz).fillColor("black");
    doc.text(longDate, doc.page.margins.left, footerY + lineStep, {
      width: footerWidth,
      align: footerAlign,
      lineBreak: false,
    });

    doc.page.margins.bottom = savedBottom;
    doc.y = savedY;
  }

  // ---- Table layout ----
  const tableX = doc.page.margins.left;
  const tableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
  const colWidths = [
    Math.round(tableWidth * 0.08),
    Math.round(tableWidth * 0.30),
    Math.round(tableWidth * 0.42),
    0,
  ];
  colWidths[3] = tableWidth - colWidths[0] - colWidths[1] - colWidths[2];
  const headerLabels = isRtl
    ? [input.labels.time, input.labels.attendees, input.labels.meeting, input.labels.no]
    : [input.labels.no, input.labels.meeting, input.labels.attendees, input.labels.time];
  const headerWidths = isRtl ? [...colWidths].reverse() : colWidths;

  const cellPadX = 4;
  const cellPadY = 0;
  const lineHeight = input.font.fontSize * 1.05;

  function drawHeader(): number {
    const headerHeight = lineHeight + cellPadY * 2;
    let cx = tableX;
    const headerY = doc.y;
    doc.save();
    doc.rect(tableX, headerY, tableWidth, headerHeight).fill("#0B1E3F");
    doc.restore();
    for (let i = 0; i < headerLabels.length; i++) {
      const label = headerLabels[i];
      const arabicChars = [...label].filter(isArabicChar).length;
      const dominantArabic = arabicChars * 2 > label.length;
      const fk = fontKeyFor(dominantArabic, "bold", mapping);
      doc.font(fk).fontSize(input.font.fontSize).fillColor("white");
      // Raw label + Arabic features → fontkit shapes & reorders.
      const features = featuresFor(dominantArabic);
      doc.text(label, cx + cellPadX, headerY + cellPadY, {
        width: headerWidths[i] - cellPadX * 2,
        align: "center",
        lineBreak: false,
        ...(features ? { features } : {}),
      });
      cx += headerWidths[i];
    }
    doc.fillColor("black");
    doc.y = headerY + headerHeight;
    return headerHeight;
  }

  // Render the table header on page 1.
  const startY = doc.y;
  const headerHeight = drawHeader();
  doc.y = startY + headerHeight;

  const MERGE_COL_INDEX: Record<string, number> = {
    number: 0, meeting: 1, attendees: 2, time: 3,
  };

  // ---- Rows ----
  for (const meeting of input.meetings) {
    let cellsLogical: { text: string; align: "left" | "center" | "right" }[] = [
      { text: String(meeting.dailyNumber), align: "center" },
      {
        text: htmlToPlain(isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr) +
          (meeting.location ? `\n${meeting.location}` : ""),
        align: "center",
      },
      {
        text:
          meeting.attendees.length === 0
            ? "—"
            : (() => {
                const ATTENDEES_PER_LINE = 3;
                let personIdx = 0;
                const parts: string[] = [];
                let currentGroup: string[] = [];
                const flushGroup = () => {
                  if (currentGroup.length === 0) return;
                  for (let i = 0; i < currentGroup.length; i += ATTENDEES_PER_LINE) {
                    parts.push(currentGroup.slice(i, i + ATTENDEES_PER_LINE).join("  "));
                  }
                  currentGroup = [];
                };
                for (const a of meeting.attendees) {
                  const isSub = (a.kind ?? "person") === "subheading";
                  const name = htmlToPlain(a.name);
                  if (isSub) {
                    flushGroup();
                    personIdx = 0;
                    parts.push(`— ${name} —`);
                  } else {
                    personIdx += 1;
                    const t = a.title?.trim();
                    currentGroup.push(`\u202A${personIdx}-\u202C ${name}${t ? ` (${t})` : ""}`);
                  }
                }
                flushGroup();
                return parts.join("\n");
              })(),
        align: "center",
      },
      {
        text: formatTimeRange(meeting.startTime, meeting.endTime, input.lang),
        align: "center",
      },
    ];
    let cellWidths = [...colWidths];

    if (meeting.mergeStartColumn && meeting.mergeEndColumn) {
      const startIdx = MERGE_COL_INDEX[meeting.mergeStartColumn];
      const endIdx = MERGE_COL_INDEX[meeting.mergeEndColumn];
      if (startIdx !== undefined && endIdx !== undefined && startIdx <= endIdx) {
        const mergedWidth = cellWidths.slice(startIdx, endIdx + 1).reduce((a, b) => a + b, 0);
        const mergedCell = { text: meeting.mergeText ? htmlToPlain(meeting.mergeText) : "", align: "center" as const };
        cellsLogical = [
          ...cellsLogical.slice(0, startIdx),
          mergedCell,
          ...cellsLogical.slice(endIdx + 1),
        ];
        cellWidths = [
          ...cellWidths.slice(0, startIdx),
          mergedWidth,
          ...cellWidths.slice(endIdx + 1),
        ];
      }
    }

    const cells = isRtl ? [...cellsLogical].reverse() : cellsLogical;
    const widths = isRtl ? [...cellWidths].reverse() : cellWidths;

    const savedProbeY = doc.y;
    const wrapOpts: WrapOpts = {
      fontSize: input.font.fontSize,
      weight: input.font.fontWeight,
      baseDirection: baseDir,
      mapping,
      width: 0,
    };
    let rowHeight = lineHeight + cellPadY * 2;
    const cellHeights: number[] = [];
    for (let i = 0; i < cells.length; i++) {
      const cellW = widths[i] - cellPadX * 2;
      const textLines = cells[i].text.split("\n");
      let h = 0;
      for (const tl of textLines) {
        if (!tl) { h += lineHeight; continue; }
        const vLines = computeVisualLines(doc, tl, { ...wrapOpts, width: cellW });
        h += vLines.length * lineHeight;
      }
      const finalH = h + cellPadY * 2;
      cellHeights.push(finalH);
      if (finalH > rowHeight) rowHeight = finalH;
    }
    doc.y = savedProbeY;

    const footerReserve =
      Math.max(11, Math.round(input.font.fontSize * 0.95)) * 1.4 * 2 + 6;
    if (
      savedProbeY + rowHeight >
      doc.page.height - doc.page.margins.bottom - footerReserve
    ) {
      doc.addPage();
      drawHeader();
    }

    const rowTop = doc.y;
    if (meeting.rowColor) {
      const fill = ROW_COLOR_FILL[meeting.rowColor];
      if (fill) {
        doc.save();
        doc.rect(tableX, rowTop, tableWidth, rowHeight).fill(fill);
        doc.restore();
      }
    }

    doc.save();
    doc.lineWidth(1.5).strokeColor("#0B1E3F");
    doc.rect(tableX, rowTop, tableWidth, rowHeight).stroke();
    let bx = tableX;
    for (let i = 0; i < cells.length - 1; i++) {
      bx += widths[i];
      doc
        .moveTo(bx, rowTop)
        .lineTo(bx, rowTop + rowHeight)
        .stroke();
    }
    doc.restore();

    const rowBottom = rowTop + rowHeight;
    let cx = tableX;
    for (let i = 0; i < cells.length; i++) {
      const savedCellY = doc.y;
      const c = cells[i];
      const cellW = widths[i] - cellPadX * 2;
      const textLines = c.text.split("\n");
      const contentH = cellHeights[i] - cellPadY * 2;
      const vOffset = Math.max(0, (rowHeight - cellPadY * 2 - contentH) / 2);
      let y = rowTop + cellPadY + vOffset;
      for (const tl of textLines) {
        if (y >= rowBottom) break;
        if (!tl) { y += lineHeight; continue; }
        const used = drawWrappingLine(doc, tl, {
          x: cx + cellPadX,
          y,
          width: cellW,
          fontSize: input.font.fontSize,
          weight: input.font.fontWeight,
          align: c.align,
          baseDirection: baseDir,
          mapping,
          color: input.font.fontColor,
          lineHeight,
          maxY: rowBottom,
        });
        y += used;
      }
      doc.y = savedCellY;
      cx += widths[i];
    }
    doc.y = rowTop + rowHeight;
  }

  // Footer — "Recorded by" label + long-form date, anchored near the
  // bottom of the (last) page on the leading edge (right for RTL, left
  // for LTR) to mirror the user's reference layout (rrr1).
  drawFooter();

  doc.end();
  await done;
  return Buffer.concat(chunks);
}

// Keep the explicit "is this an arabic-leaning family?" check exposed for
// tests / future logic that wants to know the user's font choice.
export function fontFamilyIsArabic(name: string): boolean {
  return ARABIC_FONT_NAMES.includes(name);
}
