// PDF export of the Shipping Performance BI page (/bi/shipping-performance).
// Client-only jsPDF (A4 landscape). Charts arrive as PNG snapshots of the LIVE
// amCharts roots (exporting plugin, taken by the page); everything else (rule
// text, KPI cards, source table, orders table) is drawn natively from the same
// payload the page renders. Helvetica (WinAnsi) covers umlauts and Ø — no arrows.

export interface ShippingPerfPdfChart {
  key: string
  title: string
  subtitle?: string
  dataUrl: string
  w: number            // source pixel size, only the aspect ratio is used
  h: number
}

export interface ShippingPerfPdfSummary {
  orders: number; measured: number; shipped: number
  onTime: number; late: number; overdue: number; pending: number
  noStock: number; stockBlocked: number; generatedNotShipped: number
  onTimeRate: number | null; slaRate: number | null
  avgLeadHours: number | null; medianLeadHours: number | null; p90LeadHours: number | null
  avgDaysLate: number | null; beforeCutoffShare: number | null
}

export interface ShippingPerfPdfSource {
  key: string; label: string
  orders: number; onTime: number; late: number; overdue: number; pending: number; noStock?: number
  onTimeRate: number | null; avgLeadHours: number | null
}

export interface ShippingPerfPdfRow {
  status: string
  stockBlocked?: boolean
  stockNote?: string | null
  documentNo?: string
  externalOrderId?: string | null
  orderSource?: string | null
  bpartnerName?: string | null
  createdAt?: number | null
  createdWeekday?: string | null
  beforeCutoff?: boolean
  deadlineDate?: string | null
  shippedAt?: number | null
  shipmentDocumentNo?: string | null
  inOutId?: number | null
  shipper?: string | null
  leadHours?: number | null
  daysLate?: number | null
}

export interface ShippingPerfPdfPayload {
  orgName: string
  from: string                       // ISO date
  to: string
  cutoff: string                     // HH:MM
  cutoffSource: 'partner' | 'override' | 'default' | string
  timezone: string
  nonWorkingDays: number
  noStockMode: 'exclude' | 'include' | string
  stockCheck: 'ok' | 'unavailable' | string
  truncated: boolean
  generatedAt: Date
  summary: ShippingPerfPdfSummary
  sources: ShippingPerfPdfSource[]
  charts: ShippingPerfPdfChart[]     // display order; missing charts simply absent
  rows: ShippingPerfPdfRow[]         // already in grid order (filter + sort applied)
  rowsTotal: number
  statusFilterLabel: string
  gridFiltered: boolean
}

const PAGE_W = 297
const PAGE_H = 210
const MARGIN = 12
const CONTENT_W = PAGE_W - 2 * MARGIN

type RGB = readonly [number, number, number]
const C = {
  ink: [11, 11, 11] as RGB,
  text: [52, 51, 48] as RGB,
  muted: [120, 119, 114] as RGB,
  grid: [226, 225, 221] as RGB,
  band: [245, 244, 241] as RGB,
  accent: [30, 41, 59] as RGB,
  blue: [37, 99, 235] as RGB,
  white: [255, 255, 255] as RGB,
  good: [21, 128, 61] as RGB,
  goodSoft: [220, 252, 231] as RGB,
  warn: [180, 83, 9] as RGB,
  warnSoft: [254, 243, 199] as RGB,
  bad: [185, 28, 28] as RGB,
  badSoft: [254, 226, 226] as RGB,
  overdue: [194, 65, 12] as RGB,
  overdueSoft: [255, 237, 213] as RGB,
  grey: [75, 85, 99] as RGB,
  greySoft: [243, 244, 246] as RGB,
  slate: [51, 65, 85] as RGB,
  slateSoft: [226, 232, 240] as RGB
}

// Same colours as the STATUS map on the page.
const STATUS: Record<string, { label: string; fg: RGB; bg: RGB }> = {
  ontime: { label: 'On time', fg: C.good, bg: C.goodSoft },
  late: { label: 'Late', fg: C.bad, bg: C.badSoft },
  overdue: { label: 'Overdue', fg: C.overdue, bg: C.overdueSoft },
  pending: { label: 'Pending', fg: C.grey, bg: C.greySoft },
  nostock: { label: 'No stock', fg: C.slate, bg: C.slateSoft }
}

const fill = (doc: any, c: RGB) => doc.setFillColor(c[0], c[1], c[2])
const stroke = (doc: any, c: RGB) => doc.setDrawColor(c[0], c[1], c[2])
const ink = (doc: any, c: RGB) => doc.setTextColor(c[0], c[1], c[2])
const font = (doc: any, size: number, style: 'normal' | 'bold' | 'italic' | 'bolditalic' = 'normal') => {
  doc.setFont('helvetica', style)
  doc.setFontSize(size)
}

/** Truncate with an ellipsis so the text fits `maxW` mm at the current font. */
const fitText = (doc: any, text: string, maxW: number): string => {
  const s = String(text ?? '')
  if (doc.getTextWidth(s) <= maxW) return s
  let lo = 0
  let hi = s.length
  while (lo < hi) {
    const mid = Math.ceil((lo + hi) / 2)
    if (doc.getTextWidth(s.slice(0, mid) + '…') <= maxW) lo = mid
    else hi = mid - 1
  }
  return s.slice(0, Math.max(0, lo)).trimEnd() + '…'
}

const loadImage = (url: string): Promise<{ dataUrl: string; w: number; h: number } | null> =>
  new Promise(resolve => {
    if (!import.meta.client || typeof Image === 'undefined') return resolve(null)
    const img = new Image()
    img.crossOrigin = 'anonymous'
    img.onload = () => {
      try {
        const canvas = document.createElement('canvas')
        canvas.width = img.naturalWidth
        canvas.height = img.naturalHeight
        canvas.getContext('2d')?.drawImage(img, 0, 0)
        resolve({ dataUrl: canvas.toDataURL('image/png'), w: img.naturalWidth, h: img.naturalHeight })
      } catch { resolve(null) }
    }
    img.onerror = () => resolve(null)
    img.src = url
  })

// ---------------------------------------------------------------------------
// Formatting (mirrors the page helpers)

const fmtDate = (iso?: string | null) => {
  const s = String(iso || '').slice(0, 10)
  if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return s
  return `${s.slice(8, 10)}.${s.slice(5, 7)}.${s.slice(0, 4)}`
}
const fmtDateTime = (ms?: number | null) => {
  if (!ms) return ''
  const parts = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }).formatToParts(new Date(ms))
  const g = (t: string) => parts.find(p => p.type === t)?.value || ''
  return `${g('day')}.${g('month')}.${g('year')} ${g('hour')}:${g('minute')}`
}
const fmtHours = (h?: number | null) => {
  if (h == null) return '–'
  if (h < 48) return `${h.toLocaleString('de-DE', { maximumFractionDigits: 1 })} h`
  return `${(h / 24).toLocaleString('de-DE', { maximumFractionDigits: 1 })} d`
}
const fmtPct = (v?: number | null) => (v == null ? '–' : `${v.toLocaleString('de-DE')} %`)
const fmtNum = (v?: number | null) => Number(v || 0).toLocaleString('de-DE')
const rateTint = (rate?: number | null): { fg: RGB; soft: RGB } => {
  if (rate == null) return { fg: C.accent, soft: C.band }
  if (rate >= 95) return { fg: C.good, soft: C.goodSoft }
  if (rate >= 90) return { fg: C.warn, soft: C.warnSoft }
  return { fg: C.bad, soft: C.badSoft }
}
const cutoffSourceLabel = (src: string) =>
  src === 'partner' ? 'from partner setting' : src === 'override' ? 'manual override' : 'default (no partner cut-off set)'

export const buildShippingPerformancePdf = async (p: ShippingPerfPdfPayload) => {
  if (!import.meta.client) throw new Error('PDF generation is client-only')
  const { jsPDF } = await import('jspdf')
  const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4', compress: true })
  const logo = await loadImage('/assets/logyou-logo-color.png')
  const scope = `${fmtDate(p.from)} – ${fmtDate(p.to)}`
  const generated = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Europe/Berlin' }).format(p.generatedAt)

  // ---------------------------------------------------------------------------
  // Chrome: header band + footer on every page (page numbers filled at the end)

  const HEADER_H = 22
  const FOOTER_Y = PAGE_H - 9
  const BOTTOM = FOOTER_Y - 6          // last usable y
  let y = 0

  const header = (subtitle: string) => {
    fill(doc, C.accent)
    doc.rect(0, 0, PAGE_W, HEADER_H, 'F')
    fill(doc, C.blue)
    doc.rect(0, HEADER_H, PAGE_W, 1.2, 'F')

    ink(doc, C.white)
    font(doc, 15, 'bold')
    doc.text('Shipping Performance', MARGIN, 10)
    font(doc, 9, 'normal')
    doc.setTextColor(200, 208, 222)
    doc.text(fitText(doc, subtitle, 150), MARGIN, 16.5)

    let rightEdge = PAGE_W - MARGIN
    if (logo) {
      const h = 9
      const w = h * (logo.w / logo.h)
      fill(doc, C.white)
      doc.roundedRect(rightEdge - w - 3, 4.5, w + 6, h + 4, 1.5, 1.5, 'F')
      try { doc.addImage(logo.dataUrl, 'PNG', rightEdge - w, 6.5, w, h, undefined, 'FAST') } catch {}
      rightEdge -= w + 10
    }
    font(doc, 9, 'bold')
    ink(doc, C.white)
    doc.text(fitText(doc, p.orgName || '', 90), rightEdge, 10, { align: 'right' })
    font(doc, 8, 'normal')
    doc.setTextColor(200, 208, 222)
    doc.text(scope, rightEdge, 16.5, { align: 'right' })
    y = HEADER_H + 9
  }

  const newPage = (subtitle: string) => {
    doc.addPage()
    header(subtitle)
  }
  /** Start a new page when `needed` mm no longer fit above the footer. */
  const ensure = (needed: number, subtitle: string) => {
    if (y + needed > BOTTOM) newPage(subtitle)
  }

  const footers = () => {
    const total = doc.getNumberOfPages()
    const stockNote = p.stockCheck === 'ok'
      ? (p.noStockMode === 'exclude' ? 'No-stock orders excluded' : 'No-stock orders included')
      : 'Stock check unavailable'
    const left = `Generated ${generated} · Cut-off ${p.cutoff} (${cutoffSourceLabel(p.cutoffSource)}) · ${stockNote} · Times in ${p.timezone}`
    for (let i = 1; i <= total; i++) {
      doc.setPage(i)
      stroke(doc, C.grid)
      doc.setLineWidth(0.25)
      doc.line(MARGIN, FOOTER_Y - 3.5, PAGE_W - MARGIN, FOOTER_Y - 3.5)
      font(doc, 7, 'normal')
      ink(doc, C.muted)
      doc.text(fitText(doc, left, 230), MARGIN, FOOTER_Y)
      doc.text(`Page ${i} / ${total}`, PAGE_W - MARGIN, FOOTER_Y, { align: 'right' })
    }
  }

  // ---------------------------------------------------------------------------
  // Chart placement (PNG snapshots)

  const titleBlockH = (chart: ShippingPerfPdfChart) => 5 + (chart.subtitle ? 3.6 : 0)

  /** Draw one chart at x/width starting at the current y; returns the height used. */
  const drawChartAt = (chart: ShippingPerfPdfChart, x: number, w: number, top: number, maxImgH: number) => {
    const th = titleBlockH(chart)
    font(doc, 9, 'bold')
    ink(doc, C.ink)
    doc.text(fitText(doc, chart.title, w), x, top + 3.4)
    if (chart.subtitle) {
      font(doc, 7.5, 'normal')
      ink(doc, C.muted)
      doc.text(fitText(doc, chart.subtitle, w), x, top + 3.4 + 3.6)
    }
    let imgW = w
    let imgH = w * (chart.h / chart.w)
    let imgX = x
    if (imgH > maxImgH) {           // too tall for the page: shrink proportionally, keep centred
      imgH = maxImgH
      imgW = imgH * (chart.w / chart.h)
      imgX = x + (w - imgW) / 2
    }
    try { doc.addImage(chart.dataUrl, 'PNG', imgX, top + th, imgW, imgH, undefined, 'FAST') } catch {}
    stroke(doc, C.grid)
    doc.setLineWidth(0.2)
    doc.rect(x, top + th, w, imgH)
    return th + imgH
  }

  /** Place a row of charts side by side (one chart = a row of one). */
  const placeChartRow = (slots: { chart: ShippingPerfPdfChart; x: number; w: number }[], subtitle: string) => {
    if (!slots.length) return
    const needed = Math.max(...slots.map(s => titleBlockH(s.chart) + s.w * (s.chart.h / s.chart.w)))
    if (y + needed > BOTTOM) newPage(subtitle)
    const maxImgH = BOTTOM - y - Math.max(...slots.map(s => titleBlockH(s.chart)))
    const used = Math.max(...slots.map(s => drawChartAt(s.chart, s.x, s.w, y, maxImgH)))
    y += used + 7
  }

  const chartByKey = (key: string) => p.charts.find(c => c.key === key)

  // ---------------------------------------------------------------------------
  // Page 1 — overview: SLA rule, KPI cards, monthly chart

  header('Overview')

  {
    const s = p.summary
    const nwd = p.nonWorkingDays
    const ruleText = `Orders imported before ${p.cutoff} on a working day must ship the same day, all others by the next working day (Mon–Fri, excluding ${nwd} non-working day${nwd === 1 ? '' : 's'}). Times in ${p.timezone}.`
    let stockText = ''
    let stockWarn = false
    if (p.stockCheck === 'ok' && p.noStockMode === 'exclude') {
      stockText = 'Orders whose articles were out of stock in the warehouse at the deadline are marked "No stock" and do not count toward the rates.'
    } else if (p.stockCheck === 'ok') {
      stockText = `Stock check on: ${s.stockBlocked} order${s.stockBlocked === 1 ? '' : 's'} without stock at the deadline are included in the rates.`
    } else {
      stockText = 'Stock check unavailable – direct database access is not configured, orders without stock are not identified.'
      stockWarn = true
    }
    const meta = `${p.orgName} · ${scope} · ${fmtNum(s.orders)} orders`

    const innerW = CONTENT_W - 10
    font(doc, 8, 'normal')
    const ruleLines: string[] = doc.splitTextToSize(ruleText, innerW)
    const stockLines: string[] = doc.splitTextToSize(stockText, innerW)
    const lineH = 3.8
    const boxH = 4 + 5 + ruleLines.length * lineH + stockLines.length * lineH + 1.5 + 4.5 + 3

    fill(doc, C.band)
    doc.roundedRect(MARGIN, y, CONTENT_W, boxH, 2, 2, 'F')
    fill(doc, C.blue)
    doc.rect(MARGIN, y + 3, 1.4, boxH - 6, 'F')

    let ty = y + 4 + 3.6
    font(doc, 10, 'bold')
    ink(doc, C.ink)
    doc.text(`Cut-off ${p.cutoff}`, MARGIN + 6, ty)
    const titleW = doc.getTextWidth(`Cut-off ${p.cutoff}`)
    font(doc, 8, 'normal')
    ink(doc, C.muted)
    doc.text(cutoffSourceLabel(p.cutoffSource), MARGIN + 6 + titleW + 3, ty)
    ty += 5.2
    ink(doc, C.text)
    doc.text(ruleLines, MARGIN + 6, ty)
    ty += ruleLines.length * lineH
    ink(doc, stockWarn ? C.warn : C.text)
    doc.text(stockLines, MARGIN + 6, ty)
    ty += stockLines.length * lineH + 1.5
    font(doc, 8, 'bold')
    ink(doc, C.accent)
    doc.text(meta, MARGIN + 6, ty)
    if (p.truncated) {
      const mw = doc.getTextWidth(meta)
      ink(doc, C.bad)
      doc.text('·  capped – narrow the range', MARGIN + 6 + mw + 2, ty)
    }
    y += boxH + 7

    // KPI cards — same six as the page
    const onTint = rateTint(s.onTimeRate)
    const slaTint = rateTint(s.slaRate)
    const cards: { label: string; value: string; valueColor: RGB; soft: RGB; bar: RGB; sub: string; sub2?: string; parts?: { text: string; color: RGB }[] }[] = [
      { label: 'On-time rate', value: fmtPct(s.onTimeRate), valueColor: onTint.fg, soft: onTint.soft, bar: onTint.fg, sub: `of ${fmtNum(s.shipped)} shipped orders` },
      { label: 'SLA rate incl. overdue', value: fmtPct(s.slaRate), valueColor: slaTint.fg, soft: slaTint.soft, bar: slaTint.fg, sub: 'unshipped past deadline = missed' },
      { label: 'On time', value: fmtNum(s.onTime), valueColor: C.good, soft: C.band, bar: C.good, sub: 'orders' },
      { label: 'Late', value: fmtNum(s.late), valueColor: C.bad, soft: C.band, bar: C.bad, sub: `Ø ${s.avgDaysLate ?? 0} working days late` },
      {
        label: 'Overdue · Pending', value: '', valueColor: C.ink, soft: C.band, bar: C.overdue,
        parts: [{ text: fmtNum(s.overdue), color: C.overdue }, { text: ' · ', color: C.muted }, { text: fmtNum(s.pending), color: C.grey }],
        sub: `not shipped yet (${fmtNum(s.generatedNotShipped)} generated)`,
        sub2: s.noStock ? `${fmtNum(s.noStock)} without stock – excluded` : undefined
      },
      { label: 'Lead time import to ship', value: fmtHours(s.medianLeadHours), valueColor: C.ink, soft: C.band, bar: C.blue, sub: `median · Ø ${fmtHours(s.avgLeadHours)} · p90 ${fmtHours(s.p90LeadHours)}` }
    ]
    const gap = 4
    const cardW = (CONTENT_W - gap * 5) / 6
    const cardH = 27
    cards.forEach((card, i) => {
      const x = MARGIN + i * (cardW + gap)
      fill(doc, card.soft)
      doc.roundedRect(x, y, cardW, cardH, 2, 2, 'F')
      fill(doc, card.bar)
      doc.rect(x, y + 3, 1.4, cardH - 6, 'F')
      font(doc, 7.5, 'normal')
      ink(doc, C.muted)
      doc.text(fitText(doc, card.label, cardW - 8), x + 5, y + 6.5)
      font(doc, 14, 'bold')
      if (card.parts) {
        let px = x + 5
        card.parts.forEach(part => {
          ink(doc, part.color)
          doc.text(part.text, px, y + 15)
          px += doc.getTextWidth(part.text)
        })
      } else {
        ink(doc, card.valueColor)
        doc.text(fitText(doc, card.value, cardW - 8), x + 5, y + 15)
      }
      font(doc, 6.5, 'normal')
      ink(doc, C.muted)
      doc.text(fitText(doc, card.sub, cardW - 8), x + 5, y + 20.5)
      if (card.sub2) {
        ink(doc, C.slate)
        doc.text(fitText(doc, card.sub2, cardW - 8), x + 5, y + 24)
      }
    })
    y += cardH + 8
  }

  const months = chartByKey('months')
  if (months) placeChartRow([{ chart: months, x: MARGIN, w: CONTENT_W }], 'Overview')

  // ---------------------------------------------------------------------------
  // Page 2 — remaining charts (day · hour / weekday / lateness)

  const days = chartByKey('days')
  const hours = chartByKey('hours')
  const weekdays = chartByKey('weekdays')
  const lateness = chartByKey('lateness')
  if (days || hours || weekdays || lateness) newPage('Charts')
  if (days) placeChartRow([{ chart: days, x: MARGIN, w: CONTENT_W }], 'Charts')
  {
    const rowGap = 4
    const halfW = (CONTENT_W - 2 * rowGap) / 2
    const quarterW = (CONTENT_W - 2 * rowGap) / 4
    const slots: { chart: ShippingPerfPdfChart; x: number; w: number }[] = []
    let x = MARGIN
    if (hours) { slots.push({ chart: hours, x, w: halfW }); x += halfW + rowGap }
    if (weekdays) { slots.push({ chart: weekdays, x, w: quarterW }); x += quarterW + rowGap }
    if (lateness) slots.push({ chart: lateness, x, w: quarterW })
    placeChartRow(slots, 'Charts')
  }

  // ---------------------------------------------------------------------------
  // By order source (only when more than one source, like the page)

  if (p.sources.length > 1) {
    const showNoStock = (p.summary.noStock || 0) > 0
    const numCols = ['Orders', 'On time', 'Late', 'Overdue', 'Pending', ...(showNoStock ? ['No stock'] : []), 'On-time rate', 'Ø lead']
    const numW = 22
    const labelW = CONTENT_W - numCols.length * numW
    const rowH = 5.2
    const headH = 6
    const needed = 8 + headH + p.sources.length * rowH + 4
    ensure(needed, 'By order source')
    font(doc, 10, 'bold')
    ink(doc, C.ink)
    doc.text('By order source', MARGIN, y + 3.4)
    y += 7

    const colRight = (i: number) => MARGIN + labelW + (i + 1) * numW - 1.5
    fill(doc, C.band)
    doc.rect(MARGIN, y, CONTENT_W, headH, 'F')
    font(doc, 7.5, 'bold')
    ink(doc, C.text)
    doc.text('Source', MARGIN + 1.5, y + headH - 1.8)
    numCols.forEach((c, i) => doc.text(c, colRight(i), y + headH - 1.8, { align: 'right' }))
    y += headH

    p.sources.forEach((src, idx) => {
      if (y + rowH > BOTTOM) {
        newPage('By order source')
        fill(doc, C.band)
        doc.rect(MARGIN, y, CONTENT_W, headH, 'F')
        font(doc, 7.5, 'bold')
        ink(doc, C.text)
        doc.text('Source', MARGIN + 1.5, y + headH - 1.8)
        numCols.forEach((c, i) => doc.text(c, colRight(i), y + headH - 1.8, { align: 'right' }))
        y += headH
      }
      if (idx % 2 === 1) {
        fill(doc, [250, 250, 248] as RGB)
        doc.rect(MARGIN, y, CONTENT_W, rowH, 'F')
      }
      stroke(doc, C.grid)
      doc.setLineWidth(0.15)
      doc.line(MARGIN, y + rowH, PAGE_W - MARGIN, y + rowH)
      const base = y + rowH - 1.6
      font(doc, 7.5, 'normal')
      ink(doc, C.text)
      doc.text(fitText(doc, src.label || src.key, labelW - 3), MARGIN + 1.5, base)
      const cells: { text: string; color: RGB; bold?: boolean }[] = [
        { text: fmtNum(src.orders), color: C.text },
        { text: fmtNum(src.onTime), color: C.good },
        { text: fmtNum(src.late), color: C.bad },
        { text: fmtNum(src.overdue), color: C.overdue },
        { text: fmtNum(src.pending), color: C.grey },
        ...(showNoStock ? [{ text: fmtNum(src.noStock || 0), color: C.slate }] : []),
        { text: fmtPct(src.onTimeRate), color: rateTint(src.onTimeRate).fg, bold: true },
        { text: fmtHours(src.avgLeadHours), color: C.text }
      ]
      cells.forEach((cell, i) => {
        font(doc, 7.5, cell.bold ? 'bold' : 'normal')
        ink(doc, cell.color)
        doc.text(cell.text, colRight(i), base, { align: 'right' })
      })
      y += rowH
    })
    y += 6
  }

  // ---------------------------------------------------------------------------
  // Orders table — the rows the grid currently shows, as many pages as needed

  const rows = p.rows || []
  const ordersSubtitle = `Orders · ${p.statusFilterLabel} · ${fmtNum(rows.length)} of ${fmtNum(p.rowsTotal)}${p.gridFiltered ? ' · grid filter active' : ''}`
  newPage(ordersSubtitle)

  const hasStockNote = rows.some(r => r.stockNote)
  type Col = { key: string; title: string; w: number; align?: 'left' | 'right' }
  const cols: Col[] = [
    { key: 'status', title: 'Status', w: 16 },
    { key: 'order', title: 'Order', w: 20 },
    { key: 'ext', title: 'Ext. order ID', w: hasStockNote ? 20 : 30 },
    { key: 'source', title: 'Source', w: 20 },
    { key: 'customer', title: 'Customer', w: hasStockNote ? 32 : 50 },
    { key: 'imported', title: 'Imported (Berlin)', w: 22 },
    { key: 'wd', title: 'Wd', w: 9 },
    { key: 'cutoff', title: 'Cut-off', w: 12 },
    { key: 'shipby', title: 'Ship by', w: 16 },
    { key: 'shipped', title: 'Shipped at', w: 22 },
    { key: 'shipment', title: 'Shipment', w: 18 },
    { key: 'shipper', title: 'Shipper', w: 16 },
    { key: 'lead', title: 'Lead', w: 13, align: 'right' },
    { key: 'late', title: 'Late', w: 9, align: 'right' },
    ...(hasStockNote ? [{ key: 'stock', title: 'Missing stock', w: 28 } as Col] : [])
  ]
  const colX: number[] = []
  cols.reduce((x, c) => { colX.push(x); return x + c.w }, MARGIN)
  const rowH = 4.6
  const headH = 5.6

  const tableHead = () => {
    fill(doc, C.band)
    doc.rect(MARGIN, y, CONTENT_W, headH, 'F')
    font(doc, 6.8, 'bold')
    ink(doc, C.text)
    cols.forEach((c, i) => {
      if (c.align === 'right') doc.text(c.title, colX[i] + c.w - 1.2, y + headH - 1.7, { align: 'right' })
      else doc.text(fitText(doc, c.title, c.w - 2), colX[i] + 1.2, y + headH - 1.7)
    })
    y += headH
  }
  tableHead()

  let anyMarker = false
  if (!rows.length) {
    font(doc, 8, 'italic')
    ink(doc, C.muted)
    doc.text('No orders match the current filter.', MARGIN + 1.5, y + 5)
    y += 8
  }

  rows.forEach((r, idx) => {
    if (y + rowH > BOTTOM) {
      newPage(ordersSubtitle)
      tableHead()
    }
    if (idx % 2 === 1) {
      fill(doc, [250, 250, 248] as RGB)
      doc.rect(MARGIN, y, CONTENT_W, rowH, 'F')
    }
    stroke(doc, C.grid)
    doc.setLineWidth(0.12)
    doc.line(MARGIN, y + rowH, PAGE_W - MARGIN, y + rowH)
    const base = y + rowH - 1.4
    const marker = !!(r.stockBlocked && r.status !== 'nostock')
    if (marker) anyMarker = true

    cols.forEach((c, i) => {
      const x = colX[i]
      const maxW = c.w - 2.4
      font(doc, 6.8, 'normal')
      ink(doc, C.text)
      switch (c.key) {
        case 'status': {
          const s = STATUS[r.status] || STATUS.pending
          font(doc, 6, 'bold')
          const label = s.label + (marker ? ' *' : '')
          const pillW = Math.min(c.w - 2, doc.getTextWidth(label) + 3)
          fill(doc, s.bg)
          doc.roundedRect(x + 1, y + 0.7, pillW, rowH - 1.4, 1.2, 1.2, 'F')
          ink(doc, s.fg)
          doc.text(fitText(doc, label, pillW - 1.5), x + 2.5, base)
          break
        }
        case 'order': doc.text(fitText(doc, r.documentNo || '', maxW), x + 1.2, base); break
        case 'ext': doc.text(fitText(doc, r.externalOrderId || '', maxW), x + 1.2, base); break
        case 'source': doc.text(fitText(doc, r.orderSource || '', maxW), x + 1.2, base); break
        case 'customer': doc.text(fitText(doc, r.bpartnerName || '', maxW), x + 1.2, base); break
        case 'imported': doc.text(fitText(doc, fmtDateTime(r.createdAt), maxW), x + 1.2, base); break
        case 'wd': doc.text(fitText(doc, String(r.createdWeekday || '').slice(0, 3), maxW), x + 1.2, base); break
        case 'cutoff':
          ink(doc, r.beforeCutoff ? C.good : C.grey)
          doc.text(r.beforeCutoff ? 'yes' : 'no', x + 1.2, base)
          break
        case 'shipby': doc.text(fitText(doc, fmtDate(r.deadlineDate), maxW), x + 1.2, base); break
        case 'shipped': doc.text(fitText(doc, fmtDateTime(r.shippedAt), maxW), x + 1.2, base); break
        case 'shipment': doc.text(fitText(doc, r.shipmentDocumentNo || (r.inOutId ? '#' + r.inOutId : ''), maxW), x + 1.2, base); break
        case 'shipper': doc.text(fitText(doc, r.shipper || '', maxW), x + 1.2, base); break
        case 'lead': doc.text(fmtHours(r.leadHours), x + c.w - 1.2, base, { align: 'right' }); break
        case 'late': {
          const late = Number(r.daysLate || 0)
          if (late > 0) { font(doc, 6.8, 'bold'); ink(doc, C.bad) }
          doc.text(late > 0 ? String(late) : (r.daysLate == null ? '' : '0'), x + c.w - 1.2, base, { align: 'right' })
          break
        }
        case 'stock':
          ink(doc, C.slate)
          doc.text(fitText(doc, r.stockNote || '', maxW), x + 1.2, base)
          break
      }
    })
    y += rowH
  })

  if (anyMarker) {
    ensure(8, ordersSubtitle)
    font(doc, 6.8, 'italic')
    ink(doc, C.muted)
    doc.text('* Order had no stock in the warehouse at the deadline (included in the rates).', MARGIN + 1.5, y + 5)
    y += 8
  }

  footers()
  return doc
}
