// ide-extension-discovery.mjs — OS-aware discovery of installed VS Code / JetBrains extensions.
// Zero dependencies (Node.js builtins only).
//
// VS Code + forks (Cursor, Windsurf, VSCodium, code-server, Insiders, Remote-SSH):
//   Parses extensions.json (per-dir manifest) + falls back to dir-name regex.
// JetBrains: stub (v1.1).

import { readFile, readdir, stat, lstat, access } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { existsSync } from 'node:fs';

// ---------------------------------------------------------------------------
// OS path resolution
// ---------------------------------------------------------------------------

/**
 * Return all candidate VS Code extension root directories for current OS.
 * @returns {string[]}
 */
export function getVSCodeExtensionRoots() {
  const home = homedir();
  const roots = [
    join(home, '.vscode', 'extensions'),
    join(home, '.vscode-insiders', 'extensions'),
    join(home, '.cursor', 'extensions'),
    join(home, '.windsurf', 'extensions'),
    join(home, '.vscode-oss', 'extensions'),       // VSCodium
    join(home, '.vscode-server', 'extensions'),    // Remote-SSH
  ];
  if (process.platform === 'linux') {
    roots.push(join(home, '.local', 'share', 'code-server', 'extensions'));
  }
  return roots;
}

/**
 * Return the JetBrains base directory (contains per-IDE-per-version subdirectories).
 * Actual plugins live under <base>/<IDE><Edition><Version>/plugins/. v1.1 walks these.
 * @returns {string|null}
 */
export function getJetBrainsBaseDir() {
  const home = homedir();
  let base;
  if (process.platform === 'darwin') {
    base = join(home, 'Library', 'Application Support', 'JetBrains');
  } else if (process.platform === 'win32') {
    base = join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'JetBrains');
  } else {
    base = join(home, '.local', 'share', 'JetBrains');
  }
  return existsSync(base) ? base : null;
}

/**
 * Return the Android Studio base directory. Diverges from JetBrains on Linux:
 * Android Studio uses ~/.config/Google (config path) rather than ~/.local/share.
 * @returns {string|null}
 */
export function getAndroidStudioBaseDir() {
  const home = homedir();
  let base;
  if (process.platform === 'darwin') {
    base = join(home, 'Library', 'Application Support', 'Google');
  } else if (process.platform === 'win32') {
    base = join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'Google');
  } else {
    base = join(home, '.config', 'Google');
  }
  return existsSync(base) ? base : null;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// Known VS Code target-platform suffixes per
// https://code.visualstudio.com/api/working-with-extensions/publishing-extension#platformspecific-extensions
const PLATFORM_SUFFIXES = [
  'win32-x64', 'win32-ia32', 'win32-arm64',
  'linux-x64', 'linux-arm64', 'linux-armhf',
  'darwin-x64', 'darwin-arm64',
  'alpine-x64', 'alpine-arm64',
  'web',
];

/**
 * Parse directory name of form "publisher.name-version[-platform]".
 * Strategy: strip a trailing known-platform suffix first, then match identifier + version.
 * Returns { publisher, name, version, targetPlatform } or null.
 * @param {string} dirName
 */
export function parseDirName(dirName) {
  let trimmed = dirName;
  let targetPlatform = null;
  for (const plat of PLATFORM_SUFFIXES) {
    const suffix = '-' + plat;
    if (trimmed.toLowerCase().endsWith(suffix)) {
      targetPlatform = plat;
      trimmed = trimmed.slice(0, -suffix.length);
      break;
    }
  }
  const m = trimmed.match(/^(.+?)-(\d+\.\d+\.\d+(?:-[a-z0-9.]+)?)$/i);
  if (!m) return null;
  const idPart = m[1];
  const version = m[2];
  const dotIdx = idPart.indexOf('.');
  if (dotIdx === -1) return null;
  return {
    publisher: idPart.slice(0, dotIdx),
    name: idPart.slice(dotIdx + 1),
    version,
    targetPlatform,
  };
}

async function readJson(filePath) {
  try {
    const raw = await readFile(filePath, 'utf8');
    return JSON.parse(raw);
  } catch {
    return null;
  }
}

async function pathExists(p) {
  try { await access(p); return true; } catch { return false; }
}

async function isSymlink(p) {
  try {
    const s = await lstat(p);
    return s.isSymbolicLink();
  } catch { return false; }
}

// ---------------------------------------------------------------------------
// VS Code discovery
// ---------------------------------------------------------------------------

/**
 * @typedef {object} ExtensionRecord
 * @property {string} id
 * @property {string} publisher
 * @property {string} name
 * @property {string} version
 * @property {string} location
 * @property {'vscode'|'jetbrains'} type
 * @property {'gallery'|'vsix'|null} source
 * @property {boolean} isBuiltin
 * @property {number|null} installedTimestamp
 * @property {string|null} targetPlatform
 * @property {string|null} publisherDisplayName
 * @property {boolean} signed
 * @property {string} rootDir
 */

/**
 * Discover VS Code extensions across all roots.
 * @param {object} [options]
 * @param {string[]} [options.rootsOverride] - Test injection: use these roots only.
 * @param {boolean} [options.includeBuiltin=false]
 * @param {boolean} [options.followSymlinks=false]
 * @returns {Promise<{ extensions: ExtensionRecord[], warnings: string[], rootsScanned: string[] }>}
 */
export async function discoverVSCodeExtensions(options = {}) {
  const warnings = [];
  const extensions = [];
  const rootsScanned = [];

  let roots;
  if (options.rootsOverride) {
    roots = options.rootsOverride;
  } else if (process.env.LLM_SECURITY_IDE_ROOTS) {
    roots = process.env.LLM_SECURITY_IDE_ROOTS.split(':').filter(Boolean);
  } else {
    roots = getVSCodeExtensionRoots();
  }

  for (const root of roots) {
    if (!await pathExists(root)) continue;
    rootsScanned.push(root);

    // Load per-root extensions.json (machine index) to get metadata.source, isBuiltin etc.
    const indexPath = join(root, 'extensions.json');
    const index = await readJson(indexPath);
    const metaByRelLoc = new Map();
    if (Array.isArray(index)) {
      for (const entry of index) {
        if (entry && entry.relativeLocation) {
          metaByRelLoc.set(entry.relativeLocation, entry);
        }
      }
    } else if (index !== null) {
      warnings.push(`malformed extensions.json in ${root}`);
    }

    let entries;
    try {
      entries = await readdir(root, { withFileTypes: true });
    } catch (err) {
      warnings.push(`failed to read ${root}: ${err.message}`);
      continue;
    }

    for (const entry of entries) {
      // Skip dotfiles, extensions.json, .obsolete
      if (entry.name.startsWith('.')) continue;
      if (entry.name === 'extensions.json') continue;

      // Check symlink handling
      if (entry.isSymbolicLink()) {
        if (!options.followSymlinks) continue;
      } else if (!entry.isDirectory()) {
        continue;
      }

      const extDir = join(root, entry.name);
      const parsed = parseDirName(entry.name);

      // Read package.json to get authoritative publisher + name
      const pkgPath = join(extDir, 'package.json');
      const pkg = await readJson(pkgPath);
      let publisher = pkg?.publisher;
      let name = pkg?.name;
      let version = pkg?.version;

      if (!publisher || !name) {
        if (!parsed) {
          warnings.push(`could not identify extension in ${extDir}`);
          continue;
        }
        publisher = publisher || parsed.publisher;
        name = name || parsed.name;
        version = version || parsed.version;
      }

      if (!publisher || !name || !version) {
        warnings.push(`incomplete identity for ${extDir}`);
        continue;
      }

      const id = `${publisher}.${name}`.toLowerCase();
      const indexEntry = metaByRelLoc.get(entry.name);
      const meta = indexEntry?.metadata || {};
      const isBuiltin = meta.isBuiltin === true;

      if (isBuiltin && !options.includeBuiltin) continue;

      const signed = await pathExists(join(extDir, '.signature.p7s'));

      extensions.push({
        id,
        publisher: publisher.toLowerCase(),
        name: name.toLowerCase(),
        version,
        location: extDir,
        type: 'vscode',
        source: meta.source || null,
        isBuiltin,
        installedTimestamp: typeof meta.installedTimestamp === 'number' ? meta.installedTimestamp : null,
        targetPlatform: meta.targetPlatform || parsed?.targetPlatform || null,
        publisherDisplayName: meta.publisherDisplayName || null,
        signed,
        rootDir: root,
      });
    }
  }

  return { extensions, warnings, rootsScanned };
}

// ---------------------------------------------------------------------------
// JetBrains discovery — stub (v1.1)
// ---------------------------------------------------------------------------

// Matches JetBrains + Android Studio product directory names. Explicitly excludes
// Fleet (separate SDK, different plugin model) and Toolbox (launcher cache dir).
const JB_PRODUCT_DIR_RE = /^(IntelliJIdea|IdeaIC|PyCharm|PyCharmCE|WebStorm|GoLand|PhpStorm|RubyMine|CLion|DataGrip|Rider|RustRover|DataSpell|Aqua|AndroidStudio)([0-9]+\.[0-9]+(?:\.[0-9]+)?)?$/;

/**
 * Discover JetBrains + Android Studio plugins from installed IDE directories.
 *
 * **`rootsOverride` semantics for JetBrains:** Each entry is a BASE directory —
 * the equivalent of `getJetBrainsBaseDir()` output (the `JetBrains/` or
 * `Google/` parent). The discovery walks `<base>/<ProductDir>/plugins/<plugin>/`.
 * This differs from VS Code where `rootsOverride` entries point at the
 * `extensions/` level directly. Rationale: JetBrains has many product dirs per
 * base (IntelliJIdea, PyCharm, GoLand, AndroidStudio…), so base-level injection
 * is the natural analogue for tests.
 *
 * Fleet and Toolbox are always excluded.
 *
 * @param {object} [options]
 * @param {string[]} [options.rootsOverride] - Base directories (not `plugins/` level).
 * @param {boolean} [options.followSymlinks=false]
 * @returns {Promise<{ extensions: ExtensionRecord[], warnings: string[], rootsScanned: string[] }>}
 */
export async function discoverJetBrainsExtensions(options = {}) {
  const warnings = [];
  const extensions = [];
  const rootsScanned = [];

  let baseDirs;
  if (Array.isArray(options.rootsOverride) && options.rootsOverride.length > 0) {
    baseDirs = options.rootsOverride;
  } else if (process.env.LLM_SECURITY_IDE_ROOTS) {
    baseDirs = process.env.LLM_SECURITY_IDE_ROOTS.split(':').filter(Boolean);
  } else {
    baseDirs = [getJetBrainsBaseDir(), getAndroidStudioBaseDir()].filter(Boolean);
  }

  for (const base of baseDirs) {
    if (!await pathExists(base)) continue;
    rootsScanned.push(base);

    let productEntries;
    try {
      productEntries = await readdir(base, { withFileTypes: true });
    } catch (err) {
      warnings.push(`failed to read ${base}: ${err.message}`);
      continue;
    }

    for (const pe of productEntries) {
      if (!pe.isDirectory() && !(pe.isSymbolicLink() && options.followSymlinks)) continue;
      if (pe.name.startsWith('.')) continue;
      if (!JB_PRODUCT_DIR_RE.test(pe.name)) continue; // excludes Fleet, Toolbox

      const productDir = join(base, pe.name);
      const pluginsDir = join(productDir, 'plugins');
      if (!await pathExists(pluginsDir)) continue;

      let pluginEntries;
      try {
        pluginEntries = await readdir(pluginsDir, { withFileTypes: true });
      } catch (err) {
        warnings.push(`failed to read ${pluginsDir}: ${err.message}`);
        continue;
      }

      for (const plug of pluginEntries) {
        if (plug.name.startsWith('.')) continue;
        if (plug.name === 'disabled_plugins.txt') continue;
        if (plug.isSymbolicLink() && !options.followSymlinks) continue;
        if (!plug.isDirectory() && !plug.isSymbolicLink()) continue;

        const pluginDir = join(pluginsDir, plug.name);
        // Safety: skip anything rooted under Toolbox cache
        if (pluginDir.includes(join('Caches', 'JetBrains', 'Toolbox'))) continue;

        const id = plug.name.toLowerCase();
        extensions.push({
          id,
          publisher: '',
          name: plug.name,
          version: '',
          location: pluginDir,
          type: 'jetbrains',
          source: null,
          isBuiltin: false,
          installedTimestamp: null,
          targetPlatform: null,
          publisherDisplayName: null,
          signed: false,
          rootDir: base,
          productDir: pe.name,
        });
      }
    }
  }

  return { extensions, warnings, rootsScanned };
}
