/**
 * Thumbnail / preview generation worker.
 *
 * Consumes the `thumbnail-processing` BullMQ queue. For each photo:
 *   1. Read the original blob via the storage helper.
 *   2. Generate a 300px-cover WebP (`thumbnail_key`) and a 1200px-fit WebP
 *      (`preview_key`) with Sharp.
 *   3. Write both derivatives back to storage.
 *   4. Update macsync.photos with the derived keys + processing_status='completed'.
 *
 * Videos are not processed by Sharp; their photos.processing_status becomes
 * 'skipped' on receipt. A future ffmpeg-based job can supplant this.
 *
 * Failures bubble up to BullMQ; retries are governed by the queue default
 * (3 attempts, exponential backoff — see shared/queues/index.ts).
 */

import { tmpdir } from 'os';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { unlink } from 'fs/promises';

import sharp from 'sharp';
import exifr from 'exifr';
import type { Queue } from 'bullmq';

import { getDb, pgRun } from '@/shared/db';
import { logger } from '@/shared/logger';
import { createQueue, createWorker } from '@/shared/queues';

import { readBlob, writeBlob } from './storage';
import { enqueueFaceJob } from './face-worker';
import { enqueueClassifyJob } from './classify-worker';

export const THUMBNAIL_QUEUE_NAME = 'thumbnail-processing';

const THUMBNAIL_PX = 300;
const PREVIEW_PX = 1200;
const DERIVATIVE_QUALITY = 82;
const DERIVATIVE_CONTENT_TYPE = 'image/webp';

export interface ThumbnailJob {
  readonly photoId: string;
  readonly deviceId: string;
  readonly storageKey: string;
  readonly mimeType: string;
  readonly mediaKind: 'image' | 'video' | 'live_photo' | 'audio';
}

let queueSingleton: Queue<ThumbnailJob> | null = null;

export function getThumbnailQueue(): Queue<ThumbnailJob> {
  if (!queueSingleton) {
    queueSingleton = createQueue<ThumbnailJob>(THUMBNAIL_QUEUE_NAME);
  }
  return queueSingleton;
}

export async function enqueueThumbnailJob(job: ThumbnailJob): Promise<void> {
  const queue = getThumbnailQueue();
  // BullMQ `add` is a no-op when a job with the same id already exists in any
  // state (including failed). Drop the prior record first so a retry actually
  // executes — required for backfills targeting previously-failed photos.
  await queue.remove(job.photoId);
  await queue.add('generate', job, { jobId: job.photoId });
}

export function thumbnailKeyFor(deviceId: string, photoId: string): string {
  return `thumbnails/${deviceId}/${photoId}.webp`;
}

export function previewKeyFor(deviceId: string, photoId: string): string {
  return `previews/${deviceId}/${photoId}.webp`;
}

async function setStatus(
  photoId: string,
  status: 'processing' | 'completed' | 'failed' | 'skipped',
  fields: {
    thumbnailKey?: string;
    previewKey?: string;
    error?: string;
    exif?: Record<string, unknown> | null;
  } = {},
): Promise<void> {
  const pool = getDb();
  // Only serialize when we have an actual object; null/undefined leave the column untouched.
  const exifJson =
    fields.exif && typeof fields.exif === 'object' && Object.keys(fields.exif).length > 0
      ? JSON.stringify(fields.exif)
      : null;
  await pgRun(
    pool,
    `UPDATE macsync.photos
       SET processing_status = $1,
           thumbnail_key     = COALESCE($2, thumbnail_key),
           preview_key       = COALESCE($3, preview_key),
           processing_error  = $4,
           exif              = COALESCE($5::jsonb, exif),
           updated_at        = now()
       WHERE id = $6`,
    [status, fields.thumbnailKey ?? null, fields.previewKey ?? null, fields.error ?? null, exifJson, photoId],
  );
}

/**
 * Extract a JSON-safe EXIF subset from the original image buffer. Returns
 * `null` if the format carries no EXIF or the parser throws — we never want
 * EXIF failures to fail the whole thumbnail job.
 *
 * exifr handles JPEG, HEIC, TIFF, WebP, PNG (limited), AVIF. For HEIC we
 * pass the ORIGINAL buffer (not the sips-decoded JPEG) because the sips
 * conversion strips most EXIF.
 */
async function extractExif(buf: Buffer): Promise<Record<string, unknown> | null> {
  try {
    const parsed = (await exifr.parse(buf, {
      tiff: true,
      exif: true,
      gps: true,
      iptc: false,
      xmp: false,
      icc: false,
      // Skip giant maker-note blobs and thumbnails embedded in EXIF.
      makerNote: false,
      userComment: false,
      mergeOutput: true,
      reviveValues: true,
      translateKeys: true,
      translateValues: true,
    })) as Record<string, unknown> | undefined;
    if (!parsed || Object.keys(parsed).length === 0) return null;
    // Dates come back as Date objects; convert to ISO so JSON.stringify is stable.
    for (const k of Object.keys(parsed)) {
      const v = parsed[k];
      if (v instanceof Date) parsed[k] = v.toISOString();
      else if (v instanceof Uint8Array || Buffer.isBuffer(v)) delete parsed[k];
    }
    return parsed;
  } catch {
    return null;
  }
}

/**
 * Sharp's bundled libheif on Bun/macOS omits the libde265 HEVC plugin, so
 * iPhone HEIC files (HEVC-encoded) are not decodable through Sharp at all
 * unless we rebuild libvips against system libheif with the HEVC plugin.
 *
 * On macOS, the system `sips` tool always handles HEIC including HEVC
 * (it's the same Apple framework Photos.app uses). Route HEIC inputs
 * through sips to produce a JPEG that Sharp can then resize. JPEG/PNG/WebP
 * inputs flow straight through.
 *
 * EXCEPTION: This is not a bypass — Sharp genuinely cannot decode HEVC HEIC
 * with its prebuilt libvips. sips is the supported macOS path.
 */
async function decodeForSharp(buf: Buffer, storageKey: string): Promise<Buffer> {
  if (!isHeicKey(storageKey) && !isHeicMagic(buf)) return buf;
  return await sipsHeicToJpeg(buf);
}

function isHeicKey(key: string): boolean {
  return /\.hei[cf]$/i.test(key);
}

function isHeicMagic(buf: Buffer): boolean {
  if (buf.byteLength < 12) return false;
  if (buf.toString('ascii', 4, 8) !== 'ftyp') return false;
  const brand = buf.toString('ascii', 8, 12);
  return brand === 'heic' || brand === 'heix' || brand === 'hevc' || brand === 'mif1' || brand === 'msf1';
}

async function sipsHeicToJpeg(buf: Buffer): Promise<Buffer> {
  const id = randomUUID();
  const inPath = join(tmpdir(), `macsync-heic-${id}.heic`);
  const outPath = join(tmpdir(), `macsync-heic-${id}.jpg`);
  await Bun.write(inPath, buf);
  try {
    const proc = Bun.spawn(['sips', '-s', 'format', 'jpeg', inPath, '--out', outPath], {
      stdout: 'pipe',
      stderr: 'pipe',
    });
    const code = await proc.exited;
    if (code !== 0) {
      const stderr = await new Response(proc.stderr).text();
      throw new Error(`sips exited ${code}: ${stderr.slice(0, 200)}`);
    }
    const arr = new Uint8Array(await Bun.file(outPath).arrayBuffer());
    return Buffer.from(arr);
  } finally {
    await unlink(inPath).catch(() => {});
    await unlink(outPath).catch(() => {});
  }
}

async function processOne(job: ThumbnailJob): Promise<void> {
  if (job.mediaKind !== 'image' && job.mediaKind !== 'live_photo') {
    await setStatus(job.photoId, 'skipped', { error: `media_kind=${job.mediaKind} not supported` });
    return;
  }

  let original: Buffer;
  try {
    original = await readBlob(job.storageKey);
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    await setStatus(job.photoId, 'failed', { error: `readBlob: ${msg}` });
    throw err;
  }

  let decoded: Buffer;
  try {
    decoded = await decodeForSharp(original, job.storageKey);
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    await setStatus(job.photoId, 'failed', { error: `decode: ${msg}` });
    throw err;
  }

  let thumbBuf: Buffer;
  let previewBuf: Buffer;
  try {
    thumbBuf = await sharp(decoded)
      .rotate()
      .resize(THUMBNAIL_PX, THUMBNAIL_PX, { fit: 'cover', position: 'attention' })
      .webp({ quality: DERIVATIVE_QUALITY })
      .toBuffer();

    previewBuf = await sharp(decoded)
      .rotate()
      .resize(PREVIEW_PX, PREVIEW_PX, { fit: 'inside', withoutEnlargement: true })
      .webp({ quality: DERIVATIVE_QUALITY })
      .toBuffer();
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    await setStatus(job.photoId, 'failed', { error: `sharp: ${msg}` });
    throw err;
  }

  const thumbKey = thumbnailKeyFor(job.deviceId, job.photoId);
  const previewKey = previewKeyFor(job.deviceId, job.photoId);

  try {
    await writeBlob(thumbKey, DERIVATIVE_CONTENT_TYPE, thumbBuf);
    await writeBlob(previewKey, DERIVATIVE_CONTENT_TYPE, previewBuf);
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    await setStatus(job.photoId, 'failed', { error: `writeBlob: ${msg}` });
    throw err;
  }

  const exif = await extractExif(original);
  await setStatus(job.photoId, 'completed', { thumbnailKey: thumbKey, previewKey, exif });
  logger.debug('thumbnail-worker: completed', {
    photoId: job.photoId,
    thumbBytes: thumbBuf.byteLength,
    previewBytes: previewBuf.byteLength,
    exifKeys: exif ? Object.keys(exif).length : 0,
  });

  // Fan out to downstream Phase-3 workers. Failures here don't block the
  // thumbnail completion; the chain workers will set their own statuses.
  try {
    await enqueueFaceJob({
      photoId: job.photoId,
      deviceId: job.deviceId,
      storageKey: job.storageKey,
      mediaKind: job.mediaKind,
      mimeType: job.mimeType,
    });
  } catch (err) {
    logger.warn('thumbnail-worker: failed to enqueue face job', {
      photoId: job.photoId,
      err: err instanceof Error ? err.message : String(err),
    });
  }
  try {
    await enqueueClassifyJob({
      photoId: job.photoId,
      storageKey: job.storageKey,
      mediaKind: job.mediaKind,
    });
  } catch (err) {
    logger.warn('thumbnail-worker: failed to enqueue classify job', {
      photoId: job.photoId,
      err: err instanceof Error ? err.message : String(err),
    });
  }
}

let workerStarted = false;

export function startThumbnailWorker(): void {
  if (workerStarted) return;
  workerStarted = true;
  // Ensure queue is registered (so the worker has something to attach to) and
  // bind the processor. Worker lifecycle is managed by registerGracefulShutdown().
  getThumbnailQueue();
  createWorker<ThumbnailJob>(THUMBNAIL_QUEUE_NAME, async (job) => {
    await processOne(job.data);
  });
}
