/**
 * script-runner.ts
 *
 * Parent process that:
 *  1. Reads and parses the user script's frontmatter.
 *  2. Validates params against the declared spec (type-checks, defaults).
 *  3. Strips frontmatter, writes the body to a temp .cjs file.
 *  4. Spawns a Node child process (script-runner-child.js) with the temp path.
 *  5. Sends input via stdin, collects JSON lines from stdout, enforces timeout.
 *  6. Cleans up the temp file after child exits.
 */

import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import { parseScript } from './frontmatter.js';
import type { ParamSpec } from './frontmatter.js';

// ── Types ─────────────────────────────────────────────────────────────────────

export interface RunScriptOptions {
  /** Absolute path to the .js file (may have YAML frontmatter). */
  scriptPath: string;
  /** Runtime parameter values supplied by the caller. */
  params: Record<string, unknown>;
  /**
   * 'plain'     — plain Node.js, no Chromium, main({ params }) signature.
   * 'playwright' — launches Chromium via Playwright, main({ context, params }) signature.
   */
  runtime: 'plain' | 'playwright';
  /** Playwright storageState for authenticated sessions; undefined = no session.
   *  Only meaningful when runtime === 'playwright'. Ignored (with a warn) otherwise. */
  storageState?: object;
  /** Milliseconds before the child is killed. Default 30_000. */
  timeoutMs?: number;
  /** Launch browser headless. Default true. Only meaningful for runtime === 'playwright'. */
  headless?: boolean;
}

export interface RunScriptResult {
  /** Whatever the user script's main() returned. */
  result: unknown;
  /** Lines collected from the child's console.log / structured log lines. */
  logs: string[];
  /** Wall-clock time from spawn to completion, in ms. */
  durationMs: number;
  /** Set when the child's result could not be JSON-serialized (e.g. circular reference, BigInt). */
  serializationError?: string;
}

// ── Param validation ──────────────────────────────────────────────────────────

/**
 * Validates and normalises caller-supplied params against the frontmatter spec.
 * Returns a new params object with defaults applied.
 * Throws synchronously on any mismatch.
 *
 * Exported because RenderUserTemplate reuses the same param spec semantics
 * (scripts/browser-macros/templates all share the frontmatter params shape).
 */
export function validateAndApplyDefaults(
  spec: ParamSpec[],
  params: Record<string, unknown>
): Record<string, unknown> {
  const result: Record<string, unknown> = {};

  // Check for extra params not in the spec
  const specNames = new Set(spec.map((p) => p.name));
  for (const key of Object.keys(params)) {
    if (!specNames.has(key)) {
      throw new Error(`param ${key}: not declared in script frontmatter`);
    }
  }

  for (const p of spec) {
    const { name, type } = p;

    if (Object.prototype.hasOwnProperty.call(params, name)) {
      // Param was provided — type-check it
      const value = params[name];
      // eslint-disable-next-line valid-typeof
      if (typeof value !== type) {
        throw new Error(`param ${name}: expected ${type}, got ${typeof value}`);
      }
      result[name] = value;
    } else if (Object.prototype.hasOwnProperty.call(p, 'default')) {
      // Use declared default — type-check it same as caller-provided params
      const defaultVal = p.default;
      // eslint-disable-next-line valid-typeof
      if (typeof defaultVal !== type) {
        throw new Error(
          `param ${name}: invalid default value (expected ${type}, got ${typeof defaultVal})`
        );
      }
      result[name] = defaultVal;
    } else {
      // Required param missing
      throw new Error(`param ${name}: required but not provided`);
    }
  }

  return result;
}

// ── Child path resolution ─────────────────────────────────────────────────────

/**
 * Resolves the absolute path to script-runner-child.js.
 *
 * During development (vitest/tsx): import.meta.url points to the .ts source in
 * src/user-folder/.  The compiled child is at dist/user-folder/script-runner-child.js.
 *
 * After `tsc` (production): both files are in dist/user-folder/ and the sibling
 * path resolves directly.
 *
 * Strategy:
 *  1. Try sibling in the same directory (production path).
 *  2. Fall back to the dist/ equivalent (development / vitest path).
 */
function resolveChildPath(): string {
  const thisFile = fileURLToPath(import.meta.url);
  const thisDir = dirname(thisFile);

  // Candidate 1: sibling .js in same directory (works after tsc)
  const sibling = join(thisDir, 'script-runner-child.js');
  if (existsSync(sibling)) return sibling;

  // Candidate 2: compiled dist/ version (works when vitest runs from src/)
  // Walk up until we find the project root (contains package.json), then go to dist/
  let dir = thisDir;
  let distChild = '';
  for (let i = 0; i < 10; i++) {
    const pkg = join(dir, 'package.json');
    if (existsSync(pkg)) {
      distChild = join(dir, 'dist', 'user-folder', 'script-runner-child.js');
      if (existsSync(distChild)) return distChild;
      break;
    }
    const parent = dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }

  throw new Error(
    `script-runner-child.js not found. Run \`npm run build\` to compile it. Searched: ${sibling}, ${distChild}`
  );
}

// ── Main export ───────────────────────────────────────────────────────────────

export async function runUserScript(opts: RunScriptOptions): Promise<RunScriptResult> {
  const {
    scriptPath,
    params,
    runtime,
    storageState,
    timeoutMs = 30_000,
    headless = true,
  } = opts;

  if (runtime === 'plain' && storageState !== undefined) {
    // eslint-disable-next-line no-console
    console.warn('[script-runner] storageState was provided for runtime=plain; it will be ignored.');
  }

  // 1. Parse frontmatter
  const source = readFileSync(scriptPath, 'utf-8');
  const parsed = parseScript(source);

  // 1a. Reject session_profile_id on plain runtime — it only applies to
  // browser-macros (playwright). Silently ignoring it lets a user copy a
  // browser-macros script into scripts/ and wonder why the session is missing.
  if (runtime === 'plain' && parsed.frontmatter.sessionProfileId !== undefined) {
    throw new Error(
      `session_profile_id is set in frontmatter but runtime is "plain". ` +
      `session_profile_id only applies to browser-macros (playwright runtime). ` +
      `If this is a browser automation script, move it to user-folder/browser-macros/; ` +
      `otherwise remove the session_profile_id frontmatter key.`
    );
  }

  // 2. Validate params (throws synchronously before any subprocess is spawned)
  const resolvedParams = validateAndApplyDefaults(parsed.frontmatter.params, params);

  // 3. Write body to a temp .cjs file (so Node's require() works regardless of
  //    the project's "type": "module" setting and ignores the leading frontmatter)
  const tmpPath = join(tmpdir(), `script-${process.pid}-${Date.now()}-${randomUUID().slice(0, 8)}.cjs`);
  writeFileSync(tmpPath, parsed.body, 'utf-8');

  const startMs = Date.now();

  const cleanup = () => { try { unlinkSync(tmpPath); } catch {} };
  process.once('exit', cleanup);

  try {
    return await spawnChild({
      childScriptPath: resolveChildPath(),
      tmpPath,
      resolvedParams,
      runtime,
      storageState: runtime === 'playwright' ? storageState : undefined,
      headless,
      timeoutMs,
      startMs,
    });
  } finally {
    // Always clean up temp file
    process.removeListener('exit', cleanup);
    cleanup();
  }
}

// ── Spawn helper ──────────────────────────────────────────────────────────────

interface SpawnOpts {
  childScriptPath: string;
  tmpPath: string;
  resolvedParams: Record<string, unknown>;
  runtime: 'plain' | 'playwright';
  storageState?: object;
  headless: boolean;
  timeoutMs: number;
  startMs: number;
}

// ── Output caps ───────────────────────────────────────────────────────────────
const MAX_STDOUT_BYTES = 1_000_000; // 1 MB
const MAX_STDERR_BYTES = 200_000;   // 200 KB

function spawnChild(opts: SpawnOpts): Promise<RunScriptResult> {
  const { childScriptPath, tmpPath, resolvedParams, runtime, storageState, headless, timeoutMs, startMs } = opts;

  return new Promise((resolve, reject) => {
    // ── Minimal env: deny child access to API keys, DB passwords, etc. ────────
    // Node's Permissions Model (--permission) conflicts with Playwright, so we
    // scrub the environment instead. Only variables needed by Node/Playwright
    // are forwarded.
    const minimalEnv: NodeJS.ProcessEnv = {
      PATH: process.env['PATH'],
      HOME: process.env['HOME'],
      TMPDIR: process.env['TMPDIR'],
      TMP: process.env['TMP'],
      LANG: process.env['LANG'],
      NODE_ENV: process.env['NODE_ENV'],
      // Playwright needs this to locate downloaded browser binaries
      PLAYWRIGHT_BROWSERS_PATH: process.env['PLAYWRIGHT_BROWSERS_PATH'],
    };
    // Remove entries whose value is undefined (Object.fromEntries skips nothing,
    // but passing undefined values to spawn causes issues on some platforms).
    const env = Object.fromEntries(
      Object.entries(minimalEnv).filter(([, v]) => v !== undefined)
    ) as NodeJS.ProcessEnv;

    // ── Spawn with restricted CWD and detached process group ─────────────────
    // detached: true gives the child its own process group so we can kill the
    // entire group (including Chromium descendants) on timeout or overflow.
    //
    // For plain runtime we additionally engage Node's Permissions Model
    // (--permission), which blocks child_process, worker threads, native
    // addons, and unscoped FS/net access. A plain script that tries to
    // require('child_process').spawn(...) now fails at runtime instead of
    // silently exfiltrating data.
    //
    // playwright runtime cannot use --permission: Chromium spawn, native
    // bindings, and outbound HTTPS all require unrestricted child_process /
    // addons / network. We rely on env scrub + container-level isolation
    // (when present) for browser-macros.
    const nodeArgs: string[] = [];
    if (runtime === 'plain') {
      const scriptDir = dirname(childScriptPath);
      const tmpRoot = tmpdir();
      nodeArgs.push(
        '--permission',
        `--allow-fs-read=${scriptDir}`,
        `--allow-fs-read=${tmpRoot}`,
        `--allow-fs-write=${tmpRoot}`,
      );
    }
    nodeArgs.push(childScriptPath);

    const child = spawn(process.execPath, nodeArgs, {
      stdio: ['pipe', 'pipe', 'pipe'],
      env,
      cwd: tmpdir(),
      detached: true,
    });

    // Send input via stdin
    const input = JSON.stringify({
      scriptPath: tmpPath,
      params: resolvedParams,
      runtime,
      storageState,
      headless,
    });
    child.stdin.write(input + '\n');
    child.stdin.end();

    // Collect stdout lines (structured JSON)
    let stdoutBuf = '';
    const logs: string[] = [];
    let result: unknown = undefined;
    let hasResult = false;
    let serializationError: string | undefined;
    let stdoutBytes = 0;
    let rejected = false;

    child.stdout.on('data', (chunk: Buffer) => {
      if (rejected) return;
      stdoutBytes += chunk.length;
      if (stdoutBytes > MAX_STDOUT_BYTES) {
        rejected = true;
        if (child.pid) {
          // Kill the entire process group (Chromium descendants included)
          try { process.kill(-child.pid, 'SIGTERM'); } catch {}
          setTimeout(() => { try { process.kill(-child.pid!, 'SIGKILL'); } catch {} }, 1_000);
        } else {
          child.kill('SIGTERM');
        }
        reject(new Error(`script stdout exceeded ${MAX_STDOUT_BYTES} bytes`));
        return;
      }
      stdoutBuf += chunk.toString('utf-8');
      const lines = stdoutBuf.split('\n');
      stdoutBuf = lines.pop() ?? '';
      for (const line of lines) {
        if (!line.trim()) continue;
        try {
          const msg = JSON.parse(line) as {
            type: string;
            value?: unknown;
            text?: string;
            serializationError?: string;
          };
          if (msg.type === 'result') {
            result = msg.value;
            hasResult = true;
            if (typeof msg.serializationError === 'string') {
              serializationError = msg.serializationError;
            }
          } else if (msg.type === 'log' && typeof msg.text === 'string') {
            logs.push(msg.text);
          }
        } catch {
          // Non-JSON line on stdout — treat as a log entry
          logs.push(line);
        }
      }
    });

    // Collect stderr into logs[]
    let stderrBuf = '';
    let stderrBytes = 0;

    child.stderr.on('data', (chunk: Buffer) => {
      if (rejected) return;
      stderrBytes += chunk.length;
      if (stderrBytes > MAX_STDERR_BYTES) {
        rejected = true;
        if (child.pid) {
          try { process.kill(-child.pid, 'SIGTERM'); } catch {}
          setTimeout(() => { try { process.kill(-child.pid!, 'SIGKILL'); } catch {} }, 1_000);
        } else {
          child.kill('SIGTERM');
        }
        reject(new Error(`script stderr exceeded ${MAX_STDERR_BYTES} bytes`));
        return;
      }
      stderrBuf += chunk.toString('utf-8');
    });

    // Timeout enforcement: kill the whole process group so Chromium descendants
    // spawned by Playwright don't survive the timeout.
    let timedOut = false;
    let killTimer: ReturnType<typeof setTimeout> | undefined;

    const timeoutHandle = setTimeout(() => {
      timedOut = true;
      if (child.pid) {
        try { process.kill(-child.pid, 'SIGTERM'); } catch {}
        killTimer = setTimeout(() => {
          try { process.kill(-child.pid!, 'SIGKILL'); } catch {}
        }, 1_000);
      } else {
        child.kill('SIGTERM');
        killTimer = setTimeout(() => { child.kill('SIGKILL'); }, 1_000);
      }
    }, timeoutMs);

    child.on('close', (code) => {
      clearTimeout(timeoutHandle);
      if (killTimer !== undefined) clearTimeout(killTimer);

      if (rejected) return; // already rejected (cap or timeout before close)

      if (timedOut) {
        reject(new Error(`script timeout: exceeded ${timeoutMs}ms`));
        return;
      }

      const durationMs = Date.now() - startMs;

      if (code !== 0) {
        const stderrTail = stderrBuf.slice(-2000).trim();
        // Common footgun: a plain Node script tries to child_process.spawn
        // python (or anything), but plain runtime is sandboxed with Node's
        // --permission model, which denies child_process. Detect the denial
        // signature and steer the caller to the Bash tool, which has the
        // pre-baked pip environment and is the supported way to run Python.
        const permissionDenied =
          runtime === 'plain' &&
          /ERR_ACCESS_DENIED|Access to this API has been restricted|permission/i.test(stderrTail);
        const hint = permissionDenied
          ? ' — plain scripts run under Node --permission (no child_process). ' +
            'To run Python or shell out, use the Bash tool instead (pip packages are pre-baked); ' +
            'do not wrap it in a Node script.'
          : '';
        reject(new Error(`script exited code ${code}: ${stderrTail}${hint}`));
        return;
      }

      if (!hasResult) {
        // Script exited 0 but never wrote a result — treat as undefined
        result = undefined;
      }

      // Route stderr into logs[] on success path
      if (stderrBuf.trim()) {
        for (const line of stderrBuf.split('\n')) {
          if (line.trim()) logs.push(line);
        }
      }

      resolve({ result, logs, durationMs, serializationError });
    });

    child.on('error', (err) => {
      clearTimeout(timeoutHandle);
      if (killTimer !== undefined) clearTimeout(killTimer);
      if (!rejected) reject(err);
    });
  });
}
