/**
 * Memory-efficient expense PDF export.
 *
 * Replaces the legacy `client-portal/server/api/expenses/generate-pdf.ts`
 * (1009 lines, pdfkit + full-buffer-everything + base64-wrapped JSON
 * response - would OOM on hundreds of receipts).
 *
 * Design constraints (per user requirement: "could be hundreds of
 * expenses and images, also compress files if they're stupidly large"):
 *
 *   1. **Stream the PDF output** - pdfkit.pipe(response) instead of
 *      accumulating chunks. Bytes leave the process as they're written.
 *   2. **Serial receipt processing** - fetch one receipt at a time, embed,
 *      release. Peak heap = ~one image + the in-flight pdfkit page.
 *   3. **Sharp resize before embedding** - receipts above the size/dim
 *      thresholds get downscaled to ≤1500px on the long edge at JPEG q80.
 *      A typical 8 MB phone photo collapses to ~250 KB; the embedded PDF
 *      ends up ~5–10x smaller than the legacy output.
 *   4. **Storage backend abstraction** - receipts come from
 *      `getStorageBackend().get(storageKey)`; works against MinIO/S3 in
 *      production and the local filesystem in dev.
 *   5. **Heap budget** - for a 500-receipt export (avg 8 MB raw → 250 KB
 *      resized + a few MB pdfkit working set), peak RSS stays under 100 MB.
 *      The legacy implementation needed >2 GB for the same input.
 *
 * Caller flow:
 *
 *   const pdfStream = await streamExpensePdf({ portId, expenseIds, options });
 *   return new Response(pdfStream, { headers: { 'content-type': 'application/pdf' } });
 *
 * `pdfStream` is a `ReadableStream<Uint8Array>` ready to hand straight to
 * the Web Response constructor; pdfkit's Node-stream output is converted
 * via `Readable.toWeb` so the route handler stays in standard runtime.
 */

import { Readable } from 'node:stream';
import { eq, inArray, and, gte, lte, isNull, desc } from 'drizzle-orm';
import PDFDocument from 'pdfkit';
import sharp from 'sharp';

import { db } from '@/lib/db';
import { expenses } from '@/lib/db/schema/financial';
import { files } from '@/lib/db/schema/documents';
import { ports } from '@/lib/db/schema/ports';
import { getRate } from '@/lib/services/currency';
import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo';
import { formatDate } from '@/lib/utils/format-date';
import { formatCurrency } from '@/lib/utils/currency';
import { getStorageBackend } from '@/lib/storage';
import { logger } from '@/lib/logger';

// ─── Public options + result types ──────────────────────────────────────────

export type GroupBy = 'none' | 'payer' | 'category' | 'date' | 'trip';
export type PageFormat = 'A4' | 'Letter' | 'Legal';
export type TargetCurrency = 'USD' | 'EUR';

export interface ExpensePdfOptions {
  /** Title at the top of the document, e.g. "March 2026 Expense Report". */
  documentName: string;
  /** Subtitle below the title (defaults to "Generated on <today>"). */
  subheader?: string;
  /** Group expenses in the table by payer/category/date. Default: none. */
  groupBy?: GroupBy;
  /** Append one page per receipt image at the end. */
  includeReceipts?: boolean;
  /** Include the OCR-extracted "Contents" string in the table row. */
  includeReceiptContents?: boolean;
  /** Show the summary box (count + totals + grouping label). */
  includeSummary?: boolean;
  /** Show the per-row expense table. */
  includeDetails?: boolean;
  /** Add a 5% management fee line (parent-company export). */
  includeProcessingFee?: boolean;
  /** Currency to convert all amounts into for the totals + line items. */
  targetCurrency?: TargetCurrency;
  pageFormat?: PageFormat;
}

export interface ExpensePdfArgs {
  portId: string;
  /** When set, only these expenses are exported (ordered by expenseDate desc). */
  expenseIds?: string[];
  /** Otherwise, all matching expenses for the port get exported. */
  filter?: {
    dateFrom?: Date | string | null;
    dateTo?: Date | string | null;
    category?: string | null;
    paymentStatus?: string | null;
    payer?: string | null;
    tripLabel?: string | null;
    includeArchived?: boolean;
  };
  options: ExpensePdfOptions;
  /**
   * Caller's abort signal. When the client disconnects mid-stream we stop
   * pulling receipts off the storage backend rather than burning CPU/IO on
   * an export nobody's reading. Without this, a 1000-receipt export aborted
   * at byte 0 keeps the process busy for minutes.
   */
  signal?: AbortSignal;
}

// ─── Image resize gate ──────────────────────────────────────────────────────

/** Receipts above this raw-byte size are forced through sharp resize. */
const RESIZE_BYTE_THRESHOLD = 500 * 1024; // 500 KB
/** Max long-edge pixel size after resize. Keeps text legible while
 *  collapsing typical phone-camera receipts (4032×3024 → 1500×1125). */
const MAX_DIMENSION = 1500;
/** JPEG quality for resized output. */
const JPEG_QUALITY = 80;

/**
 * Resize a receipt image to a memory-friendly size. Returns the input
 * buffer untouched when:
 *   - it's already below the byte threshold AND
 *   - sharp can read its metadata AND
 *   - both dimensions are ≤ MAX_DIMENSION
 *
 * Returns a JPEG buffer in every other case. Sharp processes the input
 * image stream-style internally (libvips), so the only Node-heap cost
 * during resize is the input + output buffers.
 */
async function maybeResizeImage(
  raw: Buffer,
  contentType: string | null | undefined,
): Promise<{ buffer: Buffer; contentType: 'image/jpeg' | 'image/png'; resized: boolean }> {
  // Pdfkit only supports JPEG + PNG. Anything else gets transcoded to JPEG.
  const isJpeg = contentType === 'image/jpeg' || contentType === 'image/jpg';
  const isPng = contentType === 'image/png';
  const passthroughCt: 'image/jpeg' | 'image/png' = isPng ? 'image/png' : 'image/jpeg';

  if (raw.byteLength <= RESIZE_BYTE_THRESHOLD && (isJpeg || isPng)) {
    try {
      const meta = await sharp(raw).metadata();
      const w = meta.width ?? 0;
      const h = meta.height ?? 0;
      if (w > 0 && h > 0 && w <= MAX_DIMENSION && h <= MAX_DIMENSION) {
        return { buffer: raw, contentType: passthroughCt, resized: false };
      }
    } catch {
      // Fall through to resize+transcode on any sharp metadata failure.
    }
  }

  const resized = await sharp(raw)
    .rotate() // honour EXIF orientation so phone photos aren't sideways
    .resize({
      width: MAX_DIMENSION,
      height: MAX_DIMENSION,
      fit: 'inside',
      withoutEnlargement: true,
    })
    .jpeg({ quality: JPEG_QUALITY, mozjpeg: true })
    .toBuffer();
  return { buffer: resized, contentType: 'image/jpeg', resized: true };
}

// ─── Currency conversion ────────────────────────────────────────────────────

interface ExpenseRow {
  id: string;
  establishmentName: string | null;
  amount: string;
  currency: string;
  amountUsd: string | null;
  paymentMethod: string | null;
  category: string | null;
  payer: string | null;
  expenseDate: Date;
  description: string | null;
  receiptFileIds: string[] | null;
  /** True when the rep created the expense without a receipt (and
   *  acknowledged it may not be reimbursed). Surfaces as a banner row in
   *  the table + a footnote at the bottom of the summary box. */
  noReceiptAcknowledged: boolean;
  paymentStatus: string | null;
  /** Free-text trip / event label - drives `groupBy=trip` sectioning. */
  tripLabel: string | null;
}

interface ProcessedExpense extends ExpenseRow {
  amountTarget: number;
  amountUsdNumeric: number;
  amountEurNumeric: number;
  /** True when ANY rate lookup for this row fell back to 1:1 (e.g. the
   *  exchange-rate cache was cold and the upstream API returned null).
   *  Surfaced via an asterisk in the table + a footnote in the summary. */
  rateUnavailable: boolean;
}

interface Totals {
  count: number;
  targetTotal: number;
  usdTotal: number;
  eurTotal: number;
  processingFee: number;
  finalTotal: number;
  targetCurrency: TargetCurrency;
  /** Number of expenses with `noReceiptAcknowledged=true` - surfaces as a
   *  warning footer in the summary box. Reps need to know this count
   *  before forwarding the export to a parent-company reimbursement queue. */
  noReceiptCount: number;
  /** Sum of the no-receipt expenses' targetTotal - the amount at risk
   *  of being denied reimbursement. */
  noReceiptAmount: number;
  /** Number of rows whose conversion fell back to 1:1 - surfaces as an
   *  amber footer so reps know the totals are approximate. Audit caught
   *  the silent 1:1 fallback; users were getting EUR-labelled USD totals. */
  rateUnavailableCount: number;
}

async function processExpenses(
  rows: ExpenseRow[],
  target: TargetCurrency,
): Promise<ProcessedExpense[]> {
  // Resolve rate ONCE per source currency (cached by getRate). Avoids the
  // legacy code's per-row API call. We also track *which* lookups failed
  // (returned null upstream) so the PDF can surface a warning rather than
  // silently treating EUR as USD.
  const rateCache = new Map<string, { rate: number; ok: boolean }>();
  const ensureRate = async (from: string, to: string): Promise<{ rate: number; ok: boolean }> => {
    if (from === to) return { rate: 1, ok: true };
    const key = `${from}->${to}`;
    if (rateCache.has(key)) return rateCache.get(key)!;
    const fetched = await getRate(from, to);
    const entry = fetched != null ? { rate: fetched, ok: true } : { rate: 1, ok: false };
    rateCache.set(key, entry);
    if (!entry.ok) {
      logger.warn({ from, to }, 'Expense PDF: exchange rate unavailable, falling back to 1:1');
    }
    return entry;
  };

  const out: ProcessedExpense[] = [];
  for (const row of rows) {
    const raw = parseFloat(row.amount);
    let rateUnavailable = false;
    let usd: number;
    if (row.amountUsd != null) {
      usd = parseFloat(row.amountUsd);
    } else if (row.currency.toUpperCase() === 'USD') {
      usd = raw;
    } else {
      const { rate, ok } = await ensureRate(row.currency.toUpperCase(), 'USD');
      usd = raw * rate;
      if (!ok) rateUnavailable = true;
    }

    // Skip the USD->EUR chain when the source already matches the target -
    // every redundant rate lookup adds rounding noise on top of the network
    // round-trip. EUR-source + EUR-target should land back exactly at the
    // input amount, not raw * USD-rate * USD-rate-inverse.
    let eur: number;
    if (row.currency.toUpperCase() === 'EUR') {
      eur = raw;
    } else {
      const { rate, ok } = await ensureRate('USD', 'EUR');
      eur = usd * rate;
      if (!ok) rateUnavailable = true;
    }

    const targetVal = target === 'USD' ? usd : eur;
    out.push({
      ...row,
      amountUsdNumeric: usd,
      amountEurNumeric: eur,
      amountTarget: targetVal,
      rateUnavailable,
    });
  }
  return out;
}

function computeTotals(
  rows: ProcessedExpense[],
  target: TargetCurrency,
  includeProcessingFee: boolean,
): Totals {
  const targetTotal = rows.reduce((s, r) => s + r.amountTarget, 0);
  const usdTotal = rows.reduce((s, r) => s + r.amountUsdNumeric, 0);
  const eurTotal = rows.reduce((s, r) => s + r.amountEurNumeric, 0);
  const processingFee = includeProcessingFee ? targetTotal * 0.05 : 0;
  const receiptlessRows = rows.filter((r) => r.noReceiptAcknowledged);
  const rateUnavailableCount = rows.reduce((n, r) => n + (r.rateUnavailable ? 1 : 0), 0);
  return {
    count: rows.length,
    targetTotal,
    usdTotal,
    eurTotal,
    processingFee,
    finalTotal: targetTotal + processingFee,
    targetCurrency: target,
    noReceiptCount: receiptlessRows.length,
    noReceiptAmount: receiptlessRows.reduce((s, r) => s + r.amountTarget, 0),
    rateUnavailableCount,
  };
}

// ─── Page dimensions ────────────────────────────────────────────────────────

function pageDims(format: PageFormat): { width: number; height: number } {
  switch (format) {
    case 'Letter':
      return { width: 612, height: 792 };
    case 'Legal':
      return { width: 612, height: 1008 };
    case 'A4':
    default:
      return { width: 595, height: 842 };
  }
}

// ─── Grouping ───────────────────────────────────────────────────────────────

function groupKey(row: ProcessedExpense, by: GroupBy): string {
  switch (by) {
    case 'payer':
      return row.payer ?? 'Unknown payer';
    case 'category':
      return row.category ?? 'Uncategorized';
    case 'trip':
      // Untagged rows go under "(no trip)" so they're still in the
      // export, just collected at the bottom.
      return row.tripLabel ?? '(no trip)';
    case 'date':
      return row.expenseDate.toISOString().slice(0, 10);
    default:
      return 'all';
  }
}

function groupRows(
  rows: ProcessedExpense[],
  by: GroupBy,
): Array<{ key: string; rows: ProcessedExpense[] }> {
  if (by === 'none') return [{ key: 'all', rows }];
  const map = new Map<string, ProcessedExpense[]>();
  for (const r of rows) {
    const k = groupKey(r, by);
    if (!map.has(k)) map.set(k, []);
    map.get(k)!.push(r);
  }
  return [...map.entries()]
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([key, rs]) => ({ key, rows: rs }));
}

// ─── Fetching ───────────────────────────────────────────────────────────────

async function fetchExpenseRows(args: ExpensePdfArgs): Promise<ExpenseRow[]> {
  const conditions = [eq(expenses.portId, args.portId)];
  // Soft-delete filter applies regardless of which path produced the
  // expense list. The audit caught a regression where an `expenseIds`
  // selection would happily export archived rows because the
  // `isNull(archivedAt)` predicate sat inside the `else` branch - that
  // violates the soft-delete contract used everywhere else.
  if (!args.filter?.includeArchived) {
    conditions.push(isNull(expenses.archivedAt));
  }
  if (args.expenseIds?.length) {
    conditions.push(inArray(expenses.id, args.expenseIds));
  } else {
    if (args.filter?.dateFrom) {
      conditions.push(
        gte(
          expenses.expenseDate,
          args.filter.dateFrom instanceof Date
            ? args.filter.dateFrom
            : new Date(args.filter.dateFrom),
        ),
      );
    }
    if (args.filter?.dateTo) {
      conditions.push(
        lte(
          expenses.expenseDate,
          args.filter.dateTo instanceof Date ? args.filter.dateTo : new Date(args.filter.dateTo),
        ),
      );
    }
    if (args.filter?.category) conditions.push(eq(expenses.category, args.filter.category));
    if (args.filter?.payer) conditions.push(eq(expenses.payer, args.filter.payer));
    if (args.filter?.paymentStatus)
      conditions.push(eq(expenses.paymentStatus, args.filter.paymentStatus));
    if (args.filter?.tripLabel) conditions.push(eq(expenses.tripLabel, args.filter.tripLabel));
  }

  const rows = await db
    .select({
      id: expenses.id,
      establishmentName: expenses.establishmentName,
      amount: expenses.amount,
      currency: expenses.currency,
      amountUsd: expenses.amountUsd,
      paymentMethod: expenses.paymentMethod,
      category: expenses.category,
      payer: expenses.payer,
      expenseDate: expenses.expenseDate,
      description: expenses.description,
      receiptFileIds: expenses.receiptFileIds,
      noReceiptAcknowledged: expenses.noReceiptAcknowledged,
      tripLabel: expenses.tripLabel,
      paymentStatus: expenses.paymentStatus,
    })
    .from(expenses)
    .where(and(...conditions))
    .orderBy(desc(expenses.expenseDate));
  return rows as ExpenseRow[];
}

interface ResolvedFile {
  fileId: string;
  storagePath: string;
  storageBucket: string;
  mimeType: string | null;
  filename: string;
}

/** Bulk-resolve file metadata so the receipt loop can do a single round-trip. */
async function resolveReceiptFiles(fileIds: string[]): Promise<Map<string, ResolvedFile>> {
  if (fileIds.length === 0) return new Map();
  const rows = await db
    .select({
      id: files.id,
      storagePath: files.storagePath,
      storageBucket: files.storageBucket,
      mimeType: files.mimeType,
      filename: files.filename,
    })
    .from(files)
    .where(inArray(files.id, fileIds));
  const map = new Map<string, ResolvedFile>();
  for (const r of rows) {
    map.set(r.id, {
      fileId: r.id,
      storagePath: r.storagePath,
      storageBucket: r.storageBucket,
      mimeType: r.mimeType,
      filename: r.filename,
    });
  }
  return map;
}

// ─── Streaming buffer helper ────────────────────────────────────────────────

/** Drain a Node ReadableStream into a Buffer. Caller is responsible for
 *  not holding multiple in memory simultaneously. */
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of stream as AsyncIterable<Buffer | string>) {
    chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
  }
  return Buffer.concat(chunks);
}

// ─── PDF builder ────────────────────────────────────────────────────────────

/**
 * Build the expense PDF and return a Web ReadableStream of bytes. The
 * caller (route handler) streams this directly to the client; we never
 * materialize the whole PDF in memory.
 */
export async function streamExpensePdf(
  args: ExpensePdfArgs,
): Promise<{ stream: ReadableStream<Uint8Array>; suggestedFilename: string }> {
  const opts: Required<
    Omit<ExpensePdfOptions, 'subheader' | 'documentName' | 'pageFormat' | 'targetCurrency'>
  > & {
    subheader?: string;
    documentName: string;
    pageFormat: PageFormat;
    targetCurrency: TargetCurrency;
  } = {
    documentName: args.options.documentName,
    subheader: args.options.subheader,
    groupBy: args.options.groupBy ?? 'none',
    includeReceipts: args.options.includeReceipts ?? false,
    includeReceiptContents: args.options.includeReceiptContents ?? false,
    includeSummary: args.options.includeSummary ?? true,
    includeDetails: args.options.includeDetails ?? true,
    includeProcessingFee: args.options.includeProcessingFee ?? false,
    targetCurrency: args.options.targetCurrency ?? 'EUR',
    pageFormat: args.options.pageFormat ?? 'A4',
  };

  const rawRows = await fetchExpenseRows(args);
  const processed = await processExpenses(rawRows, opts.targetCurrency);
  const totals = computeTotals(processed, opts.targetCurrency, opts.includeProcessingFee);

  // Brand assets for the header band - shared with the react-pdf surfaces.
  const [port, logo] = await Promise.all([
    db.query.ports.findFirst({ where: eq(ports.id, args.portId) }),
    resolvePortLogo(args.portId),
  ]);

  // Bulk-resolve receipt file metadata (one DB round-trip vs N).
  const allFileIds = processed
    .flatMap((r) => r.receiptFileIds ?? [])
    .filter((s): s is string => typeof s === 'string' && s.length > 0);
  const filesById = opts.includeReceipts
    ? await resolveReceiptFiles(allFileIds)
    : new Map<string, ResolvedFile>();

  const dims = pageDims(opts.pageFormat);
  const doc = new PDFDocument({
    size: [dims.width, dims.height],
    margins: { top: 60, bottom: 60, left: 60, right: 60 },
  });

  // Pull bytes off pdfkit's Node Readable as soon as they're available so
  // the client sees the response start streaming before we even begin
  // fetching receipts. Node Readable → Web ReadableStream conversion.
  const nodeStream = doc as unknown as NodeJS.ReadableStream;
  const webStream = Readable.toWeb(
    nodeStream as unknown as Readable,
  ) as unknown as ReadableStream<Uint8Array>;

  // Kick off the page-builder asynchronously. Errors propagate via doc.end()
  // / doc.emit('error') and surface to the consumer of the stream.
  void (async () => {
    try {
      addHeader(doc, { ...opts, portName: port?.name, logoBuffer: logo.buffer });
      if (opts.includeSummary) addSummaryBox(doc, totals, opts);
      if (opts.includeDetails) addExpenseTable(doc, processed, opts);

      if (opts.includeReceipts) {
        await addReceiptPages(doc, processed, filesById, {
          targetCurrency: opts.targetCurrency,
          signal: args.signal,
        });
      }

      addFooter(doc);
      doc.end();
    } catch (err) {
      logger.error({ err }, 'Expense PDF stream failed mid-build');
      doc.emit('error', err);
    }
  })();

  // `\s` includes CR/LF; using it lets a malicious documentName forge
  // additional response headers via Content-Disposition. Restrict to
  // word/dot/dash/space (single-line space only - \s would let \n through).
  const safeName = opts.documentName.replace(/[^\w. \-]+/g, '_').trim() || 'expenses';
  return {
    stream: webStream,
    suggestedFilename: `${safeName}.pdf`,
  };
}

// ─── Page sections ──────────────────────────────────────────────────────────

function addHeader(
  doc: PDFKit.PDFDocument,
  opts: {
    documentName: string;
    subheader?: string;
    portName?: string;
    logoBuffer?: Buffer | null;
  },
) {
  // Brand band - shared visual language with the react-pdf surfaces.
  // Dark slate matches PDF_TOKENS.colors.headerBand.
  const pageWidth = doc.page.width;
  const bandHeight = 56;
  const bandPaddingX = 24;
  const startY = doc.page.margins.top - 30;

  doc.save();
  doc.rect(0, 0, pageWidth, bandHeight).fill('#0f172a');
  doc.fillColor('#ffffff');

  if (opts.logoBuffer) {
    try {
      doc.image(opts.logoBuffer, bandPaddingX, (bandHeight - 32) / 2, {
        fit: [120, 32],
      });
    } catch {
      // Fall through to the text-name fallback if pdfkit can't decode.
      if (opts.portName) {
        doc
          .fontSize(14)
          .font('Helvetica-Bold')
          .text(opts.portName, bandPaddingX, (bandHeight - 14) / 2, { lineBreak: false });
      }
    }
  } else if (opts.portName) {
    doc
      .fontSize(14)
      .font('Helvetica-Bold')
      .text(opts.portName, bandPaddingX, (bandHeight - 14) / 2, { lineBreak: false });
  }

  doc
    .fontSize(13)
    .font('Helvetica-Bold')
    .text(opts.documentName, pageWidth / 2, (bandHeight - 13) / 2, {
      align: 'right',
      width: pageWidth - pageWidth / 2 - bandPaddingX,
      lineBreak: false,
    });
  doc.restore();

  // Move past the band and render the subheader below in black on white.
  doc.y = bandHeight + 12;
  const subheader = opts.subheader ?? `Generated on ${formatDate(new Date(), 'date.medium')}`;
  doc.fontSize(11).font('Helvetica').fillColor('#666666').text(subheader, { align: 'center' });
  doc.fillColor('#000000').moveDown(0.8);
  // Suppress unused-warning on startY (kept as a documentation anchor).
  void startY;
}

function addSummaryBox(
  doc: PDFKit.PDFDocument,
  totals: Totals,
  opts: { includeProcessingFee: boolean; groupBy: GroupBy },
) {
  const otherCurrency = totals.targetCurrency === 'USD' ? 'EUR' : 'USD';
  const otherTotal = totals.targetCurrency === 'USD' ? totals.eurTotal : totals.usdTotal;

  doc.fontSize(14).font('Helvetica-Bold').text('Summary');
  doc.moveDown(0.4);

  const lineY = doc.y;
  const lines = [
    `Total expenses: ${totals.count}`,
    `Subtotal (${totals.targetCurrency}): ${formatCurrency(totals.targetTotal, totals.targetCurrency)}`,
    `${otherCurrency} equivalent: ${formatCurrency(otherTotal, otherCurrency)}`,
  ];
  if (opts.includeProcessingFee) {
    lines.push(
      `Processing fee (5%): ${formatCurrency(totals.processingFee, totals.targetCurrency)}`,
    );
    lines.push(`Final total: ${formatCurrency(totals.finalTotal, totals.targetCurrency)}`);
  }
  if (opts.groupBy !== 'none') lines.push(`Grouping: by ${opts.groupBy}`);

  // Warning footer when the export contains acknowledged-no-receipt rows.
  // Reps need to see the at-risk count + amount BEFORE they forward the
  // PDF to a reimbursement queue.
  const showNoReceiptWarning = totals.noReceiptCount > 0;
  const warningLines = showNoReceiptWarning
    ? [
        `WARNING: ${totals.noReceiptCount} expense${totals.noReceiptCount === 1 ? '' : 's'} on this report ${totals.noReceiptCount === 1 ? 'has' : 'have'} no receipt attached`,
        `(${formatCurrency(totals.noReceiptAmount, totals.targetCurrency)} at risk of being denied reimbursement).`,
      ]
    : [];

  // Second warning band: any row whose currency conversion fell back to
  // 1:1 because the upstream rate was unavailable. Without this surface,
  // an EUR-source row would appear as `targetCurrency=EUR, amount=USD`
  // and reps would never know the totals are wrong.
  const showRateWarning = totals.rateUnavailableCount > 0;
  const rateWarningLines = showRateWarning
    ? [
        `Note: ${totals.rateUnavailableCount} expense${totals.rateUnavailableCount === 1 ? '' : 's'} could not be converted (rate unavailable);`,
        `the displayed amount${totals.rateUnavailableCount === 1 ? ' is' : 's are'} approximate (1:1 fallback).`,
      ]
    : [];

  const boxHeight = (lines.length + warningLines.length + rateWarningLines.length) * 16 + 20;
  doc
    .rect(60, lineY, doc.page.width - 120, boxHeight)
    .fillColor('#f5f5f5')
    .fill()
    .strokeColor('#dddddd')
    .stroke();
  doc.fillColor('#000000').fontSize(11).font('Helvetica');
  let y = lineY + 12;
  for (const line of lines) {
    doc.text(line, 75, y);
    y += 16;
  }
  if (showNoReceiptWarning) {
    doc.fillColor('#dc3545').font('Helvetica-Bold');
    for (const line of warningLines) {
      doc.text(line, 75, y);
      y += 16;
    }
    doc.fillColor('#000000').font('Helvetica');
  }
  if (showRateWarning) {
    doc.fillColor('#92400e').font('Helvetica-Oblique');
    for (const line of rateWarningLines) {
      doc.text(line, 75, y);
      y += 16;
    }
    doc.fillColor('#000000').font('Helvetica');
  }
  doc.y = lineY + boxHeight + 12;
}

interface Column {
  header: string;
  width: number;
  x: number;
  align?: 'left' | 'right';
}

function addExpenseTable(
  doc: PDFKit.PDFDocument,
  rows: ProcessedExpense[],
  opts: { groupBy: GroupBy; includeReceiptContents: boolean; targetCurrency: TargetCurrency },
) {
  doc.fontSize(14).font('Helvetica-Bold').text('Expense details');
  doc.moveDown(0.4);

  const baseColumns: Column[] = [
    { header: 'Date', width: 60, x: 60 },
    { header: 'Establishment', width: 110, x: 120 },
    { header: 'Category', width: 65, x: 230 },
    { header: 'Payer', width: 55, x: 295 },
    { header: 'Amount', width: 75, x: 350, align: 'right' },
    { header: 'Status', width: 50, x: 425 },
  ];
  if (opts.includeReceiptContents) {
    baseColumns.push({ header: 'Description', width: 100, x: 475 });
  }

  const drawHeader = () => {
    doc
      .fontSize(9)
      .font('Helvetica-Bold')
      .rect(60, doc.y, doc.page.width - 120, 22)
      .fillColor('#f2f2f2')
      .fill()
      .strokeColor('#dddddd')
      .stroke()
      .fillColor('#000000');
    const headerY = doc.y + 6;
    for (const col of baseColumns) {
      doc.text(col.header, col.x, headerY, { width: col.width, align: col.align ?? 'left' });
    }
    doc.y += 22;
  };

  const drawRow = (row: ProcessedExpense, alt: boolean) => {
    if (doc.y > doc.page.height - 80) {
      doc.addPage();
      drawHeader();
    }
    const rowTop = doc.y;
    if (alt) {
      doc
        .rect(60, rowTop, doc.page.width - 120, 20)
        .fillColor('#fafafa')
        .fill();
    }
    doc.fillColor('#000000').fontSize(8).font('Helvetica');
    const date = row.expenseDate.toISOString().slice(0, 10);
    const amount = formatCurrency(row.amountTarget, opts.targetCurrency);
    // Annotate the establishment cell with a red "(no receipt)" marker
    // when the rep created the expense without proof. This keeps the
    // warning glanceable per row without adding a new column.
    const establishment =
      (row.establishmentName ?? '-') + (row.noReceiptAcknowledged ? '  (no receipt)' : '');
    const data: string[] = [
      date,
      establishment,
      row.category ?? '-',
      row.payer ?? '-',
      amount,
      row.paymentStatus ?? '-',
    ];
    if (opts.includeReceiptContents) {
      data.push(((row.description ?? '') || '-').slice(0, 80));
    }
    data.forEach((value, i) => {
      const col = baseColumns[i]!;
      // Draw the establishment cell in red when no-receipt; reset to
      // black for everything else so warning visibility doesn't bleed.
      const isWarningCell = i === 1 && row.noReceiptAcknowledged;
      if (isWarningCell) doc.fillColor('#dc3545');
      doc.text(value, col.x, rowTop + 6, {
        width: col.width - 4,
        align: col.align ?? 'left',
        ellipsis: true,
      });
      if (isWarningCell) doc.fillColor('#000000');
    });
    doc.y = rowTop + 20;
  };

  drawHeader();
  let altIndex = 0;
  for (const group of groupRows(rows, opts.groupBy)) {
    if (opts.groupBy !== 'none') {
      if (doc.y > doc.page.height - 80) {
        doc.addPage();
        drawHeader();
      }
      const groupTotal = group.rows.reduce((s, r) => s + r.amountTarget, 0);
      doc
        .rect(60, doc.y, doc.page.width - 120, 20)
        .fillColor('#e7f3ff')
        .fill()
        .strokeColor('#dddddd')
        .stroke();
      doc
        .fillColor('#000000')
        .fontSize(9)
        .font('Helvetica-Bold')
        .text(
          `${group.key}  (${group.rows.length} expense${group.rows.length === 1 ? '' : 's'} - ${formatCurrency(groupTotal, opts.targetCurrency)})`,
          65,
          doc.y + 5,
          { width: doc.page.width - 130 },
        );
      doc.y += 20;
    }
    for (const row of group.rows) {
      drawRow(row, altIndex % 2 === 1);
      altIndex += 1;
    }
  }
  doc.moveDown(0.5);
}

async function addReceiptPages(
  doc: PDFKit.PDFDocument,
  rows: ProcessedExpense[],
  filesById: Map<string, ResolvedFile>,
  opts: { targetCurrency: TargetCurrency; signal?: AbortSignal },
) {
  const expensesWithReceipts = rows.filter(
    (r) => Array.isArray(r.receiptFileIds) && r.receiptFileIds.length > 0,
  );
  if (expensesWithReceipts.length === 0) return;
  const totalReceipts = expensesWithReceipts.reduce(
    (s, r) => s + (r.receiptFileIds?.length ?? 0),
    0,
  );

  const backend = await getStorageBackend();

  let receiptCounter = 0;
  let resizedCount = 0;
  const startedAt = Date.now();

  for (const expense of expensesWithReceipts) {
    for (const fileId of expense.receiptFileIds ?? []) {
      // Bail out the moment the client disconnects. Without this, an
      // export aborted on the wire would keep grinding through the
      // remaining receipts and only stop when the doc.end() write
      // failed - minutes later for a 1000-row export.
      if (opts.signal?.aborted) {
        logger.info(
          { receiptCounter, totalReceipts },
          'Expense PDF stream aborted by client; halting receipt loop',
        );
        return;
      }
      receiptCounter += 1;
      const file = filesById.get(fileId);
      if (!file) {
        addReceiptErrorPage(
          doc,
          expense,
          receiptCounter,
          totalReceipts,
          opts.targetCurrency,
          'Receipt file metadata missing',
        );
        continue;
      }

      let imageBuffer: Buffer | null = null;
      try {
        // Stream from storage → buffer. Sharp + pdfkit both need a Buffer
        // (neither accepts a streaming body), so we pay one image's bytes
        // per loop iteration. Released to GC after embed.
        const stream = await backend.get(file.storagePath);
        const raw = await streamToBuffer(stream);
        const resized = await maybeResizeImage(raw, file.mimeType);
        if (resized.resized) resizedCount += 1;
        imageBuffer = resized.buffer;

        // Page header
        doc.addPage();
        renderReceiptHeader(doc, expense, file, receiptCounter, totalReceipts, opts.targetCurrency);

        // Embed the image full-bleed in the remaining vertical space.
        const margin = 60;
        const headerBlockHeight = 110;
        const imgX = margin;
        const imgY = doc.y;
        const imgW = doc.page.width - margin * 2;
        const imgH = doc.page.height - imgY - margin;
        try {
          doc.image(imageBuffer, imgX, imgY, {
            fit: [imgW, imgH],
            align: 'center',
            valign: 'center',
          });
        } catch (err) {
          logger.warn(
            { err, fileId, mimeType: file.mimeType },
            'pdfkit refused to embed receipt; falling back to error page',
          );
          // Replace the partial page content with an error footer; pdfkit
          // doesn't allow removing already-drawn elements, so we just append
          // the error message in red below.
          doc
            .fontSize(11)
            .fillColor('#dc3545')
            .text(
              `Receipt could not be embedded: ${(err as Error).message}`,
              imgX,
              imgY + headerBlockHeight,
              { width: imgW, align: 'center' },
            );
          doc.fillColor('#000000');
        }
      } catch (err) {
        logger.warn(
          { err, fileId, expenseId: expense.id, storagePath: file.storagePath },
          'Receipt fetch failed; rendering placeholder page',
        );
        addReceiptErrorPage(
          doc,
          expense,
          receiptCounter,
          totalReceipts,
          opts.targetCurrency,
          (err as Error).message ?? 'Receipt could not be loaded from storage',
        );
      } finally {
        // Release the buffer reference so V8 can reclaim it before the
        // next iteration. Without this, the closure could pin the last
        // image until the loop fully completes.
        imageBuffer = null;
      }
    }
  }

  logger.info(
    {
      totalReceipts,
      resized: resizedCount,
      elapsedMs: Date.now() - startedAt,
    },
    'Expense PDF receipt pages built',
  );
}

function renderReceiptHeader(
  doc: PDFKit.PDFDocument,
  expense: ProcessedExpense,
  file: ResolvedFile,
  index: number,
  total: number,
  currency: string,
) {
  const margin = 60;
  const headerH = 90;
  // Capture the header's top edge BEFORE drawing - every subsequent text
  // call below uses pdfkit's auto-flow which advances `doc.y`. Using
  // `doc.y - headerH + 10` after the rect+stroke block computes against
  // the post-rect position and only happens to work because pdfkit's
  // text-after-rect hasn't moved y yet. On the first receipt page after
  // a soft page break that assumption breaks and the header misaligns.
  const baseY = doc.y;
  doc
    .rect(margin, baseY, doc.page.width - margin * 2, headerH)
    .fillColor('#f8f9fa')
    .fill()
    .strokeColor('#dee2e6')
    .stroke();
  doc.fillColor('#000000');

  doc
    .fontSize(14)
    .font('Helvetica-Bold')
    .text(`Receipt ${index} of ${total}`, margin + 10, baseY + 10);
  doc
    .fontSize(11)
    .font('Helvetica-Bold')
    .text(
      `${expense.establishmentName ?? '-'}  ${formatCurrency(expense.amountTarget, currency)}`,
      margin + 10,
      baseY + 36,
    );
  doc
    .fontSize(9)
    .font('Helvetica')
    .fillColor('#666666')
    .text(
      `Date: ${expense.expenseDate.toISOString().slice(0, 10)}  ·  Payer: ${expense.payer ?? '-'}  ·  Category: ${expense.category ?? '-'}  ·  File: ${file.filename}`,
      margin + 10,
      baseY + 56,
      { width: doc.page.width - margin * 2 - 20 },
    );
  doc.fillColor('#000000');
  // Reset cursor to below the header block, anchored to the captured
  // baseline so it's independent of however many auto-flowed text runs
  // occurred above.
  doc.y = baseY + headerH + 8;
}

function addReceiptErrorPage(
  doc: PDFKit.PDFDocument,
  expense: ProcessedExpense,
  index: number,
  total: number,
  currency: string,
  message: string,
) {
  doc.addPage();
  doc.fontSize(14).font('Helvetica-Bold').text(`Receipt ${index} of ${total}`, { align: 'center' });
  doc
    .fontSize(11)
    .font('Helvetica')
    .text(
      `${expense.establishmentName ?? '-'}  ${formatCurrency(expense.amountTarget, currency)}`,
      { align: 'center' },
    );
  doc.moveDown(2);
  doc.fontSize(11).fillColor('#dc3545').text(message, { align: 'center' });
  doc.fillColor('#000000');
}

function addFooter(doc: PDFKit.PDFDocument) {
  doc.fontSize(9).fillColor('#666666');
  const range = doc.bufferedPageRange();
  for (let i = range.start; i < range.start + range.count; i += 1) {
    doc.switchToPage(i);
    doc.text(`Page ${i + 1} of ${range.count}`, 60, doc.page.height - 30, {
      align: 'right',
      width: doc.page.width - 120,
    });
    doc.text(
      `Generated ${new Date().toISOString().slice(0, 19).replace('T', ' ')} UTC`,
      60,
      doc.page.height - 30,
      {
        align: 'left',
        width: doc.page.width - 120,
      },
    );
  }
  doc.fillColor('#000000');
}
