/* ===========================================================================
   iRaven App Store Studio — Record on the server (offline frame walk).

   The pipeline:

     Phase 0 (NEW): batch-decode every source video with WebCodecs
                    VideoDecoder (studio-frame-source.jsx). One <video>
                    seek + await per output frame was the bottleneck —
                    30-100 ms each × 900 frames = 30-90 s wall clock.
                    Pre-decoding cuts that to ~1-3 s upfront and turns
                    the render loop into pure canvas draws.
     Phase A      : walk every output frame f = 0…D*fps deterministically;
                    for each frame, replace each scene's m.el with the
                    VideoFrame from its frame source, drawAt(T), capture
                    the canvas as JPEG q=0.95, append to a JSZip.
     Phase B      : append the audio blob (raw — server re-encodes).
     Phase C      : write manifest.json, finalize the zip.
     Phase D      : POST the zip to /api/render. Server runs ffmpeg
                    image2 against the JPEG sequence → CFR 30 mp4 →
                    App Store normalize → returns mp4 + validation.

   Fallback: webm sources, browsers without WebCodecs, or codecs the
   browser refuses (e.g. HEVC on Chrome Linux) fall back to the original
   seek-and-await path. The user-visible export quality is identical;
   only the wall-clock differs.

   Public API:
     window.renderTimelineOnServer({ env, segs, audioKey, preset, onProgress })
       → { blob, validation, frameCount }
   =========================================================================== */

(function () {
  const FPS = 30;
  const JPEG_QUALITY = 0.95;
  const FRAME_PAD = 6;

  function pad(n, w) { const s = String(n); return s.length >= w ? s : '0'.repeat(w - s.length) + s; }

  /* Fallback per-frame seeker for scenes whose video we couldn't pre-
     decode. Keeps the same await-seeked + rVFC contract the previous
     implementation relied on. */
  async function awaitSeekFor(seg) {
    const want = seg.want;
    if (Math.abs(seg.el.currentTime - want) < 1 / 60) return;
    await new Promise(res => {
      let done = false;
      const fin = () => { if (done) return; done = true; seg.el.removeEventListener('seeked', fin); res(); };
      seg.el.addEventListener('seeked', fin);
      try { seg.el.currentTime = want; } catch (e) { fin(); }
      setTimeout(fin, 400);
    });
    if (seg.el.requestVideoFrameCallback) {
      await new Promise(res => {
        let done = false;
        const f = () => { if (done) return; done = true; res(); };
        seg.el.requestVideoFrameCallback(f);
        setTimeout(f, 80);
      });
    } else {
      await new Promise(r => requestAnimationFrame(() => r()));
    }
  }

  function canvasToJpeg(canvas) {
    return new Promise((res, rej) => {
      canvas.toBlob(b => b ? res(b) : rej(new Error('canvas.toBlob failed')), 'image/jpeg', JPEG_QUALITY);
    });
  }

  async function fetchAudioBlob(audioKey) {
    if (!audioKey || !window.vidMediaGet) return null;
    try { return await window.vidMediaGet(audioKey); }
    catch (e) { console.warn('audio fetch failed', e); return null; }
  }

  function audioExtFromBlob(blob) {
    const t = (blob && blob.type) || '';
    if (t.includes('mpeg')) return 'mp3';
    if (t.includes('aac') || t.includes('mp4')) return 'm4a';
    if (t.includes('wav')) return 'wav';
    if (t.includes('webm')) return 'webm';
    if (t.includes('ogg') || t.includes('opus')) return 'ogg';
    return 'bin';
  }

  /* For each env, walk its scenes and build a map sceneIndex → frame
     source for the video media we successfully pre-decoded. Scenes we
     couldn't pre-decode (webm, unsupported codec, fetch fail) remain in
     the slow fallback path. */
  async function buildEnvFrameSources(env, onProgress) {
    const sources = new Map();
    const fallbackSeeks = [];
    if (!window.createFrameSource || !window.supportsVideoDecoder || !window.supportsVideoDecoder()) {
      for (let i = 0; i < (env.sceneMedia || []).length; i++) {
        const m = env.sceneMedia[i];
        if (m && m.kind === 'video') fallbackSeeks.push({ sceneIndex: i, el: m.el, tl: env.timeline[i] });
      }
      return { sources, fallbackSeeks };
    }

    for (let i = 0; i < env.sceneMedia.length; i++) {
      const m = env.sceneMedia[i];
      if (!m || m.kind !== 'video') continue;
      const blobUrl = m.el && m.el.src;
      if (!blobUrl) { fallbackSeeks.push({ sceneIndex: i, el: m.el, tl: env.timeline[i] }); continue; }
      try {
        const resp = await fetch(blobUrl);
        const blob = await resp.blob();
        /* Only try WebCodecs for mp4-ish blobs; webm + others fall back. */
        const t = (blob.type || '').toLowerCase();
        if (!t.includes('mp4') && !t.includes('quicktime') && t !== '') {
          fallbackSeeks.push({ sceneIndex: i, el: m.el, tl: env.timeline[i] });
          continue;
        }
        if (onProgress) onProgress({ phase: 'decoding', sceneIndex: i });
        const src = await window.createFrameSource(blob);
        sources.set(i, { src, tl: env.timeline[i] });
      } catch (e) {
        console.warn('frame source unavailable for scene', i, e.message);
        fallbackSeeks.push({ sceneIndex: i, el: m.el, tl: env.timeline[i] });
      }
    }
    return { sources, fallbackSeeks };
  }

  /* env is the same shape recordTimeline already accepts; this function
     also handles `segs` (combined exports). */
  async function renderTimelineOnServer({ env, segs, audioKey, preset, onProgress }) {
    if (!window.JSZip) throw new Error('JSZip not loaded');
    const dims = env ? env.dims : segs[0].env.dims;
    const D = env ? env.duration : (segs[segs.length - 1].offset + segs[segs.length - 1].env.duration);
    const total = Math.max(1, Math.round(D * FPS));
    const usePreset = preset || 'iphone-6-5-portrait';

    const canvas = document.createElement('canvas');
    canvas.width = dims.w; canvas.height = dims.h;
    const ctx = canvas.getContext('2d');

    /* Phase 0 — pre-decode every video media. For multi-env (combined)
       exports each segment has its own sceneMedia. */
    if (onProgress) onProgress({ phase: 'decoding', percent: 0 });
    const envList = env ? [{ env, offset: 0 }] : segs;
    const envSources = []; // [{ env, offset, sources: Map<sceneIdx, {src, tl}>, fallbackSeeks: [{sceneIdx,el,tl}] }]
    for (const e of envList) {
      const { sources, fallbackSeeks } = await buildEnvFrameSources(e.env, onProgress);
      envSources.push({ env: e.env, offset: e.offset, sources, fallbackSeeks });
    }
    const decodedCount = envSources.reduce((n, e) => n + e.sources.size, 0);
    const fallbackCount = envSources.reduce((n, e) => n + e.fallbackSeeks.length, 0);
    if (onProgress) onProgress({ phase: 'decode-done', decoded: decodedCount, fallback: fallbackCount });

    /* For fallback scenes we still need to warm the decoder so the
       first <video> seek isn't black. */
    if (fallbackCount > 0) {
      if (env && window.vidPrimeMedia) await window.vidPrimeMedia(env);
      else if (segs && window.vidPrimeMedia) { for (const sg of segs) await window.vidPrimeMedia(sg.env); }
    }

    const drawAt = (T) => {
      if (env) {
        env.drawLocal(ctx, Math.min(env.duration - 0.001, Math.max(0, T)));
      } else {
        let seg = segs[0];
        for (const sg of segs) { if (T >= sg.offset && T < sg.offset + sg.env.duration) { seg = sg; break; } }
        if (T >= segs[segs.length - 1].offset + segs[segs.length - 1].env.duration) seg = segs[segs.length - 1];
        seg.env.drawLocal(ctx, Math.min(seg.env.duration - 0.001, Math.max(0, T - seg.offset)));
      }
    };

    const zip = new window.JSZip();
    const framesFolder = zip.folder('frames');

    /* ── Phase A ────────────────────────────────────────────────────── */
    const originalEls = []; // [{ env, sceneIdx, el }] — to restore after
    try {
      for (let f = 0; f < total; f++) {
        const T = f / FPS;

        /* Substitute m.el with the VideoFrame whose timestamp matches
           T - tl.start in the source video. We mutate sceneMedia[idx]
           and restore at the end. */
        for (const e of envSources) {
          const segT = T - e.offset;
          for (const [sceneIdx, { src, tl }] of e.sources) {
            if (segT < tl.start || segT >= tl.start + tl.dur) continue;
            const srcT = segT - tl.start;
            const vf = src.getFrameAt(srcT);
            if (!vf) continue;
            const m = e.env.sceneMedia[sceneIdx];
            if (!m) continue;
            if (!m._origEl) m._origEl = m.el;
            if (!originalEls.some(r => r.env === e.env && r.sceneIdx === sceneIdx)) {
              originalEls.push({ env: e.env, sceneIdx });
            }
            m.el = vf;
          }
          /* Slow path for the ones we couldn't pre-decode. */
          for (const fb of e.fallbackSeeks) {
            const segTLocal = T - e.offset;
            if (segTLocal < fb.tl.start || segTLocal >= fb.tl.start + fb.tl.dur) continue;
            const srcT = segTLocal - fb.tl.start;
            await awaitSeekFor({ el: fb.el, want: Math.min(srcT, fb.el.duration) });
          }
        }

        drawAt(T);
        const jpeg = await canvasToJpeg(canvas);
        framesFolder.file(`frame-${pad(f, FRAME_PAD)}.jpg`, jpeg);
        if (f % 6 === 0 && onProgress) {
          onProgress({ phase: 'rendering', frame: f, total, percent: f / total });
          await new Promise(r => setTimeout(r, 0));
        }
      }
    } finally {
      /* Restore original video elements + close VideoFrames. */
      for (const { env: e, sceneIdx } of originalEls) {
        const m = e.sceneMedia[sceneIdx];
        if (m && m._origEl) { m.el = m._origEl; delete m._origEl; }
      }
      for (const e of envSources) {
        for (const { src } of e.sources.values()) { try { src.close(); } catch (_) {} }
      }
    }

    /* ── Phase B — audio ──────────────────────────────────────────── */
    let audioBlob = null, audioExt = null, hasAudio = false;
    if (audioKey) {
      audioBlob = await fetchAudioBlob(audioKey);
      if (audioBlob && audioBlob.size > 0) {
        audioExt = audioExtFromBlob(audioBlob);
        zip.file(`audio.${audioExt}`, audioBlob);
        hasAudio = true;
      }
    }

    /* ── Phase C — manifest + finalize ─────────────────────────────── */
    const manifest = {
      fps: FPS,
      totalFrames: total,
      durationSeconds: D,
      width: dims.w,
      height: dims.h,
      hasAudio,
      audioExt: audioExt || undefined,
      preset: usePreset,
      decodedSources: decodedCount,
      fallbackSources: fallbackCount,
    };
    zip.file('manifest.json', JSON.stringify(manifest, null, 2));

    if (onProgress) onProgress({ phase: 'zipping', frame: total, total });
    const zipBlob = await zip.generateAsync({ type: 'blob', compression: 'STORE' });

    if (onProgress) onProgress({ phase: 'uploading', size: zipBlob.size, total });

    /* ── Phase D — upload ──────────────────────────────────────────── */
    const res = await fetch(`/api/render?preset=${encodeURIComponent(usePreset)}`, {
      method: 'POST',
      headers: { 'content-type': 'application/zip' },
      body: zipBlob,
    });
    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new Error(`server render failed (${res.status}): ${text.slice(0, 200)}`);
    }
    let validation = null;
    const vHeader = res.headers.get('x-validation');
    try { validation = vHeader ? JSON.parse(vHeader) : null; }
    catch (e) { validation = null; }
    const frameCount = Number(res.headers.get('x-frame-count') || total);
    const blob = await res.blob();
    if (onProgress) onProgress({ phase: 'done', total, frameCount });
    return { blob, validation, frameCount };
  }

  Object.assign(window, { renderTimelineOnServer });
})();
