import { createRouter } from '@@/create-router';
import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
import { resolveHomeDir } from '@@/user-home';
import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readSttConfig } from '@@/api/server-settings/stt';
import { readOcrConfig } from '@@/api/server-settings/ocr';
import { transcribeAudio } from '@@/api/stt/transcribe';
import { getUserSettings } from 'officerdb';

async function getUserTtsVoice(userId: number): Promise<string | null> {
  try {
    const settings = (await getUserSettings(userId)) as { tts?: { voice?: string | null } };
    return settings.tts?.voice ?? null;
  } catch {}
  return null;
}

const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];

async function cleanOldCacheDirs(userDataDir: string) {
  for (const dir of OLD_CACHE_DIRS) {
    const target = join(userDataDir, dir);
    if (existsSync(target)) await rm(target, { recursive: true, force: true });
  }
}

// `seedHomeDir` used to be here, creating Downloads/Documents/Music/Videos/Pictures on the first listing of
// any home. Removed 2026-08-11: it invented folders in somebody's home directory as a side effect of LOOKING
// at it, which is not a listing's business and not a layout the platform has any standing to choose.

export const router = createRouter();

/**
 * Resolve whose home this request may touch, once, before any handler runs.
 *
 * A middleware rather than a change to `getRootDir`'s signature because that function is called from
 * fifteen places in this file. Making it async would have meant editing fifteen call sites, and the
 * failure mode of missing one is the worst available: a handler that quietly serves the OWNER'S home to a
 * member. Resolving here means a handler cannot run without the answer.
 *
 * The `user-data` root is untouched by this — it is already keyed on the caller's own email and holds
 * platform-written data rather than anything executable.
 */
router.use(async (ctx, next) => {
  const user = ctx.get('user');
  const resolved = await resolveHomeDir(user.id as number);
  if (!resolved.ok) {
    throw resolved.needsOsAccount
      ? errors.FORBIDDEN(`Files are not available for this account: ${resolved.reason}.`)
      : errors.FORBIDDEN(resolved.reason);
  }
  ctx.set('user', { ...user, homeDir: resolved.home });
  return next();
});

/**
 * `homeDir` is put on the context user by `confineToHome` below, so the fifteen-odd call sites of
 * `getRootDir` keep working unchanged and none of them can forget to resolve it.
 */
type UserCtx = { email: string; homeDir?: string };

function getUserDataDir(email: string): string {
  return join(DATA_PATH, email);
}

export function getRootDir(user: UserCtx, root?: string): string {
  // `user.homeDir` is set for every request that reached a handler — the middleware refuses the request
  // otherwise. The fallback exists only for the owner-shaped callers that construct a UserCtx by hand;
  // it is NOT a "member without an OS account gets the owner's home" path, because such a request never
  // gets this far. See user-home.ts for why that distinction is the whole point.
  if (!root || root === 'home') return user.homeDir ?? getOwnerHomeDir(user.email);
  if (root === 'user-data') return getUserDataDir(user.email);
  throw errors.BAD_REQUEST(`Invalid root: ${root}`);
}

// A resolved path counts as contained only when it IS the root or sits beneath it. A bare
// startsWith also accepts a sibling whose name merely begins with the root's — `/home/br-backup`
// passes a `/home/br` check — which is how `..` segments escaped.
const isInside = (root: string, target: string): boolean => target === root || target.startsWith(root + sep);

export function resolveUserPath(rootDir: string, relPath: string): string {
  const resolved = resolve(rootDir, relPath.replace(/^\/+/, ''));
  if (!isInside(rootDir, resolved)) throw errors.FORBIDDEN('Path outside root directory');
  return resolved;
}

// mkv `title` / mp4 `handler_name` hold a track's name; the generic "…Handler" defaults are ignored.
function trackName(tags?: { title?: string; handler_name?: string }): string {
  const handler = tags?.handler_name ?? '';
  return tags?.title || (handler && !/Handler$/.test(handler) ? handler : '');
}

// ffprobe bit_rate (bits/s, as a string) → rounded kbps, or null when the container doesn't report it.
function kbps(bitRate?: string): number | null {
  const n = Number(bitRate);
  return Number.isFinite(n) && n > 0 ? Math.round(n / 1000) : null;
}

// Serve a video with a chosen audio track selected: fast `-c copy` remux (video untouched, other
// audio dropped) cached under the user's data dir, so it streams with byte-range seeking like /raw.
// In-flight remuxes are shared so concurrent requests for the same track don't race on the temp file.
const audioRemuxInFlight = new Map<string, Promise<string>>();

async function ensureAudioRemux(email: string, absPath: string, relPath: string, track: number): Promise<string> {
  const parsed = parsePath(relPath);
  const ext = (parsed.ext.slice(1) || 'mp4').toLowerCase();
  const sub = parsed.dir ? `${parsed.dir}/` : '';
  // ffmpeg picks the output muxer from the file extension, so both the final and temp names must
  // keep the real extension (a ".tmp" suffix makes ffmpeg fail with "unable to choose format").
  const base = resolve(getUserDataDir(email), `cache/audio/${sub}${parsed.name}.a${track}`);
  const cacheAbs = `${base}.${ext}`;
  if (existsSync(cacheAbs)) return cacheAbs;

  const pending = audioRemuxInFlight.get(cacheAbs);
  if (pending) return pending;

  const job = (async () => {
    await mkdir(dirname(cacheAbs), { recursive: true });
    const tmp = `${base}.tmp.${ext}`;
    const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : [];
    const proc = Bun.spawn(
      [
        'ffmpeg',
        '-v',
        'error',
        '-i',
        absPath,
        '-map',
        '0:v',
        '-map',
        `0:a:${track}`,
        '-c',
        'copy',
        '-dn',
        '-sn',
        ...movflags,
        '-y',
        tmp,
      ],
      { stdout: 'ignore', stderr: 'pipe' },
    );
    const code = await proc.exited;
    if (code !== 0) {
      const err = await new Response(proc.stderr).text();
      await rm(tmp, { force: true }).catch(() => {});
      throw errors.BAD_REQUEST(err.trim() || 'Audio track remux failed');
    }
    await rename(tmp, cacheAbs);
    return cacheAbs;
  })();

  audioRemuxInFlight.set(cacheAbs, job);
  try {
    return await job;
  } finally {
    audioRemuxInFlight.delete(cacheAbs);
  }
}

// List directory entries
router.get('/ls', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
  const absPath = resolveUserPath(rootDir, relPath);

  // Create the home root itself if it is missing, and nothing else. A listing that invents its own contents
  // is a listing you cannot trust — the folder set it used to seed is gone.
  //
  // Non-fatal: a member's home is theirs, so this can raise EPERM, and `readdir` below is the real test of
  // whether the directory can be used.
  if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
    try {
      await mkdir(absPath, { recursive: true });
    } catch {
      // Either it exists, or it is not ours to create.
    }
  }

  // Remove old top-level cache dirs (migrated to cache/ prefix)
  if (ctx.req.query('root') === 'user-data') {
    await cleanOldCacheDirs(rootDir);
  }

  let names: string[];
  try {
    names = await readdir(absPath);
  } catch (ex) {
    // A missing directory resets the browser to the root, which is the right answer for a stale path.
    //
    // A PERMISSION failure is not that, and conflating them cost an afternoon: a member's home is 700 and
    // theirs, so before the ACL grant in os-user.ts the platform's readdir raised EACCES here and this
    // returned an empty listing — the UI said "This folder is empty" over five directories that existed.
    // An empty result is data; it should never be how a refusal looks.
    if ((ex as { code?: string }).code === 'EACCES' || (ex as { code?: string }).code === 'EPERM') {
      throw errors.FORBIDDEN(
        `Officer cannot read ${relPath || 'this folder'}. If this is a member's home, its access control ` +
          `lists are missing — reprovision the Linux account from Settings → User management.`,
      );
    }
    return ctx.json({ path: '/', entries: [], reset: true });
  }
  const entries = await Promise.all(
    names.map(async (name) => {
      const fullPath = resolve(absPath, name);
      // Skip entries that escape the home dir (shouldn't happen but be safe)
      if (!isInside(rootDir, fullPath)) return null;
      const s = await stat(fullPath).catch(() => null);
      if (!s) return null;
      return {
        name,
        type: s.isDirectory() ? 'directory' : 'file',
        size: s.size,
        modifiedAt: s.mtimeMs,
      };
    }),
  );

  const path = '/' + absPath.slice(rootDir.length).replace(/^\/+/, '');
  return ctx.json({ path, rootDir, entries: entries.filter(Boolean) });
});

// Read file contents
router.get('/read', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
  if (!relPath) throw errors.BAD_REQUEST('path is required');

  const absPath = resolveUserPath(rootDir, relPath);
  const s = await stat(absPath);
  if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read a directory');

  const MAX_TEXT_SIZE = 5 * 1024 * 1024; // 5 MB
  if (s.size > MAX_TEXT_SIZE) throw errors.BAD_REQUEST('File too large to read (max 5 MB)');

  const content = await readFile(absPath, 'utf-8');
  return ctx.json({ content, size: s.size });
});

// Write file contents
router.post('/write', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const { path, content } = ctx.get('body') as { path: string; content: string };
  if (!path) throw errors.BAD_REQUEST('path is required');
  if (typeof content !== 'string') throw errors.BAD_REQUEST('content must be a string');

  const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
  if (new TextEncoder().encode(content).length > MAX_SIZE) throw errors.BAD_REQUEST('Content too large (max 5 MB)');

  const absPath = resolveUserPath(rootDir, path);
  await mkdir(dirname(absPath), { recursive: true });
  await Bun.write(absPath, content);
  return ctx.json({ ok: true });
});

// Create directory
router.post('/mkdir', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const { path } = ctx.get('body') as { path: string };
  if (!path) throw errors.BAD_REQUEST('path is required');

  const absPath = resolveUserPath(rootDir, path);
  await mkdir(absPath, { recursive: true });
  return ctx.json({ ok: true });
});

// Upload files
router.post('/upload', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
  const targetDir = resolveUserPath(rootDir, relPath);

  await mkdir(targetDir, { recursive: true });

  const body = ctx.get('body') as Record<string, unknown>;
  const raw = body['file'];
  const files = Array.isArray(raw) ? raw : raw ? [raw] : [];

  for (const file of files) {
    if (!(file instanceof File)) continue;
    const filePath = resolve(targetDir, file.name);
    if (!isInside(rootDir, filePath)) continue;
    await mkdir(dirname(filePath), { recursive: true });
    await Bun.write(filePath, file);
  }

  return ctx.json({ ok: true });
});

// Rename file or directory
router.post('/rename', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const { path: filePath, newName } = ctx.get('body') as { path: string; newName: string };
  if (!filePath || !newName) throw errors.BAD_REQUEST('path and newName are required');
  if (newName.includes('/')) throw errors.BAD_REQUEST('newName must not contain /');

  const absPath = resolveUserPath(rootDir, filePath);
  if (absPath === rootDir) throw errors.FORBIDDEN('Cannot rename home directory');

  const newPath = resolve(dirname(absPath), newName);
  if (!isInside(rootDir, newPath)) throw errors.FORBIDDEN('Path outside home directory');

  await rename(absPath, newPath);
  return ctx.json({ ok: true });
});

// Serve raw file (binary-safe, for audio/video/images/download)
// Supports Range requests for audio/video seeking
router.get('/raw', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
  if (!relPath) throw errors.BAD_REQUEST('path is required');

  const absPath = resolveUserPath(rootDir, relPath);
  const s = await stat(absPath);
  if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot serve a directory');

  // Optional: serve with a specific audio track selected (track 0 is the default → serve raw).
  let fileAbs = absPath;
  const audioParam = ctx.req.query('audio');
  if (audioParam) {
    const track = parseInt(audioParam, 10);
    if (Number.isInteger(track) && track > 0) fileAbs = await ensureAudioRemux(user.email, absPath, relPath, track);
  }

  const file = Bun.file(fileAbs);
  const contentType = file.type || 'application/octet-stream';
  const total = file.size;

  const rangeHeader = ctx.req.header('range');
  if (rangeHeader) {
    const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
    if (match) {
      const start = match[1] ? parseInt(match[1], 10) : 0;
      const end = match[2] ? parseInt(match[2], 10) : total - 1;
      const chunkSize = end - start + 1;
      const slice = file.slice(start, end + 1);

      return new Response(slice, {
        status: 206,
        headers: {
          'Content-Type': contentType,
          'Content-Range': `bytes ${start}-${end}/${total}`,
          'Content-Length': String(chunkSize),
          'Accept-Ranges': 'bytes',
        },
      });
    }
  }

  return new Response(file, {
    headers: {
      'Content-Type': contentType,
      'Content-Length': String(total),
      'Accept-Ranges': 'bytes',
    },
  });
});

// List a video's text-based subtitle tracks (for the in-browser player's selector)
const TEXT_SUBTITLE_CODECS = new Set([
  'subrip',
  'srt',
  'ass',
  'ssa',
  'mov_text',
  'webvtt',
  'text',
  'subviewer',
  'microdvd',
]);

router.get('/subtitles', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
  if (!relPath) throw errors.BAD_REQUEST('path is required');
  const absPath = resolveUserPath(rootDir, relPath);

  const proc = Bun.spawn(
    [
      'ffprobe',
      '-v',
      'error',
      '-select_streams',
      's',
      '-show_entries',
      'stream=codec_name:stream_tags=language,title,handler_name',
      '-of',
      'json',
      absPath,
    ],
    { stdout: 'pipe', stderr: 'ignore' },
  );
  const out = await new Response(proc.stdout).text();
  await proc.exited;

  type ProbeStream = { codec_name?: string; tags?: { language?: string; title?: string; handler_name?: string } };
  let streams: ProbeStream[] = [];
  try {
    streams = (JSON.parse(out).streams as ProbeStream[]) ?? [];
  } catch {
    streams = [];
  }

  // mkv stores the track name in `title`; mp4/mov stores it in `handler_name` (default names like
  // "SubtitleHandler" are generic and ignored).
  const trackName = (tags: ProbeStream['tags']) => {
    const handler = tags?.handler_name ?? '';
    return tags?.title || (handler && !/Handler$/.test(handler) ? handler : '');
  };

  // `id` is the subtitle-relative index among ALL subtitle streams (what `-map 0:s:id` expects),
  // so it is assigned before filtering out image-based tracks that can't become WebVTT.
  const tracks = streams
    .map((s, id) => ({ id, s }))
    .filter(({ s }) => TEXT_SUBTITLE_CODECS.has((s.codec_name ?? '').toLowerCase()))
    .map(({ id, s }) => ({ id, codec: s.codec_name ?? '', lang: s.tags?.language ?? '', title: trackName(s.tags) }));

  return ctx.json(tracks);
});

// Extract one subtitle track as WebVTT for a <track> element
router.get('/subtitle-vtt', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
  if (!relPath) throw errors.BAD_REQUEST('path is required');
  const track = parseInt(ctx.req.query('track') ?? '', 10);
  if (!Number.isInteger(track) || track < 0) throw errors.BAD_REQUEST('valid track is required');
  const absPath = resolveUserPath(rootDir, relPath);

  const proc = Bun.spawn(['ffmpeg', '-v', 'error', '-i', absPath, '-map', `0:s:${track}`, '-f', 'webvtt', 'pipe:1'], {
    stdout: 'pipe',
    stderr: 'ignore',
  });

  return new Response(proc.stdout as ReadableStream, {
    headers: { 'Content-Type': 'text/vtt; charset=utf-8' },
  });
});

// List a video's audio tracks (for the external audio-track selector; served via raw?audio=N)
router.get('/audio-tracks', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
  if (!relPath) throw errors.BAD_REQUEST('path is required');
  const absPath = resolveUserPath(rootDir, relPath);

  const proc = Bun.spawn(
    [
      'ffprobe',
      '-v',
      'error',
      '-select_streams',
      'a',
      '-show_entries',
      'stream=channels,codec_name,bit_rate:stream_tags=language,title,handler_name',
      '-of',
      'json',
      absPath,
    ],
    { stdout: 'pipe', stderr: 'ignore' },
  );
  const out = await new Response(proc.stdout).text();
  await proc.exited;

  type ProbeAudio = {
    channels?: number;
    codec_name?: string;
    bit_rate?: string;
    tags?: { language?: string; title?: string; handler_name?: string };
  };
  let streams: ProbeAudio[] = [];
  try {
    streams = (JSON.parse(out).streams as ProbeAudio[]) ?? [];
  } catch {
    streams = [];
  }

  const tracks = streams.map((s, id) => ({
    id,
    codec: s.codec_name ?? '',
    channels: s.channels ?? 0,
    bitrate: kbps(s.bit_rate),
    lang: s.tags?.language ?? '',
    title: trackName(s.tags),
  }));

  return ctx.json(tracks);
});

// Read-only audio metadata for the Get Lyrics run-task panel: title/artist/duration + whether the file
// already has embedded lyrics. Tolerant of missing tags / probe failures (defaults to empty/0/false).
router.get('/audio-meta', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
  if (!relPath) throw errors.BAD_REQUEST('path is required');
  const absPath = resolveUserPath(rootDir, relPath);

  // Format tags + duration.
  const fmtProc = Bun.spawn(
    [
      'ffprobe',
      '-v',
      'error',
      '-show_entries',
      'format=duration:format_tags=title,artist,album,TITLE,ARTIST',
      '-of',
      'json',
      absPath,
    ],
    { stdout: 'pipe', stderr: 'ignore' },
  );
  const fmtOut = await new Response(fmtProc.stdout).text();
  await fmtProc.exited;

  let format: { duration?: string; tags?: Record<string, string> } = {};
  try {
    format = (JSON.parse(fmtOut) as { format?: typeof format }).format ?? {};
  } catch {
    format = {};
  }
  const tags: Record<string, string> = {};
  for (const [k, val] of Object.entries(format.tags ?? {})) tags[k.toLowerCase()] = val; // ID3 case varies
  const durNum = format.duration ? parseFloat(format.duration) : 0;

  // ID3 lyrics frames (USLT/SYLT) don't reliably surface in format_tags — probe stream+format tags and
  // flag lyrics if any key matches USLT/SYLT/lyrics (case-insensitive; some muxers emit `lyrics-XXX`).
  const lyrProc = Bun.spawn(
    ['ffprobe', '-v', 'error', '-show_entries', 'stream_tags:format_tags', '-of', 'json', absPath],
    { stdout: 'pipe', stderr: 'ignore' },
  );
  const lyrOut = await new Response(lyrProc.stdout).text();
  await lyrProc.exited;

  let hasLyrics = false;
  try {
    const data = JSON.parse(lyrOut) as {
      format?: { tags?: Record<string, string> };
      streams?: Array<{ tags?: Record<string, string> }>;
    };
    const keys = [
      ...Object.keys(data.format?.tags ?? {}),
      ...(data.streams ?? []).flatMap((s) => Object.keys(s.tags ?? {})),
    ];
    hasLyrics = keys.some((k) => /uslt|sylt|lyrics/i.test(k));
  } catch {
    hasLyrics = false;
  }

  return ctx.json({
    title: tags.title ?? '',
    artist: tags.artist ?? '',
    duration: Number.isFinite(durNum) ? durNum : 0,
    hasLyrics,
  });
});

// Recursively probe a folder's videos and group them by track layout, so the task runner can offer
// one set of audio/subtitle pickers for a whole season when every episode matches — and flag the
// odd files out when they don't. Two files "match" when their audio (language + channel count) and
// subtitle (language) streams line up in order; per-episode titles are ignored (they always differ).
const VIDEO_EXTENSIONS = new Set([
  'mp4',
  'mkv',
  'webm',
  'mov',
  'avi',
  'wmv',
  'flv',
  'm4v',
  'mpg',
  'mpeg',
  'ts',
  'm2ts',
  'mts',
  '3gp',
  'ogv',
  'vob',
  'divx',
  'asf',
  'f4v',
  'rm',
  'rmvb',
]);

type FolderAudioTrack = {
  id: number;
  codec: string;
  channels: number;
  bitrate: number | null;
  lang: string;
  title: string;
};
type FolderSubtitleTrack = { id: number; codec: string; lang: string; title: string };
type ProbedTracks = { audio: FolderAudioTrack[]; subtitle: FolderSubtitleTrack[] };

async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
  const proc = Bun.spawn(
    [
      'ffprobe',
      '-v',
      'error',
      '-show_entries',
      'stream=codec_type,codec_name,channels,bit_rate:stream_tags=language,title,handler_name',
      '-of',
      'json',
      absPath,
    ],
    { stdout: 'pipe', stderr: 'ignore' },
  );
  const out = await new Response(proc.stdout).text();
  await proc.exited;

  type ProbeStream = {
    codec_type?: string;
    codec_name?: string;
    channels?: number;
    bit_rate?: string;
    tags?: { language?: string; title?: string; handler_name?: string };
  };
  let streams: ProbeStream[] = [];
  try {
    streams = (JSON.parse(out).streams as ProbeStream[]) ?? [];
  } catch {
    streams = [];
  }

  const audio = streams
    .filter((s) => s.codec_type === 'audio')
    .map((s, id) => ({
      id,
      codec: s.codec_name ?? '',
      channels: s.channels ?? 0,
      bitrate: kbps(s.bit_rate),
      lang: s.tags?.language ?? '',
      title: trackName(s.tags),
    }));
  // subtitle `id` is the index among ALL subtitle streams (what `-map 0:s:id` expects), assigned
  // before filtering out image-based tracks that can't become soft subs.
  const subtitle = streams
    .filter((s) => s.codec_type === 'subtitle')
    .map((s, id) => ({ id, codec: s.codec_name ?? '', lang: s.tags?.language ?? '', title: trackName(s.tags) }))
    .filter((t) => TEXT_SUBTITLE_CODECS.has(t.codec.toLowerCase()));

  return { audio, subtitle };
}

const layoutSignature = (t: ProbedTracks) =>
  `A:${t.audio.map((a) => `${a.lang || 'und'}:${a.channels}`).join(',')}|S:${t.subtitle.map((s) => s.lang || 'und').join(',')}`;

router.get('/probe-folder', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
  if (!relPath) throw errors.BAD_REQUEST('path is required');
  const absPath = resolveUserPath(rootDir, relPath);

  let entries: string[] = [];
  try {
    entries = await readdir(absPath, { recursive: true });
  } catch {
    entries = [];
  }
  const files = entries
    .filter((p) => VIDEO_EXTENSIONS.has(p.split('.').pop()?.toLowerCase() ?? ''))
    .sort((a, b) => a.localeCompare(b));

  // Probe in small batches so a big season doesn't spawn dozens of ffprobes at once.
  const CONCURRENCY = 8;
  const probed: { file: string; tracks: ProbedTracks }[] = [];
  for (let i = 0; i < files.length; i += CONCURRENCY) {
    const batch = files.slice(i, i + CONCURRENCY);
    const results = await Promise.all(
      batch.map(async (file) => ({
        file,
        tracks: await probeVideoTracks(resolveUserPath(rootDir, join(relPath, file))),
      })),
    );
    probed.push(...results);
  }

  type Group = {
    signature: string;
    files: string[];
    audioTracks: FolderAudioTrack[];
    subtitleTracks: FolderSubtitleTrack[];
  };
  const groupsMap = new Map<string, Group>();
  for (const { file, tracks } of probed) {
    const sig = layoutSignature(tracks);
    let group = groupsMap.get(sig);
    if (!group) {
      group = { signature: sig, files: [], audioTracks: tracks.audio, subtitleTracks: tracks.subtitle };
      groupsMap.set(sig, group);
    }
    group.files.push(file);
  }
  const groups = [...groupsMap.values()]
    .map((g) => ({ ...g, count: g.files.length }))
    .sort((a, b) => b.count - a.count || (a.files[0] ?? '').localeCompare(b.files[0] ?? ''));

  return ctx.json({ fileCount: files.length, groups });
});

// Save a cached result (ocr/tts/transcriptions/audio) next to the original file
const CACHE_PREFIXES = ['cache/tts/', 'cache/audio/'];

router.post('/save-result', async (ctx) => {
  const user = ctx.get('user');
  const { path: cachedPath } = ctx.get('body') as { path: string };
  if (!cachedPath) throw errors.BAD_REQUEST('path is required');

  const prefix = CACHE_PREFIXES.find((p) => cachedPath.startsWith(p));
  if (!prefix) throw errors.BAD_REQUEST('Not a cached result path');

  let relativePath = cachedPath.slice(prefix.length);
  // TTS cache includes a voice subdirectory (e.g. am_liam/) — strip it
  if (prefix === 'cache/tts/') {
    const slashIdx = relativePath.indexOf('/');
    if (slashIdx !== -1) relativePath = relativePath.slice(slashIdx + 1);
  }
  // Strip any nested cache prefixes (e.g. tts/ocr/photos/file.mp3 → photos/file.mp3)
  let nested: string | undefined;
  while ((nested = CACHE_PREFIXES.find((p) => relativePath.startsWith(p)))) {
    relativePath = relativePath.slice(nested.length);
  }
  const userDataDir = getUserDataDir(user.email);
  const srcAbs = resolve(userDataDir, cachedPath);
  if (!existsSync(srcAbs)) throw errors.BAD_REQUEST('Cached file not found');

  const homeDir = getRootDir(user, 'home');
  const destAbs = resolve(homeDir, relativePath);
  if (!isInside(homeDir, destAbs)) throw errors.FORBIDDEN('Path outside home directory');

  await mkdir(dirname(destAbs), { recursive: true });
  await cp(srcAbs, destAbs);

  const destPath = '/' + destAbs.slice(homeDir.length).replace(/^\/+/, '');
  return ctx.json({ savedPath: destPath });
});

// Text-to-speech with caching
router.post('/tts', async (ctx) => {
  const user = ctx.get('user');
  const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean };
  if (!filePath) throw errors.BAD_REQUEST('path is required');

  const rootDir = getRootDir(user, root);
  const absPath = resolveUserPath(rootDir, filePath);
  const s = await stat(absPath);
  if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory');

  const ttsConfig = await readTtsConfig();
  if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');

  const userVoice = await getUserTtsVoice(user.id);
  const voice = userVoice ?? ttsConfig.voice;

  const userDataDir = getUserDataDir(user.email);
  const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
  const voicePrefix = `${voice}/`;
  const cacheRel = dir ? `cache/tts/${voicePrefix}${dir}/${name}.mp3` : `cache/tts/${voicePrefix}${name}.mp3`;
  const cacheAbs = resolve(userDataDir, cacheRel);

  if (saveNextTo) {
    const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`;
    const siblingAbs = resolveUserPath(rootDir, siblingRel);
    if (existsSync(siblingAbs)) {
      return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: true });
    }
  }

  if (existsSync(cacheAbs)) {
    if (saveNextTo) {
      const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`;
      const siblingAbs = resolveUserPath(rootDir, siblingRel);
      const cached = await readFile(cacheAbs);
      await Bun.write(siblingAbs, cached);
      return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: true });
    }
    return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
  }

  const content = await readFile(absPath, 'utf-8');

  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  let res: Response;

  if (ttsConfig.provider === 'elevenlabs') {
    if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
    res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
      body: JSON.stringify({ text: content, model_id: ttsConfig.model }),
    });
  } else {
    if (ttsConfig.apiKey) headers['Authorization'] = `Bearer ${ttsConfig.apiKey}`;
    res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ model: ttsConfig.model, input: content, voice, response_format: 'mp3' }),
    });
  }
  if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');

  await mkdir(dirname(cacheAbs), { recursive: true });
  const buffer = await res.arrayBuffer();
  await Bun.write(cacheAbs, buffer);

  if (saveNextTo) {
    const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`;
    const siblingAbs = resolveUserPath(rootDir, siblingRel);
    await Bun.write(siblingAbs, buffer);
    return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: false });
  }

  return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
});

// Text-to-speech from raw text with caching
router.post('/tts-text', async (ctx) => {
  const user = ctx.get('user');
  const { text, id } = ctx.get('body') as { text: string; id: string };
  if (!text) throw errors.BAD_REQUEST('text is required');
  if (!id) throw errors.BAD_REQUEST('id is required');

  const ttsConfig = await readTtsConfig();
  if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');

  const userVoice = await getUserTtsVoice(user.id);
  const voice = userVoice ?? ttsConfig.voice;

  const userDataDir = getUserDataDir(user.email);
  const cacheRel = `cache/tts/chat/${voice}/${id}.mp3`;
  const cacheAbs = resolve(userDataDir, cacheRel);

  if (existsSync(cacheAbs)) {
    return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
  }

  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  let res: Response;

  if (ttsConfig.provider === 'elevenlabs') {
    if (!ttsConfig.apiKey) throw errors.BAD_REQUEST('ElevenLabs API key not configured');
    res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'xi-api-key': ttsConfig.apiKey },
      body: JSON.stringify({ text, model_id: ttsConfig.model }),
    });
  } else {
    if (ttsConfig.apiKey) headers['Authorization'] = `Bearer ${ttsConfig.apiKey}`;
    res = await fetch(`${ttsConfig.url.replace(/\/+$/, '')}/v1/audio/speech`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ model: ttsConfig.model, input: text, voice, response_format: 'mp3' }),
    });
  }
  if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');

  await mkdir(dirname(cacheAbs), { recursive: true });
  const buffer = await res.arrayBuffer();
  await Bun.write(cacheAbs, buffer);

  return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
});

// OCR image via vision model with caching
router.post('/ocr', async (ctx) => {
  const user = ctx.get('user');
  const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean };
  if (!filePath) throw errors.BAD_REQUEST('path is required');

  const rootDir = getRootDir(user, root);
  const absPath = resolveUserPath(rootDir, filePath);
  const s = await stat(absPath);
  if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot OCR a directory');

  // No caching: OCR is always run fresh and overwrites any previous result.
  const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));

  const ocrConfig = await readOcrConfig();
  if (!ocrConfig) throw errors.BAD_REQUEST('OCR not configured — set it up in Settings → OCR');

  const imageBytes = await Bun.file(absPath).arrayBuffer();
  const base64 = Buffer.from(imageBytes).toString('base64');
  const ext = absPath.split('.').pop()?.toLowerCase() ?? 'png';
  const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : `image/${ext}`;

  const res = await fetch(`${ocrConfig.url.replace(/\/+$/, '')}/v1/chat/completions`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: ocrConfig.model,
      messages: [
        {
          role: 'system',
          content: [
            'You are an OCR assistant. Extract meaningful text content from images.',
            'Rules:',
            '- Output ONLY the extracted text, no commentary or explanations.',
            '- For documents, articles, books: preserve the original text, paragraphs, and structure as markdown.',
            '- For tables: use markdown table format.',
            '- For code/terminal screenshots: use fenced code blocks.',
            '- For social media posts/threads: extract as clean conversation. Format as:',
            '  **username** says: "their text"',
            '  **replier** replies: "their text"',
            '  Strip all UI chrome (follow buttons, timestamps, like counts, avatars, "Everybody can reply", etc).',
            '  Keep only usernames and what they actually wrote.',
            '- For memes/image macros: describe the image briefly, then extract any text.',
            '- For handwriting: transcribe as accurately as possible.',
            '- For receipts/invoices: extract as structured text with line items.',
            '- Strip all UI elements, navigation, ads, watermarks, and other noise.',
            '- Preserve the original language of the text.',
          ].join('\n'),
        },
        {
          role: 'user',
          content: [
            { type: 'image_url', image_url: { url: `data:${mime};base64,${base64}` } },
            { type: 'text', text: 'Extract the text from this image.' },
          ],
        },
      ],
    }),
  });
  if (!res.ok) throw errors.BAD_REQUEST('OCR request failed');

  const json = (await res.json()) as { choices: { message: { content: string } }[] };
  const text = json.choices[0]?.message?.content ?? '';

  if (saveNextTo) {
    const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`;
    const siblingAbs = resolveUserPath(rootDir, siblingRel);
    await Bun.write(siblingAbs, text);
    return ctx.json({ ocrPath: `/${siblingRel}`, ocrRoot: root ?? 'home', cached: false });
  }

  return ctx.json({ text, cached: false });
});

// Extract audio from video via ffmpeg with caching
router.post('/extract-audio', async (ctx) => {
  const user = ctx.get('user');
  const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
  if (!filePath) throw errors.BAD_REQUEST('path is required');

  const rootDir = getRootDir(user, root);
  const absPath = resolveUserPath(rootDir, filePath);
  const s = await stat(absPath);
  if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract audio from a directory');

  // No caching: the extraction is always redone and overwrites the previous result. The output still
  // lives under the user's data dir because this endpoint backs the file viewer's Extract Audio
  // button, which plays it rather than saving it beside the video — the Extract Audio task is what
  // writes into the user's folders.
  const userDataDir = getUserDataDir(user.email);
  const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
  const outRel = dir ? `cache/audio/${dir}/${name}.mp3` : `cache/audio/${name}.mp3`;
  const cacheAbs = resolve(userDataDir, outRel);

  await mkdir(dirname(cacheAbs), { recursive: true });

  const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-vn', '-codec:a', 'libmp3lame', '-q:a', '2', '-y', cacheAbs], {
    stdout: 'ignore',
    stderr: 'pipe',
  });
  const exitCode = await proc.exited;

  if (exitCode !== 0) {
    const stderr = await new Response(proc.stderr).text();
    throw errors.BAD_REQUEST(stderr.trim() || 'Audio extraction failed');
  }

  return ctx.json({ audioPath: outRel, audioRoot: 'user-data', cached: false });
});

// Extract archive (zip, tar, 7z, rar) into a sibling folder
router.post('/extract', async (ctx) => {
  const user = ctx.get('user');
  const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
  if (!filePath) throw errors.BAD_REQUEST('path is required');

  const rootDir = getRootDir(user, root);
  const absPath = resolveUserPath(rootDir, filePath);
  const s = await stat(absPath);
  if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract a directory');

  const fileName = absPath.split('/').pop()!;
  const lower = fileName.toLowerCase();

  // Determine archive type and build command
  type ArchiveType = 'tar' | 'zip' | '7z' | 'rar';
  let archiveType: ArchiveType;
  if (
    lower.endsWith('.tar') ||
    lower.endsWith('.tar.gz') ||
    lower.endsWith('.tgz') ||
    lower.endsWith('.tar.bz2') ||
    lower.endsWith('.tbz2') ||
    lower.endsWith('.tar.xz') ||
    lower.endsWith('.txz') ||
    lower.endsWith('.tar.zst') ||
    lower.endsWith('.gz') ||
    lower.endsWith('.bz2') ||
    lower.endsWith('.xz') ||
    lower.endsWith('.zst')
  ) {
    archiveType = 'tar';
  } else if (lower.endsWith('.zip')) {
    archiveType = 'zip';
  } else if (lower.endsWith('.7z')) {
    archiveType = '7z';
  } else if (lower.endsWith('.rar')) {
    archiveType = 'rar';
  } else {
    throw errors.BAD_REQUEST('Unsupported archive format');
  }

  // Compute destination folder name (strip archive extension)
  const stripArchiveExt = (name: string): string => {
    const l = name.toLowerCase();
    for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) {
      if (l.endsWith(compound)) return name.slice(0, -compound.length);
    }
    const dotIdx = name.lastIndexOf('.');
    return dotIdx > 0 ? name.slice(0, dotIdx) : name;
  };

  const baseName = stripArchiveExt(fileName);
  const destPath = resolve(dirname(absPath), baseName);
  const finalDest = await resolveCollision(destPath);
  await mkdir(finalDest, { recursive: true });

  let cmd: string[];
  switch (archiveType) {
    case 'tar':
      cmd = ['tar', 'xf', absPath, '-C', finalDest];
      break;
    case 'zip':
      cmd = ['unzip', '-q', absPath, '-d', finalDest];
      break;
    case '7z':
      cmd = ['7z', 'x', absPath, `-o${finalDest}`, '-y'];
      break;
    case 'rar':
      cmd = ['unrar', 'x', '-o+', absPath, `${finalDest}/`];
      break;
  }

  const proc = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' });
  const exitCode = await proc.exited;

  if (exitCode !== 0) {
    const stderr = await new Response(proc.stderr).text();
    // Clean up the empty directory on failure
    await rm(finalDest, { recursive: true, force: true }).catch(() => {});
    throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed');
  }

  const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
  return ctx.json({ extractedPath });
});

// Transcribe audio via Whisper with caching
// Workflow: detect language → check user's spoken languages → translate if needed → transcribe
router.post('/transcribe', async (ctx) => {
  const user = ctx.get('user');
  const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean };
  if (!filePath) throw errors.BAD_REQUEST('path is required');

  const rootDir = getRootDir(user, root);
  const absPath = resolveUserPath(rootDir, filePath);
  const s = await stat(absPath);
  if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcribe a directory');

  // No caching: a transcription is always produced fresh and overwrites any previous one.
  const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));

  const sttConfig = await readSttConfig();
  const whisperUrl = sttConfig?.url;
  if (!whisperUrl) throw errors.BAD_REQUEST('Whisper not configured — set it up in Settings → Speech to Text');

  const settings = await getUserSettings(user.id);
  const spokenLanguages = (settings.languages as { spoken?: string[] })?.spoken ?? [];

  let text: string;
  try {
    const result = await transcribeAudio({ file: Bun.file(absPath), whisperUrl, spokenLanguages });
    text = result.text;
  } catch (err) {
    throw errors.BAD_REQUEST(err instanceof Error ? err.message : 'Transcription failed');
  }

  if (saveNextTo) {
    const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`;
    const siblingAbs = resolveUserPath(rootDir, siblingRel);
    await Bun.write(siblingAbs, text);
    return ctx.json({ transcriptionPath: `/${siblingRel}`, transcriptionRoot: root ?? 'home', cached: false });
  }

  return ctx.json({ text, cached: false });
});

// Search files by name
router.get('/search', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const query = (ctx.req.query('q') || '').trim().toLowerCase();
  if (!query) throw errors.BAD_REQUEST('q is required');
  const searchPath = ctx.req.query('path') || '';
  const startDir = searchPath ? resolveUserPath(rootDir, searchPath) : rootDir;

  const MAX_RESULTS = 50;
  const results: { path: string; name: string; type: string; size: number; modifiedAt: number }[] = [];

  async function walk(dir: string) {
    if (results.length >= MAX_RESULTS) return;
    const names = await readdir(dir).catch(() => [] as string[]);
    for (const name of names) {
      if (results.length >= MAX_RESULTS) break;
      const fullPath = resolve(dir, name);
      if (!isInside(rootDir, fullPath)) continue;
      const s = await stat(fullPath).catch(() => null);
      if (!s) continue;
      if (name.toLowerCase().includes(query)) {
        const relPath = '/' + fullPath.slice(rootDir.length).replace(/^\/+/, '');
        results.push({
          path: relPath,
          name,
          type: s.isDirectory() ? 'directory' : 'file',
          size: s.size,
          modifiedAt: s.mtimeMs,
        });
      }
      if (s.isDirectory()) await walk(fullPath);
    }
  }

  await walk(startDir);
  return ctx.json({ results });
});

// Resolve a destination path, appending " (copy)", " (copy 2)", etc. if it already exists
async function resolveCollision(destPath: string): Promise<string> {
  try {
    await stat(destPath);
  } catch {
    return destPath;
  }
  const dir = dirname(destPath);
  const base = destPath.split('/').pop()!;
  const dotIdx = base.lastIndexOf('.');
  const name = dotIdx > 0 ? base.slice(0, dotIdx) : base;
  const ext = dotIdx > 0 ? base.slice(dotIdx) : '';

  let n = 1;
  while (true) {
    const suffix = n === 1 ? ' (copy)' : ` (copy ${n})`;
    const candidate = resolve(dir, `${name}${suffix}${ext}`);
    try {
      await stat(candidate);
      n++;
    } catch {
      return candidate;
    }
  }
}

type CopyMoveItem = { source: string; destination: string };
type CopyMoveBody = CopyMoveItem | { items: CopyMoveItem[] };

function parseCopyMoveBody(body: CopyMoveBody): CopyMoveItem[] {
  if ('items' in body && Array.isArray(body.items)) return body.items;
  return [body as CopyMoveItem];
}

// Copy file or directory
router.post('/copy', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const body = ctx.get('body') as CopyMoveBody;
  const items = parseCopyMoveBody(body);
  if (items.length === 0) throw errors.BAD_REQUEST('No items provided');

  const results: { source: string; destination: string; error?: string }[] = [];

  for (const item of items) {
    if (!item.source || !item.destination) {
      results.push({
        source: item.source,
        destination: item.destination,
        error: 'source and destination are required',
      });
      continue;
    }
    try {
      const srcAbs = resolveUserPath(rootDir, item.source);
      const destAbs = resolveUserPath(rootDir, item.destination);
      await mkdir(dirname(destAbs), { recursive: true });
      const finalDest = await resolveCollision(destAbs);
      await cp(srcAbs, finalDest, { recursive: true });
      const relDest = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
      results.push({ source: item.source, destination: relDest });
    } catch (err: unknown) {
      const msg = err instanceof Error ? err.message : 'Unknown error';
      results.push({ source: item.source, destination: item.destination, error: msg });
    }
  }

  return ctx.json({ ok: true, results });
});

// Move file or directory
router.post('/move', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const body = ctx.get('body') as CopyMoveBody;
  const items = parseCopyMoveBody(body);
  if (items.length === 0) throw errors.BAD_REQUEST('No items provided');

  const results: { source: string; destination: string; error?: string }[] = [];

  for (const item of items) {
    if (!item.source || !item.destination) {
      results.push({
        source: item.source,
        destination: item.destination,
        error: 'source and destination are required',
      });
      continue;
    }
    try {
      const srcAbs = resolveUserPath(rootDir, item.source);
      const destAbs = resolveUserPath(rootDir, item.destination);
      // Prevent moving a directory into itself
      if (destAbs.startsWith(srcAbs + '/')) {
        throw new Error('Cannot move a directory into itself');
      }
      await mkdir(dirname(destAbs), { recursive: true });
      const finalDest = await resolveCollision(destAbs);
      await rename(srcAbs, finalDest);
      const relDest = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
      results.push({ source: item.source, destination: relDest });
    } catch (err: unknown) {
      const msg = err instanceof Error ? err.message : 'Unknown error';
      results.push({ source: item.source, destination: item.destination, error: msg });
    }
  }

  return ctx.json({ ok: true, results });
});

// Download video by delegating to the ReClip service (its own yt-dlp). It runs as a background job so a
// large download never holds one long HTTP request open (a reverse proxy would 504 on that). POST
// returns a jobId immediately; the client polls GET /download-video/:jobId until done/error.
const RECLIP_BASE = process.env.RECLIP_URL ?? 'http://localhost:8899';

type DownloadJob = {
  status: 'downloading' | 'transferring' | 'done' | 'error';
  error?: string;
  filename?: string;
  at: number;
};
const downloadJobs = new Map<string, DownloadJob>();

async function runReclipDownload(jobId: string, url: string, absPath: string, audioOnly: boolean) {
  const set = (patch: Partial<DownloadJob>) =>
    downloadJobs.set(jobId, { ...downloadJobs.get(jobId)!, ...patch, at: Date.now() });
  try {
    // Best-effort metadata for a nice title-based filename (ReClip names by job id otherwise).
    let title = '';
    try {
      const infoRes = await fetch(`${RECLIP_BASE}/api/info`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ url }),
        signal: AbortSignal.timeout(60_000),
      });
      if (infoRes.ok) title = ((await infoRes.json()) as { title?: string }).title ?? '';
    } catch {
      /* metadata is optional */
    }

    const dlRes = await fetch(`${RECLIP_BASE}/api/download`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url, format: audioOnly ? 'audio' : 'video', title }),
      signal: AbortSignal.timeout(30_000),
    }).catch(() => {
      throw new Error(`Could not reach ReClip at ${RECLIP_BASE}`);
    });
    if (!dlRes.ok) throw new Error('ReClip rejected the download request');
    const reclipJob = ((await dlRes.json()) as { job_id?: string }).job_id;
    if (!reclipJob) throw new Error('ReClip did not return a job id');

    // Poll ReClip until done/error (generous deadline; ReClip enforces its own per-download cap).
    const deadline = Date.now() + 60 * 60_000;
    let filename = '';
    for (;;) {
      if (Date.now() > deadline) throw new Error('Download timed out');
      await new Promise((r) => setTimeout(r, 2000));
      const stRes = await fetch(`${RECLIP_BASE}/api/status/${reclipJob}`, {
        signal: AbortSignal.timeout(15_000),
      }).catch(() => null);
      if (!stRes?.ok) continue;
      const st = (await stRes.json()) as { status: string; error?: string | null; filename?: string | null };
      if (st.status === 'error') throw new Error(st.error || 'ReClip download failed');
      if (st.status === 'done') {
        filename = st.filename || `${reclipJob}.${audioOnly ? 'mp3' : 'mp4'}`;
        break;
      }
    }

    set({ status: 'transferring', filename });

    // Stream the finished file into the user's folder (filename is title-sanitized, no path parts).
    const fileRes = await fetch(`${RECLIP_BASE}/api/file/${reclipJob}`, { signal: AbortSignal.timeout(600_000) });
    const body = fileRes.body;
    if (!fileRes.ok || !body) throw new Error('Failed to fetch the downloaded file from ReClip');
    // Pump the reader manually — Bun.write(path, Response) can deadlock on a streaming fetch body.
    const sink = Bun.file(join(absPath, filename)).writer();
    const reader = body.getReader();
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      sink.write(value);
    }
    await sink.end();

    set({ status: 'done', filename });
  } catch (err) {
    set({ status: 'error', error: err instanceof Error ? err.message : String(err) });
  }
}

router.post('/download-video', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const { url, path, audioOnly } = ctx.get('body') as { url: string; path: string; audioOnly?: boolean };
  if (!url) throw errors.BAD_REQUEST('url is required');
  if (!path) throw errors.BAD_REQUEST('path is required');

  const absPath = resolveUserPath(rootDir, path);
  await mkdir(absPath, { recursive: true });

  // Evict finished jobs older than an hour so the map doesn't grow unbounded.
  const cutoff = Date.now() - 60 * 60_000;
  for (const [id, job] of downloadJobs) if (job.at < cutoff) downloadJobs.delete(id);

  const jobId = crypto.randomUUID();
  downloadJobs.set(jobId, { status: 'downloading', at: Date.now() });
  void runReclipDownload(jobId, url, absPath, !!audioOnly);

  return ctx.json({ jobId });
});

router.get('/download-video/:jobId', (ctx) => {
  const job = downloadJobs.get(ctx.req.param('jobId'));
  if (!job) return ctx.json({ status: 'error', error: 'unknown or expired job' });
  return ctx.json({ status: job.status, error: job.error, filename: job.filename });
});

// Prefetch a single video's metadata (title / thumbnail / duration / uploader) — proxied straight to
// ReClip's /api/info so the download dialog can show a preview card before committing. Errors (private
// video, timeout, …) come back as { error } with a 200 so the client can render them inline.
// GET, not POST: it reads and writes nothing. The method is not cosmetic — the permission model reads
// it to decide whether a non-owner may call this, and a read filed as a write is a read they lose.
router.get('/video-info', async (ctx) => {
  const url = ctx.req.query('url');
  if (!url) throw errors.BAD_REQUEST('url is required');
  const res = await fetch(`${RECLIP_BASE}/api/info`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ url }),
    signal: AbortSignal.timeout(90_000),
  }).catch(() => null);
  if (!res) return ctx.json({ error: `Could not reach ReClip at ${RECLIP_BASE}` });
  const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
  return ctx.json(data);
});

// Expand a playlist URL into its individual video URLs (ReClip's /api/playlist → { urls }). The client
// then prefetches /video-info per url to build the per-entry cards.
// GET for the same reason as /video-info above.
router.get('/video-playlist', async (ctx) => {
  const url = ctx.req.query('url');
  if (!url) throw errors.BAD_REQUEST('url is required');
  const res = await fetch(`${RECLIP_BASE}/api/playlist`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ url }),
    signal: AbortSignal.timeout(120_000),
  }).catch(() => null);
  if (!res) return ctx.json({ error: `Could not reach ReClip at ${RECLIP_BASE}` });
  const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
  return ctx.json(data);
});

// Git clone a repository into a directory
router.post('/git-clone', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const { url, path } = ctx.get('body') as { url: string; path: string };
  if (!url) throw errors.BAD_REQUEST('url is required');
  if (!path) throw errors.BAD_REQUEST('path is required');

  const absPath = resolveUserPath(rootDir, path);
  await mkdir(absPath, { recursive: true });

  const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'ignore', stderr: 'pipe' });
  const stderrText = await new Response(proc.stderr).text();
  const exitCode = await proc.exited;

  if (exitCode !== 0) {
    throw errors.BAD_REQUEST(stderrText.trim() || 'git clone failed');
  }

  return ctx.json({ ok: true });
});

// Download file or directory (directories are zipped on-the-fly)
router.get('/download', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
  if (!relPath) throw errors.BAD_REQUEST('path is required');

  const absPath = resolveUserPath(rootDir, relPath);
  const s = await stat(absPath);
  const name = absPath.split('/').pop()!;

  if (s.isDirectory()) {
    const proc = Bun.spawn(['zip', '-r', '-', name], {
      cwd: dirname(absPath),
      stdout: 'pipe',
      stderr: 'ignore',
    });

    return new Response(proc.stdout as ReadableStream, {
      headers: {
        'Content-Type': 'application/zip',
        'Content-Disposition': `attachment; filename="${name}.zip"`,
      },
    });
  }

  const file = Bun.file(absPath);
  return new Response(file, {
    headers: {
      'Content-Type': file.type || 'application/octet-stream',
      'Content-Disposition': `attachment; filename="${name}"`,
      'Content-Length': String(s.size),
    },
  });
});

// Download multiple items as a single zip
router.post('/download', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const { paths } = ctx.get('body') as { paths: string[] };
  if (!Array.isArray(paths) || paths.length === 0) throw errors.BAD_REQUEST('paths is required');

  const items: string[] = [];
  for (const p of paths) {
    const relPath = p.replace(/^\/+/, '');
    const absPath = resolveUserPath(rootDir, relPath);
    await stat(absPath); // throws if not found
    items.push(relPath);
  }

  const proc = Bun.spawn(['zip', '-r', '-', ...items], {
    cwd: rootDir,
    stdout: 'pipe',
    stderr: 'ignore',
  });

  return new Response(proc.stdout as ReadableStream, {
    headers: {
      'Content-Type': 'application/zip',
      'Content-Disposition': 'attachment; filename="download.zip"',
    },
  });
});

// Delete file or directory
router.delete('/rm', async (ctx) => {
  const user = ctx.get('user');
  const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
  const body = await ctx.req.json<{ path: string }>();
  const { path } = body;
  if (!path) throw errors.BAD_REQUEST('path is required');

  const absPath = resolveUserPath(rootDir, path);
  // Prevent deleting the home dir itself
  if (absPath === rootDir) throw errors.FORBIDDEN('Cannot delete home directory');

  await rm(absPath, { recursive: true, force: true });
  return ctx.json({ ok: true });
});
