/**
 * Minimal hand-built PDF primitives shared by the Chromium-less document
 * fallbacks (invoices, receipts). These draw a genuine vector layout — boxes,
 * rules, right-aligned money, a round seal — so a document still looks like a
 * real document when headless Chromium is unavailable, instead of degrading to
 * a flat plain-text dump. Coordinates are PDF user space (origin bottom-left,
 * A4 = 595 x 842 pt). Fonts: F1 = Helvetica, F2 = Helvetica-Bold.
 */

export const MIN_VALID_PDF_BYTES = 2_000;

/** Colours as PDF "r g b" triples in the 0..1 range. */
export const PdfColor = {
  teal: "0.06 0.46 0.43",
  dark: "0.06 0.09 0.16",
  gray: "0.39 0.45 0.55",
  line: "0.80 0.84 0.89",
  shade: "0.96 0.97 0.98",
  tint: "0.94 0.99 0.98",
} as const;

export function escapePdfText(value: string): string {
  return value
    .replace(/\\/g, "\\\\")
    .replace(/\(/g, "\\(")
    .replace(/\)/g, "\\)")
    .replace(/[^\x20-\x7e]/g, " ");
}

/** Approximate rendered width of Helvetica text (slightly over-estimated so
 *  right-aligned text never crosses its column edge). */
export function textWidth(text: string, size: number): number {
  return text.length * size * 0.52;
}

export function textOp(
  text: string,
  x: number,
  y: number,
  size: number,
  font: "F1" | "F2" = "F1",
  color: string = PdfColor.dark,
): string {
  return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
}

/** Right-align `text` so it ends at `rightX`. */
export function textOpRight(
  text: string,
  rightX: number,
  y: number,
  size: number,
  font: "F1" | "F2" = "F1",
  color: string = PdfColor.dark,
): string {
  return textOp(text, rightX - textWidth(text, size), y, size, font, color);
}

export function lineOp(
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  color: string = PdfColor.line,
  width = 0.8,
): string {
  return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
}

export function rectOp(
  x: number,
  y: number,
  width: number,
  height: number,
  fillColor = "1 1 1",
  strokeColor: string = PdfColor.line,
  lineWidth = 0.7,
): string {
  return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
}

function circlePath(cx: number, cy: number, r: number): string {
  const k = 0.5522847498;
  const c = r * k;
  return [
    `${cx + r} ${cy} m`,
    `${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
    `${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
    `${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
    `${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
    "h",
  ].join("\n");
}

/** A double-ring round rubber-stamp seal carrying up to three centred lines. */
export function sealOp(
  cx: number,
  cy: number,
  r: number,
  lines: string[],
  color: string = PdfColor.teal,
): string {
  const rows = lines.slice(0, 3);
  const ops = [
    "q",
    `${color} RG`,
    `${color} rg`,
    "2 w",
    circlePath(cx, cy, r),
    "S",
    "0.7 w",
    circlePath(cx, cy, r - 6),
    "S",
  ];
  const startY = cy + (rows.length - 1) * 6;
  rows.forEach((text, i) => {
    const size = i === 0 ? 10 : 7.5;
    ops.push(textOpRight(text, cx + textWidth(text, size) / 2, startY - i * 12 - 3, size, "F2", color));
  });
  ops.push("Q");
  return ops.join("\n");
}

/** Hard-truncate to `max` chars (no marker — keeps dense table cells tight). */
export function clipText(value: string, max: number): string {
  const t = String(value ?? "");
  return t.length > max ? t.slice(0, Math.max(1, max)) : t;
}

/** Strip HTML tags → plain text, decoding the basic entities the doc builders emit. */
export function htmlToText(html: string): string {
  return String(html ?? "")
    .replace(/<br\s*\/?>/gi, " ")
    .replace(/<[^>]+>/g, " ")
    .replace(/&amp;/gi, "&")
    .replace(/&lt;/gi, "<")
    .replace(/&gt;/gi, ">")
    .replace(/&quot;/gi, '"')
    .replace(/&#39;/g, "'")
    .replace(/&nbsp;/gi, " ")
    .replace(/[^\x20-\x7e]/g, " ")
    .replace(/\s+/g, " ")
    .trim();
}

/**
 * Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"),
 * drawn FIRST so the page content sits on top of it. 30-degree rotation via a
 * text matrix; roughly centered on the page.
 */
export function watermarkOp(text: string, page: { width: number; height: number }): string {
  const label = clipText(text, 46);
  const size = 34;
  const w = textWidth(label, size);
  const x = page.width / 2 - (w * 0.866) / 2;
  const y = page.height / 2 - (w * 0.5) / 2;
  return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`;
}

/**
 * Parse a "summary tiles + one <table> + notice + signature lines" document (the
 * marshalling / load-list layout the train-scheduling builders emit) and draw it as a
 * styled PDF grid. Used as the Chromium-less fallback so the manifest reads as a real
 * document, not a flat text dump. Switches to landscape when the table is wide.
 */
export function buildTabularFallbackPdf(html: string): Buffer {
  // Documents printed in duplicate wrap each copy in <section class="copy">
  // (freight order: Port Operations copy + Gate Security copy). Render one
  // page per copy, each with its own watermark and tile set — parsing the
  // whole HTML at once would merge both copies' tiles and drop the watermarks.
  const copies = [...html.matchAll(/<section class="copy">([\s\S]*?)<\/section>/gi)].map((m) => m[1]);
  const fragments = copies.length ? copies : [html];
  return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment)));
}

function buildTabularPageOps(
  html: string,
): Array<{ ops: string[]; page: { width: number; height: number } }> {
  const pick = (re: RegExp) => html.match(re)?.[1];
  const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
  const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
  const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
  const metaLabel =
    htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
  const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
  const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");

  const tiles: Array<[string, string]> = [];
  for (const m of html.matchAll(
    /class="tile"[^>]*>\s*<span>([\s\S]*?)<\/span>\s*<strong>([\s\S]*?)<\/strong>/gi,
  )) {
    tiles.push([htmlToText(m[1]), htmlToText(m[2])]);
  }

  const thead = pick(/<thead>([\s\S]*?)<\/thead>/i) ?? "";
  const headers = [...thead.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1]));
  const tbody = pick(/<tbody>([\s\S]*?)<\/tbody>/i) ?? "";
  const rows: string[][] = [...tbody.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
    [...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((td) => htmlToText(td[1])),
  );
  const notice = htmlToText(pick(/class="notice"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
  const parsedSigs = [...html.matchAll(/class="line"[^>]*>([\s\S]*?)<\/div>/gi)]
    .map((m) => htmlToText(m[1]))
    .filter(Boolean);
  const signatures = parsedSigs.length ? parsedSigs : ["Prepared / date", "Check / date", "Authorization / date"];

  const landscape = headers.length > 7;
  const page = landscape ? PageSize.landscape : PageSize.portrait;
  const M = 32;
  const contentW = page.width - M * 2;
  const right = page.width - M;
  const MAX_PAGES = 12;

  const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = [];
  let ops: string[] = [];
  let y = 0;

  const drawFullHeader = () => {
    ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
    ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
    ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
    if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
    if (metaRef) {
      ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray));
      ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
    }
    if (generated) {
      ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
    }
    ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
    y = page.height - 100;
  };

  const drawContinuationHeader = (pageNo: number) => {
    ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6));
    ops.push(
      textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark),
    );
    if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray));
    y = page.height - 54;
  };

  const startPage = (first: boolean) => {
    ops = [];
    if (watermark) ops.push(watermarkOp(watermark, page));
    if (first) drawFullHeader();
    else drawContinuationHeader(pagesOut.length + 1);
  };

  const finishPage = () => pagesOut.push({ ops, page });

  startPage(true);

  // Summary tiles (first page only)
  if (tiles.length) {
    const cols = landscape ? 6 : 4;
    const tileW = contentW / cols;
    const tileH = 32;
    tiles.forEach(([label, value], i) => {
      const col = i % cols;
      if (col === 0 && i > 0) y -= tileH;
      const x = M + col * tileW;
      ops.push(rectOp(x, y - tileH + 4, tileW - 4, tileH - 4, PdfColor.shade, PdfColor.line, 0.5));
      ops.push(textOp(clipText(label.toUpperCase(), Math.floor((tileW - 12) / 3.6)), x + 6, y - 8, 6.5, "F1", PdfColor.gray));
      ops.push(textOp(clipText(value, Math.floor((tileW - 12) / 4.4)), x + 6, y - 20, 9, "F2", PdfColor.dark));
    });
    y -= tileH + 12;
  }

  // Table, paginated across as many pages as the rows need.
  if (headers.length) {
    const colW = contentW / headers.length;
    const headerH = 16;
    const rowH = 14;
    const cellChars = Math.max(4, Math.floor(colW / 3.9));
    const bottomReserve = 46; // keep clear of the page edge on row-only pages

    const drawTableHeader = () => {
      ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
      headers.forEach((h, c) =>
        ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
      );
      y -= headerH;
    };

    drawTableHeader();
    let truncated = 0;
    for (const [index, row] of rows.entries()) {
      if (y - rowH < bottomReserve) {
        if (pagesOut.length + 1 >= MAX_PAGES) {
          truncated = rows.length - index;
          break;
        }
        finishPage();
        startPage(false);
        drawTableHeader();
      }
      ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
      headers.forEach((_h, c) => {
        if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
        const cell = row[c] ?? "";
        if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
      });
      y -= rowH;
    }
    if (truncated > 0) {
      ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
    }
  }

  // Notice + signatures live on the final page; give them a fresh page when the
  // rows ran too deep for the fixed bottom band.
  if (y < 110 && (notice || signatures.length)) {
    finishPage();
    startPage(false);
  }
  if (notice) {
    ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
    wrapText(notice, landscape ? 155 : 104)
      .slice(0, 2)
      .forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
  }
  const sigW = contentW / signatures.length;
  signatures.forEach((sig, i) => {
    const x = M + i * sigW;
    ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
    ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
  });
  finishPage();

  return pagesOut;
}

/** Greedy word-wrap to a maximum character width. */
export function wrapText(text: string, maxChars: number): string[] {
  const out: string[] = [];
  for (const raw of String(text ?? "").split("\n")) {
    const words = raw.split(/\s+/).filter(Boolean);
    let line = "";
    for (const word of words) {
      const next = line ? `${line} ${word}` : word;
      if (next.length > maxChars && line) {
        out.push(line);
        line = word;
      } else {
        line = next;
      }
    }
    if (line) out.push(line);
  }
  return out.length ? out : [""];
}

/** A4 page sizes in PDF points. */
export const PageSize = {
  portrait: { width: 595, height: 842 },
  landscape: { width: 842, height: 595 },
} as const;

/** Assemble a single-page PDF from content-stream ops (Helvetica fonts). Defaults to A4 portrait. */
export function assembleSinglePagePdf(
  ops: string[],
  page: { width: number; height: number } = PageSize.portrait,
): Buffer {
  const stream = ops.join("\n");
  const objects = [
    "<< /Type /Catalog /Pages 2 0 R >>",
    "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
    `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${page.width} ${page.height}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`,
    "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
    "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
    `<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
  ];

  let pdf = "%PDF-1.4\n";
  const offsets: number[] = [0];
  objects.forEach((object, index) => {
    offsets.push(Buffer.byteLength(pdf, "latin1"));
    pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
  });
  while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
    pdf += "% fallback padding\n";
  }
  const xrefOffset = Buffer.byteLength(pdf, "latin1");
  pdf += `xref\n0 ${objects.length + 1}\n`;
  pdf += "0000000000 65535 f \n";
  for (const offset of offsets.slice(1)) {
    pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
  }
  pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
  return Buffer.from(pdf, "latin1");
}

/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */
export function assemblePdf(
  pages: Array<{ ops: string[]; page: { width: number; height: number } }>,
): Buffer {
  const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" ");
  const objects: string[] = [
    "<< /Type /Catalog /Pages 2 0 R >>",
    `<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`,
    "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
    "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
  ];
  for (const [i, p] of pages.entries()) {
    const stream = p.ops.join("\n");
    objects.push(
      `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`,
    );
    objects.push(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`);
  }

  let pdf = "%PDF-1.4\n";
  const offsets: number[] = [0];
  objects.forEach((object, index) => {
    offsets.push(Buffer.byteLength(pdf, "latin1"));
    pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
  });
  while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
    pdf += "% fallback padding\n";
  }
  const xrefOffset = Buffer.byteLength(pdf, "latin1");
  pdf += `xref\n0 ${objects.length + 1}\n`;
  pdf += "0000000000 65535 f \n";
  for (const offset of offsets.slice(1)) {
    pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
  }
  pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
  return Buffer.from(pdf, "latin1");
}
