import { base64UrlDecodeStr, bytesToHex } from '@/lib/encoding.js';
import { captureCaught } from '@/server/observability/capture.server';

/**
 * R2 storage helpers for Multideal.
 *
 * Design note on presigned uploads:
 * ─────────────────────────────────
 * Cloudflare R2 supports S3-compatible presigned URLs via the S3 API, but
 * that requires an `@aws-sdk/s3-request-presigner` dependency and exposing
 * AWS-compatible credentials. To keep the Worker lean and avoid those secrets,
 * we use a "direct-through-worker" pattern:
 *   1. Client calls POST /api/uploads/request → receives { uploadToken, r2Key }.
 *   2. Client streams the file body to POST /api/uploads/complete
 *      along with the uploadToken.
 *   3. The Worker reads the file stream, validates magic bytes, and calls
 *      putObject() here.
 * The uploadToken is a short-lived HMAC-signed opaque string stored alongside
 * a PENDING `image_uploads` row so the Worker can verify ownership before
 * writing to R2. This avoids any need for S3-presigning and keeps all
 * validation centrally enforced server-side.
 */

// ─── Constants ───────────────────────────────────────────────────────────────

/** Maximum allowed upload size: 5 MB */
export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;

// ─── Magic-byte signatures ────────────────────────────────────────────────────

const MAGIC_BYTES = {
  'image/jpeg': [[0xff, 0xd8, 0xff]],
  'image/png': [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
  'image/webp': null, // special - RIFF....WEBP
  'image/avif': null, // special - ftyp box
  'image/gif': [
    [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
    [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
  ], // GIF89a / GIF87a
} as const;

/**
 * Sniffs the first bytes of a file to confirm the true MIME type.
 * Protects against MIME-spoofing where a client declares a safe content-type
 * but uploads a different format (e.g., a PHP script with image/jpeg header).
 *
 * @param header - First 16 bytes of the file (more bytes = better coverage).
 * @returns Detected MIME type, or null if not a recognised image format.
 */
export function validateMagicBytes(
  header: Uint8Array,
): 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif' | 'image/gif' | null {
  // JPEG: FF D8 FF
  if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) {
    return 'image/jpeg';
  }

  // PNG: 89 50 4E 47 0D 0A 1A 0A
  if (
    header[0] === 0x89 &&
    header[1] === 0x50 &&
    header[2] === 0x4e &&
    header[3] === 0x47 &&
    header[4] === 0x0d &&
    header[5] === 0x0a &&
    header[6] === 0x1a &&
    header[7] === 0x0a
  ) {
    return 'image/png';
  }

  // WebP: RIFF????WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP)
  if (
    header[0] === 0x52 && // R
    header[1] === 0x49 && // I
    header[2] === 0x46 && // F
    header[3] === 0x46 && // F
    header[8] === 0x57 && // W
    header[9] === 0x45 && // E
    header[10] === 0x42 && // B
    header[11] === 0x50 // P
  ) {
    return 'image/webp';
  }

  // AVIF: ISO Base Media File Format - ftyp box at offset 4 with 'avif' or 'avis' brand
  // Bytes 4-7 = 'ftyp', bytes 8-11 = major brand ('avif' or 'avis')
  if (
    header[4] === 0x66 && // f
    header[5] === 0x74 && // t
    header[6] === 0x79 && // y
    header[7] === 0x70 // p
  ) {
    const brand =
      String.fromCharCode(header[8] ?? 0) +
      String.fromCharCode(header[9] ?? 0) +
      String.fromCharCode(header[10] ?? 0) +
      String.fromCharCode(header[11] ?? 0);
    if (brand === 'avif' || brand === 'avis') {
      return 'image/avif';
    }
  }

  // GIF: GIF89a or GIF87a
  if (
    header[0] === 0x47 && // G
    header[1] === 0x49 && // I
    header[2] === 0x46 && // F
    header[3] === 0x38 && // 8
    (header[4] === 0x39 || header[4] === 0x37) && // 9 or 7
    header[5] === 0x61 // a
  ) {
    return 'image/gif';
  }

  return null;
}

// ─── R2 operations ────────────────────────────────────────────────────────────

/**
 * Writes an object to R2.
 * @throws if the body is too large (> 5 MB).
 */
export async function putObject(
  bucket: R2Bucket,
  key: string,
  body: ReadableStream | ArrayBuffer,
  metadata?: R2HTTPMetadata,
): Promise<R2Object> {
  const result = await bucket.put(key, body, {
    httpMetadata: metadata,
  });
  if (!result) {
    throw new Error(`R2 put failed for key: ${key}`);
  }
  return result;
}

/**
 * Retrieves an object from R2.
 * @returns The R2 object body, or null if the key does not exist.
 */
export async function getObject(bucket: R2Bucket, key: string): Promise<R2ObjectBody | null> {
  const result = await bucket.get(key);
  return result;
}

/**
 * Deletes an object from R2.
 * Silently succeeds if the key does not exist (idempotent).
 */
export async function deleteObject(bucket: R2Bucket, key: string): Promise<void> {
  await bucket.delete(key);
}

/**
 * Options for creating a presigned-style upload token.
 * See module-level comment for the direct-through-worker pattern.
 */
export interface PresignedUploadOptions {
  /** Maximum allowed file size in bytes. Capped at MAX_UPLOAD_BYTES (5 MB). */
  maxSize: number;
  /** Expected content type. Validated against magic bytes on completion. */
  contentType: string;
  /** How long the token is valid for, in seconds. */
  expiresIn: number;
}

/**
 * Generates a short-lived upload token that a client sends to
 * `/api/uploads/complete`. The token is an HMAC-SHA256 signed payload
 * containing the r2Key and expiry timestamp.
 *
 * NOTE: The actual R2 write happens through our Worker on /api/uploads/complete,
 * not directly from the browser. This keeps upload validation server-side.
 *
 * @param hmacSecret - The QR_SECRET or a dedicated upload secret from env.
 */
export async function createPresignedUploadUrl(
  key: string,
  options: PresignedUploadOptions,
  hmacSecret: string,
): Promise<{ uploadToken: string; expiresAt: string }> {
  const maxSize = Math.min(options.maxSize, MAX_UPLOAD_BYTES);
  const expiresAt = new Date(Date.now() + options.expiresIn * 1000).toISOString();

  const payload = JSON.stringify({ key, contentType: options.contentType, maxSize, expiresAt });

  const enc = new TextEncoder();
  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    enc.encode(hmacSecret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const sig = await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(payload));
  const sigHex = bytesToHex(sig);

  // Token format: base64url(payload) + "." + sigHex
  const b64 = btoa(payload).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
  const uploadToken = `${b64}.${sigHex}`;

  return { uploadToken, expiresAt };
}

/**
 * Verifies an upload token created by createPresignedUploadUrl.
 * @returns The decoded payload if valid and not expired, null otherwise.
 */
export async function verifyUploadToken(
  uploadToken: string,
  hmacSecret: string,
): Promise<{ key: string; contentType: string; maxSize: number; expiresAt: string } | null> {
  try {
    const dotIdx = uploadToken.lastIndexOf('.');
    if (dotIdx === -1) return null;

    const b64 = uploadToken.slice(0, dotIdx);
    const sigHex = uploadToken.slice(dotIdx + 1);

    const payload = base64UrlDecodeStr(b64);
    const enc = new TextEncoder();

    const cryptoKey = await crypto.subtle.importKey(
      'raw',
      enc.encode(hmacSecret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign'],
    );

    // Re-compute expected signature (must import key with 'sign' usage — on
    // strict WebCrypto runtimes like Cloudflare Workers, calling `sign` with a
    // key imported for `verify` only throws InvalidAccessError).
    const expectedSig = await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(payload));
    const expectedHex = bytesToHex(expectedSig);

    // Constant-time comparison to prevent timing attacks
    if (!timingSafeEqual(sigHex, expectedHex)) return null;

    const data = JSON.parse(payload) as {
      key: string;
      contentType: string;
      maxSize: number;
      expiresAt: string;
    };
    if (new Date(data.expiresAt) < new Date()) return null;

    return data;
  } catch (err) {
    captureCaught(err, { scope: 'server.storage.r2', severity: 'warning' });
    return null;
  }
}

/** Constant-time string comparison to prevent timing side-channels. */
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= (a.charCodeAt(i) ?? 0) ^ (b.charCodeAt(i) ?? 0);
  }
  return diff === 0;
}

// Suppress "unused" warning - consumed externally
void MAGIC_BYTES;
