// Zero-dependency .pptx writer — a PowerPoint file is a zip of OOXML parts.
// We build the zip with node:zlib (deflate) + a hand-rolled central directory,
// and emit minimal-but-valid presentation XML that PowerPoint, Keynote and
// Google Slides all open. Slides are 16:9 (12192000 × 6858000 EMU).

import zlib from 'node:zlib';

// ── tiny zip writer ─────────────────────────────────────────────────────
const CRC_TABLE = (() => {
  const t = new Int32Array(256);
  for (let n = 0; n < 256; n++) {
    let c = n;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    t[n] = c;
  }
  return t;
})();
function crc32(buf) {
  let c = 0 ^ -1;
  for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ CRC_TABLE[(c ^ buf[i]) & 0xff];
  return (c ^ -1) >>> 0;
}

export function buildZip(files) {
  // files: [{ name, data (string|Buffer) }]
  const chunks = [], central = [];
  let offset = 0;
  for (const f of files) {
    const data = Buffer.isBuffer(f.data) ? f.data : Buffer.from(f.data, 'utf8');
    const comp = zlib.deflateRawSync(data);
    const crc = crc32(data);
    const name = Buffer.from(f.name, 'utf8');
    const local = Buffer.alloc(30);
    local.writeUInt32LE(0x04034b50, 0);
    local.writeUInt16LE(20, 4);       // version needed
    local.writeUInt16LE(0, 6);        // flags
    local.writeUInt16LE(8, 8);        // deflate
    local.writeUInt16LE(0, 10);       // time
    local.writeUInt16LE(0x21, 12);    // date (1980-01-01)
    local.writeUInt32LE(crc, 14);
    local.writeUInt32LE(comp.length, 18);
    local.writeUInt32LE(data.length, 22);
    local.writeUInt16LE(name.length, 26);
    local.writeUInt16LE(0, 28);
    chunks.push(local, name, comp);
    const cd = Buffer.alloc(46);
    cd.writeUInt32LE(0x02014b50, 0);
    cd.writeUInt16LE(20, 4); cd.writeUInt16LE(20, 6);
    cd.writeUInt16LE(0, 8); cd.writeUInt16LE(8, 10);
    cd.writeUInt16LE(0, 12); cd.writeUInt16LE(0x21, 14);
    cd.writeUInt32LE(crc, 16);
    cd.writeUInt32LE(comp.length, 20);
    cd.writeUInt32LE(data.length, 24);
    cd.writeUInt16LE(name.length, 28);
    // extra/comment/disk/attrs all zero
    cd.writeUInt32LE(offset, 42);
    central.push(Buffer.concat([cd, name]));
    offset += local.length + name.length + comp.length;
  }
  const cdBuf = Buffer.concat(central);
  const end = Buffer.alloc(22);
  end.writeUInt32LE(0x06054b50, 0);
  end.writeUInt16LE(files.length, 8);
  end.writeUInt16LE(files.length, 10);
  end.writeUInt32LE(cdBuf.length, 12);
  end.writeUInt32LE(offset, 16);
  return Buffer.concat([...chunks, cdBuf, end]);
}

// ── OOXML helpers ───────────────────────────────────────────────────────
const SLIDE_W = 12192000, SLIDE_H = 6858000; // EMU, 16:9
const EMU_IN = 914400;
export const inch = (v) => Math.round(v * EMU_IN);
const escXml = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const hex = (c) => String(c || 'EB6834').replace('#', '').toUpperCase().padEnd(6, '0').slice(0, 6);
// Dark shade of a colour — the deck's second-series colour derives from the
// client's accent so every chart stays in their CI.
function shadeHex(c, f = 0.45) {
  const n = parseInt(hex(c), 16);
  const ch = (v) => Math.max(0, Math.min(255, Math.round(v * (1 - f))));
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => ch(v).toString(16).padStart(2, '0')).join('').toUpperCase();
}

let SHAPE_ID = 1;
let DECK_FONT = 'Inter'; // per-deck typeface — set from the client's CI

// PNG / JPEG pixel dimensions — needed to scale an embedded logo correctly.
export function imgSize(buf) {
  if (buf.length > 24 && buf[0] === 0x89 && buf[1] === 0x50) {
    return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
  }
  if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8) {
    let i = 2;
    while (i + 9 < buf.length) {
      if (buf[i] !== 0xff) { i++; continue; }
      const m = buf[i + 1];
      if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
        return { h: buf.readUInt16BE(i + 5), w: buf.readUInt16BE(i + 7) };
      }
      i += 2 + buf.readUInt16BE(i + 2);
    }
  }
  return null;
}

// An embedded picture (the client logo) — references the slide-level image
// relationship added in build().
function pic(relId, x, y, w, h) {
  const id = ++SHAPE_ID;
  return `<p:pic><p:nvPicPr><p:cNvPr id="${id}" name="logo${id}"/><p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>
    <p:blipFill><a:blip r:embed="${relId}"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>
    <p:spPr><a:xfrm><a:off x="${Math.round(x)}" y="${Math.round(y)}"/><a:ext cx="${Math.round(w)}" cy="${Math.round(h)}"/></a:xfrm>
    <a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
}

function sp({ x, y, w, h, fill = null, line = null, text = null, radius = false }) {
  const id = ++SHAPE_ID;
  const geom = radius ? 'roundRect' : 'rect';
  const fillXml = fill ? `<a:solidFill><a:srgbClr val="${hex(fill)}"/></a:solidFill>` : '<a:noFill/>';
  const lineXml = line ? `<a:ln w="12700"><a:solidFill><a:srgbClr val="${hex(line)}"/></a:solidFill></a:ln>` : '<a:ln><a:noFill/></a:ln>';
  let tx = '<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody>';
  if (text) {
    const paras = (Array.isArray(text.lines) ? text.lines : [{ t: text.t ?? String(text), ...text }]).map((ln) => {
      const runs = (Array.isArray(ln.runs) ? ln.runs : [ln]).map((r) =>
        `<a:r><a:rPr lang="en-GB" sz="${Math.round((r.size || ln.size || 18) * 100)}" ${r.bold || ln.bold ? 'b="1"' : ''} dirty="0"><a:solidFill><a:srgbClr val="${hex(r.color || ln.color || '111111')}"/></a:solidFill><a:latin typeface="${DECK_FONT}"/><a:cs typeface="${DECK_FONT}"/></a:rPr><a:t>${escXml(r.t ?? ln.t ?? '')}</a:t></a:r>`).join('');
      return `<a:p><a:pPr algn="${ln.align || text.align || 'l'}"/>${runs || '<a:endParaRPr/>'}</a:p>`;
    }).join('');
    tx = `<p:txBody><a:bodyPr wrap="square" ${text.anchor ? `anchor="${text.anchor}"` : ''} lIns="72000" rIns="72000" tIns="36000" bIns="36000"><a:normAutofit/></a:bodyPr><a:lstStyle/>${paras}</p:txBody>`;
  }
  return `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="s${id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>
    <p:spPr><a:xfrm><a:off x="${Math.round(x)}" y="${Math.round(y)}"/><a:ext cx="${Math.round(w)}" cy="${Math.round(h)}"/></a:xfrm>
    <a:prstGeom prst="${geom}"><a:avLst/></a:prstGeom>${fillXml}${lineXml}</p:spPr>${tx}</p:sp>`;
}

// A straight line segment between two points — DrawingML draws a "line"
// preset from the top-left to the bottom-right of its bounding box, with
// flipV for upward slopes. Used for the reach line over the daily bars.
function lineSeg(x1, y1, x2, y2, color, w = 28575) {
  const id = ++SHAPE_ID;
  const x = Math.min(x1, x2), y = Math.min(y1, y2);
  const cx = Math.max(1, Math.abs(x2 - x1)), cy = Math.max(1, Math.abs(y2 - y1));
  const flipV = (y2 - y1) * (x2 - x1) < 0 ? ' flipV="1"' : '';
  return `<p:cxnSp><p:nvCxnSpPr><p:cNvPr id="${id}" name="l${id}"/><p:cNvCxnSpPr/><p:nvPr/></p:nvCxnSpPr>
    <p:spPr><a:xfrm${flipV}><a:off x="${Math.round(x)}" y="${Math.round(y)}"/><a:ext cx="${Math.round(cx)}" cy="${Math.round(cy)}"/></a:xfrm>
    <a:prstGeom prst="line"><a:avLst/></a:prstGeom>
    <a:ln w="${w}" cap="rnd"><a:solidFill><a:srgbClr val="${hex(color)}"/></a:solidFill><a:round/></a:ln></p:spPr></p:cxnSp>`;
}

function slideXml(shapes, bg = 'FFFFFF') {
  return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
  <p:cSld><p:bg><p:bgPr><a:solidFill><a:srgbClr val="${hex(bg)}"/></a:solidFill><a:effectLst/></p:bgPr></p:bg>
  <p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
  <p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
  ${shapes.join('\n')}
  </p:spTree></p:cSld><p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr></p:sld>`;
}

// ── high-level slide builders (CI-aware) ────────────────────────────────
// ci = { primary, accent, text, name }
export class Deck {
  constructor(ci = {}) {
    this.ci = { primary: ci.primary || '#131312', accent: ci.accent || '#EB6834', text: ci.text || '#111111', name: ci.name || 'SparkSoftware Reporting' };
    // Second-series colour (the reach line): default indigo, or a dark shade
    // of the client's accent when one is set — the deck stays in their CI.
    this.accent2 = ci.accent ? '#' + shadeHex(ci.accent, 0.45) : '#6E62E5';
    this.slides = [];
    this.logoData = null; // { buf, ext, w, h } — set via setLogo()
    SHAPE_ID = 1;
    DECK_FONT = String(ci.font || 'Inter').replace(/[^\w \-]/g, '').trim() || 'Inter';
    this.font = DECK_FONT;
  }

  // Embed the client's logo (PNG or JPEG buffer). Drawn on title/divider
  // slides and as a small mark above the brand bar on content slides.
  setLogo(buf, ext = 'png') {
    const dims = imgSize(buf);
    if (!dims || !dims.w || !dims.h) return this;
    this.logoData = { buf, ext: ext === 'jpg' ? 'jpeg' : ext, w: dims.w, h: dims.h };
    return this;
  }

  logoPic(hEmu, x, y) {
    if (!this.logoData) return null;
    const w = hEmu * (this.logoData.w / this.logoData.h);
    return pic('rIdLogo', x, y, Math.min(w, inch(3)), hEmu);
  }

  brandBar(shapes) {
    shapes.push(sp({ x: 0, y: SLIDE_H - inch(0.14), w: SLIDE_W, h: inch(0.14), fill: this.ci.accent }));
    shapes.push(sp({ x: inch(0.45), y: SLIDE_H - inch(0.55), w: inch(6), h: inch(0.35), text: { t: this.ci.name, size: 11, color: '8A8985' } }));
    // Small client logo bottom-right of every content slide.
    const lg = this.logoPic(inch(0.32), SLIDE_W - inch(2.6), SLIDE_H - inch(0.55));
    if (lg) shapes.push(lg);
  }

  title(title, subtitle) {
    const shapes = [];
    shapes.push(sp({ x: 0, y: 0, w: SLIDE_W, h: SLIDE_H, fill: this.ci.primary }));
    const lg = this.logoPic(inch(0.75), inch(0.7), inch(0.7));
    if (lg) shapes.push(lg);
    shapes.push(sp({ x: inch(0.7), y: inch(2.1), w: SLIDE_W - inch(1.4), h: inch(1.6), text: { t: title, size: 40, bold: true, color: 'FFFFFF' } }));
    shapes.push(sp({ x: inch(0.7), y: inch(3.6), w: SLIDE_W - inch(1.4), h: inch(0.6), text: { t: subtitle, size: 18, color: 'DDDDDD' } }));
    shapes.push(sp({ x: inch(0.7), y: inch(4.6), w: inch(1.4), h: inch(0.09), fill: this.ci.accent }));
    shapes.push(sp({ x: inch(0.7), y: inch(6.6), w: inch(8), h: inch(0.4), text: { t: this.ci.name, size: 12, color: 'AAAAAA' } }));
    this.slides.push(slideXml(shapes, this.ci.primary));
    return this;
  }

  header(shapes, heading) {
    shapes.push(sp({ x: inch(0.45), y: inch(0.35), w: SLIDE_W - inch(0.9), h: inch(0.7), text: { t: heading, size: 26, bold: true, color: this.ci.text } }));
    shapes.push(sp({ x: inch(0.47), y: inch(1.05), w: inch(1.1), h: inch(0.07), fill: this.ci.accent }));
  }

  bullets(heading, lines) {
    const shapes = [];
    this.header(shapes, heading);
    shapes.push(sp({ x: inch(0.5), y: inch(1.45), w: SLIDE_W - inch(1), h: SLIDE_H - inch(2.2),
      text: { lines: lines.slice(0, 9).map((t) => ({ t: '•  ' + t, size: 16, color: '333333' })) } }));
    this.brandBar(shapes);
    this.slides.push(slideXml(shapes));
    return this;
  }

  // Analyst commentary band under a chart — the FEA layout: visual on top,
  // written insight below. Up to 4 bullets, auto-wrapped.
  notesBand(shapes, notes, yTop) {
    const src = (notes || []).filter(Boolean).slice(0, 5);
    if (!src.length) return;
    // Auto-shrink so the manual-report-depth bullets (2-3 sentences each)
    // still fit under the chart instead of overflowing the slide.
    const chars = src.reduce((a, t) => a + String(t).length, 0);
    const size = chars > 1250 ? 9 : chars > 950 ? 9.5 : chars > 650 ? 10.5 : 11.5;
    const lines = src.map((t) => ({ t: '•  ' + String(t), size, color: '444444' }));
    shapes.push(sp({ x: inch(0.55), y: yTop, w: SLIDE_W - inch(1.1), h: SLIDE_H - yTop - inch(0.65),
      text: { lines } }));
  }

  kpis(heading, tiles, { notes = null } = {}) {
    // tiles: [{label, value, delta}] up to 6
    const shapes = [];
    this.header(shapes, heading);
    const cols = Math.min(3, Math.max(1, Math.ceil(tiles.length / 2)));
    const rows = Math.ceil(tiles.length / cols);
    const gw = (SLIDE_W - inch(1.2)) / cols, gh = inch(1.7);
    tiles.slice(0, 6).forEach((t, i) => {
      const cx = inch(0.6) + (i % cols) * gw, cy = inch(1.6) + Math.floor(i / cols) * (gh + inch(0.25));
      shapes.push(sp({ x: cx, y: cy, w: gw - inch(0.25), h: gh, fill: 'F5F5F3', radius: true }));
      shapes.push(sp({ x: cx + inch(0.2), y: cy + inch(0.15), w: gw - inch(0.6), h: inch(0.35), text: { t: t.label, size: 12, color: '8A8985' } }));
      shapes.push(sp({ x: cx + inch(0.2), y: cy + inch(0.5), w: gw - inch(0.6), h: inch(0.6), text: { t: t.value, size: 30, bold: true, color: this.ci.text } }));
      if (t.delta) shapes.push(sp({ x: cx + inch(0.2), y: cy + inch(1.15), w: gw - inch(0.6), h: inch(0.35), text: { t: t.delta, size: 12, color: t.delta.startsWith('↓') || t.delta.startsWith('-') ? 'A02C2C' : '0D7A33' } }));
    });
    this.notesBand(shapes, notes, inch(1.6) + Math.ceil(Math.min(6, tiles.length) / Math.min(3, Math.max(1, Math.ceil(tiles.length / 2)))) * (inch(1.7) + inch(0.25)) + inch(0.15));
    this.brandBar(shapes);
    this.slides.push(slideXml(shapes));
    return this;
  }

  hbar(heading, rows, { valueFmt = (v) => String(v), notes = null } = {}) {
    // rows: [{name, value}] — horizontal bars in the CI accent
    const shapes = [];
    this.header(shapes, heading);
    const list = rows.slice(0, notes?.length ? 8 : 10);
    const max = Math.max(1, ...list.map((r) => r.value || 0));
    const top = inch(1.5), rowH = Math.min(inch(0.52), (SLIDE_H - (notes?.length ? inch(4.3) : inch(2.6))) / Math.max(1, list.length));
    const labelW = inch(3.4), barMax = SLIDE_W - labelW - inch(2.4);
    list.forEach((r, i) => {
      const y = top + i * rowH;
      shapes.push(sp({ x: inch(0.5), y, w: labelW, h: rowH - inch(0.08), text: { t: String(r.name).slice(0, 44), size: 12.5, color: '333333', anchor: 'ctr' } }));
      const bw = Math.max(inch(0.08), barMax * ((r.value || 0) / max));
      shapes.push(sp({ x: inch(0.55) + labelW, y: y + rowH * 0.18, w: bw, h: rowH * 0.55, fill: this.ci.accent, radius: true }));
      shapes.push(sp({ x: inch(0.65) + labelW + bw, y, w: inch(1.4), h: rowH - inch(0.08), text: { t: valueFmt(r.value || 0), size: 12, bold: true, color: this.ci.text, anchor: 'ctr' } }));
    });
    this.notesBand(shapes, notes, top + list.length * rowH + inch(0.2));
    this.brandBar(shapes);
    this.slides.push(slideXml(shapes));
    return this;
  }

  vbar(heading, days, { key = 'v', note = '', notes = null, line = null, lineLabel = '', barLabel = '' } = {}) {
    // days: [{date, v}] — a daily column chart. Optional `line` = second
    // series (array of numbers aligned with `days`) drawn as a line over the
    // bars — the report's "Volume & reach over time" combo chart.
    const shapes = [];
    this.header(shapes, heading);
    const off = Math.max(0, days.length - 62);
    const list = days.slice(off);
    const lineVals = Array.isArray(line) ? line.slice(off) : null;
    const hasLine = lineVals?.some((v) => Number(v) > 0);
    const max = Math.max(1, ...list.map((d) => d[key] || 0));
    const base = SLIDE_H - (notes?.length ? inch(3.1) : inch(1.35)), chartH = base - inch(1.7);
    const left = inch(0.6), width = SLIDE_W - inch(1.2);
    const bw = Math.max(inch(0.05), (width / Math.max(1, list.length)) - inch(0.035));
    list.forEach((d, i) => {
      const h = Math.max(inch(0.03), chartH * ((d[key] || 0) / max));
      shapes.push(sp({ x: left + i * (width / list.length), y: base - h, w: bw, h, fill: this.ci.accent }));
    });
    if (hasLine) {
      // Reach line — independent scale (its own max), points centred on bars.
      const lmax = Math.max(1, ...lineVals.map((v) => Number(v) || 0));
      const px = (i) => left + i * (width / list.length) + bw / 2;
      const py = (v) => base - Math.max(inch(0.01), chartH * ((Number(v) || 0) / lmax));
      for (let i = 1; i < list.length; i++) {
        shapes.push(lineSeg(px(i - 1), py(lineVals[i - 1]), px(i), py(lineVals[i]), this.accent2));
      }
    }
    shapes.push(sp({ x: left, y: base + inch(0.05), w: inch(2), h: inch(0.3), text: { t: String(list[0]?.date || ''), size: 10, color: '8A8985' } }));
    shapes.push(sp({ x: left + width - inch(2), y: base + inch(0.05), w: inch(2), h: inch(0.3), text: { t: String(list[list.length - 1]?.date || ''), size: 10, color: '8A8985', align: 'r' } }));
    if (hasLine || barLabel) {
      // Centre legend, like the report chart: ■ articles — reach (peak X)
      shapes.push(sp({ x: left + inch(2.2), y: base + inch(0.05), w: width - inch(4.4), h: inch(0.3), text: { align: 'ctr', lines: [{ align: 'ctr', runs: [
        { t: '■ ', size: 10, color: this.ci.accent }, { t: (barLabel || 'articles') + (hasLine ? '   ' : ''), size: 10, color: '8A8985' },
        ...(hasLine ? [{ t: '— ', size: 10, bold: true, color: this.accent2 }, { t: lineLabel || 'reach', size: 10, color: '8A8985' }] : []),
      ] }] } }));
    }
    if (note) shapes.push(sp({ x: left, y: inch(1.25), w: width, h: inch(0.35), text: { t: note, size: 12, color: '8A8985' } }));
    this.notesBand(shapes, notes, base + inch(0.45));
    this.brandBar(shapes);
    this.slides.push(slideXml(shapes));
    return this;
  }

  // Highlight-article cards — the report's "Monthly highlights" grid: up to
  // 6 cards (3 × 2), each with the headline, a sentiment word and an
  // accent meta band (market | date | publication | reach).
  cards(heading, items, { notes = null } = {}) {
    const shapes = [];
    this.header(shapes, heading);
    const list = items.slice(0, 6);
    const cols = Math.min(3, Math.max(1, list.length));
    const gw = (SLIDE_W - inch(1.2)) / cols;
    const gh = notes?.length ? inch(1.62) : inch(2.2);
    const SENT_COL = { positive: '0D7A33', negative: 'A02C2C' };
    list.forEach((it, i) => {
      const cx = inch(0.6) + (i % cols) * gw, cy = inch(1.45) + Math.floor(i / cols) * (gh + inch(0.18));
      shapes.push(sp({ x: cx, y: cy, w: gw - inch(0.22), h: gh, fill: 'FFFFFF', line: 'D9D7CF', radius: true }));
      const sent = String(it.sentiment || '').toLowerCase();
      shapes.push(sp({ x: cx + inch(0.08), y: cy + inch(0.06), w: gw - inch(0.38), h: gh - inch(0.75),
        text: { lines: [
          { t: String(it.title || '').slice(0, 92), size: notes?.length ? 11.5 : 13, bold: true, color: this.ci.text },
          ...(sent ? [{ t: sent, size: 10, bold: true, color: SENT_COL[sent] || '8A8985' }] : []),
        ] } }));
      shapes.push(sp({ x: cx + inch(0.1), y: cy + gh - inch(0.6), w: gw - inch(0.42), h: inch(0.5), fill: this.ci.accent, radius: true,
        text: { t: String(it.meta || '').slice(0, 88), size: 9.5, bold: true, color: 'FFFFFF', anchor: 'ctr' } }));
    });
    const rows = Math.ceil(list.length / cols);
    this.notesBand(shapes, notes, inch(1.45) + rows * (gh + inch(0.18)) + inch(0.1));
    this.brandBar(shapes);
    this.slides.push(slideXml(shapes));
    return this;
  }

  table(heading, headers, rows, { notes = null, widths = null, clip = null } = {}) {
    const shapes = [];
    this.header(shapes, heading);
    const list = rows.slice(0, notes?.length ? 9 : 12);
    const top = inch(1.45), rowH = Math.min(inch(0.42), (SLIDE_H - (notes?.length ? inch(4.1) : inch(2.4))) / (list.length + 1));
    // Optional relative column widths (e.g. [2.4, 1, 0.7, 1]) and per-column
    // character clips — long first columns (article titles, release names)
    // need more room than numeric columns.
    const wts = (widths && widths.length === headers.length) ? widths : headers.map(() => 1);
    const totW = wts.reduce((a, b) => a + b, 0), availW = SLIDE_W - inch(1);
    const colX = []; let accX = inch(0.5);
    const colWn = wts.map((w) => availW * (w / totW));
    wts.forEach((w, i) => { colX.push(accX); accX += colWn[i]; });
    headers.forEach((h, c) => shapes.push(sp({ x: colX[c], y: top, w: colWn[c] - inch(0.05), h: rowH, text: { t: h, size: 11, bold: true, color: '8A8985', anchor: 'ctr' } })));
    shapes.push(sp({ x: inch(0.5), y: top + rowH, w: SLIDE_W - inch(1), h: inch(0.02), fill: 'CCCCCC' }));
    list.forEach((r, i) => {
      const y = top + rowH * (i + 1) + inch(0.03);
      if (i % 2 === 1) shapes.push(sp({ x: inch(0.5), y, w: SLIDE_W - inch(1), h: rowH, fill: 'F7F7F5' }));
      r.forEach((cell, c) => shapes.push(sp({ x: colX[c], y, w: colWn[c] - inch(0.05), h: rowH, text: { t: String(cell ?? '—').slice(0, clip?.[c] ?? (c === 0 ? 42 : 24)), size: 11, color: '333333', anchor: 'ctr' } })));
    });
    this.notesBand(shapes, notes, top + rowH * (list.length + 1) + inch(0.2));
    this.brandBar(shapes);
    this.slides.push(slideXml(shapes));
    return this;
  }

  build() {
    const n = this.slides.length;
    const files = [];
    files.push({ name: '[Content_Types].xml', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
  <Default Extension="xml" ContentType="application/xml"/>
  <Default Extension="png" ContentType="image/png"/>
  <Default Extension="jpeg" ContentType="image/jpeg"/>
  <Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
  <Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
  <Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
  <Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
  ${Array.from({ length: n }, (_, i) => `<Override PartName="/ppt/slides/slide${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`).join('\n')}
  <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
  <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
</Types>` });
    files.push({ name: '_rels/.rels', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
  <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>` });
    files.push({ name: 'docProps/core.xml', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Report</dc:title><dc:creator>${escXml(this.ci.name)}</dc:creator></cp:coreProperties>` });
    files.push({ name: 'docProps/app.xml', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>SparkSoftware Reporting</Application></Properties>` });
    files.push({ name: 'ppt/presentation.xml', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
  <p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
  <p:sldIdLst>${Array.from({ length: n }, (_, i) => `<p:sldId id="${256 + i}" r:id="rId${i + 2}"/>`).join('')}</p:sldIdLst>
  <p:sldSz cx="${SLIDE_W}" cy="${SLIDE_H}"/><p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>` });
    files.push({ name: 'ppt/_rels/presentation.xml.rels', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
  ${Array.from({ length: n }, (_, i) => `<Relationship Id="rId${i + 2}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide${i + 1}.xml"/>`).join('\n')}
  <Relationship Id="rId${n + 2}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>
</Relationships>` });
    const clrScheme = `<a:clrScheme name="Spark"><a:dk1><a:srgbClr val="111111"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="333333"/></a:dk2><a:lt2><a:srgbClr val="F2F2F0"/></a:lt2><a:accent1><a:srgbClr val="${hex(this.ci.accent)}"/></a:accent1><a:accent2><a:srgbClr val="6E62E5"/></a:accent2><a:accent3><a:srgbClr val="12A06E"/></a:accent3><a:accent4><a:srgbClr val="8A8985"/></a:accent4><a:accent5><a:srgbClr val="C14C1E"/></a:accent5><a:accent6><a:srgbClr val="131312"/></a:accent6><a:hlink><a:srgbClr val="C14C1E"/></a:hlink><a:folHlink><a:srgbClr val="8A5A00"/></a:folHlink></a:clrScheme>`;
    files.push({ name: 'ppt/theme/theme1.xml', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Spark"><a:themeElements>${clrScheme}
<a:fontScheme name="Spark"><a:majorFont><a:latin typeface="${this.font}"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="${this.font}"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme>
<a:fmtScheme name="Office"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst>
<a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="28575"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst>
<a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst>
<a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme>
</a:themeElements></a:theme>` });
    const emptyTree = `<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>`;
    files.push({ name: 'ppt/slideMasters/slideMaster1.xml', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">${emptyTree}
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>` });
    files.push({ name: 'ppt/slideMasters/_rels/slideMaster1.xml.rels', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>` });
    files.push({ name: 'ppt/slideLayouts/slideLayout1.xml', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank">${emptyTree}<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>` });
    files.push({ name: 'ppt/slideLayouts/_rels/slideLayout1.xml.rels', data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>` });
    if (this.logoData) files.push({ name: `ppt/media/logo.${this.logoData.ext}`, data: this.logoData.buf });
    const logoRel = this.logoData
      ? `\n  <Relationship Id="rIdLogo" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/logo.${this.logoData.ext}"/>`
      : '';
    this.slides.forEach((xml, i) => {
      files.push({ name: `ppt/slides/slide${i + 1}.xml`, data: xml });
      files.push({ name: `ppt/slides/_rels/slide${i + 1}.xml.rels`, data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>${logoRel}
</Relationships>` });
    });
    return buildZip(files);
  }
}
