/**
 * V3.9 — thumbnail generation helper.
 *
 * Browsers expose `OffscreenCanvas` (Workers + main thread) and a Blob /
 * File API capable of decoding common image formats; we lean on those to
 * synthesize a 256x256 preview without pulling in `sharp` or `node-canvas`.
 *
 * Server-side runtimes (Bun/Node) typically already have an upstream
 * thumbnail (e.g. computed by an image-processing pipeline) — they should
 * pass `{ thumbnail: { bytes, mime } }` to `shade.upload()` directly
 * instead of `{ generateThumbnail: true }`. `generateThumbnail` returns
 * `null` when no in-process generator is available, which the SDK treats
 * as "skip the thumbnail" rather than crashing.
 *
 * The format-hardening invariants (`THUMBNAIL_MAX_BYTES`,
 * `THUMBNAIL_MIME_ALLOWLIST`) are enforced *here* so we never produce
 * something the receiver would reject.
 */

import {
  THUMBNAIL_MAX_BYTES,
  isAllowedThumbnailMime,
  type ThumbnailMime,
} from '@shade/streams';

export interface GeneratedThumbnail {
  bytes: Uint8Array;
  mime: ThumbnailMime;
}

export interface ThumbnailGenerationOptions {
  /** Output longest-edge in pixels (default 256). */
  maxEdge?: number;
  /** Preferred output MIME (default `image/webp`, falls back to `image/jpeg`). */
  preferMime?: ThumbnailMime;
  /** JPEG/WebP quality in [0, 1]; default 0.78. */
  quality?: number;
}

interface BlobLike {
  size: number;
  readonly type: string;
  arrayBuffer(): Promise<ArrayBuffer>;
}

interface OffscreenCanvasCtor {
  new (width: number, height: number): OffscreenCanvasLike;
}

interface OffscreenCanvasLike {
  getContext(kind: '2d'): {
    drawImage(image: unknown, dx: number, dy: number, dw: number, dh: number): void;
  } | null;
  convertToBlob(opts?: { type?: string; quality?: number }): Promise<BlobLike>;
}

interface CreateImageBitmapFn {
  (source: BlobLike): Promise<{ width: number; height: number; close(): void }>;
}

function getOffscreenCanvasCtor(): OffscreenCanvasCtor | null {
  const g = globalThis as unknown as { OffscreenCanvas?: OffscreenCanvasCtor };
  return g.OffscreenCanvas ?? null;
}

function getCreateImageBitmap(): CreateImageBitmapFn | null {
  // `globalThis.createImageBitmap` (when DOM lib is loaded) has a wider
  // signature than our minimal `CreateImageBitmapFn`. Cast through
  // `unknown` so consumer tsconfigs that include "DOM" don't reject the
  // narrower local type as "insufficiently overlapping".
  const g = globalThis as unknown as { createImageBitmap?: CreateImageBitmapFn };
  return g.createImageBitmap ?? null;
}

function isImageBlob(input: unknown): input is BlobLike {
  if (typeof input !== 'object' || input === null) return false;
  const b = input as BlobLike;
  return (
    typeof b.size === 'number' &&
    typeof b.type === 'string' &&
    typeof b.arrayBuffer === 'function' &&
    b.type.startsWith('image/')
  );
}

/**
 * Generate a thumbnail from an image input. Returns `null` when:
 *  - The input is not an image Blob/File.
 *  - The runtime lacks `OffscreenCanvas` + `createImageBitmap` (Node, Deno
 *    without polyfills). Server callers should pass `thumbnail` directly.
 *  - The encoded thumbnail exceeds `THUMBNAIL_MAX_BYTES` even after
 *    quality back-off (a pathological input — caller should fall back to
 *    "no thumbnail").
 */
export async function generateThumbnail(
  input: unknown,
  opts: ThumbnailGenerationOptions = {},
): Promise<GeneratedThumbnail | null> {
  if (!isImageBlob(input)) return null;
  const Ctor = getOffscreenCanvasCtor();
  const createBitmap = getCreateImageBitmap();
  if (Ctor === null || createBitmap === null) return null;

  const maxEdge = opts.maxEdge ?? 256;
  const preferMime = opts.preferMime ?? 'image/webp';
  const quality = clamp01(opts.quality ?? 0.78);

  const bitmap = await createBitmap(input);
  try {
    const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
    const w = Math.max(1, Math.round(bitmap.width * scale));
    const h = Math.max(1, Math.round(bitmap.height * scale));
    const canvas = new Ctor(w, h);
    const ctx = canvas.getContext('2d');
    if (ctx === null) return null;
    ctx.drawImage(bitmap, 0, 0, w, h);

    const order: ThumbnailMime[] = preferMime === 'image/webp'
      ? ['image/webp', 'image/jpeg']
      : preferMime === 'image/jpeg'
        ? ['image/jpeg', 'image/webp']
        : ['image/png', 'image/webp', 'image/jpeg'];

    for (const mime of order) {
      let q = quality;
      // Try up to 3 quality back-offs before giving up on this format.
      for (let attempt = 0; attempt < 3; attempt++) {
        const blob = await canvas.convertToBlob({ type: mime, quality: q });
        if (blob.size <= THUMBNAIL_MAX_BYTES) {
          if (!isAllowedThumbnailMime(mime)) continue;
          const buf = new Uint8Array(await blob.arrayBuffer());
          return { bytes: buf, mime };
        }
        q = Math.max(0.2, q * 0.7);
      }
    }
    return null;
  } finally {
    bitmap.close();
  }
}

function clamp01(n: number): number {
  if (!Number.isFinite(n)) return 0.78;
  if (n < 0) return 0;
  if (n > 1) return 1;
  return n;
}
