/* editable.jsx — owner-editing primitives, shared by all pages.
 *
 *  • window.ed(id)          → spread props that mark a text node editable
 *  • window.TextEditLayer   → makes every [data-ed] node editable in Edit mode,
 *                             persists per-id + per-language to localStorage
 *  • window.MediaSlot       → a fillable media box: drop a photo (image-slot)
 *                             OR attach a video (file kept in IndexedDB, or a
 *                             YouTube / Vimeo / mp4 link). Persists across reloads.
 */

/* ── editable text ──────────────────────────────────────────────────────
 * Text overrides persist in the SAME sidecar file as photos/videos
 * (.image-slots.state.json via window.SidecarStore), NOT localStorage — so an
 * owner's copy edits travel with the site: share links, downloads, other
 * devices and browsers all see them, exactly like the filled image slots.
 * Keys are namespaced 'text:<id>:<lang>' so they never collide with a photo
 * slot id or a 'vid:' video key. One-time migration lifts any pre-existing
 * localStorage overrides into the sidecar so older edits aren't lost.        */

/* ── Owner gate ─────────────────────────────────────────────────────────
 * A static site can't truly authenticate, but we only need to HIDE the editing
 * affordances from clients. We do that with a private URL fragment the owner
 * keeps to herself: visiting with #edit flips a remembered flag on this device;
 * #client / #view clears it so the owner can preview what a visitor sees.
 * Visitors who never use the #edit link never see any editing controls.       */
(function resolveOwner() {
  const OWNER_LS = 'ke-owner-mode';
  let owner = false;
  try {owner = localStorage.getItem(OWNER_LS) === '1';} catch {}
  // Inside the editor runtime (where writeFile actually persists) the owner is
  // always editing, so default to owner there. On a deployed/static site this
  // is absent, so visitors get the clean read-only view by default.
  const inEditor = !!(window.omelette && window.omelette.writeFile);
  if (inEditor) owner = true;
  const s = ((location.hash || '') + ' ' + (location.search || '')).toLowerCase();
  if (/\bedit\b/.test(s)) {owner = true;try {localStorage.setItem(OWNER_LS, '1');} catch {}}
  // Explicit client/preview flag wins — lets the owner see exactly what a
  // visitor sees, even inside the editor.
  if (/\b(client|view|preview)\b/.test(s)) {owner = false;try {localStorage.removeItem(OWNER_LS);} catch {}}
  window.PF_OWNER = owner;
  try {
    console.info(
      `%c${owner ? '\u270e Owner mode ON' : '\ud83d\udc41 Visitor view'}%c \u2014 add #edit to the URL to edit, #client to preview as a visitor.`,
      'font-weight:700', 'font-weight:400'
    );
  } catch {}
})();

window.ed = (id) => ({ 'data-ed': id });

const TXT_KEY = (id, lang) => `text:${id}:${lang}`;
const TXT_LS = (id, lang) => `ketext:${id}:${lang}`;

// Text overrides are written to BOTH stores and read sidecar-first:
//   • sidecar (.image-slots.state.json)  → portable: travels with share links,
//     downloads, other devices/browsers. Persists wherever writeFile is wired.
//   • localStorage                       → instant local cache + fallback for
//     environments where the sidecar write can't land.
// Whichever survives in a given environment, the edit comes back.
const TextStore = {
  load: () => window.SidecarStore ? window.SidecarStore.load() : Promise.resolve(),
  get: (id, lang) => {
    const v = window.SidecarStore && window.SidecarStore.get(TXT_KEY(id, lang));
    if (v && typeof v.t === 'string') return v.t;
    try {
      const ls = localStorage.getItem(TXT_LS(id, lang));
      if (ls != null) return ls;
    } catch {}
    return null;
  },
  set: (id, lang, val) => {
    if (window.SidecarStore) window.SidecarStore.set(TXT_KEY(id, lang), val == null ? null : { t: val });
    try {
      if (val == null) localStorage.removeItem(TXT_LS(id, lang));else
      localStorage.setItem(TXT_LS(id, lang), val);
    } catch {}
  },
  subscribe: (fn) => window.SidecarStore ? window.SidecarStore.subscribe(fn) : () => {}
};
window.TextStore = TextStore;

// One-time: lift any legacy localStorage 'ketext:*' overrides into the sidecar
// too, so older edits become portable. Keeps the localStorage copy as cache.
let _txtMigrated = false;
function migrateLegacyText() {
  if (_txtMigrated || !window.SidecarStore) return;
  _txtMigrated = true;
  try {
    Object.keys(localStorage).
    filter((k) => k.startsWith('ketext:')).
    forEach((k) => {
      const rest = k.slice('ketext:'.length); // "<id>:<lang>"
      const i = rest.lastIndexOf(':');
      if (i < 0) return;
      const id = rest.slice(0, i),lang = rest.slice(i + 1);
      const val = localStorage.getItem(k);
      const cur = window.SidecarStore.get(TXT_KEY(id, lang));
      if (val != null && val.trim() !== '' && !(cur && typeof cur.t === 'string')) {
        window.SidecarStore.set(TXT_KEY(id, lang), { t: val });
      }
    });
  } catch {}
}

function TextEditLayer({ lang, editMode }) {
  // Re-render whenever the sidecar loads or changes, so overrides re-apply to
  // freshly-mounted nodes and reflect edits made elsewhere.
  const [, force] = React.useState(0);
  // Latest lang/editMode for the MutationObserver callback, which lives
  // outside React's render cycle and would otherwise close over stale props.
  const langRef = React.useRef(lang); langRef.current = lang;
  const editRef = React.useRef(editMode); editRef.current = editMode;

  // Push every stored override onto its [data-ed] node — but never the node
  // currently being edited, so an in-flight keystroke is never clobbered.
  const applyOverrides = React.useCallback(() => {
    const lg = langRef.current;
    document.querySelectorAll('[data-ed]').forEach((el) => {
      if (el === document.activeElement) return;
      const ov = TextStore.get(el.getAttribute('data-ed'), lg);
      if (ov != null && el.innerText !== ov) el.innerText = ov;
    });
  }, []);

  // Wire (or unwire) contenteditable + autosave handlers. Idempotent: the
  // contenteditable/spellcheck/class triple is only (re)set when not already
  // in place, so re-running this on the focused field can't collapse its
  // caret mid-edit.
  const wireEditing = React.useCallback(() => {
    const edit = editRef.current;
    document.querySelectorAll('[data-ed]').forEach((el) => {
      const id = el.getAttribute('data-ed');
      if (edit) {
        if (el.getAttribute('contenteditable') !== 'true') {
          el.setAttribute('contenteditable', 'true');
          el.setAttribute('spellcheck', 'false');
          el.classList.add('is-editable');
        }
        // Store exactly what's there — including an empty string, so the
        // owner can blank a line out and have it STAY blank instead of
        // snapping back to the built-in default.
        const commit = () => TextStore.set(id, langRef.current, el.innerText);
        el.onblur = commit;
        // Also save while typing (debounced) so a reload mid-edit — before the
        // field loses focus — can't lose the change.
        el.oninput = () => { clearTimeout(el._saveT); el._saveT = setTimeout(commit, 400); };
        // Stop clicks from triggering a parent nav button while editing.
        el.onmousedown = (e) => e.stopPropagation();
        el.onclick = (e) => e.stopPropagation();
      } else if (el.hasAttribute('contenteditable')) {
        el.removeAttribute('contenteditable');
        el.classList.remove('is-editable');
        el.onblur = null; el.oninput = null; el.onmousedown = null; el.onclick = null;
      }
    });
  }, []);

  React.useEffect(() => {
    TextStore.load().then(() => {migrateLegacyText();force((n) => n + 1);});
    const unsub = TextStore.subscribe(() => force((n) => n + 1));

    // The stack carousel (and the in-case figure carousel) rewrite their own
    // card text on every scroll — independently of this layer's render — which
    // resets each [data-ed] node back to its built-in default. Without this
    // the owner's edits silently revert the moment a card re-draws (e.g. the
    // year shows 2026 again after you saved 2023). Watch the DOM and re-apply
    // overrides whenever text is rewritten; the focused field is always
    // skipped so live typing is untouched. Re-applying sets innerText, which
    // fires one more mutation that no-ops on the equality check — no loop.
    let raf = 0;
    const obs = new MutationObserver(() => {
      if (raf) return;
      raf = requestAnimationFrame(() => { raf = 0; applyOverrides(); });
    });
    obs.observe(document.body, { subtree: true, childList: true, characterData: true });
    return () => { unsub(); obs.disconnect(); if (raf) cancelAnimationFrame(raf); };
  }, [applyOverrides]);

  // After every commit (store update, language / route change) re-apply
  // overrides and (re)wire freshly-mounted nodes.
  React.useLayoutEffect(() => { applyOverrides(); wireEditing(); });

  return null;
}
window.TextEditLayer = TextEditLayer;

/* ── shared video store ─────────────────────────────────────────────────
 * Videos persist the SAME way photos do — and in the SAME sidecar file. The
 * host only allowlists writes to .image-slots.state.json, so a second sidecar
 * silently fails. We piggyback on image-slot's store (window.SidecarStore):
 * one in-memory map + one writer, no clobber. Video keys are namespaced
 * 'vid:<id>' and hold { vid:{kind,src} } so they never collide with a photo
 * slot id. Descriptor: { kind:'embed'|'url'|'file', src }.
 *   • embed/url → tiny string (YouTube/Vimeo/mp4 link)
 *   • file      → a data: URL of the clip, stored in the sidecar so it travels
 *                 with the site (capped to keep the file sane).               */
const VS_MAX_BYTES = 150 * 1024 * 1024; // ~150MB raw. Split into ~18MB chunks by SidecarStore.setBlob so each individual write stays under the host's single-write cap.
const VS_KEY = (id) => 'vid:' + id;

// Descriptor in the MAIN sidecar stays tiny:
//   embed / url → { vid:{ kind, src } }   (src is a short link)
//   file        → { vid:{ kind:'file' } } (NO data here — the clip's data: URL
//                  lives in its OWN per-key blob file via SidecarStore.setBlob,
//                  so two videos never share one write and bust the size cap).
const VideoStore = {
  load: () => {
    const p = window.SidecarStore ? window.SidecarStore.load() : Promise.resolve();
    return p.then(() => {
      if (!window.SidecarStore || !window.SidecarStore.keys) return;
      // On every load: migrate any legacy inline file-videos out of the main
      // sidecar into their own blob file (shrinks the bloated state file back
      // to KBs and stops the next write from failing), and warm the blob cache
      // for already-split videos. Both branches are idempotent.
      window.SidecarStore.keys().
      filter((k) => k.indexOf('vid:') === 0).
      forEach((k) => {
        const entry = window.SidecarStore.get(k);
        const d = entry && entry.vid;
        if (!d || d.kind !== 'file') return;
        if (typeof d.src === 'string' && d.src) {
          // legacy inline clip → try to move the bytes to a blob file. Only
          // slim the entry once the blob write is CONFIRMED on disk, so an
          // over-cap clip is never lost mid-migration (it just keeps working
          // inline as before if the split can't land).
          window.SidecarStore.setBlob(k, d.src).then((ok) => {
            if (ok) window.SidecarStore.set(k, { vid: { kind: 'file' } });
          });
        }
        // else: the clip is already split into its own blob file. Do NOT warm
        // it here — eagerly pulling every video's bytes into memory on each
        // page load was a second source of the "slower the more I add" lag.
        // VideoStore.get() lazy-loads the blob (via getBlob) only when the
        // MediaSlot for this id actually renders.
      });
    });
  },
  get: (id) => {
    if (!window.SidecarStore) return null;
    const entry = window.SidecarStore.get(VS_KEY(id));
    const d = entry && entry.vid;
    if (!d) return null;
    if (d.kind === 'file') {
      // src resolved from the per-key blob file; null while it loads (the slot
      // shows its photo placeholder for a beat, then swaps in the video).
      const src = typeof d.src === 'string' && d.src ?
      d.src :
      window.SidecarStore.getBlob(VS_KEY(id));
      return src ? { kind: 'file', src } : null;
    }
    return d; // embed / url carry their src inline
  },
  set: (id, desc) => {
    if (!window.SidecarStore) return Promise.resolve(false);
    const key = VS_KEY(id);
    if (!desc) {
      return Promise.all([
        window.SidecarStore.set(key, null),
        window.SidecarStore.setBlob(key, null),
      ]).then(() => true);
    }
    if (desc.kind === 'file') {
      // Write the BLOB FIRST (await all chunks), THEN the main-sidecar
      // descriptor. If the page is refreshed mid-write, having the descriptor
      // without the blob bytes leaves a dead pointer; having blob bytes
      // without a descriptor is harmless (probeOrphanParts recovers anyway,
      // and on the next set the descriptor lands). Returned promise resolves
      // only after EVERY write has landed — callers (the upload UI) await
      // this before clearing their "saving…" state so the user can't refresh
      // during the writes and lose the clip.
      return window.SidecarStore.setBlob(key, desc.src)
        .then(() => window.SidecarStore.set(key, { vid: { kind: 'file' } }));
    }
    return window.SidecarStore.set(key, { vid: desc }); // embed / url inline
  },
  subscribe: (fn) => window.SidecarStore ? window.SidecarStore.subscribe(fn) : () => {}
};
window.VideoStore = VideoStore;

// React hook: live descriptor for an id, plus a setter that persists.
function useVideo(id) {
  const [, force] = React.useState(0);
  React.useEffect(() => {
    VideoStore.load();
    return VideoStore.subscribe(() => force((n) => n + 1));
  }, []);
  return [VideoStore.get(id), (d) => VideoStore.set(id, d)];
}
window.useVideo = useVideo;

function msParseUrl(raw) {
  const url = (raw || '').trim();
  if (!url) return null;
  let m = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{11})/);
  if (m) return { kind: 'embed', src: `https://www.youtube.com/embed/${m[1]}?autoplay=1&mute=1&controls=0&loop=1&playlist=${m[1]}&modestbranding=1&rel=0&playsinline=1` };
  m = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
  if (m) return { kind: 'embed', src: `https://player.vimeo.com/video/${m[1]}?autoplay=1&muted=1&loop=1&background=1` };
  return { kind: 'url', src: url };
}
function fileToDataUrl(file) {
  return new Promise((res, rej) => {
    const r = new FileReader();
    r.onload = () => res(r.result);
    r.onerror = () => rej(r.error);
    r.readAsDataURL(file);
  });
}
window.msParseUrl = msParseUrl;
window.msFileToDataUrl = fileToDataUrl;

/* ── data: URL → blob: URL ──────────────────────────────────────────────
 * A file-kind video lives on disk as a base64 data: URL split across many
 * sidecar chunks. Feeding that monster string straight into <video src> is
 * the reason playback "hangs" for a beat — the browser can't stream or seek
 * a data: URL, so it has to download the whole base64 blob into memory,
 * decode it, and only THEN start. A blob: URL backed by URL.createObjectURL
 * skips the base64 round-trip entirely and lets the video element treat it
 * like a real seekable file → playback starts almost immediately.
 *
 * The conversion is memoized per data: URL identity so we don't re-decode on
 * every React render, and old blob URLs are revoked when their data: URL is
 * superseded so we don't leak memory across video swaps.                    */
const _blobUrlCache = new Map(); // dataUrl -> blobUrl
function msDataUrlToBlobUrl(src) {
  if (typeof src !== 'string' || !src.startsWith('data:')) return src;
  const hit = _blobUrlCache.get(src);
  if (hit) return hit;
  try {
    const comma = src.indexOf(',');
    const meta = src.slice(5, comma); // e.g. 'video/mp4;base64'
    const isB64 = /;base64$/i.test(meta);
    const mime = isB64 ? meta.replace(/;base64$/i, '') : meta.split(';')[0] || 'video/mp4';
    const payload = src.slice(comma + 1);
    let bytes;
    if (isB64) {
      const bin = atob(payload);
      bytes = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    } else {
      bytes = new TextEncoder().encode(decodeURIComponent(payload));
    }
    const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
    _blobUrlCache.set(src, url);
    return url;
  } catch {
    return src; // fall back to the original data: URL if anything goes wrong
  }
}
window.msDataUrlToBlobUrl = msDataUrlToBlobUrl;

const { useState: useStateMS, useRef: useRefMS } = React;

function MediaSlot({ id, lang = 'en', ratio, tone, fit = 'cover', label, className = '', style = {} }) {
  const owner = window.PF_OWNER;
  const [desc, setDesc] = useVideo(id); // sidecar-backed, persists like photos
  const [editing, setEditing] = useStateMS(false);
  const [draft, setDraft] = useStateMS('');
  const [busy, setBusy] = useStateMS(false);
  const [err, setErr] = useStateMS('');
  const vref = useRefMS(null);
  const fileRef = useRefMS(null);

  const assign = (d) => {if (!d) return Promise.resolve();const p = setDesc(d);setEditing(false);setDraft('');setErr('');return Promise.resolve(p);};
  const pickFile = () => fileRef.current && fileRef.current.click();
  const onFile = async (e) => {
    const f = e.target.files && e.target.files[0];
    e.target.value = '';
    if (!f || !/^video\//.test(f.type)) {setErr(lang === 'ru' ? 'Выберите видеофайл' : 'Pick a video file');return;}
    if (f.size > VS_MAX_BYTES) {
      setErr(lang === 'ru' ?
      'Файл больше 150 МБ. Сожмите клип или вставьте ссылку YouTube/Vimeo.' :
      'Over 150 MB. Compress the clip or paste a YouTube/Vimeo link.');
      return;
    }
    setBusy(true);
    try {
      const dataUrl = await window.msFileToDataUrl(f);
      // Await ALL chunk + descriptor writes before clearing busy — the
      // “Saving…” label stays up until the clip is fully on disk so the
      // user knows not to refresh in the middle of the upload.
      await assign({ kind: 'file', src: dataUrl });
    } catch {
      setErr(lang === 'ru' ? 'Не удалось прочитать файл' : 'Could not read file');
    } finally {setBusy(false);}
  };
  const clear = () => {setDesc(null);setEditing(false);setErr('');};

  const stop = (e) => e.stopPropagation();
  const wrapStyle = { ...style };
  if (ratio) wrapStyle.aspectRatio = ratio;
  if (tone) wrapStyle['--tone'] = tone;

  return (
    <div className={`mslot ${className}`} style={wrapStyle}>
      {desc && desc.kind === 'embed' &&
      <iframe className="mslot-media" src={desc.src} title="video" frameBorder="0"
      allow="autoplay; fullscreen; picture-in-picture"></iframe>
      }
      {desc && (desc.kind === 'url' || desc.kind === 'file') &&
      <video ref={(el) => { vref.current = el; if (el) { el.muted = true; el.defaultMuted = true; el.volume = 0; } }} className="mslot-media"
      src={desc.kind === 'file' ? msDataUrlToBlobUrl(desc.src) : desc.src}
      muted loop autoPlay playsInline preload="auto"
      onLoadedMetadata={(e) => { try { e.currentTarget.muted = true; e.currentTarget.play().catch(() => {}); } catch {} }}
      onCanPlay={(e) => { try { e.currentTarget.muted = true; e.currentTarget.play().catch(() => {}); } catch {} }}></video>
      }
      {!desc &&
      <image-slot
        id={id}
        shape="rect"
        fit={fit}
        placeholder={owner ? label || (lang === 'ru' ? 'Перетащите фото' : 'Drop photo') : ''}>
      </image-slot>
      }

      {owner &&
      <div className="mslot-tools" onMouseDown={stop} onClick={stop}>
          {desc ?
        <button className="mslot-tool" onClick={clear}>{lang === 'ru' ? '× Видео' : '× Video'}</button> :

        <button className="mslot-tool" onClick={() => {setEditing((v) => !v);setDraft('');setErr('');}}>
              {lang === 'ru' ? '＋ Видео' : '＋ Video'}
            </button>
        }
        </div>
      }

      {editing && !desc &&
      <div className="mslot-pop" onMouseDown={stop} onClick={stop}>
          <div className="mslot-pop-h pf-mono">{lang === 'ru' ? 'Добавить видео' : 'Add video'}</div>
          <input
          className="mslot-pop-input"
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={(e) => {if (e.key === 'Enter') assign(window.msParseUrl(draft));}}
          placeholder={lang === 'ru' ? 'Ссылка YouTube / Vimeo / mp4' : 'YouTube / Vimeo / mp4 link'} />
        
          <div className="mslot-pop-row">
            <button className="mslot-pop-btn" onClick={() => assign(window.msParseUrl(draft))}>{lang === 'ru' ? 'Добавить' : 'Add'}</button>
            <button className="mslot-pop-btn" onClick={pickFile} disabled={busy}>
              {busy ? lang === 'ru' ? 'Сохранение… не закрывайте' : 'Saving… don’t close' : lang === 'ru' ? 'Загрузить файл' : 'Upload file'}
            </button>
          </div>
          {err ?
        <div className="mslot-pop-note pf-mono" style={{ color: 'var(--accent)' }}>{err}</div> :
        <div className="mslot-pop-note pf-mono">{lang === 'ru' ? 'Сохраняется на сайте' : 'Saved with the site'}</div>}
        </div>
      }

      <input ref={fileRef} type="file" accept="video/*" hidden onChange={onFile} />
    </div>);

}
window.MediaSlot = MediaSlot;

/* ── Section hero ───────────────────────────────────────────────────────
 * The framed, black-bordered title plate at the top of section pages
 * (Photo, Graphic design) — mirrors the home hero so the whole site reads as
 * one system: a vertical label rail on the left, a big editable display title
 * in the center, and the K—E® edge column on the right. The title lines are
 * owner-editable via window.ed(idBase-0 / -1); the ⌕ corner opens the index.  */
function SectionHero({ lang, frameLabel, railLabel, titleLines, idBase, edgeText, onIndex }) {
  const edsh = window.ed;
  return (
    <div className="sec-hero-wrap">
      {frameLabel ? <div className="sec-hero-toplabel pf-mono">{frameLabel}</div> : null}
      <div className="sec-hero-frame">
        <div className="sec-hero-rail">
          <span className="sec-hero-rail-label" {...edsh(`${idBase}-rail`)}>{railLabel}</span>
        </div>
        <div className="sec-hero-title-area">
          <h1 className="sec-hero-h1">
            {titleLines.map((line, i) =>
            <span key={i} className="sec-hero-h1-line" {...edsh(`${idBase}-${i}`)}>{line}</span>
            )}
          </h1>
        </div>
        <div className="sec-hero-edge">
          <div className="sec-hero-edge-mark">K—E®</div>
          <div className="sec-hero-edge-meta">
            <span className="sec-hero-edge-arrow">▼</span>
            <span className="sec-hero-edge-text">{edgeText}</span>
          </div>
          <button className="sec-hero-edge-corner" onClick={onIndex} aria-label="Index">⌕</button>
        </div>
      </div>
    </div>);

}
window.SectionHero = SectionHero;

/* ── Project plate ──────────────────────────────────────────────────────
 * Each project / series shown as the SAME framed black-bordered plate as the
 * home + section heroes: a vertical title rail on the left, the fillable cover
 * image in the center, and the index/year edge column on the right. Clicking
 * anywhere on the plate opens the case; the cover is still drop-to-fill.       */
function ProjectPlate({ lang, mediaId, ratio, tone, slotLabel, idx, title, titleId, year, yearId, cta, onOpen }) {
  const edpp = window.ed;
  const open = () => onOpen && onOpen();
  return (
    <div className="pplate" role="button" tabIndex={0}
    onClick={open}
    onKeyDown={(e) => {if (e.key === 'Enter' || e.key === ' ') {e.preventDefault();open();}}}>
      <div className="pplate-rail">
        <span className="pplate-rail-title" {...edpp(titleId)}>{title}</span>
      </div>
      <div className="pplate-media">
        <MediaSlot id={mediaId} lang={lang} ratio={ratio || '16/9'} tone={tone}
        className="pplate-img" label={slotLabel} />
        <span className="pplate-cta pf-mono">{cta} →</span>
      </div>
      <div className="pplate-edge">
        <div className="pplate-edge-mark pf-mono">{idx}</div>
        <div className="pplate-edge-meta">
          <span className="pplate-edge-arrow">▼</span>
          <span className="pplate-edge-text pf-mono" {...yearId ? edpp(yearId) : {}}>{year}</span>
        </div>
        <div className="pplate-edge-corner">→</div>
      </div>
    </div>);

}
window.ProjectPlate = ProjectPlate;