const preparedVideoFrames = new WeakMap();

function finiteDuration(video) {
  const duration = Number(video?.duration);
  return Number.isFinite(duration) && duration > 0 ? duration : null;
}

export function normalizeExportVideoTime(video, requestedTime) {
  const time = Math.max(0, Number(requestedTime) || 0);
  const duration = finiteDuration(video);
  if (duration === null) return time;
  // Seeking exactly to duration can select the empty frame after the stream.
  return Math.min(time, Math.max(0, duration - 0.000001));
}

function waitForMetadata(video, timeoutMs) {
  if ((video?.readyState ?? 0) >= 1) return Promise.resolve();

  return new Promise((resolve, reject) => {
    let timeout = null;
    const cleanup = () => {
      video.removeEventListener('loadedmetadata', onMetadata);
      video.removeEventListener('error', onError);
      if (timeout !== null) clearTimeout(timeout);
    };
    const onMetadata = () => {
      cleanup();
      resolve();
    };
    const onError = () => {
      cleanup();
      reject(new Error('Video metadata could not be loaded for export'));
    };

    video.addEventListener('loadedmetadata', onMetadata, { once: true });
    video.addEventListener('error', onError, { once: true });
    timeout = setTimeout(() => {
      cleanup();
      reject(new Error('Timed out while loading video metadata for export'));
    }, timeoutMs);
  });
}

/**
 * Seek a paused HTMLVideoElement and wait until the requested decoded frame is
 * safe to draw to a canvas. requestVideoFrameCallback is the strongest signal
 * available in Chrome; seeked + HAVE_CURRENT_DATA is the compatibility path.
 */
export async function prepareVideoFrameForExport(
  video,
  requestedTime,
  {
    timeoutMs = 10000,
    tolerance = 0.0005,
    presentationGraceMs = 50,
  } = {},
) {
  if (!video || typeof video.addEventListener !== 'function') {
    throw new TypeError('A video element is required for export frame preparation');
  }

  await waitForMetadata(video, timeoutMs);
  const targetTime = normalizeExportVideoTime(video, requestedTime);
  const currentTime = Number(video.currentTime) || 0;
  if (
    Math.abs(currentTime - targetTime) <= tolerance
    && !video.seeking
    && (video.readyState ?? 0) >= 2
  ) {
    preparedVideoFrames.set(video, { time: targetTime });
    return { time: targetTime, seeked: false };
  }

  if (typeof video.pause === 'function' && !video.paused) video.pause();

  await new Promise((resolve, reject) => {
    let timeout = null;
    let presentationFallback = null;
    let frameCallbackId = null;
    let seekComplete = false;
    let framePresented = typeof video.requestVideoFrameCallback !== 'function';
    let settled = false;

    const cleanup = () => {
      video.removeEventListener('seeked', onSeeked);
      video.removeEventListener('loadeddata', onDataReady);
      video.removeEventListener('canplay', onDataReady);
      video.removeEventListener('error', onError);
      if (
        frameCallbackId !== null
        && !framePresented
        && typeof video.cancelVideoFrameCallback === 'function'
      ) {
        video.cancelVideoFrameCallback(frameCallbackId);
      }
      if (presentationFallback !== null) clearTimeout(presentationFallback);
      if (timeout !== null) clearTimeout(timeout);
    };
    const finishIfReady = () => {
      if (
        settled
        || !seekComplete
        || (video.readyState ?? 0) < 2
      ) return;
      if (!framePresented) {
        // A 30/60 fps export can seek twice into the same decoded frame of a
        // 24 fps source. Chrome then completes the seek but may omit
        // requestVideoFrameCallback because no distinct frame was presented.
        // HAVE_CURRENT_DATA after seeked is canvas-safe, so use it after a
        // short opportunity for the stronger presentation signal.
        if (presentationFallback === null) {
          presentationFallback = setTimeout(() => {
            presentationFallback = null;
            framePresented = true;
            finishIfReady();
          }, Math.max(0, presentationGraceMs));
        }
        return;
      }
      settled = true;
      cleanup();
      resolve();
    };
    const onSeeked = () => {
      seekComplete = true;
      finishIfReady();
    };
    const onDataReady = () => {
      finishIfReady();
    };
    const onError = () => {
      if (settled) return;
      settled = true;
      cleanup();
      reject(new Error(`Video frame at ${targetTime.toFixed(3)}s could not be decoded`));
    };

    video.addEventListener('seeked', onSeeked);
    video.addEventListener('loadeddata', onDataReady);
    video.addEventListener('canplay', onDataReady);
    video.addEventListener('error', onError);

    timeout = setTimeout(() => {
      if (settled) return;
      settled = true;
      cleanup();
      reject(new Error(
        `Timed out decoding video frame at ${targetTime.toFixed(3)}s for export`,
      ));
    }, timeoutMs);

    try {
      video.currentTime = targetTime;
      // Register after initiating the asynchronous seek so Chrome cannot
      // satisfy the callback with the previously presented frame.
      if (typeof video.requestVideoFrameCallback === 'function') {
        frameCallbackId = video.requestVideoFrameCallback(() => {
          framePresented = true;
          finishIfReady();
        });
      }
    } catch (error) {
      settled = true;
      cleanup();
      reject(error);
    }
  });

  preparedVideoFrames.set(video, { time: targetTime });
  return { time: targetTime, seeked: true };
}

export function clearPreparedVideoFrame(video) {
  if (video) preparedVideoFrames.delete(video);
}
