import { readdir, stat, mkdir, writeFile, readFile, rm, symlink, rename, lstat } from 'node:fs/promises';
import { existsSync, readdirSync } from 'node:fs';
import { join, relative, basename, dirname } from 'node:path';
import { homedir } from 'node:os';

// Server-side music library indexer — the platform counterpart of the app's music-index.ts. Walks the
// library and builds a cache tree that MIRRORS the library layout (so it syncs cleanly), using ffprobe
// for tags + duration and ffmpeg to compress covers for the phone. Output, per album folder at <rel>
// (relative to the Music root):
//
//   DATA_PATH/music/cache/<rel>/meta.json   { path, cover?, tracks: [{ file, title, artist, albumArtist,
//                                             album, track, disc?, year, durationSec, lyrics?:'lrc'|'txt' }],
//                                             videos?: [{ file, title, durationSec, width, height, poster? }],
//                                             images?: [{ file }] }   (phone IndexMeta schema)
//   DATA_PATH/music/cache/<rel>/cover.jpg    compressed (<=600px, jpeg q5)
//   DATA_PATH/music/cache/<rel>/posters/<videofile>.jpg   video frame-grab thumbnails
//   DATA_PATH/music/cache/<rel>/lyrics/<trackfile>.<lrc|txt>   external-sidecar or embedded lyrics
//
// A manifest (cache root) carries a per-album version `v` = hash of the album's source signature (track
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
// phone's resync diff (fetch only changed `v`s).

import { DATA_PATH } from '@@/data-path';

const HOME = homedir();
export const MUSIC_ROOT = join(HOME, 'Music');
const CACHE_ROOT = join(DATA_PATH, 'music', 'cache');
const MANIFEST_PATH = join(CACHE_ROOT, 'manifest.json');

const AUDIO_EXT = new Set(['mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'wav', 'wma']);
// Videos (concerts, clips) that live in an artist/album dir — all converted to phone-compatible .mp4,
// but index the common containers too. NOTE: 'mp4' is a VIDEO here (moved out of AUDIO_EXT); audio-in-mp4
// belongs in .m4a.
const VIDEO_EXT = new Set(['mp4', 'm4v', 'mkv', 'mov', 'webm', 'avi']);
const COVER_FILES = ['folder.jpg', 'cover.jpg', 'folder.png', 'cover.png'];
// Loose folder images (band photos, booklet scans) — indexed + served as-is, EXCLUDING the cover files.
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif']);
const DISCO_FILE = '_discography.md';
const TRACK_CONCURRENCY = 6;
const COVER_MAX_PX = 600;
// Cache-format version. Bump when the build produces NEW per-album outputs (so far: v2 added video
// posters + lyrics). A manifest written by an older CACHE_VERSION forces a one-time FULL rebuild — the
// per-album `v` skip only applies once the cache is already at the current format.
//   v1 → initial (meta + cover)   v2 → + posters/ + lyrics/   v3 → + lyrics/poster COUNTS in the manifest
const CACHE_VERSION = 3;

// ── Staging slots + atomic symlink swap ──
// The live cache path (CACHE_ROOT) is a SYMLINK to a slot dir; all readers + incremental writes follow
// it. A full rebuild is built into a FRESH slot and swapped in atomically (rename over the symlink) only
// on success — so the live index is never half-built and a failed rebuild leaves it untouched.

const MUSIC_DIR = dirname(CACHE_ROOT);
const SLOT_PREFIX = 'cache.store-';
const slotPath = (id: string) => join(MUSIC_DIR, `${SLOT_PREFIX}${id}`);

/** Ensure CACHE_ROOT is a symlink to a slot dir, migrating an existing real cache dir once. Idempotent. */
export async function ensureCacheSetup(): Promise<void> {
  const st = await lstat(CACHE_ROOT).catch(() => null);
  if (st?.isSymbolicLink()) return; // already set up
  const slot = slotPath('initial');
  if (st?.isDirectory()) {
    await rm(slot, { recursive: true, force: true }).catch(() => {});
    await rename(CACHE_ROOT, slot); // move the existing cache into a slot (a rename, not a copy — fast)
    console.log('[music] migrated cache/ into a slot for symlink swapping');
  } else {
    await mkdir(slot, { recursive: true });
  }
  await symlink(basename(slot), CACHE_ROOT); // relative symlink: cache -> cache.store-initial
  console.log(`[music] cache -> ${basename(slot)}`);
}

/** Atomically repoint the cache symlink to `slot`, then remove every other slot. */
async function activateSlot(slot: string): Promise<void> {
  const tmp = join(MUSIC_DIR, `.cache.swap-${Date.now()}`);
  await rm(tmp, { force: true }).catch(() => {});
  await symlink(basename(slot), tmp); // temp relative symlink → new slot
  await rename(tmp, CACHE_ROOT); // atomic replace of the live cache symlink
  for (const name of await readdir(MUSIC_DIR).catch(() => [] as string[])) {
    if (name.startsWith(SLOT_PREFIX) && name !== basename(slot)) {
      await rm(join(MUSIC_DIR, name), { recursive: true, force: true }).catch(() => {});
    }
  }
}

// ── Types (meta.json matches the app's IndexMeta/IndexTrack) ──

export type IndexTrack = {
  file: string;
  title?: string;
  artist?: string;
  albumArtist?: string;
  album?: string;
  track?: string;
  disc?: number; // disc / part-of-set number, from ID3 TPOS (the `disc` tag "n" or "n/total")
  year?: string;
  durationSec?: number;
  lyrics?: 'lrc' | 'txt'; // available lyrics format ('lrc' = synced/timestamped); text via /lyrics. Absent = none.
};
export type IndexVideo = {
  file: string;
  title?: string;
  durationSec?: number;
  width?: number;
  height?: number;
  poster?: string; // relative to <rel>: "posters/<file>.jpg" — a compressed frame grab, when generated
};
export type IndexImage = { file: string; width?: number; height?: number }; // dims optional (v1: just file)
export type IndexMeta = {
  path: string;
  cover?: string;
  tracks: IndexTrack[];
  videos?: IndexVideo[];
  images?: IndexImage[];
};

type ManifestEntry = {
  v: string;
  cover: boolean;
  tracks: number;
  videos?: number;
  images?: number;
  disco?: boolean;
  // How many files the last build wrote into <rel>/lyrics and <rel>/posters. Recorded ONLY so the
  // incremental skip can notice one has gone missing — see `outputsExist` in buildFolder. Without them
  // the integrity check could verify meta.json/cover.jpg/discography.json and nothing else, so a lost
  // lyrics file or poster left the album skipped forever and only a full rebuild restored it.
  lyrics?: number;
  posters?: number;
};
type Manifest = { version: number; generatedAt: number; albums: Record<string, ManifestEntry> };

// ── Discography (artist-level _discography.md → album folder → normalized type) ──

/** Canonical section for a Type cell. The source _discography.md is NEVER modified — this only cleans
 *  the derived JSON so the phone groups into tidy sections. Unknown types pass through trimmed. */
function normalizeType(raw: string): string {
  const t = raw.trim().replace(/\?+$/, '').trim(); // "EP?" → "EP", "Single?" → "Single"
  const lower = t.toLowerCase();
  if (lower.startsWith('compilation')) return 'Compilation'; // "Compilation (VA)" → "Compilation"
  const map: Record<string, string> = {
    studio: 'Studio',
    live: 'Live',
    single: 'Single',
    ep: 'EP',
    soundtrack: 'Soundtrack',
    remix: 'Remix',
    'dj-mix': 'DJ-Mix',
    djmix: 'DJ-Mix',
    demo: 'Demo',
    mixtape: 'Mixtape',
    bootleg: 'Bootleg',
    other: 'Other',
  };
  return map[lower] ?? t;
}

/** Parse the md table into { "[year] album" (folder name) → normalized type }. */
function parseDiscography(mdText: string): Record<string, string> {
  const albums: Record<string, string> = {};
  for (const line of mdText.split('\n')) {
    const m = line.match(/^\|\s*(\d{4})\s*\|\s*(.+?)\s*\|\s*([^|]+?)\s*\|/);
    if (!m) continue; // header/separator/non-rows don't match (Year isn't 4 digits)
    albums[`[${m[1]}] ${m[2]}`] = normalizeType(m[3]!);
  }
  return albums;
}

export type IndexStatus = {
  running: boolean;
  startedAt: number | null;
  finishedAt: number | null;
  foldersScanned: number;
  albumsBuilt: number; // rebuilt this run (changed)
  albumsSkipped: number; // unchanged (v matched)
  tracksIndexed: number;
  videosIndexed: number;
  coversSaved: number;
  postersSaved: number;
  lyricsIndexed: number;
  discographies: number; // artist discography.json files written
  currentPath: string;
  error: string | null;
};

// ── In-memory manifest (loaded from disk on first access, updated on build) ──

let manifest: Manifest = { version: 1, generatedAt: 0, albums: {} };
let manifestLoaded = false;

async function loadManifest(): Promise<Manifest> {
  if (manifestLoaded) return manifest;
  try {
    manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8')) as Manifest;
  } catch {
    manifest = { version: 1, generatedAt: 0, albums: {} };
  }
  manifestLoaded = true;
  return manifest;
}

export async function getManifest(): Promise<Manifest> {
  return loadManifest();
}

/** The version stamp for an album, for ETag/diff use (null if unknown). */
export async function albumVersion(rel: string): Promise<string | null> {
  const m = await loadManifest();
  return m.albums[rel]?.v ?? null;
}

// ── Status ──

const status: IndexStatus = {
  running: false,
  startedAt: null,
  finishedAt: null,
  foldersScanned: 0,
  albumsBuilt: 0,
  albumsSkipped: 0,
  tracksIndexed: 0,
  videosIndexed: 0,
  coversSaved: 0,
  postersSaved: 0,
  lyricsIndexed: 0,
  discographies: 0,
  currentPath: '',
  error: null,
};

export function getIndexStatus(): IndexStatus {
  return { ...status };
}

export type IndexReport = {
  albums: number; // albums with content (built + skipped)
  built: number;
  skipped: number;
  foldersScanned: number;
  tracksIndexed: number;
  videosIndexed: number;
  coversSaved: number;
  discographies: number;
  elapsedSec: number;
  error: string | null;
};

export function buildReport(s: IndexStatus = status): IndexReport {
  const elapsed = s.startedAt && s.finishedAt ? (s.finishedAt - s.startedAt) / 1000 : 0;
  return {
    albums: s.albumsBuilt + s.albumsSkipped,
    built: s.albumsBuilt,
    skipped: s.albumsSkipped,
    foldersScanned: s.foldersScanned,
    tracksIndexed: s.tracksIndexed,
    videosIndexed: s.videosIndexed,
    coversSaved: s.coversSaved,
    discographies: s.discographies,
    elapsedSec: Math.round(elapsed * 10) / 10,
    error: s.error,
  };
}

// ── Progress subscribers (for SSE) ──

type ProgressListener = (s: IndexStatus) => void;
const listeners = new Set<ProgressListener>();
let lastEmit = 0;
let lastLog = 0;

export function onIndexProgress(cb: ProgressListener): () => void {
  listeners.add(cb);
  return () => listeners.delete(cb);
}

/** Notify subscribers of progress; throttled to ~200ms unless `force` (e.g. terminal 'done'). */
function emitProgress(force = false): void {
  const now = Date.now();
  // Console heartbeat (throttled to 3s, independent of the SSE cadence) so a long build shows life.
  if (now - lastLog >= 3000) {
    lastLog = now;
    console.log(
      `[music] … ${status.foldersScanned} folders · ${status.albumsBuilt} built/${status.albumsSkipped} skipped · ` +
        `${status.tracksIndexed} tracks · ${status.videosIndexed} videos · ${status.postersSaved} posters · ` +
        `${status.lyricsIndexed} lyrics — ${status.currentPath}`,
    );
  }
  if (!force && now - lastEmit < 200) return;
  lastEmit = now;
  const snap = getIndexStatus();
  for (const cb of listeners) {
    try {
      cb(snap);
    } catch {
      /* ignore listener errors */
    }
  }
}

// ── Helpers ──

function isAudio(name: string): boolean {
  const dot = name.lastIndexOf('.');
  return dot >= 0 && AUDIO_EXT.has(name.slice(dot + 1).toLowerCase());
}

function isVideo(name: string): boolean {
  const dot = name.lastIndexOf('.');
  return dot >= 0 && VIDEO_EXT.has(name.slice(dot + 1).toLowerCase());
}

function isImage(name: string): boolean {
  const dot = name.lastIndexOf('.');
  return dot >= 0 && IMAGE_EXT.has(name.slice(dot + 1).toLowerCase());
}

/** Bounded-concurrency map preserving order. */
async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
  const out: R[] = new Array(items.length);
  let next = 0;
  const worker = async () => {
    while (true) {
      const i = next++;
      if (i >= items.length) break;
      out[i] = await fn(items[i]!);
    }
  };
  await Promise.all(Array.from({ length: Math.min(limit, items.length) || 0 }, worker));
  return out;
}

async function ffprobeTrack(absPath: string, file: string): Promise<{ track: IndexTrack; lyricsText?: string }> {
  try {
    const proc = Bun.spawn(
      [
        'ffprobe',
        '-v',
        'error',
        '-print_format',
        'json',
        // All format tags — so embedded lyrics (key varies: lyrics, lyrics-<lang>, unsyncedlyrics) come through.
        '-show_entries',
        'format=duration:format_tags',
        absPath,
      ],
      { stdout: 'pipe', stderr: 'ignore' },
    );
    const out = await new Response(proc.stdout).text();
    await proc.exited;
    const data = JSON.parse(out) as { format?: { duration?: string; tags?: Record<string, string> } };
    const fmt = data.format ?? {};
    // Tag keys vary in case across containers — normalize to lowercase.
    const tags: Record<string, string> = {};
    for (const [k, val] of Object.entries(fmt.tags ?? {})) tags[k.toLowerCase()] = val;
    const durNum = fmt.duration ? Math.round(parseFloat(fmt.duration)) : undefined;
    const yearMatch = tags.date?.match(/\d{4}/);
    // Disc / part-of-set from ID3 TPOS (ffprobe surfaces it as `disc`, "n" or "n/total").
    const discNum = tags.disc ? parseInt(tags.disc.split('/')[0]!.trim(), 10) : NaN;
    // Embedded lyrics: USLT/foobar keys — `lyrics`, `lyrics-<lang>`, `unsyncedlyrics`.
    let lyricsText: string | undefined;
    for (const [k, val] of Object.entries(tags)) {
      if (/^(lyrics|unsyncedlyrics)($|[-_])/.test(k) && val && val.trim()) {
        lyricsText = val;
        break;
      }
    }
    return {
      track: {
        file,
        title: tags.title || undefined,
        artist: tags.artist || undefined,
        albumArtist: tags.album_artist || tags.albumartist || undefined,
        album: tags.album || undefined,
        track: tags.track || undefined,
        disc: Number.isFinite(discNum) ? discNum : undefined,
        year: yearMatch ? yearMatch[0] : undefined,
        durationSec: Number.isFinite(durNum) ? durNum : undefined,
      },
      lyricsText,
    };
  } catch {
    return { track: { file } };
  }
}

async function ffprobeVideo(absPath: string, file: string): Promise<IndexVideo> {
  try {
    const proc = Bun.spawn(
      [
        'ffprobe',
        '-v',
        'error',
        '-print_format',
        'json',
        '-show_entries',
        'format=duration:format_tags=title:stream=codec_type,width,height',
        absPath,
      ],
      { stdout: 'pipe', stderr: 'ignore' },
    );
    const out = await new Response(proc.stdout).text();
    await proc.exited;
    const data = JSON.parse(out) as {
      format?: { duration?: string; tags?: Record<string, string> };
      streams?: Array<{ codec_type?: string; width?: number; height?: number }>;
    };
    const fmt = data.format ?? {};
    const tags: Record<string, string> = {};
    for (const [k, val] of Object.entries(fmt.tags ?? {})) tags[k.toLowerCase()] = val;
    const vstream = (data.streams ?? []).find((s) => s.codec_type === 'video');
    const durNum = fmt.duration ? Math.round(parseFloat(fmt.duration)) : undefined;
    return {
      file,
      title: tags.title || undefined,
      durationSec: Number.isFinite(durNum) ? durNum : undefined,
      width: vstream?.width,
      height: vstream?.height,
    };
  } catch {
    return { file };
  }
}

/** Compress a cover to <=COVER_MAX_PX (no upscaling), jpeg q5. Returns true on success. */
async function compressCover(srcAbs: string, destAbs: string): Promise<boolean> {
  try {
    const proc = Bun.spawn(
      [
        'ffmpeg',
        '-y',
        '-i',
        srcAbs,
        '-vf',
        `scale='min(iw,${COVER_MAX_PX})':'min(ih,${COVER_MAX_PX})':force_original_aspect_ratio=decrease`,
        '-q:v',
        '5',
        destAbs,
      ],
      { stdout: 'ignore', stderr: 'ignore' },
    );
    await proc.exited;
    return proc.exitCode === 0 && existsSync(destAbs);
  } catch {
    return false;
  }
}

/**
 * Poster = a representative frame, compressed like a cover. Seek ~10% in (capped at 30s) to skip
 * intros/title cards, then let ffmpeg's `thumbnail` filter pick the most representative frame from the
 * next ~300 (avoids black/uniform frames — e.g. a fade-in from black).
 */
async function generateVideoPoster(srcAbs: string, destAbs: string, durationSec?: number): Promise<boolean> {
  const ts = durationSec && durationSec > 0 ? Math.min(30, Math.max(1, Math.floor(durationSec * 0.1))) : 5;
  try {
    const proc = Bun.spawn(
      [
        'ffmpeg',
        '-y',
        '-ss',
        String(ts),
        '-i',
        srcAbs,
        '-vf',
        `thumbnail=n=300,scale='min(iw,${COVER_MAX_PX})':'min(ih,${COVER_MAX_PX})':force_original_aspect_ratio=decrease`,
        '-frames:v',
        '1',
        '-q:v',
        '5',
        destAbs,
      ],
      { stdout: 'ignore', stderr: 'ignore' },
    );
    await proc.exited;
    return proc.exitCode === 0 && existsSync(destAbs);
  } catch {
    return false;
  }
}

// ── Lyrics ──

const LRC_TIMESTAMP_RE = /^\s*\[\d{1,2}:\d{2}(?:[.:]\d{1,3})?\]/m;
const isLrc = (text: string): boolean => LRC_TIMESTAMP_RE.test(text);

/**
 * Resolve a track's lyrics and cache them at cache/<rel>/lyrics/<trackfile>.<lrc|txt>. Returns the
 * cached format, or null if none. Content (embedded or a .txt) that actually contains [mm:ss] lines is
 * treated as synced (`lrc`).
 *
 * Precedence — the file's OWN embedded lyrics are canonical, and SYNCED always beats plain:
 *   synced-embedded > synced-sidecar(.lrc) > plain-embedded > plain-sidecar(.txt)
 * So an mp3 whose synced lyrics are embedded (as LRC text in the USLT/`lyrics` tag) wins over leftover
 * sidecars, but a track that only has a *plain* embed still serves a synced `.lrc` until it's re-embedded
 * — no silent downgrade. (Binary SYLT frames aren't readable here; sync must live in the text tag.)
 */
async function resolveTrackLyrics(
  dirAbs: string,
  lyricsDir: string,
  trackFile: string,
  folderFiles: Set<string>,
  embedded: string | undefined,
): Promise<'lrc' | 'txt' | null> {
  const base = trackFile.replace(/\.[^.]+$/, '');

  type Candidate = { text: string; fmt: 'lrc' | 'txt'; embedded: boolean };
  const candidates: Candidate[] = [];
  if (embedded?.trim()) candidates.push({ text: embedded, fmt: isLrc(embedded) ? 'lrc' : 'txt', embedded: true });
  for (const name of [`${base}.lrc`, `${base}.txt`]) {
    if (!folderFiles.has(name)) continue;
    const text = await readFile(join(dirAbs, name), 'utf8').catch(() => null);
    if (text?.trim()) candidates.push({ text, fmt: isLrc(text) ? 'lrc' : 'txt', embedded: false });
  }
  if (!candidates.length) return null;

  // Rank: synced (0) before plain (2); within a tier, the file's own embedded copy (0) before a sidecar (1).
  const rank = (c: Candidate) => (c.fmt === 'lrc' ? 0 : 2) + (c.embedded ? 0 : 1);
  candidates.sort((a, b) => rank(a) - rank(b));
  const best = candidates[0]!;

  await mkdir(lyricsDir, { recursive: true });
  await writeFile(join(lyricsDir, `${trackFile}.${best.fmt}`), best.text);
  return best.fmt;
}

// ── Build ──

// Core build: writes a full or incremental index into `outRoot` (+ its manifest.json), then returns the
// built manifest. Does NOT touch the live serving state — the caller decides when/if it goes live.
// `prev` drives the incremental skip: a populated prev skips unchanged albums (in-place live build); an
// empty prev rebuilds everything (into a fresh staging slot for a full reindex).
async function runBuild(outRoot: string, prev: Manifest): Promise<Manifest> {
  Object.assign(status, {
    running: true,
    startedAt: Date.now(),
    finishedAt: null,
    foldersScanned: 0,
    albumsBuilt: 0,
    albumsSkipped: 0,
    tracksIndexed: 0,
    videosIndexed: 0,
    coversSaved: 0,
    postersSaved: 0,
    lyricsIndexed: 0,
    discographies: 0,
    currentPath: '',
    error: null,
  });
  console.log('[music] resync started');

  const next: Manifest = { version: CACHE_VERSION, generatedAt: status.startedAt!, albums: {} };

  try {
    await mkdir(outRoot, { recursive: true });
    walkFailures = 0;
    await walk(MUSIC_ROOT, prev, next, outRoot);

    // An incremental build survives an unreadable folder by carrying the previous entries forward (see
    // walk). A from-scratch build has no previous entries to carry — every unreadable folder is simply
    // absent from `next`, and publishing that slot would delete those albums from the live index for
    // real. Refuse instead: reindexFull discards a slot whose build errored and leaves the live index
    // alone, so a disk that hiccups during the nightly costs one skipped night, not a hole in the library.
    if (walkFailures > 0 && !Object.keys(prev.albums).length) {
      throw new Error(
        `${walkFailures} folder(s) unreadable during a from-scratch build — refusing to publish a partial index`,
      );
    }

    // Prune cache dirs for albums that vanished from the library (only meaningful when prev is populated).
    for (const rel of Object.keys(prev.albums)) {
      if (!next.albums[rel]) {
        await rm(join(outRoot, rel), { recursive: true, force: true }).catch(() => {});
      }
    }

    await writeFile(join(outRoot, 'manifest.json'), JSON.stringify(next));
  } catch (err) {
    status.error = err instanceof Error ? err.message : String(err);
  } finally {
    status.running = false;
    status.finishedAt = Date.now();
    status.currentPath = '';
    emitProgress(true); // final push — signals 'done' to SSE subscribers
    const r = buildReport();
    if (r.error) {
      console.error(`[music] resync failed after ${r.elapsedSec}s: ${r.error}`);
    } else {
      console.log(
        `[music] resync done — ${r.built} built, ${r.skipped} skipped, ${r.coversSaved} covers, ${r.tracksIndexed} tracks, ${r.videosIndexed} videos, ${status.lyricsIndexed} lyrics, ${r.foldersScanned} folders in ${r.elapsedSec}s`,
      );
    }
  }
  return next;
}

// ── Coalesced build entry point ──
// Builds are EXPLICIT only (POST /reindex, the SSE stream). Reads (/manifest, /meta, /cover, …) never
// trigger a build — a manifest fetch just returns the last completed index. Route all build triggers
// through reindexNow so concurrent callers join one in-flight build instead of stampeding.

// All index-mutating ops (full, incremental, localized) run one-at-a-time through this lock, so a
// localized reindex can never race a full reindex's atomic swap (which would corrupt the manifest).
let indexLock: Promise<unknown> = Promise.resolve();

// Folders the current build could not read for a reason other than "it is gone". Reset at the start of
// every runBuild; consulted at the end to decide whether a from-scratch index is safe to publish.
let walkFailures = 0;
function withIndexLock<T>(fn: () => Promise<T>): Promise<T> {
  const result = indexLock.then(fn, fn);
  indexLock = result.then(
    () => {},
    () => {},
  );
  return result;
}

/** Persist the live in-memory manifest to disk (via the cache symlink → live slot). */
async function persistManifest(): Promise<void> {
  manifest.generatedAt = Date.now();
  await writeFile(MANIFEST_PATH, JSON.stringify(manifest));
}

/**
 * Incremental, live: rebuild only changed albums (unchanged ones skip by version stamp), updating the
 * live cache + manifest in place. Fast — the manual reindex button uses this. The one exception is a
 * cache-format upgrade, which is delegated to the staged full rebuild below.
 */
export async function reindexNow(): Promise<IndexStatus> {
  // A cache written by an older CACHE_VERSION forces every album to rebuild (see `formatCurrent` in
  // buildFolder). Doing that through THIS path would rewrite all ~6k albums inside the LIVE cache, each
  // one rm'd and then regenerated, so for the ~40 minutes it runs a reader can hit an album whose files
  // are momentarily missing. The staged path does identical work into a fresh slot and swaps atomically,
  // so hand the format upgrade to it. Checked before withIndexLock — that lock is a plain promise chain
  // and is not reentrant, so calling reindexFull() inside it would deadlock.
  const current = await loadManifest();
  if (current.version !== CACHE_VERSION) {
    console.log(`[music] cache format v${current.version} → v${CACHE_VERSION}: routing to a staged rebuild`);
    return reindexFull();
  }

  return withIndexLock(async () => {
    const next = await runBuild(CACHE_ROOT, await loadManifest());
    if (!status.error) {
      manifest = next;
      manifestLoaded = true;
    }
    return getIndexStatus();
  });
}

type ManifestDelta = { added: string[]; removed: string[]; changed: string[] };

// Compare a from-scratch build against the index that was already live. This exists to answer one
// question with evidence rather than opinion: is the nightly full still FINDING anything, or is it
// spending 40 minutes of disk rebuilding what the incremental had already got right?
//
// A run that reports no differences did no useful work. A run of those is the case for retiring the
// nightly; a delta that keeps reappearing names the albums to go and look at.
//
// Compared field-by-field rather than by JSON.stringify, because two entries built by the same code
// can serialise with different key order (the optional fields are spread conditionally) and that would
// report drift where there is none.
function diffManifest(prev: Manifest, next: Manifest): ManifestDelta {
  const canon = (e: ManifestEntry) =>
    [e.v, e.cover, e.tracks, e.videos ?? 0, e.images ?? 0, e.disco ?? false, e.lyrics ?? 0, e.posters ?? 0].join('|');

  const added: string[] = [];
  const changed: string[] = [];
  for (const [rel, entry] of Object.entries(next.albums)) {
    const before = prev.albums[rel];
    if (!before) added.push(rel);
    else if (canon(before) !== canon(entry)) changed.push(rel);
  }
  const removed = Object.keys(prev.albums).filter((rel) => !next.albums[rel]);
  return { added, removed, changed };
}

function logManifestDelta(prev: Manifest, next: Manifest): void {
  // A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
  // nothing about drift. Label it rather than let it read as 6k albums of rot.
  if (prev.version !== next.version) {
    console.log(
      `[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`,
    );
  }

  const { added, removed, changed } = diffManifest(prev, next);
  const total = added.length + removed.length + changed.length;
  if (total === 0) {
    console.log('[music] full reindex delta: NONE — the incremental index was already identical');
    return;
  }

  console.log(
    `[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`,
  );
  const sample = (label: string, rels: string[]) => {
    for (const rel of rels.slice(0, 5)) console.log(`[music]   ${label} ${rel || '.'}`);
    if (rels.length > 5) console.log(`[music]   ${label} …and ${rels.length - 5} more`);
  };
  sample('+', added);
  sample('-', removed);
  sample('~', changed);
}

/**
 * Full, from scratch: build a complete index into a FRESH slot without touching the live one, then swap
 * it in atomically only on success — a failed rebuild leaves the live index untouched. For the nightly cron.
 */
export function reindexFull(): Promise<IndexStatus> {
  return withIndexLock(async () => {
    await ensureCacheSetup();
    // Read the live index BEFORE the swap — it is what the delta below is measured against.
    const live = await loadManifest();
    const slot = slotPath(String(Date.now()));
    await rm(slot, { recursive: true, force: true }).catch(() => {});
    // Empty prev ⇒ everything rebuilds into the fresh slot.
    const next = await runBuild(slot, { version: CACHE_VERSION, generatedAt: 0, albums: {} });
    if (status.error) {
      await rm(slot, { recursive: true, force: true }).catch(() => {});
      console.error('[music] full reindex failed — live index left untouched');
    } else {
      logManifestDelta(live, next);
      await activateSlot(slot);
      manifest = next;
      manifestLoaded = true;
      console.log('[music] full reindex swapped in as the live index');
    }
    return getIndexStatus();
  });
}

/**
 * Localized reindex of a single folder: rebuild just this album/artist's cache entry in the LIVE cache
 * and patch the manifest; a vanished folder is pruned. Serialized with the full/incremental builds so
 * it never races the atomic swap.
 *
 * CURRENTLY UNUSED. It was the filesystem watcher's unit of work until that was removed (see the note
 * in index.ts — the recursive watch could not fit in the inotify budget). Kept because it is the right
 * hook for a writer that already knows what it wrote: slskd, transmission or the download-media task
 * calling this with the one folder it just created is strictly cheaper and more accurate than either
 * watching 92k inodes or walking the whole tree.
 */
export function reindexFolder(rel: string, opts?: { recursive?: boolean }): Promise<void> {
  return withIndexLock(async () => {
    await loadManifest();
    const changed = await reindexInto(rel, opts?.recursive ?? false);
    if (changed) await persistManifest();
  });
}

// Reindex `rel` against the live manifest/cache. Always: rebuild rel's own entry and prune any manifest
// descendants whose top-level child dir has vanished from disk (catches a renamed/removed subdir when the
// caller only knows the new name, or only the parent). When `recursive`, also descends into each subdir —
// so a newly-appeared or renamed-in container's album children get indexed (a shallow reindex of a
// container is a no-op, since containers hold no audio of their own). Returns whether the manifest changed
// so the caller persists exactly once. Runs inside `withIndexLock` via reindexFolder.
async function reindexInto(rel: string, recursive: boolean): Promise<boolean> {
  const dirAbs = join(MUSIC_ROOT, rel);
  let entries: import('node:fs').Dirent[] | null = null;
  try {
    entries = await readdir(dirAbs, { withFileTypes: true });
  } catch {
    entries = null;
  }

  // Dir gone (or `rel` is a now-deleted file) → prune it and everything the manifest still has under it.
  if (!entries) return pruneSubtree(rel);

  let changed = false;
  const files = entries.filter((e) => e.isFile());
  const subdirs = entries.filter((e) => e.isDirectory());

  const entry = await buildFolder(dirAbs, rel, files, manifest.albums[rel], manifest.version, CACHE_ROOT);
  if (entry) {
    manifest.albums[rel] = entry;
    changed = true;
    console.log(`[music] watch: reindexed ${rel || '.'}`);
  } else if (manifest.albums[rel]) {
    delete manifest.albums[rel];
    await rm(join(CACHE_ROOT, rel), { recursive: true, force: true }).catch(() => {});
    changed = true;
    console.log(`[music] watch: cleared ${rel || '.'}`);
  }

  // Prune manifest keys under `rel/` whose immediate child dir no longer exists on disk.
  const names = new Set(subdirs.map((d) => d.name));
  const prefix = rel ? rel + '/' : '';
  for (const key of Object.keys(manifest.albums)) {
    if (key === rel || !key.startsWith(prefix)) continue;
    const seg = key.slice(prefix.length).split('/')[0]!;
    if (!names.has(seg)) {
      delete manifest.albums[key];
      await rm(join(CACHE_ROOT, key), { recursive: true, force: true }).catch(() => {});
      changed = true;
      console.log(`[music] watch: pruned ${key}`);
    }
  }

  if (recursive) {
    for (const d of subdirs) {
      if (await reindexInto(prefix + d.name, true)) changed = true;
    }
  }

  return changed;
}

// Remove `rel` and every manifest entry beneath it, dropping the cache subtree in one recursive rm.
// A no-op (returns false) when `rel` covers nothing indexed — e.g. a deleted loose file.
async function pruneSubtree(rel: string): Promise<boolean> {
  let changed = false;
  const prefix = rel ? rel + '/' : '';
  for (const key of Object.keys(manifest.albums)) {
    if (key === rel || key.startsWith(prefix)) {
      delete manifest.albums[key];
      changed = true;
    }
  }
  if (changed) {
    await rm(join(CACHE_ROOT, rel), { recursive: true, force: true }).catch(() => {});
    console.log(`[music] watch: removed ${rel || '.'}`);
  }
  return changed;
}

// Build ONE folder's cache entry (meta / cover / posters / lyrics / discography) into `outRoot`,
// honoring the per-folder version skip. Returns its manifest entry, or null if the folder holds nothing
// indexable. This is the single-folder unit shared by the full/incremental walk AND `reindexFolder`'s
// localized rebuild. `files` are the folder's file entries (dirs excluded); `prevEntry`/`prevVersion`
// drive the skip check.
async function buildFolder(
  dirAbs: string,
  rel: string,
  files: import('node:fs').Dirent[],
  prevEntry: ManifestEntry | undefined,
  prevVersion: number,
  outRoot: string,
): Promise<ManifestEntry | null> {
  const audio = files.filter((e) => isAudio(e.name)).map((e) => e.name);
  // Videos (concerts/clips): the folder is always an artist or album dir, so its rel is the video's
  // location. A folder with only videos still gets a meta.json.
  const video = files.filter((e) => isVideo(e.name)).map((e) => e.name);
  // Loose folder images (band photos, booklet scans) — everything but the cover file(s), served as-is.
  const image = files
    .filter((e) => isImage(e.name) && !COVER_FILES.includes(e.name))
    .map((e) => e.name)
    .sort();
  const coverName = COVER_FILES.find((c) => files.some((e) => e.name === c));
  const hasDisco = files.some((e) => e.name === DISCO_FILE);

  // External lyrics sidecars (.lrc/.txt) that match a track basename (a stray notes.txt won't count).
  const fileSet = new Set(files.map((e) => e.name));
  const audioBases = new Set(audio.map((n) => n.replace(/\.[^.]+$/, '')));
  const lyricsSidecars = files
    .map((e) => e.name)
    .filter((n) => {
      const m = /^(.*)\.(lrc|txt)$/i.exec(n);
      return m !== null && audioBases.has(m[1]!);
    });

  if (!(audio.length || video.length || image.length || coverName || hasDisco)) return null;

  {
    // Source signature → version (includes _discography.md so regenerating it bumps v — isolated from
    // the album meta/cover of the tracks underneath).
    const sigParts: string[] = [];
    for (const name of [...audio].sort()) {
      const st = await stat(join(dirAbs, name)).catch(() => null);
      if (st) sigParts.push(`${name}:${st.size}:${Math.round(st.mtimeMs)}`);
    }
    for (const name of [...video].sort()) {
      const st = await stat(join(dirAbs, name)).catch(() => null);
      if (st) sigParts.push(`vid:${name}:${st.size}:${Math.round(st.mtimeMs)}`);
    }
    for (const name of [...lyricsSidecars].sort()) {
      const st = await stat(join(dirAbs, name)).catch(() => null);
      if (st) sigParts.push(`lyr:${name}:${st.size}:${Math.round(st.mtimeMs)}`);
    }
    // Images are source files, so this makes v change when one is added/removed (client re-syncs via the
    // normal v-diff — no re-sync hack needed, unlike derived posters/embedded lyrics).
    for (const name of image) {
      const st = await stat(join(dirAbs, name)).catch(() => null);
      if (st) sigParts.push(`img:${name}:${st.size}:${Math.round(st.mtimeMs)}`);
    }
    if (coverName) {
      const st = await stat(join(dirAbs, coverName)).catch(() => null);
      if (st) sigParts.push(`cover:${coverName}:${st.size}:${Math.round(st.mtimeMs)}`);
    }
    if (hasDisco) {
      const st = await stat(join(dirAbs, DISCO_FILE)).catch(() => null);
      if (st) sigParts.push(`disco:${st.size}:${Math.round(st.mtimeMs)}`);
    }
    const v = Bun.hash(sigParts.join('|')).toString(16);
    const cacheDir = join(outRoot, rel);

    const coverJpg = join(cacheDir, 'cover.jpg');
    // Skip only if v matches AND every expected output already exists. Expect a cached cover ONLY if one
    // was successfully cached before (prev.cover) — a source cover that can't be decoded (a junk .jpg)
    // ends up cover:false and is never re-expected, so it doesn't rebuild every run.
    const expected = [
      audio.length || video.length || image.length ? 'meta.json' : null,
      prevEntry?.cover ? 'cover.jpg' : null,
      hasDisco ? 'discography.json' : null,
    ].filter((f): f is string => f !== null);
    // A build writes FIVE kinds of output; the three above are single files, lyrics/ and posters/ are
    // directories of them. Checking only the files meant a lost lyrics file or poster kept a matching `v`
    // and a passing existence check, so the album was skipped on every incremental forever and only the
    // nightly full restored it — the "inconsistencies the normal reindex misses". Compare counts instead.
    // `>=` deliberately: a missing output must rebuild, a stray extra one need not.
    const cachedFileCount = (name: string): number => {
      try {
        return readdirSync(join(cacheDir, name)).length;
      } catch {
        return 0;
      }
    };
    const outputsExist =
      expected.every((f) => existsSync(join(cacheDir, f))) &&
      cachedFileCount('lyrics') >= (prevEntry?.lyrics ?? 0) &&
      cachedFileCount('posters') >= (prevEntry?.posters ?? 0);

    // Only trust the per-album `v` skip once the cache is already at the current format — an older
    // CACHE_VERSION means new outputs (posters/lyrics) may be missing, so rebuild every folder once.
    const formatCurrent = prevVersion === CACHE_VERSION;
    // Carried forward untouched on a skip (nothing was rewritten, so the previous counts still describe
    // what is on disk); recomputed from what the build actually wrote below.
    let lyricsWritten = prevEntry?.lyrics ?? 0;
    let postersWritten = prevEntry?.posters ?? 0;
    if (formatCurrent && prevEntry?.v === v && outputsExist) {
      status.albumsSkipped += 1;
    } else {
      await mkdir(cacheDir, { recursive: true });

      // Clear any prior cached cover first, then regenerate from the current source (if any). Otherwise a
      // removed OR now-undecodable source cover leaves the old cover.jpg behind — which keeps being served
      // and reported as cover:true (the "deleted the folder image but it still shows" bug).
      await rm(coverJpg, { force: true }).catch(() => {});
      if (coverName) {
        const ok = await compressCover(join(dirAbs, coverName), coverJpg);
        if (ok) status.coversSaved += 1;
      }

      if (audio.length || video.length || image.length) {
        // Regenerate lyrics cache from scratch (drop any orphaned by removed tracks/sidecars).
        const lyricsDir = join(cacheDir, 'lyrics');
        await rm(lyricsDir, { recursive: true, force: true }).catch(() => {});
        const tracks = await mapPool(audio, TRACK_CONCURRENCY, async (name) => {
          const { track: t, lyricsText } = await ffprobeTrack(join(dirAbs, name), name);
          const fmt = await resolveTrackLyrics(dirAbs, lyricsDir, name, fileSet, lyricsText);
          if (fmt) {
            t.lyrics = fmt;
            status.lyricsIndexed += 1;
          }
          status.tracksIndexed += 1;
          emitProgress();
          return t;
        });
        // Regenerate posters from scratch (drop any orphaned by removed videos).
        const postersDir = join(cacheDir, 'posters');
        await rm(postersDir, { recursive: true, force: true }).catch(() => {});
        if (video.length) await mkdir(postersDir, { recursive: true });
        const videos = await mapPool(video, TRACK_CONCURRENCY, async (name) => {
          const vmeta = await ffprobeVideo(join(dirAbs, name), name);
          if (await generateVideoPoster(join(dirAbs, name), join(postersDir, `${name}.jpg`), vmeta.durationSec)) {
            vmeta.poster = `posters/${name}.jpg`;
            status.postersSaved += 1;
          }
          status.videosIndexed += 1;
          emitProgress();
          return vmeta;
        });
        // Both dirs were just rm'd and rebuilt, and each writer emits exactly one file per item it
        // reports (resolveTrackLyrics → `<track>.<fmt>`, the poster pass → `posters/<video>.jpg`), so
        // these counts are the on-disk file counts `outputsExist` will check against next run.
        lyricsWritten = tracks.filter((t) => t.lyrics).length;
        postersWritten = videos.filter((vm) => vm.poster).length;
        const meta: IndexMeta = {
          path: rel,
          cover: existsSync(coverJpg) ? 'cover.jpg' : undefined,
          tracks,
          ...(videos.length ? { videos } : {}),
          ...(image.length ? { images: image.map((file) => ({ file })) } : {}),
        };
        await writeFile(join(cacheDir, 'meta.json'), JSON.stringify(meta));
      }

      if (hasDisco) {
        try {
          const md = await readFile(join(dirAbs, DISCO_FILE), 'utf8');
          const disco = { artist: basename(dirAbs), albums: parseDiscography(md) };
          await writeFile(join(cacheDir, 'discography.json'), JSON.stringify(disco));
          status.discographies += 1;
        } catch {
          /* skip discography on failure */
        }
      }

      status.albumsBuilt += 1;
    }

    return {
      v,
      cover: existsSync(coverJpg),
      tracks: audio.length,
      ...(video.length ? { videos: video.length } : {}),
      ...(image.length ? { images: image.length } : {}),
      ...(hasDisco ? { disco: true } : {}),
      ...(lyricsWritten ? { lyrics: lyricsWritten } : {}),
      ...(postersWritten ? { posters: postersWritten } : {}),
    };
  }
}

async function walk(dirAbs: string, prev: Manifest, next: Manifest, outRoot: string): Promise<void> {
  status.currentPath = relative(MUSIC_ROOT, dirAbs) || '.';
  status.foldersScanned += 1;
  emitProgress();

  let entries: import('node:fs').Dirent[];
  try {
    entries = await readdir(dirAbs, { withFileTypes: true });
  } catch (err) {
    // "Gone" and "could not look" are NOT the same thing, and conflating them lost albums. Anything that
    // never enters `next` is pruned by runBuild — cache dir deleted, manifest key dropped — so a single
    // transient EIO on the library disk silently removed that folder AND its whole subtree from the
    // index. ENOENT/ENOTDIR really do mean deleted, so those still fall through to the prune. For every
    // other error keep what the last good build knew: an unreadable folder is left exactly as it was.
    const code = (err as NodeJS.ErrnoException).code;
    if (code === 'ENOENT' || code === 'ENOTDIR') return;

    walkFailures += 1;
    const relFailed = relative(MUSIC_ROOT, dirAbs);
    // '' at the root, whose prefix matches every key — if the Music root itself is unreadable the whole
    // previous manifest carries over, which is the only safe reading of "the disk did not answer".
    const prefix = relFailed ? `${relFailed}/` : '';
    let kept = 0;
    for (const [key, entry] of Object.entries(prev.albums)) {
      if (key === relFailed || key.startsWith(prefix)) {
        next.albums[key] = entry;
        kept += 1;
      }
    }
    console.error(`[music] could not read ${relFailed || '.'} (${code}) — kept ${kept} previous entrie(s)`);
    return;
  }
  const files = entries.filter((e) => e.isFile());
  const subdirs = entries.filter((e) => e.isDirectory());
  const rel = relative(MUSIC_ROOT, dirAbs); // '' at root
  const entry = await buildFolder(dirAbs, rel, files, prev.albums[rel], prev.version, outRoot);
  if (entry) next.albums[rel] = entry;
  for (const d of subdirs) await walk(join(dirAbs, d.name), prev, next, outRoot);
}

// ── Serving helpers (path-safe within CACHE_ROOT) ──

function resolveCachePath(rel: string, file: string): string | null {
  const clean = rel.replace(/^\/+/, '');
  const abs = join(CACHE_ROOT, clean, file);
  if (abs !== CACHE_ROOT && !abs.startsWith(CACHE_ROOT + '/')) return null;
  return abs;
}

export function metaFilePath(rel: string): string | null {
  return resolveCachePath(rel, 'meta.json');
}
export function coverFilePath(rel: string): string | null {
  return resolveCachePath(rel, 'cover.jpg');
}
export function discographyFilePath(rel: string): string | null {
  return resolveCachePath(rel, 'discography.json');
}
/** A video's poster: cache/<rel>/posters/<file>.jpg. `file` is basename'd to keep it within the folder. */
export function posterFilePath(rel: string, file: string): string | null {
  return resolveCachePath(rel, join('posters', `${basename(file)}.jpg`));
}
/** A track's cached lyrics: cache/<rel>/lyrics/<file>.<lrc|txt>. `file` basename'd for path-safety. */
export function lyricsFilePath(rel: string, file: string, fmt: 'lrc' | 'txt'): string | null {
  return resolveCachePath(rel, join('lyrics', `${basename(file)}.${fmt}`));
}
