// wall-e/tools/lsp-servers.js
// Ported from OpenCode (https://github.com/anomalyco/opencode)
// Source: packages/opencode/src/lsp/server.ts
// Server definitions for language servers. Each server knows:
// - which file extensions it handles
// - which command to spawn
// - how to find the project root (walk up looking for marker files)
// - exclude patterns that disqualify a root (e.g. deno.json excludes typescript)
'use strict';

const path = require('node:path');
const fs = require('node:fs');
const { execFileSync } = require('node:child_process');

/**
 * Walk up from startDir toward stopDir looking for any of the marker files.
 * If excludeMarkers are provided and found first, returns null (server should not activate).
 * Returns the directory containing the first include match, or stopDir if none found.
 * Ported from OpenCode's NearestRoot pattern (include + exclude).
 *
 * @param {string[]} markers - File names to look for (e.g., ['package.json', 'tsconfig.json'])
 * @param {string} startDir - Directory to start searching from
 * @param {string} stopDir - Directory to stop searching at (project cwd)
 * @param {string[]} [excludeMarkers] - If any of these are found, return null (skip this server)
 * @returns {Promise<string|null>} The directory containing the marker, stopDir, or null if excluded
 */
async function nearestRoot(markers, startDir, stopDir, excludeMarkers) {
  const resolved = path.resolve(stopDir);

  // Phase 1: check exclude markers — if found, this server should not activate
  if (excludeMarkers && excludeMarkers.length > 0) {
    let dir = path.resolve(startDir);
    while (true) {
      for (const marker of excludeMarkers) {
        try {
          fs.statSync(path.join(dir, marker));
          return null; // excluded
        } catch { /* not found */ }
      }
      const parent = path.dirname(dir);
      if (dir === parent) break;
      if (!dir.startsWith(resolved)) break;
      dir = parent;
    }
  }

  // Phase 2: walk up looking for include markers
  let dir = path.resolve(startDir);
  while (true) {
    for (const marker of markers) {
      const candidate = path.join(dir, marker);
      try {
        fs.statSync(candidate);
        return dir;
      } catch { /* not found, keep walking */ }
    }
    const parent = path.dirname(dir);
    if (dir === parent) return resolved;
    if (!dir.startsWith(resolved)) return resolved;
    dir = parent;
  }
}

/**
 * Check if a binary is on PATH.
 * Also checks local node_modules/.bin in the given cwd (if provided),
 * and tries npx resolution as a last resort.
 * Returns the path or null.
 * @param {string} name - Binary name
 * @param {string} [cwd] - Optional cwd to check node_modules/.bin
 * @returns {string|null}
 */
function which(name, cwd) {
  // 1. Check local node_modules/.bin if cwd given
  if (cwd) {
    const localBin = path.join(cwd, 'node_modules', '.bin', name);
    try {
      fs.statSync(localBin);
      return localBin;
    } catch { /* not there */ }
  }

  // 2. Check PATH
  try {
    const cmd = process.platform === 'win32' ? 'where' : 'which';
    const result = execFileSync(cmd, [name], { encoding: 'utf8', timeout: 5000 }).trim().split('\n')[0];
    if (result) return result;
  } catch { /* not found */ }

  // 3. Walk up from cwd checking node_modules/.bin in parent dirs
  if (cwd) {
    let dir = path.resolve(cwd);
    const root = path.parse(dir).root;
    while (dir !== root) {
      const candidate = path.join(dir, 'node_modules', '.bin', name);
      try {
        fs.statSync(candidate);
        return candidate;
      } catch { /* keep walking */ }
      dir = path.dirname(dir);
    }
  }

  return null;
}

// ─── Shared root-marker groups (OpenCode convention) ─────────────────────────
const JS_LOCK_FILES = ['package-lock.json', 'bun.lockb', 'bun.lock', 'pnpm-lock.yaml', 'yarn.lock'];
const DENO_MARKERS = ['deno.json', 'deno.jsonc'];

/**
 * Server definitions.
 * Each server has:
 * - id: unique identifier
 * - extensions: file extensions this server handles
 * - command: [binary, ...args] to spawn (stdio transport)
 * - rootMarkers: files that indicate the project root for this language
 * - excludeMarkers: (optional) files that, if found, mean this server should NOT activate
 * - initialization: optional init options to send after initialize
 * - env: optional environment variables for the spawned process
 * - checkBinary: returns binary path or null (for graceful fallback)
 *
 * Servers ported from OpenCode (https://github.com/anomalyco/opencode)
 * packages/opencode/src/lsp/server.ts
 */
const SERVERS = {

  // ─── JavaScript / TypeScript ecosystem ──────────────────────────────────────

  typescript: {
    id: 'typescript',
    extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'],
    command: ['typescript-language-server', '--stdio'],
    rootMarkers: ['package.json', 'tsconfig.json', 'jsconfig.json', ...JS_LOCK_FILES],
    excludeMarkers: DENO_MARKERS,
    initialization: {},
    checkBinary() { return which('typescript-language-server'); },
  },

  deno: {
    // OpenCode: Deno — only activates when deno.json(c) is present
    id: 'deno',
    extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs'],
    command: ['deno', 'lsp'],
    rootMarkers: DENO_MARKERS,
    // No excludeMarkers — Deno only matches if deno.json exists (nearestRoot returns stopDir otherwise,
    // but the custom root function in OpenCode returns undefined). We rely on rootMarkers presence.
    initialization: {
      enable: true,
      lint: true,
      unstable: [],
    },
    checkBinary() { return which('deno'); },
  },

  vue: {
    // OpenCode: Vue — @vue/language-server or vue-language-server
    id: 'vue',
    extensions: ['.vue'],
    command: ['vue-language-server', '--stdio'],
    rootMarkers: ['package.json', ...JS_LOCK_FILES],
    initialization: {},
    checkBinary() { return which('vue-language-server'); },
  },

  eslint: {
    // OpenCode: ESLint — requires eslint in node_modules + vscode-eslint server built locally
    // Simplified: just detect vscode-eslint-language-server or eslint_d
    id: 'eslint',
    extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '.vue'],
    command: ['vscode-eslint-language-server', '--stdio'],
    rootMarkers: ['.eslintrc', '.eslintrc.js', '.eslintrc.json', '.eslintrc.yml', 'eslint.config.js',
                   'eslint.config.mjs', 'eslint.config.ts', 'package.json', ...JS_LOCK_FILES],
    initialization: {},
    checkBinary() { return which('vscode-eslint-language-server'); },
  },

  biome: {
    // OpenCode: Biome — @biomejs/biome lsp-proxy
    id: 'biome',
    extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts',
                 '.json', '.jsonc', '.vue', '.astro', '.svelte', '.css', '.graphql', '.gql', '.html'],
    command: ['biome', 'lsp-proxy'],
    rootMarkers: ['biome.json', 'biome.jsonc', ...JS_LOCK_FILES],
    initialization: {},
    checkBinary(cwd) { return which('biome', cwd); },
  },

  svelte: {
    // OpenCode: Svelte — svelte-language-server
    id: 'svelte',
    extensions: ['.svelte'],
    command: ['svelteserver', '--stdio'],
    rootMarkers: ['svelte.config.js', 'svelte.config.ts', 'package.json', ...JS_LOCK_FILES],
    initialization: {},
    checkBinary() { return which('svelteserver'); },
  },

  astro: {
    // OpenCode: Astro — @astrojs/language-server (requires TypeScript SDK)
    id: 'astro',
    extensions: ['.astro'],
    command: ['astro-ls', '--stdio'],
    rootMarkers: ['astro.config.mjs', 'astro.config.ts', 'astro.config.js', 'package.json', ...JS_LOCK_FILES],
    initialization: {},
    checkBinary() { return which('astro-ls'); },
  },

  prisma: {
    // OpenCode: Prisma — prisma language-server
    id: 'prisma',
    extensions: ['.prisma'],
    command: ['prisma-language-server', '--stdio'],
    rootMarkers: ['schema.prisma', 'prisma/schema.prisma', 'package.json'],
    initialization: {},
    checkBinary() { return which('prisma-language-server'); },
  },

  yaml: {
    // OpenCode: YAML — yaml-language-server
    id: 'yaml',
    extensions: ['.yaml', '.yml'],
    command: ['yaml-language-server', '--stdio'],
    rootMarkers: ['package.json', ...JS_LOCK_FILES, '.yaml-language-server.json'],
    initialization: {},
    checkBinary() { return which('yaml-language-server'); },
  },

  // ─── Python ─────────────────────────────────────────────────────────────────

  pyright: {
    id: 'pyright',
    extensions: ['.py', '.pyi'],
    command: ['pyright-langserver', '--stdio'],
    rootMarkers: ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile', 'pyrightconfig.json'],
    initialization: {},
    checkBinary() {
      return which('pyright-langserver') || which('basedpyright-langserver');
    },
  },

  // ─── Go ─────────────────────────────────────────────────────────────────────

  gopls: {
    id: 'gopls',
    extensions: ['.go'],
    command: ['gopls', 'serve'],
    rootMarkers: ['go.mod', 'go.sum', 'go.work'],
    initialization: {},
    checkBinary() { return which('gopls'); },
  },

  // ─── Rust ───────────────────────────────────────────────────────────────────

  'rust-analyzer': {
    id: 'rust-analyzer',
    extensions: ['.rs'],
    command: ['rust-analyzer'],
    rootMarkers: ['Cargo.toml', 'Cargo.lock'],
    initialization: {},
    checkBinary() { return which('rust-analyzer'); },
  },

  // ─── C / C++ ────────────────────────────────────────────────────────────────

  clangd: {
    // OpenCode: Clangd — --background-index --clang-tidy
    id: 'clangd',
    extensions: ['.c', '.cpp', '.cc', '.cxx', '.c++', '.h', '.hpp', '.hh', '.hxx', '.h++'],
    command: ['clangd', '--background-index', '--clang-tidy'],
    rootMarkers: ['compile_commands.json', 'compile_flags.txt', '.clangd', 'CMakeLists.txt', 'Makefile'],
    initialization: {},
    checkBinary() { return which('clangd'); },
  },

  // ─── Java ───────────────────────────────────────────────────────────────────

  jdtls: {
    // OpenCode: JDTLS — Eclipse JDT Language Server (requires Java 21+)
    // Note: spawning is complex (java -jar launcher.jar ...). checkBinary only checks for jdtls wrapper.
    id: 'jdtls',
    extensions: ['.java'],
    command: ['jdtls'],
    rootMarkers: ['pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts',
                   '.project', '.classpath', 'gradlew'],
    initialization: {},
    checkBinary() { return which('jdtls'); },
  },

  // ─── Kotlin ─────────────────────────────────────────────────────────────────

  'kotlin-ls': {
    // OpenCode: KotlinLS — kotlin-language-server / kotlin-lsp
    id: 'kotlin-ls',
    extensions: ['.kt', '.kts'],
    command: ['kotlin-language-server'],
    rootMarkers: ['settings.gradle', 'settings.gradle.kts', 'gradlew', 'gradlew.bat',
                   'build.gradle', 'build.gradle.kts', 'pom.xml'],
    initialization: {},
    checkBinary() { return which('kotlin-language-server') || which('kotlin-lsp'); },
  },

  // ─── Swift ──────────────────────────────────────────────────────────────────

  'sourcekit-lsp': {
    // OpenCode: SourceKit — comes with Swift toolchain or Xcode
    id: 'sourcekit-lsp',
    extensions: ['.swift'],
    command: ['sourcekit-lsp'],
    rootMarkers: ['Package.swift', '*.xcodeproj', '*.xcworkspace'],
    initialization: {},
    checkBinary() {
      const direct = which('sourcekit-lsp');
      if (direct) return direct;
      // macOS: try xcrun
      if (process.platform === 'darwin') {
        try {
          return execFileSync('xcrun', ['--find', 'sourcekit-lsp'],
            { encoding: 'utf8', timeout: 5000 }).trim();
        } catch { /* not found */ }
      }
      return null;
    },
  },

  // ─── PHP ────────────────────────────────────────────────────────────────────

  intelephense: {
    // OpenCode: PHPIntelephense
    id: 'intelephense',
    extensions: ['.php'],
    command: ['intelephense', '--stdio'],
    rootMarkers: ['composer.json', 'composer.lock', '.php-version'],
    initialization: {
      telemetry: { enabled: false },
    },
    checkBinary() { return which('intelephense'); },
  },

  // ─── Ruby ───────────────────────────────────────────────────────────────────

  rubocop: {
    // OpenCode: Rubocop --lsp
    id: 'rubocop',
    extensions: ['.rb', '.rake', '.gemspec', '.ru'],
    command: ['rubocop', '--lsp'],
    rootMarkers: ['Gemfile', 'Gemfile.lock', '.rubocop.yml'],
    initialization: {},
    checkBinary() { return which('rubocop'); },
  },

  // ─── Elixir ─────────────────────────────────────────────────────────────────

  'elixir-ls': {
    // OpenCode: ElixirLS
    id: 'elixir-ls',
    extensions: ['.ex', '.exs'],
    command: ['elixir-ls'],
    rootMarkers: ['mix.exs', 'mix.lock'],
    initialization: {},
    checkBinary() {
      return which('elixir-ls') || which('language_server.sh');
    },
  },

  // ─── Zig ────────────────────────────────────────────────────────────────────

  zls: {
    // OpenCode: Zls
    id: 'zls',
    extensions: ['.zig', '.zon'],
    command: ['zls'],
    rootMarkers: ['build.zig', 'build.zig.zon'],
    initialization: {},
    checkBinary() { return which('zls'); },
  },

  // ─── C# ─────────────────────────────────────────────────────────────────────

  'csharp-ls': {
    // OpenCode: CSharp — csharp-ls (dotnet tool)
    id: 'csharp-ls',
    extensions: ['.cs'],
    command: ['csharp-ls'],
    rootMarkers: ['.slnx', '.sln', '.csproj', 'global.json'],
    initialization: {},
    checkBinary() { return which('csharp-ls'); },
  },

  // ─── Lua ────────────────────────────────────────────────────────────────────

  'lua-ls': {
    // OpenCode: LuaLS — lua-language-server
    id: 'lua-ls',
    extensions: ['.lua'],
    command: ['lua-language-server'],
    rootMarkers: ['.luarc.json', '.luarc.jsonc', '.luacheckrc', '.stylua.toml', 'stylua.toml',
                   'selene.toml', 'selene.yml'],
    initialization: {},
    checkBinary() { return which('lua-language-server'); },
  },

  // ─── Gleam ──────────────────────────────────────────────────────────────────

  gleam: {
    // OpenCode: Gleam — gleam lsp
    id: 'gleam',
    extensions: ['.gleam'],
    command: ['gleam', 'lsp'],
    rootMarkers: ['gleam.toml'],
    initialization: {},
    checkBinary() { return which('gleam'); },
  },

  // ─── Terraform ──────────────────────────────────────────────────────────────

  terraform: {
    // OpenCode: TerraformLS — terraform-ls serve
    id: 'terraform',
    extensions: ['.tf', '.tfvars'],
    command: ['terraform-ls', 'serve'],
    rootMarkers: ['.terraform.lock.hcl', 'terraform.tfstate', 'main.tf'],
    initialization: {
      experimentalFeatures: {
        prefillRequiredFields: true,
        validateOnSave: true,
      },
    },
    checkBinary() { return which('terraform-ls'); },
  },

  // ─── Additional servers from OpenCode ───────────────────────────────────────

  dart: {
    // OpenCode: Dart
    id: 'dart',
    extensions: ['.dart'],
    command: ['dart', 'language-server', '--lsp'],
    rootMarkers: ['pubspec.yaml', 'analysis_options.yaml'],
    initialization: {},
    checkBinary() { return which('dart'); },
  },

  'ocaml-lsp': {
    // OpenCode: Ocaml — ocamllsp
    id: 'ocaml-lsp',
    extensions: ['.ml', '.mli'],
    command: ['ocamllsp'],
    rootMarkers: ['dune-project', 'dune-workspace', '.merlin', 'opam'],
    initialization: {},
    checkBinary() { return which('ocamllsp'); },
  },

  bash: {
    // OpenCode: BashLS — bash-language-server
    id: 'bash',
    extensions: ['.sh', '.bash', '.zsh', '.ksh'],
    command: ['bash-language-server', 'start'],
    rootMarkers: ['.bashrc', '.zshrc', 'Makefile', 'package.json'],
    initialization: {},
    checkBinary() { return which('bash-language-server'); },
  },

  'clojure-lsp': {
    // OpenCode: Clojure
    id: 'clojure-lsp',
    extensions: ['.clj', '.cljs', '.cljc', '.edn'],
    command: ['clojure-lsp', 'listen'],
    rootMarkers: ['deps.edn', 'project.clj', 'shadow-cljs.edn', 'bb.edn', 'build.boot'],
    initialization: {},
    checkBinary() { return which('clojure-lsp'); },
  },

  haskell: {
    // OpenCode: HLS — haskell-language-server-wrapper
    id: 'haskell',
    extensions: ['.hs', '.lhs'],
    command: ['haskell-language-server-wrapper', '--lsp'],
    rootMarkers: ['stack.yaml', 'cabal.project', 'hie.yaml'],
    initialization: {},
    checkBinary() { return which('haskell-language-server-wrapper'); },
  },

  nixd: {
    // OpenCode: Nixd
    id: 'nixd',
    extensions: ['.nix'],
    command: ['nixd'],
    rootMarkers: ['flake.nix', 'default.nix', 'shell.nix'],
    initialization: {},
    checkBinary() { return which('nixd'); },
  },

  // ─── Docker ──────────────────────────────────────────────────────────────────

  dockerfile: {
    id: 'dockerfile',
    extensions: [], // Dockerfiles have no extension — matched by filePatterns instead
    filePatterns: ['Dockerfile', 'Dockerfile.*', 'dockerfile', 'dockerfile.*', '*.dockerfile'],
    command: ['docker-langserver', '--stdio'],
    rootMarkers: ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', '.dockerignore'],
    initialization: {},
    checkBinary() { return which('docker-langserver'); },
  },
};

// ── Auto-install support ──
// Ported from OpenCode (https://github.com/anomalyco/opencode)
// Source: packages/opencode/src/lsp/server.ts — auto-download/install pattern
// OpenCode auto-installs clangd, jdtls, kotlin-ls, lua-ls, zls, elixir-ls.
// Wall-E auto-installs npm-installable, go-installable, and downloadable servers.

const AUTO_INSTALL_TIMEOUT_MS = 60_000;

/**
 * Map of server IDs to their npm install commands.
 * Only servers where npm install -g is the standard installation method.
 */
const INSTALL_COMMANDS = {
  typescript: { cmd: ['npm', 'install', '-g', 'typescript-language-server', 'typescript'], name: 'typescript-language-server' },
  pyright:    { cmd: ['npm', 'install', '-g', 'pyright'], name: 'pyright' },
  astro:      { cmd: ['npm', 'install', '-g', '@astrojs/language-server'], name: '@astrojs/language-server' },
  bash:       { cmd: ['npm', 'install', '-g', 'bash-language-server'], name: 'bash-language-server' },
  svelte:     { cmd: ['npm', 'install', '-g', 'svelte-language-server'], name: 'svelte-language-server' },
  vue:        { cmd: ['npm', 'install', '-g', '@vue/language-server'], name: '@vue/language-server' },
  yaml:       { cmd: ['npm', 'install', '-g', 'yaml-language-server'], name: 'yaml-language-server' },
  dockerfile: { cmd: ['npm', 'install', '-g', 'dockerfile-language-server-nodejs'], name: 'dockerfile-language-server-nodejs' },
  // Note: Prisma LSP comes bundled with the prisma npm package, not as a separate global install.
  // Users install prisma locally; the LSP is launched via npx or the extension. No global install needed.
  eslint:     { cmd: ['npm', 'install', '-g', 'vscode-langservers-extracted'], name: 'vscode-langservers-extracted' },
  gopls:      { cmd: ['go', 'install', 'golang.org/x/tools/gopls@latest'], name: 'gopls', checkPrereq() { return !!which('go'); } },
};

/**
 * Auto-install a language server binary if it's not found on PATH.
 * Runs `npm install -g` with a timeout. Only attempts install for servers
 * that have a defined installCommand.
 *
 * Ported from OpenCode's auto-download pattern in packages/opencode/src/lsp/server.ts.
 *
 * @param {string} serverId - Server ID (e.g., 'typescript', 'pyright')
 * @returns {Promise<string|null>} Path to installed binary, or null if install failed
 */
async function autoInstall(serverId) {
  const spec = INSTALL_COMMANDS[serverId];
  if (!spec) return null;

  // Some servers require a prerequisite binary (e.g., gopls needs go)
  if (spec.checkPrereq && !spec.checkPrereq()) {
    console.log(`[lsp-servers] Skipping auto-install of ${spec.name} -- prerequisite not found`);
    return null;
  }

  console.log(`[lsp-servers] Auto-installing ${spec.name} (${spec.cmd.join(' ')})...`);

  // I6: Use NpmService for npm-based installs (validates package names)
  if (spec.cmd[0] === 'npm' && spec.cmd[1] === 'install') {
    try {
      const { NpmService } = require('./npm-service');
      const npm = new NpmService({ installTimeoutMs: AUTO_INSTALL_TIMEOUT_MS });
      // Extract package names (skip 'npm', 'install', flags like '-g')
      const pkgs = spec.cmd.slice(2).filter(a => !a.startsWith('-'));
      for (const pkg of pkgs) {
        const result = await npm.install(pkg, process.cwd(), { global: true, timeoutMs: AUTO_INSTALL_TIMEOUT_MS });
        if (!result.success) {
          console.error(`[lsp-servers] Failed to install ${pkg}: ${result.stderr}`);
          return null;
        }
      }
      console.log(`[lsp-servers] Successfully installed ${spec.name}`);
      const server = SERVERS[serverId];
      if (server && server.checkBinary) {
        const bin = server.checkBinary();
        if (bin) {
          console.log(`[lsp-servers] Verified ${spec.name} binary at: ${bin}`);
          return bin;
        }
      }
      return null;
    } catch (err) {
      console.error(`[lsp-servers] Failed to auto-install ${spec.name}: ${err.message}`);
      return null;
    }
  }

  // Non-npm installs (e.g., go install) — use execFileSync directly
  const { execFileSync } = require('node:child_process');
  try {
    execFileSync(spec.cmd[0], spec.cmd.slice(1), {
      encoding: 'utf8',
      timeout: AUTO_INSTALL_TIMEOUT_MS,
      stdio: 'pipe',
    });
    console.log(`[lsp-servers] Successfully installed ${spec.name}`);

    // Verify the binary is now available
    const server = SERVERS[serverId];
    if (server && server.checkBinary) {
      const bin = server.checkBinary();
      if (bin) {
        console.log(`[lsp-servers] Verified ${spec.name} binary at: ${bin}`);
        return bin;
      }
    }
    return null;
  } catch (err) {
    console.error(`[lsp-servers] Failed to auto-install ${spec.name}: ${err.message}`);
    return null;
  }
}

// ── Binary download support for non-npm servers ──
// Ported from OpenCode (https://github.com/anomalyco/opencode)
// Source: packages/opencode/src/lsp/server.ts — auto-download pattern for clangd, lua-ls, zls

const https = require('node:https');
const os = require('node:os');

const DEFAULT_LSP_DIR = path.join(os.homedir(), '.walle', 'lsp-servers');

/**
 * Map platform/arch from Node conventions to release asset naming.
 */
function detectPlatformArch() {
  const p = process.platform;
  const a = process.arch;
  const platform = p === 'darwin' ? 'macos' : p === 'win32' ? 'windows' : 'linux';
  const arch = a === 'arm64' ? 'aarch64' : a === 'x64' ? 'x86_64' : a;
  return { platform, arch };
}

/**
 * Download a URL to a file, following redirects (GitHub releases redirect).
 * Uses Node built-in https module — no new dependencies.
 * @param {string} url
 * @param {string} dest
 * @returns {Promise<void>}
 */
function httpsDownload(url, dest, maxRedirects = 5) {
  return new Promise((resolve, reject) => {
    if (maxRedirects <= 0) return reject(new Error('Too many redirects'));
    const mod = url.startsWith('https') ? https : require('node:http');
    mod.get(url, { headers: { 'User-Agent': 'wall-e-lsp' } }, (res) => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        res.resume();
        return httpsDownload(res.headers.location, dest, maxRedirects - 1).then(resolve, reject);
      }
      if (res.statusCode !== 200) {
        res.resume();
        return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
      }
      const file = fs.createWriteStream(dest);
      res.pipe(file);
      file.on('finish', () => file.close(resolve));
      file.on('error', reject);
    }).on('error', reject);
  });
}

/**
 * Fetch JSON from a URL (for GitHub API).
 * @param {string} url
 * @returns {Promise<any>}
 */
function httpsGetJson(url, maxRedirects = 5) {
  return new Promise((resolve, reject) => {
    if (maxRedirects <= 0) return reject(new Error('Too many redirects'));
    https.get(url, { headers: { 'User-Agent': 'wall-e-lsp', 'Accept': 'application/json' } }, (res) => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        res.resume();
        return httpsGetJson(res.headers.location, maxRedirects - 1).then(resolve, reject);
      }
      if (res.statusCode !== 200) {
        res.resume();
        return reject(new Error(`HTTP ${res.statusCode}`));
      }
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
    }).on('error', reject);
  });
}

/**
 * Download configs for non-npm language servers.
 * Each config defines how to construct the asset name and extract the binary.
 *
 * Ported from OpenCode (https://github.com/anomalyco/opencode)
 * Source: packages/opencode/src/lsp/server.ts
 */
const DOWNLOAD_CONFIGS = {
  clangd: {
    repo: 'clangd/clangd',
    assetName: ({ platform }) => {
      // clangd assets: clangd-{platform}-{tag}.zip  (platform token differs from Node)
      const tokens = { macos: 'mac', linux: 'linux', windows: 'windows' };
      return tokens[platform] || null; // partial match — we search assets
    },
    archiveType: 'zip',
    binaryPath: (extractDir) => {
      // clangd extracts to clangd_<tag>/bin/clangd
      const entries = fs.readdirSync(extractDir).filter(e => e.startsWith('clangd_'));
      if (entries.length === 0) return null;
      return path.join(extractDir, entries[0], 'bin', 'clangd');
    },
  },

  'lua-ls': {
    repo: 'LuaLS/lua-language-server',
    assetName: ({ platform, arch, tag }) => {
      // lua-language-server-{tag}-{platform}-{arch}.tar.gz
      const p = platform === 'macos' ? 'darwin' : platform;
      const a = arch === 'aarch64' ? 'arm64' : (arch === 'x86_64' ? 'x64' : arch);
      return `lua-language-server-${tag}-${p}-${a}.tar.gz`;
    },
    archiveType: 'tar.gz',
    binaryPath: (extractDir) => path.join(extractDir, 'bin', 'lua-language-server'),
  },

  zls: {
    repo: 'zigtools/zls',
    assetName: ({ platform, arch }) => {
      const p = platform === 'macos' ? 'macos' : platform;
      const ext = platform === 'windows' ? 'zip' : 'tar.xz';
      return `zls-${arch}-${p}.${ext}`;
    },
    archiveType: ({ platform }) => platform === 'windows' ? 'zip' : 'tar.xz',
    binaryPath: (extractDir) => path.join(extractDir, 'zls'),
  },

  terraform: {
    repo: 'hashicorp/terraform-ls',
    // terraform-ls assets: terraform-ls_{version}_{os}_{arch}.zip
    // Uses resolveAsset because the asset name includes the version from the tag
    assetName: () => null, // resolved via resolveAsset
    resolveAsset: (release, { platform, arch }) => {
      const p = platform === 'macos' ? 'darwin' : platform;
      const a = arch === 'aarch64' ? 'arm64' : (arch === 'x86_64' ? 'amd64' : arch);
      const suffix = `_${p}_${a}.zip`;
      return release.assets.find(asset => asset.name && asset.name.endsWith(suffix));
    },
    archiveType: 'zip',
    binaryPath: (extractDir) => path.join(extractDir, 'terraform-ls' + (process.platform === 'win32' ? '.exe' : '')),
  },

  'elixir-ls': {
    repo: 'elixir-lsp/elixir-ls',
    assetName: () => 'elixir-ls.zip', // single platform-independent zip (Erlang/BEAM)
    archiveType: 'zip',
    binaryPath: (extractDir) => {
      // elixir-ls extracts language_server.sh (Unix) or language_server.bat (Windows)
      const sh = path.join(extractDir, 'language_server.sh');
      const bat = path.join(extractDir, 'language_server.bat');
      if (process.platform === 'win32') return fs.existsSync(bat) ? bat : null;
      return fs.existsSync(sh) ? sh : null;
    },
  },
};

/**
 * Download and extract a language server binary from GitHub releases.
 *
 * @param {string} serverId - Key in DOWNLOAD_CONFIGS (e.g., 'clangd', 'lua-ls', 'zls')
 * @param {string} [targetDir] - Base dir for installs (default: ~/.walle/lsp-servers/)
 * @returns {Promise<string|null>} Absolute path to the binary, or null on failure
 */
async function downloadServer(serverId, targetDir) {
  const config = DOWNLOAD_CONFIGS[serverId];
  if (!config) return null;

  const baseDir = targetDir || DEFAULT_LSP_DIR;
  const installDir = path.join(baseDir, serverId);
  const { platform, arch } = detectPlatformArch();

  console.log(`[lsp-servers] Downloading ${serverId} for ${platform}/${arch}...`);

  try {
    // Fetch latest release info from GitHub
    const release = await httpsGetJson(`https://api.github.com/repos/${config.repo}/releases/latest`);
    const tag = release.tag_name;
    if (!tag || !release.assets) throw new Error('Invalid release data');

    // Determine expected asset name
    const assetName = config.assetName({ platform, arch, tag });
    if (!assetName && !config.resolveAsset) throw new Error(`Unsupported platform: ${platform}/${arch}`);

    // Find the matching asset (custom resolver, partial for clangd, or exact match)
    let asset;
    if (config.resolveAsset) {
      asset = config.resolveAsset(release, { platform, arch });
    } else if (serverId === 'clangd') {
      // clangd uses partial match: asset contains the platform token
      asset = release.assets.find(a => a.name && a.name.includes(assetName) && a.name.endsWith('.zip'));
    } else {
      asset = release.assets.find(a => a.name === assetName);
    }
    if (!asset || !asset.browser_download_url) {
      throw new Error(`Asset not found: ${assetName || serverId} in ${config.repo}@${tag}`);
    }

    // Prepare install directory
    fs.mkdirSync(installDir, { recursive: true });
    const archivePath = path.join(installDir, asset.name);

    // Download
    console.log(`[lsp-servers] Fetching ${asset.browser_download_url}`);
    await httpsDownload(asset.browser_download_url, archivePath);

    // Determine archive type
    const archiveType = typeof config.archiveType === 'function'
      ? config.archiveType({ platform })
      : config.archiveType;

    // Extract using child_process.execFileSync (safe — no shell injection)
    if (archiveType === 'zip') {
      execFileSync('unzip', ['-o', '-q', archivePath, '-d', installDir], { timeout: 60_000, stdio: 'pipe' });
    } else if (archiveType === 'tar.gz') {
      execFileSync('tar', ['-xzf', archivePath, '-C', installDir], { timeout: 60_000, stdio: 'pipe' });
    } else if (archiveType === 'tar.xz') {
      execFileSync('tar', ['-xJf', archivePath, '-C', installDir], { timeout: 60_000, stdio: 'pipe' });
    } else {
      throw new Error(`Unknown archive type: ${archiveType}`);
    }

    // Clean up archive
    try { fs.unlinkSync(archivePath); } catch { /* ok */ }

    // Locate binary
    const binPath = config.binaryPath(installDir);
    if (!binPath || !fs.existsSync(binPath)) {
      throw new Error(`Binary not found after extraction at ${binPath}`);
    }

    // Make executable on Unix
    if (process.platform !== 'win32') {
      try { fs.chmodSync(binPath, 0o755); } catch { /* ok */ }
    }

    console.log(`[lsp-servers] Installed ${serverId} at: ${binPath}`);
    return binPath;
  } catch (err) {
    console.error(`[lsp-servers] Failed to download ${serverId}: ${err.message}`);
    return null;
  }
}

/**
 * Remove all downloaded LSP server binaries.
 * @param {string} [baseDir] - Base dir (default: ~/.walle/lsp-servers/)
 */
function cleanupServers(baseDir) {
  const dir = baseDir || DEFAULT_LSP_DIR;
  if (!fs.existsSync(dir)) return;
  const entries = fs.readdirSync(dir);
  for (const entry of entries) {
    const full = path.join(dir, entry);
    try {
      fs.rmSync(full, { recursive: true, force: true });
      console.log(`[lsp-servers] Removed ${full}`);
    } catch (err) {
      console.error(`[lsp-servers] Failed to remove ${full}: ${err.message}`);
    }
  }
}

module.exports = {
  SERVERS, nearestRoot, which, autoInstall, INSTALL_COMMANDS,
  DOWNLOAD_CONFIGS, downloadServer, cleanupServers, DEFAULT_LSP_DIR,
};
