/**
 * User-submitted image upload orchestration.
 *
 * Flow:
 *   1. initiateUpload()  - validates, creates a PENDING image_uploads row, returns token.
 *   2. Client streams file to /api/uploads/complete.
 *   3. finalizeUpload()  - verifies magic bytes, writes to R2, marks row CLEAN,
 *                          optionally triggers Cloudflare Images variant generation.
 */

import { eq } from 'drizzle-orm';
import { bytesToHex } from '@/lib/encoding.js';
import { captureCaught } from '@/server/observability/capture.server';
import { getDb } from '@/server/db/client.js';
import { imageUploads } from '@/server/db/schema.js';
import {
  putObject,
  validateMagicBytes,
  createPresignedUploadUrl,
  verifyUploadToken,
  MAX_UPLOAD_BYTES,
} from './r2.js';
import { setVendorHeroPending } from '../db/queries/vendors.js';
import { isAdminUser } from '../db/queries/users.js';
import { PURPOSE_CONSTRAINTS, parseDimensions } from './imageDimensions.js';
import { variantR2Key } from '@/lib/imageVariants';
import { createImageUpload, markUploadScanStatus } from '@/server/db/queries/image-uploads.js';
import { insertOutboxRow } from '@/server/db/queries/outbox.js';
/** Display variant identifiers used across the upload/image pipeline. */
export type ImageVariant = 'thumb' | 'card' | 'hero' | 'og';

/**
 * Build the public R2 variant URL for a content-addressed sha256 key.
 * Pattern: /r2/variants/{sha256}/{variant}-{width}.{format}
 */
export function variantR2PublicUrl(
  sha256: string,
  variant: string,
  width: number,
  format: string,
): string {
  return `/r2/variants/${sha256}/${variant}-${width}.${format}`;
}
import { enqueueLlmJob } from '../ai/llm.js';
import { sendLlmJobToQueue } from '../queues/llm-jobs-producer.js';
import type { JobRunnerNudgeCtx } from '../queues/llm-jobs-producer.js';

// ─── Types ────────────────────────────────────────────────────────────────────

import type { UploadPurpose } from '@/lib/enums/upload-purpose';
export type { UploadPurpose };

export type UploaderType = 'user' | 'vendor';

export type ImageEntityType = 'vendor' | 'deal' | 'user' | 'page_module' | 'support_ticket';

/** Optional link to the owning entity for AI moderation context. */
export interface EntityLink {
  entityType: ImageEntityType;
  entityId: string;
}

/**
 * Purposes that bypass the IMAGE_APPROVAL queue entirely.
 * - homepage_banner: admin-only upload, pre-approved by platform.
 */
const ADMIN_PURPOSES = new Set<UploadPurpose>(['homepage_banner']);

/** Minimal env shape needed for upload operations. */
export interface UploadEnv {
  DATABASE_URL: string;
  R2_BUCKET: R2Bucket;
  /** Used as the HMAC secret for upload tokens (reuse QR_SECRET). */
  QR_SECRET: string;
  /** Public base URL for R2 objects (used to generate return URLs). */
  PUBLIC_SITE_URL: string;
  /** CF Queue for LLM jobs — required for IMAGE_APPROVAL enqueue. */
  LLM_JOBS_QUEUE: Queue<{ jobId: string }>;
  /** Gemini API key — used to gate whether AI review is available. */
  GOOGLE_API_KEY?: string;
}

export interface InitiateUploadResult {
  /** Short-lived HMAC-signed token the client sends to /api/uploads/complete. */
  uploadToken: string;
  /** R2 key where the file will be stored. */
  r2Key: string;
  /** Maximum allowed file size in bytes. */
  maxSize: number;
  /** ISO timestamp when the token expires. */
  expiresAt: string;
}

export interface FinalizeUploadResult {
  /** Content-addressed sha256 hex of the original file. Also used as imageId. */
  imageId: string;
  /** Content-addressed sha256 hex of the original file. */
  sha256: string;
  /** Variant R2 keys produced by the jsquash pipeline. */
  variants: Record<string, string>;
  /** Raw R2 key (not a baked URL) — use resolveImageUrl() or <Image src={r2Url}> to build proxy URLs at read time. */
  r2Url: string;
  /** The R2 key for this upload — store in DB; use buildVariantUrl() to build /r2/variants/ URLs at read time. */
  r2Key: string;
  /** Image variant used — 'hero' for vendor_hero/homepage_banner, 'card' otherwise. */
  variant: ImageVariant;
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

/** Derives the R2 key from uploader context and purpose. */
function buildR2Key(
  uploaderId: string,
  uploaderType: UploaderType,
  purpose: UploadPurpose,
  uploadId: string,
): string {
  const scope = uploaderType === 'vendor' ? 'vendors' : 'users';
  return `${scope}/${uploaderId}/${purpose}/${uploadId}`;
}

const PURPOSE_TO_VARIANT: Partial<Record<UploadPurpose, ImageVariant>> = {
  vendor_hero: 'hero',
  homepage_banner: 'hero',
};

/** Maps content-type to allowed purposes. */
const ALLOWED_CONTENT_TYPES = new Set([
  'image/jpeg',
  'image/png',
  'image/webp',
  'image/avif',
  'image/gif',
]);

/** Upload tokens expire in 15 minutes. */
const UPLOAD_TOKEN_TTL_SECONDS = 900;

// ─── Private helpers ──────────────────────────────────────────────────────────

/**
 * Buffers a ReadableStream and validates magic bytes.
 * Returns the assembled Uint8Array and detected MIME type.
 * Throws on oversized, empty, or unrecognised content.
 */
async function validateImageBytes(
  r2Stream: ReadableStream,
): Promise<{ fileBytes: Uint8Array; detectedMime: string }> {
  const chunks: Uint8Array[] = [];
  let totalBytes = 0;
  const reader = r2Stream.getReader();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    totalBytes += value.length;
    if (totalBytes > MAX_UPLOAD_BYTES) {
      throw new Error(`File exceeds maximum size of ${MAX_UPLOAD_BYTES} bytes (5 MB)`);
    }
    chunks.push(value);
  }

  if (totalBytes === 0) {
    throw new Error('Empty file body');
  }

  const fileBytes = new Uint8Array(totalBytes);
  let offset = 0;
  for (const chunk of chunks) {
    fileBytes.set(chunk, offset);
    offset += chunk.length;
  }

  const header = fileBytes.slice(0, 16);
  const detectedMime = validateMagicBytes(header);
  if (!detectedMime) {
    throw new Error(
      'File content does not match a recognised image format (magic byte check failed)',
    );
  }

  return { fileBytes, detectedMime };
}

/**
 * Writes assembled image bytes to R2 under the given key.
 */
async function writeToR2(
  bucket: R2Bucket,
  r2Key: string,
  fileBytes: Uint8Array,
  detectedMime: string,
): Promise<void> {
  await putObject(bucket, r2Key, fileBytes.buffer as ArrayBuffer, {
    contentType: detectedMime,
    cacheControl: 'private, no-store',
  });
}

/**
 * Marks an upload row CLEAN in the DB.
 */
async function updateUploadRecord(db: ReturnType<typeof getDb>, uploadId: string): Promise<void> {
  await markUploadScanStatus(db, uploadId, 'CLEAN');
}

/**
 * Handles vendor_hero side-effects: writes to pending slot + outbox event.
 */
async function maybeApplyVendorHeroEffects(
  db: ReturnType<typeof getDb>,
  env: UploadEnv,
  uploadId: string,
  r2Key: string,
): Promise<void> {
  const [uploadRow] = await db
    .select({ uploaderVendorId: imageUploads.uploaderVendorId })
    .from(imageUploads)
    .where(eq(imageUploads.id, uploadId))
    .limit(1);
  const vendorId = uploadRow?.uploaderVendorId;
  if (!vendorId) return;

  const sha = r2Key.replace(/^originals\//, '').replace(/\.[^.]+$/, '');
  const displayUrl = `/r2/${r2Key}`;
  const moderationR2Key = variantR2Key(sha, {
    variant: 'og',
    width: 1200,
    format: 'webp',
    height: 630,
  });
  const moderationUrl = `${env.PUBLIC_SITE_URL}/r2/${moderationR2Key}`;
  await setVendorHeroPending(db, vendorId, displayUrl, r2Key);
  await insertOutboxRow(db, {
    aggregateType: 'vendor',
    aggregateId: vendorId,
    eventType: 'vendor.hero.review',
    payload: { vendorId, r2Key, imageUrl: moderationUrl },
  });
}

/**
 * Enqueues an IMAGE_APPROVAL LLM job unless the upload is exempt.
 * Exempt cases: admin-only purposes, deal_image, vendor_hero, or no API key.
 * Failures are non-fatal — image stays PENDING; queue delivery or 30-min cron backstop.
 */
async function maybeEnqueueModeration(
  db: ReturnType<typeof getDb>,
  env: UploadEnv,
  row: { uploaderUserId: string | null },
  uploadId: string,
  r2Key: string,
  purpose: UploadPurpose | undefined,
  ctx?: JobRunnerNudgeCtx,
): Promise<void> {
  const skipAiReview =
    !purpose ||
    ADMIN_PURPOSES.has(purpose) ||
    purpose === 'deal_image' ||
    purpose === 'vendor_hero' ||
    !env.GOOGLE_API_KEY;

  if (skipAiReview || !env.LLM_JOBS_QUEUE) return;

  try {
    const uploaderUserId = row.uploaderUserId ?? null;
    let bypassForAdmin = false;
    if (uploaderUserId) {
      bypassForAdmin = await isAdminUser(db, uploaderUserId);
    }

    if (!bypassForAdmin) {
      const jobId = await enqueueLlmJob(db, {
        jobType: 'IMAGE_APPROVAL',
        targetType: 'IMAGE',
        targetId: uploadId,
        inputPayload: { uploadId, r2Key, purpose },
      });
      await sendLlmJobToQueue(env, jobId, ctx);
    }
  } catch (err) {
    // Non-fatal: fail open; queue delivery or 30-min cron backstop picks up PENDING
    captureCaught(err, {
      scope: 'server.storage.uploads.ai-enqueue',
      severity: 'warning',
      extra: { uploadId, purpose },
    });
  }
}

// ─── Public API ───────────────────────────────────────────────────────────────

/**
 * Initiates an upload by:
 *   1. Validating content-type and file size.
 *   2. Creating a PENDING `image_uploads` row.
 *   3. Returning a short-lived token the client sends to /api/uploads/complete.
 *
 * @param env           - Worker env bindings.
 * @param uploaderId    - UUID of the user or vendor initiating the upload.
 * @param uploaderType  - 'user' | 'vendor'.
 * @param contentType   - Declared MIME type (validated against magic bytes later).
 * @param sizeBytes     - Declared file size (enforced ≤ 5 MB).
 * @param purpose       - Upload context (deals, logos, gallery, etc.).
 * @param entityLink    - Optional owning entity for AI moderation context.
 */
export async function initiateUpload(
  env: UploadEnv,
  uploaderId: string,
  uploaderType: UploaderType,
  contentType: string,
  sizeBytes: number,
  purpose: UploadPurpose,
  entityLink?: EntityLink,
): Promise<InitiateUploadResult> {
  // Validate content type
  if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
    throw new Error(`Unsupported content type: ${contentType}`);
  }

  // Enforce 5 MB cap
  if (sizeBytes > MAX_UPLOAD_BYTES) {
    throw new Error(`File size ${sizeBytes} exceeds maximum of ${MAX_UPLOAD_BYTES} bytes (5 MB)`);
  }
  if (sizeBytes <= 0) {
    throw new Error('File size must be greater than 0');
  }

  const db = getDb({ DATABASE_URL: env.DATABASE_URL });
  const uploadId = crypto.randomUUID();

  const r2Key = buildR2Key(uploaderId, uploaderType, purpose, uploadId);

  // Create PENDING row in image_uploads
  await createImageUpload(db, {
    id: uploadId,
    uploaderUserId: uploaderType === 'user' ? uploaderId : null,
    uploaderVendorId: uploaderType === 'vendor' ? uploaderId : null,
    r2Key,
    mime: contentType,
    sizeBytes,
    scanStatus: 'PENDING',
    approvalStatus: 'PENDING',
    purpose,
    entityType: entityLink?.entityType ?? null,
    entityId: entityLink?.entityId ?? null,
  });

  // Generate a short-lived HMAC token embedding the uploadId and r2Key
  const { uploadToken, expiresAt } = await createPresignedUploadUrl(
    r2Key,
    {
      maxSize: sizeBytes,
      contentType,
      expiresIn: UPLOAD_TOKEN_TTL_SECONDS,
    },
    env.QR_SECRET,
  );

  // Embed uploadId in the token key so finalizeUpload can look up the DB row.
  // We prefix the r2Key with the uploadId for retrieval.
  const tokenWithId = `${uploadId}:${uploadToken}`;

  return {
    uploadToken: tokenWithId,
    r2Key,
    maxSize: MAX_UPLOAD_BYTES,
    expiresAt,
  };
}

/**
 * Finalizes an upload by:
 *   1. Parsing and verifying the upload token.
 *   2. Reading the upload row to confirm PENDING status.
 *   3. Buffering the stream and validating magic bytes.
 *   4. Writing to R2.
 *   5. Transitioning the DB row to CLEAN (scan_status).
 *   6. Optionally triggering Cloudflare Images variant generation.
 *
 * @param env          - Worker env bindings.
 * @param uploadToken  - Token returned by initiateUpload.
 * @param r2Stream     - The file body stream from the multipart upload.
 */
export async function finalizeUpload(
  env: UploadEnv,
  uploadToken: string,
  r2Stream: ReadableStream,
  ctx?: JobRunnerNudgeCtx,
): Promise<FinalizeUploadResult> {
  // 1. Parse and verify the composite upload token (uploadId:signedToken)
  const colonIdx = uploadToken.indexOf(':');
  if (colonIdx === -1) {
    throw new Error('Invalid upload token format');
  }
  const uploadId = uploadToken.slice(0, colonIdx);
  const signedToken = uploadToken.slice(colonIdx + 1);

  const tokenData = await verifyUploadToken(signedToken, env.QR_SECRET);
  if (!tokenData) {
    throw new Error('Upload token is invalid or expired');
  }

  // 2. Load and validate the PENDING DB row
  const db = getDb({ DATABASE_URL: env.DATABASE_URL });
  const [row] = await db.select().from(imageUploads).where(eq(imageUploads.id, uploadId)).limit(1);

  if (!row) {
    throw new Error('Upload record not found');
  }
  if (row.scanStatus !== 'PENDING') {
    throw new Error(`Upload already processed (status: ${row.scanStatus})`);
  }

  // 3. Buffer stream, validate magic bytes, check dimensions
  const { fileBytes, detectedMime } = await validateImageBytes(r2Stream);

  // Derive purpose from r2Key path: {scope}/{id}/{purpose}/{uploadId}
  const r2Key = tokenData.key;
  const purpose = r2Key.split('/')[2] as UploadPurpose | undefined;

  if (purpose) {
    const constraint = PURPOSE_CONSTRAINTS[purpose];
    if (constraint) {
      const dims = parseDimensions(fileBytes);
      if (!dims) {
        throw new Error(
          `Could not read image dimensions for purpose "${purpose}". Please upload a valid JPEG, PNG, or WebP image.`,
        );
      }
      if (dims.width < constraint.minWidth || dims.height < constraint.minHeight) {
        throw new Error(
          `Image too small for "${purpose}": ${dims.width}×${dims.height}px - minimum required is ${constraint.minWidth}×${constraint.minHeight}px.`,
        );
      }
    }
  }

  const resolvedVariant: ImageVariant = (purpose && PURPOSE_TO_VARIANT[purpose]) ?? 'card';

  // 4. Write to R2
  await writeToR2(env.R2_BUCKET, r2Key, fileBytes, detectedMime);

  // 5. Mark row CLEAN
  await updateUploadRecord(db, uploadId);

  // 6. Vendor hero side-effects (pending slot + outbox review event)
  if (purpose === 'vendor_hero') {
    await maybeApplyVendorHeroEffects(db, env, uploadId, r2Key);
  }

  // 7. Optionally enqueue AI image moderation
  //    Bypass: admin purposes, deal_image, vendor_hero, no API key.
  await maybeEnqueueModeration(db, env, row, uploadId, r2Key, purpose, ctx);

  // 8. Compute sha256 content-address and return result
  const digestBuf = await crypto.subtle.digest('SHA-256', fileBytes.buffer as ArrayBuffer);
  const sha256 = bytesToHex(digestBuf);

  const sha256r2Key = `originals/${sha256}.${detectedMime.split('/')[1] ?? 'jpeg'}`;
  return {
    imageId: sha256,
    sha256,
    variants: {},
    r2Url: sha256r2Key,
    r2Key: sha256r2Key,
    variant: resolvedVariant,
  };
}
