import { GIFEncoder, quantize, applyPalette } from "gifenc";

/**
 * Builds an animated GIF from event frames captured during a recording.
 * Frames are scaled to max 800px wide; every frame carries the delay of the
 * gap that followed its event (clamped), so the GIF paces like the track.
 */
export interface GifFrame {
  dataUrl: string;
  delayMs: number;
}

export async function buildGif(frames: GifFrame[], maxWidth = 800): Promise<Blob | null> {
  if (!frames.length) return null;
  const encoder = GIFEncoder();
  let size: { w: number; h: number } | null = null;

  for (const frame of frames) {
    const blob = await (await fetch(frame.dataUrl)).blob();
    const image = await createImageBitmap(blob);
    try {
      if (!size) {
        const scale = Math.min(1, maxWidth / image.width);
        size = { w: Math.max(1, Math.round(image.width * scale)), h: Math.max(1, Math.round(image.height * scale)) };
      }
      const canvas = new OffscreenCanvas(size.w, size.h);
      const context = canvas.getContext("2d");
      if (!context) throw new Error("no 2d context");
      context.drawImage(image, 0, 0, size.w, size.h);
      await encodeFrame(encoder, context, size.w, size.h, frame.delayMs);
    } finally {
      image.close();
    }
  }

  encoder.finish();
  return new Blob([encoder.bytes().buffer as ArrayBuffer], { type: "image/gif" });
}

async function encodeFrame(
  encoder: ReturnType<typeof GIFEncoder>,
  context: OffscreenCanvasRenderingContext2D,
  width: number,
  height: number,
  delayMs: number
): Promise<void> {
  const { data } = context.getImageData(0, 0, width, height);
  const palette = quantize(data, 256, { format: "rgb444" });
  const index = applyPalette(data, palette, "rgb444");
  encoder.writeFrame(index, width, height, { palette, delay: Math.round(delayMs) });
}

/** A screencast frame captured while recording (JPEG bytes + device-pixel size). */
export interface VideoFrame {
  /** client timestamp (Date.now()) of the frame's arrival */
  at: number;
  jpeg: Uint8Array;
  w: number;
  h: number;
}

export interface CursorGifOptions {
  /** output square size in px */
  size: number;
  /** CSS viewport the cursor track was recorded in */
  viewport: { w: number; h: number };
  /** recording start (Date.now()) — frame times convert to track offsets */
  startedAt: number;
  /** track points: {t (ms from start), x, y} in CSS px */
  track: { t: number; x: number; y: number }[];
  /** hard cap on GIF frames — keeps the upload under the server limit */
  maxFrames?: number;
}

/** Options shared by every cursor-following encoder (GIF and WebM). */
export interface CursorCropOptions {
  /** CSS viewport the cursor track was recorded in */
  viewport: { w: number; h: number };
  /** recording start (Date.now()) — frame times convert to track offsets */
  startedAt: number;
  /** track points: {t (ms from start), x, y} in CSS px */
  track: { t: number; x: number; y: number }[];
  /** output square size in CSS px */
  size: number;
}

/**
 * Source rectangle for a cursor-following `size`×`size` CSS-px square crop of
 * a screencast frame, centered on the cursor position (interpolated from the
 * mouse track at the frame's time). The square is clamped to stay inside the
 * viewport; when the viewport is smaller than the square the crop centers on
 * the smaller canvas. Shared by the GIF and WebM encoders.
 */
export function cursorCropRect(
  frame: VideoFrame,
  options: { viewport: { w: number; h: number }; startedAt: number; track: { t: number; x: number; y: number }[]; size: number }
): { sx: number; sy: number; s: number } {
  // device px per CSS px for this frame (fallback: frame pixels are CSS px)
  const scale = options.viewport.w > 0 ? frame.w / options.viewport.w : 1;
  if (!Number.isFinite(scale) || scale <= 0) throw new Error("bad frame scale");

  const cursor = cursorAt(options.track, frame.at - options.startedAt, options.viewport);
  // clamp the square so it stays inside the viewport
  const crop = Math.min(options.size, options.viewport.w, options.viewport.h);
  const half = crop / 2;
  const cx = Math.min(Math.max(cursor.x, half), Math.max(options.viewport.w - half, half));
  const cy = Math.min(Math.max(cursor.y, half), Math.max(options.viewport.h - half, half));

  const sx = Math.min(Math.max((cx - half) * scale, 0), Math.max(frame.w - crop * scale, 0));
  const sy = Math.min(Math.max((cy - half) * scale, 0), Math.max(frame.h - crop * scale, 0));
  const s = Math.min(crop * scale, frame.w - sx, frame.h - sy);
  return { sx, sy, s };
}

/**
 * Builds a square GIF that follows the cursor: each screencast frame is
 * cropped to a `size`×`size` CSS-px square centered on the cursor position
 * (interpolated from the mouse track at the frame's time) and paces like the
 * recording. All output frames share the same dimensions; when the viewport
 * is smaller than the square, the crop is centered on the smaller canvas.
 */
export async function buildCursorGif(frames: VideoFrame[], options: CursorGifOptions): Promise<Blob | null> {
  if (!frames.length) return null;

  // decimate long recordings instead of producing a huge GIF
  let picked = frames;
  while (picked.length > (options.maxFrames ?? 240)) picked = picked.filter((_, i) => i % 2 === 0);

  const size = options.size;
  const encoder = GIFEncoder();
  const canvas = new OffscreenCanvas(size, size);
  const context = canvas.getContext("2d");
  if (!context) throw new Error("no 2d context");

  for (let position = 0; position < picked.length; position++) {
    const frame = picked[position];
    const nextAt = position + 1 < picked.length ? picked[position + 1].at : null;
    const delayMs = nextAt != null ? Math.min(Math.max(nextAt - frame.at, 66), 2000) : 500;

    const image = await createImageBitmap(new Blob([frame.jpeg.buffer as ArrayBuffer], { type: "image/jpeg" }));
    try {
      // crop in the JPEG's true pixel space — the screencast metadata's device
      // size can be larger than the delivered (downscaled) frame
      const { sx, sy, s } = cursorCropRect({ ...frame, w: image.width, h: image.height }, options);
      context.fillStyle = "#10121c";
      context.fillRect(0, 0, size, size);
      context.drawImage(image, sx, sy, s, s, 0, 0, size, size);
      await encodeFrame(encoder, context, size, size, delayMs);
    } finally {
      image.close();
    }
  }

  encoder.finish();
  return new Blob([encoder.bytes().buffer as ArrayBuffer], { type: "image/gif" });
}

/** Last known cursor position at track offset t (ms); falls back progressively. */
export function cursorAt(
  track: { t: number; x: number; y: number }[],
  t: number,
  viewport: { w: number; h: number }
): { x: number; y: number } {
  if (!track.length) return { x: viewport.w / 2, y: viewport.h / 2 };
  let point = track[0];
  for (const candidate of track) {
    if (candidate.t > t) break;
    point = candidate;
  }
  return point;
}