const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { detectAll, getAdapter, listAdapters } = require('./lsp-adapters');
const UI = require('./ui');

/**
 * The `claude` CLI is built on Ink (React for TUIs) and writes stdout in a
 * way that truncates at ~8 KB when piped directly to a node child process —
 * regardless of `maxBuffer` setting. Workaround: shell-redirect to a temp
 * file and read the file back. Used only for read-only `--json` queries.
 *
 * Returns the file contents as a string, or throws (caller catches).
 */
function captureClaudeJson(args, { timeout = 10000 } = {}) {
  const tmp = path.join(os.tmpdir(), `baldart-claude-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
  try {
    execSync(`claude ${args} > ${JSON.stringify(tmp)} 2>/dev/null`, {
      stdio: ['ignore', 'ignore', 'ignore'],
      timeout,
      shell: '/bin/bash',
    });
    return fs.readFileSync(tmp, 'utf8');
  } finally {
    try { fs.unlinkSync(tmp); } catch (_) { /* best-effort cleanup */ }
  }
}

/**
 * Anthropic's official marketplace for native Claude Code LSP plugins
 * (typescript-lsp, pyright-lsp, gopls-lsp, rust-analyzer-lsp, ruby-lsp, …).
 * Every adapter's `claudePluginId()` returns an id namespaced under this
 * marketplace, so the installer hard-codes it here. If a future adapter needs
 * a different marketplace, lift this into a per-adapter field.
 */
const OFFICIAL_MARKETPLACE_NAME = 'claude-plugins-official';
const OFFICIAL_MARKETPLACE_SOURCE = 'anthropics/claude-plugins-official';

/**
 * High-level LSP installer used by `baldart configure` and the `lsp-bootstrap`
 * skill / doctor step. Owns the install + verify side effects so the command
 * layers stay thin.
 *
 * Contract:
 *   - Pure detection (no install) when only `detectLanguages` is called.
 *   - All mutating ops respect `--non-interactive` via the `interactive` flag.
 *   - Returns plain objects (no throws) so the caller can render results.
 *   - Claude plugin install ops gate on the `claude` CLI being on PATH AND on
 *     `claude` being present in `tools.enabled`; when either fails the methods
 *     report `{ skipped: [...], reason }` so the caller can render a hint
 *     instead of erroring.
 */
class LspInstaller {
  constructor(cwd = process.cwd()) { this.cwd = cwd; }

  /** Languages whose marker files exist under cwd. */
  detectLanguages() { return detectAll(this.cwd); }

  /** Adapters that BALDART knows about (regardless of detection). */
  knownAdapters() { return listAdapters(); }

  /**
   * Build a recommendation list from detected languages. Each entry carries
   * the install command and the install mode so the UI can decide whether
   * to run it automatically (npm-global) or just print it (system).
   */
  recommend(langs = this.detectLanguages()) {
    return langs.map(name => {
      const a = getAdapter(name, this.cwd);
      return {
        name: a.name,
        label: a.label,
        binary: a.binary,
        installMode: a.installMode,
        installCommand: a.installCommand(),
        verifyCommand: a.verifyCommand(),
        claudePluginId: a.claudePluginId()
      };
    });
  }

  /**
   * Install one or more language servers. Returns `{ installed, skipped, manual }`.
   *  - npm-global adapters: executed in-process (npm install -g …) so the
   *    binary lands on `$PATH` where the consumer can spawn it.
   *  - system adapters: never executed automatically; surfaced under `manual`
   *    so the caller can print the command for the user to run.
   *
   * `opts.interactive`: when true, asks confirmation per server (default true).
   * `opts.dryRun`:      when true, no commands are executed.
   */
  async installServers(serverNames, opts = {}) {
    const { interactive = true, dryRun = false } = opts;
    const installed = [];
    const skipped = [];
    const manual = [];

    for (const name of serverNames) {
      let a;
      try { a = getAdapter(name, this.cwd); }
      catch (err) { skipped.push({ name, reason: err.message }); continue; }

      if (a.installMode === 'system') {
        manual.push({
          name: a.name,
          label: a.label,
          command: a.installCommand()
        });
        continue;
      }

      if (interactive) {
        const ok = await UI.confirm(`Install ${a.label} via \`${a.installCommand()}\`?`, true);
        if (!ok) { skipped.push({ name, reason: 'user declined' }); continue; }
      }

      if (dryRun) { installed.push({ name, dryRun: true }); continue; }

      const spinner = UI.spinner(`Installing ${a.label}…`).start();
      try {
        execSync(a.installCommand(), { cwd: this.cwd, stdio: 'pipe' });
        spinner.succeed(`Installed ${a.label}`);
        installed.push({ name });
      } catch (err) {
        spinner.fail(`Failed to install ${a.label}`);
        skipped.push({ name, reason: err.message.split('\n')[0] });
      }
    }

    return { installed, skipped, manual };
  }

  // ──────────────────────────────────────────────────────────────────────
  // Native Claude Code companion plugins (since v3.14.0)
  //
  // For each language whose adapter declares a `claudePluginId()`, this layer
  // ensures the matching plugin from the `claude-plugins-official` marketplace
  // is installed. Without it, enabling `features.has_lsp_layer: true` only
  // installs the language-server binary — Claude Code itself never sees it,
  // and agents fall back to Grep silently. Wiring the two together is the
  // whole point of opting into LSP, so the installer treats this as part of
  // the install, not an optional extra.
  // ──────────────────────────────────────────────────────────────────────

  /** True iff the `claude` CLI is on PATH and answers `--version`. */
  isClaudeCliAvailable() {
    try {
      execSync('claude --version', { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
      return true;
    } catch { return false; }
  }

  /**
   * Idempotently register the official marketplace. Reads the existing list
   * first so we never spam `claude plugin marketplace add` on every configure
   * run. Returns `{ ok, alreadyPresent, error? }`. Never throws.
   */
  ensureClaudeMarketplace() {
    if (!this.isClaudeCliAvailable()) {
      return { ok: false, error: 'claude CLI not on PATH' };
    }
    try {
      const out = captureClaudeJson('plugin marketplace list --json', { timeout: 8000 });
      const parsed = JSON.parse(out);
      // Tolerate both shapes: object map keyed by name, or array of { name, … }.
      const names = Array.isArray(parsed)
        ? parsed.map(m => m && m.name).filter(Boolean)
        : Object.keys(parsed || {});
      if (names.includes(OFFICIAL_MARKETPLACE_NAME)) {
        return { ok: true, alreadyPresent: true };
      }
    } catch (_) {
      // Fall through to the add — if the add itself fails, we report that.
    }
    try {
      execSync(`claude plugin marketplace add ${OFFICIAL_MARKETPLACE_SOURCE} --scope user`, {
        stdio: ['ignore', 'pipe', 'pipe'],
        timeout: 60000 // first add clones the marketplace repo; allow time
      });
      return { ok: true, alreadyPresent: false };
    } catch (err) {
      return { ok: false, error: (err.message || '').split('\n')[0] };
    }
  }

  /**
   * Return the set of plugin ids (e.g. `typescript-lsp@claude-plugins-official`)
   * currently installed at ANY scope. Used to skip already-installed plugins.
   * Returns `Set<string>`; empty set on any failure (degrade open — we'd rather
   * issue a redundant install than block on a parser hiccup).
   */
  listInstalledClaudePlugins() {
    const ids = new Set();
    try {
      const out = captureClaudeJson('plugin list --json', { timeout: 8000 });
      const parsed = JSON.parse(out);
      const entries = Array.isArray(parsed) ? parsed : Object.values(parsed || {});
      for (const entry of entries) {
        // Be tolerant of shape drift: pull the id from `name`, `id`, or
        // the object's own key.
        const id = (entry && (entry.id || entry.name)) || null;
        if (id) ids.add(id);
      }
      // Also handle the `{ "<id>@<marketplace>": [...] }` map shape that the
      // on-disk `installed_plugins.json` uses, in case `list --json` mirrors it.
      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
        for (const key of Object.keys(parsed)) {
          if (key.includes('@')) ids.add(key);
        }
      }
    } catch (_) { /* swallow — empty set means "install, don't skip" */ }
    return ids;
  }

  /**
   * Install the native Claude Code companion plugins for the given language
   * server names. Idempotent: skips plugins already installed, skips adapters
   * whose `claudePluginId()` is null.
   *
   * Gating:
   *   - `opts.toolsEnabled` (array, default `['claude']`): when `claude` is
   *     not in the array, the whole method is a silent no-op (Codex-only users
   *     don't want Claude marketplace mutation).
   *   - When the `claude` CLI is missing, returns `{ skipped, reason }` so the
   *     caller can render a manual-install hint.
   *
   * Returns `{ installed: [...], skipped: [...], manual: [...], marketplace }`.
   * Never throws.
   */
  async installClaudePlugins(serverNames, opts = {}) {
    const {
      toolsEnabled = ['claude'],
      scope = 'user', // LSP plugins are machine-wide useful — avoid N project copies
      interactive = false
    } = opts;

    if (!Array.isArray(toolsEnabled) || !toolsEnabled.includes('claude')) {
      return { skipped: [], installed: [], manual: [], reason: 'claude not in tools.enabled' };
    }
    if (!this.isClaudeCliAvailable()) {
      return {
        skipped: serverNames.map(n => ({ name: n, reason: 'claude CLI not on PATH' })),
        installed: [], manual: [],
        reason: 'claude CLI not on PATH'
      };
    }

    // Resolve <serverName> → <claudePluginId>, dropping adapters without one.
    const targets = [];
    for (const name of serverNames) {
      try {
        const a = getAdapter(name, this.cwd);
        const pid = a.claudePluginId();
        if (pid) targets.push({ name, label: a.label, pluginId: pid });
      } catch { /* unknown adapter — ignore */ }
    }
    if (!targets.length) {
      return { installed: [], skipped: [], manual: [], reason: 'no adapters declare a Claude plugin id' };
    }

    // Idempotency 1: marketplace add (no-op if already registered).
    const mkt = this.ensureClaudeMarketplace();
    if (!mkt.ok) {
      return {
        installed: [],
        skipped: targets.map(t => ({ name: t.name, reason: `marketplace not registered (${mkt.error || 'unknown'})` })),
        manual: targets.map(t => ({
          name: t.name,
          label: t.label,
          command: `claude plugin marketplace add ${OFFICIAL_MARKETPLACE_SOURCE} && claude plugin install ${t.pluginId} --scope ${scope}`
        })),
        marketplace: mkt
      };
    }

    // Idempotency 2: skip plugins already installed at any scope.
    const already = this.listInstalledClaudePlugins();

    const installed = [];
    const skipped = [];
    const manual = [];

    for (const t of targets) {
      if (already.has(t.pluginId)) {
        skipped.push({ name: t.name, reason: 'plugin already installed' });
        continue;
      }

      if (interactive) {
        const ok = await UI.confirm(
          `Install native Claude Code plugin \`${t.pluginId}\` (--scope ${scope})?`,
          true
        );
        if (!ok) { skipped.push({ name: t.name, reason: 'user declined' }); continue; }
      }

      const spinner = UI.spinner(`Installing ${t.pluginId}…`).start();
      try {
        execSync(`claude plugin install ${t.pluginId} --scope ${scope}`, {
          stdio: ['ignore', 'pipe', 'pipe'],
          timeout: 60000
        });
        spinner.succeed(`Installed ${t.pluginId}`);
        installed.push({ name: t.name, pluginId: t.pluginId });
      } catch (err) {
        spinner.fail(`Failed to install ${t.pluginId}`);
        manual.push({
          name: t.name,
          label: t.label,
          command: `claude plugin install ${t.pluginId} --scope ${scope}`
        });
        skipped.push({ name: t.name, reason: (err.message || '').split('\n')[0] });
      }
    }

    return { installed, skipped, manual, marketplace: mkt };
  }

  /**
   * For each declared server, report whether the matching Claude plugin is
   * installed. Returns `[{ name, pluginId, installed }]`. Adapters without a
   * `claudePluginId()` are reported as `{ pluginId: null, installed: null }`
   * so callers can filter them out.
   */
  verifyClaudePlugins(serverNames) {
    const already = this.listInstalledClaudePlugins();
    return serverNames.map(name => {
      let a;
      try { a = getAdapter(name, this.cwd); }
      catch { return { name, pluginId: null, installed: null }; }
      const pid = a.claudePluginId();
      if (!pid) return { name, pluginId: null, installed: null };
      return { name, pluginId: pid, installed: already.has(pid) };
    });
  }

  /**
   * Is `binary` resolvable on `$PATH`? This mirrors EXACTLY how the consumer
   * (Claude Code's LSP tool) spawns the language server — by name, from
   * `$PATH`. Using `npx --no-install` here instead would resolve from
   * `node_modules/.bin/` and produce a false positive: the verify passes while
   * every real LSP spawn fails with `Executable not found in $PATH`. That
   * resolution mismatch was the root cause of the pre-v3.33.1 dead-layer bug.
   */
  _isOnPath(binary) {
    try {
      execSync(`command -v ${binary}`, {
        cwd: this.cwd,
        stdio: ['ignore', 'pipe', 'pipe'],
        timeout: 5000,
        shell: '/bin/sh'
      });
      return true;
    } catch { return false; }
  }

  /**
   * For npm-managed adapters: is the binary installed project-locally (in
   * `node_modules/.bin/`) even though it isn't on `$PATH`? This is the precise
   * fingerprint of the legacy `npm install --save-dev` install — present, but
   * unreachable by the consumer. System adapters have no local notion → false.
   */
  _isInstalledLocally(adapter) {
    if (adapter.installMode === 'system') return false;
    try {
      execSync(`npx --no-install ${adapter.binary} --version`, {
        cwd: this.cwd,
        stdio: ['ignore', 'pipe', 'pipe'],
        timeout: 8000
      });
      return true;
    } catch { return false; }
  }

  /**
   * Verify each named server is reachable the way the consumer spawns it.
   * Returns `[{ name, ok, status, output? , error?, fix? }]`. Never throws.
   *
   * `status` is one of:
   *   - `verified`                — on `$PATH` AND the functional probe passed.
   *   - `installed-but-unreachable` — installed locally (node_modules) but not
   *     on `$PATH`; the consumer cannot spawn it. `fix` carries the command
   *     that puts it on `$PATH`. Treated as broken (`ok:false`) — same honesty
   *     bar already applied to system adapters whose binary isn't installed.
   *   - `unreachable`             — on `$PATH` but the probe errored (corrupt
   *     install / wrong flag). Rare.
   *   - `not-installed`           — neither on `$PATH` nor locally installed.
   *   - `unknown-adapter`         — name not in the registry.
   */
  verifyServers(serverNames) {
    return serverNames.map(name => {
      let a;
      try { a = getAdapter(name, this.cwd); }
      catch (err) { return { name, ok: false, status: 'unknown-adapter', error: err.message }; }

      if (this._isOnPath(a.binary)) {
        try {
          const out = execSync(a.verifyCommand(), {
            cwd: this.cwd,
            stdio: ['ignore', 'pipe', 'pipe'],
            timeout: 8000
          }).toString().trim();
          return { name, ok: true, status: 'verified', output: out };
        } catch (err) {
          return {
            name, ok: false, status: 'unreachable',
            error: (err.message || '').split('\n')[0],
            fix: a.installCommand()
          };
        }
      }

      // Not on $PATH. Distinguish "installed locally but unreachable" (the
      // documented false-positive scenario) from "not installed at all".
      if (this._isInstalledLocally(a)) {
        return {
          name, ok: false, status: 'installed-but-unreachable',
          error: `${a.binary} is installed locally but not on $PATH; the Claude Code LSP tool spawns it by name from $PATH`,
          fix: a.installCommand()
        };
      }

      return {
        name, ok: false, status: 'not-installed',
        error: `${a.binary} not found on $PATH`,
        fix: a.installCommand()
      };
    });
  }
}

module.exports = LspInstaller;
