const fs = require("fs");
const path = require("path");
const sharp = require("sharp");

const { loadMeals, repoRoot } = require("./lib/meals");

const fullsDir = path.join(repoRoot, "images", "fulls");
const thumbsDir = path.join(repoRoot, "images", "thumbs");
const manifestPath = path.join(thumbsDir, ".thumbs-manifest.json");

const THUMB_VERSION = 1;
const THUMB_WIDTH = 240;
const THUMB_HEIGHT = 320;
const JPEG_QUALITY = 82;

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

function parseArgs(argv) {
  return {
    force: argv.includes("--force"),
  };
}

function getManifestKey(mealId) {
  return `meal:${mealId}`;
}

function getThumbnailPaths(meal) {
  return {
    fullPath: path.join(fullsDir, `${meal.id}.jpg`),
    thumbPath: path.join(thumbsDir, `${meal.id}.jpg`),
  };
}

function getOrientedDimensions(metadata) {
  if (!metadata.width || !metadata.height) {
    throw new Error("Could not determine image dimensions");
  }

  if ([5, 6, 7, 8].includes(metadata.orientation)) {
    return {
      width: metadata.height,
      height: metadata.width,
    };
  }

  return {
    width: metadata.width,
    height: metadata.height,
  };
}

function getCropArea(width, height, focus) {
  const targetRatio = THUMB_WIDTH / THUMB_HEIGHT;
  const sourceRatio = width / height;

  let cropWidth = width;
  let cropHeight = height;

  if (sourceRatio > targetRatio) {
    cropWidth = Math.round(height * targetRatio);
  } else if (sourceRatio < targetRatio) {
    cropHeight = Math.round(width / targetRatio);
  }

  const centerX = (focus?.x ?? 0.5) * width;
  const centerY = (focus?.y ?? 0.5) * height;
  const left = Math.round(centerX - cropWidth / 2);
  const top = Math.round(centerY - cropHeight / 2);

  return {
    left: clamp(left, 0, width - cropWidth),
    top: clamp(top, 0, height - cropHeight),
    width: cropWidth,
    height: cropHeight,
  };
}

function loadManifest() {
  if (!fs.existsSync(manifestPath)) {
    return {};
  }

  return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
}

function getThumbSignature(meal, sourceStats) {
  return {
    version: THUMB_VERSION,
    width: THUMB_WIDTH,
    height: THUMB_HEIGHT,
    quality: JPEG_QUALITY,
    mtimeMs: sourceStats.mtimeMs,
    size: sourceStats.size,
    focus: meal.thumbnail?.focus ?? null,
  };
}

function manifestEntryMatches(currentEntry, nextEntry) {
  return JSON.stringify(currentEntry) === JSON.stringify(nextEntry);
}

async function removeStaleThumbnails(expectedIds) {
  if (!fs.existsSync(thumbsDir)) {
    return 0;
  }

  const entries = await fs.promises.readdir(thumbsDir, { withFileTypes: true });
  let removed = 0;

  for (const entry of entries) {
    if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== ".jpg") {
      continue;
    }

    const id = path.basename(entry.name, path.extname(entry.name));

    if (expectedIds.has(id)) {
      continue;
    }

    await fs.promises.unlink(path.join(thumbsDir, entry.name));
    removed += 1;
  }

  return removed;
}

async function generateThumbnail(meal, manifest, options) {
  const { fullPath, thumbPath } = getThumbnailPaths(meal);

  if (!fs.existsSync(fullPath)) {
    throw new Error(`Missing full-size image for meal ${meal.id}: ${fullPath}`);
  }

  const sourceStats = await fs.promises.stat(fullPath);
  const signature = getThumbSignature(meal, sourceStats);
  const thumbExists = fs.existsSync(thumbPath);

  if (
    !options.force &&
    thumbExists &&
    manifestEntryMatches(manifest[getManifestKey(meal.id)], signature)
  ) {
    return {
      mealId: meal.id,
      changed: false,
      manifestEntry: signature,
    };
  }

  const image = sharp(fullPath);
  const metadata = await image.metadata();
  const { width, height } = getOrientedDimensions(metadata);

  const cropArea = getCropArea(width, height, meal.thumbnail?.focus);

  await image
    .rotate()
    .extract(cropArea)
    .resize(THUMB_WIDTH, THUMB_HEIGHT)
    .jpeg({ quality: JPEG_QUALITY, mozjpeg: true })
    .toFile(thumbPath);

  return {
    mealId: meal.id,
    changed: true,
    manifestEntry: signature,
  };
}

function writeManifest(manifest) {
  const sortedEntries = Object.entries(manifest).sort(([left], [right]) =>
    left.localeCompare(right, undefined, { numeric: true })
  );

  const orderedManifest = Object.fromEntries(sortedEntries);
  fs.writeFileSync(manifestPath, `${JSON.stringify(orderedManifest, null, 2)}\n`);
}

async function main() {
  const options = parseArgs(process.argv.slice(2));
  const summary = await buildThumbnails(options);

  console.log(
    `Thumbnail build complete: ${summary.generated} generated, ${summary.skipped} skipped, ${summary.removed} removed`
  );
}

async function buildThumbnails(options = {}) {
  const settings = {
    force: false,
    ...options,
  };
  const meals = loadMeals();
  const manifest = loadManifest();
  const nextManifest = {};
  const expectedIds = new Set(meals.map((meal) => meal.id));

  await fs.promises.mkdir(thumbsDir, { recursive: true });

  const removed = await removeStaleThumbnails(expectedIds);
  let generated = 0;
  let skipped = 0;

  for (const meal of meals) {
    const result = await generateThumbnail(meal, manifest, settings);
    nextManifest[getManifestKey(meal.id)] = result.manifestEntry;

    if (result.changed) {
      generated += 1;
    } else {
      skipped += 1;
    }
  }

  writeManifest(nextManifest);

  return {
    generated,
    removed,
    skipped,
    total: meals.length,
  };
}

if (require.main === module) {
  main().catch((error) => {
    console.error(error.message);
    process.exitCode = 1;
  });
}

module.exports = {
  buildThumbnails,
};
