// components/Scaffold.jsx — honest-skeleton helpers · v2 "Liner Notes".
// Todo: a clearly-marked slot for writing Merijn will do himself.
//       data-noword keeps prompts out of the 3000-word count.
// EvidenceSlot: a drag-and-drop figure slot (image-slot) with caption.

// Figure numbering — an unfilled caption auto-labels itself "Figure N" by its
// position in the reading flow (Index → Past → Present → Future), so the
// placeholders stay correctly ordered no matter how many slots exist. The
// fixed section slots bracket the expertise figures pulled from data.jsx;
// image and video slots share one continuous sequence. Once a real caption
// is written it overrides the number entirely.
let _figureMap = null;
function figureMap() {
  if (_figureMap) return _figureMap;
  const flow = ['vision-fig', 'pi-fig', 'course-fig-1', 'course-fig-2', 'course-fig-3', 'past-origin'];
  ((window.EXPERTISE_LONG) || []).forEach((area) => {
    (area.figures || []).forEach((f) => flow.push(f.id));
  });
  flow.push('process-fig-1', 'process-fig-2', 'skills-fig-1', 'future-fig-1');
  _figureMap = {};
  flow.forEach((id, i) => {_figureMap[id] = i + 1;});
  return _figureMap;
}
function figureLabel(id) {
  const n = figureMap()[id];
  return n ? 'Figure ' + n : 'Figure';
}

// Per-figure caption placeholder styling (faint amber prompt when empty).
if (typeof document !== 'undefined' && !document.getElementById('caption-ph-style')) {
  const st = document.createElement('style');
  st.id = 'caption-ph-style';
  st.textContent = '.fig-cap:empty::before{content:attr(data-ph);color:var(--ink-butter);}';
  document.head.appendChild(st);
}

// CaptionField: "Figure N: <description>". The number is automatic (from the
// document flow); the description is an editable, per-slot field persisted in
// localStorage under 'evidence-caption:<id>' — mirroring how dropped images
// persist. Each figure owns its own caption, so edits never collide with a
// shared placeholder (the bug that previously ate the typed titles).
function CaptionField({ id, fallback }) {
  const ref = React.useRef(null);
  const editable = !!(window.omelette && window.omelette.writeFile);
  const read = () => {
    try {const s = localStorage.getItem('evidence-caption:' + id);return s != null ? s : fallback || '';} catch (e) {return fallback || '';}
  };
  const [hasText, setHasText] = React.useState(() => read().trim().length > 0);

  React.useEffect(() => {
    const sync = () => {
      const v = read();
      if (ref.current && document.activeElement !== ref.current) ref.current.textContent = v;
      setHasText(v.trim().length > 0);
    };
    sync();
    const onSync = (e) => {if (e.detail && e.detail.id === id) sync();};
    window.addEventListener('evidence-caption', onSync);
    return () => window.removeEventListener('evidence-caption', onSync);
  }, [id]);

  const commit = () => {
    const v = ref.current ? ref.current.textContent.trim() : '';
    try {if (v) localStorage.setItem('evidence-caption:' + id, v);else localStorage.removeItem('evidence-caption:' + id);} catch (e) {}
    if (ref.current) ref.current.textContent = v;
    setHasText(v.length > 0);
    window.dispatchEvent(new CustomEvent('evidence-caption', { detail: { id } }));
  };

  const showColon = hasText || editable;
  return (
    <figcaption style={{ marginTop: 10, fontFamily: 'var(--font-body)', fontSize: 13, color: 'var(--c-whisper)', lineHeight: 1.55 }}>
      <span style={{ fontWeight: 600, color: 'var(--c-smoke)' }}>{figureLabel(id)}{showColon ? ':' : ''}</span>{showColon ? ' ' : ''}
      <span
        ref={ref}
        className="fig-cap"
        data-ph={editable ? 'add a description' : ''}
        contentEditable={editable}
        suppressContentEditableWarning={true}
        onBlur={commit}
        onKeyDown={(e) => {if (e.key === 'Enter') {e.preventDefault();e.currentTarget.blur();}}}
        style={{ outline: 'none', cursor: editable ? 'text' : 'default' }}>
      </span>
    </figcaption>);

}

function Todo({ children, label }) {
  return (
    <div
      data-noword="true"
      className="todo-slot"
      style={{
        border: '1px dashed #E3CE92',
        background: '#FBF4DE',
        padding: '14px 18px',
        margin: '10px 0',
        display: 'flex',
        gap: 14,
        alignItems: 'baseline',
        maxWidth: '68ch'
      }}>

      <span style={{
        fontFamily: 'var(--font-body)',
        fontSize: 11,
        fontWeight: 700,
        letterSpacing: '0.16em',
        textTransform: 'uppercase',
        color: 'var(--ink-butter)',
        flexShrink: 0
      }}>
        {label || 'To write'}
      </span>
      <span style={{
        fontFamily: 'var(--font-body)',
        fontSize: 13.5,
        fontWeight: 400,
        lineHeight: 1.65,
        color: '#7A7261'
      }}>
        {children}
      </span>
    </div>);

}

// Aspect handling: by default ('AUTO') the slot resizes itself to the
// natural aspect ratio of whatever image is dropped in — no cropping.
// A small hover picker lets you lock any slot to a fixed ratio instead
// (the image then cover-crops; double-click it to reframe). The per-slot
// choice persists in localStorage under 'evidence-aspect:<id>'.
const EVIDENCE_RATIOS = [
['auto', 'AUTO'],
['16/9', '16:9'],
['3/2', '3:2'],
['4/3', '4:3'],
['1/1', '1:1'],
['4/5', '4:5']];


function EvidenceSlot({ id, caption, height = 240, aspect }) {
  const slotRef = React.useRef(null);
  const [pick, setPick] = React.useState(() => {
    try {return localStorage.getItem('evidence-aspect:' + id) || 'auto';} catch (e) {return 'auto';}
  });
  const [naturalRatio, setNaturalRatio] = React.useState(null);
  const [hover, setHover] = React.useState(false);
  const editable = !!(window.omelette && window.omelette.writeFile);

  // Watch the slot's internal <img> to learn the dropped image's natural
  // dimensions (and forget them when the image is removed).
  React.useEffect(() => {
    const el = slotRef.current;
    if (!el || !el.shadowRoot) return;
    const img = el.shadowRoot.querySelector('.frame img');
    if (!img) return;
    const update = () => {
      const ok = img.getAttribute('src') && img.naturalWidth > 0 && img.naturalHeight > 0;
      setNaturalRatio(ok ? img.naturalWidth / img.naturalHeight : null);
    };
    update();
    img.addEventListener('load', update);
    const mo = new MutationObserver(update);
    mo.observe(img, { attributes: true, attributeFilter: ['src'] });
    return () => {img.removeEventListener('load', update);mo.disconnect();};
  }, []);

  const choose = (v) => {
    setPick(v);
    try {localStorage.setItem('evidence-aspect:' + id, v);} catch (e) {}
    // The page can mount the same slot twice (desktop/mobile trees) —
    // broadcast so every copy stays in sync.
    window.dispatchEvent(new CustomEvent('evidence-aspect', { detail: { id, v } }));
  };

  React.useEffect(() => {
    const onSync = (e) => {if (e.detail && e.detail.id === id) setPick(e.detail.v);};
    window.addEventListener('evidence-aspect', onSync);
    return () => window.removeEventListener('evidence-aspect', onSync);
  }, [id]);

  const slotStyle = {
    width: '100%',
    display: 'block',
    border: '1px dashed #C8D2DD',
    background: '#F2F5F9'
  };
  // NB: <image-slot> has an internal default height (160px) that wins
  // unless an inline height is set — so every aspect-ratio branch must
  // also set height:'auto' for the ratio to actually take effect.
  if (pick !== 'auto') {
    slotStyle.aspectRatio = pick;
    slotStyle.height = 'auto';
  } else if (naturalRatio) {
    // Adapt to the image's true ratio — never crop. Tall images are
    // scaled DOWN to fit a max height instead: width shrinks to
    // height-cap × ratio and the slot centers in the column.
    const MAX_H = 640;
    slotStyle.aspectRatio = String(naturalRatio);
    slotStyle.height = 'auto';
    slotStyle.width = 'min(100%, ' + Math.round(MAX_H * naturalRatio) + 'px)';
    slotStyle.marginLeft = 'auto';
    slotStyle.marginRight = 'auto';
  } else if (aspect) {
    slotStyle.aspectRatio = aspect;
    slotStyle.height = 'auto';
  } else {
    slotStyle.height = height;
  }

  return (
    <figure
      data-noword="true"
      className="evidence-figure"
      style={{ margin: 0, position: 'relative' }}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}>

      {editable &&
      <div style={{
        position: 'absolute',
        top: 6,
        right: 6,
        zIndex: 3,
        display: 'flex',
        gap: 2,
        padding: 3,
        background: 'rgba(255,255,255,0.95)',
        border: '1px solid var(--c-border)',
        boxShadow: 'var(--shadow-sm)',
        opacity: hover ? 1 : 0,
        pointerEvents: hover ? 'auto' : 'none',
        transition: 'opacity 120ms ease'
      }}>
          {EVIDENCE_RATIOS.map(([v, label]) =>
        <button
          key={v}
          type="button"
          onClick={() => choose(v)}
          title={v === 'auto' ? 'Match the dropped image\u2019s aspect ratio' : 'Lock to ' + label}
          style={{
            appearance: 'none',
            border: 0,
            padding: '3px 7px',
            cursor: 'pointer',
            fontFamily: 'var(--font-body)',
            fontSize: 10,
            fontWeight: 600,
            letterSpacing: '0.06em',
            background: pick === v ? 'rgba(127,181,232,0.25)' : 'transparent',
            color: pick === v ? 'var(--ink-sky)' : 'var(--c-whisper)'
          }}>

              {label}
            </button>
        )}
        </div>
      }
      <image-slot
        ref={slotRef}
        id={id}
        shape="rect"
        placeholder="Drop evidence — image, scan, photo"
        style={slotStyle}>
      </image-slot>
      <CaptionField id={id} fallback={caption} />
    </figure>);

}

// ── VideoSlot ───────────────────────────────────────────────────────────
// Drag-and-drop video figure. Images are small enough to live in
// localStorage as data URLs (image-slot's approach), but a video would blow
// the quota — so clips are kept as Blobs in IndexedDB, keyed by slot id, and
// re-hydrated into an object URL on load. Persists across reloads.
const VIDEO_STORE = 'evidence-clips';
function videoIDB(mode, id, blob) {
  return new Promise((resolve, reject) => {
    let req;
    try {req = indexedDB.open('portfolio-evidence', 1);}
    catch (e) {reject(e);return;}
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(VIDEO_STORE)) db.createObjectStore(VIDEO_STORE);
    };
    req.onerror = () => reject(req.error);
    req.onsuccess = () => {
      const db = req.result;
      const write = mode !== 'get';
      const tx = db.transaction(VIDEO_STORE, write ? 'readwrite' : 'readonly');
      const store = tx.objectStore(VIDEO_STORE);
      const op = mode === 'put' ? store.put(blob, id) : mode === 'del' ? store.delete(id) : store.get(id);
      op.onsuccess = () => resolve(op.result);
      op.onerror = () => reject(op.error);
    };
  });
}

const vsBtn = {
  appearance: 'none', border: 0, padding: '3px 7px', cursor: 'pointer',
  fontFamily: 'var(--font-body)', fontSize: 10, fontWeight: 600,
  letterSpacing: '0.06em', background: 'transparent', color: 'var(--ink-sky)'
};

function VideoSlot({ id, caption, aspect = '16/9', src }) {
  const [url, setUrl] = React.useState(null);
  const [err, setErr] = React.useState(null);
  const [over, setOver] = React.useState(false);
  const [hover, setHover] = React.useState(false);
  const inputRef = React.useRef(null);
  const urlRef = React.useRef(null);
  const editable = !!(window.omelette && window.omelette.writeFile);

  // A dropped clip lives as a Blob in IndexedDB (object URL). When there is
  // none, fall back to the committed `src` file so the video shows for
  // everyone, not just the browser that dropped it. urlRef only tracks
  // object URLs — the static src path is never revoked.
  const apply = (blob) => {
    if (urlRef.current) URL.revokeObjectURL(urlRef.current);
    if (blob) {const u = URL.createObjectURL(blob);urlRef.current = u;setUrl(u);} else
    {urlRef.current = null;setUrl(src || null);}
  };

  const load = React.useCallback(() => {
    videoIDB('get', id).then((blob) => {if (blob) apply(blob);else {urlRef.current = null;setUrl(src || null);}}).catch(() => {if (src) setUrl(src);});
  }, [id, src]);

  React.useEffect(() => {
    load();
    // The same slot can mount twice (e.g. responsive trees) — keep copies synced.
    const onSync = (e) => {if (e.detail && e.detail.id === id) load();};
    window.addEventListener('evidence-video', onSync);
    return () => {
      window.removeEventListener('evidence-video', onSync);
      if (urlRef.current) URL.revokeObjectURL(urlRef.current);
    };
  }, [id, load]);

  const ingest = async (file) => {
    setErr(null);
    if (!file || !file.type.startsWith('video/')) {setErr('Drop a video file — .mp4, .webm, .mov');return;}
    try {
      await videoIDB('put', id, file);
      apply(file);
      window.dispatchEvent(new CustomEvent('evidence-video', { detail: { id } }));
    } catch (e) {setErr('Could not store this video (too large?).');}
  };

  const clear = async () => {
    try {await videoIDB('del', id);} catch (e) {}
    apply(null);
    window.dispatchEvent(new CustomEvent('evidence-video', { detail: { id } }));
  };

  return (
    <figure
      data-noword="true"
      className="evidence-figure"
      style={{ margin: 0, position: 'relative' }}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}>

      <div
        onDragEnter={(e) => {e.preventDefault();setOver(true);}}
        onDragOver={(e) => {e.preventDefault();}}
        onDragLeave={(e) => {e.preventDefault();setOver(false);}}
        onDrop={(e) => {e.preventDefault();setOver(false);const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];if (f) ingest(f);}}
        onClick={() => {if (!url) inputRef.current && inputRef.current.click();}}
        style={{
          width: '100%',
          aspectRatio: aspect,
          display: 'block',
          position: 'relative',
          border: url ? '1px solid var(--c-border)' : '1px dashed ' + (over ? 'var(--ink-coral)' : '#C8D2DD'),
          background: url ? '#000' : over ? 'rgba(228,138,106,0.08)' : '#F2F5F9',
          overflow: 'hidden',
          cursor: url ? 'default' : 'pointer'
        }}>

        {url ?
        <video src={url} controls playsInline style={{ width: '100%', height: '100%', objectFit: 'contain', background: '#000', display: 'block' }}></video> :

        <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 10, textAlign: 'center', padding: 16 }}>
            <div style={{ width: 0, height: 0, borderTop: '11px solid transparent', borderBottom: '11px solid transparent', borderLeft: '18px solid var(--c-muted)' }}></div>
            <div style={{ fontFamily: 'var(--font-body)', fontSize: 12.5, color: err ? 'var(--ink-coral)' : 'var(--c-whisper)', letterSpacing: '0.04em' }}>
              {err || 'Drop a video — .mp4, .webm'}
            </div>
          </div>
        }
      </div>

      {editable && url &&
      <div style={{ position: 'absolute', top: 6, right: 6, zIndex: 3, display: 'flex', gap: 2, padding: 3, background: 'rgba(255,255,255,0.95)', border: '1px solid var(--c-border)', boxShadow: 'var(--shadow-sm)', opacity: hover ? 1 : 0, pointerEvents: hover ? 'auto' : 'none', transition: 'opacity 120ms ease' }}>
          <button type="button" onClick={() => inputRef.current && inputRef.current.click()} style={vsBtn}>Replace</button>
          <button type="button" onClick={clear} style={vsBtn}>Remove</button>
        </div>
      }

      <input ref={inputRef} type="file" accept="video/*" hidden onChange={(e) => {const f = e.target.files && e.target.files[0];if (f) ingest(f);e.target.value = '';}} />

      <CaptionField id={id} fallback={caption} />
    </figure>);

}

Object.assign(window, { Todo, EvidenceSlot, VideoSlot, CaptionField });