#!/usr/bin/env node
// ide-extension-scanner.mjs — Scan installed VS Code (and forks) extensions for supply-chain,
// typosquat, obfuscation, theme-with-code, sideload, broad activation, and nested deps.
//
// Standalone — NOT registered in scan-orchestrator.mjs.
// Reuses existing scanners (UNI, ENT, NET, TNT, MEM, SCR) via direct import.
//
// Scanner prefix: IDE
// OWASP: LLM01, LLM02, LLM03, LLM06, ASI02, ASI04
// Zero external dependencies — Node.js builtins only.
//
// CLI: node scanners/ide-extension-scanner.mjs [target] [options]
// Library: import { scan, discoverAll } from './ide-extension-scanner.mjs'

import { resolve, join, relative } from 'node:path';
import { writeFileSync, existsSync } from 'node:fs';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { discoverFiles } from './lib/file-discovery.mjs';
import { finding, scannerResult } from './lib/output.mjs';
import { SEVERITY, riskScore, riskBand, verdict } from './lib/severity.mjs';
import { levenshtein } from './lib/string-utils.mjs';
import {
  discoverVSCodeExtensions,
  discoverJetBrainsExtensions,
} from './lib/ide-extension-discovery.mjs';
import { parseVSCodeExtension, parseVsixFile, parseIntelliJPlugin } from './lib/ide-extension-parser.mjs';
import {
  loadTopVSCode,
  loadVSCodeBlocklist,
  loadTopJetBrains,
  loadJetBrainsBlocklist,
  normalizeId,
} from './lib/ide-extension-data.mjs';
import { fetchVsixFromUrl, fetchPluginFromUrl, detectUrlType } from './lib/vsix-fetch.mjs';
import { extractToDir, ZipError } from './lib/zip-extract.mjs';
import {
  runVsixWorker,
  runPluginWorker,
  DEFAULT_VSIX_WORKER_PATH,
  DEFAULT_JETBRAINS_WORKER_PATH,
} from './lib/vsix-sandbox.mjs';

import { scan as scanUnicode } from './unicode-scanner.mjs';
import { scan as scanEntropy } from './entropy-scanner.mjs';
import { scan as scanNetwork } from './network-mapper.mjs';
import { scan as scanTaint } from './taint-tracer.mjs';
import { scan as scanMemoryPoisoning } from './memory-poisoning-scanner.mjs';
import { scan as scanSupplyChain } from './supply-chain-recheck.mjs';

const VERSION = '7.3.1';
const SCANNER = 'IDE';

// ---------------------------------------------------------------------------
// URL → temp dir orchestration
// ---------------------------------------------------------------------------

function isUrlTarget(target) {
  return typeof target === 'string' && /^https?:\/\//i.test(target);
}

/**
 * Fetch a VSIX from a URL, extract it to a temp dir, and return the path that
 * `parseVSCodeExtension` should be pointed at. VSIX layout always nests the
 * extension under `extension/`.
 *
 * Two modes:
 *   - useSandbox=true (default for CLI):  spawns vsix-fetch-worker.mjs under
 *     sandbox-exec (macOS) / bwrap (Linux) so any FS write is restricted to
 *     <tempDir>. Defense-in-depth against zip-extract bugs.
 *   - useSandbox=false:  runs fetch + extract in-process. Used by tests that
 *     mock globalThis.fetch (mocking does not cross process boundaries).
 *
 * Caller MUST `await rm(result.tempDir, { recursive: true, force: true })` in finally.
 *
 * @param {string} url
 * @param {{ useSandbox?: boolean }} [opts]
 * @returns {Promise<{ extRoot: string, tempDir: string, source: object, sandbox: 'sandbox-exec'|'bwrap'|null|'in-process' }>}
 */
async function fetchAndExtractVsixUrl(url, opts = {}) {
  const useSandbox = opts.useSandbox !== false;
  const tempDir = await mkdtemp(join(tmpdir(), 'llm-sec-vsix-'));
  try {
    if (useSandbox) {
      const { ok, sandbox, payload } = await runVsixWorker(url, tempDir);
      if (!ok) {
        const msg = payload && payload.error ? payload.error : 'worker failed';
        throw new Error(msg);
      }
      const { type: kind, ...sourceMeta } = payload.source;
      const source = {
        type: 'url',
        kind,
        url,
        finalUrl: payload.finalUrl,
        sha256: payload.sha256,
        size: payload.size,
        sandbox: sandbox || 'none',
        ...sourceMeta,
      };
      return { extRoot: payload.extRoot, tempDir, source, sandbox: sandbox || null };
    }

    // In-process path (tests, or fallback when caller wants no sub-process).
    let fetched;
    try {
      fetched = await fetchVsixFromUrl(url);
    } catch (err) {
      throw new Error(`fetch failed: ${err.message}`);
    }
    try {
      await extractToDir(fetched.buffer, tempDir);
    } catch (err) {
      if (err instanceof ZipError) {
        throw new Error(`malformed VSIX (${err.code}): ${err.message}`);
      }
      throw err;
    }
    const nested = join(tempDir, 'extension');
    const extRoot = existsSync(nested) ? nested : tempDir;
    const { type: kind, ...sourceMeta } = fetched.source;
    const source = {
      type: 'url',
      kind,
      url,
      finalUrl: fetched.finalUrl,
      sha256: fetched.sha256,
      size: fetched.size,
      sandbox: 'in-process',
      ...sourceMeta,
    };
    return { extRoot, tempDir, source, sandbox: 'in-process' };
  } catch (err) {
    await rm(tempDir, { recursive: true, force: true }).catch(() => {});
    throw err;
  }
}

/**
 * Generalized URL fetch + extract for JetBrains plugins (and callable for VSIX
 * too via `workerKind: 'vsix'`). Uses the generalized `runPluginWorker` from
 * `vsix-sandbox.mjs` so both worker kinds share the same sandbox pipeline.
 *
 * JetBrains-specific differences from the VSIX helper:
 *   - Worker is `DEFAULT_JETBRAINS_WORKER_PATH` (emits the plugin root under
 *     <tempDir>, not `<tempDir>/extension`).
 *   - In-process fallback uses `fetchJetBrainsPlugin` + manual extRoot probe
 *     mirroring the worker (first child of tempDir with `lib/*.jar`).
 *
 * Caller MUST `await rm(result.tempDir, { recursive: true, force: true })` in finally.
 *
 * @param {string} url
 * @param {{ useSandbox?: boolean, workerKind?: 'jetbrains'|'vsix' }} [opts]
 * @returns {Promise<{ extRoot: string, tempDir: string, source: object, sandbox: 'sandbox-exec'|'bwrap'|null|'in-process' }>}
 */
async function fetchAndExtractPluginUrl(url, opts = {}) {
  const useSandbox = opts.useSandbox !== false;
  const workerKind = opts.workerKind || 'jetbrains';
  const workerPath = workerKind === 'vsix' ? DEFAULT_VSIX_WORKER_PATH : DEFAULT_JETBRAINS_WORKER_PATH;
  const tempDir = await mkdtemp(join(tmpdir(), `llm-sec-${workerKind}-`));
  try {
    if (useSandbox) {
      const { ok, sandbox, payload } = await runPluginWorker(
        workerPath,
        ['--url', url, '--tmpdir', tempDir],
        tempDir,
      );
      if (!ok) {
        const msg = payload && payload.error ? payload.error : 'worker failed';
        throw new Error(msg);
      }
      const { type: kind, ...sourceMeta } = payload.source;
      const source = {
        type: 'url',
        kind,
        url,
        finalUrl: payload.finalUrl,
        sha256: payload.sha256,
        size: payload.size,
        sandbox: sandbox || 'none',
        ...sourceMeta,
      };
      return { extRoot: payload.extRoot, tempDir, source, sandbox: sandbox || null };
    }

    // In-process path — used by tests that mock globalThis.fetch.
    let fetched;
    try {
      fetched = await fetchPluginFromUrl(url);
    } catch (err) {
      throw new Error(`fetch failed: ${err.message}`);
    }
    try {
      await extractToDir(fetched.buffer, tempDir);
    } catch (err) {
      if (err instanceof ZipError) {
        throw new Error(`malformed plugin archive (${err.code}): ${err.message}`);
      }
      throw err;
    }

    // JetBrains archives: first child dir containing lib/*.jar is the plugin root.
    let extRoot = tempDir;
    if (workerKind === 'jetbrains') {
      try {
        const { readdirSync, statSync } = await import('node:fs');
        for (const name of readdirSync(tempDir)) {
          const candidate = join(tempDir, name);
          try {
            if (!statSync(candidate).isDirectory()) continue;
            const libDir = join(candidate, 'lib');
            if (!statSync(libDir).isDirectory()) continue;
            const libEntries = readdirSync(libDir);
            if (libEntries.some((n) => n.toLowerCase().endsWith('.jar'))) {
              extRoot = candidate;
              break;
            }
          } catch { /* skip */ }
        }
      } catch { /* fallback to tempDir */ }
    } else {
      const nested = join(tempDir, 'extension');
      if (existsSync(nested)) extRoot = nested;
    }

    const { type: kind, ...sourceMeta } = fetched.source;
    const source = {
      type: 'url',
      kind,
      url,
      finalUrl: fetched.finalUrl,
      sha256: fetched.sha256,
      size: fetched.size,
      sandbox: 'in-process',
      ...sourceMeta,
    };
    return { extRoot, tempDir, source, sandbox: 'in-process' };
  } catch (err) {
    await rm(tempDir, { recursive: true, force: true }).catch(() => {});
    throw err;
  }
}

// ---------------------------------------------------------------------------
// IDE-specific checks (operate on parsed manifest)
// ---------------------------------------------------------------------------

function matchBlocklistEntry(id, version, entry) {
  const [blockId, blockVer] = entry.split('@');
  if (!blockId) return false;
  if (normalizeId(blockId) !== normalizeId(id)) return false;
  if (!blockVer || blockVer === '*') return true;
  return blockVer === version;
}

function checkBlocklist(ext, manifest, blocklist, relLocation) {
  const findings = [];
  for (const entry of blocklist) {
    if (matchBlocklistEntry(ext.id, ext.version, entry)) {
      findings.push(finding({
        scanner: SCANNER,
        severity: SEVERITY.CRITICAL,
        title: `Block-listed extension: ${ext.id}@${ext.version}`,
        description: `Extension ID matches entry in known-malicious blocklist (${entry}).`,
        file: relLocation,
        evidence: `id=${ext.id} version=${ext.version}`,
        owasp: 'LLM03, ASI04',
        recommendation: `Uninstall immediately via VS Code Extensions view, or run: code --uninstall-extension ${ext.id}`,
      }));
      break;
    }
  }
  return findings;
}

function checkThemeWithCode(ext, manifest, relLocation) {
  const findings = [];
  const cats = manifest.categories.map(c => c.toLowerCase());
  if (!cats.includes('themes')) return findings;
  const hasMain = !!manifest.main || !!manifest.browser;
  const hasActivation = Array.isArray(manifest.activationEvents) && manifest.activationEvents.length > 0;
  if (!hasMain && !hasActivation) return findings;
  findings.push(finding({
    scanner: SCANNER,
    severity: SEVERITY.HIGH,
    title: `Theme extension has executable code: ${ext.id}`,
    description: 'Extensions categorized as "Themes" should not require runtime entry points. Presence of main/browser/activationEvents is a strong red flag (see Material Theme malware case).',
    file: relLocation,
    evidence: `categories=${JSON.stringify(manifest.categories)} main=${manifest.main} activationEvents=${JSON.stringify(manifest.activationEvents)}`,
    owasp: 'LLM06, ASI02',
    recommendation: `Audit ${manifest.main || manifest.browser} for data exfiltration logic. Consider uninstalling.`,
  }));
  return findings;
}

function checkSideload(ext, manifest, relLocation) {
  const findings = [];
  if (ext.source !== 'vsix') return findings;
  const sev = ext.signed ? SEVERITY.MEDIUM : SEVERITY.HIGH;
  findings.push(finding({
    scanner: SCANNER,
    severity: sev,
    title: `Sideloaded extension (source=vsix): ${ext.id}`,
    description: ext.signed
      ? 'Extension installed from local .vsix file. Signature present — possibly Marketplace-downloaded .vsix. Verify provenance.'
      : 'Extension installed from local .vsix file without signature verification. Marketplace malware-scan and publisher trust bypassed.',
    file: relLocation,
    evidence: `source=vsix signed=${ext.signed}`,
    owasp: 'LLM03',
    recommendation: 'Verify source of .vsix file. Prefer Marketplace installs.',
  }));
  return findings;
}

function checkBroadActivation(ext, manifest, topSet, relLocation) {
  const findings = [];
  const events = manifest.activationEvents || [];
  const hasStar = events.includes('*');
  const hasStartup = events.includes('onStartupFinished');
  if (!hasStar && !hasStartup) return findings;
  // Suppress exact match with top-list (trusted baseline)
  if (topSet.has(ext.id)) return findings;
  if (hasStar) {
    findings.push(finding({
      scanner: SCANNER,
      severity: SEVERITY.MEDIUM,
      title: `Wildcard activation (*): ${ext.id}`,
      description: 'Extension activates on any workspace event via "*". Broad activation surface is unusual and should be justified.',
      file: relLocation,
      evidence: 'activationEvents includes "*"',
      owasp: 'LLM06',
      recommendation: 'Audit extension behavior. Review if broad activation is justified.',
    }));
  } else if (hasStartup) {
    findings.push(finding({
      scanner: SCANNER,
      severity: SEVERITY.LOW,
      title: `Startup activation: ${ext.id}`,
      description: 'Extension activates on onStartupFinished. Near-wildcard activation surface.',
      file: relLocation,
      evidence: 'activationEvents includes "onStartupFinished"',
      owasp: 'LLM06',
      recommendation: 'Confirm extension is trusted.',
    }));
  }
  return findings;
}

function checkTyposquat(ext, topList, relLocation) {
  const findings = [];
  const topSet = new Set(topList);
  if (topSet.has(ext.id)) return findings; // exact legit match
  let best = null;
  let bestDist = 99;
  for (let i = 0; i < topList.length; i++) {
    const target = topList[i];
    if (Math.abs(target.length - ext.id.length) > 2) continue;
    const d = levenshtein(ext.id, target);
    if (d < bestDist) {
      bestDist = d;
      best = { target, rank: i };
      if (d === 1) break;
    }
  }
  if (!best || bestDist > 2) return findings;
  let sev = null;
  if (bestDist === 1) sev = SEVERITY.HIGH;
  else if (bestDist === 2 && best.rank < 50) sev = SEVERITY.MEDIUM;
  if (!sev) return findings;
  findings.push(finding({
    scanner: SCANNER,
    severity: sev,
    title: `Possible typosquat: "${ext.id}" vs "${best.target}" (Levenshtein=${bestDist})`,
    description: `Extension ID is ${bestDist} edit(s) from top-${best.rank + 1} extension "${best.target}". Common impersonation pattern (TigerJack, publisher spoofing).`,
    file: relLocation,
    evidence: `candidate=${best.target} distance=${bestDist}`,
    owasp: 'LLM03',
    recommendation: `Verify publisher identity. If "${best.target}" is what you intended, uninstall this and install from the verified publisher.`,
  }));
  return findings;
}

function checkExtensionPackExpansion(ext, manifest, relLocation) {
  const findings = [];
  const pack = manifest.extensionPack || [];
  if (pack.length < 3) return findings;
  findings.push(finding({
    scanner: SCANNER,
    severity: SEVERITY.MEDIUM,
    title: `Extension pack installs ${pack.length} bundled extensions: ${ext.id}`,
    description: 'Extension packs amplify trust chain — installing one extension installs N others, each with its own risk surface.',
    file: relLocation,
    evidence: `extensionPack=[${pack.slice(0, 3).join(', ')}${pack.length > 3 ? ', ...' : ''}]`,
    owasp: 'LLM03',
    recommendation: 'Audit each bundled extension individually.',
  }));
  return findings;
}

const SHELL_PATTERNS = /\b(child_process|curl|wget|\brm\b|powershell|iex|Invoke-Expression|Start-Process|Invoke-WebRequest)\b/i;

function checkUninstallHook(ext, manifest, relLocation) {
  const findings = [];
  const scripts = manifest.scripts || {};
  const hook = scripts['vscode:uninstall'];
  if (!hook || typeof hook !== 'string') return findings;
  const matches = SHELL_PATTERNS.test(hook);
  findings.push(finding({
    scanner: SCANNER,
    severity: matches ? SEVERITY.HIGH : SEVERITY.LOW,
    title: `Uninstall hook defined: ${ext.id}`,
    description: matches
      ? 'Uninstall script references shell patterns (child_process, curl, rm, powershell etc.). Persistence hook risk.'
      : 'Extension defines a vscode:uninstall script. Review what it does.',
    file: relLocation,
    evidence: hook.slice(0, 200),
    owasp: 'LLM06, ASI02',
    recommendation: 'Inspect the uninstall hook before uninstalling.',
  }));
  return findings;
}

function runIdeChecks(ext, manifest, topList, blocklist, relLocation) {
  const topSet = new Set(topList);
  const out = [];
  out.push(...checkBlocklist(ext, manifest, blocklist, relLocation));
  out.push(...checkThemeWithCode(ext, manifest, relLocation));
  out.push(...checkSideload(ext, manifest, relLocation));
  out.push(...checkBroadActivation(ext, manifest, topSet, relLocation));
  out.push(...checkTyposquat(ext, topList, relLocation));
  out.push(...checkExtensionPackExpansion(ext, manifest, relLocation));
  out.push(...checkUninstallHook(ext, manifest, relLocation));
  return out;
}

// ---------------------------------------------------------------------------
// JetBrains-specific checks
// ---------------------------------------------------------------------------

function checkBlocklistJB(ext, manifest, blocklist, relLocation) {
  return checkBlocklist(ext, manifest, blocklist, relLocation);
}

function checkThemeWithCodeJB(ext, manifest, relLocation) {
  const findings = [];
  const themeProviders = manifest.themeProviders || [];
  const extDecls = manifest.extensionDeclarations || [];
  const appComps = manifest.applicationComponents || [];
  if (themeProviders.length === 0) return findings;
  if (extDecls.length > themeProviders.length || appComps.length > 0) {
    findings.push(finding({
      scanner: SCANNER,
      severity: SEVERITY.HIGH,
      title: `checkThemeWithCodeJB: theme plugin has executable code: ${ext.id}`,
      description: 'JetBrains plugin declares themeProviders but also has executable extension points or application-components. Theme plugins should be UI-only.',
      file: relLocation,
      evidence: `themeProviders=${themeProviders.length} extensions=${extDecls.length} applicationComponents=${appComps.length}`,
      owasp: 'LLM06, ASI02',
      recommendation: 'Audit non-theme extension points. Consider uninstalling.',
    }));
  }
  return findings;
}

function checkBroadActivationJB(ext, manifest, relLocation) {
  const findings = [];
  const appComps = manifest.applicationComponents || [];
  const listeners = manifest.listeners || [];
  const extDecls = manifest.extensionDeclarations || [];

  const hasAppLifecycleListener = listeners.some(l => typeof l.topic === 'string' && l.topic.includes('AppLifecycleListener'));
  if (appComps.length > 0 || hasAppLifecycleListener) {
    findings.push(finding({
      scanner: SCANNER,
      severity: SEVERITY.HIGH,
      title: `checkBroadActivationJB: eager startup activation: ${ext.id}`,
      description: appComps.length > 0
        ? 'Plugin declares legacy <application-components> which load at IDE startup. Deprecated but not malicious — review for necessity.'
        : 'Plugin listens to AppLifecycleListener.appStarted — runs at IDE startup.',
      file: relLocation,
      evidence: `applicationComponents=${appComps.length} listeners=${listeners.map(l => l.topic).join(',')}`,
      owasp: 'LLM06',
      recommendation: 'Verify startup-time activation is necessary.',
    }));
    return findings;
  }

  const POSTSTARTUP_NAMES = new Set(['postStartupActivity', 'backgroundPostStartupActivity']);
  const postStartupCount = extDecls.filter(d => POSTSTARTUP_NAMES.has(d.name)).length;
  const preloadAppService = extDecls.some(d =>
    d.name === 'applicationService' && d.attrs && d.attrs.preload === 'true'
  );
  if (postStartupCount > 0 || preloadAppService) {
    findings.push(finding({
      scanner: SCANNER,
      severity: SEVERITY.MEDIUM,
      title: `checkBroadActivationJB: post-startup activation: ${ext.id}`,
      description: 'Plugin uses postStartupActivity or preloaded applicationService — runs shortly after IDE startup.',
      file: relLocation,
      evidence: `postStartupActivity=${postStartupCount} preloadAppService=${preloadAppService}`,
      owasp: 'LLM06',
      recommendation: 'Review what the startup activity does.',
    }));
  }
  return findings;
}

// Whitelist: IDs that are legit despite being close to a corpus entry.
// Empty by design — the typosquat logic resolves most cases via "corpus entry wins".
const JB_TYPOSQUAT_WHITELIST = new Set();

function checkTyposquatJB(ext, topList, relLocation) {
  const findings = [];
  if (!Array.isArray(topList) || topList.length === 0) return findings;
  const scannedId = normalizeId(ext.id);
  if (JB_TYPOSQUAT_WHITELIST.has(scannedId)) return findings;
  // Exact corpus match = legitimate canonical
  for (const entry of topList) {
    if (normalizeId(entry) === scannedId) return findings;
  }
  for (const entry of topList) {
    const corpusId = normalizeId(entry);
    if (corpusId === scannedId) continue;
    if (Math.abs(corpusId.length - scannedId.length) > 2) continue;
    const d = levenshtein(scannedId, corpusId);
    if (d > 0 && d <= 2) {
      findings.push(finding({
        scanner: SCANNER,
        severity: SEVERITY.MEDIUM,
        title: `checkTyposquatJB: possible typosquat: "${ext.id}" vs "${entry}" (distance=${d})`,
        description: 'JetBrains plugin ID is close to a known legitimate plugin ID. See research brief §4 — JetBrains corpus is legitimate canonical IDs; anything within Levenshtein 2 is suspicious.',
        file: relLocation,
        evidence: `scanned=${scannedId} corpus=${corpusId} distance=${d}`,
        owasp: 'LLM03',
        recommendation: `Verify publisher. If "${entry}" was intended, uninstall this plugin and install the canonical one.`,
      }));
      break; // one finding per plugin
    }
  }
  return findings;
}

function checkDependsChainJB(ext, manifest, relLocation) {
  const findings = [];
  const depends = manifest.depends || [];
  if (depends.length < 3) return findings;
  const hasMandatory = depends.some(d => d.optional === false);
  if (!hasMandatory) return findings;
  findings.push(finding({
    scanner: SCANNER,
    severity: SEVERITY.MEDIUM,
    title: `checkDependsChainJB: deep mandatory dependency chain: ${ext.id}`,
    description: `Plugin declares ${depends.length} <depends>, at least one mandatory — amplified trust chain.`,
    file: relLocation,
    evidence: `depends=${depends.map(d => d.id).slice(0, 5).join(',')}`,
    owasp: 'LLM03',
    recommendation: 'Audit each mandatory dependency.',
  }));
  return findings;
}

function checkPremainClassJB(ext, manifest, relLocation) {
  const findings = [];
  if (!manifest.hasPremainClass) return findings;
  findings.push(finding({
    scanner: SCANNER,
    severity: SEVERITY.HIGH,
    title: `checkPremainClassJB: Java agent detected: ${ext.id}`,
    description: `Plugin JAR declares Premain-Class=${manifest.premainClass} in MANIFEST.MF — loads as a JVM agent with bytecode-rewrite capability.`,
    file: relLocation,
    evidence: `premainClass=${manifest.premainClass}`,
    owasp: 'LLM06, ASI02',
    recommendation: 'Audit the premain class. Legitimate profilers will trigger this too — verify the vendor.',
  }));
  return findings;
}

function checkNativeBinariesJB(ext, manifest, relLocation) {
  const findings = [];
  const binaries = manifest.nativeBinaries || [];
  if (binaries.length === 0) return findings;
  const top = binaries.slice(0, 3).map(b => `${b.path}(${b.size}B, ${b.sha256.slice(0, 12)}…)`);
  findings.push(finding({
    scanner: SCANNER,
    severity: SEVERITY.MEDIUM,
    title: `checkNativeBinariesJB: plugin bundles ${binaries.length} native binaries: ${ext.id}`,
    description: 'Native binaries (.dll/.so/.dylib/.jnilib/.exe) run outside JVM sandbox. Benign for some plugins (e.g. jssc) but non-zero signal.',
    file: relLocation,
    evidence: top.join(' | '),
    owasp: 'LLM06',
    recommendation: 'Verify each binary via VirusTotal or vendor checksum.',
  }));
  return findings;
}

function checkShadedJarsJB(ext, manifest, relLocation) {
  const findings = [];
  const shaded = (manifest.bundledJars || []).filter(j => j.shaded);
  if (shaded.length === 0) return findings;
  findings.push(finding({
    scanner: SCANNER,
    severity: SEVERITY.MEDIUM,
    title: `checkShadedJarsJB: ${shaded.length} shaded jars (cannot audit via OSV): ${ext.id}`,
    description: 'Plugin bundles jars without Implementation-Title/Version in MANIFEST.MF. Vulnerability scanning against OSV/Maven coords not possible.',
    file: relLocation,
    evidence: `shaded=${shaded.slice(0, 5).map(j => j.name).join(',')}`,
    owasp: 'LLM05',
    recommendation: 'Ask vendor for SBOM; consider declining plugin if origin unknown.',
  }));
  return findings;
}

function runJetBrainsChecks(ext, manifest, topList, blocklist, relLocation) {
  const out = [];
  out.push(...checkBlocklistJB(ext, manifest, blocklist, relLocation));
  out.push(...checkThemeWithCodeJB(ext, manifest, relLocation));
  out.push(...checkBroadActivationJB(ext, manifest, relLocation));
  out.push(...checkTyposquatJB(ext, topList, relLocation));
  out.push(...checkDependsChainJB(ext, manifest, relLocation));
  out.push(...checkPremainClassJB(ext, manifest, relLocation));
  out.push(...checkNativeBinariesJB(ext, manifest, relLocation));
  out.push(...checkShadedJarsJB(ext, manifest, relLocation));
  return out;
}

// ---------------------------------------------------------------------------
// Reused-scanner orchestration per extension
// ---------------------------------------------------------------------------

async function scanOneExtension(ext, options) {
  const started = Date.now();
  const warnings = [];

  // Parse manifest — dispatch on extension type
  const parsed = ext.type === 'jetbrains'
    ? await parseIntelliJPlugin(ext.location)
    : await parseVSCodeExtension(ext.location);
  if (!parsed) {
    return {
      id: ext.id,
      version: ext.version,
      type: ext.type,
      location: ext.location,
      publisher: ext.publisher,
      source: ext.source,
      is_builtin: ext.isBuiltin,
      signed: ext.signed,
      scanner_results: {},
      warnings: [`failed to parse manifest for ${ext.id}`],
      aggregate: { counts: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, risk_score: 0, risk_band: 'Low', verdict: 'ALLOW' },
      duration_ms: Date.now() - started,
    };
  }
  const manifest = parsed.manifest;
  warnings.push(...parsed.warnings);

  const isJetBrains = ext.type === 'jetbrains';
  const topList = isJetBrains ? [] : await loadTopVSCode();
  const blocklist = isJetBrains ? [] : await loadVSCodeBlocklist();
  const topListJB = isJetBrains ? await loadTopJetBrains() : [];
  const blocklistJB = isJetBrains ? await loadJetBrainsBlocklist() : [];

  const relLocation = relative(options.targetBase || ext.location, ext.location) || '.';

  // Discover files (Pass A) — excludes node_modules, used for ENT/NET/TNT/UNI
  const discovery = await discoverFiles(ext.location).catch(() => ({ files: [], skipped: 0, truncated: false }));

  // Pass B for MEM — filter to README/CHANGELOG/package.json only (VS Code),
  // plus plugin.xml and META-INF/MANIFEST.MF for JetBrains plugins.
  const memFiles = discovery.files.filter(f => {
    const lower = (f.relPath || '').toLowerCase();
    if (lower === 'readme.md' || lower === 'changelog.md' || lower === 'package.json') return true;
    if (isJetBrains) {
      if (lower === 'plugin.xml' || lower.endsWith('/plugin.xml')) return true;
      if (lower === 'meta-inf/manifest.mf' || lower.endsWith('/meta-inf/manifest.mf')) return true;
    }
    return false;
  });

  // IDE-specific findings — dispatch on extension type
  const ideFindings = isJetBrains
    ? runJetBrainsChecks(
        { ...ext, signed: manifest.hasSignature || ext.signed },
        manifest,
        topListJB,
        blocklistJB,
        relLocation,
      )
    : runIdeChecks(
        { ...ext, signed: manifest.hasSignature || ext.signed },
        manifest,
        topList,
        blocklist,
        relLocation,
      );
  const ideResult = scannerResult(SCANNER, 'ok', ideFindings, 1, Date.now() - started);

  // Run reused scanners (each is independent; run sequentially to avoid burst-rate issues)
  const scanner_results = { IDE: ideResult };

  try {
    scanner_results.UNI = await scanUnicode(ext.location, discovery);
  } catch (err) {
    scanner_results.UNI = scannerResult('UNI', 'error', [], 0, 0, err.message);
  }
  try {
    scanner_results.ENT = await scanEntropy(ext.location, discovery);
  } catch (err) {
    scanner_results.ENT = scannerResult('ENT', 'error', [], 0, 0, err.message);
  }
  try {
    scanner_results.NET = await scanNetwork(ext.location, discovery);
  } catch (err) {
    scanner_results.NET = scannerResult('NET', 'error', [], 0, 0, err.message);
  }
  try {
    scanner_results.TNT = await scanTaint(ext.location, discovery);
  } catch (err) {
    scanner_results.TNT = scannerResult('TNT', 'error', [], 0, 0, err.message);
  }
  try {
    scanner_results.MEM = await scanMemoryPoisoning(ext.location, { ...discovery, files: memFiles });
  } catch (err) {
    scanner_results.MEM = scannerResult('MEM', 'error', [], 0, 0, err.message);
  }
  try {
    // SCR walks its own lockfiles; discovery is unused by it.
    scanner_results.SCR = await scanSupplyChain(ext.location, discovery);
  } catch (err) {
    scanner_results.SCR = scannerResult('SCR', 'error', [], 0, 0, err.message);
  }

  // Aggregate per-extension
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
  for (const r of Object.values(scanner_results)) {
    for (const sev of Object.keys(counts)) {
      counts[sev] += (r.counts && r.counts[sev]) || 0;
    }
  }
  const score = riskScore(counts);

  return {
    id: ext.id,
    version: ext.version,
    type: ext.type,
    location: ext.location,
    publisher: ext.publisher,
    source: ext.source,
    is_builtin: ext.isBuiltin,
    signed: manifest.hasSignature || ext.signed,
    warnings,
    scanner_results,
    aggregate: {
      counts,
      risk_score: score,
      risk_band: riskBand(score),
      verdict: verdict(counts),
    },
    duration_ms: Date.now() - started,
  };
}

// ---------------------------------------------------------------------------
// Bounded concurrency helper
// ---------------------------------------------------------------------------

async function mapConcurrent(items, limit, fn) {
  const out = new Array(items.length);
  let i = 0;
  async function worker() {
    while (true) {
      const idx = i++;
      if (idx >= items.length) return;
      out[idx] = await fn(items[idx], idx);
    }
  }
  const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, () => worker());
  await Promise.all(workers);
  return out;
}

// ---------------------------------------------------------------------------
// Top-level scan
// ---------------------------------------------------------------------------

/**
 * Discover + scan installed extensions.
 * @param {string|null} target - null/'.' => discover all; absolute path to an extracted ext dir => scan single.
 * @param {object} [options]
 * @param {boolean} [options.vscodeOnly=false]
 * @param {boolean} [options.intellijOnly=false]
 * @param {boolean} [options.includeBuiltin=false]
 * @param {boolean} [options.online=false]
 * @param {string[]} [options.rootsOverride]
 * @param {number} [options.concurrency=4]
 * @returns {Promise<object>} - Envelope
 */
export async function scan(target, options = {}) {
  const started = Date.now();
  const warnings = [];
  let extensions = [];
  let rootsScanned = [];
  let urlSource = null;
  let urlTempDir = null;

  // URL mode: fetch plugin archive, extract to temp dir, then treat extracted dir as single target.
  if (isUrlTarget(target)) {
    const detected = detectUrlType(target);
    if (detected.type === 'unknown') {
      warnings.push(`unsupported URL: ${target} (expected VS Code Marketplace, OpenVSX, direct .vsix, or plugins.jetbrains.com)`);
    } else if (detected.type === 'github') {
      warnings.push('GitHub repo URLs are not supported in v6.4.0 — would require build step. Use the Marketplace, OpenVSX, or a direct .vsix link.');
    } else if (detected.type === 'jetbrains') {
      try {
        const fetched = await fetchAndExtractPluginUrl(target, {
          useSandbox: options.useSandbox,
          workerKind: 'jetbrains',
        });
        urlSource = fetched.source;
        urlTempDir = fetched.tempDir;
        target = fetched.extRoot;
        if (fetched.sandbox === null && options.useSandbox !== false) {
          warnings.push('OS sandbox unavailable on this platform — JetBrains plugin extracted without sandbox-exec/bwrap. Defense-in-depth reduced to in-process zip-extract validation.');
        }
      } catch (err) {
        warnings.push(`URL fetch/extract failed: ${err.message}`);
      }
    } else {
      try {
        const fetched = await fetchAndExtractVsixUrl(target, { useSandbox: options.useSandbox });
        urlSource = fetched.source;
        urlTempDir = fetched.tempDir;
        target = fetched.extRoot; // forward into single-target path mode
        if (fetched.sandbox === null && options.useSandbox !== false) {
          warnings.push('OS sandbox unavailable on this platform — VSIX extracted without sandbox-exec/bwrap. Defense-in-depth reduced to in-process zip-extract validation.');
        }
      } catch (err) {
        warnings.push(`URL fetch/extract failed: ${err.message}`);
      }
    }
  }

  const urlFetchFailed = isUrlTarget(target) && !urlSource;
  const singleTargetPath = target && target !== '.' && target !== 'all' && !isUrlTarget(target)
    ? resolve(target)
    : null;

  try {

  if (urlFetchFailed) {
    // Don't fall through to discovery when the user asked for a specific URL.
  } else if (singleTargetPath) {
    // Single-directory mode — detect plugin type from layout.
    // - `lib/*.jar` subtree → JetBrains plugin (parsed via parseIntelliJPlugin)
    // - `package.json` at root → VS Code extension (parsed via parseVSCodeExtension)
    // - neither → warn + skip
    const hasLibDir = existsSync(join(singleTargetPath, 'lib'));
    const hasPackageJson = existsSync(join(singleTargetPath, 'package.json'));
    const isJetBrainsLayout = hasLibDir && !hasPackageJson;

    if (isJetBrainsLayout) {
      const parsed = await parseIntelliJPlugin(singleTargetPath);
      if (!parsed || !parsed.manifest) {
        warnings.push(`cannot parse JetBrains plugin at ${singleTargetPath}`);
        if (parsed && parsed.warnings) warnings.push(...parsed.warnings);
      } else {
        const m = parsed.manifest;
        extensions.push({
          id: m.id,
          publisher: m.publisher || null,
          name: m.name || null,
          version: m.version || null,
          location: singleTargetPath,
          type: 'jetbrains',
          source: null,
          isBuiltin: false,
          installedTimestamp: null,
          targetPlatform: null,
          publisherDisplayName: null,
          signed: false,
          rootDir: singleTargetPath,
        });
        rootsScanned.push(singleTargetPath);
        warnings.push(...parsed.warnings);
      }
    } else if (hasPackageJson) {
      const parsed = await parseVSCodeExtension(singleTargetPath);
      if (!parsed) {
        warnings.push(`cannot parse extension at ${singleTargetPath}`);
      } else {
        const m = parsed.manifest;
        extensions.push({
          id: m.id,
          publisher: m.publisher,
          name: m.name,
          version: m.version,
          location: singleTargetPath,
          type: 'vscode',
          source: null,
          isBuiltin: false,
          installedTimestamp: null,
          targetPlatform: null,
          publisherDisplayName: null,
          signed: m.hasSignature,
          rootDir: singleTargetPath,
        });
        rootsScanned.push(singleTargetPath);
      }
    } else {
      warnings.push(`cannot determine plugin type at ${singleTargetPath} (no package.json, no lib/ dir)`);
    }
  } else {
    // Discovery mode
    if (!options.intellijOnly) {
      const vs = await discoverVSCodeExtensions({
        rootsOverride: options.rootsOverride,
        includeBuiltin: options.includeBuiltin,
        followSymlinks: options.followSymlinks,
      });
      extensions.push(...vs.extensions);
      warnings.push(...vs.warnings);
      rootsScanned.push(...vs.rootsScanned);
    }
    if (!options.vscodeOnly) {
      const jb = await discoverJetBrainsExtensions({
        rootsOverride: options.rootsOverride,
      });
      extensions.push(...jb.extensions);
      warnings.push(...jb.warnings);
      rootsScanned.push(...jb.rootsScanned);
    }
  }

  const targetBase = singleTargetPath || (rootsScanned[0] || process.cwd());
  const concurrency = Math.max(1, Math.min(options.concurrency || 4, 16));

  const perExt = await mapConcurrent(extensions, concurrency, ext =>
    scanOneExtension(ext, { targetBase, online: options.online === true }));

  // Top-level aggregate
  const aggCounts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
  let blocked = 0, warningCount = 0;
  for (const r of perExt) {
    for (const sev of Object.keys(aggCounts)) aggCounts[sev] += r.aggregate.counts[sev] || 0;
    if (r.aggregate.verdict === 'BLOCK') blocked++;
    else if (r.aggregate.verdict === 'WARNING') warningCount++;
  }
  const topScore = riskScore(aggCounts);

  return {
    meta: {
      scanner: 'ide-extension-scanner',
      version: VERSION,
      target: urlSource ? urlSource.url : (singleTargetPath || (target || 'discover-all')),
      timestamp: new Date().toISOString(),
      node_version: process.version,
      duration_ms: Date.now() - started,
      extensions_discovered: {
        vscode: extensions.filter(e => e.type === 'vscode').length,
        jetbrains: extensions.filter(e => e.type === 'jetbrains').length,
      },
      roots_scanned: rootsScanned,
      online: options.online === true,
      source: urlSource,
      warnings,
    },
    extensions: perExt,
    aggregate: {
      counts: aggCounts,
      risk_score: topScore,
      risk_band: riskBand(topScore),
      verdict: verdict(aggCounts),
      extensions_total: extensions.length,
      extensions_blocked: blocked,
      extensions_warning: warningCount,
    },
  };
  } finally {
    if (urlTempDir) {
      await rm(urlTempDir, { recursive: true, force: true }).catch(() => {});
    }
  }
}

// Internal exports for unit testing only — not a stable API.
export const __testing = {
  runJetBrainsChecks,
  checkThemeWithCodeJB,
  checkBroadActivationJB,
  checkTyposquatJB,
  checkDependsChainJB,
  checkPremainClassJB,
  checkNativeBinariesJB,
  checkShadedJarsJB,
  scanOneExtension,
};

/**
 * Discovery-only (for tests/debugging).
 * @param {object} [options]
 */
export async function discoverAll(options = {}) {
  const vs = await discoverVSCodeExtensions({
    rootsOverride: options.rootsOverride,
    includeBuiltin: options.includeBuiltin,
    followSymlinks: options.followSymlinks,
  });
  return vs.extensions;
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

function parseArgs(argv) {
  const args = { target: null, vscodeOnly: false, intellijOnly: false, includeBuiltin: false, online: false, format: 'json', failOn: null, outputFile: null };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--vscode-only') args.vscodeOnly = true;
    else if (a === '--intellij-only') args.intellijOnly = true;
    else if (a === '--include-builtin') args.includeBuiltin = true;
    else if (a === '--online') args.online = true;
    else if (a === '--format') args.format = argv[++i];
    else if (a === '--fail-on') args.failOn = argv[++i];
    else if (a === '--output-file') args.outputFile = argv[++i];
    else if (a === '--help' || a === '-h') args.help = true;
    else if (!args.target) args.target = a;
  }
  return args;
}

function toCompact(env) {
  const lines = [];
  lines.push(`ide-extension-scanner v${VERSION}`);
  lines.push(`target=${env.meta.target} extensions=${env.aggregate.extensions_total} duration=${env.meta.duration_ms}ms`);
  lines.push(`verdict=${env.aggregate.verdict} risk=${env.aggregate.risk_score} (${env.aggregate.risk_band})`);
  lines.push(`counts: crit=${env.aggregate.counts.critical} high=${env.aggregate.counts.high} med=${env.aggregate.counts.medium} low=${env.aggregate.counts.low} info=${env.aggregate.counts.info}`);
  for (const ext of env.extensions) {
    if (ext.aggregate.verdict === 'ALLOW' && ext.aggregate.counts.info === 0) continue;
    lines.push(`- ${ext.id}@${ext.version} → ${ext.aggregate.verdict} (risk=${ext.aggregate.risk_score})`);
    const all = Object.values(ext.scanner_results || {}).flatMap(r => r.findings || []);
    for (const f of all.slice(0, 3)) {
      lines.push(`    [${f.severity.toUpperCase()}] ${f.scanner}: ${f.title}`);
    }
  }
  return lines.join('\n');
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  if (args.help) {
    console.log(`ide-extension-scanner v${VERSION}
Usage: node ide-extension-scanner.mjs [target] [options]

target: omitted/"."/"all" = discover all installed; path to extracted extension directory = single scan;
        https://marketplace.visualstudio.com/items?itemName=publisher.name = fetch from Marketplace;
        https://open-vsx.org/extension/publisher/name[/version] = fetch from OpenVSX;
        https://example.com/path/foo.vsix = direct VSIX download

Options:
  --vscode-only         Skip JetBrains discovery
  --intellij-only       Skip VS Code discovery
  --include-builtin     Include Microsoft builtin extensions
  --online              Enable Marketplace/OSV.dev lookups (opt-in)
  --format <fmt>        json (default) | compact
  --fail-on <severity>  Exit 1 if findings at/above severity (critical|high|medium|low)
  --output-file <path>  Write JSON envelope to file (still prints compact to stdout)
  -h, --help            Show help
`);
    process.exit(0);
  }

  const env = await scan(args.target, {
    vscodeOnly: args.vscodeOnly,
    intellijOnly: args.intellijOnly,
    includeBuiltin: args.includeBuiltin,
    online: args.online,
  });

  if (args.outputFile) {
    try { writeFileSync(args.outputFile, JSON.stringify(env, null, 2)); }
    catch (err) { console.error(`Failed to write ${args.outputFile}: ${err.message}`); process.exit(3); }
    console.log(toCompact(env));
  } else if (args.format === 'compact') {
    console.log(toCompact(env));
  } else {
    console.log(JSON.stringify(env, null, 2));
  }

  if (args.failOn) {
    const order = ['low', 'medium', 'high', 'critical'];
    const threshold = order.indexOf(String(args.failOn).toLowerCase());
    if (threshold < 0) {
      console.error(`Invalid --fail-on: ${args.failOn}`);
      process.exit(2);
    }
    for (let i = threshold; i < order.length; i++) {
      if ((env.aggregate.counts[order[i]] || 0) > 0) process.exit(1);
    }
  }
}

const isMain = fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
  main().catch(err => {
    console.error(err.stack || err.message || err);
    process.exit(2);
  });
}
