/**
 * Safe Python subprocess runner.
 *
 * SECURITY: every argv element is passed as a separate argv slot to spawn().
 * We NEVER use shell:true and NEVER string-concatenate user input into a
 * command. Callers must hand us a `script` allow-listed by relative path and
 * a `payload` that we JSON.stringify into a single argv[1].
 *
 * Why JSON-as-argv-1 instead of stdin: the existing Python enricher scripts
 * already read `sys.argv[1]` per the spec. Keeping this convention means we
 * can add new scripts without rewriting the Python side.
 */

const { spawn } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs');

const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
const VENV_PYTHON = path.join(PROJECT_ROOT, 'nba-service', 'venv', 'bin', 'python');

// Scripts ALLOW-LIST. The only paths that can be invoked.
// Any value outside this set is rejected.
const ALLOWED_SCRIPTS = Object.freeze({
  'nba/refs': 'nba-service/scripts/refs_cli.py',
  'mlb/statcast': 'nba-service/scripts/mlb_statcast_cli.py',
  'mlb/umpire': 'nba-service/scripts/mlb_umpire_cli.py',
  'mlb/bvp': 'nba-service/scripts/mlb_bvp_cli.py',
  'wnba/season-avg': 'nba-service/scripts/wnba_season_cli.py',
});

const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; // 4 MB cap

class PythonScriptError extends Error {
  constructor(message, { code, stderr, script } = {}) {
    super(message);
    this.name = 'PythonScriptError';
    this.code = code;
    this.stderr = stderr;
    this.script = script;
  }
}

function resolveScript(key) {
  const rel = ALLOWED_SCRIPTS[key];
  if (!rel) throw new PythonScriptError(`unknown python script: ${key}`);
  const abs = path.join(PROJECT_ROOT, rel);
  if (!abs.startsWith(PROJECT_ROOT + path.sep)) {
    throw new PythonScriptError(`path traversal blocked: ${key}`);
  }
  if (!fs.existsSync(abs)) {
    throw new PythonScriptError(`python script missing on disk: ${rel}`);
  }
  return abs;
}

function pickInterpreter() {
  if (fs.existsSync(VENV_PYTHON)) return VENV_PYTHON;
  // Fall back to system python so dev environments without a venv still
  // surface a useful error rather than ENOENT.
  return process.env.VYNDR_PYTHON || 'python3';
}

/**
 * Run an allow-listed Python script with a validated JSON payload.
 *
 * @param {keyof typeof ALLOWED_SCRIPTS} scriptKey
 * @param {object} payload  Must be JSON-serializable. No functions, no Dates.
 * @param {object} [opts]
 * @param {number} [opts.timeoutMs=30000]
 * @param {AbortSignal} [opts.signal]  Outer abort signal (HTTP cancel etc.)
 * @returns {Promise<any>} parsed JSON from script stdout
 */
async function runPython(scriptKey, payload, opts = {}) {
  const scriptPath = resolveScript(scriptKey);
  const interpreter = pickInterpreter();

  let payloadJson;
  try {
    payloadJson = JSON.stringify(payload ?? {});
  } catch (err) {
    throw new PythonScriptError('payload not JSON-serializable', { script: scriptKey });
  }
  if (payloadJson.length > 32_000) {
    throw new PythonScriptError('payload too large', { script: scriptKey });
  }

  const timeoutMs = Math.max(1_000, Math.min(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, 120_000));
  const controller = new AbortController();
  const outerSignal = opts.signal;
  if (outerSignal) {
    if (outerSignal.aborted) controller.abort(outerSignal.reason);
    else outerSignal.addEventListener('abort', () => controller.abort(outerSignal.reason), { once: true });
  }
  const timer = setTimeout(() => controller.abort(new Error('python timeout')), timeoutMs);

  return new Promise((resolve, reject) => {
    const child = spawn(interpreter, [scriptPath, payloadJson], {
      // Hard-fail safety: explicit "no shell".
      shell: false,
      // Don't inherit stdio — we collect output ourselves and cap the size.
      stdio: ['ignore', 'pipe', 'pipe'],
      // Restricted env: keep PATH for the interpreter but strip everything else.
      env: {
        PATH: process.env.PATH,
        PYTHONUNBUFFERED: '1',
        // Forward Redis URL so the enrichers can use the same cache.
        REDIS_URL: process.env.REDIS_URL || '',
      },
      cwd: PROJECT_ROOT,
      signal: controller.signal,
    });

    let stdout = '';
    let stderr = '';
    let stdoutBytes = 0;
    let killedForSize = false;

    child.stdout.setEncoding('utf8');
    child.stderr.setEncoding('utf8');

    child.stdout.on('data', (chunk) => {
      stdoutBytes += Buffer.byteLength(chunk);
      if (stdoutBytes > MAX_OUTPUT_BYTES) {
        killedForSize = true;
        controller.abort(new Error('python stdout exceeded limit'));
        return;
      }
      stdout += chunk;
    });
    child.stderr.on('data', (chunk) => {
      // We don't cap stderr as tightly — log it.
      stderr += chunk.slice(0, 8_000);
    });

    child.on('error', (err) => {
      clearTimeout(timer);
      reject(new PythonScriptError(`spawn failed: ${err.message}`, { script: scriptKey, stderr }));
    });
    child.on('close', (code) => {
      clearTimeout(timer);
      if (killedForSize) {
        return reject(new PythonScriptError('python output exceeded limit', { code, stderr, script: scriptKey }));
      }
      if (controller.signal.aborted) {
        return reject(new PythonScriptError('python aborted', { code, stderr, script: scriptKey }));
      }
      if (code !== 0) {
        return reject(new PythonScriptError(`python exited ${code}`, { code, stderr, script: scriptKey }));
      }
      try {
        // Tolerate scripts that print extra lines before the JSON object.
        const trimmed = stdout.trim();
        const lastBrace = trimmed.lastIndexOf('{');
        const candidate = lastBrace >= 0 ? trimmed.slice(lastBrace) : trimmed;
        resolve(JSON.parse(candidate));
      } catch (err) {
        reject(new PythonScriptError('python returned non-JSON', { code, stderr, script: scriptKey }));
      }
    });
  });
}

module.exports = { runPython, PythonScriptError, ALLOWED_SCRIPTS };
