import { Router, type Request, type Response, type NextFunction } from 'express';
import express from 'express';
import { existsSync, readFileSync, writeFileSync, statSync, readdirSync, renameSync, mkdirSync, unlinkSync } from 'fs';
import { join, dirname, basename } from 'path';
import {
  USER_SUBDIRS,
  type UserSubdir,
  ensureUserFolder,
  resolveUserSubdir,
  userRoot,
  readUserAgentsMd,
  writeUserAgentsMd,
  deleteUserAgentsMd,
} from '../user-folder/paths.js';
import { logger } from '../logger.js';
import { setUntrustedFileResponseHeaders } from './local-api-helpers.js';
import { compileScript } from '../user-folder/script-compiler.js';
import { parseScript, serializeScript } from '../user-folder/frontmatter.js';
import { runUserScript } from '../user-folder/script-runner.js';
import type { RecordedAction } from '../engine/browser-recorder.js';
import { recorder } from '../engine/browser-recorder.js';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { loadSessionStateForUser } from '../user-folder/session-loader.js';
import {
  deletePet,
  getPet,
  importPetZip,
  listPets,
  PetConflictError,
  PetValidationError,
  readPetSettings,
  resolvePetAsset,
  slugifyPetId,
  writePetSettings,
} from '../user-folder/pets.js';
import type { NotesService } from '../notes/notes-service.js';

interface Deps {
  userFolderRoot: string;
  sessRepo?: BrowserSessionRepo;
  masterKeyPath?: string;
  authActive?: boolean;  // default true; when false, fall back to synthetic 'local' user
  notesService?: NotesService;
}

interface AuthedUser { id: string; role: string; }

function getUser(req: Request): AuthedUser | null {
  return (req.user as AuthedUser | undefined) ?? null;
}

const MAX_FILE_BYTES = 1024 * 1024; // 1 MB

function isUserSubdir(s: string): s is UserSubdir {
  return (USER_SUBDIRS as readonly string[]).includes(s);
}

// Subdirs that users may write to / delete from. 'trash' is system-managed.
// 'notes' is included here so the PUT/DELETE whitelist accepts it; those handlers
// then delegate immediately to NotesService rather than the generic file writer.
const WRITABLE_SUBDIRS = ['browser-macros', 'recordings', 'notes'] as const;
type WritableSubdir = typeof WRITABLE_SUBDIRS[number];
function isWritableSubdir(s: string): s is WritableSubdir {
  return (WRITABLE_SUBDIRS as readonly string[]).includes(s);
}

/**
 * Write file atomically via tmp + rename.
 * The tmp file is created in the same directory as the target to ensure
 * rename is an atomic single-filesystem move.
 */
function writeAtomic(path: string, content: string): void {
  const dir = dirname(path);
  mkdirSync(dir, { recursive: true });
  const tmp = join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
  let renamed = false;
  try {
    writeFileSync(tmp, content, { encoding: 'utf-8', mode: 0o600 });
    renameSync(tmp, path);
    renamed = true;
  } finally {
    if (!renamed) {
      try { unlinkSync(tmp); } catch { /* tmp may not exist if writeFileSync threw */ }
    }
  }
}

/**
 * Format a Date as YYYYMMDD-HHMMSS in UTC (used for trash prefix).
 */
function utcTimestamp(d: Date): string {
  const pad = (n: number, len = 2) => String(n).padStart(len, '0');
  return (
    `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
    `-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
  );
}

export function createUserFolderApi(deps: Deps): Router {
  const { userFolderRoot } = deps;
  const r = Router();

  // ── Auth gate ────────────────────────────────────────────────────────────
  const authActive = deps.authActive ?? true;
  r.use((req: Request, res: Response, next) => {
    if (!authActive && !getUser(req)) {
      // Local-dev / no-auth mode: inject a synthetic 'local' user so handlers
      // can operate against data/users/local/. Real OAuth deployments are
      // unaffected because authActive=true and Passport populates req.user.
      (req as any).user = { id: 'local', role: 'user' };
    }
    if (!getUser(req)) {
      res.status(401).json({ error: 'Unauthenticated' });
      return;
    }
    next();
  });

  // ── Pets: Codex Pets-compatible user imports ────────────────────────────
  r.get('/pets', (req: Request, res: Response) => {
    const u = getUser(req)!;
    try {
      res.json({
        pets: listPets(userFolderRoot, u.id),
        settings: readPetSettings(userFolderRoot, u.id),
      });
    } catch (err) {
      logger.error(`[user-folder-api] pets list failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to list pets' });
    }
  });

  r.post('/pets/import', express.raw({ limit: '12mb', type: '*/*' }), (req: Request, res: Response) => {
    const u = getUser(req)!;
    const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
    const rawPetId = typeof req.query['petId'] === 'string'
      ? req.query['petId']
      : typeof req.query['filename'] === 'string'
        ? req.query['filename']
        : null;
    const overwrite = req.query['overwrite'] === 'true';
    try {
      const detail = importPetZip(userFolderRoot, u.id, body, {
        preferredId: rawPetId ? slugifyPetId(rawPetId) : null,
        overwrite,
      });
      res.json({ ok: true, pet: detail });
    } catch (err) {
      if (err instanceof PetConflictError) {
        res.status(409).json({ error: err.message, petId: err.petId });
        return;
      }
      if (err instanceof PetValidationError || err instanceof SyntaxError) {
        res.status(400).json({ error: (err as Error).message });
        return;
      }
      logger.error(`[user-folder-api] pet import failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to import pet' });
    }
  });

  r.get('/pets/settings', (req: Request, res: Response) => {
    const u = getUser(req)!;
    try {
      res.json({ settings: readPetSettings(userFolderRoot, u.id) });
    } catch (err) {
      logger.error(`[user-folder-api] pet settings read failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to read pet settings' });
    }
  });

  r.put('/pets/settings', express.json({ limit: '32kb' }), (req: Request, res: Response) => {
    const u = getUser(req)!;
    try {
      const settings = writePetSettings(userFolderRoot, u.id, req.body);
      res.json({ ok: true, settings });
    } catch (err) {
      if (err instanceof PetValidationError) {
        res.status(400).json({ error: err.message });
        return;
      }
      logger.error(`[user-folder-api] pet settings write failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to write pet settings' });
    }
  });

  r.get('/pets/:petId/assets/:file', (req: Request, res: Response) => {
    const u = getUser(req)!;
    const asset = resolvePetAsset(userFolderRoot, u.id, req.params.petId, req.params.file);
    if (!asset) {
      res.status(404).json({ error: 'Asset not found' });
      return;
    }
    setUntrustedFileResponseHeaders(res);
    res.setHeader('Content-Type', asset.contentType);
    res.sendFile(asset.path);
  });

  r.get('/pets/:petId', (req: Request, res: Response) => {
    const u = getUser(req)!;
    try {
      const pet = getPet(userFolderRoot, u.id, req.params.petId);
      if (!pet) {
        res.status(404).json({ error: 'Pet not found' });
        return;
      }
      res.json({ pet });
    } catch (err) {
      if (err instanceof PetValidationError) {
        res.status(400).json({ error: err.message });
        return;
      }
      logger.error(`[user-folder-api] pet read failed user=${u.id} pet=${req.params.petId} err=${err}`);
      res.status(500).json({ error: 'Failed to read pet' });
    }
  });

  r.delete('/pets/:petId', (req: Request, res: Response) => {
    const u = getUser(req)!;
    try {
      const deleted = deletePet(userFolderRoot, u.id, req.params.petId);
      if (!deleted) {
        res.status(404).json({ error: 'Pet not found' });
        return;
      }
      res.json({ ok: true });
    } catch (err) {
      logger.error(`[user-folder-api] pet delete failed user=${u.id} pet=${req.params.petId} err=${err}`);
      res.status(500).json({ error: 'Failed to delete pet' });
    }
  });

  // ── GET /folder/list?subdir=scripts ──────────────────────────────────────
  r.get('/folder/list', (req: Request, res: Response) => {
    const u = getUser(req)!;
    const subdir = req.query['subdir'] as string | undefined;

    if (!subdir || !isUserSubdir(subdir)) {
      res.status(400).json({ error: `subdir must be one of: ${USER_SUBDIRS.join(', ')}` });
      return;
    }

    try {
      ensureUserFolder(userFolderRoot, u.id);
    } catch (err) {
      logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to ensure user folder' });
      return;
    }

    const dirPath = join(userRoot(userFolderRoot, u.id), subdir);

    try {
      const entries = readdirSync(dirPath, { withFileTypes: true });
      const files = entries
        .filter(e => e.isFile() && !e.name.startsWith('.'))
        .map(e => {
          const stat = statSync(join(dirPath, e.name));
          return {
            name: e.name,
            size: stat.size,
            mtime: stat.mtime.toISOString(),
          };
        });
      res.json({ files });
    } catch (err) {
      logger.error(`[user-folder-api] list failed user=${u.id} subdir=${subdir} err=${err}`);
      res.status(500).json({ error: 'Failed to list folder' });
    }
  });

  // ── GET /folder/file?subdir=scripts&path=foo.js ──────────────────────────
  r.get('/folder/file', (req: Request, res: Response) => {
    const u = getUser(req)!;
    const subdir = req.query['subdir'] as string | undefined;
    const relPath = req.query['path'] as string | undefined;

    if (!subdir || !isUserSubdir(subdir)) {
      res.status(400).json({ error: `subdir must be one of: ${USER_SUBDIRS.join(', ')}` });
      return;
    }
    if (!relPath) {
      res.status(400).json({ error: 'path query parameter is required' });
      return;
    }

    try {
      ensureUserFolder(userFolderRoot, u.id);
    } catch (err) {
      logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to ensure user folder' });
      return;
    }

    let fullPath: string;
    try {
      fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
    } catch {
      res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
      return;
    }

    if (!existsSync(fullPath)) {
      res.status(404).json({ error: 'File not found' });
      return;
    }

    let stat: ReturnType<typeof statSync>;
    try {
      stat = statSync(fullPath);
    } catch {
      res.status(404).json({ error: 'File not found' });
      return;
    }

    if (!stat.isFile()) {
      res.status(404).json({ error: 'Not a file' });
      return;
    }

    if (stat.size > MAX_FILE_BYTES) {
      res.status(413).json({ error: 'File exceeds 1 MB limit' });
      return;
    }

    try {
      const content = readFileSync(fullPath, 'utf-8');
      setUntrustedFileResponseHeaders(res);
      res.setHeader('Content-Type', 'text/plain; charset=utf-8');
      res.send(content);
    } catch (err) {
      logger.error(`[user-folder-api] read failed user=${u.id} path=${relPath} err=${err}`);
      res.status(500).json({ error: 'Failed to read file' });
    }
  });

  // ── PUT /folder/file?subdir=scripts&path=foo.js ──────────────────────────
  r.put('/folder/file', express.text({ limit: '1mb', type: '*/*' }), async (req: Request, res: Response) => {
    const u = getUser(req)!;
    const subdir = req.query['subdir'] as string | undefined;
    const relPath = req.query['path'] as string | undefined;

    if (!subdir || !isWritableSubdir(subdir)) {
      res.status(400).json({ error: `subdir must be one of: ${WRITABLE_SUBDIRS.join(', ')}` });
      return;
    }
    if (!relPath) {
      res.status(400).json({ error: 'path query parameter is required' });
      return;
    }

    // ── notes subdir: delegate entirely to NotesService ──────────────────
    if (subdir === 'notes') {
      if (!deps.notesService) {
        res.status(500).json({ error: 'notesService is not configured; cannot write notes' });
        return;
      }
      // Validate path: must be exactly 2 segments (<folder>/<file.md>)
      const segments = relPath.split('/').filter(s => s.length > 0);
      if (segments.length !== 2) {
        res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
        return;
      }
      const [folder, fileName] = segments as [string, string];
      const content = typeof req.body === 'string' ? req.body : '';
      try {
        deps.notesService.writeNote({ ownerId: u.id, folder, fileName, content });
        res.json({ ok: true, indexed: true });
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        if (/scope_org_id|invalid|path|\.md/.test(msg)) {
          res.status(400).json({ error: msg });
          return;
        }
        logger.error(`[user-folder-api] notes write failed user=${u.id} path=${relPath} err=${err}`);
        res.status(500).json({ error: 'Failed to write note' });
      }
      return;
    }

    try {
      ensureUserFolder(userFolderRoot, u.id);
    } catch (err) {
      logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to ensure user folder' });
      return;
    }

    let fullPath: string;
    try {
      fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
    } catch {
      res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
      return;
    }

    const content = typeof req.body === 'string' ? req.body : '';

    try {
      writeAtomic(fullPath, content);
      const stat = statSync(fullPath);
      res.json({ ok: true, size: stat.size, mtime: stat.mtime.toISOString() });
    } catch (err) {
      logger.error(`[user-folder-api] write failed user=${u.id} path=${relPath} err=${err}`);
      res.status(500).json({ error: 'Failed to write file' });
    }
  });

  // ── DELETE /folder/file?subdir=scripts&path=foo.js ───────────────────────
  r.delete('/folder/file', async (req: Request, res: Response) => {
    const u = getUser(req)!;
    const subdir = req.query['subdir'] as string | undefined;
    const relPath = req.query['path'] as string | undefined;

    if (!subdir || !isWritableSubdir(subdir)) {
      res.status(400).json({ error: `subdir must be one of: ${WRITABLE_SUBDIRS.join(', ')}` });
      return;
    }
    if (!relPath) {
      res.status(400).json({ error: 'path query parameter is required' });
      return;
    }

    // ── notes subdir: delegate entirely to NotesService ──────────────────
    if (subdir === 'notes') {
      if (!deps.notesService) {
        res.status(500).json({ error: 'notesService is not configured; cannot delete notes' });
        return;
      }
      const segments = relPath.split('/').filter(s => s.length > 0);
      if (segments.length !== 2) {
        res.status(400).json({ error: 'notes path must be exactly <folder>/<file.md>' });
        return;
      }
      const [folder, fileName] = segments as [string, string];
      try {
        deps.notesService.deleteNote({ ownerId: u.id, folder, fileName });
        res.json({ ok: true });
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        if (/scope_org_id|invalid|path|\.md/.test(msg)) {
          res.status(400).json({ error: msg });
          return;
        }
        logger.error(`[user-folder-api] notes delete failed user=${u.id} path=${relPath} err=${err}`);
        res.status(500).json({ error: 'Failed to delete note' });
      }
      return;
    }

    try {
      ensureUserFolder(userFolderRoot, u.id);
    } catch (err) {
      logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to ensure user folder' });
      return;
    }

    let fullPath: string;
    try {
      fullPath = resolveUserSubdir(userFolderRoot, u.id, subdir, relPath);
    } catch {
      res.status(400).json({ error: 'Invalid path: traversal or absolute path not allowed' });
      return;
    }

    if (!existsSync(fullPath)) {
      res.status(404).json({ error: 'File not found' });
      return;
    }

    // Extract the base filename for the trash name
    const originalName = relPath.split('/').pop()!;
    const ts = utcTimestamp(new Date());
    const suffix = Math.random().toString(16).slice(2, 6);
    const trashedAs = `${ts}-${suffix}-${originalName}`;
    const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
    const trashPath = join(trashDir, trashedAs);

    try {
      renameSync(fullPath, trashPath);
      res.json({ ok: true, trashedAs });
    } catch (err) {
      logger.error(`[user-folder-api] delete/trash failed user=${u.id} path=${relPath} err=${err}`);
      res.status(500).json({ error: 'Failed to move file to trash' });
    }
  });

  // ── POST /browser-macros/compile ──────────────────────────────────────────────
  r.post('/browser-macros/compile', express.json({ limit: '256kb' }), async (req: Request, res: Response) => {
    const u = getUser(req)!;

    const {
      recordingName,
      scriptName,
      description,
      sessionProfileId,
      paramHints,
    } = req.body as {
      recordingName?: unknown;
      scriptName?: unknown;
      description?: unknown;
      sessionProfileId?: unknown;
      paramHints?: unknown;
    };

    if (typeof recordingName !== 'string' || !recordingName.trim()) {
      res.status(400).json({ error: 'recordingName is required' });
      return;
    }
    if (typeof scriptName !== 'string' || !scriptName.trim()) {
      res.status(400).json({ error: 'scriptName is required' });
      return;
    }
    if (typeof description !== 'string') {
      res.status(400).json({ error: 'description is required' });
      return;
    }

    try {
      ensureUserFolder(userFolderRoot, u.id);
    } catch (err) {
      logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to ensure user folder' });
      return;
    }

    // Resolve recording path
    let recordingPath: string;
    try {
      recordingPath = resolveUserSubdir(userFolderRoot, u.id, 'recordings', `${recordingName}.json`);
    } catch {
      res.status(400).json({ error: 'Invalid recordingName' });
      return;
    }

    if (!existsSync(recordingPath)) {
      res.status(404).json({ error: `Recording not found: ${recordingName}.json` });
      return;
    }

    // Parse recording JSON
    let recording: { recordTo?: unknown; capturedAt?: unknown; actions?: unknown };
    try {
      recording = JSON.parse(readFileSync(recordingPath, 'utf-8')) as typeof recording;
    } catch {
      res.status(400).json({ error: 'Recording is not valid JSON' });
      return;
    }

    // Validate shape
    if (!recording || typeof recording !== 'object' || !Array.isArray(recording.actions)) {
      res.status(400).json({ error: 'Recording is missing required fields (expected { recordTo, capturedAt, actions })' });
      return;
    }

    // Conflict policy: check if script already exists
    const scriptFileName = scriptName.endsWith('.js') ? scriptName : `${scriptName}.js`;
    let scriptPath: string;
    try {
      scriptPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
    } catch {
      res.status(400).json({ error: 'Invalid scriptName' });
      return;
    }

    if (existsSync(scriptPath) && req.query['overwrite'] !== 'true') {
      res.status(409).json({ error: 'Script already exists; pass ?overwrite=true to replace' });
      return;
    }

    // Validate paramHints shape
    if (paramHints !== undefined) {
      if (!Array.isArray(paramHints)) {
        res.status(400).json({ error: 'paramHints must be an array' });
        return;
      }
      for (let i = 0; i < paramHints.length; i++) {
        const hint = paramHints[i];
        if (!hint || typeof hint !== 'object' ||
            typeof hint.name !== 'string' || !hint.name ||
            typeof hint.valueToReplace !== 'string' ||
            !['string', 'number', 'boolean'].includes((hint as any).type)) {
          res.status(400).json({
            error: `paramHints[${i}] must be { name: string, valueToReplace: string, type: 'string' | 'number' | 'boolean' }`,
          });
          return;
        }
      }
    }

    // Compile
    let compiled: ReturnType<typeof compileScript>;
    try {
      compiled = compileScript({
        recording: recording.actions as RecordedAction[],
        description,
        sessionProfileId: typeof sessionProfileId === 'number' ? sessionProfileId : undefined,
        paramHints: Array.isArray(paramHints)
          ? (paramHints as { name: string; valueToReplace: string; type: 'string' | 'number' | 'boolean' }[])
          : undefined,
        recordingSource: `${recordingName}.json`,
      });
    } catch (err) {
      logger.error(`[user-folder-api] compile failed user=${u.id} recording=${recordingName} err=${err}`);
      res.status(500).json({ error: `Compile failed: ${(err as Error).message}` });
      return;
    }

    // Stamp timestamps and re-serialize
    const now = new Date().toISOString();
    const meta = { ...compiled.meta, createdAt: now, updatedAt: now };
    const { body } = parseScript(compiled.source);
    const source = serializeScript({ frontmatter: meta, body });

    // Write atomically
    try {
      writeAtomic(scriptPath, source);
    } catch (err) {
      logger.error(`[user-folder-api] write failed user=${u.id} script=${scriptName} err=${err}`);
      res.status(500).json({ error: 'Failed to write script file' });
      return;
    }

    const size = statSync(scriptPath).size;
    res.json({ ok: true, scriptName: scriptFileName, source, size });
  });

  // ── POST /scripts/:name/run ───────────────────────────────────────────────────
  // body.kind: 'script' | 'browser-macro' — determines which subdir to load from.
  // If omitted, both are tried (scripts/ first, then browser-macros/).
  r.post('/scripts/:name/run', express.json({ limit: '256kb' }), async (req: Request, res: Response) => {
    const u = getUser(req)!;
    const rawName = req.params['name'] ?? '';
    const scriptFileName = rawName.endsWith('.js') ? rawName : `${rawName}.js`;

    try {
      ensureUserFolder(userFolderRoot, u.id);
    } catch (err) {
      logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to ensure user folder' });
      return;
    }

    const { params, timeoutMs } = ((req.body as Record<string, unknown>) ?? {}) as {
      params?: Record<string, unknown>;
      timeoutMs?: number;
    };

    // Plain-Node scripts/ were retired (2026-06) — only browser-macros run here.
    let scriptPath: string | null = null;
    const resolvedRuntime = 'playwright' as const;
    try {
      const candidate = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', scriptFileName);
      if (existsSync(candidate)) scriptPath = candidate;
    } catch { /* invalid path */ }
    if (scriptPath === null) {
      res.status(404).json({ error: `Script not found: ${scriptFileName}` });
      return;
    }

    // Clamp timeoutMs to 5 minutes max (prevent malicious long-running requests)
    const requestedTimeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 60_000;
    const cappedTimeout = Math.min(requestedTimeout, 300_000);

    // Load session storageState if it's a playwright-runtime script with sessionProfileId
    let storageState: object | undefined;
    if (resolvedRuntime === 'playwright') {
      try {
        const source = readFileSync(scriptPath, 'utf-8');
        const parsed = parseScript(source);
        const sessionProfileId = parsed.frontmatter.sessionProfileId;

        if (sessionProfileId !== undefined) {
          if (!deps.sessRepo || !deps.masterKeyPath) {
            res.status(500).json({
              error: 'Session profile required but session repository is not configured',
            });
            return;
          }

          const sessionResult = await loadSessionStateForUser(
            { sessRepo: deps.sessRepo, masterKeyPath: deps.masterKeyPath },
            u.id,
            sessionProfileId,
          );

          if (!sessionResult.ok) {
            res.status(500).json({ error: sessionResult.error.message });
            return;
          }

          storageState = sessionResult.storageState;
        }
      } catch (err) {
        res.status(500).json({ error: `Failed to parse script: ${(err as Error).message}` });
        return;
      }
    }

    // Run
    const startMs = Date.now();
    try {
      const scriptResult = await runUserScript({
        scriptPath,
        params: params ?? {},
        runtime: resolvedRuntime,
        storageState,
        timeoutMs: cappedTimeout,
      });
      const durationMs = Date.now() - startMs;
      res.json({ result: scriptResult.result, logs: scriptResult.logs, durationMs });
    } catch (err) {
      const durationMs = Date.now() - startMs;
      const message = (err as Error).message;
      res.status(500).json({ error: message, durationMs });
    }
  });

  // ── GET /browser-macros/:name/diff ───────────────────────────────────────────
  // Returns { current: string|null, candidate: string, candidateMtime: string }
  // or 404 if no .next.js exists.
  r.get('/browser-macros/:name/diff', (req: Request, res: Response) => {
    const u = getUser(req)!;
    const rawName = req.params['name'] ?? '';
    // Normalize: strip any trailing .js suffix to get the bare name
    const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;

    try {
      ensureUserFolder(userFolderRoot, u.id);
    } catch (err) {
      logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to ensure user folder' });
      return;
    }

    let candidatePath: string;
    let currentPath: string;
    try {
      candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
      currentPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.js`);
    } catch {
      res.status(400).json({ error: 'Invalid script name' });
      return;
    }

    if (!existsSync(candidatePath)) {
      res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
      return;
    }

    let candidate: string;
    let candidateMtime: string;
    try {
      candidate = readFileSync(candidatePath, 'utf-8');
      candidateMtime = statSync(candidatePath).mtime.toISOString();
    } catch (err) {
      logger.error(`[user-folder-api] diff read candidate failed user=${u.id} name=${baseName} err=${err}`);
      res.status(500).json({ error: 'Failed to read candidate file' });
      return;
    }

    // current may not exist (orphaned .next.js)
    let current: string | null = null;
    if (existsSync(currentPath)) {
      try {
        current = readFileSync(currentPath, 'utf-8');
      } catch (err) {
        logger.error(`[user-folder-api] diff read current failed user=${u.id} name=${baseName} err=${err}`);
        res.status(500).json({ error: 'Failed to read current file' });
        return;
      }
    }

    res.json({ current, candidate, candidateMtime });
  });

  // ── POST /browser-macros/:name/accept ────────────────────────────────────────
  // Atomically archives browser-macros/{name}.js to trash, then renames .next.js into place.
  // NOTE: Not fully atomic — a crash between step 1 and step 2 would leave no
  // browser-macros/{name}.js. Acceptable given the complexity of a copy-then-rename
  // alternative. The .next.js is always preserved or moved to trash.
  r.post('/browser-macros/:name/accept', (req: Request, res: Response) => {
    const u = getUser(req)!;
    const rawName = req.params['name'] ?? '';
    const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;

    try {
      ensureUserFolder(userFolderRoot, u.id);
    } catch (err) {
      logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to ensure user folder' });
      return;
    }

    let candidatePath: string;
    let currentPath: string;
    try {
      candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
      currentPath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.js`);
    } catch {
      res.status(400).json({ error: 'Invalid script name' });
      return;
    }

    if (!existsSync(candidatePath)) {
      res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
      return;
    }

    const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
    const ts = utcTimestamp(new Date());
    const suffix = Math.random().toString(16).slice(2, 6);

    let archivedAs: string | null = null;

    try {
      // Step 1: Archive the existing script to trash (if it exists)
      if (existsSync(currentPath)) {
        archivedAs = `${ts}-${suffix}-${baseName}.js`;
        const trashPath = join(trashDir, archivedAs);
        renameSync(currentPath, trashPath);
      }

      // Step 2: Rename .next.js into the canonical script location
      renameSync(candidatePath, currentPath);
    } catch (err) {
      logger.error(`[user-folder-api] accept failed user=${u.id} name=${baseName} err=${err}`);
      res.status(500).json({ error: 'Failed to accept patch' });
      return;
    }

    logger.info(`[user-folder-api] accept user=${u.id} name=${baseName} archivedAs=${archivedAs ?? 'none'}`);
    res.json({ ok: true, accepted: `${baseName}.js`, archivedAs });
  });

  // ── POST /browser-macros/:name/reject ────────────────────────────────────────
  // Moves browser-macros/{name}.next.js to trash; the original .js is untouched.
  r.post('/browser-macros/:name/reject', (req: Request, res: Response) => {
    const u = getUser(req)!;
    const rawName = req.params['name'] ?? '';
    const baseName = rawName.endsWith('.js') ? rawName.slice(0, -3) : rawName;

    try {
      ensureUserFolder(userFolderRoot, u.id);
    } catch (err) {
      logger.error(`[user-folder-api] ensureUserFolder failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to ensure user folder' });
      return;
    }

    let candidatePath: string;
    try {
      candidatePath = resolveUserSubdir(userFolderRoot, u.id, 'browser-macros', `${baseName}.next.js`);
    } catch {
      res.status(400).json({ error: 'Invalid script name' });
      return;
    }

    if (!existsSync(candidatePath)) {
      res.status(404).json({ error: `No pending patch: ${baseName}.next.js not found` });
      return;
    }

    const trashDir = join(userRoot(userFolderRoot, u.id), 'trash');
    const ts = utcTimestamp(new Date());
    const suffix = Math.random().toString(16).slice(2, 6);
    const trashedAs = `${ts}-${suffix}-${baseName}.next.js`;
    const trashPath = join(trashDir, trashedAs);

    try {
      renameSync(candidatePath, trashPath);
    } catch (err) {
      logger.error(`[user-folder-api] reject failed user=${u.id} name=${baseName} err=${err}`);
      res.status(500).json({ error: 'Failed to reject patch' });
      return;
    }

    logger.info(`[user-folder-api] reject user=${u.id} name=${baseName} trashedAs=${trashedAs}`);
    res.json({ ok: true, rejected: `${baseName}.next.js`, trashedAs });
  });

  // ── POST /recordings/flush?taskId=<id> ───────────────────────────────────
  // Flush the in-memory recording buffer for a given taskId to disk.
  // Returns { ok: true, recordingName, path } or 404 if no buffer exists.
  r.post('/recordings/flush', (req: Request, res: Response) => {
    const u = getUser(req)!;
    const taskId = req.query['taskId'] as string | undefined;

    if (!taskId) {
      res.status(400).json({ error: 'taskId query parameter is required' });
      return;
    }

    let absPath: string | null;
    try {
      absPath = recorder.flush(taskId, userFolderRoot, u.id);
    } catch (err) {
      logger.error(`[user-folder-api] recordings/flush failed user=${u.id} taskId=${taskId} err=${err}`);
      res.status(500).json({ error: 'Failed to flush recording' });
      return;
    }

    if (absPath === null) {
      res.status(404).json({ error: 'no active recording for this task' });
      return;
    }

    // e.g. absPath = "/data/users/user-a/recordings/my-rec.json"
    // recordingName = "my-rec" (basename without .json)
    const fileBasename = basename(absPath);
    const recordingName = fileBasename.endsWith('.json')
      ? fileBasename.slice(0, -5)
      : fileBasename;
    const relPath = `recordings/${fileBasename}`;

    logger.info(`[user-folder-api] recordings/flush user=${u.id} taskId=${taskId} recordingName=${recordingName}`);
    res.json({ ok: true, recordingName, path: relPath });
  });

  // ── GET /agents-md ───────────────────────────────────────────────────────
  r.get('/agents-md', (req: Request, res: Response) => {
    const u = getUser(req)!;
    try {
      const content = readUserAgentsMd(userFolderRoot, u.id);
      if (content === null) {
        res.json({ exists: false, content: '' });
        return;
      }
      res.json({ exists: true, content });
    } catch (err) {
      logger.error(`[user-folder-api] read AGENTS.md failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to read AGENTS.md' });
    }
  });

  // ── PUT /agents-md ───────────────────────────────────────────────────────
  r.put('/agents-md', express.text({ type: '*/*', limit: '256kb' }), (req: Request, res: Response) => {
    const u = getUser(req)!;
    const body = (req.body as unknown) as string;
    if (typeof body !== 'string') {
      res.status(400).json({ error: 'body must be text/plain' });
      return;
    }
    try {
      writeUserAgentsMd(userFolderRoot, u.id, body);
      res.json({ ok: true, bytes: Buffer.byteLength(body, 'utf-8') });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      if (msg.includes('exceeds')) {
        res.status(413).json({ error: msg });
        return;
      }
      logger.error(`[user-folder-api] write AGENTS.md failed user=${u.id} err=${msg}`);
      res.status(500).json({ error: 'Failed to write AGENTS.md' });
    }
  });

  // ── DELETE /agents-md ────────────────────────────────────────────────────
  r.delete('/agents-md', (req: Request, res: Response) => {
    const u = getUser(req)!;
    try {
      const existed = deleteUserAgentsMd(userFolderRoot, u.id);
      res.json({ ok: true, existed });
    } catch (err) {
      logger.error(`[user-folder-api] delete AGENTS.md failed user=${u.id} err=${err}`);
      res.status(500).json({ error: 'Failed to delete AGENTS.md' });
    }
  });

  // ── Router-level error middleware ─────────────────────────────────────────
  // Catches errors from route handlers (e.g. express.text body-too-large).
  r.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
    if (err && err.type === 'entity.too.large') {
      res.status(413).json({ error: 'Request body exceeds 1 MB limit' });
      return;
    }
    res.status(err?.status ?? 500).json({ error: err?.message ?? 'Internal error' });
  });

  return r;
}
