#!/usr/bin/env node
// Two-phase migration: legacy snapshot → PhotoPrism (metadata + heaps).
//
// Phase A — for each legacy photo, hash the on-disk file, resolve the
// PhotoPrism UID via `q=hash:<sha1>`, then PUT the merged metadata onto
// `/api/v1/photos/:uid`. We don't write sidecars directly because
// PhotoPrism's reindex rewrites them from its DB state — PUT is the only
// authoritative path. Slower (one API call per photo) but reliable.
//
// Phase B — creates a manual PhotoPrism Album for each legacy heap and
// bulk-adds the resolved UIDs via `/albums/:uid/photos`.
//
// Both phases are idempotent. Re-running Phase A merges the same fields
// (PhotoPrism's PUT does a deep-merge for nested Details). Phase B looks
// up heaps by title and only creates if absent.

import crypto from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import path from 'node:path';
import process from 'node:process';

// ── env / cli ────────────────────────────────────────────────────────────────

const PP_BASE = process.env.PHOTOPRISM_BASE_URL ?? 'http://localhost:2342';
const PP_USER = process.env.PHOTOPRISM_USER ?? 'admin';
const PP_PW = process.env.PHOTOPRISM_PASSWORD;
const ORIGINALS_ROOT = path.resolve(
	process.env.ORIGINALS_ROOT ?? '/photoprism/originals'
);
const STORAGE_ROOT = path.resolve(
	process.env.STORAGE_ROOT ?? '/photoprism/storage'
);

if (!PP_PW) {
	console.error('PHOTOPRISM_PASSWORD is required');
	process.exit(2);
}

const args = parseArgs(process.argv.slice(2));
if (!args.snapshot) {
	console.error('--snapshot <path> required');
	process.exit(2);
}
const phase = args.phase ?? 'all';
if (!['sidecars', 'heaps', 'all'].includes(phase)) {
	console.error(`unknown --phase=${phase} (sidecars|heaps|all)`);
	process.exit(2);
}

function parseArgs(argv) {
	const out = {};
	for (let i = 0; i < argv.length; i++) {
		const a = argv[i];
		if (a === '--snapshot') out.snapshot = argv[++i];
		else if (a === '--phase') out.phase = argv[++i];
		else if (a === '--dry-run') out.dryRun = true;
	}
	return out;
}

// ── http helpers ─────────────────────────────────────────────────────────────

let TOKEN = null;
async function pp(method, urlPath, body) {
	const init = {
		method,
		headers: {
			'Content-Type': 'application/json',
			...(TOKEN ? { 'X-Auth-Token': TOKEN } : {})
		}
	};
	if (body !== undefined) init.body = JSON.stringify(body);
	const r = await fetch(new URL(urlPath, PP_BASE), init);
	const text = await r.text();
	let data = null;
	try {
		data = text ? JSON.parse(text) : null;
	} catch {
		data = text;
	}
	if (!r.ok) {
		const msg = data?.error ?? data?.message ?? `HTTP ${r.status}`;
		throw new Error(`${method} ${urlPath} → ${msg}`);
	}
	return data;
}

async function login() {
	const data = await pp('POST', '/api/v1/session', {
		username: PP_USER,
		password: PP_PW
	});
	TOKEN = data.access_token;
	console.log(`logged in as ${data.user.Name}`);
}

// ── hashing ──────────────────────────────────────────────────────────────────

async function sha1(absPath) {
	return new Promise((resolve, reject) => {
		const h = crypto.createHash('sha1');
		const s = createReadStream(absPath);
		s.on('data', (c) => h.update(c));
		s.on('end', () => resolve(h.digest('hex')));
		s.on('error', reject);
	});
}

// ── Phase A: PUT merged metadata per photo ───────────────────────────────────

function buildPatch(photo) {
	const patch = {};

	if (photo.user_title) {
		patch.Title = photo.user_title;
		patch.TitleSrc = 'manual';
	}
	if (photo.user_notes) {
		patch.Caption = photo.user_notes;
		patch.CaptionSrc = 'manual';
	}
	// mule-image's is_picked (binary "starred") → PhotoPrism Favorite.
	// is_discarded → Archived. Rating + is_hidden intentionally not
	// migrated per the merge plan.
	if (photo.is_picked) patch.Favorite = true;
	if (photo.is_discarded) patch.Archived = true;

	if (photo.taken_at) {
		const d = new Date(photo.taken_at);
		if (!Number.isNaN(d.getTime())) {
			const iso = d.toISOString().replace(/\.\d+Z$/, 'Z');
			patch.TakenAt = iso;
			patch.TakenAtLocal = iso;
			patch.TakenSrc = 'manual';
			patch.Year = d.getUTCFullYear();
			patch.Month = d.getUTCMonth() + 1;
			patch.Day = d.getUTCDate();
		}
	}

	if (Array.isArray(photo.tags) && photo.tags.length) {
		patch.Details = {
			Keywords: photo.tags.join(', '),
			KeywordsSrc: 'manual'
		};
	}

	return patch;
}

async function resolveUid(filepath) {
	const abs = path.join(ORIGINALS_ROOT, filepath);
	const exists = await stat(abs).then(() => true, () => false);
	if (!exists) return null;
	const hash = await sha1(abs);
	const r = await pp(
		'GET',
		`/api/v1/photos?count=1&q=${encodeURIComponent(`hash:${hash}`)}`
	);
	const match = Array.isArray(r) ? r[0] : null;
	return match ? match.UID : null;
}

async function runPhaseA(snapshot) {
	console.log(`phase A — ${snapshot.photos.length} photos`);
	let updated = 0;
	let skipped = 0;
	let missing = 0;
	for (const p of snapshot.photos) {
		const patch = buildPatch(p);
		if (Object.keys(patch).length === 0) {
			skipped++;
			continue;
		}
		const uid = await resolveUid(p.filepath);
		if (!uid) {
			console.warn(`  [skip] ${p.filepath} — no matching PhotoPrism photo`);
			missing++;
			continue;
		}
		if (args.dryRun) {
			console.log(`  [dry] ${p.filepath} → ${uid}`);
			updated++;
			continue;
		}
		// PhotoPrism PUT requires the full photo body for nested Details to
		// stick — fetch first, deep-merge the patch in, then PUT.
		const current = await pp('GET', `/api/v1/photos/${uid}`);
		const merged = { ...current, ...patch };
		if (patch.Details) {
			merged.Details = { ...(current.Details ?? {}), ...patch.Details };
		}
		await pp('PUT', `/api/v1/photos/${uid}`, merged);
		console.log(`  patched ${p.filepath} (${uid})`);
		updated++;
	}
	console.log(`phase A — updated ${updated}, no-data ${skipped}, missing ${missing}`);
}

// ── Phase B: heaps ───────────────────────────────────────────────────────────

async function findHeapByTitle(title) {
	const r = await pp(
		'GET',
		`/api/v1/albums?type=album&count=500&q=${encodeURIComponent(title)}`
	);
	const arr = Array.isArray(r) ? r : [];
	return arr.find((a) => a.Title === title) ?? null;
}

async function runPhaseB(snapshot) {
	console.log(`phase B — ${snapshot.heaps.length} heaps`);
	for (const heap of snapshot.heaps) {
		const existing = await findHeapByTitle(heap.name);
		let album;
		if (existing) {
			console.log(`  heap "${heap.name}" exists (${existing.UID})`);
			album = existing;
		} else if (args.dryRun) {
			console.log(`  [dry] create heap "${heap.name}"`);
			continue;
		} else {
			album = await pp('POST', '/api/v1/albums', {
				Title: heap.name,
				Type: 'album'
			});
			console.log(`  created heap "${heap.name}" (${album.UID})`);
		}

		const uids = [];
		for (const fp of heap.photo_filepaths) {
			const uid = await resolveUid(fp);
			if (uid) uids.push(uid);
			else console.warn(`    [skip] ${fp} — no matching PhotoPrism photo`);
		}
		if (!uids.length) {
			console.log(`  heap "${heap.name}" — nothing to add`);
			continue;
		}
		if (args.dryRun) {
			console.log(`  [dry] add ${uids.length} to "${heap.name}"`);
			continue;
		}
		const r = await pp('POST', `/api/v1/albums/${album.UID}/photos`, { photos: uids });
		console.log(`  added ${uids.length} to "${heap.name}"`);
		void r;
	}
}

// ── main ─────────────────────────────────────────────────────────────────────

const snapshot = JSON.parse(await readFile(args.snapshot, 'utf8'));
await login();
if (phase === 'sidecars' || phase === 'all') await runPhaseA(snapshot);
if (phase === 'heaps' || phase === 'all') await runPhaseB(snapshot);
console.log('done');
