import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { fileURLToPath } from 'node:url';
import { describe, it, expect, afterEach } from 'vitest';
import { runUserScript } from './script-runner.js';

// Resolve paths relative to this test file
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Fixtures are at <repo>/tests/fixtures/scripts/
const fixturesDir = path.resolve(__dirname, '../../tests/fixtures/scripts');

function fixture(name: string) {
  return path.join(fixturesDir, name);
}

const browserMacroFixturesDir = path.resolve(__dirname, '../../tests/fixtures/browser-macros');
function browserMacroFixture(name: string) {
  return path.join(browserMacroFixturesDir, name);
}

// Playwright E2E is opt-out (cheap to skip when chromium isn't installed in CI).
// Set SKIP_PLAYWRIGHT_E2E=1 to bypass these tests; they run by default since
// dev environments and CI both ship the chromium binary via @playwright/test.
const skipPlaywright = process.env['SKIP_PLAYWRIGHT_E2E'] === '1';

// Track temp files for cleanup
const tmpFiles: string[] = [];
afterEach(() => {
  for (const f of tmpFiles) {
    try { fs.unlinkSync(f); } catch { /* already gone */ }
  }
  tmpFiles.length = 0;
});

/**
 * Write a temporary script file and return its path.
 * The file is registered for cleanup in afterEach.
 */
function writeTmpScript(content: string): string {
  const tmpPath = path.join(os.tmpdir(), `test-script-${process.pid}-${Date.now()}.js`);
  fs.writeFileSync(tmpPath, content, 'utf-8');
  tmpFiles.push(tmpPath);
  return tmpPath;
}

// ─── Test 1: return-42.js → result is 42 ──────────────────────────────────────

describe('script-runner', () => {
  it('resolves with result=42 for return-42.js (plain runtime)', async () => {
    const res = await runUserScript({
      scriptPath: fixture('return-42.js'),
      params: {},
      runtime: 'plain',
    });
    expect(res.result).toBe(42);
    expect(typeof res.durationMs).toBe('number');
    expect(Array.isArray(res.logs)).toBe(true);
  }, 10_000); // plain runtime is fast — no Chromium launch

  // ─── Test 2: timeout.js → rejects after ~200ms ──────────────────────────────

  it('rejects with timeout error when script hangs (plain runtime)', async () => {
    await expect(
      runUserScript({
        scriptPath: fixture('timeout.js'),
        params: {},
        runtime: 'plain',
        timeoutMs: 500,
      })
    ).rejects.toThrow(/timeout/);
  }, 10_000);

  // ─── Test 3: throws.js → rejects with exit code error ──────────────────────

  it('rejects with exit-code error and includes stderr for throws.js (plain runtime)', async () => {
    await expect(
      runUserScript({
        scriptPath: fixture('throws.js'),
        params: {},
        runtime: 'plain',
      })
    ).rejects.toThrow(/exited code 1.*boom from user script/s);
  }, 10_000);

  // ─── Test 4: param type mismatch → synchronous rejection ───────────────────

  it('rejects synchronously on param type mismatch (no browser spawn)', async () => {
    // Script with a frontmatter declaring x: number
    const scriptContent = `---
description: "Echo params"
params:
  - name: x
    type: number
---
async function main({ params }) { return params.x; }
module.exports = main;
`;
    const scriptPath = writeTmpScript(scriptContent);

    // Passing a string where number is expected
    await expect(
      runUserScript({
        scriptPath,
        params: { x: 'not a number' },
        runtime: 'plain',
      })
    ).rejects.toThrow(/param x: expected number/);
  });

  // ─── Test 5: param default applied ─────────────────────────────────────────

  it('applies param defaults when optional param is omitted (plain runtime)', async () => {
    // Script with a boolean param that has default: false
    const scriptContent = `---
description: "Echo verbose"
params:
  - name: verbose
    type: boolean
    default: false
---
async function main({ params }) { return params.verbose; }
module.exports = main;
`;
    const scriptPath = writeTmpScript(scriptContent);

    const res = await runUserScript({
      scriptPath,
      params: {}, // no 'verbose' key
      runtime: 'plain',
    });
    expect(res.result).toBe(false);
  }, 10_000);

  // ─── Test 6: stderr captured into logs[] ────────────────────────────────────

  it('captures stderr into logs[] (plain runtime)', async () => {
    const res = await runUserScript({
      scriptPath: fixture('logs-to-stderr.js'),
      params: {},
      runtime: 'plain',
    });
    expect(res.result).toBe('done');
    expect(res.logs.some(l => l.includes('hello from stderr'))).toBe(true);
    expect(res.logs.some(l => l.includes('second line'))).toBe(true);
  }, 10_000);

  // ─── Test 7: bad default type → rejected with /invalid default/ ─────────────

  it('rejects when frontmatter default has wrong type', async () => {
    const scriptContent = `---
description: "Bad default"
params:
  - name: count
    type: number
    default: "not-a-number"
---
async function main({ params }) { return params.count; }
module.exports = main;
`;
    const scriptPath = writeTmpScript(scriptContent);
    await expect(
      runUserScript({ scriptPath, params: {}, runtime: 'plain' })
    ).rejects.toThrow(/invalid default/);
  });

  // ─── Test 8: stdout cap → rejects with /stdout exceeded/ ────────────────────

  it('rejects when script stdout exceeds 1 MB (plain runtime)', async () => {
    // Emit 2 MB of data synchronously then return
    const scriptContent = `---
description: "Stdout flood"
params: []
---
async function main() {
  // Write ~2 MB of data to stdout in one shot via process.stdout.write
  // (bypasses our console.log interception — tests raw stdout cap)
  const chunk = Buffer.alloc(1024, 65); // 'A' * 1024
  for (let i = 0; i < 2048; i++) process.stdout.write(chunk);
  return 'done';
}
module.exports = main;
`;
    const scriptPath = writeTmpScript(scriptContent);
    await expect(
      runUserScript({ scriptPath, params: {}, runtime: 'plain' })
    ).rejects.toThrow(/stdout exceeded/);
  }, 30_000);

  // ─── Test 9: circular object → serializationError, result is null ───────────

  it('returns null result with serializationError for circular objects (plain runtime)', async () => {
    const res = await runUserScript({
      scriptPath: fixture('circular.js'),
      params: {},
      runtime: 'plain',
    });
    expect(res.result).toBeNull();
    expect(typeof res.serializationError).toBe('string');
    expect(res.serializationError).toMatch(/circular/i);
  }, 10_000);

  // ─── Test 10a: plain runtime + session_profile_id → synchronous rejection ───

  it('rejects synchronously when session_profile_id is set on plain runtime', async () => {
    const scriptContent = `---
description: "Browser macro mistakenly placed in scripts/"
session_profile_id: 7
---
async function main() { return 'should never run'; }
module.exports = main;
`;
    const scriptPath = writeTmpScript(scriptContent);
    await expect(
      runUserScript({ scriptPath, params: {}, runtime: 'plain' })
    ).rejects.toThrow(/session_profile_id.*plain/i);
  });

  // ─── Test 10b: plain runtime + no session_profile_id → no rejection ─────────

  it('does not reject when session_profile_id is absent on plain runtime', async () => {
    const scriptContent = `---
description: "Normal plain script"
---
async function main() { return 'ok'; }
module.exports = main;
`;
    const scriptPath = writeTmpScript(scriptContent);
    const res = await runUserScript({ scriptPath, params: {}, runtime: 'plain' });
    expect(res.result).toBe('ok');
  }, 10_000);

  // ─── Test 10: plain runtime does not launch Chromium ────────────────────────

  it('plain runtime completes quickly (no Chromium launch overhead)', async () => {
    const scriptContent = `---
description: "Quick plain script"
params: []
---
async function main({ params }) { return 'fast'; }
module.exports = main;
`;
    const scriptPath = writeTmpScript(scriptContent);
    const start = Date.now();
    const res = await runUserScript({ scriptPath, params: {}, runtime: 'plain' });
    const elapsed = Date.now() - start;
    expect(res.result).toBe('fast');
    // Plain runtime should complete well under 5 seconds (Chromium launch is ~2-5s)
    expect(elapsed).toBeLessThan(5_000);
  }, 10_000);
});

// ── --permission sandbox (plain runtime) ─────────────────────────────────────

describe('script-runner: --permission sandbox (plain runtime)', () => {
  it('blocks child_process.spawn (no --allow-child-process flag is passed)', async () => {
    const scriptPath = writeTmpScript(`
async function main() {
  const cp = require('child_process');
  cp.spawnSync('/bin/echo', ['x']);
  return 'should-not-reach-here';
}
module.exports = main;
`);
    await expect(
      runUserScript({ scriptPath, params: {}, runtime: 'plain' })
    ).rejects.toThrow(/permission/i);
  });

  it('steers child_process denials to the Bash tool (python footgun hint)', async () => {
    // The recurring failure: a plain script tries to shell out to python.
    // --permission denies child_process; the error must point the caller at
    // Bash (which has the pre-baked pip env) instead of leaving them stuck.
    const scriptPath = writeTmpScript(`
async function main() {
  const cp = require('child_process');
  cp.spawnSync('python3', ['-c', 'print(1)']);
  return 'should-not-reach-here';
}
module.exports = main;
`);
    await expect(
      runUserScript({ scriptPath, params: {}, runtime: 'plain' })
    ).rejects.toThrow(/use the Bash tool/);
  });

  it('blocks worker_threads (no --allow-worker flag is passed)', async () => {
    const scriptPath = writeTmpScript(`
async function main() {
  const { Worker } = require('worker_threads');
  new Worker('console.log("x")', { eval: true });
  return 'should-not-reach-here';
}
module.exports = main;
`);
    await expect(
      runUserScript({ scriptPath, params: {}, runtime: 'plain' })
    ).rejects.toThrow(/permission/i);
  });

  it('blocks reading files outside the staging tmp dir', async () => {
    const scriptPath = writeTmpScript(`
async function main() {
  const fs = require('fs');
  // /etc/passwd is allowed to exist but should be unreadable under --permission
  // because we only --allow-fs-read=<tmpdir,scriptDir>.
  fs.readFileSync('/etc/passwd', 'utf-8');
  return 'should-not-reach-here';
}
module.exports = main;
`);
    await expect(
      runUserScript({ scriptPath, params: {}, runtime: 'plain' })
    ).rejects.toThrow(/permission|ERR_ACCESS_DENIED/i);
  });

  it('still lets the script return values, console.log, and use tmp-dir fs', async () => {
    const scriptPath = writeTmpScript(`
async function main({ params }) {
  console.log('logged from inside the sandbox');
  return { ok: true, n: params.n };
}
module.exports = main;
`);
    // The runner declares the param 'n' in its compiled wrapper, so the plain
    // path validates against the script's own frontmatter — we have none here,
    // so pass empty params.
    const res = await runUserScript({ scriptPath, params: {}, runtime: 'plain' });
    expect(res.result).toMatchObject({ ok: true });
    expect(res.logs.some(l => l.includes('logged from inside the sandbox'))).toBe(true);
  });
});

// ── Playwright runtime E2E ────────────────────────────────────────────────────

describe.skipIf(skipPlaywright)('script-runner: playwright runtime (E2E)', () => {
  it('navigates a data: URL, clicks a button, and returns textContent', async () => {
    const res = await runUserScript({
      scriptPath: browserMacroFixture('click-and-read.js'),
      params: { title: 'hello-from-fixture' },
      runtime: 'playwright',
    });
    expect(res.result).toBe('revealed:hello-from-fixture');
    expect(typeof res.durationMs).toBe('number');
    expect(res.durationMs).toBeGreaterThan(0);
  }, 30_000);

  it('runs a browser-macro without session frontmatter', async () => {
    const res = await runUserScript({
      scriptPath: browserMacroFixture('no-frontmatter.js'),
      params: {},
      runtime: 'playwright',
    });
    expect(res.result).toBe('hello');
  }, 30_000);

  it('surfaces an error when the macro throws inside the browser context', async () => {
    const scriptPath = writeTmpScript(`
async function main({ context }) {
  const page = await context.newPage();
  try {
    await page.goto('data:text/html,<body>x</body>');
    throw new Error('intentional inside playwright');
  } finally {
    await page.close();
  }
}
module.exports = main;
`);
    await expect(
      runUserScript({ scriptPath, params: {}, runtime: 'playwright' })
    ).rejects.toThrow(/intentional inside playwright/);
  }, 30_000);
});
