// wall-e/tools/npm-service.js
// Inspired by OpenCode's Npm service
// (https://github.com/anomalyco/opencode, packages/opencode/src/npm/index.ts)
// Package resolution, installation, and caching for Wall-E coding agent.
'use strict';

const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const path = require('node:path');
const fs = require('node:fs');

const execFileAsync = promisify(execFile);

const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const DEFAULT_INSTALL_TIMEOUT_MS = 120_000;   // 2 minutes

/**
 * NpmService — npm package resolution and installation.
 *
 * OpenCode uses @npmcli/arborist for tree management and fetches
 * the npm registry directly for version resolution. Wall-E adapts
 * this with a simpler approach: direct registry fetch + npm CLI install,
 * with an in-memory cache to avoid repeated registry calls.
 */
class NpmService {
  /**
   * @param {object} [opts]
   * @param {number} [opts.cacheTtlMs] - Cache TTL in ms (default: 1 hour)
   * @param {number} [opts.installTimeoutMs] - npm install timeout (default: 2 min)
   * @param {Function} [opts.fetcher] - Custom fetch function (for testing)
   */
  constructor(opts = {}) {
    this._cacheTtlMs = opts.cacheTtlMs || DEFAULT_CACHE_TTL_MS;
    this._installTimeoutMs = opts.installTimeoutMs || DEFAULT_INSTALL_TIMEOUT_MS;
    this._fetcher = opts.fetcher || globalThis.fetch;
    /** @type {Map<string, { data: object, fetchedAt: number }>} */
    this._cache = new Map();
  }

  /**
   * Resolve the latest version of a package from the npm registry.
   * Results are cached for cacheTtlMs to avoid repeated network requests.
   *
   * Ported from OpenCode's Npm.outdated() registry fetch pattern.
   *
   * @param {string} packageName - npm package name (e.g., 'lodash', '@types/node')
   * @returns {Promise<{ name: string, version: string, description: string } | null>}
   */
  async resolveVersion(packageName) {
    if (!packageName) throw new Error('packageName is required');

    // Check cache
    const cached = this._cache.get(packageName);
    if (cached && (Date.now() - cached.fetchedAt) < this._cacheTtlMs) {
      return cached.data;
    }

    try {
      const response = await this._fetcher(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`);
      if (!response.ok) {
        if (response.status === 404) return null;
        throw new Error(`Registry returned ${response.status}`);
      }
      const json = await response.json();
      const latest = json['dist-tags']?.latest;
      if (!latest) return null;

      const result = {
        name: json.name || packageName,
        version: latest,
        description: json.description || '',
      };

      this._cache.set(packageName, { data: result, fetchedAt: Date.now() });
      return result;
    } catch (err) {
      // Return cached data even if expired, rather than failing
      if (cached) return cached.data;
      throw err;
    }
  }

  /**
   * Check if a package is installed in a given directory's node_modules.
   *
   * @param {string} packageName - Package name
   * @param {string} cwd - Project directory to check
   * @returns {boolean}
   */
  isInstalled(packageName, cwd) {
    if (!packageName || !cwd) return false;
    const pkgDir = path.join(cwd, 'node_modules', packageName);
    try {
      const pkgJson = path.join(pkgDir, 'package.json');
      fs.statSync(pkgJson);
      return true;
    } catch {
      return false;
    }
  }

  /**
   * Install a package using npm CLI.
   * Captures stdout/stderr and respects a timeout.
   *
   * Ported from OpenCode's Npm.add() / Npm.install() pattern,
   * simplified to use npm CLI instead of @npmcli/arborist.
   *
   * @param {string} packageName - Package to install (e.g., 'lodash', 'lodash@4.17.21')
   * @param {string} cwd - Directory to install in
   * @param {object} [opts]
   * @param {boolean} [opts.saveDev] - Install as devDependency
   * @param {boolean} [opts.global] - Install globally
   * @param {number} [opts.timeoutMs] - Override timeout
   * @returns {Promise<{ success: boolean, stdout: string, stderr: string, version?: string }>}
   */
  async install(packageName, cwd, opts = {}) {
    if (!packageName) throw new Error('packageName is required');
    if (!cwd) throw new Error('cwd is required');

    // B2: Validate package name to prevent command injection
    const PACKAGE_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[^\s]+)?$/;
    if (!PACKAGE_NAME_RE.test(packageName)) {
      throw new Error(`Invalid package name: ${packageName}`);
    }

    const args = ['install', packageName];
    if (opts.saveDev) args.push('--save-dev');
    if (opts.global) args.push('-g');
    args.push('--no-fund', '--no-audit');

    const timeout = opts.timeoutMs || this._installTimeoutMs;

    try {
      const { stdout, stderr } = await execFileAsync('npm', args, {
        cwd,
        timeout,
        env: { ...process.env, NO_UPDATE_NOTIFIER: '1' },
      });

      // Try to extract installed version
      let version;
      const match = stdout.match(/added\s+\d+\s+package|[\w@/.-]+@(\d+\.\d+\.\d+[^\s]*)/);
      if (match && match[1]) version = match[1];

      return { success: true, stdout, stderr: stderr || '', version };
    } catch (err) {
      return {
        success: false,
        stdout: err.stdout || '',
        stderr: err.stderr || err.message,
      };
    }
  }

  /**
   * Clear the registry cache (or a single entry).
   * @param {string} [packageName] - Clear specific package, or all if omitted
   */
  clearCache(packageName) {
    if (packageName) {
      this._cache.delete(packageName);
    } else {
      this._cache.clear();
    }
  }

  /**
   * Get cache stats.
   * @returns {{ size: number, entries: string[] }}
   */
  cacheStats() {
    return {
      size: this._cache.size,
      entries: Array.from(this._cache.keys()),
    };
  }
}

module.exports = { NpmService };
