import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import Database from 'better-sqlite3';
import { createUserFolderApi } from './user-folder-api.js';
import { recorder } from '../engine/browser-recorder.js';
import AdmZip from 'adm-zip';
import { runMigrations } from '../db/migrate.js';
import { NotesRepository } from '../notes/notes-repository.js';
import { NotesService } from '../notes/notes-service.js';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeApp(userId: string, userFolderRoot: string): express.Application {
  const tmpDb = new Database(':memory:');
  runMigrations(tmpDb);
  tmpDb.prepare(`INSERT OR IGNORE INTO users (id, email) VALUES (?, ?)`).run(userId, `${userId}@x.com`);
  const repo = new NotesRepository(tmpDb);
  const notesService = new NotesService({ db: tmpDb, repo, userFolderRoot, getUserOrgIds: () => ['team1'] });
  const app = express();
  // Inject a fake req.user
  app.use((req, _res, next) => {
    (req as any).user = { id: userId, role: 'user', orgIds: ['team1'] };
    next();
  });
  app.use('/api/users/me', createUserFolderApi({ userFolderRoot, notesService }));
  return app;
}

function makeUnauthApp(userFolderRoot: string): express.Application {
  const app = express();
  // No req.user set
  app.use('/api/users/me', createUserFolderApi({ userFolderRoot }));
  return app;
}

/** authActive=false, no req.user injection — synthetic 'local' user should be used */
function makeNoAuthModeApp(userFolderRoot: string): express.Application {
  const app = express();
  app.use('/api/users/me', createUserFolderApi({ userFolderRoot, authActive: false }));
  return app;
}

function makePetZip(files: Record<string, Buffer | string>): Buffer {
  const zip = new AdmZip();
  for (const [name, content] of Object.entries(files)) {
    zip.addFile(name, Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'));
  }
  return zip.toBuffer();
}

// ---------------------------------------------------------------------------
// Setup / Teardown
// ---------------------------------------------------------------------------

describe('User Folder API', () => {
  let tmpRoot: string;
  let app: express.Application;
  const USER_A = 'user-a';
  const USER_B = 'user-b';

  beforeEach(() => {
    tmpRoot = mkdtempSync(join(tmpdir(), 'user-folder-api-test-'));
    app = makeApp(USER_A, tmpRoot);
  });

  afterEach(() => {
    rmSync(tmpRoot, { recursive: true, force: true });
  });

  // -------------------------------------------------------------------------
  // GET /folder/list
  // -------------------------------------------------------------------------

  describe('GET /folder/list', () => {
    it('returns files in the requested subdir', async () => {
      // Write 2 files directly into the subdir
      const scriptsDir = join(tmpRoot, USER_A, 'scripts');
      mkdirSync(scriptsDir, { recursive: true });
      writeFileSync(join(scriptsDir, 'hello.js'), 'console.log("hi")');
      writeFileSync(join(scriptsDir, 'world.ts'), 'export {}');

      const res = await request(app).get('/api/users/me/folder/list?subdir=scripts');
      expect(res.status).toBe(200);
      const names = (res.body.files as Array<{ name: string }>).map(f => f.name).sort();
      expect(names).toEqual(['hello.js', 'world.ts']);
      // Each file entry must have name, size, mtime
      const file = (res.body.files as Array<{ name: string; size: number; mtime: string }>)[0]!;
      expect(typeof file.size).toBe('number');
      expect(typeof file.mtime).toBe('string');
    });

    it('returns 400 for invalid subdir', async () => {
      const res = await request(app).get('/api/users/me/folder/list?subdir=invalid');
      expect(res.status).toBe(400);
    });

    it('does not return hidden files (starting with .)', async () => {
      const scriptsDir = join(tmpRoot, USER_A, 'scripts');
      mkdirSync(scriptsDir, { recursive: true });
      writeFileSync(join(scriptsDir, 'visible.js'), 'ok');
      writeFileSync(join(scriptsDir, '.hidden'), 'secret');

      const res = await request(app).get('/api/users/me/folder/list?subdir=scripts');
      expect(res.status).toBe(200);
      const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
      expect(names).toContain('visible.js');
      expect(names).not.toContain('.hidden');
    });

    it('returns 401 when req.user is missing', async () => {
      const unauthApp = makeUnauthApp(tmpRoot);
      const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=scripts');
      expect(res.status).toBe(401);
    });

    it('GET list of trash subdir works (so users can see deleted files)', async () => {
      // Create a trash dir with a file to simulate a previous delete
      const trashDir = join(tmpRoot, USER_A, 'trash');
      mkdirSync(trashDir, { recursive: true });
      writeFileSync(join(trashDir, '20260101-000000-abcd-deleted.js'), 'old');

      const res = await request(app).get('/api/users/me/folder/list?subdir=trash');
      expect(res.status).toBe(200);
      const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
      expect(names).toContain('20260101-000000-abcd-deleted.js');
    });
  });

  describe('pets API', () => {
    it('imports a pet zip, lists it, and serves its asset', async () => {
      const zip = makePetZip({
        'pet.json': JSON.stringify({ name: 'Lumi', description: 'small companion', spritesheet: 'spritesheet.webp' }),
        'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
      });

      const importRes = await request(app)
        .post('/api/users/me/pets/import?filename=lumi.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);

      expect(importRes.status).toBe(200);
      expect(importRes.body.pet.id).toBe('lumi');
      expect(importRes.body.pet.name).toBe('Lumi');

      const listRes = await request(app).get('/api/users/me/pets');
      expect(listRes.status).toBe(200);
      expect(listRes.body.pets).toHaveLength(1);
      expect(listRes.body.pets[0].spriteFile).toBe('spritesheet.webp');
      expect(listRes.body.settings.enabled).toBe(true);

      const assetRes = await request(app).get('/api/users/me/pets/lumi/assets/spritesheet.webp');
      expect(assetRes.status).toBe(200);
      expect(assetRes.headers['content-type']).toMatch(/image\/webp/);
    });

    it('reads Codex Pets schema fields (displayName + spritesheetPath)', async () => {
      const zip = makePetZip({
        'pet.json': JSON.stringify({
          id: 'bolt',
          displayName: 'Bolt',
          description: 'A cute compact robot companion.',
          spritesheetPath: 'spritesheet.webp',
        }),
        'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
      });
      const res = await request(app)
        .post('/api/users/me/pets/import?filename=bolt.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);
      expect(res.status).toBe(200);
      expect(res.body.pet.id).toBe('bolt');
      expect(res.body.pet.name).toBe('Bolt');
      expect(res.body.pet.spriteFile).toBe('spritesheet.webp');
    });

    it('reads optional gridCols/gridRows from manifest', async () => {
      const zip = makePetZip({
        'pet.json': JSON.stringify({
          displayName: 'Grid',
          spritesheetPath: 'spritesheet.webp',
          gridCols: 8,
          gridRows: 9,
        }),
        'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
      });
      const res = await request(app)
        .post('/api/users/me/pets/import?filename=grid.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);
      expect(res.status).toBe(200);
      expect(res.body.pet.gridCols).toBe(8);
      expect(res.body.pet.gridRows).toBe(9);
    });

    it('extracts frameWidth/frameHeight when both are valid integers', async () => {
      const zip = makePetZip({
        'pet.json': JSON.stringify({ name: 'Pixel', spritesheet: 'spritesheet.webp', frameWidth: 64, frameHeight: 48 }),
        'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
      });
      const res = await request(app)
        .post('/api/users/me/pets/import?filename=pixel.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);
      expect(res.status).toBe(200);
      expect(res.body.pet.frameWidth).toBe(64);
      expect(res.body.pet.frameHeight).toBe(48);
    });

    it('reads frameWidth/frameHeight from nested spritesheet object', async () => {
      const zip = makePetZip({
        'pet.json': JSON.stringify({
          name: 'Nested',
          spritesheet: { file: 'spritesheet.webp', frameWidth: 32, frameHeight: 32 },
        }),
        'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
      });
      const res = await request(app)
        .post('/api/users/me/pets/import?filename=nested.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);
      expect(res.status).toBe(200);
      expect(res.body.pet.frameWidth).toBe(32);
      expect(res.body.pet.frameHeight).toBe(32);
    });

    it('returns null frame dimensions when only one of width/height is set', async () => {
      const zip = makePetZip({
        'pet.json': JSON.stringify({ name: 'Half', spritesheet: 'spritesheet.webp', frameWidth: 64 }),
        'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
      });
      const res = await request(app)
        .post('/api/users/me/pets/import?filename=half.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);
      expect(res.status).toBe(200);
      expect(res.body.pet.frameWidth).toBeNull();
      expect(res.body.pet.frameHeight).toBeNull();
    });

    it('returns null frame dimensions when value is out of range', async () => {
      const zip = makePetZip({
        'pet.json': JSON.stringify({ name: 'Huge', spritesheet: 'spritesheet.webp', frameWidth: 99999, frameHeight: 64 }),
        'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
      });
      const res = await request(app)
        .post('/api/users/me/pets/import?filename=huge.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);
      expect(res.status).toBe(200);
      expect(res.body.pet.frameWidth).toBeNull();
      expect(res.body.pet.frameHeight).toBeNull();
    });

    it('rejects a pet zip without pet.json', async () => {
      const zip = makePetZip({ 'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake') });
      const res = await request(app)
        .post('/api/users/me/pets/import?filename=nope.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/pet\.json/);
    });

    it('rejects hidden zip paths', async () => {
      const zip = makePetZip({
        'pet.json': JSON.stringify({ name: 'Bad' }),
        '.hidden.png': 'x',
      });
      const res = await request(app)
        .post('/api/users/me/pets/import?filename=bad.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);
      expect(res.status).toBe(400);
    });

    it('keeps pets isolated by user', async () => {
      const zip = makePetZip({
        'pet.json': JSON.stringify({ name: 'Private' }),
        'spritesheet.png': Buffer.from([0x89, 0x50, 0x4e, 0x47]),
      });
      await request(app)
        .post('/api/users/me/pets/import?filename=private.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);

      const appB = makeApp(USER_B, tmpRoot);
      const res = await request(appB).get('/api/users/me/pets/private');
      expect(res.status).toBe(404);
    });

    it('updates pet settings with validation', async () => {
      const ok = await request(app)
        .put('/api/users/me/pets/settings')
        .send({ enabled: false, activePetId: null, size: 48, toolSparkEnabled: false });
      expect(ok.status).toBe(200);
      expect(ok.body.settings.enabled).toBe(false);
      expect(ok.body.settings.size).toBe(48);

      const bad = await request(app)
        .put('/api/users/me/pets/settings')
        .send({ size: 13 });
      expect(bad.status).toBe(400);
    });

    it('accepts a valid workerPets mapping and defaults to an empty map', async () => {
      const defaults = await request(app).get('/api/users/me/pets');
      expect(defaults.body.settings.workerPets).toEqual({});

      const ok = await request(app)
        .put('/api/users/me/pets/settings')
        .send({ workerPets: { gpu1: 'lumi', gpu2: 'mio' } });
      expect(ok.status).toBe(200);
      expect(ok.body.settings.workerPets).toEqual({ gpu1: 'lumi', gpu2: 'mio' });
    });

    it('rejects workerPets with invalid key or value', async () => {
      const badKey = await request(app)
        .put('/api/users/me/pets/settings')
        .send({ workerPets: { 'spaces are bad': 'lumi' } });
      expect(badKey.status).toBe(400);

      const badValue = await request(app)
        .put('/api/users/me/pets/settings')
        .send({ workerPets: { gpu1: 'Not A Pet Id!' } });
      expect(badValue.status).toBe(400);
    });

    it('treats empty / null workerPets values as removals', async () => {
      await request(app)
        .put('/api/users/me/pets/settings')
        .send({ workerPets: { gpu1: 'lumi', gpu2: 'mio' } });
      const cleared = await request(app)
        .put('/api/users/me/pets/settings')
        .send({ workerPets: { gpu1: '', gpu2: 'mio' } });
      expect(cleared.status).toBe(200);
      expect(cleared.body.settings.workerPets).toEqual({ gpu2: 'mio' });
    });

    it('clears workerPets entries when the mapped pet is deleted', async () => {
      const zip = makePetZip({
        'pet.json': JSON.stringify({ name: 'Removable', spritesheet: 'spritesheet.webp' }),
        'spritesheet.webp': Buffer.from('RIFFxxxxWEBPfake'),
      });
      await request(app)
        .post('/api/users/me/pets/import?filename=removable.zip')
        .set('Content-Type', 'application/zip')
        .send(zip);

      await request(app)
        .put('/api/users/me/pets/settings')
        .send({ workerPets: { gpu1: 'removable', gpu2: 'removable' } });

      const del = await request(app).delete('/api/users/me/pets/removable');
      expect(del.status).toBe(200);

      const after = await request(app).get('/api/users/me/pets');
      expect(after.body.settings.workerPets).toEqual({});
    });
  });

  // -------------------------------------------------------------------------
  // GET /folder/file
  // -------------------------------------------------------------------------

  describe('GET /folder/file', () => {
    it('returns file contents as text', async () => {
      const scriptsDir = join(tmpRoot, USER_A, 'scripts');
      mkdirSync(scriptsDir, { recursive: true });
      writeFileSync(join(scriptsDir, 'test.js'), 'console.log("hello")');

      const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=test.js');
      expect(res.status).toBe(200);
      expect(res.text).toBe('console.log("hello")');
    });

    it('returns 400 for path traversal attempt', async () => {
      const res = await request(app).get(
        '/api/users/me/folder/file?subdir=scripts&path=../../etc/passwd',
      );
      expect(res.status).toBe(400);
    });

    it('returns 404 for a missing file', async () => {
      const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=nope.js');
      expect(res.status).toBe(404);
    });

    it('returns 413 for a file larger than 1 MB', async () => {
      const scriptsDir = join(tmpRoot, USER_A, 'scripts');
      mkdirSync(scriptsDir, { recursive: true });
      // Write a 1.1 MB file
      const big = Buffer.alloc(1024 * 1024 + 100, 'x');
      writeFileSync(join(scriptsDir, 'big.txt'), big);

      const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=big.txt');
      expect(res.status).toBe(413);
    });

    it('returns 401 when req.user is missing', async () => {
      const unauthApp = makeUnauthApp(tmpRoot);
      const res = await request(unauthApp).get('/api/users/me/folder/file?subdir=scripts&path=x.js');
      expect(res.status).toBe(401);
    });
  });

  // -------------------------------------------------------------------------
  // PUT /folder/file
  // -------------------------------------------------------------------------

  describe('PUT /folder/file', () => {
    it('writes the file (verifiable via direct fs read)', async () => {
      const content = 'export const x = 1;';
      const res = await request(app)
        .put('/api/users/me/folder/file?subdir=scripts&path=new.js')
        .set('Content-Type', 'text/plain')
        .send(content);

      expect(res.status).toBe(200);
      expect(res.body.ok).toBe(true);
      expect(typeof res.body.size).toBe('number');
      expect(typeof res.body.mtime).toBe('string');

      const written = readFileSync(join(tmpRoot, USER_A, 'scripts', 'new.js'), 'utf-8');
      expect(written).toBe(content);
    });

    it('is atomic: a follow-up GET sees the new content', async () => {
      const content = 'const y = 42;';
      await request(app)
        .put('/api/users/me/folder/file?subdir=scripts&path=atomic.js')
        .set('Content-Type', 'text/plain')
        .send(content);

      const res = await request(app).get('/api/users/me/folder/file?subdir=scripts&path=atomic.js');
      expect(res.status).toBe(200);
      expect(res.text).toBe(content);
    });

    it('returns 413 when body exceeds 1 MB', async () => {
      const big = Buffer.alloc(1024 * 1024 + 100, 'a').toString();
      const res = await request(app)
        .put('/api/users/me/folder/file?subdir=scripts&path=big.js')
        .set('Content-Type', 'text/plain')
        .send(big);
      expect(res.status).toBe(413);
      expect(res.body.error).toMatch(/1 MB/);
    });

    it('PUT to trash subdir returns 400', async () => {
      const res = await request(app)
        .put('/api/users/me/folder/file?subdir=trash&path=sneaky.js')
        .set('Content-Type', 'text/plain')
        .send('evil');
      expect(res.status).toBe(400);
    });

    it('returns 401 when req.user is missing', async () => {
      const unauthApp = makeUnauthApp(tmpRoot);
      const res = await request(unauthApp)
        .put('/api/users/me/folder/file?subdir=scripts&path=x.js')
        .set('Content-Type', 'text/plain')
        .send('hi');
      expect(res.status).toBe(401);
    });
  });

  // -------------------------------------------------------------------------
  // DELETE /folder/file
  // -------------------------------------------------------------------------

  describe('DELETE /folder/file', () => {
    it('moves the file into trash/ with a timestamp prefix', async () => {
      const scriptsDir = join(tmpRoot, USER_A, 'scripts');
      mkdirSync(scriptsDir, { recursive: true });
      writeFileSync(join(scriptsDir, 'to-delete.js'), 'bye');

      const res = await request(app).delete(
        '/api/users/me/folder/file?subdir=scripts&path=to-delete.js',
      );
      expect(res.status).toBe(200);
      expect(res.body.ok).toBe(true);
      expect(typeof res.body.trashedAs).toBe('string');
      expect(res.body.trashedAs as string).toContain('to-delete.js');

      // Original file must be gone
      expect(existsSync(join(scriptsDir, 'to-delete.js'))).toBe(false);

      // File must exist in trash/
      const trashDir = join(tmpRoot, USER_A, 'trash');
      const trashFiles = readdirSync(trashDir);
      expect(trashFiles.some(f => f.endsWith('to-delete.js'))).toBe(true);
    });

    it('returns 401 when req.user is missing', async () => {
      const unauthApp = makeUnauthApp(tmpRoot);
      const res = await request(unauthApp).delete(
        '/api/users/me/folder/file?subdir=scripts&path=x.js',
      );
      expect(res.status).toBe(401);
    });

    it('DELETE from trash subdir returns 400', async () => {
      const res = await request(app).delete(
        '/api/users/me/folder/file?subdir=trash&path=some-trashed-file.js',
      );
      expect(res.status).toBe(400);
    });

    it('returns 404 when DELETE targets a missing file', async () => {
      const res = await request(app).delete(
        '/api/users/me/folder/file?subdir=scripts&path=ghost.js',
      );
      expect(res.status).toBe(404);
    });

    it('handles two same-name deletes in quick succession without data loss', async () => {
      const scriptsDir = join(tmpRoot, USER_A, 'scripts');
      mkdirSync(scriptsDir, { recursive: true });

      // First file
      writeFileSync(join(scriptsDir, 'dup.js'), 'first');
      const res1 = await request(app).delete(
        '/api/users/me/folder/file?subdir=scripts&path=dup.js',
      );
      expect(res1.status).toBe(200);
      const trashedAs1 = res1.body.trashedAs as string;

      // Second file with same name
      writeFileSync(join(scriptsDir, 'dup.js'), 'second');
      const res2 = await request(app).delete(
        '/api/users/me/folder/file?subdir=scripts&path=dup.js',
      );
      expect(res2.status).toBe(200);
      const trashedAs2 = res2.body.trashedAs as string;

      // Both trash names must be distinct
      expect(trashedAs1).not.toBe(trashedAs2);

      // Both files must exist in trash
      const trashDir = join(tmpRoot, USER_A, 'trash');
      expect(existsSync(join(trashDir, trashedAs1))).toBe(true);
      expect(existsSync(join(trashDir, trashedAs2))).toBe(true);
    });
  });

  // -------------------------------------------------------------------------
  // Cross-user isolation
  // -------------------------------------------------------------------------

  describe('Cross-user isolation', () => {
    it('user A cannot read files belonging to user B', async () => {
      // Write a file under user B's folder directly
      const bScriptsDir = join(tmpRoot, USER_B, 'scripts');
      mkdirSync(bScriptsDir, { recursive: true });
      writeFileSync(join(bScriptsDir, 'secret.js'), 'b-secret');

      // app is authed as USER_A; try to reach USER_B's file via traversal
      const res = await request(app).get(
        `/api/users/me/folder/file?subdir=scripts&path=../../${USER_B}/scripts/secret.js`,
      );
      // Must be 400 (traversal blocked) — NOT 200
      expect(res.status).toBe(400);
    });

    it('user A list only sees their own files, not user B files', async () => {
      // Create scripts for both users
      const aDir = join(tmpRoot, USER_A, 'scripts');
      const bDir = join(tmpRoot, USER_B, 'scripts');
      mkdirSync(aDir, { recursive: true });
      mkdirSync(bDir, { recursive: true });
      writeFileSync(join(aDir, 'a-only.js'), 'a');
      writeFileSync(join(bDir, 'b-only.js'), 'b');

      const res = await request(app).get('/api/users/me/folder/list?subdir=scripts');
      expect(res.status).toBe(200);
      const names = (res.body.files as Array<{ name: string }>).map(f => f.name);
      expect(names).toContain('a-only.js');
      expect(names).not.toContain('b-only.js');
    });
  });

  // -------------------------------------------------------------------------
  // POST /browser-macros/compile
  // -------------------------------------------------------------------------

  // Minimal valid recording fixture
  const MINIMAL_RECORDING = JSON.stringify({
    recordTo: 'test-rec',
    capturedAt: '2026-01-01T00:00:00.000Z',
    actions: [
      { type: 'goto', url: 'https://example.com', ts: '2026-01-01T00:00:00.000Z' },
    ],
  });

  describe('POST /browser-macros/compile', () => {
    it('compiles a recording and writes a script with frontmatter + body', async () => {
      const recDir = join(tmpRoot, USER_A, 'recordings');
      mkdirSync(recDir, { recursive: true });
      writeFileSync(join(recDir, 'my-rec.json'), MINIMAL_RECORDING);

      const res = await request(app)
        .post('/api/users/me/browser-macros/compile')
        .send({ recordingName: 'my-rec', scriptName: 'my-script', description: 'A test script' });

      expect(res.status).toBe(200);
      expect(res.body.ok).toBe(true);
      expect(res.body.scriptName).toBe('my-script.js');
      expect(typeof res.body.source).toBe('string');
      expect(typeof res.body.size).toBe('number');

      // File must exist on disk
      const scriptPath = join(tmpRoot, USER_A, 'browser-macros', 'my-script.js');
      expect(existsSync(scriptPath)).toBe(true);

      const written = readFileSync(scriptPath, 'utf-8');
      // Should have frontmatter (gray-matter header)
      expect(written).toContain('---');
      // Should have the goto call in the body
      expect(written).toContain('page.goto');
    });

    it('returns 404 when recording is missing', async () => {
      const res = await request(app)
        .post('/api/users/me/browser-macros/compile')
        .send({ recordingName: 'no-such-rec', scriptName: 'out', description: 'X' });
      expect(res.status).toBe(404);
    });

    it('returns 400 when recording file is malformed JSON', async () => {
      const recDir = join(tmpRoot, USER_A, 'recordings');
      mkdirSync(recDir, { recursive: true });
      writeFileSync(join(recDir, 'bad.json'), 'not json {{');

      const res = await request(app)
        .post('/api/users/me/browser-macros/compile')
        .send({ recordingName: 'bad', scriptName: 'out', description: 'X' });
      expect(res.status).toBe(400);
    });

    it('returns 400 when recording is valid JSON but missing actions', async () => {
      const recDir = join(tmpRoot, USER_A, 'recordings');
      mkdirSync(recDir, { recursive: true });
      writeFileSync(join(recDir, 'no-actions.json'), JSON.stringify({ recordTo: 'x', capturedAt: 'y' }));

      const res = await request(app)
        .post('/api/users/me/browser-macros/compile')
        .send({ recordingName: 'no-actions', scriptName: 'out', description: 'X' });
      expect(res.status).toBe(400);
    });

    it('returns 409 when script already exists and no ?overwrite=true', async () => {
      const recDir = join(tmpRoot, USER_A, 'recordings');
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      mkdirSync(recDir, { recursive: true });
      mkdirSync(macrosDir, { recursive: true });
      writeFileSync(join(recDir, 'my-rec2.json'), MINIMAL_RECORDING);
      writeFileSync(join(macrosDir, 'existing.js'), '// already here');

      const res = await request(app)
        .post('/api/users/me/browser-macros/compile')
        .send({ recordingName: 'my-rec2', scriptName: 'existing', description: 'X' });
      expect(res.status).toBe(409);
      expect(res.body.error).toMatch(/overwrite=true/);
    });

    it('overwrites script when ?overwrite=true is passed', async () => {
      const recDir = join(tmpRoot, USER_A, 'recordings');
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      mkdirSync(recDir, { recursive: true });
      mkdirSync(macrosDir, { recursive: true });
      writeFileSync(join(recDir, 'my-rec3.json'), MINIMAL_RECORDING);
      writeFileSync(join(macrosDir, 'will-replace.js'), '// old content');

      const res = await request(app)
        .post('/api/users/me/browser-macros/compile?overwrite=true')
        .send({ recordingName: 'my-rec3', scriptName: 'will-replace', description: 'replaced' });
      expect(res.status).toBe(200);
      expect(res.body.ok).toBe(true);

      const written = readFileSync(join(macrosDir, 'will-replace.js'), 'utf-8');
      expect(written).not.toBe('// old content');
    });

    it('applies paramHints: param name appears in body, param appears in frontmatter', async () => {
      const recDir = join(tmpRoot, USER_A, 'recordings');
      mkdirSync(recDir, { recursive: true });
      const recWithFill = JSON.stringify({
        recordTo: 'fill-rec',
        capturedAt: '2026-01-01T00:00:00.000Z',
        actions: [
          { type: 'goto', url: 'https://example.com', ts: '2026-01-01T00:00:01.000Z' },
          { type: 'fill', selector: '#email', value: 'user@example.com', ts: '2026-01-01T00:00:02.000Z' },
        ],
      });
      writeFileSync(join(recDir, 'fill-rec.json'), recWithFill);

      const res = await request(app)
        .post('/api/users/me/browser-macros/compile')
        .send({
          recordingName: 'fill-rec',
          scriptName: 'fill-script',
          description: 'Fill example',
          paramHints: [{ name: 'email', valueToReplace: 'user@example.com', type: 'string' }],
        });

      expect(res.status).toBe(200);
      const { source } = res.body as { source: string };
      // Body should use params.email instead of literal string
      expect(source).toContain('params.email');
      // Frontmatter should declare the param
      expect(source).toContain('email');
    });
  });

  // -------------------------------------------------------------------------
  // POST /scripts/:name/run
  // -------------------------------------------------------------------------

  // A minimal script that doesn't require Playwright — uses the raw module.exports form
  // so the child process can execute it without a real browser.
  const SIMPLE_SCRIPT_BODY = `module.exports = async function main() { return 42; };`;

  describe('POST /scripts/:name/run', () => {
    it('runs a script that returns 42', async () => {
      const scriptDir = join(tmpRoot, USER_A, 'scripts');
      mkdirSync(scriptDir, { recursive: true });
      writeFileSync(join(scriptDir, 'simple.js'), SIMPLE_SCRIPT_BODY);

      const res = await request(app)
        .post('/api/users/me/scripts/simple/run')
        .send({});

      expect(res.status).toBe(200);
      expect(res.body.result).toBe(42);
      expect(Array.isArray(res.body.logs)).toBe(true);
      expect(typeof res.body.durationMs).toBe('number');
    });

    it('returns 404 when script is missing', async () => {
      const res = await request(app)
        .post('/api/users/me/scripts/ghost/run')
        .send({});
      expect(res.status).toBe(404);
    });

    it('returns 500 with "param" in error when params are bad', async () => {
      const scriptDir = join(tmpRoot, USER_A, 'scripts');
      mkdirSync(scriptDir, { recursive: true });
      // Script with a declared param of type string
      const scriptWithParam = `\
---
description: Needs a string param
params:
  - name: username
    type: string
---
module.exports = async function main({ params }) { return params.username; };
`;
      writeFileSync(join(scriptDir, 'needs-param.js'), scriptWithParam);

      const res = await request(app)
        .post('/api/users/me/scripts/needs-param/run')
        .send({ params: { username: 12345 } }); // wrong type: number instead of string

      expect(res.status).toBe(500);
      expect(res.body.error.toLowerCase()).toContain('param');
    });

    it('clamps timeoutMs to 5 minutes max', async () => {
      const scriptDir = join(tmpRoot, USER_A, 'scripts');
      mkdirSync(scriptDir, { recursive: true });
      writeFileSync(join(scriptDir, 'fast.js'), SIMPLE_SCRIPT_BODY);

      // POST /run with extreme timeoutMs value
      const res = await request(app)
        .post('/api/users/me/scripts/fast/run')
        .send({ timeoutMs: 999_999_999 });

      // If clamping is working, the request succeeds (doesn't exceed actual limit).
      // No way to verify the cap directly from the API response, so we just confirm
      // the run succeeds without error (which it wouldn't if the cap wasn't applied).
      expect(res.status).toBe(200);
      expect(res.body.result).toBe(42);
    });
  });

  // -------------------------------------------------------------------------
  // POST /recordings/flush
  // -------------------------------------------------------------------------

  describe('POST /recordings/flush', () => {
    afterEach(() => {
      // Ensure any leftover buffers are cancelled after each test
      recorder.cancel('flush-test-task');
      recorder.cancel('flush-test-empty');
    });

    it('returns 400 without taskId', async () => {
      const res = await request(app).post('/api/users/me/recordings/flush');
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/taskId/);
    });

    it('returns 404 when no buffer exists for taskId', async () => {
      const res = await request(app).post('/api/users/me/recordings/flush?taskId=no-such-task');
      expect(res.status).toBe(404);
      expect(res.body.error).toMatch(/no active recording/);
    });

    it('flushes the buffer and returns recordingName', async () => {
      const taskId = 'flush-test-task';
      // Enable recording and record one action
      recorder.enable(taskId, 'test-rec');
      recorder.record(taskId, { type: 'goto', url: 'https://x.com', frameChain: [] });

      const res = await request(app)
        .post(`/api/users/me/recordings/flush?taskId=${taskId}`);

      expect(res.status).toBe(200);
      expect(res.body.ok).toBe(true);
      expect(res.body.recordingName).toBe('test-rec');
      expect(typeof res.body.path).toBe('string');
      expect(res.body.path).toContain('test-rec.json');

      // Assert the file was written to disk
      const recFile = join(tmpRoot, USER_A, 'recordings', 'test-rec.json');
      expect(existsSync(recFile)).toBe(true);

      const written = JSON.parse(readFileSync(recFile, 'utf-8'));
      expect(written.recordTo).toBe('test-rec');
      expect(Array.isArray(written.actions)).toBe(true);
      expect(written.actions[0].type).toBe('goto');
    });

    it('returns 404 when buffer is enabled but empty', async () => {
      const taskId = 'flush-test-empty';
      // Enable but don't record anything — flush returns null for empty buffer
      recorder.enable(taskId, 'empty-rec');

      const res = await request(app)
        .post(`/api/users/me/recordings/flush?taskId=${taskId}`);

      expect(res.status).toBe(404);
      expect(res.body.error).toMatch(/no active recording/);
    });
  });

  // -------------------------------------------------------------------------
  // GET /browser-macros/:name/diff  |  POST /browser-macros/:name/accept  |  POST /browser-macros/:name/reject
  // -------------------------------------------------------------------------

  describe('GET /browser-macros/:name/diff', () => {
    it('returns current + candidate contents when both exist', async () => {
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      mkdirSync(macrosDir, { recursive: true });
      writeFileSync(join(macrosDir, 'myscript.js'), '// original');
      writeFileSync(join(macrosDir, 'myscript.next.js'), '// patched');

      const res = await request(app).get('/api/users/me/browser-macros/myscript/diff');
      expect(res.status).toBe(200);
      expect(res.body.current).toBe('// original');
      expect(res.body.candidate).toBe('// patched');
      expect(typeof res.body.candidateMtime).toBe('string');
    });

    it('returns current: null when only .next.js exists (orphaned candidate)', async () => {
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      mkdirSync(macrosDir, { recursive: true });
      writeFileSync(join(macrosDir, 'orphan.next.js'), '// orphan patch');

      const res = await request(app).get('/api/users/me/browser-macros/orphan/diff');
      expect(res.status).toBe(200);
      expect(res.body.current).toBeNull();
      expect(res.body.candidate).toBe('// orphan patch');
      expect(typeof res.body.candidateMtime).toBe('string');
    });

    it('returns 404 when .next.js is absent', async () => {
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      mkdirSync(macrosDir, { recursive: true });
      writeFileSync(join(macrosDir, 'existing.js'), '// only original');

      const res = await request(app).get('/api/users/me/browser-macros/existing/diff');
      expect(res.status).toBe(404);
    });

    it('accepts name with .js suffix (normalizes it)', async () => {
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      mkdirSync(macrosDir, { recursive: true });
      writeFileSync(join(macrosDir, 'normalize.js'), '// orig');
      writeFileSync(join(macrosDir, 'normalize.next.js'), '// next');

      const res = await request(app).get('/api/users/me/browser-macros/normalize.js/diff');
      expect(res.status).toBe(200);
      expect(res.body.current).toBe('// orig');
      expect(res.body.candidate).toBe('// next');
    });
  });

  describe('POST /browser-macros/:name/accept', () => {
    it('archives the original to trash and renames .next.js into place', async () => {
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      const trashDir = join(tmpRoot, USER_A, 'trash');
      mkdirSync(macrosDir, { recursive: true });
      mkdirSync(trashDir, { recursive: true });
      writeFileSync(join(macrosDir, 'foo.js'), '// old version');
      writeFileSync(join(macrosDir, 'foo.next.js'), '// new version');

      const res = await request(app).post('/api/users/me/browser-macros/foo/accept');
      expect(res.status).toBe(200);
      expect(res.body.ok).toBe(true);
      expect(res.body.accepted).toBe('foo.js');
      expect(typeof res.body.archivedAs).toBe('string');
      expect(res.body.archivedAs).toContain('foo.js');

      // .next.js must be gone; .js must have the new content
      expect(existsSync(join(macrosDir, 'foo.next.js'))).toBe(false);
      expect(readFileSync(join(macrosDir, 'foo.js'), 'utf-8')).toBe('// new version');

      // Old version must be in trash
      expect(existsSync(join(trashDir, res.body.archivedAs))).toBe(true);
      expect(readFileSync(join(trashDir, res.body.archivedAs), 'utf-8')).toBe('// old version');
    });

    it('returns 404 when .next.js is absent', async () => {
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      mkdirSync(macrosDir, { recursive: true });

      const res = await request(app).post('/api/users/me/browser-macros/nopatch/accept');
      expect(res.status).toBe(404);
    });

    it('works when no original .js exists (orphan candidate)', async () => {
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      mkdirSync(macrosDir, { recursive: true });
      writeFileSync(join(macrosDir, 'new-script.next.js'), '// brand new');

      const res = await request(app).post('/api/users/me/browser-macros/new-script/accept');
      expect(res.status).toBe(200);
      expect(res.body.ok).toBe(true);
      expect(res.body.archivedAs).toBeNull();

      // .next.js gone; .js has the content
      expect(existsSync(join(macrosDir, 'new-script.next.js'))).toBe(false);
      expect(readFileSync(join(macrosDir, 'new-script.js'), 'utf-8')).toBe('// brand new');
    });
  });

  describe('POST /browser-macros/:name/reject', () => {
    it('moves .next.js to trash; original stays unchanged', async () => {
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      const trashDir = join(tmpRoot, USER_A, 'trash');
      mkdirSync(macrosDir, { recursive: true });
      mkdirSync(trashDir, { recursive: true });
      writeFileSync(join(macrosDir, 'bar.js'), '// keep me');
      writeFileSync(join(macrosDir, 'bar.next.js'), '// unwanted patch');

      const res = await request(app).post('/api/users/me/browser-macros/bar/reject');
      expect(res.status).toBe(200);
      expect(res.body.ok).toBe(true);
      expect(res.body.rejected).toBe('bar.next.js');
      expect(typeof res.body.trashedAs).toBe('string');
      expect(res.body.trashedAs).toContain('bar.next.js');

      // .next.js must be gone; original must still have original content
      expect(existsSync(join(macrosDir, 'bar.next.js'))).toBe(false);
      expect(readFileSync(join(macrosDir, 'bar.js'), 'utf-8')).toBe('// keep me');

      // Rejected candidate must be in trash
      expect(existsSync(join(trashDir, res.body.trashedAs))).toBe(true);
    });

    it('returns 404 when .next.js is absent', async () => {
      const macrosDir = join(tmpRoot, USER_A, 'browser-macros');
      mkdirSync(macrosDir, { recursive: true });

      const res = await request(app).post('/api/users/me/browser-macros/nopatch/reject');
      expect(res.status).toBe(404);
    });
  });

  // -------------------------------------------------------------------------
  // POST /browser-macros/compile — paramHints validation
  // -------------------------------------------------------------------------

  describe('POST /browser-macros/compile — paramHints validation', () => {
    it('returns 400 when paramHints is not an array', async () => {
      const recDir = join(tmpRoot, USER_A, 'recordings');
      mkdirSync(recDir, { recursive: true });
      writeFileSync(join(recDir, 'rec.json'), MINIMAL_RECORDING);

      const res = await request(app)
        .post('/api/users/me/browser-macros/compile')
        .send({
          recordingName: 'rec',
          scriptName: 'test',
          description: 'Test',
          paramHints: { foo: 'bar' }, // Object instead of array
        });

      expect(res.status).toBe(400);
      expect(res.body.error).toContain('paramHints must be an array');
    });

    it('returns 400 when paramHints entry is missing name', async () => {
      const recDir = join(tmpRoot, USER_A, 'recordings');
      mkdirSync(recDir, { recursive: true });
      writeFileSync(join(recDir, 'rec.json'), MINIMAL_RECORDING);

      const res = await request(app)
        .post('/api/users/me/browser-macros/compile')
        .send({
          recordingName: 'rec',
          scriptName: 'test',
          description: 'Test',
          paramHints: [{ valueToReplace: 'x', type: 'string' }], // Missing name
        });

      expect(res.status).toBe(400);
      expect(res.body.error).toContain('paramHints[0]');
      expect(res.body.error).toContain('name');
    });

    it('returns 400 when paramHints entry has invalid type', async () => {
      const recDir = join(tmpRoot, USER_A, 'recordings');
      mkdirSync(recDir, { recursive: true });
      writeFileSync(join(recDir, 'rec.json'), MINIMAL_RECORDING);

      const res = await request(app)
        .post('/api/users/me/browser-macros/compile')
        .send({
          recordingName: 'rec',
          scriptName: 'test',
          description: 'Test',
          paramHints: [{ name: 'a', valueToReplace: 'x', type: 'date' }], // Invalid type: 'date'
        });

      expect(res.status).toBe(400);
      expect(res.body.error).toContain('paramHints[0]');
      expect(res.body.error).toContain('type');
    });
  });

  // -------------------------------------------------------------------------
  // Auth gate fallback (authActive flag)
  // -------------------------------------------------------------------------

  describe('auth gate fallback', () => {
    it('returns 401 when authActive=true (default) and no user', async () => {
      // makeUnauthApp uses the default (authActive not passed → defaults to true)
      const unauthApp = makeUnauthApp(tmpRoot);
      const res = await request(unauthApp).get('/api/users/me/folder/list?subdir=scripts');
      expect(res.status).toBe(401);
      expect(res.body.error).toMatch(/Unauthenticated/i);
    });

    it('falls back to synthetic local user when authActive=false', async () => {
      const noAuthApp = makeNoAuthModeApp(tmpRoot);
      // Pre-create the 'local' user scripts dir so list returns 200 rather than 500
      const localScriptsDir = join(tmpRoot, 'local', 'scripts');
      mkdirSync(localScriptsDir, { recursive: true });

      const res = await request(noAuthApp).get('/api/users/me/folder/list?subdir=scripts');
      expect(res.status).toBe(200);
      expect(Array.isArray(res.body.files)).toBe(true);
    });
  });

  // -------------------------------------------------------------------------
  // PUT /folder/file with subdir=notes
  // -------------------------------------------------------------------------

  describe('PUT /folder/file with subdir=notes', () => {
    it('writes note and creates DB index row via notes-service', async () => {
      const noteContent = `---\ntitle: Test\nvisibility: public\n---\nbody content`;
      const res = await request(app)
        .put('/api/users/me/folder/file?subdir=notes&path=cve/foo.md')
        .set('Content-Type', 'text/plain')
        .send(noteContent);
      expect(res.status).toBe(200);
      expect(res.body.ok).toBe(true);
      expect(res.body.indexed).toBe(true);
      const filePath = join(tmpRoot, USER_A, 'notes', 'cve', 'foo.md');
      expect(existsSync(filePath)).toBe(true);
    });

    it('rejects PUT with depth-1 path (no folder)', async () => {
      const res = await request(app)
        .put('/api/users/me/folder/file?subdir=notes&path=foo.md')
        .set('Content-Type', 'text/plain')
        .send('body');
      expect(res.status).toBe(400);
    });

    it('rejects PUT with depth-3 path', async () => {
      const res = await request(app)
        .put('/api/users/me/folder/file?subdir=notes&path=a/b/c.md')
        .set('Content-Type', 'text/plain')
        .send('body');
      expect(res.status).toBe(400);
    });
  });
});
