// supply-chain-data.mjs — Shared blocklists, parsers, and OSV.dev API for supply chain checks
// Used by: pre-install-supply-chain.mjs (hook) and supply-chain-recheck.mjs (scanner)
// Zero external dependencies (Node.js builtins only).

import { execSync } from 'node:child_process';

// ---------------------------------------------------------------------------
// Cross-platform HTTP helper (replaces curl subprocess)
// ---------------------------------------------------------------------------

/**
 * Fetch JSON from a URL with timeout. Cross-platform (no curl dependency).
 * @param {string} url
 * @param {object} [options] - fetch options (method, headers, body)
 * @param {number} [timeoutMs=8000]
 * @returns {Promise<object|null>} Parsed JSON or null on failure
 */
async function fetchJSON(url, options = {}, timeoutMs = 8000) {
  try {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    const res = await fetch(url, { ...options, signal: controller.signal });
    clearTimeout(timer);
    if (!res.ok) return null;
    return await res.json();
  } catch {
    return null;
  }
}

// ===========================================================================
// Age threshold for new package detection (hours)
// ===========================================================================

export const AGE_THRESHOLD_HOURS = 72;

// ===========================================================================
// KNOWN COMPROMISED — curated blocklists per ecosystem
// '*' = all versions blocked (entirely malicious package)
// ===========================================================================

export const NPM_COMPROMISED = {
  'axios':            ['1.14.1', '0.30.4'],
  'event-stream':     ['3.3.6'],
  'ua-parser-js':     ['0.7.29', '0.8.0', '1.0.0'],
  'coa':              ['2.0.3', '2.0.4', '2.1.1', '2.1.3'],
  'rc':               ['1.2.9', '1.3.9', '2.3.9'],
  'colors':           ['1.4.1', '1.4.2'],
  'faker':            ['6.6.6'],
  'node-ipc':         ['10.1.1', '10.1.2', '10.1.3', '11.0.0', '11.1.0'],
  'peacenotwar':      ['*'],
  'plain-crypto-js':  ['*'],
};

export const PIP_COMPROMISED = {
  'colourama':           ['*'],
  'python3-dateutil':    ['*'],
  'jeIlyfish':           ['*'],
  'python-binance':      ['*'],
  'openai-api':          ['*'],
  'requesocks':          ['*'],
  'python-mongo':        ['*'],
  'nmap-python':         ['*'],
  'beautifulsoup':       ['*'],
  'djanga':              ['*'],
  'httpslib2':           ['*'],
  'urllib4':             ['*'],
  'pipsqlite3':          ['*'],
  'torlogging':          ['*'],
  'flasck':              ['*'],
  'matploltlib':         ['*'],
  'discordi':            ['*'],
  'numpyi':              ['*'],
  'pycryptdome':         ['*'],
};

export const CARGO_COMPROMISED = {
  'rustdecimal':  ['*'],
  'cratesio':     ['*'],
};

export const GEM_COMPROMISED = {
  'rest-client':     ['1.6.13'],
  'strong_password': ['0.0.7'],
  'bootstrap-sass':  ['3.2.0.3'],
};

export const DOCKER_SUSPICIOUS = [
  /xmrig/i,
  /cryptonight/i,
  /monero-?miner/i,
  /coin-?hive/i,
];

// Popular PyPI packages for typosquat detection (used by hook)
export const POPULAR_PIP = [
  'requests', 'flask', 'django', 'numpy', 'pandas', 'scipy', 'matplotlib',
  'tensorflow', 'torch', 'opencv-python', 'pillow', 'beautifulsoup4',
  'sqlalchemy', 'celery', 'redis', 'boto3', 'openai', 'anthropic',
  'fastapi', 'uvicorn', 'pydantic', 'httpx', 'aiohttp', 'colorama',
  'cryptography', 'pycryptodome', 'paramiko', 'fabric', 'pytest',
  'setuptools', 'pip', 'wheel', 'twine', 'black', 'mypy', 'ruff',
  'python-dateutil', 'jellyfish', 'pymongo', 'psycopg2', 'python-nmap',
  'discord.py', 'selenium', 'scrapy', 'lxml', 'pyyaml',
];

// Popular npm packages for scope-hop detection (E13). Subset of
// knowledge/top-packages.json npm list focused on names most attractive
// as a scope-hop lure. Kept hardcoded to keep hook startup synchronous.
export const POPULAR_NPM = [
  'express', 'react', 'react-dom', 'lodash', 'axios', 'chalk', 'commander',
  'debug', 'dotenv', 'eslint', 'jest', 'mocha', 'webpack', 'typescript',
  'next', 'vue', 'angular', 'moment', 'dayjs', 'uuid', 'minimist', 'yargs',
  'semver', 'mkdirp', 'fs-extra', 'cross-env', 'concurrently', 'nodemon',
  'prettier', 'ts-node', 'rxjs', 'redux', 'react-redux', 'styled-components',
  'tailwindcss', 'postcss', 'autoprefixer', 'sass', 'less', 'parcel',
  'lerna', 'http-server', 'serve', 'cors', 'body-parser', 'cookie-parser',
  'express-session', 'passport', 'jsonwebtoken', 'bcrypt', 'bcryptjs',
  'mongoose', 'sequelize', 'prisma', 'typeorm', 'knex', 'pg', 'mysql2',
  'sqlite3', 'ioredis', 'aws-sdk', 'firebase', 'graphql', 'apollo-server',
  'socket.io', 'ws', 'puppeteer', 'playwright', 'cheerio', 'jsdom',
  'sharp', 'jimp', 'multer', 'nodemailer', 'bull', 'cron', 'winston',
  'pino', 'morgan', 'helmet', 'compression', 'joi', 'yup', 'ajv',
  'validator', 'marked', 'three', 'chart.js', 'date-fns', 'underscore',
  'ramda', 'immer', 'execa', 'shelljs', 'fast-glob', 'micromatch',
  'inquirer', 'ora', 'boxen', 'node-fetch', 'got', 'supertest',
];

// Official npm scopes that publish well-known packages. A scoped install
// like `@types/lodash` whose unscoped name matches a popular package is
// only suspicious if `@types` is NOT on this list. Mirrored into
// knowledge/typosquat-allowlist.json as `npm_official_scopes` for the
// doc-consistency drift-guard test.
export const NPM_OFFICIAL_SCOPES = [
  '@types', '@reduxjs', '@nestjs', '@angular', '@nrwl',
  '@modelcontextprotocol', '@babel', '@testing-library',
  '@aws-sdk', '@azure', '@google-cloud',
  '@vue', '@svelte', '@nuxt', '@sveltejs', '@vitejs',
  '@playwright', '@storybook', '@radix-ui', '@reach',
  '@emotion', '@mui',
];

/**
 * E13: scope-hopping detector. Returns null if `name` is not a scope-hop
 * candidate, or `{ scope, unscoped, spec }` if it is. A scope-hop is a
 * scoped npm name `@<scope>/<unscoped>` where:
 *   - `<scope>` is NOT on NPM_OFFICIAL_SCOPES,
 *   - `<scope>` is NOT on `extraAllowedScopes` (e.g. policy.json), and
 *   - `<unscoped>` matches a popular npm package (POPULAR_NPM).
 *
 * @param {string} name              Full package name (`@scope/pkg` or bare)
 * @param {string[]} [extraAllowedScopes=[]] Additional scopes to whitelist
 * @returns {{ scope: string, unscoped: string, spec: string } | null}
 */
export function checkScopeHop(name, extraAllowedScopes = []) {
  if (typeof name !== 'string') return null;
  const m = name.match(/^(@[\w-]+)\/(.+)$/);
  if (!m) return null;
  const scope = m[1];
  const unscoped = m[2];
  if (NPM_OFFICIAL_SCOPES.includes(scope)) return null;
  if (Array.isArray(extraAllowedScopes) && extraAllowedScopes.includes(scope)) return null;
  if (!POPULAR_NPM.includes(unscoped)) return null;
  return { scope, unscoped, spec: name };
}

// ===========================================================================
// Helper functions
// ===========================================================================

/**
 * Check if a package name+version is on a compromised blocklist.
 * @param {Record<string, string[]>} list - Blocklist object
 * @param {string} name - Package name
 * @param {string|null} version - Package version (null = any)
 * @returns {boolean}
 */
export function isCompromised(list, name, version) {
  const bad = list[name];
  if (!bad) return false;
  if (bad.includes('*')) return true;
  if (version && bad.includes(version)) return true;
  return false;
}

/**
 * Parse an npm package specifier (e.g. "@scope/pkg@1.0.0" or "pkg@1.0.0").
 * @param {string} spec
 * @returns {{ name: string, version: string|null }}
 */
export function parseSpec(spec) {
  if (spec.startsWith('@')) {
    const rest = spec.slice(1);
    const atIdx = rest.lastIndexOf('@');
    if (atIdx > 0) return { name: '@' + rest.slice(0, atIdx), version: rest.slice(atIdx + 1) };
    return { name: spec, version: null };
  }
  const atIdx = spec.lastIndexOf('@');
  if (atIdx > 0) return { name: spec.slice(0, atIdx), version: spec.slice(atIdx + 1) };
  return { name: spec, version: null };
}

/**
 * Parse a pip package specifier (e.g. "requests==2.28.0" or "flask>=2.0").
 * @param {string} spec
 * @returns {{ name: string, version: string|null }}
 */
export function parsePipSpec(spec) {
  const eqIdx = spec.indexOf('==');
  if (eqIdx > 0) return { name: spec.slice(0, eqIdx), version: spec.slice(eqIdx + 2) };
  const match = spec.match(/^([a-zA-Z0-9_.-]+)/);
  return { name: match ? match[1] : spec, version: null };
}

/**
 * Execute a shell command safely with timeout.
 * @param {string} cmd
 * @param {number} [timeoutMs=10000]
 * @returns {string|null}
 */
export function execSafe(cmd, timeoutMs = 10000) {
  try {
    return execSync(cmd, { timeout: timeoutMs, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
  } catch (err) {
    return err.stdout || null;
  }
}

// ===========================================================================
// OSV.dev API — unified vulnerability database
// ===========================================================================

/** Map ecosystem names to OSV format. */
export const OSV_ECOSYSTEM_MAP = {
  npm: 'npm',
  pip: 'PyPI',
  cargo: 'crates.io',
  gem: 'RubyGems',
  go: 'Go',
};

/**
 * Extract severity from an OSV vulnerability record.
 * @param {object} vuln - OSV vulnerability object
 * @returns {string} - 'CRITICAL', 'HIGH', or 'MEDIUM'
 */
export function extractOSVSeverity(vuln) {
  const dbSev = vuln.database_specific?.severity;
  if (dbSev) return dbSev.toUpperCase();

  const ecoSev = vuln.ecosystem_specific?.severity;
  if (ecoSev) return ecoSev.toUpperCase();

  for (const sev of vuln.severity || []) {
    if (sev.score && typeof sev.score === 'number') {
      if (sev.score >= 9.0) return 'CRITICAL';
      if (sev.score >= 7.0) return 'HIGH';
      return 'MEDIUM';
    }
  }

  if (vuln.id?.startsWith('GHSA') || vuln.id?.startsWith('CVE')) return 'HIGH';
  return 'MEDIUM';
}

/**
 * Query OSV.dev for vulnerabilities on a single package version.
 * Used by the hook (real-time, single package).
 * @param {string} ecosystem - 'npm', 'pip', 'cargo', 'gem', 'go'
 * @param {string} name
 * @param {string} version
 * @returns {Promise<{ critical: object[], high: object[] }>}
 */
export async function queryOSV(ecosystem, name, version) {
  const critical = [];
  const high = [];

  const osvEcosystem = OSV_ECOSYSTEM_MAP[ecosystem];
  if (!osvEcosystem) return { critical, high };

  try {
    const result = await fetchJSON('https://api.osv.dev/v1/query', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        version,
        package: { name, ecosystem: osvEcosystem },
      }),
    }, 8000);
    if (!result) return { critical, high };

    for (const vuln of result.vulns || []) {
      const severity = extractOSVSeverity(vuln);
      const entry = {
        id: vuln.id,
        summary: (vuln.summary || vuln.details || 'No description').slice(0, 120),
        severity,
      };
      if (severity === 'CRITICAL') critical.push(entry);
      else if (severity === 'HIGH') high.push(entry);
    }
  } catch { /* network error — fail open */ }

  return { critical, high };
}

/**
 * Query OSV.dev batch API for multiple packages at once.
 * Used by the scanner (periodic re-check of all lockfile deps).
 * Falls back gracefully if network is unavailable.
 * @param {{ ecosystem: string, name: string, version: string }[]} packages
 * @returns {Promise<{ results: Array<{ vulns: object[] }>, offline: boolean }>}
 */
export async function queryOSVBatch(packages) {
  if (packages.length === 0) return { results: [], offline: false };

  const queries = packages.map(pkg => ({
    version: pkg.version,
    package: { name: pkg.name, ecosystem: OSV_ECOSYSTEM_MAP[pkg.ecosystem] || pkg.ecosystem },
  }));

  // OSV batch API accepts max 1000 queries per request
  const BATCH_SIZE = 1000;
  const allResults = [];

  for (let i = 0; i < queries.length; i += BATCH_SIZE) {
    const batch = queries.slice(i, i + BATCH_SIZE);

    try {
      const result = await fetchJSON('https://api.osv.dev/v1/querybatch', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ queries: batch }),
      }, 15000);
      if (!result) return { results: [], offline: true };

      allResults.push(...(result.results || []));
    } catch {
      return { results: [], offline: true };
    }
  }

  return { results: allResults, offline: false };
}
