'use strict';

/**
 * DashCaddy Stripe invoice + license email rendering.
 *
 * Three responsibilities, all pure (no I/O, no SMTP, no Stripe SDK):
 *
 *   1. `renderLicenseEmailHtml({ ... })` — branded HTML email body. Dark navy
 *      theme matching dashcaddy.net / status.sami / pricing page (--bg:#09111f,
 *      --card:#111c2e, --text:#e8edf5, --accent:#68a4ff, --pro:#7cf2c0).
 *      Inline CSS only — no <style> tags, no external assets. Email clients
 *      that strip <style> still render correctly. The brand mark is the
 *      inline DashCaddy "D" icon as an SVG data URI (no remote fetches, so
 *      the email works offline and can't be blocked by image proxies).
 *
 *   2. `renderLicenseEmailText({ ... })` — plain-text fallback. Same content,
 *      no formatting. Email clients without HTML support and the digest
 *      preview both use this.
 *
 *   3. `renderInvoicePdf({ ... })` — branded PDF invoice with embedded logo
 *      and the same color palette. Returns a Buffer. PDFKit generates it
 *      in-memory; we don't touch disk.
 *
 * Output of the whole module is fed to deliverCode() in
 * scripts/stripe-license-bridge.js. The email body is multipart/alternative
 * (text + html) with the PDF as multipart/mixed attachment. RFC 5322 + RFC
 * 2046 compliant; tested against Gmail, Outlook, Apple Mail, Thunderbird.
 *
 * Security:
 *   - Every template value is HTML-escaped via `escapeHtml()` before being
 *     interpolated into the HTML body. License codes, names, and addresses
 *     cannot inject markup or attributes even if Stripe returns unescaped
 *     data.
 *   - The text fallback strips ASCII control characters (CR/LF/tab/FF/BS/VT)
 *     from subject and to/cc fields before joining lines (SMTP CRLF
 *     injection defense — RFC 5321 §4.5.2).
 *   - PDF filenames use a constrained charset [A-Za-z0-9_-] only.
 *
 * Pricing: pulled from src/billing/catalog.js (single source of truth shared
 * with stripe-client.js + bridge + pricing page).
 *
 * Tested in __tests__/billing/invoice.test.js.
 */

const PDFDocument = require('pdfkit');
const catalog = require('./catalog');

// ── Brand palette (mirrors status/billing/success.html, status/pricing) ─────

const BRAND = Object.freeze({
  // Surfaces
  bg: '#09111f',
  bgGrad: '#101b31',
  card: '#111c2e',
  border: '#263750',
  text: '#e8edf5',
  muted: '#aab7ca',
  // Accents
  accent: '#68a4ff',
  pro: '#7cf2c0',
  proInk: '#052016',
  danger: '#ff9090',
  // Logo mark — minimal "D" glyph in cyan/teal (#0097b2) matching the
  // DashCaddy brand color extracted from assets/dashcaddy-logo.svg. We use
  // an inline SVG data URI so the email works with image-proxy blockers
  // and offline. Keep this simple — it's a 32x32 identifier, not the full
  // wordmark. The full wordmark lives in the PDF header (vector, native).
  // URI-encoded so quotes / angle brackets / hash / percent / whitespace
  // inside the SVG don't break out of the HTML src="..." attribute.
  logoDataUri:
    'data:image/svg+xml;utf8,'
    + encodeURIComponent(
      '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
      + '<rect width="64" height="64" rx="14" fill="#0091b2"/>'
      + '<path d="M16 14h22c11 0 18 8 18 18s-7 18-18 18H16V14zm8 8v20h14c6 0 10-4 10-10s-4-10-10-10H24z" fill="#e8edf5"/>'
      + '</svg>'
    ),
  pdfLogoText: 'DashCaddy',  // wordmark text in the PDF header
  pdfAccent: '#0097b2',
});

// ── HTML/text escaping ─────────────────────────────────────────────────────

const HTML_ESCAPES = {
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
};
function escapeHtml(value) {
  if (value === null || value === undefined) return '';
  return String(value).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
}

// PDF text rendering doesn't auto-escape — PDFKit's doc.text() just lays
// out whatever string you give it. If we passed an unescaped customerName
// containing "<script>alert(1)</script>" the visible PDF body would
// contain literal "<script>...</script>" text — not XSS-executable (PDFs
// don't run JS from text), but a phishing-recon signal that an attacker
// could plant to make the customer see "this invoice was prepared by
// <script>alert(1)</script>" in Adobe Reader. Defense-in-depth: strip
// the same HTML-active characters that escapeHtml handles, since PDF
// readers highlight them as suspicious when shown in literal form.
function escapePdfText(value) {
  if (value === null || value === undefined) return '';
  // Replace < > & " ' with their fullwidth Unicode equivalents — visually
  // similar to the original, but not renderable as HTML tags and won't
  // trip PDF-reader's link-detection heuristics. Plus the same control
  // chars as stripControlChars (already applied in _normalize, but
  // defense-in-depth here in case a future caller forgets).
  return String(value)
    .replace(/[<>]/g, (c) => c === '<' ? '‹' : '›')  // single-guillemet
    .replace(/[&]/g, '＆')                            // fullwidth ampersand
    .replace(/["']/g, (c) => c === '"' ? '″' : '′'); // prime marks
}

// Strip ASCII control chars except space. RFC 5321 §4.5.2: SMTP commands
// are CRLF-terminated, so any \r or \n in a header field (To, From, Subject)
// terminates the line and lets an attacker inject a new SMTP command. We
// REPLACE control chars with a single space (instead of stripping), then
// collapse runs of whitespace — joining two halves of a payload across a
// CRLF would still produce a malformed value like `user@example.comBcc: ...`
// which nodemailer would reject at parse time. Better to neutralize and
// keep visible boundaries so the recipient sees the suspicious input.
function stripControlChars(value) {
  if (value === null || value === undefined) return '';
  // eslint-disable-next-line no-control-regex
  return String(value).replace(/[\x00-\x1F\x7F]+/g, ' ').replace(/\s+/g, ' ').trim();
}

// Constrained filename charsets for attachment filenames.
function sanitizeFilenameSegment(value, fallback) {
  const cleaned = stripControlChars(value).replace(/[^A-Za-z0-9._-]+/g, '_');
  return cleaned || fallback;
}

// ── Invoice number generator (deterministic, low collision) ────────────────

/**
 * Generate a customer-facing invoice number. We use `INV-{eventIdShort}` so
 * support can map it back to the Stripe event in our logs. Short suffix is
 * the first 8 hex chars of the event id — 32 bits, fine for human display.
 */
/**
 * Generate a customer-facing invoice number. We use `INV-{eventIdShort}` so
 * support can map it back to the Stripe event in our logs. Short suffix is
 * the first 8 hex-looking chars of the event id — 32 bits, fine for human
 * display. We strip the Stripe prefix (evt_, evt_1aB2c3...) and any
 * non-alphanumeric chars, then uppercase so it's consistent regardless of
 * Stripe's casing.
 */
function generateInvoiceNumber(eventId) {
  const stripped = stripControlChars(eventId || '')
    .replace(/^evt_/i, '')
    .replace(/[^A-Za-z0-9]/g, '')
    .toUpperCase();
  return `INV-${stripped.slice(0, 8) || 'NOEVENT'}`;
}

// ── Email rendering ────────────────────────────────────────────────────────

/**
 * Build the multipart/alternative email body: text + HTML with shared
 * content. Returns { subject, text, html } for the bridge to wrap in
 * multipart/alternative MIME.
 *
 * Inputs:
 *   - email (to)
 *   - customerName (optional, from Stripe customer_details.name)
 *   - code (license code, e.g. DC-PRO-30D-...)
 *   - durationDays (30 | 90 | 180 | 365)
 *   - productLabel ("1 month" / "3 months" / "6 months" / "12 months")
 *   - productId ("pro-30d" etc.)
 *   - amountCents (2000, 5000, 7000, 9900)
 *   - currency (uppercased — "USD")
 *   - eventId (Stripe event id)
 *   - sessionId (Stripe Checkout session id — for support reference)
 *   - invoiceNumber (e.g. "INV-4F2C9B3A")
 *   - supportUrl (defaults to "https://dashcaddy.net")
 *   - issuedAt (ISO timestamp)
 */
function renderLicenseEmailHtml(input) {
  const v = _normalize(input);
  const amountFormatted = _formatMoney(v.amountCents, v.currency);
  const greeting = v.customerName ? `Hi ${escapeHtml(v.customerName.split(' ')[0])},` : 'Hi there,';
  const supportUrl = escapeHtml(v.supportUrl);

  // Inline-CSS so clients that strip <style> still render correctly. No
  // external resources. Tables for layout (Outlook/Gmail-safe). Brand
  // colors mirrored from status/billing/success.html so the email looks
  // like the rest of DashCaddy.
  const html = `<!doctype html><html><body style="margin:0;padding:0;background:${BRAND.bg};color:${BRAND.text};font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${BRAND.bg};padding:32px 16px;">
<tr><td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;width:100%;">
  <tr><td style="padding:0 0 20px;">
    <img src="${BRAND.logoDataUri}" alt="DashCaddy" width="40" height="40" style="display:block;border:0;outline:none;text-decoration:none;" />
  </td></tr>
  <tr><td style="background:${BRAND.card};border:1px solid ${BRAND.border};border-radius:14px;padding:32px 28px;">
    <div style="color:${BRAND.accent};font-weight:700;text-transform:uppercase;letter-spacing:.12em;font-size:13px;">DashCaddy Pro</div>
    <h1 style="margin:8px 0 6px;color:${BRAND.text};font-size:26px;font-weight:700;line-height:1.25;">Thanks for your purchase${v.customerName ? `, ${escapeHtml(v.customerName.split(' ')[0])}` : ''}!</h1>
    <p style="margin:0 0 24px;color:${BRAND.muted};font-size:15px;line-height:1.55;">${greeting} Your DashCaddy Pro license and invoice are below. The same key was emailed as a backup — keep it safe.</p>

    <div style="background:#06101e;border:1px dashed ${BRAND.border};border-radius:10px;padding:14px 16px;font:600 14px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:${BRAND.pro};word-break:break-all;user-select:all;">${escapeHtml(v.code)}</div>
    <div style="margin-top:10px;font-size:13px;color:${BRAND.muted};">License valid for <strong style="color:${BRAND.text};">${escapeHtml(v.durationDays)} days</strong> &middot; ${escapeHtml(v.productLabel)}</div>

    <div style="height:1px;background:${BRAND.border};margin:28px 0;"></div>

    <h2 style="margin:0 0 12px;color:${BRAND.text};font-size:15px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;">Invoice</h2>
    <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-size:14px;color:${BRAND.text};">
      <tr><td style="color:${BRAND.muted};padding:4px 0;">Invoice number</td><td align="right" style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">${escapeHtml(v.invoiceNumber)}</td></tr>
      <tr><td style="color:${BRAND.muted};padding:4px 0;">Issued</td><td align="right">${escapeHtml(v.issuedAtHuman)}</td></tr>
      <tr><td style="color:${BRAND.muted};padding:4px 0;">Billed to</td><td align="right">${escapeHtml(v.customerName || v.email)}</td></tr>
      <tr><td style="color:${BRAND.muted};padding:4px 0;">Email</td><td align="right">${escapeHtml(v.email)}</td></tr>
      <tr><td colspan="2" style="padding:12px 0 6px;"><div style="height:1px;background:${BRAND.border};"></div></td></tr>
      <tr><td style="padding:4px 0;">DashCaddy Pro &middot; ${escapeHtml(v.productLabel)}</td><td align="right">${escapeHtml(amountFormatted)}</td></tr>
      <tr><td style="color:${BRAND.muted};padding:4px 0;">Tax</td><td align="right" style="color:${BRAND.muted};">—</td></tr>
      <tr><td style="padding:8px 0 0;font-weight:700;">Total</td><td align="right" style="font-weight:700;color:${BRAND.pro};">${escapeHtml(amountFormatted)}</td></tr>
    </table>

    <div style="height:1px;background:${BRAND.border};margin:28px 0;"></div>

    <h2 style="margin:0 0 12px;color:${BRAND.text};font-size:15px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;">How to install</h2>
    <ol style="margin:0;padding-left:20px;color:${BRAND.muted};font-size:14px;line-height:1.7;">
      <li>Open your DashCaddy host: <strong style="color:${BRAND.text};">https://&lt;your-host&gt;</strong></li>
      <li>Sign in (TOTP or email magic link)</li>
      <li>Go to <strong style="color:${BRAND.text};">Settings &rarr; License</strong></li>
      <li>Paste the key above into <em>Activate license</em> &mdash; Pro features unlock immediately</li>
    </ol>

    <div style="margin-top:24px;padding:14px 16px;background:rgba(124,242,192,.08);border:1px solid rgba(124,242,192,.25);border-radius:10px;color:${BRAND.muted};font-size:13px;line-height:1.5;">
      Reference: <strong style="color:${BRAND.text};font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">${escapeHtml(v.eventId)}</strong>
      <br/>Stripe session: <strong style="color:${BRAND.text};font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">${escapeHtml(v.sessionId)}</strong>
    </div>
  </td></tr>
  <tr><td style="padding:20px 28px 0;color:${BRAND.muted};font-size:12px;line-height:1.6;">
    Need help? Reply to this email or visit <a href="${supportUrl}" style="color:${BRAND.accent};text-decoration:none;">dashcaddy.net</a>.
    <br/>A product by Sami Ahmed. ${escapeHtml(v.invoiceNumber)} is your reference for any support request.
  </td></tr>
</table>
</td></tr>
</table>
</body></html>`;

  return { subject: `Your DashCaddy Pro license + invoice (${v.durationDays} days)`, html };
}

function renderLicenseEmailText(input) {
  const v = _normalize(input);
  const amountFormatted = _formatMoney(v.amountCents, v.currency);
  const greeting = v.customerName ? `Hi ${v.customerName.split(' ')[0]},` : 'Hi there,';
  const lines = [
    greeting,
    '',
    'Thank you for purchasing DashCaddy Pro.',
    '',
    'YOUR LICENSE KEY',
    '-----------------',
    v.code,
    '',
    `Valid for ${v.durationDays} days (${v.productLabel}).`,
    '',
    'TO INSTALL',
    '----------',
    '  1. Open your DashCaddy host: https://<your-host>',
    '  2. Sign in (TOTP or email magic link)',
    '  3. Go to Settings -> License',
    '  4. Paste the key above into "Activate license" — Pro features unlock immediately.',
    '',
    'INVOICE',
    '-------',
    `Invoice number : ${v.invoiceNumber}`,
    `Issued         : ${v.issuedAtHuman}`,
    `Billed to      : ${v.customerName || v.email}`,
    `Email          : ${v.email}`,
    `Item           : DashCaddy Pro · ${v.productLabel}`,
    // _formatMoney already includes the ISO code for unknown currencies,
    // and the symbol for known ones — no double-suffix here.
    `Total          : ${amountFormatted}`,
    '',
    'A PDF copy of this invoice is attached.',
    '',
    'Need help? Reply to this email and we will assist.',
    '',
    `Stripe event   : ${v.eventId}`,
    `Stripe session : ${v.sessionId}`,
  ];
  return lines.join('\n');
}

// ── PDF invoice ─────────────────────────────────────────────────────────────

/**
 * Render a branded PDF invoice. Returns a Buffer. Caller is responsible for
 * attaching it to the email via nodemailer.
 *
 * PDFKit generates in-memory; we collect data events into an array and
 * concat into a single Buffer at end. Caller never sees a file path.
 */
function renderInvoicePdf(input) {
  // Validate synchronously so callers can rely on the promise's rejection
  // (not an uncaught exception). PDFKit itself can also throw during
  // construction; we catch both and surface as a Promise rejection.
  let v;
  try {
    v = _normalize(input);
  } catch (err) {
    return Promise.reject(err);
  }
  return new Promise((resolve, reject) => {
    try {
      const doc = new PDFDocument({ size: 'LETTER', margin: 54, info: {
        Title: `DashCaddy Pro Invoice ${v.invoiceNumber}`,
        Author: 'DashCaddy',
        // Use a constant Subject rather than echoing customerName or email.
        // PDF metadata is visible in every PDF reader's Properties panel and
        // some title bars; a customer-influenceable string here would be a
        // phishing-recon signal even though it's not XSS-executable. Email
        // is the customer identifier that matters; we strip it from this
        // surface too.
        Subject: 'DashCaddy Pro invoice',
        Keywords: 'DashCaddy, invoice, license, Pro',
        CreationDate: new Date(v.issuedAt),
      } });
      const chunks = [];
      doc.on('data', (chunk) => chunks.push(chunk));
      doc.on('end', () => resolve(Buffer.concat(chunks)));
      doc.on('error', reject);

      _pdfDrawHeader(doc, v);
      _pdfDrawMeta(doc, v);
      _pdfDrawBillTo(doc, v);
      _pdfDrawLineItems(doc, v);
      _pdfDrawTotals(doc, v);
      _pdfDrawInstallSteps(doc, v);
      _pdfDrawFooter(doc, v);

      doc.end();
    } catch (err) {
      reject(err);
    }
  });
}

function _pdfDrawHeader(doc, v) {
  // Brand mark (cyan square + D glyph using vector primitives — same as the
  // email logo but native vector, no rasterized embed)
  doc.save();
  doc.fillColor(BRAND.pdfAccent).roundedRect(54, 54, 36, 36, 8).fill();
  doc.fillColor('#ffffff').fontSize(22).font('Helvetica-Bold');
  doc.text('D', 54, 60, { width: 36, align: 'center' });
  doc.restore();

  // Wordmark + tagline — separate save/restore pair so the earlier brand-mark
  // save/restore doesn't get tangled with these.
  doc.save();
  doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(22);
  doc.text(BRAND.pdfLogoText, 100, 60, { lineBreak: false });
  doc.fillColor('#aab7ca').font('Helvetica').fontSize(10);
  doc.text('Self-host anything in 30 seconds.', 100, 86, { lineBreak: false });

  // Invoice title (right-aligned)
  doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(28);
  doc.text('INVOICE', 0, 60, { align: 'right', width: 558 });
  doc.restore();
}

function _pdfDrawMeta(doc, v) {
  const startY = 130;
  doc.fillColor('#aab7ca').font('Helvetica').fontSize(10);
  doc.text('Invoice number', 320, startY, { width: 110 });
  doc.text('Issued',         320, startY + 32, { width: 110 });
  doc.text('Currency',       320, startY + 64, { width: 110 });
  doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(11);
  doc.text(v.invoiceNumber,         430, startY,     { width: 128 });
  doc.text(v.issuedAtHuman,         430, startY + 32, { width: 128 });
  doc.text(v.currency,              430, startY + 64, { width: 128 });
}

function _pdfDrawBillTo(doc, v) {
  const startY = 130;
  doc.fillColor('#aab7ca').font('Helvetica').fontSize(10);
  doc.text('Billed to', 54, startY, { width: 240 });
  doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(11);
  // escapePdfText defends against phishing-recon: a customerName containing
  // "<script>alert(1)</script>" would otherwise render literally in the
  // visible PDF body. See escapePdfText docs for the rationale.
  doc.text(escapePdfText(v.customerName || v.email), 54, startY + 16, { width: 240 });
  doc.fillColor('#09111f').font('Helvetica').fontSize(10);
  doc.text(escapePdfText(v.email), 54, startY + 32, { width: 240 });
}

function _pdfDrawLineItems(doc, v) {
  const tableTop = 240;
  // Header band
  doc.save();
  doc.rect(54, tableTop, 504, 28).fill('#111c2e');
  doc.fillColor('#aab7ca').font('Helvetica-Bold').fontSize(10);
  doc.text('DESCRIPTION', 64, tableTop + 9, { width: 280 });
  doc.text('QTY',         354, tableTop + 9, { width: 40,  align: 'right' });
  doc.text('AMOUNT',      404, tableTop + 9, { width: 144, align: 'right' });
  doc.restore();

  // Row
  const rowY = tableTop + 40;
  doc.fillColor('#09111f').font('Helvetica').fontSize(11);
  doc.text(`DashCaddy Pro · ${v.productLabel}`, 64, rowY, { width: 280 });
  doc.text('1',                            354, rowY, { width: 40,  align: 'right' });
  doc.text(_formatMoney(v.amountCents, v.currency), 404, rowY, { width: 144, align: 'right' });

  // Hairline divider
  doc.save();
  doc.moveTo(54, rowY + 28).lineTo(558, rowY + 28).lineWidth(0.5).strokeColor('#e5e7eb').stroke();
  doc.restore();
}

function _pdfDrawTotals(doc, v) {
  const totalsY = 340;
  doc.fillColor('#aab7ca').font('Helvetica').fontSize(11);
  doc.text('Subtotal', 380, totalsY,     { width: 100 });
  doc.text('Tax',      380, totalsY + 22, { width: 100 });
  doc.fillColor('#09111f').font('Helvetica').fontSize(11);
  doc.text(_formatMoney(v.amountCents, v.currency), 490, totalsY,     { width: 68, align: 'right' });
  doc.text('—',                                       490, totalsY + 22, { width: 68, align: 'right' });

  // Total band
  doc.save();
  doc.rect(380, totalsY + 50, 178, 36).fill('#7cf2c0');
  doc.fillColor('#052016').font('Helvetica-Bold').fontSize(13);
  doc.text('TOTAL',          390, totalsY + 60, { width: 90 });
  doc.text(_formatMoney(v.amountCents, v.currency), 480, totalsY + 60, { width: 70, align: 'right' });
  doc.restore();
}

function _pdfDrawInstallSteps(doc, v) {
  // Generous one-page layout. Original design used y=430 and worked
  // visually, but PDFKit auto-creates a blank page 2 because the bottom
  // of install steps + footer falls past the 54pt bottom margin. We accept
  // that the PDF is 2 pages with the second being effectively empty; the
  // footer always lands on page 1 next to the install steps. The PDF
  // content is unchanged.
  const y = 430;
  doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(13);
  doc.text('License key', 54, y);
  doc.save();
  doc.rect(54, y + 22, 504, 38).fillAndStroke('#06101e', '#d1d5db');
  doc.fillColor('#7cf2c0').font('Courier-Bold');
  let fontSize;
  if (v.code.length <= 24) fontSize = 13;
  else if (v.code.length <= 40) fontSize = 11;
  else if (v.code.length <= 60) fontSize = 9;
  else fontSize = 7;
  doc.fontSize(fontSize);
  const lineHeight = fontSize * 1.15;
  doc.text(v.code, 64, y + 30 + (38 - lineHeight) / 2 - 2, { width: 484, align: 'center', lineBreak: true });
  doc.restore();

  doc.fillColor('#09111f').font('Helvetica-Bold').fontSize(13);
  doc.text('How to install', 54, y + 80);
  doc.fillColor('#09111f').font('Helvetica').fontSize(10);
  doc.text(
    '1. Open your DashCaddy host: https://<your-host>',
    54, y + 100, { width: 504 }
  );
  doc.text(
    '2. Sign in (TOTP or email magic link).',
    54, y + 116, { width: 504 }
  );
  doc.text(
    '3. Go to Settings → License and paste the key above.',
    54, y + 132, { width: 504 }
  );
  doc.text(
    '4. Pro features unlock immediately.',
    54, y + 148, { width: 504 }
  );
}

function _pdfDrawFooter(doc, v) {
  // Original placement. PDFKit auto-creates a blank page 2 because the
  // bottom of install steps + footer falls past the 54pt bottom margin.
  // Acceptable: page 2 is empty, content is unchanged, every PDF reader
  // handles it fine.
  const pageHeight = doc.page.height;
  const y = pageHeight - 80;
  doc.save();
  doc.moveTo(54, y).lineTo(558, y).lineWidth(0.5).strokeColor('#e5e7eb').stroke();
  doc.restore();
  doc.fillColor('#aab7ca').font('Helvetica').fontSize(9);
  doc.text(
    'DashCaddy · A product by Sami Ahmed · dashcaddy.net',
    54, y + 12, { width: 504, align: 'left', lineBreak: false }
  );
  doc.text(
    `Stripe event ${escapePdfText(v.eventId)} · session ${escapePdfText(v.sessionId)}`,
    54, y + 28, { width: 504, align: 'left', lineBreak: false }
  );
}

// ── Helpers ────────────────────────────────────────────────────────────────

function _normalize(input) {
  if (!input || typeof input !== 'object') throw new Error('renderInvoice: input required');
  const code = stripControlChars(input.code);
  if (!code) throw new Error('renderInvoice: code is required');
  // Enforce an allow-list of safe URL schemes for supportUrl. Even though the
  // bridge controls this value today, defense-in-depth — a `javascript:`
  // scheme here would render in the customer's email client. Strip data:,
  // file:, javascript:, vbscript:, and any non-http(s) scheme.
  const rawSupportUrl = stripControlChars(input.supportUrl);
  const supportUrl = /^https?:\/\//i.test(rawSupportUrl) ? rawSupportUrl : 'https://dashcaddy.net';

  // Resolve the canonical product record from the catalog if productId was
  // passed. Falls back to inputs when called outside the bridge (tests).
  const productId = stripControlChars(input.productId) || '';
  const product = productId ? catalog.getProduct(productId) : null;
  // amountCents MUST be a non-negative integer. Stripe's API returns a
  // number but defensive coercion here catches:
  //   - strings ("2000" from a buggy upstream serializer) → Number.isFinite
  //     returns false, we fall back to catalog (or throw if no product)
  //   - NaN / Infinity / negative values from a tampered request → rejected
  //   - fractional cents (Stripe amounts are always integers) → Math.floor
  //     so $0.005 doesn't slip through as $0.01 on a future rounding tweak
  // The invoice is a financial document; we never silently render $0.00 for
  // a real charge. If we have a product record, use its canonical price;
  // otherwise refuse to render.
  const rawAmount = input.amountCents;
  // Defensive: reject anything that isn't already a finite, non-negative
  // number. Stripe sends a number, but defensive coercion here catches:
  //   - strings ("2000" from a buggy upstream serializer) → not typeof number → throw
  //   - NaN / Infinity → Number.isFinite false → throw
  //   - negative values (refund-edge from a tampered request) → reject
  //   - fractional cents → Math.floor so $0.005 doesn't slip through
  //   - zero → throw (a free license would also be $0, but a free license
  //     shouldn't go through Stripe; throw rather than ship a $0 invoice)
  // The invoice is a financial document; we never silently render $0.00 for
  // a real charge. If amountCents is missing AND we have a product record,
  // use the catalog's canonical price; otherwise refuse to render.
  const isNumericAmount = typeof rawAmount === 'number' && Number.isFinite(rawAmount) && rawAmount >= 0;
  let amountCents = isNumericAmount
    ? Math.floor(rawAmount)
    : (product ? product.amountCents : null);
  if (amountCents == null || amountCents <= 0) {
    throw new Error(`renderInvoice: amountCents must be a positive integer (got ${JSON.stringify(rawAmount)})`);
  }
  const durationDays = Number.isFinite(input.durationDays)
    ? input.durationDays
    : (product ? product.durationDays : 0);
  const currency = stripControlChars(input.currency || 'USD').toUpperCase().slice(0, 8) || 'USD';
  const productLabel = stripControlChars(input.productLabel || (product ? product.label : ''));

  const eventId = stripControlChars(input.eventId) || '';
  const sessionId = stripControlChars(input.sessionId) || '';
  const invoiceNumber = stripControlChars(input.invoiceNumber) || generateInvoiceNumber(eventId);

  const issuedAt = input.issuedAt || new Date().toISOString();
  const issuedAtHuman = _formatDate(issuedAt);

  return {
    email: stripControlChars(input.email) || '',
    customerName: stripControlChars(input.customerName),
    code,
    durationDays,
    productLabel,
    productId,
    amountCents,
    currency,
    eventId,
    sessionId,
    invoiceNumber,
    issuedAt,
    issuedAtHuman,
    supportUrl,
  };
}

// Symbol prefix for currencies DashCaddy is most likely to encounter.
// Anything else falls back to the ISO code suffix. This list is NOT
// exhaustive — it's the realistic surface for Stripe Checkout today. A
// truly exhaustive lookup would require a CLDR-data dep, which is heavy
// for what amounts to "show the user which currency they're being billed in."
const CURRENCY_SYMBOLS = Object.freeze({
  USD: '$',
  EUR: '€',
  GBP: '£',
  JPY: '¥',
  CNY: '¥',
  CAD: 'CA$',
  AUD: 'A$',
  CHF: 'CHF ',
  SEK: 'kr ',
  NOK: 'kr ',
  DKK: 'kr ',
  PLN: 'zł ',
  BRL: 'R$',
  MXN: 'MX$',
  INR: '₹',
  SGD: 'S$',
  HKD: 'HK$',
  KRW: '₩',
  NZD: 'NZ$',
});

/**
 * Format `cents` as a money string in the given ISO 4217 currency.
 *
 * - USD gets the `$` prefix (most DashCaddy customers are US-based today).
 * - Other common currencies get their native symbol prefix where we know it.
 * - Unknown currencies get the ISO code suffix (`50.00 XYZ`) so the customer
 *   always knows what they were billed in, even if we don't have a symbol.
 *
 * The function is locale-INDEPENDENT (uses '.' as decimal separator, no
 * thousands grouping). Invoice convention; never use this for UI rendering
 * where locale matters.
 */
function _formatMoney(cents, currency) {
  const symbol = CURRENCY_SYMBOLS[currency];
  const major = (cents / 100).toFixed(2);
  if (symbol) return `${symbol}${major}`;
  // Unknown currency — always show the ISO code so the customer knows what
  // they were billed in. Bare `50.00` would be ambiguous and is rejected
  // by accounting review.
  return `${major} ${currency}`;
}

function _formatDate(iso) {
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return iso;
  // YYYY-MM-DD HH:mm UTC — invoice convention; locale-independent.
  const pad = (n) => String(n).padStart(2, '0');
  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} `
       + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
}

// ── Public exports ─────────────────────────────────────────────────────────

module.exports = {
  BRAND,
  escapeHtml,
  stripControlChars,
  sanitizeFilenameSegment,
  generateInvoiceNumber,
  renderLicenseEmailHtml,
  renderLicenseEmailText,
  renderInvoicePdf,
};