// ═════════════════════════════════════════════════════════════════════════════
// MindForge · Layer 1 · core/media-store.jsx
// ─────────────────────────────────────────────────────────────────────────────
// Role     : IndexedDB blob store for media (audio/video/images) + canvas
//            image compression + URL parser for embedded videos (YouTube,
//            Vimeo, Drive, direct mp4/webm with time fragments).
// Loaded   : #3
// Reads    : window.React, window.indexedDB
// Exposes  : window.{ mediaStore, useMediaURL, compressImage,
//            maybeCompressImage, isMediaId, isDataURL,
//            parseVideoEmbed, parseTimeString, extractStartTime,
//            extractEndTime, detectVideoHost, buildVideoUrl,
//            hostSupportsEnd, formatSeconds }
// See also : docs/ARCHITECTURE.md §"Storage layout"
//            docs/DATA-MODEL.md §"Custom field types"
// ═════════════════════════════════════════════════════════════════════════════
//
// Card payloads reference media by an opaque `media:…` id. Legacy `data:` URL
// values still flow through unchanged so old libraries keep working without
// an explicit migration step.
//
// MindForge — Media blob store (legacy header preserved below)
//
// Stores large binary blobs (audio, video, images) outside of localStorage
// so we're not capped at ~5–10 MB total. Cards reference media by an opaque
// `mediaId` string; the binary lives here.
//
// Object URLs are cached so re-rendering doesn't recreate them on each pass.
// A media value can also be a legacy `data:` URL (from the older base64
// storage) — `getURL` / the React hook return it unchanged so old cards keep
// working without migration.

const MEDIA_DB_NAME    = 'mindforge.media';
const MEDIA_DB_VERSION = 1;
const MEDIA_STORE_NAME = 'blobs';
const MEDIA_ID_PREFIX  = 'media:';

let _dbPromise = null;

const _openDB = () => {
  if (_dbPromise) return _dbPromise;
  _dbPromise = new Promise((resolve, reject) => {
    if (typeof indexedDB === 'undefined') {
      reject(new Error('IndexedDB indisponible dans ce contexte.'));
      return;
    }
    const req = indexedDB.open(MEDIA_DB_NAME, MEDIA_DB_VERSION);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(MEDIA_STORE_NAME)) {
        db.createObjectStore(MEDIA_STORE_NAME);
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror   = () => reject(req.error);
    req.onblocked = () => reject(new Error('IndexedDB upgrade blocked.'));
  });
  return _dbPromise;
};

const _withStore = async (mode, fn) => {
  const db = await _openDB();
  return new Promise((resolve, reject) => {
    const t = db.transaction(MEDIA_STORE_NAME, mode);
    const store = t.objectStore(MEDIA_STORE_NAME);
    let result;
    try {
      Promise.resolve(fn(store))
        .then(r => { result = r; })
        .catch(reject);
    } catch (e) { reject(e); return; }
    t.oncomplete = () => resolve(result);
    t.onerror    = () => reject(t.error);
    t.onabort    = () => reject(t.error);
  });
};

const _newMediaId = () => {
  const t = Date.now().toString(36);
  const buf = new Uint8Array(8);
  (window.crypto || window.msCrypto).getRandomValues(buf);
  let r = '';
  for (const b of buf) r += b.toString(16).padStart(2, '0');
  return `${MEDIA_ID_PREFIX}${t}-${r}`;
};

const isMediaId = (v) => typeof v === 'string' && v.startsWith(MEDIA_ID_PREFIX);
const isDataURL = (v) => typeof v === 'string' && v.startsWith('data:');

// id -> object URL (revoked when the blob is removed or replaced).
const _urlCache = new Map();

const mediaStore = {
  // Put a blob, return its new id.
  async put(blob) {
    const id = _newMediaId();
    await _withStore('readwrite', s => new Promise((res, rej) => {
      const r = s.put(blob, id);
      r.onsuccess = () => res();
      r.onerror   = () => rej(r.error);
    }));
    return id;
  },

  // Fetch the raw Blob.
  async get(id) {
    if (!isMediaId(id)) return null;
    return _withStore('readonly', s => new Promise((res, rej) => {
      const r = s.get(id);
      r.onsuccess = () => res(r.result || null);
      r.onerror   = () => rej(r.error);
    }));
  },

  // Return an object URL suitable for <audio>/<video>/<img> src.
  // Legacy values: if `v` looks like a data URL, returns it as-is.
  async getURL(v) {
    if (!v) return null;
    if (isDataURL(v)) return v;
    if (!isMediaId(v)) return null;
    if (_urlCache.has(v)) return _urlCache.get(v);
    const blob = await mediaStore.get(v);
    if (!blob) return null;
    const url = URL.createObjectURL(blob);
    _urlCache.set(v, url);
    return url;
  },

  // Delete a blob (and its cached URL). Safe on legacy values & missing ids.
  async remove(id) {
    if (!isMediaId(id)) return;
    if (_urlCache.has(id)) {
      try { URL.revokeObjectURL(_urlCache.get(id)); } catch {}
      _urlCache.delete(id);
    }
    try {
      await _withStore('readwrite', s => new Promise((res, rej) => {
        const r = s.delete(id);
        r.onsuccess = () => res();
        r.onerror   = () => rej(r.error);
      }));
    } catch (e) {
      console.warn('mediaStore.remove failed', id, e);
    }
  },

  // Remove many ids (best-effort, parallel).
  async removeMany(ids) {
    await Promise.all((ids || []).filter(isMediaId).map(id => mediaStore.remove(id)));
  },

  // Wipe everything. Used by "reset library".
  async clear() {
    for (const url of _urlCache.values()) {
      try { URL.revokeObjectURL(url); } catch {}
    }
    _urlCache.clear();
    try {
      await _withStore('readwrite', s => new Promise((res, rej) => {
        const r = s.clear();
        r.onsuccess = () => res();
        r.onerror   = () => rej(r.error);
      }));
    } catch (e) { console.warn('mediaStore.clear failed', e); }
  },
};

// React hook: resolves an id (or legacy dataURL) to a renderable URL.
// Returns null while pending, then the URL string.
const useMediaURL = (value) => {
  const [url, setUrl] = React.useState(() => isDataURL(value) ? value : null);
  React.useEffect(() => {
    if (!value) { setUrl(null); return; }
    if (isDataURL(value)) { setUrl(value); return; }
    let cancelled = false;
    mediaStore.getURL(value).then(u => { if (!cancelled) setUrl(u); }).catch(() => {});
    return () => { cancelled = true; };
  }, [value]);
  return url;
};

// ── Image compression (canvas) ─────────────────────────────────────────────
//
// Resize-down to at most maxDim on the longest edge, re-encode as JPEG at
// `quality`. Typical 5 MB camera photo → ~400–800 KB. Returns a Blob.

const compressImage = (file, { maxDim = 1920, quality = 0.82, mimeType = 'image/jpeg' } = {}) =>
  new Promise((resolve, reject) => {
    const url = URL.createObjectURL(file);
    const img = new Image();
    img.onload = () => {
      try {
        const longest = Math.max(img.naturalWidth, img.naturalHeight);
        const scale = longest > maxDim ? maxDim / longest : 1;
        const w = Math.max(1, Math.round(img.naturalWidth  * scale));
        const h = Math.max(1, Math.round(img.naturalHeight * scale));
        const canvas = document.createElement('canvas');
        canvas.width  = w;
        canvas.height = h;
        const ctx = canvas.getContext('2d');
        // Fill white so transparency in PNG sources doesn't become black in JPEG.
        if (mimeType === 'image/jpeg') {
          ctx.fillStyle = '#ffffff';
          ctx.fillRect(0, 0, w, h);
        }
        ctx.drawImage(img, 0, 0, w, h);
        URL.revokeObjectURL(url);
        canvas.toBlob(
          (blob) => blob ? resolve(blob) : reject(new Error('Compression: toBlob() a échoué.')),
          mimeType,
          quality
        );
      } catch (e) { URL.revokeObjectURL(url); reject(e); }
    };
    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("Lecture de l'image impossible.")); };
    img.src = url;
  });

// Compress a File only if it looks oversized (or always when forced).
// Falls back to the original blob if compression somehow inflates it.
const maybeCompressImage = async (file, opts = {}) => {
  if (!file || !file.type?.startsWith('image/')) return file;
  try {
    const out = await compressImage(file, opts);
    return out.size < file.size ? out : file;
  } catch (e) {
    console.warn('Image compression failed; using original', e);
    return file;
  }
};

// ── External video URL parser ──────────────────────────────────────────────
//
// Recognises the major free video hosts and returns an embed descriptor:
//   { kind: 'iframe', src, startSec? }  — render inside an <iframe> (YouTube, Vimeo, Drive)
//   { kind: 'video',  src, startSec? }  — render inside a <video controls src=…>
//   null                                 — unknown URL, render as a plain link
//
// Tested patterns:
//   https://www.youtube.com/watch?v=ID&t=2m45s          ← timestamp respected
//   https://youtu.be/ID?t=165
//   https://www.youtube.com/embed/ID?start=165
//   https://vimeo.com/ID#t=2m45s
//   https://drive.google.com/file/d/ID/view             ← no seek support
//   https://…/whatever.mp4#t=165                        ← HTML5 Media Fragments

// Parse a duration string into seconds. Accepts:
//   "165"        → 165
//   "165s"       → 165
//   "2m45s"      → 165   (YouTube share format)
//   "1h2m3s"     → 3723
//   "2:45"       → 165   (mm:ss)
//   "1:02:45"    → 3765  (hh:mm:ss)
const parseTimeString = (str) => {
  if (str == null) return null;
  const s = String(str).trim();
  if (!s) return null;
  let m;
  // Plain integer (optionally with trailing 's')
  m = s.match(/^(\d+)s?$/);
  if (m) return parseInt(m[1], 10);
  // YouTube-style "1h2m3s" / "2m45s" / "45s"
  m = s.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i);
  if (m && (m[1] || m[2] || m[3])) {
    return parseInt(m[1] || '0', 10) * 3600
         + parseInt(m[2] || '0', 10) * 60
         + parseInt(m[3] || '0', 10);
  }
  // Clock format "mm:ss" or "hh:mm:ss"
  m = s.match(/^(\d+):(\d{1,2})(?::(\d{1,2}))?$/);
  if (m) {
    if (m[3] !== undefined) {
      return parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10);
    }
    return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
  }
  return null;
};

// Extract a "start at" seconds value from common URL conventions:
//   ?t=…, &t=…, ?start=…, ?time_continue=…, #t=… (single value, or first of "start,end")
const extractStartTime = (url) => {
  try {
    const u = new URL(url);
    for (const key of ['t', 'start', 'time_continue']) {
      const v = u.searchParams.get(key);
      const parsed = parseTimeString(v);
      if (parsed != null && parsed > 0) return parsed;
    }
    const hash = (u.hash || '').replace(/^#/, '');
    const fm = hash.match(/(?:^|&)t=([^&]+)/);
    if (fm) {
      // Media Fragments allow "start,end" — keep only the start part here.
      const raw = decodeURIComponent(fm[1]).split(',')[0];
      const parsed = parseTimeString(raw);
      if (parsed != null && parsed > 0) return parsed;
    }
  } catch {
    const m = url.match(/[?&#]t=([^&#]+)/);
    if (m) {
      const raw = decodeURIComponent(m[1]).split(',')[0];
      const parsed = parseTimeString(raw);
      if (parsed != null && parsed > 0) return parsed;
    }
  }
  return null;
};

// Extract an "end at" seconds value. Conventions:
//   ?end=…  (YouTube embed standard, tolerated on watch URLs)
//   #t=start,end  (HTML5 Media Fragments)
const extractEndTime = (url) => {
  try {
    const u = new URL(url);
    const v = u.searchParams.get('end');
    const parsed = parseTimeString(v);
    if (parsed != null && parsed > 0) return parsed;
    const hash = (u.hash || '').replace(/^#/, '');
    const fm = hash.match(/(?:^|&)t=([^&]+)/);
    if (fm) {
      const parts = decodeURIComponent(fm[1]).split(',');
      if (parts.length >= 2) {
        const p = parseTimeString(parts[1]);
        if (p != null && p > 0) return p;
      }
    }
  } catch {
    const m = url.match(/[?&]end=([^&#]+)/);
    if (m) {
      const p = parseTimeString(decodeURIComponent(m[1]));
      if (p != null && p > 0) return p;
    }
  }
  return null;
};

// Classify a URL — used by buildVideoUrl to know which host conventions apply.
const detectVideoHost = (url) => {
  if (!url) return 'unknown';
  if (/youtube\.com|youtu\.be/i.test(url))      return 'youtube';
  if (/vimeo\.com/i.test(url))                  return 'vimeo';
  if (/drive\.google\.com/i.test(url))          return 'drive';
  if (/\.(mp4|webm|mov|m4v|ogg|ogv)(\?|#|$)/i.test(url)) return 'direct';
  return 'unknown';
};

// Rewrite a base URL with the given start/end seconds, using the right
// convention for the detected host. null/0 values clear the marker. Other
// query/hash parameters are preserved.
const buildVideoUrl = (baseUrl, startSec, endSec) => {
  if (!baseUrl) return baseUrl;
  let u;
  try { u = new URL(baseUrl); } catch { return baseUrl; }

  // Wipe any existing start/end markers — we'll re-add them per host.
  for (const k of ['t', 'start', 'time_continue', 'end']) u.searchParams.delete(k);
  if (u.hash) {
    const kept = u.hash.replace(/^#/, '').split('&').filter(p => !p.startsWith('t=')).join('&');
    u.hash = kept ? '#' + kept : '';
  }

  const host = detectVideoHost(baseUrl);
  if (host === 'youtube') {
    if (startSec) u.searchParams.set('t', `${startSec}s`);
    if (endSec)   u.searchParams.set('end', String(endSec));
  } else if (host === 'vimeo') {
    if (startSec) u.hash = `#t=${startSec}s`;
    // Vimeo has no end-at parameter in the standard player.
  } else if (host === 'direct') {
    if (startSec || endSec) {
      const parts = [];
      parts.push(startSec || 0);
      if (endSec) parts.push(endSec);
      u.hash = `#t=${parts.join(',')}`;
    }
  } else {
    if (startSec) u.hash = `#t=${startSec}s`;
  }
  return u.toString();
};

// Hosts that honour an "end at" marker through the URL.
const hostSupportsEnd = (host) => host === 'youtube' || host === 'direct';

// Pretty-format a seconds count: "2 min 45 s" / "1 h 02 min 45 s".
const formatSeconds = (sec) => {
  if (sec == null) return '';
  const h = Math.floor(sec / 3600);
  const m = Math.floor((sec % 3600) / 60);
  const s = sec % 60;
  const parts = [];
  if (h) parts.push(`${h} h`);
  if (m) parts.push(h ? String(m).padStart(2, '0') + ' min' : `${m} min`);
  if (s || (!h && !m)) parts.push((h || m) ? String(s).padStart(2, '0') + ' s' : `${s} s`);
  return parts.join(' ');
};

const parseVideoEmbed = (url) => {
  if (!url || typeof url !== 'string') return null;
  const u = url.trim();
  if (!u) return null;
  const startSec = extractStartTime(u);
  const endSec   = extractEndTime(u);
  const host     = detectVideoHost(u);

  let m;
  // YouTube — supports ?start=N&end=N on /embed/
  m = u.match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
  if (m) {
    const params = new URLSearchParams({ rel: '0', modestbranding: '1' });
    if (startSec) params.set('start', String(startSec));
    if (endSec)   params.set('end',   String(endSec));
    return { kind: 'iframe', src: `https://www.youtube.com/embed/${m[1]}?${params.toString()}`, startSec, endSec, host };
  }

  // Vimeo — supports #t=Ns for start only.
  m = u.match(/vimeo\.com\/(?:video\/)?(\d+)/);
  if (m) {
    const src = startSec
      ? `https://player.vimeo.com/video/${m[1]}#t=${startSec}s`
      : `https://player.vimeo.com/video/${m[1]}`;
    return { kind: 'iframe', src, startSec, endSec: null, host };
  }

  // Google Drive — no seek API in the embed.
  m = u.match(/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)/);
  if (m) return { kind: 'iframe', src: `https://drive.google.com/file/d/${m[1]}/preview`, startSec: null, endSec: null, host };
  m = u.match(/drive\.google\.com\/(?:open|uc)\?(?:.*&)?id=([A-Za-z0-9_-]+)/);
  if (m) return { kind: 'iframe', src: `https://drive.google.com/file/d/${m[1]}/preview`, startSec: null, endSec: null, host };

  // Direct video file — HTML5 Media Fragments (#t=start,end).
  if (/\.(mp4|webm|mov|m4v|ogg|ogv)(\?|#|$)/i.test(u)) {
    let src = u;
    if (startSec || endSec) {
      const hashIdx = src.indexOf('#');
      if (hashIdx >= 0) src = src.slice(0, hashIdx);
      const parts = [];
      parts.push(startSec || 0);
      if (endSec) parts.push(endSec);
      src += `#t=${parts.join(',')}`;
    }
    return { kind: 'video', src, startSec, endSec, host };
  }

  return null;
};

Object.assign(window, {
  mediaStore, useMediaURL,
  compressImage, maybeCompressImage,
  isMediaId, isDataURL,
  parseVideoEmbed, parseTimeString, extractStartTime, extractEndTime,
  detectVideoHost, buildVideoUrl, hostSupportsEnd, formatSeconds,
});
