// npx-resolver.ts - Resolve npx/npm exec binaries to avoid npm parent processes
import { existsSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync, openSync, readSync, closeSync } from "node:fs";
import { join, dirname, extname, resolve, sep } from "node:path";
import { getAgentPath } from "./agent-dir.ts";
import { spawn, spawnSync } from "node:child_process";

// v2: cache keys are canonical (packageSpec/binName) instead of raw invocation
// args, and entries carry packageDir so reuse can revalidate the on-disk
// version. v1 entries are discarded on load.
const CACHE_VERSION = 2;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;

interface NpxCacheEntry {
  resolvedBin: string;
  resolvedAt: number;
  packageVersion?: string;
  packageDir?: string;
  isJs: boolean;
}

interface NpxCache {
  version: number;
  entries: Record<string, NpxCacheEntry>;
}

export interface NpxResolution {
  binPath: string;
  extraArgs: string[];
  isJs: boolean;
}

interface ParsedInvocation {
  packageSpec: string;
  binName?: string;
  extraArgs: string[];
}

export async function resolveNpxBinary(
  command: string,
  args: string[]
): Promise<NpxResolution | null> {
  const parsed = command === "npx"
    ? parseNpxArgs(args)
    : command === "npm"
      ? parseNpmExecArgs(args)
      : null;

  if (!parsed) return null;

  // Without semver we cannot prove a cached install satisfies a range or tag
  // ("^1.2.3", "latest", ...). Fail safe: let npx/npm itself resolve those.
  const exactVersion = parseExactVersion(parsed.packageSpec);
  if (!exactVersion) return null;

  const cacheDir = getNpmCacheDir();
  if (!cacheDir) return null;

  // Binary identity is the resolved package spec plus selected bin, not the
  // runtime argument vector: equivalent invocations with different server
  // flags share one resolution while each keeps its own extraArgs.
  const cacheKey = canonicalCacheKey(cacheDir, parsed.packageSpec, parsed.binName);
  const cache = loadCache();
  const cached = cache?.entries?.[cacheKey];

  if (
    cached
    && Date.now() - cached.resolvedAt < CACHE_TTL_MS
    && existsSync(cached.resolvedBin)
    && persistentEntryMatches(cached, exactVersion)
  ) {
    return { binPath: cached.resolvedBin, extraArgs: parsed.extraArgs, isJs: cached.isJs };
  }

  const resolved = resolveFromNpmCache(parsed.packageSpec, parsed.binName);
  if (resolved) {
    saveCacheEntry(cacheKey, resolved);
    return { binPath: resolved.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolved.isJs };
  }

  // Slow path: force npx cache population. Concurrent misses for the same
  // spec share one in-flight population; waiters re-resolve from disk after.
  const flightKey = `${cacheDir}\u0000${parsed.packageSpec}`;
  let flight = inflightPopulations.get(flightKey);
  if (!flight) {
    flight = cachePopulator(parsed.packageSpec)
      .catch(() => undefined)
      .finally(() => inflightPopulations.delete(flightKey));
    inflightPopulations.set(flightKey, flight);
  }
  await flight;

  const resolvedAfterInstall = resolveFromNpmCache(parsed.packageSpec, parsed.binName);
  if (resolvedAfterInstall) {
    saveCacheEntry(cacheKey, resolvedAfterInstall);
    return { binPath: resolvedAfterInstall.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolvedAfterInstall.isJs };
  }

  return null;
}

const inflightPopulations = new Map<string, Promise<void>>();

let cachePopulator: (packageSpec: string) => Promise<void> = forceNpxCache;

/** Test only: swap the slow-path cache populator so tests never spawn npm. */
export function setNpxCachePopulatorForTesting(
  populator: ((packageSpec: string) => Promise<void>) | null
): void {
  cachePopulator = populator ?? forceNpxCache;
}

function parseNpxArgs(args: string[]): ParsedInvocation | null {
  const separatorIndex = args.indexOf("--");
  const before = separatorIndex >= 0 ? args.slice(0, separatorIndex) : args;
  const after = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [];

  const positionals: string[] = [];
  let packageSpec: string | undefined;
  let sawPackageFlag = false;
  let foundFirstPositional = false;

  for (let i = 0; i < before.length; i++) {
    const arg = before[i];
    if (foundFirstPositional) {
      positionals.push(arg);
      continue;
    }
    if (arg === "-y" || arg === "--yes") continue;
    if (arg === "-p" || arg === "--package") {
      const value = before[i + 1];
      if (!value || value.startsWith("-")) return null;
      if (!packageSpec) packageSpec = value;
      sawPackageFlag = true;
      i++;
      continue;
    }
    if (arg.startsWith("--package=")) {
      const value = arg.slice("--package=".length);
      if (!value) return null;
      if (!packageSpec) packageSpec = value;
      sawPackageFlag = true;
      continue;
    }
    if (arg.startsWith("-")) {
      return null;
    }
    positionals.push(arg);
    foundFirstPositional = true;
  }

  const separatedAfter = separatorIndex >= 0 && after.length > 0 ? ["--", ...after] : after;

  if (sawPackageFlag) {
    const binName = positionals[0];
    if (!packageSpec || !binName) return null;
    const extraArgs = positionals.slice(1).concat(separatedAfter);
    return { packageSpec, binName, extraArgs };
  }

  const packagePositional = positionals[0];
  if (!packagePositional) return null;
  const extraArgs = positionals.slice(1).concat(separatedAfter);
  return { packageSpec: packagePositional, extraArgs };
}

function parseNpmExecArgs(args: string[]): ParsedInvocation | null {
  if (args[0] !== "exec") return null;
  const execArgs = args.slice(1);
  const separatorIndex = execArgs.indexOf("--");
  if (separatorIndex < 0) return null;

  const before = execArgs.slice(0, separatorIndex);
  const after = execArgs.slice(separatorIndex + 1);

  let packageSpec: string | undefined;
  for (let i = 0; i < before.length; i++) {
    const arg = before[i];
    if (arg === "-y" || arg === "--yes") continue;
    if (arg === "--package") {
      const value = before[i + 1];
      if (!value || value.startsWith("-")) return null;
      if (!packageSpec) packageSpec = value;
      i++;
      continue;
    }
    if (arg.startsWith("--package=")) {
      const value = arg.slice("--package=".length);
      if (!value) return null;
      if (!packageSpec) packageSpec = value;
      continue;
    }
    if (arg.startsWith("-")) {
      return null;
    }
  }

  const binName = after[0];
  if (!packageSpec || !binName) return null;
  const extraArgs = after.slice(1);
  return { packageSpec, binName, extraArgs };
}

function resolveFromNpmCache(packageSpec: string, binName?: string): NpxCacheEntry | null {
  const cacheDir = getNpmCacheDir();
  if (!cacheDir) return null;

  const exactVersion = parseExactVersion(packageSpec);
  if (!exactVersion) return null;

  const packageName = extractPackageName(packageSpec);
  if (!packageName) return null;

  const packageDir = findCachedPackageDir(cacheDir, packageName, exactVersion);
  if (!packageDir) return null;

  const packageJsonPath = join(packageDir, "package.json");
  if (!existsSync(packageJsonPath)) return null;

  let pkg: { bin?: string | Record<string, string>; version?: string } | null = null;
  try {
    pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
      bin?: string | Record<string, string>;
      version?: string;
    };
  } catch {
    return null;
  }

  const binField = pkg?.bin;
  if (!binField) return null;

  const candidates = buildBinCandidates(packageName, binName);
  let chosenBinName: string | undefined;
  let binRel: string | undefined;

  if (typeof binField === "string") {
    chosenBinName = defaultBinName(packageName);
    binRel = binField;
  } else {
    for (const candidate of candidates) {
      if (binField[candidate]) {
        chosenBinName = candidate;
        binRel = binField[candidate];
        break;
      }
    }
    if (!binRel) {
      const firstEntry = Object.entries(binField)[0];
      if (firstEntry) {
        chosenBinName = firstEntry[0];
        binRel = firstEntry[1];
      }
    }
  }

  if (!binRel) return null;

  const nodeModulesDir = findNodeModulesDir(packageDir);
  const binLink = chosenBinName ? join(nodeModulesDir, ".bin", chosenBinName) : null;
  let resolvedBin = binLink && existsSync(binLink) ? safeRealpath(binLink) : "";
  if (!resolvedBin) {
    resolvedBin = resolve(packageDir, binRel);
    if (!existsSync(resolvedBin)) return null;
  }

  const isJs = detectJsBinary(resolvedBin);
  return {
    resolvedBin,
    resolvedAt: Date.now(),
    packageVersion: pkg?.version,
    packageDir,
    isJs,
  };
}

const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;

function parseExactVersion(spec: string): string | null {
  const packageName = extractPackageName(spec);
  if (!packageName) return null;
  const rest = spec.slice(packageName.length);
  if (!rest.startsWith("@")) return null;
  const version = rest.slice(1);
  const normalized = version.startsWith("v") ? version.slice(1) : version;
  return EXACT_VERSION_PATTERN.test(normalized) ? normalized : null;
}

function versionsEqual(a: string, b: string): boolean {
  const normalize = (value: string) => (value.startsWith("v") ? value.slice(1) : value);
  return normalize(a) === normalize(b);
}

function readPackageVersion(packageDir: string): string | null {
  try {
    const pkg = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf-8")) as { version?: unknown };
    return typeof pkg.version === "string" ? pkg.version : null;
  } catch {
    return null;
  }
}

// A persistent entry is reusable only while the install it points at still
// matches the requested exact version: npm may have replaced the _npx tree.
function persistentEntryMatches(entry: NpxCacheEntry, exactVersion: string): boolean {
  if (!entry.packageDir || !entry.packageVersion) return false;
  if (!versionsEqual(entry.packageVersion, exactVersion)) return false;
  const onDisk = readPackageVersion(entry.packageDir);
  return onDisk !== null && versionsEqual(onDisk, exactVersion);
}

function canonicalCacheKey(cacheDir: string, packageSpec: string, binName: string | undefined): string {
  const packageName = extractPackageName(packageSpec) ?? packageSpec;
  const effectiveBin = binName ?? defaultBinName(packageName);
  return JSON.stringify([cacheDir, packageSpec, effectiveBin]);
}

const FORCE_CACHE_TIMEOUT_MS = 30_000;

async function forceNpxCache(packageSpec: string): Promise<void> {
  try {
    await new Promise<void>((resolve, reject) => {
      const proc = spawn(
        "npm",
        ["exec", "--yes", "--package", packageSpec, "--", "node", "-e", "1"],
        { stdio: "ignore", windowsHide: true }
      );
      const timer = setTimeout(() => {
        proc.kill();
        reject(new Error("timeout"));
      }, FORCE_CACHE_TIMEOUT_MS);
      timer.unref();
      proc.on("close", () => { clearTimeout(timer); resolve(); });
      proc.on("error", (err) => { clearTimeout(timer); reject(err); });
    });
  } catch {
    // Ignore failures, resolution will fall back to original command
  }
}

function buildBinCandidates(packageName: string, explicitBin?: string): string[] {
  const candidates: string[] = [];
  if (explicitBin) candidates.push(explicitBin);

  if (packageName.startsWith("@")) {
    const namePart = packageName.split("/")[1] ?? "";
    const scopePart = packageName.split("/")[0]?.replace("@", "") ?? "";
    if (namePart) candidates.push(namePart);
    if (scopePart && namePart) candidates.push(`${scopePart}-${namePart}`);
  } else {
    candidates.push(packageName);
  }

  return [...new Set(candidates.filter(Boolean))];
}

function extractPackageName(spec: string): string | null {
  const trimmed = spec.trim();
  if (!trimmed) return null;
  if (trimmed.startsWith("@")) {
    const slashIndex = trimmed.indexOf("/");
    if (slashIndex < 0) return null;
    const atIndex = trimmed.lastIndexOf("@");
    if (atIndex > slashIndex) {
      return trimmed.slice(0, atIndex);
    }
    return trimmed;
  }
  const atIndex = trimmed.indexOf("@");
  return atIndex >= 0 ? trimmed.slice(0, atIndex) : trimmed;
}

function defaultBinName(packageName: string): string {
  if (packageName.startsWith("@")) {
    const parts = packageName.split("/");
    return parts[1] ?? packageName.replace("@", "").replace("/", "-");
  }
  return packageName;
}

function findCachedPackageDir(cacheDir: string, packageName: string, exactVersion: string): string | null {
  const npxDir = join(cacheDir, "_npx");
  if (!existsSync(npxDir)) return null;

  const packagePathParts = packageName.startsWith("@")
    ? packageName.split("/")
    : [packageName];

  const candidates = readdirSync(npxDir, { withFileTypes: true })
    .filter(entry => entry.isDirectory())
    .map(entry => {
      const full = join(npxDir, entry.name);
      const mtime = safeStatMtime(full);
      return { name: entry.name, mtime };
    })
    .sort((a, b) => b.mtime - a.mtime);

  for (const entry of candidates) {
    const pkgDir = join(npxDir, entry.name, "node_modules", ...packagePathParts);
    if (!existsSync(join(pkgDir, "package.json"))) continue;
    // The newest install is not necessarily the requested one: accept only a
    // directory whose version matches the exact spec.
    const version = readPackageVersion(pkgDir);
    if (version !== null && versionsEqual(version, exactVersion)) {
      return pkgDir;
    }
  }

  return null;
}

function findNodeModulesDir(packageDir: string): string {
  const parts = packageDir.split(sep);
  const idx = parts.lastIndexOf("node_modules");
  if (idx >= 0) {
    return parts.slice(0, idx + 1).join(sep);
  }
  return join(packageDir, "..");
}

function detectJsBinary(binPath: string): boolean {
  const ext = extname(binPath).toLowerCase();
  if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return true;
  try {
    const fd = openSync(binPath, "r");
    try {
      const buf = Buffer.alloc(256);
      readSync(fd, buf, 0, 256, 0);
      const firstLine = buf.toString("utf-8").split("\n")[0] ?? "";
      return firstLine.startsWith("#!") && firstLine.includes("node");
    } finally {
      closeSync(fd);
    }
  } catch {
    return false;
  }
}

let npmCacheDirCached: { dir: string | null; envSource: string | undefined } | undefined;

function getNpmCacheDir(): string | null {
  const envSource = process.env.NPM_CONFIG_CACHE;
  if (npmCacheDirCached && npmCacheDirCached.envSource === envSource) {
    return npmCacheDirCached.dir;
  }
  if (envSource) {
    npmCacheDirCached = { dir: envSource, envSource };
    return envSource;
  }
  try {
    const result = spawnSync("npm", ["config", "get", "cache"], { encoding: "utf-8", windowsHide: true });
    if (result.status === 0) {
      const path = String(result.stdout).trim();
      npmCacheDirCached = { dir: path || null, envSource };
      return npmCacheDirCached.dir;
    }
  } catch {
    npmCacheDirCached = { dir: null, envSource };
    return null;
  }
  npmCacheDirCached = { dir: null, envSource };
  return null;
}

function getNpxCachePath(): string {
  return getAgentPath("mcp-npx-cache.json");
}

function loadCache(): NpxCache | null {
  const cachePath = getNpxCachePath();
  if (!existsSync(cachePath)) return null;
  try {
    const raw = JSON.parse(readFileSync(cachePath, "utf-8"));
    if (!raw || typeof raw !== "object") return null;
    if (raw.version !== CACHE_VERSION) return null;
    if (!raw.entries || typeof raw.entries !== "object") return null;
    return raw as NpxCache;
  } catch {
    return null;
  }
}

function saveCacheEntry(key: string, entry: NpxCacheEntry): void {
  const cachePath = getNpxCachePath();
  const dir = dirname(cachePath);
  mkdirSync(dir, { recursive: true });

  let merged: NpxCache = { version: CACHE_VERSION, entries: {} };
  try {
    if (existsSync(cachePath)) {
      const existing = JSON.parse(readFileSync(cachePath, "utf-8")) as NpxCache;
      if (existing && existing.version === CACHE_VERSION && existing.entries) {
        merged.entries = { ...existing.entries };
      }
    }
  } catch {
    // Ignore parse errors
  }

  merged.entries[key] = entry;
  const tmpPath = `${cachePath}.${process.pid}.tmp`;
  writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
  renameSync(tmpPath, cachePath);
}

function safeRealpath(path: string): string {
  try {
    return realpathSync(path);
  } catch {
    return "";
  }
}

function safeStatMtime(path: string): number {
  try {
    return statSync(path).mtimeMs;
  } catch {
    return 0;
  }
}
