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";

import sanitizeHtml from "sanitize-html";

const bidi = bidiFactory();

export type PdfFontPrefs = {
  fontFamily: string;
  fontSize: number;
  fontWeight: "regular" | "bold";
  alignment: "start" | "center";
};

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;
  attendees: PdfMeetingAttendee[];
};

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;
  };
};

// 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",
} 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;
};

const FAMILY_MAP: Record<string, FamilyMapping> = {
  system: {
    arabicRegular: "naskhRegular",
    arabicBold: "naskhBold",
    latinRegular: "latinRegular",
    latinBold: "latinBold",
  },
  Cairo: {
    arabicRegular: "arabicSansRegular",
    arabicBold: "arabicSansBold",
    latinRegular: "latinRegular",
    latinBold: "latinBold",
  },
  Tajawal: {
    arabicRegular: "arabicSansRegular",
    arabicBold: "arabicSansBold",
    latinRegular: "latinRegular",
    latinBold: "latinBold",
  },
  "Noto Naskh Arabic": {
    arabicRegular: "naskhRegular",
    arabicBold: "naskhBold",
    latinRegular: "latinRegular",
    latinBold: "latinBold",
  },
  Amiri: {
    arabicRegular: "naskhRegular",
    arabicBold: "naskhBold",
    latinRegular: "latinRegular",
    latinBold: "latinBold",
  },
};

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

const ARABIC_FONT_NAMES: ReadonlyArray<string> = [
  "Cairo",
  "Tajawal",
  "Noto Naskh Arabic",
  "Amiri",
];

// 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 };

// 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;
}

// Apply Unicode Bidi to a string and return runs in *visual* order (which
// is the order PDFKit will paint them along the line). Without this, mixed
// Arabic/Latin/digit cells look wrong (e.g. "10:00 - 11:00" inside an
// otherwise-RTL line gets digit-reversed by the renderer).
function visualOrderRuns(text: string, baseDirection: "ltr" | "rtl"): Run[] {
  if (!text) return [];
  const embeddingLevels = bidi.getEmbeddingLevels(text, baseDirection);
  const reordered = bidi.getReorderedString(text, embeddingLevels);
  return splitRunsByScript(reordered);
}

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;
    return new Intl.DateTimeFormat(lang === "ar" ? "ar" : "en", {
      hour: "numeric",
      minute: "2-digit",
      hour12: true,
      // Force Latin digits even for Arabic locale so the Time column
      // renders consistently across the schedule and the PDF — matches
      // i18nFormatTime in the frontend.
      numberingSystem: "latn",
    } as Intl.DateTimeFormatOptions).format(parsed);
  };
  if (startTime && endTime) return `${fmt(startTime)} – ${fmt(endTime)}`;
  if (startTime) return fmt(startTime);
  return "—";
}

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

// Draw a single line of text using mixed Arabic/Latin runs (in visual
// order). We measure each run with the right font and lay them out
// left-to-right starting from the resolved alignment. Returns the height
// consumed by this line (including a small line-gap).
function drawMixedLine(
  doc: PDFKit.PDFDocument,
  text: string,
  opts: DrawOpts,
): number {
  const runs = visualOrderRuns(text, opts.baseDirection);
  if (runs.length === 0) {
    return opts.fontSize * 1.2;
  }
  // Measure full line width.
  let totalWidth = 0;
  const measured = runs.map((r) => {
    const fk = fontKeyFor(r.isArabic, opts.weight, opts.mapping);
    doc.font(fk).fontSize(opts.fontSize);
    const w = doc.widthOfString(r.text);
    totalWidth += w;
    return { ...r, width: w, fontKey: fk };
  });
  const overflow = totalWidth - opts.width;
  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);
  }
  // If we overflow horizontally, fall back to wrapping via PDFKit's own
  // text engine on the first run. Cells in the schedule rarely overflow
  // because column widths are generous, but Arabic titles can be long.
  if (overflow > 4) {
    return drawWrappingLine(doc, text, opts);
  }
  for (const r of measured) {
    doc.font(r.fontKey).fontSize(opts.fontSize).fillColor("black");
    doc.text(r.text, cursorX, opts.y, {
      lineBreak: false,
      width: r.width + 1,
    });
    cursorX += r.width;
  }
  return opts.fontSize * 1.25;
}

function drawWrappingLine(
  doc: PDFKit.PDFDocument,
  text: string,
  opts: DrawOpts,
): number {
  // Determine the dominant script for the wrapping pass. Without true
  // bidi line-breaking we pick the "majority" script font to wrap with;
  // this keeps shaping intact for the dominant direction at the cost of
  // a slight metric mismatch on the minority runs.
  const arabicChars = [...text].filter(isArabicChar).length;
  const dominantArabic = arabicChars * 2 > text.length;
  const fk = fontKeyFor(dominantArabic, opts.weight, opts.mapping);
  doc.font(fk).fontSize(opts.fontSize).fillColor("black");
  // For Arabic text, run bidi on the whole string so the font sees runs
  // already in visual order — this matches what drawMixedLine does for
  // single-line cells.
  const reordered = bidi.getReorderedString(
    text,
    bidi.getEmbeddingLevels(text, opts.baseDirection),
  );
  const before = doc.y;
  doc.text(reordered, opts.x, opts.y, {
    width: opts.width,
    align: opts.align,
    lineBreak: true,
  });
  return Math.max(opts.fontSize * 1.25, doc.y - before + 2);
}

export async function renderSchedulePdf(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: 36, // ~12.7 mm — matches the screen print page's @page margin closely
    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 — show the schedule title and the date on its own line.
  const titleSize = Math.min(28, input.font.fontSize + 10);
  drawMixedLine(doc, input.labels.title, {
    x: doc.page.margins.left,
    y: doc.y,
    width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
    fontSize: titleSize,
    weight: "bold",
    align: "center",
    baseDirection: baseDir,
    mapping,
  });
  doc.moveDown(0.4);
  drawMixedLine(doc, input.date, {
    x: doc.page.margins.left,
    y: doc.y,
    width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
    fontSize: Math.max(11, Math.round(input.font.fontSize * 0.85)),
    weight: "regular",
    align: "center",
    baseDirection: baseDir,
    mapping,
  });
  doc.moveDown(0.8);

  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,
    });
    doc.end();
    await done;
    return Buffer.concat(chunks);
  }

  // ---- Table layout ----
  // Mirror the print page's column widths (6 / 30 / 44 / 20).
  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.06),
    Math.round(tableWidth * 0.3),
    Math.round(tableWidth * 0.44),
    0, // last col absorbs rounding
  ];
  colWidths[3] = tableWidth - colWidths[0] - colWidths[1] - colWidths[2];
  const headerLabels = isRtl
    ? // RTL: visually right-to-left ordering matches the schedule's
      // Arabic layout (#, Meeting, Attendees, Time becomes Time first).
      [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 = 6;
  const cellPadY = 5;
  const lineHeight = input.font.fontSize * 1.25;

  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");
      const reordered = bidi.getReorderedString(
        label,
        bidi.getEmbeddingLevels(label, baseDir),
      );
      doc.text(reordered, cx + cellPadX, headerY + cellPadY, {
        width: headerWidths[i] - cellPadX * 2,
        align: "center",
        lineBreak: false,
      });
      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;

  // ---- Rows ----
  for (const meeting of input.meetings) {
    const 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: alignment(input.font, isRtl),
      },
      {
        text:
          meeting.attendees.length === 0
            ? "—"
            : (() => {
                // Walking person counter — each subheading starts a
                // fresh numbered section, so persons restart at 1- after
                // every subheading. Same logic as the on-screen
                // AttendeeFlow and the client print page.
                let personIdx = 0;
                return meeting.attendees
                  .map((a) => {
                    const isSub = (a.kind ?? "person") === "subheading";
                    const name = htmlToPlain(a.name);
                    if (isSub) {
                      // Reset the in-section counter so the next person
                      // starts at 1.
                      personIdx = 0;
                      // Brackets keep subheadings visually distinct in the
                      // monospace plain-text PDF cell where bold/colour
                      // can't carry. Adapter prefix is non-numeric so a
                      // human reader can scan it as "section label".
                      return `— ${name} —`;
                    }
                    personIdx += 1;
                    const t = a.title?.trim();
                    return `${personIdx}- ${name}${t ? ` (${t})` : ""}`;
                  })
                  .join("\n");
              })(),
        align: alignment(input.font, isRtl),
      },
      {
        text: formatTimeRange(meeting.startTime, meeting.endTime, input.lang),
        align: "center",
      },
    ];
    const cells = isRtl ? [...cellsLogical].reverse() : cellsLogical;
    const widths = isRtl ? [...colWidths].reverse() : colWidths;

    // Measure the row height by laying out each cell to a temporary
    // y-cursor and taking the max. We then redraw at the final position.
    const probe = doc;
    const probeY = probe.y;
    let rowHeight = lineHeight + cellPadY * 2;
    const cellHeights: number[] = [];
    let cx = tableX;
    for (let i = 0; i < cells.length; i++) {
      const c = cells[i];
      const startProbeY = probeY + cellPadY;
      probe.y = startProbeY;
      // Reuse drawWrappingLine to compute height — drop the actual draw
      // by using a temporary off-screen page would be ideal, but PDFKit
      // doesn't expose an undo. We instead measure by counting expected
      // wraps at the dominant font.
      const lines = c.text.split("\n");
      let h = 0;
      const arabicChars = [...c.text].filter(isArabicChar).length;
      const dominantArabic = arabicChars * 2 > c.text.length;
      const fk = fontKeyFor(dominantArabic, input.font.fontWeight, mapping);
      doc.font(fk).fontSize(input.font.fontSize);
      for (const line of lines) {
        if (!line) {
          h += lineHeight;
          continue;
        }
        const w = doc.widthOfString(line);
        const wraps = Math.max(1, Math.ceil(w / (widths[i] - cellPadX * 2)));
        h += wraps * lineHeight;
      }
      const finalH = h + cellPadY * 2;
      cellHeights.push(finalH);
      if (finalH > rowHeight) rowHeight = finalH;
      cx += widths[i];
    }

    // Page break check.
    if (probeY + rowHeight > doc.page.height - doc.page.margins.bottom) {
      doc.addPage();
      drawHeader();
    }

    const rowTop = doc.y;
    if (meeting.isHighlighted) {
      doc.save();
      doc.rect(tableX, rowTop, tableWidth, rowHeight).fill("#fecaca");
      doc.restore();
    }

    // Cell borders.
    doc.save();
    doc.lineWidth(0.5).strokeColor("#d1d5db");
    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();

    // Draw cell contents.
    cx = tableX;
    for (let i = 0; i < cells.length; i++) {
      const c = cells[i];
      const cellW = widths[i] - cellPadX * 2;
      const lines = c.text.split("\n");
      let y = rowTop + cellPadY;
      for (const line of lines) {
        if (!line) {
          y += lineHeight;
          continue;
        }
        const used = drawWrappingLine(doc, line, {
          x: cx + cellPadX,
          y,
          width: cellW,
          fontSize: input.font.fontSize,
          weight: input.font.fontWeight,
          align: c.align,
          baseDirection: baseDir,
          mapping,
        });
        y += used;
      }
      cx += widths[i];
    }
    doc.y = rowTop + rowHeight;
  }

  // Footer — generation timestamp and font info.
  doc.moveDown(0.6);
  const footer = `Generated ${new Date().toISOString()} · font=${input.font.fontFamily} ${input.font.fontSize}px ${input.font.fontWeight}`;
  doc
    .font(mapping.latinRegular)
    .fontSize(8)
    .fillColor("#6b7280")
    .text(footer, doc.page.margins.left, doc.y, {
      width: doc.page.width - doc.page.margins.left - doc.page.margins.right,
      align: "left",
    });

  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);
}
