// postcard.jsx — 电子明信片
// 概念:访客把馆藏文物转到自己喜欢的角度,那个角度就是明信片的正面。
//       转 → 写 → 寄,三步构成仪式;同一张图将来也用于馆内纸质版。

// 邮寄通道开关。后端需配齐 RESEND_API_KEY + POSTCARD_FROM + TURNSTILE_SECRET
// 才能真正发信;在那之前主动作是「保存 / 分享」,不摆一个填了发不出去的表单。
const MAIL_ON = false;

// 手机上真正有用的不是「下载」而是系统分享面板 —— 一点就能直接发微信。
// iOS Safari 对 <a download> 的支持很差,Web Share API 才是正路。
async function shareCard(file, title) {
  try {
    if (navigator.canShare && navigator.canShare({ files: [file] })) {
      await navigator.share({ files: [file], title });
      return 'shared';
    }
  } catch (e) {
    if (e && e.name === 'AbortError') return 'cancelled';
  }
  return 'unsupported';
}

function canShareFiles() {
  try {
    return !!(navigator.canShare && navigator.canShare({
      files: [new File([new Blob([1])], 'a.png', { type: 'image/png' })]
    }));
  } catch (e) { return false; }
}

// ── 印刷质感处理 ────────────────────────────────────────────────
// 参考本馆那两张画稿的语言:半调网点 + 平涂专色 / 水墨。
// 在画布里实时做,访客转到哪个角度就印哪个角度。

function toImage(src) {
  return new Promise(r => {
    const im = new Image();
    im.onload = () => r(im); im.onerror = () => r(null);
    im.src = src;
  });
}

// 半调网点:按亮度画大小不等的圆点,网格旋转 15° 才像真的印刷
function halftone(img, { cell = 7, angle = 0.26, ink = '#1a1a1a', bg = null, gamma = 1.0 } = {}) {
  const w = img.width, h = img.height;
  const src = document.createElement('canvas'); src.width = w; src.height = h;
  const sx = src.getContext('2d'); sx.drawImage(img, 0, 0);
  const d = sx.getImageData(0, 0, w, h).data;

  const out = document.createElement('canvas'); out.width = w; out.height = h;
  const x = out.getContext('2d');
  if (bg) { x.fillStyle = bg; x.fillRect(0, 0, w, h); }
  x.fillStyle = ink;

  const cos = Math.cos(angle), sin = Math.sin(angle);
  const diag = Math.ceil(Math.hypot(w, h));
  for (let v = -diag; v < diag; v += cell) {
    for (let u = -diag; u < diag; u += cell) {
      const px = Math.round(u * cos - v * sin + w / 2);
      const py = Math.round(u * sin + v * cos + h / 2);
      if (px < 0 || py < 0 || px >= w || py >= h) continue;
      const i = (py * w + px) * 4;
      if (d[i + 3] < 24) continue;                       // 透明处不印
      const lum = (0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]) / 255;
      const k = Math.pow(1 - lum, gamma) * (d[i + 3] / 255);
      const r = k * cell * 0.78;
      if (r < 0.35) continue;
      x.beginPath(); x.arc(px, py, r, 0, Math.PI * 2); x.fill();
    }
  }
  return out;
}

// 双色印刷:亮度映射到两种油墨之间
function duotone(img, dark, light) {
  const w = img.width, h = img.height;
  const c = document.createElement('canvas'); c.width = w; c.height = h;
  const x = c.getContext('2d'); x.drawImage(img, 0, 0);
  const im = x.getImageData(0, 0, w, h), d = im.data;
  const A = hex(dark), B = hex(light);
  for (let i = 0; i < d.length; i += 4) {
    if (d[i + 3] < 8) continue;
    const t = (0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]) / 255;
    d[i]     = A[0] + (B[0] - A[0]) * t;
    d[i + 1] = A[1] + (B[1] - A[1]) * t;
    d[i + 2] = A[2] + (B[2] - A[2]) * t;
  }
  x.putImageData(im, 0, 0);
  return c;
}

// 水墨:压暗、拉对比、留飞白 —— 不做描边,靠灰阶自己出笔触感
function inkwash(img, ink = [26, 24, 22]) {
  const w = img.width, h = img.height;
  const c = document.createElement('canvas'); c.width = w; c.height = h;
  const x = c.getContext('2d'); x.drawImage(img, 0, 0);
  const im = x.getImageData(0, 0, w, h), d = im.data;
  for (let i = 0; i < d.length; i += 4) {
    if (d[i + 3] < 8) continue;
    let t = (0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]) / 255;
    t = Math.min(1, Math.max(0, (t - 0.16) / 0.62));      // 拉对比
    t = Math.pow(t, 1.35);
    const n = (Math.random() - 0.5) * 0.07;               // 纸面颗粒,制造飞白
    const k = Math.min(1, Math.max(0, 1 - t + n));
    d[i] = ink[0]; d[i + 1] = ink[1]; d[i + 2] = ink[2];
    d[i + 3] = Math.round(d[i + 3] * k);
  }
  x.putImageData(im, 0, 0);
  return c;
}

// 中英混排按字符逐个量宽换行,英文优先在空格处断
function wrap(ctx, text, maxW, maxLines) {
  const out = []; let line = '';
  for (const ch of text.replace(/\s+/g, ' ')) {
    if (ctx.measureText(line + ch).width > maxW && line) {
      const sp = /[A-Za-z0-9]/.test(ch) ? line.lastIndexOf(' ') : -1;
      if (sp > maxW / 40) { out.push(line.slice(0, sp)); line = line.slice(sp + 1) + ch; }
      else { out.push(line); line = ch; }
      if (out.length === maxLines) return out;
    } else line += ch;
  }
  if (line && out.length < maxLines) out.push(line);
  if (out.length === maxLines && line && out[maxLines-1] !== line) out[maxLines-1] += '…';
  return out;
}

function hex(s) {
  const n = parseInt(s.slice(1), 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

// 按版式把器物图处理成对应质感,返回可直接 drawImage 的 canvas
async function treat(objPng, mode) {
  const img = await toImage(objPng);
  if (!img) return null;
  if (mode === 'riso') {
    // 蓝印半调:一层专色网点 + 一层黑网点,网格错开角度,像双色套印
    const w = img.width, h = img.height;
    const c = document.createElement('canvas'); c.width = w; c.height = h;
    const x = c.getContext('2d');
    x.globalAlpha = 0.9;  x.drawImage(halftone(img, { cell: 8, angle: 0.26, ink: '#1b3fd8', gamma: 1.7 }), 0, 0);
    x.globalAlpha = 0.85; x.drawImage(halftone(img, { cell: 7, angle: 1.05, ink: '#1d1a14', gamma: 1.15 }), 0, 0);
    return c;
  }
  if (mode === 'ink')  return inkwash(img);
  if (mode === 'duo')  return duotone(img, '#2b2118', '#e8dcc4');
  return img;               // 纸本:原样
}

// 两套版式:横版纸本(3:2)与竖版珂罗版(2:3,用本馆那张蓝印建筑稿做底)
// 四套版式,各自是一种完整的印刷语言
const CARDS = {
  paper: { w:1500, h:1000, bg:null,                   treat:'photo', objTop:0.36, objBox:[0.62,0.50], plateY:0.705,
           paper:'#f4efe6', ink:'#161412', mute:'#6b645b', rule:'#b4392b', foot:'rgba(26,24,22,0.14)', shadow:true,  grain:true  },
  riso:  { w:1000, h:1500, bg:'assets/bldg-tall.jpg', treat:'riso',  objTop:0.40, objBox:[0.70,0.34], plateY:0.055, plateAlign:'center',
           paper:'#e2cc9c', ink:'#1d1a14', mute:'#6f6248', rule:'#1b3fd8', foot:'rgba(29,26,20,0.20)', shadow:false, grain:false },
  ink:   { w:1500, h:1000, bg:'assets/bldg-wide.jpg', treat:'ink',   objTop:0.32, objBox:[0.54,0.44], plateY:0.70, plateAlign:'left',
           paper:'#f4ecdf', ink:'#161412', mute:'#6b645b', rule:'#161412', foot:'rgba(26,24,22,0.14)', shadow:false, grain:false },
  duo:   { w:1500, h:1000, bg:null,                   treat:'duo',   objTop:0.36, objBox:[0.62,0.50], plateY:0.705,
           paper:'#efe6d4', ink:'#2b2118', mute:'#7a6a52', rule:'#2b2118', foot:'rgba(43,33,24,0.18)', shadow:true,  grain:true  }
};

// 把三维截图合成到一张卡上:纸底 / 画稿、器物、展签、馆名。
// 全部用 2D canvas 画,不依赖字体加载顺序以外的东西。
function composeCard(objPng, name, sub, styleKey, message) {
  if (message && message.trim()) return composeCard(objPng, name, sub, styleKey, '')
    .then(front => composeComplete(front, name, message, 'zh'));
  const S = CARDS[styleKey] || CARDS.paper;
  const W = S.w, H = S.h, k = W / 1500 * (styleKey === 'riso' ? 1.28 : 1);
  return new Promise(resolve => {
    const c = document.createElement('canvas');
    c.width = W; c.height = H;
    const x = c.getContext('2d');

    x.fillStyle = S.paper;
    x.fillRect(0, 0, W, H);

    if (S.grain) {
      // 中心微亮四周略沉,模仿纸张受光
      const g = x.createRadialGradient(W*0.5, H*0.42, 40, W*0.5, H*0.42, W*0.62);
      g.addColorStop(0, 'rgba(255,253,248,0.85)');
      g.addColorStop(1, 'rgba(232,224,209,0)');
      x.fillStyle = g; x.fillRect(0, 0, W, H);
      // 纸纹
      const n = x.createImageData(W, H), d = n.data;
      for (let i = 0; i < d.length; i += 4) {
        const v = 244 + (Math.random()*16 - 8);
        d[i]=v; d[i+1]=v-5; d[i+2]=v-14; d[i+3]=18;
      }
      const nc = document.createElement('canvas'); nc.width=W; nc.height=H;
      nc.getContext('2d').putImageData(n, 0, 0);
      x.globalAlpha = 0.55; x.drawImage(nc, 0, 0); x.globalAlpha = 1;
    }

    function plate() {
      // 有留言时展签整块上移,给三行字让出空间(顶部题头的珂罗版不用让)
      const hasMsg = !!(message && message.trim());
      const py = H * (hasMsg && S.plateY > 0.5 ? S.plateY - 0.09 : S.plateY);
      const left = S.plateAlign === 'left';
      const cx = left ? W * 0.065 : W / 2;
      x.fillStyle = S.rule;
      x.fillRect(left ? cx : cx - 16*k, py, 32*k, Math.max(1.5*k, 1.2));
      x.textAlign = left ? 'left' : 'center'; x.fillStyle = S.ink;
      x.font = '500 ' + Math.round(30*k) + 'px "Avenir Next", Avenir, Nunito, Inter, "PingFang SC", sans-serif';
      x.fillText(name, cx, py + 46*k);
      x.fillStyle = S.mute;
      x.font = '400 ' + Math.round(17*k) + 'px "JetBrains Mono", ui-monospace, Menlo, monospace';
      x.fillText(sub, cx, py + 80*k);
      x.fillText('Nantong, China', cx, py + 112*k);

      // 访客写的话直接印在卡上 —— 不经过任何后端,保存/分享出去就带着
      if (hasMsg) {
        const fs = Math.round(21*k);
        x.font = '400 ' + fs + 'px "Noto Serif SC", "Songti SC", "PingFang SC", Georgia, serif';
        x.fillStyle = S.ink;
        const maxW = W * (left ? 0.52 : 0.68);
        const lines = wrap(x, message.trim(), maxW, 3);
        let ly = py + 152*k;
        lines.forEach(t => { x.fillText(t, cx, ly); ly += fs * 1.62; });
      }

      const pad = 70*k, fy = H - 78*k;
      x.strokeStyle = S.foot; x.lineWidth = 1;
      x.beginPath(); x.moveTo(pad, fy); x.lineTo(W - pad, fy); x.stroke();
      x.font = '400 ' + Math.round(15*k) + 'px "JetBrains Mono", ui-monospace, Menlo, monospace';
      x.fillStyle = S.mute; x.textAlign = 'left';
      x.fillText('南通市富美帽饰博物馆', pad, fy + 30*k);
      x.textAlign = 'right';
      x.fillText(location.host, W - pad, fy + 30*k);

      resolve(c.toDataURL('image/png'));
    }

    function shadowThenPlate() {
      if (!S.shadow) return plate();      // 珂罗版底稿自带地面
      x.save();
      x.translate(W/2, H*0.635);
      x.scale(1, 0.13);
      const sh = x.createRadialGradient(0,0,0, 0,0,210);
      sh.addColorStop(0, 'rgba(34,28,22,0.22)');
      sh.addColorStop(1, 'rgba(34,28,22,0)');
      x.fillStyle = sh; x.beginPath(); x.arc(0,0,210,0,Math.PI*2); x.fill();
      x.restore();
      plate();
    }

    async function drawObj() {
      if (!objPng) return shadowThenPlate();
      const layer = await treat(objPng, S.treat);      // 按版式做半调 / 双色 / 水墨
      if (!layer) return shadowThenPlate();
      const s = Math.min(W*S.objBox[0]/layer.width, H*S.objBox[1]/layer.height);
      const w = layer.width*s, h = layer.height*s;
      x.drawImage(layer, (W-w)/2, H*S.objTop - h/2, w, h);
      shadowThenPlate();
    }

    if (S.bg) {
      const bg = new Image();
      bg.onload = () => {                 // 画稿等比铺满卡面,底对底
        const s = Math.max(W/bg.width, H/bg.height);
        const w = bg.width*s, h = bg.height*s;
        x.drawImage(bg, (W-w)/2, H-h, w, h);
        drawObj();
      };
      bg.onerror = drawObj;
      bg.src = S.bg;
    } else drawObj();
  });
}

// 截图可能返回一张「合法但空白」的图(弹窗刚提交 DOM 时出现过),
// 所以不能只判非空,必须抽样验证真的有像素,否则重试。
async function hasContent(dataUrl) {
  if (!dataUrl) return false;
  const img = new Image();
  await new Promise(r => { img.onload = r; img.onerror = r; img.src = dataUrl; });
  if (!img.width) return false;
  const c = document.createElement('canvas');
  c.width = 60; c.height = 40;
  const x = c.getContext('2d');
  x.drawImage(img, 0, 0, 60, 40);
  const d = x.getImageData(0, 0, 60, 40).data;
  let op = 0;
  for (let i = 3; i < d.length; i += 4) if (d[i] > 12) op++;
  return op > 40;
}

async function grabShot(v) {
  for (let i = 0; i < 6; i++) {
    let shot = null;
    try { shot = v && v.capture ? v.capture(1100, 760) : null; } catch (e) { shot = null; }
    if (await hasContent(shot)) return shot;
    await new Promise(r => setTimeout(r, 150));
  }
  return null;
}

// User-supplied finished artworks. Never crop, overlay text, or re-filter the front.
const POSTCARD_ART = [
  {id:'curve-opens', zh:'弧线展开', en:'The Curve Opens', w:1024, h:1536},
  {id:'flight-held-still', zh:'凝住的飞翔', en:'A Flight Held Still', w:1024, h:1536},
  {id:'heron-ink', zh:'水墨鹭影', en:'Heron in Ink', w:971, h:1619},
  {id:'nantong-blue', zh:'南通蓝印', en:'Nantong, China', w:1086, h:1448},
  {id:'outside-score', zh:'红马与柱廊', en:'Outside the Score', w:1536, h:1024},
  {id:'arches-steps', zh:'拱门与阶梯', en:'Arches / Steps / Stillness', w:971, h:1619},
  {id:'heritage', zh:'藏 · 承', en:'Heritage', w:971, h:1619},
  {id:'arc-water', zh:'弧线与水院', en:'Arc / Water / Stillness', w:971, h:1619},
  {id:'hats-begin', zh:'帽子从这里开始', en:'Hats Begin Here', w:1086, h:1448},
  {id:'look-away', zh:'回望', en:'Look Away Again', w:1537, h:1023},
  {id:'garden-sky', zh:'建筑、花园与天空', en:'Buildings / Garden / Sky', w:971, h:1619}
];

let postcardFontReady;
function loadPostcardHand() {
  if (!postcardFontReady) postcardFontReady = document.fonts.load('36px "Postcard Hand"', '你好 Love').catch(() => []);
  return postcardFontReady;
}

function fitPostcardText(ctx, text, box, fontSize, family, lineHeight = 1.55) {
  const normalized = String(text || '').replace(/\r/g, '');
  let lines, fs = fontSize;
  do {
    ctx.font = `${fs}px ${family}`;
    lines = [];
    for (const paragraph of normalized.split('\n')) {
      let line = '';
      for (const ch of paragraph) {
        if (line && ctx.measureText(line + ch).width > box.w) { lines.push(line); line = ''; }
        line += ch;
      }
      lines.push(line);
    }
    if (lines.length * fs * lineHeight <= box.h) break;
    fs *= .92;
  } while (fs > .5);
  ctx.textAlign = 'left';
  lines.forEach((line, i) => ctx.fillText(line, box.x, box.y + fs + i * fs * lineHeight));
  return {lines, fontSize:fs};
}

async function composeReverse(format, name, message, lang, recipient = '', sender = '') {
  await loadPostcardHand();
  // Rotate a portrait card's reverse into a traditional landscape writing face.
  const W = Math.max(format.w, format.h), H = Math.min(format.w, format.h), k = H/1000;
  const c = document.createElement('canvas'); c.width=W; c.height=H;
  const x=c.getContext('2d'), pad=70*k, split=W*.62;
  const hand='"Postcard Hand", "Kaiti SC", KaiTi, cursive';
  x.fillStyle='#faf5e9';x.fillRect(0,0,W,H);
  x.fillStyle='#876a52';x.font=`${27*k}px Georgia, serif`;
  x.fillText('POST CARD / 明信片',pad,95*k);
  x.strokeStyle='#d5c5ac';x.lineWidth=k;
  for(let i=0;i<6;i++) x.strokeRect(pad+i*34*k,125*k,25*k,31*k);
  x.beginPath();x.moveTo(split,260*k);x.lineTo(split,H-155*k);x.stroke();
  const stampX=W-pad-116*k;
  x.setLineDash([4*k,5*k]);x.strokeStyle='#ae5e4c';x.strokeRect(stampX,65*k,116*k,155*k);x.setLineDash([]);
  x.fillStyle='#b4392b';x.textAlign='center';x.font=`${44*k}px ${hand}`;x.fillText('帽',stampX+58*k,130*k);
  x.font=`${15*k}px Georgia, serif`;x.fillText('NANTONG',stampX+58*k,168*k);x.fillText('DIGITAL',stampX+58*k,193*k);
  x.strokeStyle='#9f8b73';x.beginPath();x.arc(stampX-97*k,149*k,62*k,0,Math.PI*2);x.stroke();
  x.fillStyle='#7b6956';x.font=`${14*k}px Georgia, serif`;x.fillText('NANTONG',stampX-97*k,130*k);
  x.fillText(new Date().toLocaleDateString('en-CA',{timeZone:'Asia/Shanghai'}),stampX-97*k,155*k);
  x.fillText('电子纪念',stampX-97*k,178*k);
  x.fillStyle='#243749';
  fitPostcardText(x,message,{x:pad,y:230*k,w:split-pad-48*k,h:H-420*k},45*k,hand);
  const right=split+48*k, rw=W-right-pad;
  x.fillStyle='#8c7660';x.textAlign='left';x.font=`${18*k}px Georgia, serif`;x.fillText('TO / 致',right,310*k);
  x.fillStyle='#243749';fitPostcardText(x,recipient,{x:right,y:332*k,w:rw,h:180*k},43*k,hand);
  x.strokeStyle='#d5c5ac';
  [510,595,680].forEach(y=>{x.beginPath();x.moveTo(right,y*k);x.lineTo(W-pad,y*k);x.stroke()});
  x.fillStyle='#8c7660';x.font=`${18*k}px Georgia, serif`;x.fillText('FROM / 来自',pad,H-143*k);
  x.fillStyle='#243749';fitPostcardText(x,sender || (lang==='zh'?'南通，富美帽饰博物馆':'Foremost Hat Museum, Nantong'),{x:pad,y:H-128*k,w:split-pad-48*k,h:80*k},30*k,hand);
  x.fillStyle='#8c7660';x.font=`${17*k}px Georgia, serif`;x.fillText('Nantong, China',right,H-143*k);
  fitPostcardText(x,name,{x:right,y:H-121*k,w:rw,h:53*k},18*k,'Georgia, "Microsoft YaHei", serif');
  x.textAlign='right';x.font=`${15*k}px Georgia, serif`;x.fillText(location.host + ' · DIGITAL POSTCARD',W-pad,H-28*k);
  return c.toDataURL('image/png');
}

async function composeArtwork(art, side, message, lang, recipient = '', sender = '') {
  if (side === 'back') return composeReverse(art, art[lang], message, lang, recipient, sender);
  const c = document.createElement('canvas');
  c.width = art.w; c.height = art.h;
  const x = c.getContext('2d');
  if (side === 'front') {
    const img = await toImage(`assets/postcards/${art.id}.webp`);
    if (!img) throw new Error('Artwork unavailable');
    x.drawImage(img, 0, 0, art.w, art.h);
  } else {
    const W = art.w, H = art.h, u = Math.min(W, H), pad = u * .085;
    x.fillStyle = '#f4efe6'; x.fillRect(0, 0, W, H);
    x.strokeStyle = '#b4392b'; x.lineWidth = 2;
    x.strokeRect(W-pad-u*.13, pad, u*.13, u*.16);
    x.fillStyle = '#b4392b'; x.textAlign = 'center';
    x.font = `${u*.023}px Georgia, serif`;
    x.fillText('NANTONG', W-pad-u*.065, pad+u*.072);
    x.fillText('CHINA', W-pad-u*.065, pad+u*.111);
    x.textAlign = 'left'; x.fillStyle = '#b4392b';
    x.font = `${u*.033}px Georgia, serif`;
    x.fillText('POSTCARD / 明信片', pad, pad+u*.045);
    x.fillStyle = '#6b645b'; x.font = `${u*.021}px Georgia, serif`;
    x.fillText(lang === 'zh' ? art.zh : art.en, pad, pad+u*.092, W-pad*2-u*.17);
    // Fit every character of the 140-character note, including explicit line breaks.
    // Grow the writing area vertically instead of putting any text on the artwork.
    const top = pad+u*.25, bottom = H-pad-u*.12, maxW = W-2*pad;
    let fs = u*.037, lines;
    const splitLines = () => {
      const result = [];
      for (const paragraph of message.replace(/\r/g, '').split('\n')) {
        let line = '';
        for (const char of paragraph) {
          if (line && x.measureText(line+char).width > maxW) { result.push(line); line = ''; }
          line += char;
        }
        result.push(line);
      }
      return result;
    };
    do {
      x.font = `${fs}px "PingFang SC", "Microsoft YaHei", Georgia, serif`;
      lines = splitLines();
      if (lines.length*fs*1.7 <= bottom-top) break;
      fs *= .9;
    } while (fs > 1);
    x.fillStyle = '#26221d';
    lines.forEach((line, i) => x.fillText(line, pad, top+fs+i*fs*1.7));
    x.strokeStyle = '#c9bfb0'; x.lineWidth = 1;
    x.beginPath(); x.moveTo(pad, H-pad-u*.065); x.lineTo(W-pad, H-pad-u*.065); x.stroke();
    x.font = `${u*.021}px "PingFang SC", "Microsoft YaHei", sans-serif`;
    x.fillStyle = '#6b645b'; x.fillText('南通市富美帽饰博物馆', pad, H-pad);
    x.textAlign = 'right'; x.fillText(location.host, W-pad, H-pad);
  }
  return c.toDataURL('image/png');
}

// A single digital keepsake: preserve every pixel of the front, then add a
// writing panel below it. The exported file is exactly the previewed image.
async function composeComplete(front, name, message, lang, recipient = '', sender = '') {
  await loadPostcardHand();
  const img = await toImage(front);
  if (!img) throw new Error('Front unavailable');
  const W = img.width, k = W / 1000, pad = 65*k;
  const c = document.createElement('canvas'), x = c.getContext('2d');
  const hand = '"Postcard Hand", "Kaiti SC", KaiTi, cursive';
  // Collapse excess blank lines only; never truncate a visitor's words.
  const text = String(message || '').replace(/\r/g, '').replace(/\n{3,}/g, '\n\n');
  x.font = `${33*k}px ${hand}`;
  const lines = [];
  for (const paragraph of text.split('\n')) {
    let line = '';
    for (const ch of paragraph) {
      if (line && x.measureText(line + ch).width > W-2*pad) { lines.push(line); line = ''; }
      line += ch;
    }
    lines.push(line);
  }
  const bodyH = Math.max(100*k, lines.length*51*k);
  const H = Math.ceil(315*k + bodyH);
  c.width = W; c.height = img.height + H;
  x.drawImage(img, 0, 0); // no cropping, filtering, or text over the artwork
  x.save(); x.translate(0, img.height);
  x.fillStyle = '#faf5e9'; x.fillRect(0,0,W,H);
  x.strokeStyle = '#d5c5ac'; x.lineWidth = k;
  x.beginPath();x.moveTo(pad,20*k);x.lineTo(W-pad,20*k);x.stroke();
  x.fillStyle = '#a43b30'; x.font = `${18*k}px Georgia, serif`;
  x.fillText(lang === 'zh' ? '来自南通的一张明信片' : 'A POSTCARD FROM NANTONG',pad,68*k);
  x.textAlign = 'right'; x.fillStyle = '#786959'; x.font = `${14*k}px Georgia, serif`;
  x.fillText(new Date().toLocaleDateString('en-CA',{timeZone:'Asia/Shanghai'}),W-pad,68*k);
  x.fillStyle = '#243749';
  fitPostcardText(x, recipient ? `${lang==='zh'?'致':'To'} ${recipient}` : '',
    {x:pad,y:96*k,w:W-2*pad,h:75*k},28*k,hand,1.3);
  x.font = `${33*k}px ${hand}`; x.textAlign = 'left';
  lines.forEach((line,i)=>x.fillText(line,pad,210*k+i*51*k));
  const fy = H-91*k;
  fitPostcardText(x,sender ? `${lang==='zh'?'来自':'From'} ${sender}` : '',
    {x:pad,y:fy-35*k,w:W-2*pad,h:60*k},25*k,hand,1.3);
  x.strokeStyle='#d5c5ac';x.beginPath();x.moveTo(pad,H-55*k);x.lineTo(W-pad,H-55*k);x.stroke();
  x.fillStyle='#786959';
  fitPostcardText(x,name,{x:pad,y:H-42*k,w:W*.6,h:28*k},14*k,'Georgia, "Microsoft YaHei", serif',1.2);
  x.textAlign='right';x.font=`${14*k}px Georgia, serif`;x.fillText(location.host,W-pad,H-24*k);
  x.restore();
  return c.toDataURL('image/png');
}

function Postcard({ lang, shot, object, returnFocusRef, onClose }) {
  const t = T.card[lang];
  const [png, setPng] = React.useState(null);
  const [msg, setMsg] = React.useState('');
  const [from, setFrom] = React.useState('');
  const [recipient, setRecipient] = React.useState('');
  const [saveView, setSaveView] = React.useState(false);
  const [to, setTo] = React.useState('');
  const [state, setState] = React.useState('idle');   // idle | sending | sent | error
  const [style, setStyle] = React.useState('paper');  // paper | riso | ink | duo
  const [kind, setKind] = React.useState(shot ? 'object' : 'art');
  const [artId, setArtId] = React.useState(POSTCARD_ART[0].id);
  const [side, setSide] = React.useState('complete');
  const frontCache = React.useRef(null);
  const [renderedFor, setRenderedFor] = React.useState(null);
  const [renderError, setRenderError] = React.useState(false);
  const [retry, setRetry] = React.useState(0);
  const dialogRef = React.useRef(null);
  const closeRef = React.useRef(onClose);
  closeRef.current = () => saveView ? setSaveView(false) : onClose();
  const art = POSTCARD_ART.find(a => a.id === artId);
  const isArt = kind === 'art';
  const [sharing, setSharing] = React.useState(false);
  const [shared, setShared] = React.useState(false);
  const [err, setErr] = React.useState('');
  const name = lang === 'zh' ? object.zh : object.en;
  const sub = lang === 'zh' ? object.zhSub : object.enSub;
  const cardName = isArt ? art[lang] : name;
  const filename = isArt ? `foremosthatmuseum-${artId}-${side}.png` : `foremosthatmuseum-${object.slug}-${style}-${side}.png`;
  const exportFile = React.useMemo(() => {
    if (!png) return null;
    const bytes = Uint8Array.from(atob(png.split(',')[1]), c => c.charCodeAt(0));
    return new File([bytes], filename, {type:'image/png'});
  }, [png, filename]);

  // shot 由首屏在「弹窗尚未覆盖画布」时截好后传进来 —— 这里只负责排版。
  // 换版式时用同一帧重新合成,角度保持一致。
  // 留言会印到卡上,所以改字也要重排;打字时防抖,不每个键都重绘
  const [deb, setDeb] = React.useState('');
  const renderInputs = [shot, name, sub, style, kind, artId, side, lang, recipient, from, deb];
  React.useEffect(() => {
    const id = setTimeout(() => setDeb(msg), 420);
    return () => clearTimeout(id);
  }, [msg]);
  React.useEffect(() => {
    let alive = true;
    setPng(null);
    setRenderError(false);
    const frontInputs = [shot, name, sub, style, kind, artId, lang, retry];
    if (!frontCache.current || !frontCache.current.inputs.every((v,i)=>v===frontInputs[i])) {
      frontCache.current = {inputs:frontInputs, promise:isArt
        ? composeArtwork(art, 'front', '', lang)
        : composeCard(shot, name, sub, style, '')};
    }
    const render = frontCache.current.promise.then(front => side === 'front' ? front
      : composeComplete(front, cardName, deb, lang, recipient, from));
    render.then(v => { if (alive) {setPng(v);setRenderedFor(renderInputs);} }).catch(() => { if (alive) setRenderError(true); });
    return () => { alive = false; };
  }, [shot, name, sub, style, deb, kind, artId, side, lang, retry, recipient, from]);
  // Do not export the previous message during the debounce interval.
  const ready = !!png && renderedFor?.every((v,i)=>v===renderInputs[i]) && (side === 'front' || msg === deb);
  React.useEffect(() => {
    const panel = dialogRef.current?.querySelector('.pc-expanded');
    if (panel) panel.scrollTop = 0;
    if (saveView) dialogRef.current?.querySelector('.pc-save-view button')?.focus({preventScroll:true});
  }, [saveView]);
  const showShare = async () => {
    if (!ready || !exportFile) return;
    setSharing(true);
    const result = await shareCard(exportFile, cardName);
    setSharing(false);
    if (result === 'shared') { setShared(true); setTimeout(() => setShared(false), 2600); }
    if (result === 'unsupported') setSaveView(true);
  };

  React.useEffect(() => {
    const previousFocus = returnFocusRef?.current || document.activeElement;
    const previousOverflow = document.body.style.overflow;
    const esc = e => {
      if (e.key === 'Escape') closeRef.current();
      if (e.key !== 'Tab') return;
      const items = Array.from(dialogRef.current.querySelectorAll('button:not(:disabled), a[href], textarea, input, [tabindex="0"]'))
        .filter(el => el.getClientRects().length);
      const first = items[0], last = items[items.length-1];
      if (e.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) { e.preventDefault(); last?.focus(); }
      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); }
    };
    window.addEventListener('keydown', esc);
    document.body.style.overflow = 'hidden';
    dialogRef.current.focus({preventScroll: true});
    return () => {
      window.removeEventListener('keydown', esc); document.body.style.overflow = previousOverflow;
      if (previousFocus?.isConnected) previousFocus.focus({preventScroll: true});
    };
  }, []);

  const send = async () => {
    setErr('');
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(to)) { setErr(t.errMail); return; }
    if (!msg.trim()) { setErr(t.errMsg); return; }
    setState('sending');
    try {
      const r = await fetch('/api/postcard', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ to, from: from.trim(), message: msg.trim(), lang, object: name, image: png })
      });
      // 501 = 后端未配齐;404 = 本地预览没有 Functions —— 都提示先保存图片
      if (r.status === 501 || r.status === 404) { setState('error'); setErr(t.offline); return; }
      if (!r.ok) throw new Error(String(r.status));
      setState('sent');
    } catch (e) { setState('error'); setErr(t.errNet); }
  };

  return (
    <div className="pc-veil" role="dialog" aria-modal="true" aria-label={t.title} ref={dialogRef} tabIndex={-1} onMouseDown={e => { if (e.target === e.currentTarget) closeRef.current(); }}>
      <button className="pc-x" onClick={() => closeRef.current()} aria-label={saveView ? t.backToEdit : t.close}>×</button>
      <div className="pc pc-expanded">
        {saveView ? <div className="pc-save-view">
          <button className="pc-chip" onClick={() => setSaveView(false)}>← {t.backToEdit}</button>
          <h3 className="pc-title">{t.albumTitle}</h3>
          <p className="pc-lede">{/MicroMessenger/i.test(navigator.userAgent) ? t.wechatSave : t.albumHelp}</p>
          <div className="pc-save-actions">
            {canShareFiles() && <button className="pc-act" disabled={sharing} onClick={showShare}>{t.systemMenu}</button>}
            <a className="pc-ghost" href={png} download={filename}>{t.fileDownload}</a>
          </div>
          <img className="pc-save-image" src={png} alt={cardName} />
          <p className="pc-note">{t.albumFallback}</p>
        </div> : <>
        <div className="pc-kinds" role="group" aria-label={t.styleLabel}>
          <button className={'pc-kind' + (isArt ? ' on' : '')} aria-pressed={isArt}
            onClick={() => setKind('art')}>{t.artLabel}</button>
          <button className={'pc-kind' + (!isArt ? ' on' : '')} aria-pressed={!isArt} disabled={!shot}
            onClick={() => setKind('object')}>{t.objectLabel}</button>
        </div>
        {isArt && <p className="pc-picker-hint">{lang === 'zh' ? '左右滑动选卡 · 画面和留言，一张保存' : 'Choose an artwork · Picture and message, saved together'}</p>}
        {isArt && <div className="pc-art-picker" role="group" aria-label={t.artLabel}>
          {POSTCARD_ART.map((a, i) => <button key={a.id} className={'pc-art-option' + (artId === a.id ? ' on' : '')}
            aria-pressed={artId === a.id} aria-label={`${i+1}. ${a[lang]}`}
            onClick={e => { setArtId(a.id); e.currentTarget.scrollIntoView({block:'nearest', inline:'nearest'}); }}>
            <img src={`assets/postcards/${a.id}-thumb.webp`} alt="" decoding="async" width={a.w} height={a.h} />
            <span>{String(i+1).padStart(2,'0')} · {a[lang]}</span>
          </button>)}
        </div>}
        <div className="pc-grid">

          <div className="pc-side">
            <div className="pc-faces" role="group" aria-label={cardName}>
              <button aria-pressed={side === 'complete'} onClick={() => setSide('complete')}>{t.complete}</button>
              <button aria-pressed={side === 'front'} onClick={() => setSide('front')}>{t.imageOnly}</button>
            </div>
            <div className="pc-card pc-card-art" aria-busy={!png && !renderError}>
              {png
                ? <img key={`${kind}-${artId}-${style}-${side}`} className="pc-art-face" src={png} alt={`${cardName} · ${side === 'front' ? t.imageOnly : t.complete}`} />
                : <div className="pc-skel mono" role="status">{renderError ? <>{t.renderError}<button className="pc-chip" onClick={() => setRetry(v => v+1)}>{t.retry}</button></> : t.sharing}</div>}
            </div>
            {!isArt && <div className="pc-styles mono">
              <span className="pc-styles-l">{t.styleLabel}</span>
              {[['paper', t.stylePaper], ['riso', t.styleRiso], ['ink', t.styleInk], ['duo', t.styleDuo]].map(([k, lab]) => (
                <button key={k} className={'pc-chip' + (style === k ? ' on' : '')}
                  aria-pressed={style === k} onClick={() => setStyle(k)}>{lab}</button>
              ))}
            </div>}
            <p className="pc-note">{t.artNote}</p>
          </div>

          <div className="pc-side">
            <h3 className="pc-title">{t.title}</h3>
            <p className="pc-lede">{isArt ? t.artLede : t.lede}</p>
            <p className="pc-guide mono">{isArt ? t.artGuide : t.guide}</p>

            {!MAIL_ON && <p className="pc-soon mono">{t.mailSoon}</p>}

            <div className="pc-row pc-address-fields">
              <label className="pc-lab" htmlFor="pc-recipient">{t.recipientLabel}<input id="pc-recipient" className="pc-in" maxLength={40} value={recipient} onChange={e => {setRecipient(e.target.value);setSide('complete');}} /></label>
              <label className="pc-lab" htmlFor="pc-sender">{t.senderLabel}<input id="pc-sender" className="pc-in" maxLength={40} value={from} onChange={e => {setFrom(e.target.value);setSide('complete');}} /></label>
            </div>
            <label className="pc-lab mono" htmlFor="pc-msg">{t.artMsgLabel}</label>
            <textarea id="pc-msg" className="pc-msg" rows={4} maxLength={140}
              placeholder={t.msgPh} value={msg} onChange={e => { setMsg(e.target.value); setSide('complete'); }} />
            <div className="pc-count mono">{msg.length} / 140 {t.count}</div>

            <div className="pc-delivery">
              <h4>{t.deliveryLabel} · {side === 'front' ? t.imageOnly : t.complete}</h4>
              <p className="pc-export-note" role="status">{side === 'front' ? t.imageOnlyNote : t.completeNote}</p>
              <div className="pc-tools">
                <button className="pc-save-album" disabled={!ready} onClick={() => {setSaveView(true); if(canShareFiles()) showShare();}}><span aria-hidden="true">↓</span><span>{t.albumButton}<small>{t.albumSub}</small></span></button>
                <button className="pc-share-person" disabled={!ready || sharing} onClick={showShare}><span aria-hidden="true">↗</span><span>{sharing ? t.sharing : shared ? t.shared : t.share}<small>{t.shareSub}</small></span></button>
              </div>
              <a className="pc-file-link" aria-disabled={!ready} href={ready ? png : undefined} download={filename}>{t.fileDownload}</a>
            </div>

            {MAIL_ON && (
              <>
                <div className="pc-row">
                  <div>
                    <label className="pc-lab mono" htmlFor="pc-from">{t.fromLabel}</label>
                    <input id="pc-from" className="pc-in" value={from} placeholder={t.fromPh}
                      onChange={e => setFrom(e.target.value)} />
                  </div>
                  <div>
                    <label className="pc-lab mono" htmlFor="pc-to">{t.toLabel}</label>
                    <input id="pc-to" className="pc-in" type="email" value={to} placeholder={t.toPh}
                      onChange={e => setTo(e.target.value)} />
                  </div>
                </div>
                {state === 'sent'
                  ? <p className="pc-ok mono">{t.sent}</p>
                  : <button className="pc-send" onClick={send} disabled={state === 'sending' || !png}>
                      {state === 'sending' ? t.sending : t.send}
                    </button>}
                {err && <p className="pc-err mono">{err}</p>}
              </>)}
          </div>

        </div>
        </>}
      </div>
    </div>);
}

Object.assign(window, { Postcard, composeCard, composeArtwork, composeReverse, composeComplete, fitPostcardText, POSTCARD_ART, grabShot });
