import { spawn, type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type { Sql } from '../db.js';
import type { DeltaEmitter } from './delta-emitter.js';
import { publishJob } from './publish-job.js';
import { recordEvalResult } from './eval-suites.js';
import { acquireHostAccess } from './host-access.js';

export interface SandboxEvalParams {
  runId: string;
  providerId: string;
  model: string;
  quant: string | null;
  tasks: Array<Record<string, unknown>>;
}

export interface SandboxProgress {
  completedTasks: number;
}

export interface SandboxResult {
  error: string | null;
}

export interface SandboxContainer {
  id: string;
  process: ChildProcess;
  timeoutHandle: NodeJS.Timeout | null;
}

const SANDBOX_IMAGE = process.env.SANDBOX_IMAGE ?? 'node:20-bookworm-slim';
const SANDBOX_MEMORY = process.env.SANDBOX_MEMORY ?? '512m';
const SANDBOX_CPU = process.env.SANDBOX_CPU ?? '0.5';
const SANDBOX_PIDS = process.env.SANDBOX_PIDS ?? '100';
const SANDBOX_TIMEOUT_MS = Number(process.env.SANDBOX_TIMEOUT_MS ?? '30000');
const SANDBOX_CONCURRENCY = Number(process.env.SANDBOX_CONCURRENCY ?? '4');
const SANDBOX_LABEL = 'boocontrol-eval';

/**
 * Run a code sandbox eval: each task generates code via LLM, executes in
 * an ephemeral Docker container with hardening flags, and scores pass@1.
 *
 * HARDENING FLAGS (LAW):
 * - --network none: NO network access
 * - --user 1000:1000: non-root user
 * - --memory, --cpus, --pids-limit: resource caps
 * - --tmpfs /workspace:tmpfs workdir
 * - --rm: auto-remove on exit
 * - --label boocontrol-eval: orphan findability
 * - --security-opt=no-new-privileges: no privilege escalation
 * - --cap-drop=ALL: drop all capabilities
 *
 * NO volume mounts from the repo.
 * NO docker socket inside containers.
 *
 * Bounded concurrency via Promise.allSettled.
 * Per-task finally cleanup.
 * Kill-on-timeout.
 */
export async function runCodeEval(
  params: SandboxEvalParams,
  sql: Sql,
  emitter: DeltaEmitter,
  seq: number,
  onProgress: (progress: SandboxProgress) => void,
): Promise<SandboxResult> {
  const { runId, tasks } = params;

  const grant = await acquireHostAccess(params.providerId, 'sandbox');
  if (!grant.ok) {
    return { error: `host access denied: ${grant.reason}` };
  }

  // Orphan prune at engine start.
  await pruneOrphanContainers();

  let completedTasks = 0;
  let error: string | null = null;

  // Bounded concurrency: process tasks in batches.
  const batchSizes: number[] = [];
  for (let i = 0; i < tasks.length; i += SANDBOX_CONCURRENCY) {
    const batch = tasks.slice(i, i + SANDBOX_CONCURRENCY);
    batchSizes.push(batch.length);

    // Promise.allSettled: a single task failure never abandons in-flight containers.
    const results = await Promise.allSettled(
      batch.map(async (task, batchIdx) => {
        const globalIdx = i + batchIdx;
        const taskId = (task.id as string) ?? `task_${globalIdx}`;
        const prompt = (task.prompt as string) ?? '';
        const testCode = (task.test_code as string) ?? '';
        const expectedOutput = (task.expected_output as string) ?? '';
        const language = (task.language as string) ?? 'typescript';

        const startTime = Date.now();
        let container: SandboxContainer | null = null;

        try {
          // Generate code from LLM.
          const generatedCode = await generateCode(params.providerId, params.model, prompt, language);

          const execResult = await executeInSandbox(generatedCode, testCode, language);

          const executionMs = Date.now() - startTime;

          // pass@1 scoring: output matches expected.
          const passed = normalizeOutput(execResult.stdout) === normalizeOutput(expectedOutput);
          const score = passed ? 1 : 0;

          await recordEvalResult(
            sql,
            runId,
            taskId,
            globalIdx,
            score,
            1,
            passed ? 'Output matches expected' : `Expected: ${expectedOutput}, Got: ${execResult.stdout}`,
            execResult.exitCode,
            execResult.stderr,
            execResult.stdout,
            executionMs,
            null,
          );

          publishJob(emitter, {
            seq,
            jobType: 'eval',
            jobId: runId,
            status: 'running',
            detail: {
              taskId,
              taskIndex: globalIdx,
              passed,
              score,
            },
          });

          return { taskId, passed, score };
        } catch (err) {
          const msg = (err as Error).message ?? String(err);
          const executionMs = Date.now() - startTime;

          await recordEvalResult(
            sql,
            runId,
            taskId,
            globalIdx,
            null,
            1,
            null,
            null,
            msg,
            null,
            executionMs,
            msg,
          ).catch(() => {});

          return { taskId, passed: false, score: 0, error: msg };
        } finally {
          // Per-task finally cleanup: kill container + remove.
          if (container) {
            await cleanupContainer(container);
          }
          completedTasks++;
          onProgress({ completedTasks });
        }
      }),
    );

    for (const result of results) {
      if (result.status === 'rejected') {
        console.error('sandbox: batch task rejected:', result.reason);
      }
    }
  }

  return { error };
}

/**
 * Generate code from the target model.
 */
async function generateCode(
  providerId: string,
  model: string,
  prompt: string,
  language: string,
): Promise<string> {
  const baseUrl = resolveProviderBaseUrlInternal(providerId);
  if (!baseUrl) {
    throw new Error(`no base URL for provider ${providerId}`);
  }

  const systemPrompt = `You are a code generator. Write ${language} code that solves the given task.
Output ONLY the code, no explanations, no markdown fences. The code will be executed directly.`;

  const res = await fetch(`${baseUrl}/v1/chat/completions`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Boo-Source': 'control-eval',
    },
    body: JSON.stringify({
      model,
      messages: [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: prompt },
      ],
      temperature: 0,
      max_tokens: 2048,
    }),
    signal: AbortSignal.timeout(120_000),
  });

  if (!res.ok) {
    const body = await res.text().catch(() => '');
    throw new Error(`code generation failed: ${res.status} ${body.slice(0, 200)}`);
  }

  const data = await res.json() as { choices?: Array<{ message?: { content?: string } }> };
  let code = data.choices?.[0]?.message?.content ?? '';

  // Strip markdown code fences if present.
  const fenceMatch = code.match(/```[\w]*\n([\s\S]*?)```/);
  if (fenceMatch && fenceMatch[1]) {
    code = fenceMatch[1];
  }

  return code.trim();
}

/**
 * Execute code in a hardened Docker container.
 */
async function executeInSandbox(
  generatedCode: string,
  testCode: string,
  language: string,
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
  return new Promise((resolve, reject) => {
    const containerId = `eval_${randomUUID().slice(0, 12)}`;

    const script = buildExecutionScript(generatedCode, testCode, language);

    // SECURITY: Hardened Docker run command.
    // --network none: NO network access.
    // --user 1000:1000: non-root user.
    // --memory, --cpus, --pids-limit: resource caps.
    // --tmpfs /workspace: tmpfs workdir, no persistent storage.
    // --rm: auto-remove on exit.
    // --label boocontrol-eval: orphan findability.
    // --security-opt=no-new-privileges: no privilege escalation.
    // --cap-drop=ALL: drop all capabilities.
    const dockerArgs = [
      'run',
      '--network', 'none',
      '--user', '1000:1000',
      '--memory', SANDBOX_MEMORY,
      '--cpus', String(SANDBOX_CPU),
      '--pids-limit', String(SANDBOX_PIDS),
      '--tmpfs', '/workspace:rw,noexec,size=64m',
      '--rm',
      '--label', SANDBOX_LABEL,
      '--security-opt', 'no-new-privileges',
      '--cap-drop', 'ALL',
      '--name', containerId,
      '-e', 'NODE_ENV=production',
      SANDBOX_IMAGE,
      'sh', '-c', script,
    ];

    const dockerProcess = spawn('docker', dockerArgs, {
      timeout: SANDBOX_TIMEOUT_MS,
      env: { ...process.env },
    });

    let stdout = '';
    let stderr = '';

    dockerProcess.stdout.on('data', (chunk: Buffer) => {
      stdout += chunk.toString();
    });

    dockerProcess.stderr.on('data', (chunk: Buffer) => {
      stderr += chunk.toString();
    });

    dockerProcess.on('close', (code) => {
      resolve({
        stdout: stdout.trim(),
        stderr: stderr.trim(),
        exitCode: code,
      });
    });

    dockerProcess.on('error', (err) => {
      reject(new Error(`docker spawn failed: ${err.message}`));
    });

    // Kill-on-timeout: if the process exceeds SANDBOX_TIMEOUT_MS, kill it.
    const timeoutHandle = setTimeout(() => {
      dockerProcess.kill('SIGKILL');
      reject(new Error(`sandbox execution timeout (${SANDBOX_TIMEOUT_MS}ms)`));
    }, SANDBOX_TIMEOUT_MS);

    // Clear timeout on close.
    dockerProcess.on('close', () => {
      clearTimeout(timeoutHandle);
    });
  });
}

/**
 * Build the execution script for the sandbox.
 */
function buildExecutionScript(
  generatedCode: string,
  testCode: string,
  language: string,
): string {
  if (language === 'typescript' || language === 'javascript') {
    return [
      'cd /workspace',
      `echo '${escapeShell(generatedCode)}' > output.js`,
      `echo '${escapeShell(testCode)}' > test.js`,
      'npx --yes tsx test.js 2>&1',
    ].join(' && ');
  }

  // Fallback: generic shell execution.
  return [
    'cd /workspace',
    `echo '${escapeShell(generatedCode)}' > output.sh`,
    `echo '${escapeShell(testCode)}' > test.sh`,
    'chmod +x output.sh test.sh',
    'bash test.sh 2>&1',
  ].join(' && ');
}

/**
 * Escape a string for safe shell embedding.
 */
function escapeShell(str: string): string {
  return str.replace(/'/g, "'\\''");
}

/**
 * Normalize output for comparison (trim, collapse whitespace).
 */
function normalizeOutput(output: string): string {
  return output.trim().replace(/\s+/g, ' ');
}

/**
 * Prune orphan containers from crashed runs.
 */
async function pruneOrphanContainers(): Promise<void> {
  return new Promise((resolve) => {
    const pruneCmd = spawn('docker', ['ps', '-q', '--filter', `label=${SANDBOX_LABEL}`]);
    let output = '';
    pruneCmd.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); });
    pruneCmd.on('close', async () => {
      const containerIds = output.trim().split('\n').filter(Boolean);
      if (containerIds.length > 0) {
        const kill = spawn('docker', ['kill', ...containerIds]);
        await new Promise((r) => {
          kill.on('close', r);
          kill.on('error', r);
        });
      }
      resolve();
    });
    pruneCmd.on('error', () => resolve());
  });
}

/**
 * Cleanup a sandbox container.
 */
async function cleanupContainer(container: SandboxContainer): Promise<void> {
  if (container.timeoutHandle) {
    clearTimeout(container.timeoutHandle);
  }
  if (container.process.exitCode === null) {
    container.process.kill('SIGKILL');
  }
  // Container is --rm, so it auto-removes. But force-remove as safety net.
  await new Promise<void>((resolve) => {
    const rm = spawn('docker', ['rm', '-f', container.id]);
    rm.on('close', resolve);
    rm.on('error', resolve);
  }).catch(() => {});
}

/**
 * Resolve provider base URL (internal, mirrors llama-providers).
 */
function resolveProviderBaseUrlInternal(providerId: string): string | null {
  try {
    const { resolveProviderBaseUrl } = require('./llama-providers.js');
    return resolveProviderBaseUrl(providerId);
  } catch {
    return null;
  }
}
