import axios, { AxiosError, type AxiosInstance } from 'axios';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { adoptSession, clearSession, session } from '$lib/stores/session.svelte';
import { primaryFile } from '$lib/types/photoprism';
import type {
	PpClientConfig,
	PpPhoto,
	PpSessionResponse,
	PpUser
} from '$lib/types/photoprism';

/**
 * Axios client pre-configured for PhotoPrism's /api/v1. Same-origin in dev
 * (vite proxies /api → photoprism:2342), same-origin in prod (Caddy fronts
 * both the SPA and PhotoPrism on one hostname).
 */
const http: AxiosInstance = axios.create({
	baseURL: '/api/v1',
	headers: { 'Content-Type': 'application/json' }
});

http.interceptors.request.use((config) => {
	if (session.accessToken) {
		config.headers = config.headers ?? {};
		(config.headers as Record<string, string>)['X-Auth-Token'] = session.accessToken;
	}
	return config;
});

http.interceptors.response.use(
	(r) => r,
	(err: AxiosError) => {
		if (err.response?.status === 401 && browser) {
			clearSession();
			// Avoid redirect loops if the request was a login probe.
			const url = err.config?.url ?? '';
			if (!url.endsWith('/session')) {
				void goto('/login', { replaceState: true });
			}
		}
		return Promise.reject(err);
	}
);

// ── Auth ─────────────────────────────────────────────────────────────────────

export async function login(username: string, password: string): Promise<PpSessionResponse> {
	const { data } = await http.post<PpSessionResponse>('/session', { username, password });
	adoptSession(data);
	return data;
}

export async function logout(): Promise<void> {
	if (session.id) {
		try {
			await http.delete(`/session/${session.id}`);
		} catch {
			// Best-effort: even if PhotoPrism rejects, drop the client state.
		}
	}
	clearSession();
}

export async function fetchSession(id: string): Promise<PpSessionResponse> {
	const { data } = await http.get<PpSessionResponse>(`/session/${id}`);
	return data;
}

/**
 * After OIDC completes, PhotoPrism returns an HTML page that writes the
 * issued session into `localStorage` under the namespaced keys
 * `pp:<storageNamespace>:session.{id,token,user,provider}` and then runs
 * `window.location.href = "/library/login"`. With our Caddy bouncing
 * `/library/*` back to `/`, the browser lands on the SvelteKit root with
 * those entries already in localStorage but with no PhotoPrism cookies set
 * — so we read them back to adopt the OIDC-issued session.
 *
 * Returns the adopted session, or null when nothing is waiting in storage
 * (caller treats null as "stay on /login").
 */
export async function bootstrapSessionFromPhotoPrism(): Promise<PpSessionResponse | null> {
	if (!browser) return null;
	// PhotoPrism's storageNamespace is per-instance (build-time hash); fetch
	// it via /api/v1/config so we resolve the right key prefix.
	let namespace: string | undefined;
	try {
		const cfg = await getConfig();
		namespace = cfg.storageNamespace;
	} catch {
		return null;
	}
	if (!namespace) return null;
	const prefix = `pp:${namespace}:`;
	const sid = localStorage.getItem(prefix + 'session.id');
	const token = localStorage.getItem(prefix + 'session.token');
	if (!sid || !token) return null;
	// Prime the http client so the X-Auth-Token interceptor fires for the
	// session lookup below.
	session.accessToken = token;
	try {
		const resp = await fetchSession(sid);
		adoptSession(resp);
		// adoptSession persists into our own storage key (`pp_session`);
		// PhotoPrism's `pp:<ns>:session.*` entries are one-shot delivery,
		// so clear them now to avoid stale state on logout.
		for (const k of [
			'session.id',
			'session.token',
			'session.user',
			'session.provider',
			'session.error'
		]) {
			localStorage.removeItem(prefix + k);
		}
		return resp;
	} catch {
		session.accessToken = null;
		return null;
	}
}

export async function getConfig(): Promise<PpClientConfig> {
	const { data } = await http.get<PpClientConfig>('/config');
	return data;
}

// ── Photos ───────────────────────────────────────────────────────────────────

export interface ListPhotosParams {
	q?: string;
	count?: number;
	offset?: number;
	order?: 'newest' | 'oldest' | 'added' | 'edited' | 'name';
	merged?: boolean;
}

export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto[]> {
	const { data } = await http.get<PpPhoto[]>('/photos', {
		params: {
			count: 60,
			offset: 0,
			order: 'newest',
			merged: true,
			...params
		}
	});
	return data;
}

export async function getPhoto(uid: string): Promise<PpPhoto> {
	const { data } = await http.get<PpPhoto>(`/photos/${uid}`);
	return data;
}

/**
 * Patch payload accepted by `updatePhoto`. Top-level scalars merge in
 * place; `Details` is shallow-merged onto the existing Details object so
 * callers can patch a single Details field (Keywords, Subject, …) without
 * clobbering siblings.
 *
 * **PhotoPrism quirk**: nested `Details.*` only persists when the PUT
 * carries the FULL photo body — partial PUTs silently no-op for those
 * fields. `updatePhoto` handles the fetch/merge/PUT round-trip so callers
 * can stick to a partial shape.
 */
export interface UpdatePhotoBody {
	OriginalName?: string;
	Caption?: string;
	CaptionSrc?: 'manual' | '';
	Favorite?: boolean;
	Private?: boolean;
	Archived?: boolean;
	/** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep —
	 * use `buildTakenAtPatch` to assemble all five fields from one ISO. */
	TakenAt?: string;
	TakenAtLocal?: string;
	TakenSrc?: 'manual' | '';
	Year?: number;
	Month?: number;
	Day?: number;
	TimeZone?: string;
	Lat?: number;
	Lng?: number;
	Altitude?: number;
	Country?: string;
	CountrySrc?: 'manual' | '';
	Details?: Partial<import('$lib/types/photoprism').PpDetails>;
}

export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
	const d = new Date(iso);
	if (Number.isNaN(d.getTime())) return {};
	const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
	return {
		TakenAt: utc,
		TakenAtLocal: utc,
		TakenSrc: 'manual',
		Year: d.getUTCFullYear(),
		Month: d.getUTCMonth() + 1,
		Day: d.getUTCDate()
	};
}

/**
 * Merge a partial `UpdatePhotoBody` onto a full photo and PUT the result.
 * Required for `Details.*` because PhotoPrism rejects partial bodies for
 * nested fields. Top-level fields would work with a thinner body but we
 * unify on full-body PUT to keep the call sites simple.
 */
export async function updatePhoto(photo: PpPhoto, patch: UpdatePhotoBody): Promise<PpPhoto> {
	const merged: Record<string, unknown> = { ...photo, ...patch };
	if (patch.Details) {
		merged.Details = { ...(photo.Details ?? {}), ...patch.Details };
	}
	const { data } = await http.put<PpPhoto>(`/photos/${photo.UID}`, merged);
	return data;
}

// ── Batch ────────────────────────────────────────────────────────────────────

interface BatchPhotosBody {
	photos: string[];
}

function toBatchBody(uids: string[]): BatchPhotosBody {
	// PhotoPrism's batch endpoints expect a flat array of UIDs, not
	// `{UID: ...}` objects (verified against the bundled Vue client).
	return { photos: uids };
}

export async function batchArchive(uids: string[]): Promise<void> {
	await http.post('/batch/photos/archive', toBatchBody(uids));
}

export async function batchRestore(uids: string[]): Promise<void> {
	await http.post('/batch/photos/restore', toBatchBody(uids));
}

/**
 * Permanently delete photos. PhotoPrism only accepts UIDs that are already
 * archived — calling on a live photo returns 4xx. Irreversible; no undo
 * counterpart.
 */
export async function batchDelete(uids: string[]): Promise<void> {
	await http.post('/batch/photos/delete', toBatchBody(uids));
}

/**
 * Approve a photo in the review pile. PhotoPrism's indexer leaves photos
 * with low quality scores in `review:true` purgatory; approving bumps the
 * score above the review threshold (Quality goes to 3+) so the photo
 * lands in the main timeline. No corresponding "unapprove" endpoint — the
 * review pile is one-way out.
 */
export async function approvePhoto(uid: string): Promise<void> {
	await http.post(`/photos/${uid}/approve`);
}

/**
 * Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
 * routes that are atomic; preferred over PUT for this one field.
 */
export async function likePhoto(uid: string): Promise<void> {
	await http.post(`/photos/${uid}/like`);
}

export async function unlikePhoto(uid: string): Promise<void> {
	await http.delete(`/photos/${uid}/like`);
}

// ── Stack file operations ───────────────────────────────────────────────────
// PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into
// a single Photo entity. The duplicate-resolution flow needs two ops, both
// nested under the photo UID:
//   - setPrimary: pick which file is the canonical/cover for the stack.
//   - unstackFile: pull a file out of the stack so it becomes its own Photo
//     record (which can then be archived via batchArchive). PhotoPrism returns
//     the freshly-promoted parent photo body on success.
//
// PhotoPrism refuses to unstack auto-generated sidecar files (e.g. `.jpg`
// companions next to a RAW) and live-photo pairs — both return 4xx/5xx. The
// callers above must surface the failure rather than retry, hence the
// passthrough error from the axios layer.

export async function setPrimary(photoUid: string, fileUid: string): Promise<void> {
	await http.post(`/photos/${photoUid}/files/${fileUid}/primary`);
}

export async function unstackFile(photoUid: string, fileUid: string): Promise<PpPhoto> {
	const { data } = await http.post<PpPhoto>(`/photos/${photoUid}/files/${fileUid}/unstack`);
	return data;
}

/**
 * Remove a non-primary file from a stack. PhotoPrism cascades the delete
 * through related variants in the same logical group (e.g. deleting one
 * file of a Live Photo HEIC+MOV pair removes the whole pair). The file is
 * NOT erased from disk; PhotoPrism renames the on-disk file with a hash
 * suffix to take it out of the indexer's path. The response is the
 * updated parent photo body.
 *
 * Used by the duplicate-resolution flow as the practical "discard rest"
 * primitive because `/unstack` returns 5xx for live-photo and sidecar
 * files (`only originals can be unstacked` / `Changes could not be saved`).
 */
export async function deleteFile(photoUid: string, fileUid: string): Promise<PpPhoto> {
	const { data } = await http.delete<PpPhoto>(`/photos/${photoUid}/files/${fileUid}`);
	return data;
}

// ── Folders ──────────────────────────────────────────────────────────────────

export interface PpFolder {
	UID: string;
	Path: string;
	Root: string;
	Title: string;
	FileCount?: number;
	Favorite?: boolean;
	Private?: boolean;
}

/**
 * Recursive list of subfolders under originals/. `uncached=true` because
 * PhotoPrism's folder cache lags new folders by a noticeable interval and
 * mule-image's folder tree expects to surface mutations immediately.
 */
export async function listFolders(): Promise<PpFolder[]> {
	const { data } = await http.get<{ folders?: PpFolder[] }>(
		'/folders/originals',
		{ params: { recursive: true, uncached: true, files: false } }
	);
	return data.folders ?? [];
}

/**
 * Inbox / import staging area. PhotoPrism keeps uploaded-but-not-yet-indexed
 * files in a separate `/photoprism/import` root, exposed via
 * `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
 * `/folders/originals`, but the photo counts come from `X-Files` and
 * `X-Folders` response headers since the body only lists subfolders.
 */
export interface ImportInfo {
	files: number;
	folders: number;
	subfolders: PpFolder[];
}

export async function getImportInfo(): Promise<ImportInfo> {
	const res = await http.get<{ folders?: PpFolder[] }>('/folders/import', {
		params: { recursive: true, uncached: true, files: false }
	});
	const num = (h: unknown) => {
		const n = typeof h === 'string' ? parseInt(h, 10) : NaN;
		return Number.isFinite(n) ? n : 0;
	};
	return {
		files: num(res.headers['x-files'] ?? res.headers['X-Files']),
		folders: num(res.headers['x-folders'] ?? res.headers['X-Folders']),
		subfolders: res.data.folders ?? []
	};
}

/**
 * Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
 * endpoint reports `FileCount: 0` even when populated, so the count has
 * to be derived from a `/photos?q=path:X` lookup per folder.
 *
 * We hand this off to the sidecar (`POST /api/sidecar/folders/counts`)
 * which fans out to PhotoPrism over loopback, dedupes by UID, and
 * returns a single `{path: count}` payload of <5 KB. The previous
 * client-side implementation issued one `/photos?count=1000` per folder
 * from the browser — on a library with 30 folders that's ≈30 MB of JSON
 * pulled across the wire on every cold sidebar mount.
 *
 * `path:X` is non-recursive in PhotoPrism's q-DSL: it matches direct
 * children only, so summing the per-path counts (no double-counting
 * from nested folders) is the right way to derive the root-folder
 * photo count. Capped at PhotoPrism's 1000-row ceiling; folders larger
 * than that under-report (pre-existing limitation, unchanged here).
 *
 * Returns a plain object keyed by the input paths to keep it JSON-
 * friendly for TanStack's structural sharing.
 */
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
	if (paths.length === 0) return {};
	const data = (await sidecar('POST', '/folders/counts', { paths })) as Record<
		string,
		number
	>;
	return data;
}

// ── Geo ──────────────────────────────────────────────────────────────────────

export interface PpGeoFeature {
	type: 'Feature';
	id: string;
	geometry: { type: 'Point'; coordinates: [number, number] };
	properties: {
		UID: string;
		Hash: string;
		Title?: string;
		TakenAt?: string;
		FavId?: number;
	};
}

export interface PpGeoCollection {
	type: 'FeatureCollection';
	features: PpGeoFeature[];
	bbox?: number[];
}

export async function listGeo(q = ''): Promise<PpGeoCollection> {
	// PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every
	// matching geocoded photo. MapLibre's native clustering handles 50k+
	// points without breaking a sweat (PhotoPrism upstream documents
	// 500k); we ask for a generous cap that covers realistic libraries.
	const { data } = await http.get<PpGeoCollection>('/geo', {
		params: { count: 50000, q: q || undefined }
	});
	return data;
}

// ── Labels ───────────────────────────────────────────────────────────────────

export interface PpLabel {
	UID: string;
	Slug: string;
	CustomSlug?: string;
	Name: string;
	Favorite?: boolean;
	Priority?: number;
	Description?: string;
	PhotoCount?: number;
	Thumb?: string;
}

/**
 * User-set keyword aggregation. PhotoPrism's `/labels` endpoint only
 * surfaces classifier-derived labels; user-typed keywords live in
 * `Details.Keywords` (a flat comma-separated string) which is *not*
 * included in the `/photos` list response. The only path to it is the
 * single-photo endpoint, so we fetch the library top-end (capped at
 * PhotoPrism's 1000-row ceiling), fan out `getPhoto` calls in batches,
 * and aggregate.
 *
 * Slow but cached upstream via TanStack — keys under the `['photos', …]`
 * prefix so the existing photo-mutation invalidations cascade to it.
 */
export interface AggregatedKeyword {
	keyword: string;
	count: number;
	/** UID of an arbitrary photo carrying this keyword — used as the
	 *  thumbnail source so the tile is visually consistent with
	 *  classifier-label tiles. */
	sampleUid: string;
	sampleHash: string;
}

export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
	const list = await listPhotos({ count: 1000, merged: true });
	const buckets = new Map<string, AggregatedKeyword>();
	const concurrency = 8;
	for (let i = 0; i < list.length; i += concurrency) {
		const slice = list.slice(i, i + concurrency);
		const fulls = await Promise.all(
			slice.map((p) => getPhoto(p.UID).catch(() => null))
		);
		for (let j = 0; j < slice.length; j++) {
			const photo = slice[j];
			const full = fulls[j];
			if (!full) continue;
			const raw = full.Details?.Keywords ?? '';
			if (!raw) continue;
			for (const kw of raw.split(',').map((k) => k.trim()).filter(Boolean)) {
				const bucket = buckets.get(kw);
				if (bucket) {
					bucket.count++;
					continue;
				}
				const hash = photo.Hash ?? primaryFile(photo).Hash;
				buckets.set(kw, {
					keyword: kw,
					count: 1,
					sampleUid: photo.UID,
					sampleHash: hash
				});
			}
		}
	}
	return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
}

export async function listLabels(): Promise<PpLabel[]> {
	// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
	// low-confidence classifier hits, manually-removed labels). They're
	// still attached to photos in the DB and the `label:<slug>` query
	// still resolves; without `all=true` the /labels endpoint filters
	// them out and the tags page silently shows only ~40% of the user's
	// real tag set. `count` bumped to 1000 so a moderately tagged library
	// returns the full list in one round-trip.
	const { data } = await http.get<PpLabel[]>('/labels', {
		params: { count: 1000, order: 'count', all: true }
	});
	return data;
}

// ── Albums = Heaps ───────────────────────────────────────────────────────────

export interface PpAlbum {
	UID: string;
	Slug?: string;
	Type: string;
	Title: string;
	Description?: string;
	Favorite?: boolean;
	PhotoCount?: number;
	CreatedAt?: string;
	UpdatedAt?: string;
	Thumb?: string;
}

export async function listHeaps(): Promise<PpAlbum[]> {
	const { data } = await http.get<PpAlbum[]>('/albums', {
		params: { type: 'album', count: 500, order: 'newest' }
	});
	return data;
}

export async function getHeap(uid: string): Promise<PpAlbum> {
	const { data } = await http.get<PpAlbum>(`/albums/${uid}`);
	return data;
}

export async function createHeap(title: string): Promise<PpAlbum> {
	const { data } = await http.post<PpAlbum>('/albums', {
		Title: title,
		Type: 'album'
	});
	return data;
}

export async function renameHeap(uid: string, title: string): Promise<PpAlbum> {
	const { data } = await http.put<PpAlbum>(`/albums/${uid}`, { Title: title });
	return data;
}

export async function deleteHeap(uid: string): Promise<void> {
	await http.delete(`/albums/${uid}`);
}

/**
 * PhotoPrism returns `{ code, message, album, photos: [uids in album], added: [delta] }`.
 * Surface `added` so callers can detect "200-but-nothing-happened" — PhotoPrism
 * silently skips UIDs that are missing from the index or already in the album,
 * which used to look like a successful add to the user.
 */
export interface AddToHeapResult {
	added: string[];
}

export async function addToHeap(uid: string, photos: string[]): Promise<AddToHeapResult> {
	const { data } = await http.post<{ added?: string[] }>(`/albums/${uid}/photos`, { photos });
	return { added: data.added ?? [] };
}

export async function removeFromHeap(uid: string, photos: string[]): Promise<void> {
	await http.delete(`/albums/${uid}/photos`, { data: { photos } });
}

/**
 * Clone a heap. PhotoPrism has no native duplicate endpoint, so we fan out
 * three round-trips: read the source title, list its members via the q-DSL
 * (the same `album:<UID>` filter the timeline uses for the heap view), create
 * a new "X (copy)" album, then add every member to it. Matches mule-image's
 * backend `POST /heaps/{id}/duplicate` behaviour.
 */
export async function duplicateHeap(uid: string): Promise<PpAlbum> {
	const source = await getHeap(uid);
	const members = await listPhotos({ q: `album:${uid}`, count: 1000 });
	const copy = await createHeap(`${source.Title} (copy)`);
	if (members.length > 0) {
		await addToHeap(
			copy.UID,
			members.map((p) => p.UID)
		);
	}
	return copy;
}

/**
 * URL for PhotoPrism's album-as-zip download. The download token comes from
 * the session config and is what authenticates the GET — no auth header
 * needed, which is why the URL can be opened in a fresh window/tab.
 */
export function heapDownloadUrl(uid: string): string {
	const t = session.downloadToken ?? '';
	return `/api/v1/albums/${uid}/dl?t=${encodeURIComponent(t)}`;
}

/**
 * Trigger a browser download by injecting a transient <a> element and
 * clicking it. Matches the pattern from mule-image's `downloads.trigger`.
 * Uses target=_blank so PhotoPrism's zip response (which streams) doesn't
 * navigate the current page away.
 */
export function triggerDownload(url: string): void {
	const a = document.createElement('a');
	a.href = url;
	a.rel = 'noopener';
	a.target = '_blank';
	document.body.appendChild(a);
	a.click();
	a.remove();
}

// ── mule-sidecar (Node prototype today, Go in M4) ────────────────────────────

/**
 * Rename the primary file of a photo on disk. PhotoPrism's `OriginalName`
 * field only updates the display name; this calls the mule-sidecar service
 * to issue an actual `os.Rename` under `originals/` and then trigger a
 * PhotoPrism reindex of the parent path.
 *
 * Network: same-origin via the dev proxy entry `/api/sidecar/*`.
 */
export interface RenameResult {
	ok: boolean;
	oldName: string;
	newName: string;
	oldRelPath: string;
	newRelPath: string;
}

async function sidecar(method: string, urlPath: string, body?: unknown): Promise<unknown> {
	const res = await fetch(`/api/sidecar${urlPath}`, {
		method,
		headers: {
			'Content-Type': 'application/json',
			'X-Auth-Token': session.accessToken ?? ''
		},
		body: body === undefined ? undefined : JSON.stringify(body)
	});
	const data = await res.json().catch(() => ({}));
	if (!res.ok) {
		const err = (data as { error?: string }).error ?? `HTTP ${res.status}`;
		throw new Error(err);
	}
	return data;
}

export async function createFolder(relPath: string): Promise<{ path: string }> {
	return sidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>;
}

export async function renameFolder(
	relPath: string,
	newName: string
): Promise<{ oldPath: string; newPath: string }> {
	return sidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, {
		newName
	}) as Promise<{ oldPath: string; newPath: string }>;
}

export async function deleteFolder(relPath: string): Promise<{ path: string }> {
	return sidecar('DELETE', `/folders/${encodeURIComponent(relPath)}`) as Promise<{
		path: string;
	}>;
}

// ── Cross-folder duplicate detection (sidecar-driven) ───────────────────────
// PhotoPrism silently drops byte-identical files at index time, so duplicates
// across folders never enter its DB. The sidecar walks the originals tree,
// pre-filters by size, sha1s the survivors, and returns the hash-collision
// groups. Resolution moves the unwanted copies into a `.duplicates/`
// quarantine folder PhotoPrism's indexer ignores.

export interface DupFileEntry {
	path: string;
	size: number;
}

export interface CrossFolderDuplicateGroup {
	hash: string;
	size: number;
	/** Path of the file PhotoPrism currently has indexed for this hash,
	 *  or null if none (the rare case of every copy being dropped). The
	 *  UI uses this to default the "keep" pick. */
	indexedPath: string | null;
	files: DupFileEntry[];
}

export interface CrossFolderScanResult {
	groups: CrossFolderDuplicateGroup[];
	scannedMs: number;
}

export async function scanCrossFolderDuplicates(): Promise<CrossFolderScanResult> {
	return sidecar('GET', '/duplicates/scan') as Promise<CrossFolderScanResult>;
}

export interface ArchiveDuplicatesResult {
	moved: { from: string; to: string }[];
	errors: { path: string; error: string }[];
}

export async function archiveDuplicatePaths(
	paths: string[]
): Promise<ArchiveDuplicatesResult> {
	return sidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
}

// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
// Lives on the sidecar because moving the underlying files is a filesystem
// operation PhotoPrism's API doesn't expose. The sidecar lists album members
// via PhotoPrism's q-DSL, fs.rename / fs.copyFile each primary file into the
// target folder, then triggers a PhotoPrism reindex.

export interface HeapConvertBody {
	/** Originals-relative target folder. Must exist. */
	targetFolder: string;
	mode: 'move' | 'copy';
	/** Optional subfolder name to create under `targetFolder` and place
	 *  files into. Lets the user keep a heap's worth of files grouped. */
	subfolder?: string | null;
	/** Delete the album after a successful move. Ignored when mode='copy'
	 *  (a copy doesn't change membership). */
	deleteHeap?: boolean;
}

export interface HeapConvertResult {
	moved: number;
	copied: number;
	errors: { uid: string; reason: string }[];
	heap_deleted: boolean;
}

export async function convertHeap(
	uid: string,
	body: HeapConvertBody
): Promise<HeapConvertResult> {
	return sidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
}

// ── Photo marks (rating + color) ─────────────────────────────────────────────
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
// internal fields). We store them in mule-sidecar instead.

export interface PhotoMark {
	rating?: number;
	color?: string;
	updatedAt?: string;
}

export type PhotoMarksMap = Record<string, PhotoMark>;

export async function getAllMarks(): Promise<PhotoMarksMap> {
	const data = await sidecar('GET', '/photos/marks');
	return (data ?? {}) as PhotoMarksMap;
}

export async function setMark(photoUid: string, patch: PhotoMark): Promise<PhotoMark> {
	return sidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise<PhotoMark>;
}

export async function bulkSetMarks(
	ids: string[],
	patch: PhotoMark
): Promise<{ count: number; marks: PhotoMarksMap }> {
	return sidecar('POST', '/photos/marks/bulk', { ids, patch }) as Promise<{
		count: number;
		marks: PhotoMarksMap;
	}>;
}

export async function renameOnDisk(photoUid: string, newName: string): Promise<RenameResult> {
	// Bypass the axios client because the sidecar lives at /api/sidecar, not
	// /api/v1 — http.baseURL would prepend the wrong prefix.
	const res = await fetch(`/api/sidecar/files/${photoUid}/rename`, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
			'X-Auth-Token': session.accessToken ?? ''
		},
		body: JSON.stringify({ newName })
	});
	const data = (await res.json()) as Partial<RenameResult> & { error?: string };
	if (!res.ok) throw new Error(data.error ?? `Rename failed (${res.status})`);
	return data as RenameResult;
}

// ── Settings / Admin ─────────────────────────────────────────────────────────
//
// Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog
// (Library / Index / Import / Logs). Shapes are deliberately partial — newer
// PhotoPrism versions ship extra fields we don't render, and the POST endpoint
// merges server-side, so it's safe to round-trip an incomplete object.

export interface PpSettings {
	ui?: {
		theme?: string;
		language?: string;
		timeZone?: string;
		startPage?: string;
		scrollbar?: boolean;
		zoom?: boolean;
	};
	search?: {
		batchSize?: number;
		listView?: boolean;
		showTitles?: boolean;
		showCaptions?: boolean;
	};
	maps?: { animate?: number; style?: string };
	index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean };
	import?: { path?: string; move?: boolean; dest?: string };
	stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
	download?: {
		name?: string;
		disabled?: boolean;
		originals?: boolean;
		mediaRaw?: boolean;
		mediaSidecar?: boolean;
	};
	[k: string]: unknown;
}

export async function getSettings(): Promise<PpSettings> {
	const { data } = await http.get<PpSettings>('/settings');
	return data;
}

export async function saveSettings(patch: Partial<PpSettings>): Promise<PpSettings> {
	const { data } = await http.post<PpSettings>('/settings', patch);
	return data;
}

export interface IndexBody {
	path?: string;
	rescan?: boolean;
	cleanup?: boolean;
}

export async function startIndex(body: IndexBody = {}): Promise<{ message: string }> {
	const { data } = await http.post<{ message: string }>('/index', {
		path: '/',
		rescan: false,
		cleanup: false,
		...body
	});
	return data;
}

export async function cancelIndex(): Promise<void> {
	await http.delete('/index');
}

export interface ImportBody {
	path?: string;
	move?: boolean;
	dest?: string;
}

export async function startImport(body: ImportBody = {}): Promise<{ message: string }> {
	const { data } = await http.post<{ message: string }>('/import', {
		path: '/',
		move: false,
		dest: '',
		...body
	});
	return data;
}

export async function cancelImport(): Promise<void> {
	await http.delete('/import');
}

export interface PpLogEntry {
	Time: string;
	Level: string;
	Message: string;
}

export async function getErrors(opts: { limit?: number } = {}): Promise<PpLogEntry[]> {
	const { data } = await http.get<PpLogEntry[]>('/errors', {
		params: { limit: opts.limit ?? 200 }
	});
	return data ?? [];
}

// ── Re-exports ───────────────────────────────────────────────────────────────

export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };
