import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
  mkdtempSync,
  rmSync,
  mkdirSync,
  writeFileSync,
} from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { TOOL_DEFS, executeTool, setUserFolderToolDeps } from './user-folder.js';
import { recorder } from '../../engine/browser-recorder.js';
import type { ToolContext } from './core.js';

// ── Mock loadConfig: enable user scripts for these tool tests ─────────────────
// The real loadConfig reads config.yaml which doesn't exist in CI / test env.
// We need userScriptsEnabled: true so RunUserScript tests aren't blocked by the gate.
const { mockedLoadConfig } = vi.hoisted(() => ({
  mockedLoadConfig: vi.fn(),
}));

vi.mock('../../config.js', () => ({
  loadConfig: mockedLoadConfig,
}));

// Default config for all tests: scripts enabled
mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true } });

// ── Fixtures ──────────────────────────────────────────────────────────────────

const SCRIPT_FOO = `\
---
description: Log into example.com
params:
  - name: date
    type: string
---
async function main({ date }) {
  return { logged: true, date };
}
`;

const SCRIPT_BAR = `\
---
description: Check dashboard
params: []
---
async function main() {
  return { dashboard: 'ok' };
}
`;

// Minimal script that the child runner can execute (must export a callable)
const SCRIPT_NO_FM = `module.exports = async function main({ params }) { return 42; };`;

// Script that always throws at runtime
const SCRIPT_THROWS = `\
---
description: Always fails
params: []
---
module.exports = async function main() { throw new Error('boom'); };
`;

function buildCtx(extra: Partial<ToolContext> = {}): ToolContext {
  return {
    workspacePath: '/tmp',
    editAllowed: false,
    ...extra,
  };
}

// ── Setup ─────────────────────────────────────────────────────────────────────

let tempDir: string;
let userFolderRoot: string;
const TEST_USER = 'user-abc';

beforeEach(() => {
  tempDir = mkdtempSync(join(tmpdir(), 'uf-tool-test-'));
  userFolderRoot = join(tempDir, 'users');
  mkdirSync(userFolderRoot, { recursive: true });

  // Create user subdirs (including memory and trash for memory tool tests)
  mkdirSync(join(userFolderRoot, TEST_USER, 'scripts'), { recursive: true });
  mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true });
  mkdirSync(join(userFolderRoot, TEST_USER, 'templates'), { recursive: true });
  mkdirSync(join(userFolderRoot, TEST_USER, 'recordings'), { recursive: true });
  mkdirSync(join(userFolderRoot, TEST_USER, 'memory'), { recursive: true });
  mkdirSync(join(userFolderRoot, TEST_USER, 'trash'), { recursive: true });

  // Inject deps (no sessRepo needed for these tests — scripts have no sessionProfileId)
  setUserFolderToolDeps({
    sessRepo: null as never,
    masterKeyPath: '',
    userFolderRoot,
  });
});

afterEach(() => {
  rmSync(tempDir, { recursive: true, force: true });
  // Reset deps
  setUserFolderToolDeps(null);
  // Cancel any lingering recorder state (tests use unique taskIds but clean up defensively)
  recorder.cancel('test-task-failure');
  recorder.cancel('test-task-no-override');
});

// ── TOOL_DEFS ─────────────────────────────────────────────────────────────────

describe('TOOL_DEFS', () => {
  it('exports ListUserAssets and RunUserScript', () => {
    expect(TOOL_DEFS).toHaveProperty('ListUserAssets');
    expect(TOOL_DEFS).toHaveProperty('RunUserScript');
  });
});

// ── ListUserAssets ────────────────────────────────────────────────────────────

describe('ListUserAssets', () => {
  it('lists scripts with descriptions and params', async () => {
    writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'foo.js'), SCRIPT_FOO);
    writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'bar.js'), SCRIPT_BAR);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('ListUserAssets', { kind: 'scripts' }, ctx);

    expect(result).not.toBeNull();
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('foo.js');
    expect(result!.output).toContain('Log into example.com');
    expect(result!.output).toContain('date:string');
    expect(result!.output).toContain('bar.js');
    expect(result!.output).toContain('Check dashboard');
    expect(result!.output).toContain('Scripts (2)');
  });

  it('returns error when userId is missing', async () => {
    const ctx = buildCtx({ userId: undefined });
    const result = await executeTool('ListUserAssets', {}, ctx);

    expect(result).not.toBeNull();
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('authenticated');
  });

  it('cross-user access is denied (ctx.userId must match target folder)', async () => {
    // Create another user's folder
    mkdirSync(join(userFolderRoot, 'other-user', 'scripts'), { recursive: true });
    writeFileSync(
      join(userFolderRoot, 'other-user', 'scripts', 'secret.js'),
      SCRIPT_FOO,
    );

    // Logged in as TEST_USER but the tool always reads from ctx.userId,
    // so there is no way to list another user's folder.
    // Verify that the tool reads only TEST_USER's scripts (0 scripts).
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('ListUserAssets', { kind: 'scripts' }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('Scripts (0)');
    expect(result!.output).not.toContain('secret.js');
  });

  it('returns "all" categories when kind is omitted', async () => {
    writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'foo.js'), SCRIPT_FOO);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('ListUserAssets', {}, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('Scripts');
    expect(result!.output).toContain('Templates');
    expect(result!.output).toContain('Recordings');
  });
});

// ── RunUserScript ─────────────────────────────────────────────────────────────

describe('RunUserScript', () => {
  it('runs a fixture script that returns a value', async () => {
    writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_NO_FM);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'nofm' }, ctx);

    expect(result).not.toBeNull();
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('42');
  });

  it('returns isError true with "param" in message for bad params', async () => {
    // Script declares `date:string` but we pass wrong type
    writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'foo.js'), SCRIPT_FOO);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool(
      'RunUserScript',
      { name: 'foo', params: { date: 123 } }, // wrong type
      ctx,
    );

    expect(result!.isError).toBe(true);
    expect(result!.output.toLowerCase()).toContain('param');
  });

  it('returns isError true with "not found" for missing script', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'nonexistent' }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output.toLowerCase()).toContain('not found');
  });

  it('returns isError true with clear message when browser-macro needs session but sessRepo is unavailable', async () => {
    // Script with sessionProfileId — but sessRepo is null (no browser session infra)
    const SCRIPT_WITH_SESSION = `\
---
description: Needs login
params: []
session_profile_id: 1
---
module.exports = async function main() { return 'ok'; };
`;
    mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true });
    writeFileSync(
      join(userFolderRoot, TEST_USER, 'browser-macros', 'authed.js'),
      SCRIPT_WITH_SESSION,
    );

    // Keep userFolderRoot but remove sessRepo (simulates missing browser session infra)
    setUserFolderToolDeps({
      sessRepo: null as never,
      masterKeyPath: '',
      userFolderRoot,
    });

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'authed', kind: 'browser-macro' }, ctx);

    expect(result!.isError).toBe(true);
    // Should mention that deps are not configured and suggest BrowseWeb
    expect(result!.output).toContain('not configured');
    expect(result!.output.toLowerCase()).toContain('browseweb');
  });

  it('on browser-macro failure, auto-enables recorder with .next suffix', async () => {
    mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true });
    writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'boom.js'), SCRIPT_THROWS);

    const taskId = 'test-task-failure';
    const ctx = buildCtx({ userId: TEST_USER, taskId });

    // Ensure no pre-existing recorder state for this taskId
    recorder.cancel(taskId);

    const result = await executeTool('RunUserScript', { name: 'boom', kind: 'browser-macro' }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('boom.next.js');
    expect(result!.output).toContain('recorder is now enabled');
    // Recorder should now be active for this task with the .next recordTo
    expect(recorder.recordTo(taskId)).toBe('boom.next');
  });

  it('does not override existing recordTo when browser-macro fails', async () => {
    mkdirSync(join(userFolderRoot, TEST_USER, 'browser-macros'), { recursive: true });
    writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'boom.js'), SCRIPT_THROWS);

    const taskId = 'test-task-no-override';
    const ctx = buildCtx({ userId: TEST_USER, taskId });

    // Pre-enable the recorder with a user-chosen recordTo
    recorder.enable(taskId, 'manual-rec');

    const result = await executeTool('RunUserScript', { name: 'boom', kind: 'browser-macro' }, ctx);

    expect(result!.isError).toBe(true);
    // The recorder should still be set to the original value, not overridden
    expect(recorder.recordTo(taskId)).toBe('manual-rec');
  });

  // ── P1: path traversal protection ─────────────────────────────────────────

  it('rejects path traversal in RunUserScript name', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: '../../escape' }, ctx);
    expect(result!.isError).toBe(true);
    expect(result!.output).toMatch(/invalid script name|outside owner folder|not found/);
  });

  // ── P2a: kind fallback (undefined → search scripts/ then browser-macros/) ─

  it('resolves to scripts/ first when kind omitted', async () => {
    writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_NO_FM);
    writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_THROWS);

    const ctx = buildCtx({ userId: TEST_USER });
    // kind omitted → should find scripts/nofm.js (plain runtime, returns 42)
    const result = await executeTool('RunUserScript', { name: 'nofm' }, ctx);
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('42');
  });

  it('falls back to browser-macros/ when kind omitted and not in scripts/', async () => {
    // Only exists in browser-macros/
    writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_NO_FM);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'nofm' }, ctx);
    // Should find it in browser-macros/ (plain-compatible SCRIPT_NO_FM, no playwright deps)
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('42');
  });

  it('kind: "browser-macro" goes straight to browser-macros/', async () => {
    writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'nofm.js'), SCRIPT_THROWS);
    writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'nofm.js'), SCRIPT_NO_FM);

    const ctx = buildCtx({ userId: TEST_USER });
    // Explicit kind=browser-macro should use browser-macros/ not scripts/
    const result = await executeTool('RunUserScript', { name: 'nofm', kind: 'browser-macro' }, ctx);
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('42');
  });

  it('returns error mentioning both subdirs when kind omitted and not found', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'nonexistent' }, ctx);
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('scripts/');
    expect(result!.output).toContain('browser-macros/');
  });

  // ── session_profile_id in scripts/ (plain runtime) → friendly error ───────

  it('rejects scripts/*.js with session_profile_id (plain runtime mismatch)', async () => {
    const scriptWithSession = `\
---
description: Mistakenly placed browser macro
session_profile_id: 3
---
module.exports = async function main() { return 'unreachable'; };
`;
    writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'wrongplace.js'), scriptWithSession);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'wrongplace', kind: 'script' }, ctx);
    expect(result!.isError).toBe(true);
    expect(result!.output).toMatch(/session_profile_id/);
    expect(result!.output).toMatch(/browser-macros/);
  });
});

// ── UpdateUserMemory ──────────────────────────────────────────────────────────

describe('UpdateUserMemory', () => {
  it('upserts a memory entry and returns success message', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('UpdateUserMemory', {
      action: 'upsert',
      name: 'test-fact',
      type: 'user',
      description: 'A test fact',
      body: 'Details about the test fact.',
    }, ctx);

    expect(result).not.toBeNull();
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('upserted');
    expect(result!.output).toContain('test-fact');
  });

  it('returns error when userId is missing (owner gate)', async () => {
    const ctx = buildCtx({ userId: undefined });
    const result = await executeTool('UpdateUserMemory', {
      action: 'upsert',
      name: 'x',
      type: 'user',
      description: 'x',
      body: 'x',
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('authenticated');
  });

  it('returns error for invalid type', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('UpdateUserMemory', {
      action: 'upsert',
      name: 'bad',
      type: 'bogus-type',
      description: 'X',
      body: 'Y',
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output.toLowerCase()).toContain('type');
  });

  it('returns error for invalid name (path traversal chars)', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('UpdateUserMemory', {
      action: 'upsert',
      name: '../escape',
      type: 'user',
      description: 'X',
      body: 'Y',
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output.toLowerCase()).toContain('name');
  });

  it('deletes an existing entry and returns success', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    // First upsert
    await executeTool('UpdateUserMemory', {
      action: 'upsert',
      name: 'to-delete',
      type: 'feedback',
      description: 'Will be deleted',
      body: 'bye',
    }, ctx);

    // Then delete
    const result = await executeTool('UpdateUserMemory', {
      action: 'delete',
      name: 'to-delete',
    }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('deleted');
    expect(result!.output).toContain('to-delete');
  });

  it('returns error when deleting a non-existent entry', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('UpdateUserMemory', {
      action: 'delete',
      name: 'nonexistent',
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('not found');
  });
});

// ── ReadUserTemplate ──────────────────────────────────────────────────────────

describe('ReadUserTemplate', () => {
  it('reads a plain markdown template by name', async () => {
    writeFileSync(
      join(userFolderRoot, TEST_USER, 'templates', 'weekly-report.md'),
      '# Weekly Report\n\nFill in this week\'s highlights here.\n',
    );
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('ReadUserTemplate', { name: 'weekly-report' }, ctx);

    expect(result).not.toBeNull();
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('# Template: weekly-report');
    expect(result!.output).toContain('Fill in this week\'s highlights here.');
  });

  it('reads a template with frontmatter', async () => {
    const content = [
      '---',
      'title: API Error Email',
      'audience: external',
      '---',
      'Dear customer,',
      '',
      'We apologize for the inconvenience.',
    ].join('\n') + '\n';
    writeFileSync(
      join(userFolderRoot, TEST_USER, 'templates', 'api-error-email.md'),
      content,
    );
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('ReadUserTemplate', { name: 'api-error-email' }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('## Frontmatter');
    expect(result!.output).toContain('title');
    expect(result!.output).toContain('API Error Email');
    expect(result!.output).toContain('Dear customer,');
  });

  it('returns error for missing template', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('ReadUserTemplate', { name: 'nonexistent' }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('not found');
  });

  it('rejects path traversal in name', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('ReadUserTemplate', { name: '../escape' }, ctx);

    expect(result!.isError).toBe(true);
  });

  it('handles name with .md suffix gracefully', async () => {
    writeFileSync(
      join(userFolderRoot, TEST_USER, 'templates', 'boilerplate.md'),
      'Hello world template.\n',
    );
    const ctx = buildCtx({ userId: TEST_USER });
    // Pass name with .md extension — should still work
    const result = await executeTool('ReadUserTemplate', { name: 'boilerplate.md' }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('Hello world template.');
  });

  it('rejects without authenticated user', async () => {
    const ctx = buildCtx({ userId: undefined });
    const result = await executeTool('ReadUserTemplate', { name: 'anything' }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('authenticated');
  });
});

// ── RenderUserTemplate ────────────────────────────────────────────────────────

describe('RenderUserTemplate', () => {
  it('substitutes {{var}} declared in frontmatter.params', async () => {
    const content = [
      '---',
      'description: Weekly report',
      'params:',
      '  - name: date',
      '    type: string',
      '  - name: summary',
      '    type: string',
      '---',
      'On {{date}}: {{summary}}',
    ].join('\n') + '\n';
    writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'weekly.md'), content);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool(
      'RenderUserTemplate',
      { name: 'weekly', params: { date: '2026-05-11', summary: 'shipped 3 PRs' } },
      ctx,
    );

    expect(result!.isError).toBe(false);
    expect(result!.output).toBe('On 2026-05-11: shipped 3 PRs\n');
  });

  it('applies declared defaults when params are omitted', async () => {
    const content = [
      '---',
      'description: Greeting',
      'params:',
      '  - name: name',
      '    type: string',
      '    default: world',
      '---',
      'Hello, {{name}}!',
    ].join('\n') + '\n';
    writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'greet.md'), content);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RenderUserTemplate', { name: 'greet' }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toBe('Hello, world!\n');
  });

  it('leaves undeclared {{var}} literal', async () => {
    const content = [
      '---',
      'description: Mixed',
      'params:',
      '  - name: title',
      '    type: string',
      '---',
      '# {{title}}\n\nSee {{see_also}} for details.',
    ].join('\n') + '\n';
    writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'mixed.md'), content);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool(
      'RenderUserTemplate',
      { name: 'mixed', params: { title: 'Notes' } },
      ctx,
    );

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('# Notes');
    expect(result!.output).toContain('See {{see_also}} for details.');
  });

  it('rejects when a required param is missing', async () => {
    const content = [
      '---',
      'description: Required param',
      'params:',
      '  - name: subject',
      '    type: string',
      '---',
      'Subject: {{subject}}',
    ].join('\n') + '\n';
    writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'req.md'), content);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RenderUserTemplate', { name: 'req' }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toMatch(/subject.*required/i);
  });

  it('rejects type-mismatched params', async () => {
    const content = [
      '---',
      'description: Number param',
      'params:',
      '  - name: count',
      '    type: number',
      '---',
      'Total: {{count}}',
    ].join('\n') + '\n';
    writeFileSync(join(userFolderRoot, TEST_USER, 'templates', 'count.md'), content);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool(
      'RenderUserTemplate',
      { name: 'count', params: { count: 'three' } },
      ctx,
    );

    expect(result!.isError).toBe(true);
    expect(result!.output).toMatch(/count.*expected number/);
  });

  it('renders a template without frontmatter as-is', async () => {
    writeFileSync(
      join(userFolderRoot, TEST_USER, 'templates', 'plain.md'),
      'Just plain text with {{notRendered}}.\n',
    );

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RenderUserTemplate', { name: 'plain' }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toBe('Just plain text with {{notRendered}}.\n');
  });

  it('rejects path traversal', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RenderUserTemplate', { name: '../escape' }, ctx);
    expect(result!.isError).toBe(true);
  });

  it('returns error for missing template', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RenderUserTemplate', { name: 'never-existed' }, ctx);
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('not found');
  });

  it('rejects without authenticated user', async () => {
    const ctx = buildCtx({ userId: undefined });
    const result = await executeTool('RenderUserTemplate', { name: 'anything' }, ctx);
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('authenticated');
  });
});

// ── ReadUserMemory ────────────────────────────────────────────────────────────

describe('ReadUserMemory', () => {
  it('reads an existing memory entry', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    // Set up entry first
    await executeTool('UpdateUserMemory', {
      action: 'upsert',
      name: 'readable-fact',
      type: 'project',
      description: 'A readable fact',
      body: 'This is the body.',
    }, ctx);

    const result = await executeTool('ReadUserMemory', { name: 'readable-fact' }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('readable-fact');
    expect(result!.output).toContain('project');
    expect(result!.output).toContain('This is the body.');
  });

  it('returns error (404-style) for missing entry', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('ReadUserMemory', { name: 'missing-entry' }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('not found');
  });

  it('returns error when userId is missing (owner gate)', async () => {
    const ctx = buildCtx({ userId: undefined });
    const result = await executeTool('ReadUserMemory', { name: 'anything' }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('authenticated');
  });
});

// ── RunUserScript: per-user allowlist + audit log ────────────────────────────

describe('RunUserScript: tools.user_scripts_allow_userids', () => {
  // Override the default loadConfig mock for these tests; restored after.
  afterEach(() => {
    mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true } });
  });

  it('refuses when the calling user is not in the allowlist', async () => {
    mockedLoadConfig.mockReturnValue({
      tools: { userScriptsEnabled: true, userScriptsAllowUserids: ['other-user'] },
    });
    writeFileSync(
      join(userFolderRoot, TEST_USER, 'scripts', 'noop.js'),
      `---\nparams: []\n---\nasync function main(){return 'ok';}\nmodule.exports=main;\n`,
      'utf-8',
    );
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'noop' }, ctx);
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('user_scripts_allow_userids');
  });

  it('runs when the calling user is in the allowlist', async () => {
    mockedLoadConfig.mockReturnValue({
      tools: { userScriptsEnabled: true, userScriptsAllowUserids: [TEST_USER, 'someone-else'] },
    });
    writeFileSync(
      join(userFolderRoot, TEST_USER, 'scripts', 'ok.js'),
      `---\nparams: []\n---\nasync function main(){return 'allowed';}\nmodule.exports=main;\n`,
      'utf-8',
    );
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'ok' }, ctx);
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('allowed');
  });

  it('empty allowlist is treated as "no gate" (only the global enable matters)', async () => {
    mockedLoadConfig.mockReturnValue({
      tools: { userScriptsEnabled: true, userScriptsAllowUserids: [] },
    });
    writeFileSync(
      join(userFolderRoot, TEST_USER, 'scripts', 'empty.js'),
      `---\nparams: []\n---\nasync function main(){return 'still ok';}\nmodule.exports=main;\n`,
      'utf-8',
    );
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'empty' }, ctx);
    expect(result!.isError).toBe(false);
  });
});

// ── WriteUserScript ───────────────────────────────────────────────────────────

const VALID_SCRIPT = `\
---
description: Fetch and clean data
params:
  - name: url
    type: string
---
async function main({ params }) {
  return { fetched: params.url };
}
`;

const VALID_BROWSER_MACRO = `\
---
description: Open a page
params: []
---
async function main({ context, params }) {
  const page = await context.newPage();
  await page.goto('https://example.com');
  return { ok: true };
}
`;

describe('WriteUserScript', () => {
  it('writes a plain script to scripts/', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', {
      name: 'fetch-and-clean',
      content: VALID_SCRIPT,
      kind: 'script',
    }, ctx);

    expect(result).not.toBeNull();
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('scripts/fetch-and-clean.js');

    const written = join(userFolderRoot, TEST_USER, 'scripts', 'fetch-and-clean.js');
    const { existsSync: checkExists, readFileSync: rf } = await import('fs');
    expect(checkExists(written)).toBe(true);
    expect(rf(written, 'utf-8')).toBe(VALID_SCRIPT);
  });

  it('writes a browser-macro to browser-macros/', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', {
      name: 'open-page',
      content: VALID_BROWSER_MACRO,
      kind: 'browser-macro',
    }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('browser-macros/open-page.js');

    const { existsSync: checkExists } = await import('fs');
    const written = join(userFolderRoot, TEST_USER, 'browser-macros', 'open-page.js');
    expect(checkExists(written)).toBe(true);
  });

  it('defaults kind to "script" when omitted', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', {
      name: 'implicit-kind',
      content: VALID_SCRIPT,
    }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('scripts/implicit-kind.js');
  });

  it('accepts name with .js suffix (strips it)', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', {
      name: 'my-script.js',
      content: VALID_SCRIPT,
    }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('scripts/my-script.js');
    // Must not write "my-script.js.js"
    expect(result!.output).not.toContain('my-script.js.js');
  });

  it('rejects missing name', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', { content: VALID_SCRIPT }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('"name"');
  });

  it('rejects non-slug name (slash)', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', {
      name: 'foo/bar',
      content: VALID_SCRIPT,
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output.toLowerCase()).toContain('alphanumeric');
  });

  it('rejects non-slug name (space)', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', {
      name: 'my script',
      content: VALID_SCRIPT,
    }, ctx);

    expect(result!.isError).toBe(true);
  });

  it('rejects path traversal via name', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', {
      name: '../escape',
      content: VALID_SCRIPT,
    }, ctx);

    expect(result!.isError).toBe(true);
  });

  it('rejects content without a main definition', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', {
      name: 'no-main',
      content: 'function helper() { return 1; }',
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('main');
  });

  it('accepts module.exports = ... as a valid main definition', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', {
      name: 'cjs-export',
      content: 'module.exports = async function main({ params }) { return 42; };',
    }, ctx);

    expect(result!.isError).toBe(false);
  });

  it('rejects oversized content', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const huge = 'async function main() {}\n' + 'x'.repeat(256 * 1024);
    const result = await executeTool('WriteUserScript', {
      name: 'big-script',
      content: huge,
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('bytes');
  });

  it('rejects duplicate without overwrite', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    // Write once
    await executeTool('WriteUserScript', { name: 'dup', content: VALID_SCRIPT }, ctx);
    // Write again without overwrite
    const result = await executeTool('WriteUserScript', { name: 'dup', content: VALID_SCRIPT }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('overwrite');
  });

  it('overwrites when overwrite: true', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    await executeTool('WriteUserScript', { name: 'overwritable', content: VALID_SCRIPT }, ctx);

    const updated = VALID_SCRIPT.replace('fetched', 'updated');
    const result = await executeTool('WriteUserScript', {
      name: 'overwritable',
      content: updated,
      overwrite: true,
    }, ctx);

    expect(result!.isError).toBe(false);
    const { readFileSync: rf } = await import('fs');
    const written = join(userFolderRoot, TEST_USER, 'scripts', 'overwritable.js');
    expect(rf(written, 'utf-8')).toContain('updated');
  });

  it('requires authenticated user', async () => {
    const ctx = buildCtx({ userId: undefined });
    const result = await executeTool('WriteUserScript', {
      name: 'test',
      content: VALID_SCRIPT,
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('authenticated');
  });

  it('invokes auditLog on success', async () => {
    const auditCalls: Array<{ action: string; detail: Record<string, unknown> }> = [];
    setUserFolderToolDeps({
      sessRepo: null as never,
      masterKeyPath: '',
      userFolderRoot,
      auditLog: (action, detail) => { auditCalls.push({ action, detail: detail as Record<string, unknown> }); },
    });

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserScript', {
      name: 'audit-test',
      content: VALID_SCRIPT,
    }, ctx);

    expect(result!.isError).toBe(false);
    const entry = auditCalls.find(c => c.action === 'user_script_written');
    expect(entry).toBeDefined();
    expect(entry!.detail['userId']).toBe(TEST_USER);
    expect(entry!.detail['filename']).toBe('audit-test.js');
  });
});

// ── WriteUserTemplate ─────────────────────────────────────────────────────────

const VALID_TEMPLATE = `\
---
description: Weekly report
params:
  - name: date
    type: string
---
# Weekly Report — {{date}}

Highlights this week:
- ...
`;

describe('WriteUserTemplate', () => {
  it('writes a template to templates/', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserTemplate', {
      name: 'weekly-report',
      content: VALID_TEMPLATE,
    }, ctx);

    expect(result).not.toBeNull();
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('templates/weekly-report.md');

    const { existsSync: checkExists, readFileSync: rf } = await import('fs');
    const written = join(userFolderRoot, TEST_USER, 'templates', 'weekly-report.md');
    expect(checkExists(written)).toBe(true);
    expect(rf(written, 'utf-8')).toBe(VALID_TEMPLATE);
  });

  it('accepts name with .md suffix (strips it)', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserTemplate', {
      name: 'report.md',
      content: VALID_TEMPLATE,
    }, ctx);

    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('templates/report.md');
    // Must not write "report.md.md"
    expect(result!.output).not.toContain('report.md.md');
  });

  it('rejects missing name', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserTemplate', { content: VALID_TEMPLATE }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('"name"');
  });

  it('rejects non-slug name (slash)', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserTemplate', {
      name: 'foo/bar',
      content: VALID_TEMPLATE,
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output.toLowerCase()).toContain('alphanumeric');
  });

  it('rejects path traversal via name', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserTemplate', {
      name: '../escape',
      content: VALID_TEMPLATE,
    }, ctx);

    expect(result!.isError).toBe(true);
  });

  it('rejects oversized content', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const huge = '# template\n' + 'x'.repeat(128 * 1024);
    const result = await executeTool('WriteUserTemplate', {
      name: 'big-template',
      content: huge,
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('bytes');
  });

  it('rejects duplicate without overwrite', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    await executeTool('WriteUserTemplate', { name: 'dup', content: VALID_TEMPLATE }, ctx);
    const result = await executeTool('WriteUserTemplate', { name: 'dup', content: VALID_TEMPLATE }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('overwrite');
  });

  it('overwrites when overwrite: true', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    await executeTool('WriteUserTemplate', { name: 'overwritable-tmpl', content: VALID_TEMPLATE }, ctx);

    const updated = VALID_TEMPLATE.replace('Weekly Report', 'Monthly Report');
    const result = await executeTool('WriteUserTemplate', {
      name: 'overwritable-tmpl',
      content: updated,
      overwrite: true,
    }, ctx);

    expect(result!.isError).toBe(false);
    const { readFileSync: rf } = await import('fs');
    const written = join(userFolderRoot, TEST_USER, 'templates', 'overwritable-tmpl.md');
    expect(rf(written, 'utf-8')).toContain('Monthly Report');
  });

  it('requires authenticated user', async () => {
    const ctx = buildCtx({ userId: undefined });
    const result = await executeTool('WriteUserTemplate', {
      name: 'test',
      content: VALID_TEMPLATE,
    }, ctx);

    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('authenticated');
  });

  it('invokes auditLog on success', async () => {
    const auditCalls: Array<{ action: string; detail: Record<string, unknown> }> = [];
    setUserFolderToolDeps({
      sessRepo: null as never,
      masterKeyPath: '',
      userFolderRoot,
      auditLog: (action, detail) => { auditCalls.push({ action, detail: detail as Record<string, unknown> }); },
    });

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('WriteUserTemplate', {
      name: 'audit-tmpl',
      content: VALID_TEMPLATE,
    }, ctx);

    expect(result!.isError).toBe(false);
    const entry = auditCalls.find(c => c.action === 'user_template_written');
    expect(entry).toBeDefined();
    expect(entry!.detail['userId']).toBe(TEST_USER);
    expect(entry!.detail['filename']).toBe('audit-tmpl.md');
  });

  it('written template is immediately readable via ReadUserTemplate', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    await executeTool('WriteUserTemplate', {
      name: 'round-trip',
      content: VALID_TEMPLATE,
    }, ctx);

    const result = await executeTool('ReadUserTemplate', { name: 'round-trip' }, ctx);
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('Weekly Report');
    expect(result!.output).toContain('{{date}}');
  });
});

describe('RunUserScript: audit log hook', () => {
  afterEach(() => {
    mockedLoadConfig.mockReturnValue({ tools: { userScriptsEnabled: true } });
  });

  it('invokes the auditLog dep with ok=true after a successful run', async () => {
    const auditCalls: Array<{ action: string; detail: Record<string, unknown>; jobId?: string | null }> = [];
    setUserFolderToolDeps({
      sessRepo: null as never,
      masterKeyPath: '',
      userFolderRoot,
      auditLog: (action, detail, jobId) => {
        auditCalls.push({ action, detail: detail as Record<string, unknown>, jobId });
      },
    });
    writeFileSync(
      join(userFolderRoot, TEST_USER, 'scripts', 'audited.js'),
      `---\nparams: []\n---\nasync function main(){return 'audited ok';}\nmodule.exports=main;\n`,
      'utf-8',
    );
    const ctx = buildCtx({ userId: TEST_USER, taskId: 'task-123' });
    const result = await executeTool('RunUserScript', { name: 'audited' }, ctx);
    expect(result!.isError).toBe(false);

    const run = auditCalls.find(c => c.action === 'user_script_run');
    expect(run).toBeDefined();
    expect(run!.detail['ok']).toBe(true);
    expect(run!.detail['userId']).toBe(TEST_USER);
    expect(run!.detail['scriptName']).toBe('audited');
    expect(run!.jobId).toBe('task-123');
  });

  it('logs user_script_denied when the allowlist refuses the call', async () => {
    mockedLoadConfig.mockReturnValue({
      tools: { userScriptsEnabled: true, userScriptsAllowUserids: ['other-user'] },
    });
    const auditCalls: Array<{ action: string }> = [];
    setUserFolderToolDeps({
      sessRepo: null as never,
      masterKeyPath: '',
      userFolderRoot,
      auditLog: (action) => { auditCalls.push({ action }); },
    });
    const ctx = buildCtx({ userId: TEST_USER });
    await executeTool('RunUserScript', { name: 'whatever' }, ctx);

    expect(auditCalls.some(c => c.action === 'user_script_denied')).toBe(true);
  });
});
