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, 'browser-macros'), { 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 browser-macros with descriptions and params', async () => {
    writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'foo.js'), SCRIPT_FOO);
    writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', 'bar.js'), SCRIPT_BAR);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('ListUserAssets', { kind: 'browser-macros' }, 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('Browser Macros (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', 'browser-macros'), { recursive: true });
    writeFileSync(
      join(userFolderRoot, 'other-user', 'browser-macros', '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 macros (0 macros).
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('ListUserAssets', { kind: 'browser-macros' }, ctx);

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

  it('returns "all" categories when kind is omitted', async () => {
    writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', '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('Browser Macros');
    expect(result!.output).toContain('Recordings');
    // Retired categories must no longer appear
    expect(result!.output).not.toContain('Templates');
    expect(result!.output).not.toContain('Scripts (');
  });
});

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

describe('RunUserScript', () => {
  it('runs a fixture macro that returns a value', async () => {
    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);

    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 () => {
    // Macro declares `date:string` but we pass wrong type
    writeFileSync(join(userFolderRoot, TEST_USER, 'browser-macros', '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/);
  });

  // ── Retirement (2026-06): plain-Node scripts/ are gone ─────────────────────

  it('a file living only in the retired scripts/ dir is no longer resolvable', async () => {
    mkdirSync(join(userFolderRoot, TEST_USER, 'scripts'), { recursive: true });
    writeFileSync(join(userFolderRoot, TEST_USER, 'scripts', 'legacy.js'), SCRIPT_NO_FM);

    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'legacy' }, ctx);
    expect(result!.isError).toBe(true);
    expect(result!.output.toLowerCase()).toContain('not found');
  });

  it('explicit kind: "script" is rejected with a pointer to Bash/Skills', async () => {
    const ctx = buildCtx({ userId: TEST_USER });
    const result = await executeTool('RunUserScript', { name: 'whatever', kind: 'script' }, ctx);
    expect(result!.isError).toBe(true);
    expect(result!.output).toContain('retired');
    expect(result!.output).toContain('Bash');
  });

  it('kind: "browser-macro" still accepted explicitly (back-compat)', async () => {
    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', kind: 'browser-macro' }, ctx);
    expect(result!.isError).toBe(false);
    expect(result!.output).toContain('42');
  });
});

// ── 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');
  });
});

// ── 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, 'browser-macros', '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, 'browser-macros', '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, 'browser-macros', '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('rejects the retired kind: "script" with a pointer to Skills/Bash', 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(true);
    expect(result!.output).toContain('retired');
  });

  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('writes to browser-macros/ when kind is 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('browser-macros/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('browser-macros/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, 'browser-macros', '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');
  });
});

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, 'browser-macros', '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);
  });
});
