// card.jsx — the flip card, lanyard, both sides

const { useState, useEffect, useRef, useMemo } = React;

// Normalize portfolio URL — accept bare domain, handle missing protocol
function normalizeUrl(raw) {
  const s = String(raw || '').trim();
  if (!s) return 'https://workid.app';
  if (/^https?:\/\//i.test(s)) return s;
  return 'https://' + s.replace(/^\/+/, '');
}

// QR generator
function QR({ value, size = 52 }) {
  const dataUri = useMemo(() => {
    try {
      const qr = qrcode(0, 'M');
      qr.addData(normalizeUrl(value));
      qr.make();
      return qr.createDataURL(4, 0);
    } catch (e) { return ''; }
  }, [value]);
  return <div className="qr-box"><img src={dataUri} alt="QR" /></div>;
}

// Inline editable text
function Inline({ value, onChange, editing, multiline, className, placeholder, style, max, field, blank, show }) {
  const [local, setLocal] = useState(value);
  useEffect(() => setLocal(value), [value]);
  if (!editing) {
    // `blank` fields are optional: left empty, they print nothing rather than the example text.
    return <span className={(className || '') + (value ? '' : ' is-empty')} style={style}>{(value && show ? show(value) : value) || (blank ? '' : placeholder)}</span>;
  }
  if (multiline) {
    return (
      <textarea
        className={"edit-input " + (className || '')}
        style={{ ...style, resize: 'none', minHeight: '2.2em' }}
        value={local}
        placeholder={placeholder}
        rows={2}
        maxLength={max}
        data-field={field}
        autoComplete="off"
        onChange={e => { setLocal(e.target.value); onChange(e.target.value); }}
      />
    );
  }
  return (
    <input
      className={"edit-input " + (className || '')}
      style={style}
      value={local}
      maxLength={max}
      data-field={field}
      autoComplete="off"
      placeholder={placeholder}
      onChange={e => { setLocal(e.target.value); onChange(e.target.value); }}
    />
  );
}

// The space the photo sits in. The photo is a circle as wide as 64% of this space, or as tall as
// the space if that is smaller. The size is worked out here rather than in CSS because iPhones
// (Safari) get the CSS-only version wrong when the badge is scaled to fit the screen.
function PhotoArea({ children }) {
  const ref = useRef();
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const fit = () => {
      const cs = getComputedStyle(el);
      const w = el.clientWidth, h = el.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
      if (w > 0 && h > 0) el.style.setProperty('--photo-pct', Math.min(64, h / w * 100).toFixed(2) + '%');
    };
    fit();
    if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', fit); return () => window.removeEventListener('resize', fit); }
    const ro = new ResizeObserver(fit); ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return <div className="photo-slot" ref={ref}>{children}</div>;
}

// Photo uploader w/ drag-to-position and zoom
function PhotoSlot({ src, pos, onChange, onPosChange, editing, kind }) {
  const inputRef = useRef();
  const wrapRef = useRef();
  const dragState = useRef(null);
  const position = pos || { x: 50, y: 50, zoom: 1 };

  const handleFile = async (e) => {
    const f = e.target.files && e.target.files[0];
    e.target.value = '';                       // so picking the same file again still fires
    if (!f) return;
    try {
      onChange(await shrinkImage(f));
    } catch (err) { alert(err.message || 'Couldn’t read that photo'); }
  };

  // A tap on the photo opens the picker to swap it; a drag moves it inside the circle.
  const movedRef = useRef(false);
  const tapPhoto = (e) => {
    if (!editing || !src) return;
    e.stopPropagation();
    if (movedRef.current) { movedRef.current = false; return; }
    inputRef.current && inputRef.current.click();
  };

  const startDrag = (e) => {
    if (!editing || !src) return;
    movedRef.current = false;
    e.preventDefault(); e.stopPropagation();
    const t = e.touches ? e.touches[0] : e;
    dragState.current = { startX: t.clientX, startY: t.clientY, baseX: position.x, baseY: position.y, rect: wrapRef.current.getBoundingClientRect() };
    window.addEventListener('mousemove', moveDrag);
    window.addEventListener('mouseup', endDrag);
    window.addEventListener('touchmove', moveDrag, { passive: false });
    window.addEventListener('touchend', endDrag);
  };
  const moveDrag = (e) => {
    if (!dragState.current) return;
    if (e.preventDefault) e.preventDefault();
    const t = e.touches ? e.touches[0] : e;
    const { startX, startY, baseX, baseY, rect } = dragState.current;
    if (Math.abs(t.clientX - startX) + Math.abs(t.clientY - startY) > 5) movedRef.current = true;
    if (!movedRef.current) return;
    const dx = (t.clientX - startX) / rect.width * 100;
    const dy = (t.clientY - startY) / rect.height * 100;
    const nx = Math.max(0, Math.min(100, baseX - dx));
    const ny = Math.max(0, Math.min(100, baseY - dy));
    onPosChange && onPosChange({ ...position, x: nx, y: ny });
  };
  const endDrag = () => {
    dragState.current = null;
    window.removeEventListener('mousemove', moveDrag);
    window.removeEventListener('mouseup', endDrag);
    window.removeEventListener('touchmove', moveDrag);
    window.removeEventListener('touchend', endDrag);
  };

  const setZoom = (z) => {
    const clamped = Math.max(1, Math.min(3, z));
    onPosChange && onPosChange({ ...position, zoom: clamped });
  };

  const imgStyle = src ? {
    objectPosition: `${position.x}% ${position.y}%`,
    transform: `scale(${position.zoom || 1})`,
    transformOrigin: `${position.x}% ${position.y}%`,
    cursor: editing ? 'pointer' : 'default',
  } : {};

  const placeholder = kind === 'kid' ? 'Baby photo' : 'Your photo';
  return (
    <React.Fragment>
      <div
        ref={wrapRef}
        className="photo-inner"
        onMouseDown={startDrag}
        onTouchStart={startDrag}
        onClick={tapPhoto}
        title={editing && src ? 'Click to change the photo. Drag to move it.' : undefined}
        style={{ width: '100%', height: '100%', position: 'absolute', inset: 0, overflow: 'hidden', borderRadius: 'inherit' }}
      >
        {src ? <img src={src} alt="" style={imgStyle} draggable={false} crossOrigin={/^https?:/.test(src) ? 'anonymous' : undefined} /> : (
          <div className="ph">
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
              <rect x="3" y="5" width="18" height="14" rx="2"/>
              <circle cx="12" cy="12" r="3"/>
            </svg>
            <div>{placeholder}</div>
          </div>
        )}
      </div>
      {editing && (
        <React.Fragment>
          <input ref={inputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFile} />
          {src ? (
            <div className="photo-tools" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
              <button className="pt-btn" onClick={(e) => { e.stopPropagation(); inputRef.current && inputRef.current.click(); }} title="Replace">⇆</button>
              <button className="pt-btn" onClick={(e) => { e.stopPropagation(); setZoom((position.zoom || 1) - 0.2); }} title="Zoom out">−</button>
              <button className="pt-btn" onClick={(e) => { e.stopPropagation(); setZoom((position.zoom || 1) + 0.2); }} title="Zoom in">+</button>
            </div>
          ) : (
            <button className="upload-btn" onClick={(e) => { e.stopPropagation(); inputRef.current && inputRef.current.click(); }}>
              {kind === 'kid' ? 'Upload baby “you” photo' : 'Upload photo'}
            </button>
          )}
        </React.Fragment>
      )}
    </React.Fragment>
  );
}

// Pro side
function ProSide({ data, editing, onUpdate, theme, faceRef }) {
  // On the finished badge, an optional detail left empty hides its label too (it keeps its space).
  const hideIf = (empty) => (!editing && empty) ? { visibility: 'hidden' } : undefined;
  const set = (k, v) => onUpdate({ ...data, [k]: v });
  const setHead = (i, v) => {
    const h = [...(data.headline || ['','',''])];
    h[i] = v;
    onUpdate({ ...data, headline: h });
  };
  const [h0, h1, h2] = data.headline || ['','',''];
  // Each top line is set as large as fits the card width, up to its own limit: the strip (the job
  // title) is the biggest, the lines above and below it a little smaller.
  const [, fontsReady] = useState(0);
  useEffect(() => { if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => fontsReady(1)); }, []);
  const fitLine = (t, maxPx) => {
    const ctx = fitLine.ctx || (fitLine.ctx = document.createElement('canvas').getContext('2d'));
    ctx.font = '100px "Archivo Black", sans-serif';
    const em = ctx.measureText(t || '').width / 100;      // width of the line at 1px type
    return Math.max(11, Math.min(maxPx, Math.floor(294 / Math.max(em, 1) * 10) / 10));
  };
  // While editing, each line's box is exactly as wide as its words, so nothing is cut off.
  const boxW = (t, px) => editing ? { width: Math.ceil(fitLine.ctx.measureText(t).width / 100 * px) + 10 + 'px', maxWidth: '100%' } : undefined;
  const stripPx = fitLine(h1 || 'a product designer', 28);
  const sidePx = Math.min(22, Math.max(15, stripPx * 0.9));     // never bigger than the strip
  const px = (n) => ({ fontSize: n + 'px' });
  // Pasted web addresses print without the "https://www." in front.
  const tidyUrl = (u) => u.replace(/^https?:\/\//i, '').replace(/^www\./i, '').replace(/\/+$/, '');
  // The finished badge shows no web addresses, only the QR codes, their names and the LinkedIn handle.
  const linkedinName = (u) => { const m = /linkedin\.com\/(?:in|company)\/([^/?#]+)/i.exec(u || ''); try { return m ? decodeURIComponent(m[1]) : ''; } catch (e) { return m ? m[1] : ''; } };
  return (
    <div ref={faceRef} className={"card-face front " + (editing ? 'editing' : '') + (theme && theme.dark ? ' dark-face' : '')}>
      <div className="gloss" aria-hidden="true"/>
      <div className="corner-stamp">WORK · ID</div>
      <div className="card-head">
        <span className="line" style={px(Math.min(sidePx, fitLine(h0 || 'currently playing', 22)))}><Inline value={h0} onChange={v => setHead(0, v)} max={30} editing={editing} placeholder="currently playing" style={boxW(h0 || 'currently playing', Math.min(sidePx, fitLine(h0 || 'currently playing', 22)))} /></span><br/>
        <span className="line hl" style={px(stripPx)}><Inline value={h1} onChange={v => setHead(1, v)} max={30} editing={editing} placeholder="a product designer" style={boxW(h1 || 'a product designer', stripPx)} /></span><br/>
        <span className="line accent" style={px(Math.min(sidePx, fitLine(h2 || 'at work', 22)))}><Inline value={h2} onChange={v => setHead(2, v)} max={30} editing={editing} placeholder="at work" style={boxW(h2 || 'at work', Math.min(sidePx, fitLine(h2 || 'at work', 22)))} /></span>
      </div>

      <PhotoArea><div className="photo-frame">
        <PhotoSlot
          src={data.photo}
          pos={data.photoPos}
          onChange={v => onUpdate({ ...data, photo: v, photoPos: { x: 50, y: 50, zoom: 1 } })}
          onPosChange={v => set('photoPos', v)}
          editing={editing}
          kind="pro"
        />
      </div></PhotoArea>

      <div className="name-row">
        <div className="name-tag">
          <Inline value={data.name} onChange={v => set('name', v)} field="name" max={24} editing={editing} placeholder="Your Name" />
        </div>
      </div>
      <div className="meta-strip" style={hideIf(!data.years && !data.company && !data.location)}>
        <div style={hideIf(!data.years)}>
          YRS·EXP
          <b><Inline value={data.years} onChange={v => set('years', v)} max={3} editing={editing} blank placeholder="0" /></b>
        </div>
        <div style={hideIf(!data.company)}>
          COMPANY
          <b><Inline value={data.company} onChange={v => set('company', v)} max={16} editing={editing} blank placeholder="Where you work" /></b>
        </div>
        <div style={hideIf(!data.location)}>
          BASE
          <b><Inline value={data.location} onChange={v => set('location', v)} max={14} editing={editing} blank placeholder="City" /></b>
        </div>
      </div>

      <div className="qr-strip">
        {(editing || hasLink(data.portfolio, 'yourportfolio.com')) && <div className="qr-item">
          <QR value={data.portfolio || 'yourportfolio.com'} />
          {editing ? <div className="qr-label">
            <b><Inline value={data.portfolio} onChange={v => set('portfolio', v)} max={200} show={tidyUrl} editing={editing} blank placeholder="yourportfolio.com" /></b>
            portfolio
          </div> : <div className="qr-label done"><b>Portfolio</b></div>}
        </div>}
        {(editing || hasLink(data.linkedin, 'linkedin.com/in/yourname')) && <div className="qr-item">
          <QR value={data.linkedin || 'linkedin.com/in/yourname'} />
          {editing ? <div className="qr-label">
            <b><Inline value={data.linkedin} onChange={v => set('linkedin', v)} max={200} show={tidyUrl} editing={editing} blank placeholder="linkedin.com/in/yourname" /></b>
            linkedin
          </div> : <div className="qr-label done"><b>LinkedIn</b>{linkedinName(data.linkedin)}</div>}
        </div>}
      </div>
    </div>
  );
}

// Kid side
function KidSide({ data, editing, onUpdate, theme, faceRef }) {
  const set = (k, v) => onUpdate({ ...data, [k]: v });
  const age = Math.min(15, Math.max(5, parseInt(data.age, 10) || 6));
  // The last two questions are either/or: on the finished badge an unanswered one is left off.
  const skip = (mine, other) => !editing && !(mine || '').trim() && !!(other || '').trim();
  return (
    <div ref={faceRef} className={"card-face back " + (editing ? 'editing' : '') + (theme && theme.dark ? ' dark-face' : '')}
      style={theme ? { '--card-bg': theme.bg, '--card-ink': theme.ink, '--card-accent': theme.accent, '--card-accent-ink': theme.accentInk, '--card-starburst': theme.starburst } : undefined}>
      <div className="gloss" aria-hidden="true"/>
      <div className="corner-stamp">♡</div>
      <div className="kid-head">
        {editing && <span className="age-help">Tap the age to change it, 5 to 15</span>}
        <span className="k1">but before all that,</span><br/>
        <span className="k2">a {editing
          ? <span className="age-wrap"><span className="age-num">{age}</span>
              <select className="age-pick" value={age} onChange={e => set('age', e.target.value)} aria-label="Your age in the photo">
                {[5,6,7,8,9,10,11,12,13,14,15].map(n => <option key={n} value={n}>{n}</option>)}
              </select></span>
          : age} year old <span>me</span> was</span>
      </div>

      <PhotoArea><div className="kid-photo">
        <PhotoSlot
          src={data.photo}
          pos={data.photoPos}
          onChange={v => onUpdate({ ...data, photo: v, photoPos: { x: 50, y: 50, zoom: 1 } })}
          onPosChange={v => set('photoPos', v)}
          editing={editing}
          kind="kid"
        />
      </div></PhotoArea>

      <div className="kid-field">
        <div className="lbl">i was born in...</div>
        <div className="val small">
          <Inline value={data.bornIn} onChange={v => set('bornIn', v)} field="bornIn" max={40} editing={editing}
                  placeholder="a small town by the sea..."/>
        </div>
      </div>
      <div className="kid-field">
        <div className="lbl">i wanted to grow up to be...</div>
        <div className="val">
          <Inline value={data.wantedToBe} onChange={v => set('wantedToBe', v)} field="wantedToBe" max={64} editing={editing} multiline
                  placeholder="an astronaut, a pirate, a vet..."/>
        </div>
      </div>

      {!skip(data.hobbies, data.dreamJob) && <div className="kid-field">
        <div className="lbl">you’d always find me...</div>
        <div className="val small">
          <Inline value={data.hobbies} onChange={v => set('hobbies', v)} field="hobbies" max={64} editing={editing} multiline
                  placeholder="up the guava tree in our backyard..."/>
        </div>
      </div>}

      {editing && <div className="kid-or">answer either one, or both</div>}
      {!skip(data.dreamJob, data.hobbies) && <div className="kid-field">
        <div className="lbl">my favourite thing in the world was...</div>
        <div className="val small">
          <Inline value={data.dreamJob} onChange={v => set('dreamJob', v)} field="dreamJob" max={64} editing={editing} multiline
                  placeholder="a red bicycle with one broken pedal..."/>
        </div>
      </div>}

    </div>
  );
}

// Main Card: lanyard + flip + a small physics rig that makes it hang like a
// real badge. Three springs run in one rAF loop:
//   a1 — the strap swinging from the top of the page
//   a2 — the card swinging on the clip, lagging behind the strap
//   tw — the card twisting on the swivel
// A slow "breeze" keeps it gently alive; dragging, flicking and flipping add
// energy that then settles on its own.
function Card({ card, editing = false, onUpdate, large = false, side: sideProp, onFlip, tilt = false, onSideChange, still = false }) {
  const [flipped, setFlipped] = useState(false);
  const [isFlipping, setIsFlipping] = useState(false);
  const [tiltActive, setTiltActive] = useState(false);
  const theme = getTheme(card);          // professional side
  const kidTheme = getTheme(card, 1);    // childhood side

  const controlled = sideProp !== undefined;
  const side = controlled ? sideProp : (flipped ? 1 : 0);

  useEffect(() => { onSideChange && onSideChange(side); }, [side]);

  const cssVars = {
    '--card-bg': theme.bg,
    '--card-ink': theme.ink,
    '--card-accent': theme.accent,
    '--card-accent-ink': theme.accentInk,
    '--card-starburst': theme.starburst,
  };

  const updatePro = (pro) => onUpdate && onUpdate({ ...card, pro });
  const updateKid = (kid) => onUpdate && onUpdate({ ...card, kid });

  const wrapRef = useRef(null);
  const rigRef = useRef(null);
  const clipRef = useRef(null);
  const stageRef = useRef(null);
  const frontFaceRef = useRef(null);
  const backFaceRef = useRef(null);
  const sim = useRef({ a1: 0, v1: 0, a2: 0, v2: 0, tw: 0, vtw: 0, tx: 0, ty: 0, ttx: 0, tty: 0, gx: 50, gy: 50, drag: null, pivot: 200 }).current;
  const suppressClickRef = useRef(false);
  const gyroEnabledRef = useRef(false);
  const gyroCalibrateRef = useRef(null);

  const FLIP_MS = 720;
  const triggerFlip = () => {
    if (isFlipping) return;
    setIsFlipping(true);
    if (controlled) onFlip && onFlip(); else setFlipped(f => !f);
    // A flip is a flick of the wrist: it knocks the card and strap a little.
    const dir = Math.random() < 0.5 ? -1 : 1;
    sim.v2 += dir * (editing ? 0.5 : 1.3);
    sim.v1 += dir * (editing ? 0.08 : 0.22);
    setTimeout(() => setIsFlipping(false), FLIP_MS);
  };

  // ===== The physics loop =====
  useEffect(() => {
    if (still || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const rig = rigRef.current, stage = stageRef.current, clip = clipRef.current, wrap = wrapRef.current;
    if (!rig || !stage) return;

    // The strap swings from the top edge of the page, so the pivot is however
    // far the rig sits below it.
    const measure = () => {
      const top = wrap.getBoundingClientRect().top + window.scrollY;
      sim.pivot = Math.max(80, top);
      // On phones the card is drawn smaller with CSS zoom; lengths inside it are in unzoomed px.
      const z = parseFloat(getComputedStyle(wrap).zoom) || 1;
      rig.style.transformOrigin = `50% ${-sim.pivot / z}px`;
    };
    measure();
    // the phone layout re-sizes the badge on resize, so measure again once that has settled
    const onResize = () => { measure(); setTimeout(measure, 80); };
    window.addEventListener('resize', onResize);
    window.addEventListener('tinyyou:fit', measure);
    const ro = window.ResizeObserver ? new ResizeObserver(measure) : null;
    ro && ro.observe(document.body);

    const calm = editing ? 0.3 : 1;
    let raf, last = performance.now();
    const t0 = last - Math.random() * 20000;
    let lastGloss = '';
    const step = (now) => {
      raf = requestAnimationFrame(step);
      const dt = Math.min(0.032, (now - last) / 1000); last = now;
      if (window.__freezeRig) {
        rig.style.transform = ''; stage.style.transform = ''; if (clip) clip.style.transform = '';
        return;
      }
      const t = (now - t0) / 1000;
      // Hold still while someone is typing on the card.
      const ae = document.activeElement;
      const typing = editing && ae && rig.contains(ae) && /INPUT|TEXTAREA/.test(ae.tagName);
      const k = typing ? 0 : calm;
      const breeze = k * (0.9 * Math.sin(t * 0.71) + 0.6 * Math.sin(t * 1.37 + 1.1) + 0.35 * Math.sin(t * 2.3 + 2.0));
      const breeze2 = k * (Math.sin(t * 0.53 + 0.4) + 0.5 * Math.sin(t * 0.97 + 2.2));

      let acc1;
      if (sim.drag) acc1 = (sim.drag.angle - sim.a1) * 140 - sim.v1 * 16;
      else acc1 = -11 * sim.a1 - (typing ? 6 : 0.85) * sim.v1 + breeze * 0.05;
      sim.v1 += acc1 * dt; sim.a1 += sim.v1 * dt;

      // The card wants to hang straight down in the world (a1 + a2 -> 0), is
      // held loosely by the clip (a2 -> 0), and lags when the strap accelerates.
      const acc2 = -20 * (sim.a1 + sim.a2) - 12 * sim.a2 - (typing ? 9 : 2.4) * sim.v2 - 0.85 * acc1 + breeze2 * 0.12;
      sim.v2 += acc2 * dt; sim.a2 += sim.v2 * dt;
      sim.a2 = Math.max(-0.6, Math.min(0.6, sim.a2));

      const accT = -7 * sim.tw - (typing ? 8 : 1.3) * sim.vtw + breeze2 * 16 + sim.v1 * 60;
      sim.vtw += accT * dt; sim.tw += sim.vtw * dt;

      const e = 1 - Math.exp(-dt * 9);
      sim.tx += (sim.ttx - sim.tx) * e;
      sim.ty += (sim.tty - sim.ty) * e;

      rig.style.transform = `rotate(${sim.a1.toFixed(5)}rad)`;
      stage.style.transform = `translateY(-50%) rotate(${sim.a2.toFixed(5)}rad) translateY(50%) rotateX(${sim.tx.toFixed(3)}deg) rotateY(${(sim.ty + sim.tw).toFixed(3)}deg)`;
      if (clip) clip.style.transform = `rotate(${(sim.a2 * 0.5).toFixed(5)}rad)`;

      const g = `${sim.gx.toFixed(0)}|${sim.gy.toFixed(0)}`;
      if (g !== lastGloss) {
        lastGloss = g;
        if (frontFaceRef.current) { frontFaceRef.current.style.setProperty('--gloss-x', sim.gx + '%'); frontFaceRef.current.style.setProperty('--gloss-y', sim.gy + '%'); }
        if (backFaceRef.current) { backFaceRef.current.style.setProperty('--gloss-x', (100 - sim.gx) + '%'); backFaceRef.current.style.setProperty('--gloss-y', sim.gy + '%'); }
      }
    };
    raf = requestAnimationFrame(step);
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('resize', onResize);
      window.removeEventListener('tinyyou:fit', measure);
      ro && ro.disconnect();
    };
  }, [editing]);

  // ===== Cursor tilt (desktop, preview only) =====
  useEffect(() => {
    if (!tilt) return;
    if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    const stage = stageRef.current;
    if (!stage) return;
    const onMove = (e) => {
      const r = stage.getBoundingClientRect();
      const px = (e.clientX - r.left) / r.width;
      const py = (e.clientY - r.top) / r.height;
      sim.tty = (px - 0.5) * 26;
      sim.ttx = (0.5 - py) * 18;
      sim.gx = px * 100; sim.gy = py * 100;
      setTiltActive(true);
    };
    const onLeave = () => { sim.ttx = 0; sim.tty = 0; sim.gx = 50; sim.gy = 50; setTiltActive(false); };
    stage.addEventListener('mousemove', onMove);
    stage.addEventListener('mouseleave', onLeave);
    return () => { stage.removeEventListener('mousemove', onMove); stage.removeEventListener('mouseleave', onLeave); };
  }, [tilt]);

  // ===== Drag / flick to swing (preview only) =====
  const dragRef = useRef(null);
  const onPointerDown = (e) => {
    if (editing || e.button > 0) return;
    dragRef.current = { x: e.clientX, y: e.clientY, active: false, id: e.pointerId };
  };
  const onPointerMove = (e) => {
    const d = dragRef.current;
    if (!d) return;
    if (!d.active) {
      if (Math.abs(e.clientX - d.x) < 7) return;
      d.active = true;
      try { e.currentTarget.setPointerCapture(d.id); } catch (err) {}
    }
    const wr = wrapRef.current.getBoundingClientRect();
    const pivotX = wr.left + wr.width / 2, pivotY = wr.top - sim.pivot;
    const angle = -Math.atan2(e.clientX - pivotX, e.clientY - pivotY);
    sim.drag = { angle: Math.max(-0.55, Math.min(0.55, angle)) };
  };
  const onPointerUp = () => {
    const d = dragRef.current;
    dragRef.current = null;
    if (d && d.active) { sim.drag = null; suppressClickRef.current = true; setTimeout(() => { suppressClickRef.current = false; }, 60); }
  };

  // Mobile gyro tilt — needs a tap first (iOS permission rule).
  const enableGyro = async () => {
    if (!tilt || gyroEnabledRef.current) return;
    if (!('DeviceOrientationEvent' in window)) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    try {
      if (typeof DeviceOrientationEvent.requestPermission === 'function') {
        const state = await DeviceOrientationEvent.requestPermission();
        if (state !== 'granted') return;
      }
    } catch (err) { return; }
    gyroEnabledRef.current = true;
    setTiltActive(true);
    window.addEventListener('deviceorientation', (e) => {
      if (e.gamma == null || e.beta == null) return;
      if (!gyroCalibrateRef.current) { gyroCalibrateRef.current = { gamma: e.gamma, beta: e.beta }; return; }
      const cal = gyroCalibrateRef.current;
      const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
      const dG = clamp(e.gamma - cal.gamma, -30, 30);
      const dB = clamp(e.beta - cal.beta, -30, 30);
      sim.ttx = dB * 0.55; sim.tty = -dG * 0.65;
      sim.gx = 50 + dG * 1.2; sim.gy = 50 + dB * 1.2;
    });
  };

  const handleFlip = (e) => {
    if (editing) { e.stopPropagation(); return; }
    if (suppressClickRef.current) return;
    if (e.target.closest('.edit-input') || e.target.closest('.upload-btn') || e.target.closest('input[type="file"]')) return;
    if (tilt) enableGyro();
    triggerFlip();
  };
  const handleFlipBtn = (e) => { e.stopPropagation(); triggerFlip(); };

  return (
    <div ref={wrapRef} className={"card-wrap " + (large ? 'large' : '')} style={cssVars}>
      <div ref={rigRef} className="rig">
        {/* Strap, D-ring and swivel hook are cut from a real photo. The fabric
            is stored as a neutral texture and multiplied by the card colour;
            the metal stays as photographed. */}
        <div className="lanyard" aria-hidden="true" style={{ '--card-accent': getStrap(card) }}>
          <div className="strap"/>
          <div className="lan-end">
            <div className="lan-fabric"/>
            <div ref={clipRef} className={"lan-metal" + (side === 1 ? ' turned' : '')}>
              <img className="lan-ring" src="assets/lanyard-metal.png?v=125" alt="" draggable="false"/>
              <img className="lan-hook" src="assets/lanyard-metal.png?v=125" alt="" draggable="false"/>
            </div>
          </div>
        </div>
        <div
          ref={stageRef}
          className={"card-stage" + (tiltActive ? ' tilt-active' : '') + (editing ? '' : ' can-swing')}
          onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}
        >
          <div
            className={"card-3d " + (side === 1 ? 'flipped' : '') + (editing ? ' no-click-flip' : '') + (isFlipping ? ' is-flipping' : '')}
            onClick={handleFlip}
          >
            <ProSide data={card.pro} editing={editing && side === 0} onUpdate={updatePro} theme={theme} faceRef={frontFaceRef}/>
            <KidSide data={card.kid} editing={editing && side === 1} onUpdate={updateKid} theme={kidTheme} faceRef={backFaceRef}/>
          </div>
        </div>
      </div>
      <div className="flip-hint">
        <button className="flip-btn" onClick={handleFlipBtn} aria-label="Flip card">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <path d="M3 12a9 9 0 0 1 15.3-6.4L21 8" strokeLinecap="round"/>
            <path d="M21 3v5h-5" strokeLinecap="round" strokeLinejoin="round"/>
            <path d="M21 12a9 9 0 0 1-15.3 6.4L3 16" strokeLinecap="round"/>
            <path d="M3 21v-5h5" strokeLinecap="round" strokeLinejoin="round"/>
          </svg>
        </button>
        <span>{side === 0 ? 'flip to childhood' : 'flip to professional'}</span>
      </div>
    </div>
  );
}

Object.assign(window, { Card, QR });
