// WeddingOS · photo storage layer.
// Photos are too big for localStorage (~5MB iOS ceiling). This module moves
// every `data:image/...` value OUT of the persisted/synced JSON and into:
//   • IndexedDB (on-device · no 5MB cap)  ← survives reloads, instant render
//   • R2 via the worker (cloud · cross-device sync)
//
// The persisted/synced JSON keeps only a tiny token: `wos-photo:<sha256hex>`.
// The in-memory React state is NEVER touched · it always holds the real data
// URLs, so every render site works exactly as before.
//
// Loaded BEFORE store.jsx and sync-bridge.jsx so its helpers exist when they run.
(function () {
  const IDB_NAME = 'weddingos-photos';
  const IDB_STORE = 'photos';
  const TOKEN_PREFIX = 'wos-photo:';
  const writtenHashes = new Set(); // skip redundant IndexedDB writes this session

  // ── IndexedDB ────────────────────────────────────────────────
  let _dbPromise = null;
  function openDb() {
    if (_dbPromise) return _dbPromise;
    _dbPromise = new Promise((resolve, reject) => {
      try {
        const req = indexedDB.open(IDB_NAME, 1);
        req.onupgradeneeded = () => {
          const db = req.result;
          if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE);
        };
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
      } catch (e) { reject(e); }
    });
    return _dbPromise;
  }
  async function idbGet(hash) {
    try {
      const db = await openDb();
      return await new Promise((resolve) => {
        const tx = db.transaction(IDB_STORE, 'readonly');
        const rq = tx.objectStore(IDB_STORE).get(hash);
        rq.onsuccess = () => resolve(rq.result || null);
        rq.onerror = () => resolve(null);
      });
    } catch { return null; }
  }
  async function idbPut(hash, value) {
    try {
      const db = await openDb();
      return await new Promise((resolve) => {
        const tx = db.transaction(IDB_STORE, 'readwrite');
        tx.objectStore(IDB_STORE).put(value, hash);
        tx.oncomplete = () => { writtenHashes.add(hash); resolve(true); };
        tx.onerror = () => resolve(false);
      });
    } catch { return false; }
  }

  // ── hashing ──────────────────────────────────────────────────
  async function sha256Hex(str) {
    const buf = new TextEncoder().encode(str);
    const digest = await crypto.subtle.digest('SHA-256', buf);
    return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('');
  }

  // ── R2 (via worker) ──────────────────────────────────────────
  function licenseCode() {
    try { return localStorage.getItem('weddingos.license'); } catch { return null; }
  }
  async function r2Put(hash, dataUrl) {
    const base = window.WOS_SYNC_ENDPOINT, code = licenseCode();
    if (!base || !code) return false;
    try {
      const res = await fetch(`${base}/photo/${hash}`, {
        method: 'PUT',
        headers: { 'content-type': 'text/plain', 'x-license': code },
        body: dataUrl,
      });
      return res.ok;
    } catch { return false; }
  }
  async function r2Get(hash) {
    const base = window.WOS_SYNC_ENDPOINT, code = licenseCode();
    if (!base || !code) return null;
    try {
      const res = await fetch(`${base}/photo/${hash}`, { headers: { 'x-license': code } });
      if (!res.ok) return null;
      return await res.text();
    } catch { return null; }
  }

  // ── dehydrate: data URLs -> tokens (caches each to IndexedDB) ──
  async function dehydrateNode(node, photos) {
    if (typeof node === 'string') {
      if (node.startsWith('data:image/')) {
        const hash = await sha256Hex(node);
        if (!writtenHashes.has(hash)) await idbPut(hash, node);
        photos.push({ hash, dataUrl: node });
        return TOKEN_PREFIX + hash;
      }
      return node;
    }
    if (Array.isArray(node)) {
      const out = [];
      for (const v of node) out.push(await dehydrateNode(v, photos));
      return out;
    }
    if (node && typeof node === 'object') {
      const out = {};
      for (const k of Object.keys(node)) out[k] = await dehydrateNode(node[k], photos);
      return out;
    }
    return node;
  }
  // Returns { state: tokenized, photos: [{hash,dataUrl}] }. Photos are already
  // in IndexedDB on return · caller uploads them to R2 separately (sync only).
  async function dehydratePhotos(state) {
    const photos = [];
    const tokenized = await dehydrateNode(state, photos);
    return { state: tokenized, photos };
  }

  // ── hydrate: tokens -> data URLs (IndexedDB first, then R2) ───
  async function hydrateNode(node, fetchMissing) {
    if (typeof node === 'string') {
      if (node.startsWith(TOKEN_PREFIX)) {
        const hash = node.slice(TOKEN_PREFIX.length);
        let url = await idbGet(hash);
        if (!url && fetchMissing) {
          url = await r2Get(hash);
          if (url) await idbPut(hash, url);
        }
        return url || ''; // graceful · empty string renders as "no image", never crashes
      }
      return node;
    }
    if (Array.isArray(node)) {
      const out = [];
      for (const v of node) out.push(await hydrateNode(v, fetchMissing));
      return out;
    }
    if (node && typeof node === 'object') {
      const out = {};
      for (const k of Object.keys(node)) out[k] = await hydrateNode(node[k], fetchMissing);
      return out;
    }
    return node;
  }
  async function hydratePhotos(state, opts) {
    return hydrateNode(state, !!(opts && opts.fetchMissing));
  }

  async function uploadPhotos(photos) {
    for (const p of (photos || [])) {
      // r2Put is idempotent (worker dedups via R2 head check)
      await r2Put(p.hash, p.dataUrl);
    }
  }

  // Walk a TOKENIZED state, collect token hashes, and ensure each photo's bytes
  // are uploaded to R2 (reading the data URL from IndexedDB). Used by manual sync
  // paths that only have the tokenized localStorage copy, not in-memory data URLs.
  function collectHashes(node, set) {
    if (typeof node === 'string') {
      if (node.startsWith(TOKEN_PREFIX)) set.add(node.slice(TOKEN_PREFIX.length));
    } else if (Array.isArray(node)) {
      for (const v of node) collectHashes(v, set);
    } else if (node && typeof node === 'object') {
      for (const k of Object.keys(node)) collectHashes(node[k], set);
    }
    return set;
  }
  async function uploadReferencedPhotos(tokenizedState) {
    const hashes = collectHashes(tokenizedState, new Set());
    for (const hash of hashes) {
      const dataUrl = await idbGet(hash);
      if (dataUrl) await r2Put(hash, dataUrl);
    }
  }

  // Quick test: does a value/state contain any unresolved photo token?
  function stateHasTokens(state) {
    try { return JSON.stringify(state).includes(TOKEN_PREFIX); } catch { return false; }
  }

  // Synchronous strip: tokens -> '' . Used for the very first paint so a token
  // never lands in an <img src> (broken-image flash) before async hydrate runs.
  function stripTokensSyncNode(node) {
    if (typeof node === 'string') return node.startsWith(TOKEN_PREFIX) ? '' : node;
    if (Array.isArray(node)) return node.map(stripTokensSyncNode);
    if (node && typeof node === 'object') {
      const out = {};
      for (const k of Object.keys(node)) out[k] = stripTokensSyncNode(node[k]);
      return out;
    }
    return node;
  }

  Object.assign(window, {
    wosDehydratePhotos: dehydratePhotos,
    wosHydratePhotos: hydratePhotos,
    wosUploadPhotos: uploadPhotos,
    wosUploadReferencedPhotos: uploadReferencedPhotos,
    wosPhotoIdbGet: idbGet,
    wosPhotoIdbPut: idbPut,
    wosStateHasTokens: stateHasTokens,
    wosStripTokensSync: stripTokensSyncNode,
    __WOS_PHOTO_TOKEN_PREFIX: TOKEN_PREFIX,
  });
})();
