// Shared asset-upload helper — real presigned R2 upload through the gateway.
//
// Replaces the old setTimeout placeholder "uploads" in VideoMediaBlock /
// CompanionSlot (field-widgets.jsx) and BackgroundPicker / MediaSlot
// (editors.jsx). Returns an `asset://<id>` reference the layout draft stores.
//
// Flow (PUT-then-POST, same shape the export surface uses):
//   1. POST  /v1/courses/:courseId/assets/upload-url     → { assetId, uploadUrl }
//   2. PUT   <uploadUrl> (R2 presigned)                  → 200
//   3. POST  /v1/courses/:courseId/assets/upload-complete → { ref: 'asset://<id>' }
//
// Wrapped in an IIFE so the module-level `GATEWAY_BASE` const does NOT collide
// with the identically-named top-level const in surface-export.jsx (classic
// <script> bodies share one global lexical environment here). Only
// `window.uploadAsset` is exposed.
(function () {
  // Environment-aware gateway host (see src/env-config.js). Mirrors surface-export.jsx.
  const GATEWAY_BASE = (window.DYNAMO_ENV || {}).gatewayBase;

  async function uploadAsset(courseId, file, kind) {
    const token = await window.dynamoGetAccessToken();
    const authJson = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
    const r1 = await fetch(`${GATEWAY_BASE}/v1/courses/${courseId}/assets/upload-url`, {
      method: 'POST', headers: authJson,
      body: JSON.stringify({ filename: file.name, mime: file.type, kind, sizeBytes: file.size }),
    });
    if (!r1.ok) throw new Error(`upload-url ${r1.status}`);
    const { assetId, uploadUrl } = await r1.json();
    const put = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': file.type }, body: file });
    if (!put.ok) throw new Error(`R2 PUT ${put.status}`);
    const r2 = await fetch(`${GATEWAY_BASE}/v1/courses/${courseId}/assets/upload-complete`, {
      method: 'POST', headers: authJson,
      body: JSON.stringify({ assetId, filename: file.name, mime: file.type, kind }),
    });
    if (!r2.ok) throw new Error(`upload-complete ${r2.status}`);
    const ref = (await r2.json()).ref; // 'asset://<id>'
    // Cache the local bytes against the ref so previews render instantly,
    // without waiting on a signed-GET round trip.
    rememberLocalAsset(ref, file);
    assetNames.set(ref, file.name);
    return ref;
  }

  // ── Shrinking an oversized image before it is uploaded ─────────────────────
  // Omar, 2026-08-12: "REview the code base so that when logos are uploaded the app
  // resize them to work properly both desktop and mobile."
  //
  // What makes a logo DISPLAY correctly is the packager's `background-size: contain`
  // (`packages/scorm-packager/src/assemble.ts`) — that, and only that, can fit both a
  // square badge and a 3:1 wordmark into one box. Resizing the file cannot do it: a
  // square logo scaled to 600px is still square in a 150×75 box.
  //
  // This exists for the other half — WEIGHT. Every logo is inlined into the SCORM ZIP
  // and decoded on the learner's phone, and the largest box any logo is drawn in is
  // ~164×82 CSS px. A 3000px master is ~500× the pixels that can ever be shown.
  //
  // Three deliberate properties:
  //   · DOWNSCALE ONLY (`Math.min(1, …)`). An image already smaller than the cap is
  //     returned untouched — the same File object, not a re-encode, so a hand-tuned
  //     small PNG is never resampled or recompressed.
  //   · The MIME type is preserved. PNG stays PNG, so transparency survives; a JPEG
  //     is not converted into a much larger PNG.
  //   · It NEVER blocks the upload. If decoding or encoding fails for any reason, the
  //     original file goes up and displays correctly anyway, because the fit is the
  //     stylesheet's job. A failure here costs bytes, not correctness — so it must not
  //     be turned into an error the author has to deal with.
  const SHRINK_JPEG_QUALITY = 0.92;
  // Only what the gateway accepts (`services/gateway/src/routes/assets.ts`) and what a
  // canvas can round-trip without loss of meaning. No SVG — it is not accepted by the
  // API, and rasterising a vector logo would be a downgrade, not an optimisation.
  const SHRINKABLE_MIME = ['image/png', 'image/jpeg', 'image/webp'];

  /** The scale that fits `w`×`h` inside `maxEdge`, never enlarging. */
  function fitScale(w, h, maxEdge) {
    if (!(w > 0) || !(h > 0) || !(maxEdge > 0)) return 1;
    return Math.min(1, maxEdge / Math.max(w, h));
  }

  function decodeImageFile(file) {
    return new Promise((resolve, reject) => {
      const url = URL.createObjectURL(file);
      const img = new Image();
      img.onload = () => resolve({ img, revoke: () => URL.revokeObjectURL(url) });
      img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('could not decode')); };
      img.src = url;
    });
  }

  /**
   * Return `file` scaled so its longest edge is at most `maxEdge`, or `file` itself
   * when it is already small enough or anything goes wrong.
   */
  async function shrinkImageFile(file, maxEdge) {
    if (!file || !maxEdge || !SHRINKABLE_MIME.includes(file.type)) return file;
    let handle = null;
    try {
      handle = await decodeImageFile(file);
      const { img } = handle;
      // `naturalWidth`, not `width`: a decode failure leaves `complete === true` with
      // zero natural size, and `width` would report the layout width of a detached
      // element (`feedback_complete_is_not_loaded`).
      const w = img.naturalWidth, h = img.naturalHeight;
      const scale = fitScale(w, h, maxEdge);
      if (scale >= 1) return file;                       // already small enough
      const canvas = document.createElement('canvas');
      canvas.width = Math.max(1, Math.round(w * scale));
      canvas.height = Math.max(1, Math.round(h * scale));
      const ctx = canvas.getContext('2d');
      if (!ctx) return file;
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      const blob = await new Promise((resolve) => {
        try {
          canvas.toBlob((b) => resolve(b), file.type,
            file.type === 'image/png' ? undefined : SHRINK_JPEG_QUALITY);
        } catch { resolve(null); }
      });
      if (!blob) return file;
      return new File([blob], file.name, { type: file.type, lastModified: file.lastModified });
    } catch {
      return file;
    } finally {
      if (handle) handle.revoke();
    }
  }

  // ── Capturing a still from a video ─────────────────────────────────────────
  // "Capture frame" turns the frame the author is looking at into a real
  // uploaded image for a poster field. It is the SECOND door onto
  // `videoThumbUrl` — "Choose file" stays, because an author often wants a
  // designed still (titled, colour-graded) rather than a frame of the footage.
  // Both doors end in the same `uploadAsset` call and write the same
  // `asset://<id>`, so nothing downstream can tell them apart.
  //
  // ── Why this NEVER draws the on-screen player ──────────────────────────────
  // The obvious implementation — `drawImage(thePreviewElement)` — is broken in
  // two independent ways, both measured in a real headless Chrome rather than
  // reasoned about:
  //
  // 1. IT SILENTLY PRODUCES A BLACK IMAGE. The preview sets `preload="metadata"`
  //    (field-widgets.jsx). At that setting Chrome reports `readyState === 4`
  //    while `drawImage` is still a NO-OP, because no frame has been presented
  //    yet. Nothing throws: the canvas is untainted, `toBlob` succeeds, and the
  //    author gets a 4 KB all-black poster that exports into the SCORM ZIP
  //    looking like a real one. A `readyState` guard does NOT catch this — it
  //    was passing. Proven with a red-fill discriminator (pre-fill the canvas,
  //    draw, see whether the fill survived):
  //    `services/gateway/capture-appelement.harness.mjs`.
  //
  // 2. AFTER A RELOAD ITS PIXELS ARE UNREADABLE ANYWAY. A same-session upload
  //    resolves to a `blob:` URL (same-origin, fine), but after a reload the
  //    video comes from a presigned R2 GET — cross-origin, so the canvas TAINTS
  //    and `toBlob` throws SecurityError. Only an element that opted into CORS
  //    *before* loading (`crossOrigin="anonymous"`) can read those pixels, and
  //    the preview deliberately does not: were the bucket's CORS headers ever to
  //    lapse, that attribute would break the PREVIEW ITSELF, which is a worse
  //    failure than a capture that has to work harder.
  //
  // So every capture goes through a dedicated `preload="auto"` element that we
  // load, position and await explicitly. One path, no branch that only runs in
  // production, and it fixes both problems at once: `preload="auto"` guarantees
  // a decoded frame, and `crossOrigin="anonymous"` guarantees readable pixels.
  // R2 permits it — verified 2026-08-01 against the live bucket, 4/4 FE origins
  // get an `Access-Control-Allow-Origin` on GET
  // (`services/gateway/r2-cors-get.harness.mts`).
  //
  // The cost is re-reading the video. That is near-free for the common case (a
  // just-uploaded video is a local `blob:`) and a ranged refetch otherwise —
  // paid once, on a deliberate button press that shows a "Capturing…" state.
  const CAPTURE_MIME = 'image/jpeg';
  const CAPTURE_QUALITY = 0.92;
  // Cap the long edge. A 4K frame encodes to ~2 MB of JPEG for a poster that is
  // never displayed near that size, and every byte is inlined into the SCORM ZIP.
  const CAPTURE_MAX_EDGE = 1920;
  const CAPTURE_LOAD_TIMEOUT_MS = 15000;

  function captureFileName(seconds) {
    const t = Math.max(0, Math.floor(+seconds || 0));
    return `poster-${String(Math.floor(t / 60)).padStart(2, '0')}-${String(t % 60).padStart(2, '0')}.jpg`;
  }

  /** Draw the frame `el` is currently showing and encode it as a File. */
  async function frameToFile(el, seconds) {
    const vw = el.videoWidth;
    const vh = el.videoHeight;
    if (!vw || !vh) throw new Error("This video hasn't loaded far enough to read a frame.");
    const scale = Math.min(1, CAPTURE_MAX_EDGE / Math.max(vw, vh));
    const canvas = document.createElement('canvas');
    canvas.width = Math.max(1, Math.round(vw * scale));
    canvas.height = Math.max(1, Math.round(vh * scale));
    canvas.getContext('2d').drawImage(el, 0, 0, canvas.width, canvas.height);
    return await new Promise((resolve, reject) => {
      let blobbed;
      try {
        blobbed = canvas.toBlob((blob) => {
          if (!blob) { reject(new Error('The browser could not encode this frame.')); return; }
          resolve(new File([blob], captureFileName(seconds), { type: CAPTURE_MIME }));
        }, CAPTURE_MIME, CAPTURE_QUALITY);
      } catch (err) {
        // `toBlob` — not `drawImage` — is what throws on a tainted canvas.
        // Reaching here would mean the CORS element loaded but still isn't
        // origin-clean, which shouldn't be possible; say something the author
        // can act on rather than leaking "SecurityError" into the UI.
        reject((err && (err.name === 'SecurityError' || err.code === 18))
          ? new Error("The browser wouldn't let us read this video's frames. Use Choose file to pick an image instead.")
          : err);
      }
      return blobbed;
    });
  }

  /** Load `src` into a throwaway element that opted into CORS, parked at
   *  `seconds`. Always settles — on data, on error, or on a timeout. */
  function loadCorsFrameSource(src, seconds) {
    return new Promise((resolve, reject) => {
      let el = document.createElement('video');
      let timer = 0;
      const detach = () => {
        if (timer) { clearTimeout(timer); timer = 0; }
        if (el) { el.onloadeddata = null; el.onseeked = null; el.onerror = null; }
      };
      const fail = (msg) => {
        const dead = el; detach(); el = null;
        if (dead) disposeFrameSource(dead);
        reject(new Error(msg));
      };
      const done = () => { const ready = el; detach(); el = null; resolve(ready); };
      // MUST be assigned before `src`: the CORS mode is fixed when loading
      // starts, so setting it afterwards cannot un-taint an element.
      el.crossOrigin = 'anonymous';
      el.preload = 'auto';
      el.muted = true;
      el.playsInline = true;
      el.onerror = () => fail(
        "The browser wouldn't let us read this video's frames. Use Choose file to pick an image instead.");
      el.onloadeddata = () => {
        if (!el) return;
        const d = el.duration;
        const end = Number.isFinite(d) && d > 0 ? Math.max(0, d - 0.05) : seconds;
        const want = Math.min(Math.max(0, seconds), end);
        // Seeking to where we already are fires no `seeked` event, so waiting for
        // one would hang until the timeout. This is the t=0 case, i.e. the common
        // one for a video the author hasn't scrubbed.
        if (Math.abs((el.currentTime || 0) - want) < 0.01) { done(); return; }
        el.onseeked = done;
        try { el.currentTime = want; } catch { done(); }
      };
      el.src = src;
      try { el.load(); } catch { /* the timeout below covers a refusal to load */ }
      timer = setTimeout(
        () => fail('Timed out reading this video for a frame capture.'),
        CAPTURE_LOAD_TIMEOUT_MS);
    });
  }

  /** Release a throwaway capture element's decoder + buffered bytes. */
  function disposeFrameSource(el) {
    try { el.removeAttribute('src'); el.load(); } catch { /* best effort */ }
  }

  /** Capture the frame `videoEl` is parked on as an uploadable JPEG File.
   *  `videoRef` is the RAW field value (`asset://<id>`), used to sign a fresh
   *  read URL. */
  async function captureVideoFrame(videoEl, videoRef) {
    if (!videoEl) throw new Error('There is no loaded video to capture from.');
    // The on-screen element is read for two things only — WHERE the author has
    // scrubbed to, and WHAT it is playing. Its pixels are never used; see the
    // note above for why drawing them yields a black image.
    const at = Number.isFinite(videoEl.currentTime) ? videoEl.currentTime : 0;
    let src = videoEl.currentSrc || videoEl.src || '';
    if (typeof videoRef === 'string' && videoRef.startsWith('asset://')) {
      // Prefer a freshly signed URL: the one the preview has been holding may be
      // minutes from expiry, and a capture that 403s would be indistinguishable
      // from one the browser refused to let us read.
      src = (await resolveAssetUrl(videoRef)) || src;
    }
    if (!src) throw new Error('This video has no source we can read for a capture.');
    const el = await loadCorsFrameSource(src, at);
    try {
      return await frameToFile(el, at);
    } finally {
      disposeFrameSource(el);
    }
  }

  // ── Displaying uploaded media ───────────────────────────────────────────────
  // Drafts store an opaque `asset://<id>`; the R2 bucket is private, so an
  // <img>/<video> given that string loads nothing (uploaded images appeared to
  // vanish from the editor and module preview — fixed 2026-07-26).
  //
  // Two resolution paths, cheapest first:
  //   1. A blob URL captured at upload time — instant, no network, and correct
  //      for the whole session the upload happened in.
  //   2. `GET /v1/assets/:id/view-url` — a short-lived presigned GET, so media
  //      still displays after a reload or on another machine.
  // Resolved values are cached per ref. `viewUrlTtlMs` is deliberately under the
  // server's TTL so a cached URL is refetched before it can expire.
  const assetUrlCache = new Map(); // ref -> { url, at, blob }
  const VIEW_URL_TTL_MS = 10 * 60 * 1000; // server signs for 15 min

  function rememberLocalAsset(ref, file) {
    try {
      const url = URL.createObjectURL(file);
      assetUrlCache.set(ref, { url, at: Date.now(), blob: true });
    } catch { /* no object-URL support — fall back to the signed GET */ }
  }

  /** Resolve any media field value to something an <img>/<video> can load.
   *  Non-asset values (real paths, data: URLs, placeholder: sentinels) pass
   *  through unchanged so existing behaviour is untouched. */
  const viewUrlInFlight = new Map(); // ref -> Promise<url|null>
  async function resolveAssetUrl(ref) {
    if (typeof ref !== 'string' || !ref.startsWith('asset://')) return ref;
    const hit = assetUrlCache.get(ref);
    if (hit && (hit.blob || Date.now() - hit.at < VIEW_URL_TTL_MS)) return hit.url;
    // Share one request per ref: the <video> preview and the duration probe both
    // ask for the same asset on open, and each was signing its own URL.
    const inFlight = viewUrlInFlight.get(ref);
    if (inFlight) return inFlight;
    const id = ref.slice('asset://'.length);
    const p = (async () => {
      try {
        const token = await window.dynamoGetAccessToken();
        const res = await fetch(`${GATEWAY_BASE}/v1/assets/${id}/view-url`, {
          headers: { Authorization: `Bearer ${token}` },
        });
        if (!res.ok) return null;
        const { url } = await res.json();
        assetUrlCache.set(ref, { url, at: Date.now(), blob: false });
        return url;
      } catch { return null; }
    })().finally(() => { viewUrlInFlight.delete(ref); });
    viewUrlInFlight.set(ref, p);
    return p;
  }

  /** Human label for a stored media value: the real filename when we know it
   *  (captured at upload), otherwise "Uploaded file" for an asset:// ref — never
   *  a bare UUID, which is what `asset://<id>`.split('/').pop() used to render. */
  const assetNames = new Map(); // ref -> original filename
  function assetLabel(value) {
    if (typeof value !== 'string' || !value) return '';
    if (!value.startsWith('asset://')) {
      return value.replace('placeholder:', '').split('/').pop();
    }
    return assetNames.get(value) || 'Uploaded file';
  }

  /** Real duration (in seconds) of an UPLOADED video, read from the browser's
   *  own metadata parse. Returns { seconds, state } where state is:
   *    'none'    — no media at all; NO length may be assumed
   *    'unknown' — sample (`placeholder:`) media with no real file; the caller's
   *                declared sample length is the only length it has
   *    'loading' — metadata request in flight
   *    'ready'   — `seconds` is the true length
   *    'error'   — the file exists but its length can't be read
   *
   *  Why this exists: the in-video overlay timeline scaled itself off a
   *  hardcoded 272 s ("04:32") sample constant, so a real 20 s upload got a
   *  timeline more than 13× too long. Every marker sat at the wrong time and
   *  new interactions were seeded at duration/2 — 136 s into a 20 s video,
   *  past the end, where the Player never fires them (silently missing
   *  content). Reported by Omar 2026-07-27.
   */
  const videoDurationCache = new Map(); // ref -> seconds (successes only)
  const videoDurationInFlight = new Map(); // ref -> Promise<seconds|'error'>
  const PROBE_TIMEOUT_MS = 12000;
  function useVideoDuration(ref) {
    const value = typeof ref === 'string' ? ref : '';
    const isUploaded = value.startsWith('asset://');
    // 'none' vs 'unknown' matters: 'none' means there is no media at all (an
    // empty video slot), where NO length may be assumed. 'unknown' means sample
    // `placeholder:` media, whose declared sample length is the only length it
    // has. Collapsing the two let empty video tabs draw a fictional timeline.
    const kind = isUploaded ? 'asset' : value ? 'unknown' : 'none';
    const [entry, setEntry] = React.useState(
      () => (isUploaded ? videoDurationCache.get(ref) : undefined));

    React.useEffect(() => {
      if (!isUploaded) { setEntry(undefined); return undefined; }
      const cached = videoDurationCache.get(ref);
      if (typeof cached === 'number') { setEntry(cached); return undefined; }
      let cancelled = false;
      setEntry(undefined);
      // The probe element and its timeout belong to the SHARED promise, never to
      // a subscribing component: an earlier version let the first subscriber's
      // unmount detach the element, which stranded every other subscriber of the
      // same promise at 'loading' forever.
      let pending = videoDurationInFlight.get(ref);
      if (!pending) {
        pending = probeVideoDuration(ref)
          .finally(() => { videoDurationInFlight.delete(ref); });
        videoDurationInFlight.set(ref, pending);
      }
      pending.then(
        (v) => {
          // Only successes are cached. Caching 'error' permanently meant one
          // transient /view-url blip disabled "Add interaction" for the rest of
          // the session, while the UI told the author to re-upload — which could
          // not have helped, because the cache outlived every remount.
          if (typeof v === 'number') videoDurationCache.set(ref, v);
          if (!cancelled) setEntry(v);
        },
        () => { if (!cancelled) setEntry('error'); },
      );
      return () => { cancelled = true; };
    }, [ref, isUploaded]);

    if (kind === 'none') return { seconds: null, state: 'none' };
    if (kind === 'unknown') return { seconds: null, state: 'unknown' };
    if (entry === 'error') return { seconds: null, state: 'error' };
    if (typeof entry === 'number') return { seconds: entry, state: 'ready' };
    return { seconds: null, state: 'loading' };
  }

  /** Read a video's real duration once. Always settles — on metadata, on error,
   *  or on a timeout — so a hung request can never strand a subscriber. */
  function probeVideoDuration(ref) {
    return (async () => {
      const src = await resolveAssetUrl(ref);
      if (!src) return 'error';
      return await new Promise((resolve) => {
        let probe = document.createElement('video');
        let timer = 0;
        const done = (v) => {
          if (!probe) return;
          if (timer) { clearTimeout(timer); timer = 0; }
          probe.onloadedmetadata = null;
          probe.onerror = null;
          probe.removeAttribute('src');
          probe = null;
          resolve(v);
        };
        probe.preload = 'metadata';
        probe.muted = true;
        probe.onloadedmetadata = () => {
          const d = probe ? probe.duration : NaN;
          done(Number.isFinite(d) && d > 0 ? d : 'error');
        };
        probe.onerror = () => done('error');
        probe.src = src;
        // Safari can ignore preload="metadata" without an explicit load().
        try { probe.load(); } catch { /* the timeout below covers it */ }
        timer = setTimeout(() => done('error'), PROBE_TIMEOUT_MS);
      });
    })();
  }

  /** Forget a cached probe result so a retry can re-read the file. */
  function forgetVideoDuration(ref) { videoDurationCache.delete(ref); }

  /** MM:SS for a duration in seconds. FLOORS, matching both the browser's own
   *  player readout and the timeline's `fmt` — rounding here made a 19.99 s clip
   *  read "00:20" in the media strip and "00:19" in the timeline below it. */
  function formatDuration(seconds) {
    const total = Math.max(0, Math.floor(+seconds || 0));
    return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
  }

  window.uploadAsset = uploadAsset;
  window.shrinkImageFile = shrinkImageFile;
  window.captureVideoFrame = captureVideoFrame;
  window.assetLabel = assetLabel;
  window.resolveAssetUrl = resolveAssetUrl;
  window.rememberLocalAsset = rememberLocalAsset;
  window.useVideoDuration = useVideoDuration;
  window.forgetVideoDuration = forgetVideoDuration;
  window.formatDuration = formatDuration;
  // Seeded fallback (matches the export surface's UUID fallback) so uploads
  // resolve a valid course id even before app.jsx sets the live one.
  window.dynamoCourseId = window.dynamoCourseId || '7bf69e2d-51e7-4856-86bb-bb2b77b47216';
})();
