/**
 * Admin media orchestration (PLANNING.md §4b, §7). Controllers stay HTTP-only.
 * The upload path is synchronous (§7): decode -> normalise -> hash -> generate
 * derivatives -> one transaction for the DB rows -> store the original -> reply
 * with the finished record including real dimensions.
 */
import { AppError } from '../../lib/AppError';
import { logger } from '../../lib/logger';
import { fromSqlUtc } from '../../lib/dates';
import { sha256Hex } from '../../lib/hash';
import { sniffImage } from '../../lib/imageSniff';
import { assetUrl } from './asset-url';
import { generateDerivatives, normalise } from './derivative.service';
import { cleanupTmp, readHead, storeOriginal } from './storage.service';
import type { UpdateImageInput } from './media.schema';
import {
  db,
  findAdminImageById,
  findCategoryBySlug,
  insertImage,
  insertVariants,
  listAdminImages,
  listCategories,
  listVariants,
  nextPosition,
  pageImageIds,
  setImageStatus,
  setPositions,
  setVisibility as repoSetVisibility,
  softDeleteImage,
  updateImageFields,
  type AdminMediaImageRow,
  type MediaVariantRow,
  type NewVariantRow,
  type Page,
} from './media.repository';

const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;

export interface AdminImageDto {
  id: string;
  page: Page;
  category: string | null;
  alt: string;
  caption: string | null;
  order: number;
  isVisible: boolean;
  status: 'ready' | 'processing' | 'failed';
  width: number;
  height: number;
  src: string;
  variants: {
    avif: { url: string; width: number }[];
    webp: { url: string; width: number }[];
  };
  createdAt: string;
  updatedAt: string;
}

export interface CategoryDto {
  id: number;
  slug: string;
  label: string;
}

// ─── reads ──────────────────────────────────────────────────────────────

export async function getCategories(): Promise<CategoryDto[]> {
  const rows = await listCategories();
  return rows.map((r) => ({ id: r.id, slug: r.slug, label: r.label }));
}

export async function listImages(filter: {
  page: Page;
  category?: string;
  visible?: boolean;
}): Promise<AdminImageDto[]> {
  let categoryId: number | undefined;
  if (filter.category) {
    const cat = await findCategoryBySlug(filter.category);
    if (!cat) return []; // unknown category -> nothing matches
    categoryId = cat.id;
  }

  const rows = await listAdminImages({ page: filter.page, categoryId, visible: filter.visible });
  return assembleMany(rows);
}

// ─── upload ─────────────────────────────────────────────────────────────

export interface UploadInput {
  page: Page;
  categorySlug?: string;
  alt: string;
  caption?: string;
  tmpPath: string;
  originalName: string;
  sizeBytes: number;
  createdBy: string;
}

export async function uploadImage(input: UploadInput): Promise<AdminImageDto> {
  try {
    if (input.sizeBytes > MAX_UPLOAD_BYTES) {
      throw new AppError(413, 'File exceeds the 25MB limit', { expose: true });
    }

    const categoryId = await resolveCategoryId(input.categorySlug);

    // Magic-byte gate before sharp decodes — never trust the Content-Type (§7).
    if (!sniffImage(await readHead(input.tmpPath, 4096))) {
      throw AppError.validation({ file: 'Not a recognised image file' });
    }

    const norm = await normalise(input.tmpPath);
    const contentHash = sha256Hex(norm.buffer);
    const basePath = `${input.page}/${contentHash}/`;

    const variants = await generateDerivatives(norm, input.page, contentHash);

    const id = await db.transaction(async (trx) => {
      const position = await nextPosition(input.page, trx);
      const imageId = await insertImage(
        {
          page: input.page,
          category_id: categoryId,
          alt: input.alt,
          caption: input.caption ?? null,
          position,
          width: norm.width,
          height: norm.height,
          content_hash: contentHash,
          base_path: basePath,
          original_name: input.originalName.slice(0, 255),
          original_bytes: input.sizeBytes,
          status: 'processing',
          created_by: input.createdBy,
        },
        trx,
      );
      await insertVariants(toVariantRows(imageId, variants), trx);
      await setImageStatus(imageId, 'ready', trx);
      return imageId;
    });

    await storeOriginal(input.tmpPath, contentHash, norm.ext);
    logger.info({ imageId: id, page: input.page, contentHash }, 'media image uploaded');

    return await getImageDto(id);
  } finally {
    await cleanupTmp(input.tmpPath);
  }
}

// ─── mutations ──────────────────────────────────────────────────────────

export async function updateImage(id: string, patch: UpdateImageInput): Promise<AdminImageDto> {
  await requireImage(id);

  const dbPatch: Parameters<typeof updateImageFields>[1] = {};
  if (patch.alt !== undefined) dbPatch.alt = patch.alt;
  if (patch.caption !== undefined) dbPatch.caption = patch.caption;
  if (patch.category !== undefined) {
    dbPatch.category_id = patch.category === null ? null : await resolveCategoryId(patch.category);
  }

  await updateImageFields(id, dbPatch);
  return getImageDto(id);
}

export async function setVisibility(id: string, isVisible: boolean): Promise<AdminImageDto> {
  await requireImage(id);
  await repoSetVisibility(id, isVisible);
  return getImageDto(id);
}

export async function reorder(page: Page, ids: string[]): Promise<void> {
  const current = await pageImageIds(page);

  const currentSet = new Set(current);
  const givenSet = new Set(ids);
  const sameSize = currentSet.size === givenSet.size;
  const sameMembers = [...givenSet].every((x) => currentSet.has(x));
  if (!sameSize || !sameMembers || ids.length !== current.length) {
    throw AppError.validation({
      ids: 'Must be the exact set of non-deleted images for this page',
    });
  }

  await db.transaction((trx) => setPositions(ids, trx));
}

export async function deleteImage(id: string): Promise<void> {
  await requireImage(id);
  // Soft delete — the derivative files stay for 30 days (scripts/reprocess-media.ts
  // --gc removes them), because the frontend bakes CMS responses into committed,
  // year-cached snapshots (PLANNING.md §6c).
  await softDeleteImage(id);
}

// ─── helpers ────────────────────────────────────────────────────────────

async function resolveCategoryId(slug: string | undefined): Promise<number | null> {
  if (!slug) return null;
  const cat = await findCategoryBySlug(slug);
  if (!cat) throw AppError.validation({ category: `Unknown category "${slug}"` });
  return cat.id;
}

async function requireImage(id: string): Promise<AdminMediaImageRow> {
  const row = await findAdminImageById(id);
  if (!row) throw AppError.notFound('Image not found');
  return row;
}

async function getImageDto(id: string): Promise<AdminImageDto> {
  const row = await requireImage(id);
  return (await assembleMany([row]))[0];
}

async function assembleMany(rows: AdminMediaImageRow[]): Promise<AdminImageDto[]> {
  if (rows.length === 0) return [];

  const [variants, categories] = await Promise.all([
    listVariants(rows.map((r) => r.id)),
    listCategories(),
  ]);
  const slugById = new Map(categories.map((c) => [c.id, c.slug]));

  const byImage = new Map<string, MediaVariantRow[]>();
  for (const v of variants) {
    const list = byImage.get(v.image_id);
    if (list) list.push(v);
    else byImage.set(v.image_id, [v]);
  }

  return rows.map((row) => {
    const vs = byImage.get(row.id) ?? [];
    const primary =
      vs.find((v) => v.format === 'jpeg' && v.is_primary) ?? vs.find((v) => v.format === 'jpeg');
    const pick = (format: 'avif' | 'webp') =>
      vs
        .filter((v) => v.format === format)
        .sort((a, b) => a.width - b.width)
        .map((v) => ({ url: assetUrl(v.path), width: v.width }));

    return {
      id: row.id,
      page: row.page,
      category: row.category_id !== null ? (slugById.get(row.category_id) ?? null) : null,
      alt: row.alt,
      caption: row.caption,
      order: row.position,
      isVisible: row.is_visible === 1,
      status: row.status,
      width: row.width,
      height: row.height,
      src: primary ? assetUrl(primary.path) : '',
      variants: { avif: pick('avif'), webp: pick('webp') },
      createdAt: fromSqlUtc(row.created_at).toISOString(),
      updatedAt: fromSqlUtc(row.updated_at).toISOString(),
    };
  });
}

function toVariantRows(
  imageId: string,
  specs: Awaited<ReturnType<typeof generateDerivatives>>,
): NewVariantRow[] {
  return specs.map((s) => ({
    image_id: imageId,
    format: s.format,
    width: s.width,
    height: s.height,
    bytes: s.bytes,
    path: s.path,
    is_primary: s.isPrimary ? 1 : 0,
  }));
}
