#!/usr/bin/env -S node --experimental-strip-types

import { writeFile } from "node:fs/promises";
import { basename, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { consola } from "consola";
import sharp from "sharp";
import {
  getImagesMissingMetadata,
  getMetadataPathForImage,
  getPhotoAbsolutePath,
  getPhotoDirectories,
  PHOTOS_DIRECTORY,
} from "../src/lib/photo-albums.ts";

const PHOTOS_DIR = PHOTOS_DIRECTORY;

// ─── IPTC parser ────────────────────────────────────────────────────────────

interface IptcFields {
  title?: string;
  caption?: string;
  keywords?: string[];
  dateCreated?: string;
  timeCreated?: string;
}

function parseIptc(buf: Buffer): IptcFields {
  const fields: IptcFields = {};
  let i = 0;

  while (i < buf.length - 4) {
    if (buf[i] !== 0x1c) {
      i++;
      continue;
    }

    const record = buf[i + 1];
    const dataset = buf[i + 2];
    const len = buf.readUInt16BE(i + 3);
    const value = buf.subarray(i + 5, i + 5 + len).toString("utf8");
    i += 5 + len;

    if (record !== 2) continue;

    switch (dataset) {
      case 5:
        fields.title = value;
        break;
      case 25:
        fields.keywords ??= [];
        fields.keywords.push(value);
        break;
      case 55:
        fields.dateCreated = value;
        break;
      case 60:
        fields.timeCreated = value;
        break;
      case 120:
        fields.caption = value;
        break;
    }
  }

  return fields;
}

// ─── XMP parser ─────────────────────────────────────────────────────────────

interface XmpFields {
  title?: string;
  description?: string;
  keywords?: string[];
  lens?: string | undefined;
  createDate?: string | undefined;
}

function extractRdfLiValues(xml: string, tagName: string): string[] {
  const re = new RegExp(`<${tagName}[^>]*>[\\s\\S]*?<\\/${tagName}>`, "i");
  const match = xml.match(re);
  if (!match) return [];

  const liRe = /<rdf:li[^>]*>([^<]*)<\/rdf:li>/gi;
  const values: string[] = [];
  for (let m = liRe.exec(match[0]); m !== null; m = liRe.exec(match[0])) {
    if (m[1]?.trim()) values.push(m[1].trim());
  }
  return values;
}

function extractXmpAttr(xml: string, attr: string): string | undefined {
  const re = new RegExp(`${attr}="([^"]*)"`, "i");
  return xml.match(re)?.[1] ?? undefined;
}

function parseXmp(buf: Buffer): XmpFields {
  const xml = buf.toString("utf8");
  const fields: XmpFields = {};

  const titles = extractRdfLiValues(xml, "dc:title");
  if (titles[0]) fields.title = titles[0];

  const descriptions = extractRdfLiValues(xml, "dc:description");
  if (descriptions[0]) fields.description = descriptions[0];

  const subjects = extractRdfLiValues(xml, "dc:subject");
  if (subjects.length > 0) fields.keywords = subjects;

  fields.lens = extractXmpAttr(xml, "aux:Lens");
  fields.createDate = extractXmpAttr(xml, "xmp:CreateDate");

  return fields;
}

// ─── EXIF parser (minimal TIFF IFD0 + SubIFD) ──────────────────────────────

interface ExifFields {
  model?: string;
  lensModel?: string;
  fNumber?: number;
  focalLength?: string;
  exposureTime?: string;
  iso?: number;
  dateTimeOriginal?: string;
  gpsLatitude?: string;
  gpsLongitude?: string;
  gpsLatitudeRef?: string;
  gpsLongitudeRef?: string;
}

function parseExifBuffer(buf: Buffer): ExifFields {
  // Skip "Exif\0\0" header if present
  let offset = 0;
  if (
    buf[0] === 0x45 &&
    buf[1] === 0x78 &&
    buf[2] === 0x69 &&
    buf[3] === 0x66
  ) {
    offset = 6;
  }

  const isLE = buf[offset] === 0x49; // "II" = little endian
  const read16 = isLE
    ? (o: number) => buf.readUInt16LE(offset + o)
    : (o: number) => buf.readUInt16BE(offset + o);
  const read32 = isLE
    ? (o: number) => buf.readUInt32LE(offset + o)
    : (o: number) => buf.readUInt32BE(offset + o);

  const readRational = (o: number): number => {
    const num = read32(o);
    const den = read32(o + 4);
    return den === 0 ? 0 : num / den;
  };

  const readString = (o: number, len: number): string => {
    return buf
      .subarray(offset + o, offset + o + len)
      .toString("ascii")
      .replace(/\0+$/, "");
  };

  const fields: ExifFields = {};

  const parseIfd = (ifdOffset: number, parseGps = false) => {
    if (ifdOffset + 2 > buf.length - offset) return;
    const count = read16(ifdOffset);

    for (let i = 0; i < count; i++) {
      const entryOffset = ifdOffset + 2 + i * 12;
      if (entryOffset + 12 > buf.length - offset) break;

      const tag = read16(entryOffset);
      const type = read16(entryOffset + 2);
      const numValues = read32(entryOffset + 4);
      const valueOffset = read32(entryOffset + 8);

      // For values that fit in 4 bytes, data is inline at entryOffset+8
      const dataOffset =
        type === 2 && numValues <= 4
          ? entryOffset + 8
          : type === 5 || numValues > 4
            ? valueOffset
            : entryOffset + 8;

      if (parseGps) {
        switch (tag) {
          case 1: // GPSLatitudeRef
            fields.gpsLatitudeRef = readString(entryOffset + 8, 2);
            break;
          case 2: // GPSLatitude
            if (dataOffset + 24 <= buf.length - offset) {
              const d = readRational(dataOffset);
              const m = readRational(dataOffset + 8);
              const s = readRational(dataOffset + 16);
              fields.gpsLatitude = `${d} deg ${Math.floor(m)}' ${s.toFixed(2)}"`;
            }
            break;
          case 3: // GPSLongitudeRef
            fields.gpsLongitudeRef = readString(entryOffset + 8, 2);
            break;
          case 4: // GPSLongitude
            if (dataOffset + 24 <= buf.length - offset) {
              const d = readRational(dataOffset);
              const m = readRational(dataOffset + 8);
              const s = readRational(dataOffset + 16);
              fields.gpsLongitude = `${d} deg ${Math.floor(m)}' ${s.toFixed(2)}"`;
            }
            break;
        }
        continue;
      }

      switch (tag) {
        case 0x0110: // Model
          if (dataOffset + numValues <= buf.length - offset) {
            fields.model = readString(dataOffset, numValues);
          }
          break;
        case 0x8769: // ExifIFD pointer
          parseIfd(valueOffset);
          break;
        case 0x8825: // GPS IFD pointer
          parseIfd(valueOffset, true);
          break;
        case 0x829a: // ExposureTime
          if (dataOffset + 8 <= buf.length - offset) {
            const num = read32(dataOffset);
            const den = read32(dataOffset + 4);
            fields.exposureTime =
              den > num ? `1/${Math.round(den / num)}` : `${num / den}`;
          }
          break;
        case 0x829d: // FNumber
          if (dataOffset + 8 <= buf.length - offset) {
            fields.fNumber = readRational(dataOffset);
          }
          break;
        case 0x8827: // ISO
          fields.iso = type === 3 ? read16(entryOffset + 8) : valueOffset;
          break;
        case 0x9003: // DateTimeOriginal
          if (dataOffset + numValues <= buf.length - offset) {
            fields.dateTimeOriginal = readString(dataOffset, numValues);
          }
          break;
        case 0x920a: // FocalLength
          if (dataOffset + 8 <= buf.length - offset) {
            const fl = readRational(dataOffset);
            fields.focalLength = fl.toFixed(1).replace(/\.0$/, "");
          }
          break;
        case 0xa434: // LensModel
          if (dataOffset + numValues <= buf.length - offset) {
            fields.lensModel = readString(dataOffset, numValues);
          }
          break;
      }
    }
  };

  const ifdOffset = read32(4);
  parseIfd(ifdOffset);

  return fields;
}

// ─── Merged metadata ────────────────────────────────────────────────────────

interface ImageMetadata {
  id: string;
  title: string[];
  image: string;
  alt: string;
  location: string;
  date: string;
  tags: string[];
  exif: {
    camera: string;
    lens: string;
    aperture: string;
    iso: string;
    focal_length: string;
    shutter_speed: string;
  };
}

function formatGpsLocation(exif: ExifFields): string {
  if (!exif.gpsLatitude || !exif.gpsLongitude) return "";

  const latRef = exif.gpsLatitudeRef ?? "N";
  const lonRef = exif.gpsLongitudeRef ?? "E";
  return `${exif.gpsLatitude} ${latRef}, ${exif.gpsLongitude} ${lonRef}`;
}

function formatDate(raw: string | undefined): string {
  if (!raw) return "";

  // Handle "YYYY:MM:DD HH:MM:SS" or "YYYYMMDD" or "YYYY-MM-DDTHH:MM:SS"
  if (/^\d{8}$/.test(raw)) {
    return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
  }

  const [datePart] = raw.split(/[T ]/);
  if (!datePart) return "";
  return datePart.replaceAll(":", "-");
}

async function extractMetadata(imagePath: string): Promise<ImageMetadata> {
  const meta = await sharp(imagePath).metadata();
  const fileName = basename(imagePath);

  const iptc = meta.iptc ? parseIptc(meta.iptc) : ({} as IptcFields);
  const xmp = meta.xmp ? parseXmp(meta.xmp) : ({} as XmpFields);
  const exif = meta.exif ? parseExifBuffer(meta.exif) : ({} as ExifFields);

  const title = iptc.title || xmp.title || "";
  const caption = iptc.caption || xmp.description || "";
  const keywords = iptc.keywords ?? xmp.keywords ?? [];
  const date = formatDate(
    exif.dateTimeOriginal ?? xmp.createDate ?? iptc.dateCreated,
  );

  if (!title && !caption) {
    consola.warn(`No title or caption found in ${fileName}`);
  }

  return {
    id: fileName.replace(/\.jpg$/i, ""),
    title: title ? [title] : [],
    image: `./${fileName}`,
    alt: caption,
    location: formatGpsLocation(exif),
    date,
    tags: keywords,
    exif: {
      camera: exif.model ?? "",
      lens: exif.lensModel || xmp.lens || "",
      aperture: exif.fNumber?.toString() ?? "",
      iso: exif.iso?.toString() ?? "",
      focal_length: exif.focalLength?.replace(/ mm$/, "") ?? "",
      shutter_speed: exif.exposureTime ?? "",
    },
  };
}

// ─── CLI ────────────────────────────────────────────────────────────────────

interface CliOptions {
  refresh: boolean;
  photosDirectory: string;
}

function parseCliOptions(argv: string[]): CliOptions {
  const nonFlagArgs = argv.filter((arg) => !arg.startsWith("--"));
  return {
    refresh: argv.includes("--refresh"),
    photosDirectory: resolve(nonFlagArgs[0] ?? PHOTOS_DIR),
  };
}

async function getImagesToProcess(
  photosDirectory: string,
  options: Pick<CliOptions, "refresh">,
): Promise<string[]> {
  const relativeImagePaths = options.refresh
    ? (await getPhotoDirectories(photosDirectory)).flatMap((d) => d.imagePaths)
    : await getImagesMissingMetadata(photosDirectory);

  consola.info(
    options.refresh
      ? `Refreshing ${relativeImagePaths.length} image(s)`
      : `Found ${relativeImagePaths.length} image(s) without metadata`,
  );

  return relativeImagePaths.map((p) =>
    getPhotoAbsolutePath(p, photosDirectory),
  );
}

async function main() {
  consola.start("Checking for images to process...");
  const opts = parseCliOptions(process.argv.slice(2));

  const images = await getImagesToProcess(opts.photosDirectory, opts);

  if (images.length === 0) {
    consola.success(
      opts.refresh
        ? "No images found to refresh."
        : "No images require metadata.",
    );
    return;
  }

  for (let i = 0; i < images.length; i++) {
    const imagePath = images[i] as string;
    const rel = relative(process.cwd(), imagePath);
    consola.info(`Processing ${i + 1}/${images.length}: ${rel}`);

    const metadata = await extractMetadata(imagePath);
    const relativeImagePath = relative(opts.photosDirectory, imagePath);
    const jsonPath = getMetadataPathForImage(
      relativeImagePath,
      opts.photosDirectory,
    );

    await writeFile(jsonPath, JSON.stringify(metadata, null, 2));
    consola.info(`Wrote ${relative(process.cwd(), jsonPath)}`);
  }

  consola.success(`Processed ${images.length} image(s).`);
}

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
  try {
    await main();
  } catch (error) {
    consola.error(error);
    process.exit(1);
  }
}
