// wall-e/tools/lsp-client.js
// Ported from OpenCode (https://github.com/anomalyco/opencode)
// Source: packages/opencode/src/lsp/client.ts + packages/opencode/src/lsp/index.ts
// Manages LSP server lifecycle: lazy spawn, JSON-RPC communication, diagnostics.
'use strict';

const path = require('node:path');
const { spawn } = require('node:child_process');
const { pathToFileURL, fileURLToPath } = require('node:url');
const { SERVERS, nearestRoot, autoInstall, downloadServer } = require('./lsp-servers');
const { getLanguageId } = require('./lsp-language');

const STARTUP_TIMEOUT_MS = 45_000;
const DIAGNOSTICS_DEBOUNCE_MS = 150;
const DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;

/**
 * LSP client wrapper around a JSON-RPC connection to a single language server.
 * One client per (serverID, projectRoot) pair.
 */
class LSPClient {
  constructor(serverID, connection, serverProcess, root) {
    this.serverID = serverID;
    this.connection = connection;
    this.process = serverProcess;
    this.root = root;
    this._diagnostics = new Map();  // filePath -> Diagnostic[]
    this._files = {};               // filePath -> version number
    this._diagnosticListeners = []; // { path, resolve, timer }
  }

  /** Notify LSP that a file was opened or changed. */
  async touchFile(filePath) {
    const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(this.root, filePath);
    const uri = pathToFileURL(absPath).href;
    const ext = path.extname(absPath);
    const languageId = getLanguageId(ext);

    let text;
    try {
      text = require('node:fs').readFileSync(absPath, 'utf8');
    } catch {
      return; // file doesn't exist
    }

    const version = this._files[absPath];
    if (version !== undefined) {
      // File already opened -- send didChange
      const next = version + 1;
      this._files[absPath] = next;
      await this.connection.sendNotification('workspace/didChangeWatchedFiles', {
        changes: [{ uri, type: 2 /* Changed */ }],
      });
      await this.connection.sendNotification('textDocument/didChange', {
        textDocument: { uri, version: next },
        contentChanges: [{ text }],
      });
    } else {
      // First open
      this._diagnostics.delete(absPath);
      await this.connection.sendNotification('workspace/didChangeWatchedFiles', {
        changes: [{ uri, type: 1 /* Created */ }],
      });
      await this.connection.sendNotification('textDocument/didOpen', {
        textDocument: { uri, languageId, version: 0, text },
      });
      this._files[absPath] = 0;
    }
  }

  /** Wait for diagnostics to arrive for a file (with debounce). */
  async waitForDiagnostics(filePath) {
    const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(this.root, filePath);
    return new Promise((resolve) => {
      let debounceTimer;
      const entry = { path: absPath, resolve: null, timer: null };

      const done = () => {
        if (debounceTimer) clearTimeout(debounceTimer);
        const idx = this._diagnosticListeners.indexOf(entry);
        if (idx >= 0) this._diagnosticListeners.splice(idx, 1);
        resolve();
      };

      entry.resolve = (diagPath) => {
        if (diagPath !== absPath) return;
        if (debounceTimer) clearTimeout(debounceTimer);
        debounceTimer = setTimeout(done, DIAGNOSTICS_DEBOUNCE_MS);
      };

      // Timeout fallback
      entry.timer = setTimeout(done, DIAGNOSTICS_WAIT_TIMEOUT_MS);
      this._diagnosticListeners.push(entry);
    });
  }

  /** Called by the connection when publishDiagnostics arrives. */
  _onDiagnostics(filePath, diagnostics) {
    this._diagnostics.set(filePath, diagnostics);
    for (const listener of this._diagnosticListeners) {
      if (listener.resolve) listener.resolve(filePath);
    }
  }

  /** Get all accumulated diagnostics. */
  getDiagnostics() {
    return Object.fromEntries(this._diagnostics);
  }

  /** Get diagnostics for a specific file. */
  getFileDiagnostics(filePath) {
    const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(this.root, filePath);
    return this._diagnostics.get(absPath) || [];
  }
}

/**
 * LSPManager -- manages all LSP server/client instances for a coding task.
 *
 * Lifecycle:
 * - Servers are spawned lazily on first file access
 * - Broken servers (binary not found, init timeout) are tracked and not retried
 * - All servers are killed on shutdown() (when coding task ends)
 */
class LSPManager {
  /**
   * @param {object} opts
   * @param {string} opts.cwd - Project working directory
   */
  constructor(opts = {}) {
    this.cwd = opts.cwd || process.cwd();
    this._clients = new Map();   // key: "serverID:root" -> LSPClient
    this._broken = new Set();    // key: "serverID:root"
    this._spawning = new Map();  // key: "serverID:root" -> Promise<LSPClient|null>
  }

  /**
   * Find the server definition that handles a given file extension.
   * @param {string} ext - File extension (e.g., '.ts')
   * @returns {object|null} Server definition or null
   */
  _findServer(ext) {
    for (const server of Object.values(SERVERS)) {
      if (server.extensions.includes(ext)) return server;
    }
    return null;
  }

  /**
   * Find ALL server definitions that handle a given file extension.
   * Ported from OpenCode (packages/opencode/src/lsp/index.ts) — OpenCode runs requests
   * on ALL matching clients and merges results, not just the first match.
   * @param {string} ext - File extension (e.g., '.ts')
   * @returns {object[]} All matching server definitions
   */
  _findAllServers(extOrFilename) {
    return Object.values(SERVERS).filter(s => {
      if (s.extensions.includes(extOrFilename)) return true;
      // Also check filePatterns for servers with no extension (e.g., Dockerfile)
      if (s.filePatterns && extOrFilename) {
        const basename = path.basename(extOrFilename);
        return s.filePatterns.some(pattern => {
          if (pattern.includes('*')) {
            // Simple glob: 'Dockerfile.*' matches 'Dockerfile.dev'
            // '*.dockerfile' matches 'my.dockerfile'
            const prefix = pattern.split('*')[0];
            const suffix = pattern.split('*')[1] || '';
            if (prefix && suffix) return basename.startsWith(prefix) && basename.endsWith(suffix);
            if (prefix) return basename.startsWith(prefix);
            if (suffix) return basename.endsWith(suffix);
            return false; // bare '*' should not match everything
          }
          return basename === pattern;
        });
      }
      return false;
    });
  }

  /**
   * Check if any LSP server supports this file type and can be spawned.
   * Ported from OpenCode LSP.hasClients (packages/opencode/src/lsp/index.ts line 341-354).
   *
   * @param {string} filePath - Absolute path to a file
   * @returns {Promise<boolean>}
   */
  async hasClients(filePath) {
    const ext = path.extname(filePath) || path.basename(filePath);
    const servers = this._findAllServers(ext);
    if (servers.length === 0) return false;

    for (const server of servers) {
      const root = await nearestRoot(server.rootMarkers, path.dirname(filePath), this.cwd);
      if (!root) continue;
      const key = `${server.id}:${root}`;
      if (this._broken.has(key)) continue;
      // Either already connected or could be spawned
      return true;
    }
    return false;
  }

  /**
   * Ensure connected clients for ALL matching servers for a file.
   * Ported from OpenCode LSP.getClients — runs on ALL matching clients, not just first.
   * @param {string} filePath - Absolute path
   * @returns {Promise<LSPClient[]>}
   */
  async ensureAllClients(filePath) {
    const ext = path.extname(filePath) || path.basename(filePath);
    const servers = this._findAllServers(ext);
    const clients = [];
    for (const server of servers) {
      const root = await nearestRoot(server.rootMarkers, path.dirname(filePath), this.cwd);
      if (!root) continue;
      const key = `${server.id}:${root}`;
      if (this._broken.has(key)) continue;

      let client;
      if (this._clients.has(key)) {
        client = this._clients.get(key);
      } else if (this._spawning.has(key)) {
        client = await this._spawning.get(key);
      } else {
        const task = this._spawnClient(server, root, key);
        this._spawning.set(key, task);
        task.finally(() => {
          if (this._spawning.get(key) === task) this._spawning.delete(key);
        });
        client = await task;
      }
      if (client) clients.push(client);
    }
    return clients;
  }

  /**
   * Ensure a connected client exists for the given file.
   * Spawns the server lazily if needed. Returns null if:
   * - No server handles this file type
   * - Server binary not found
   * - Server failed to initialize
   *
   * @param {string} filePath - Absolute path to the file
   * @returns {Promise<LSPClient|null>}
   */
  async ensureClient(filePath) {
    const ext = path.extname(filePath);
    const server = this._findServer(ext);
    if (!server) return null;

    const root = await nearestRoot(server.rootMarkers, path.dirname(filePath), this.cwd);
    const key = `${server.id}:${root}`;

    // Already connected?
    if (this._clients.has(key)) return this._clients.get(key);

    // Known broken?
    if (this._broken.has(key)) return null;

    // Already spawning?
    if (this._spawning.has(key)) return this._spawning.get(key);

    const task = this._spawnClient(server, root, key);
    this._spawning.set(key, task);
    task.finally(() => {
      if (this._spawning.get(key) === task) this._spawning.delete(key);
    });

    return task;
  }

  /**
   * Spawn and initialize a language server.
   * @private
   */
  async _spawnClient(server, root, key) {
    // Check binary exists before spawning; try auto-install if missing
    let bin = server.checkBinary ? server.checkBinary() : null;
    if (!bin) {
      // Attempt auto-install for npm servers (typescript, pyright),
      // then fall back to binary download for non-npm servers (clangd, lua-ls, zls)
      const installed = await autoInstall(server.id);
      if (installed) {
        bin = installed;
      } else {
        const downloaded = await downloadServer(server.id);
        if (downloaded) {
          bin = downloaded;
        } else {
          this._broken.add(key);
          return null;
        }
      }
    }

    // Use the actual command; if bin is an absolute path (downloaded binary),
    // use it directly instead of the generic command name from server.command
    const [defaultCmd, ...args] = server.command;
    const cmd = (bin && path.isAbsolute(bin)) ? bin : defaultCmd;
    let proc;
    try {
      proc = spawn(cmd, args, {
        cwd: root,
        stdio: ['pipe', 'pipe', 'pipe'],
        env: { ...process.env },
      });
    } catch (err) {
      this._broken.add(key);
      return null;
    }

    // Handle spawn errors
    proc.on('error', () => {
      this._broken.add(key);
    });

    let connection;
    try {
      // Require vscode-jsonrpc lazily (only when actually used)
      const { createMessageConnection, StreamMessageReader, StreamMessageWriter } =
        require('vscode-jsonrpc/node');

      connection = createMessageConnection(
        new StreamMessageReader(proc.stdout),
        new StreamMessageWriter(proc.stdin),
      );
    } catch (err) {
      // vscode-jsonrpc not installed -- kill proc, mark broken
      proc.kill();
      this._broken.add(key);
      return null;
    }

    const client = new LSPClient(server.id, connection, proc, root);

    // Wire up diagnostics notification
    connection.onNotification('textDocument/publishDiagnostics', (params) => {
      const filePath = fileURLToPath(params.uri);
      client._onDiagnostics(filePath, params.diagnostics);
    });

    // Handle server requests
    connection.onRequest('window/workDoneProgress/create', () => null);
    connection.onRequest('workspace/configuration', () => [server.initialization || {}]);
    connection.onRequest('client/registerCapability', () => {});
    connection.onRequest('client/unregisterCapability', () => {});
    connection.onRequest('workspace/workspaceFolders', () => [{
      name: 'workspace',
      uri: pathToFileURL(root).href,
    }]);

    connection.listen();

    // Initialize with timeout
    try {
      await withTimeout(
        connection.sendRequest('initialize', {
          rootUri: pathToFileURL(root).href,
          processId: proc.pid,
          workspaceFolders: [{
            name: 'workspace',
            uri: pathToFileURL(root).href,
          }],
          initializationOptions: { ...server.initialization },
          capabilities: {
            window: { workDoneProgress: true },
            workspace: {
              configuration: true,
              didChangeWatchedFiles: { dynamicRegistration: true },
            },
            textDocument: {
              synchronization: { didOpen: true, didChange: true },
              publishDiagnostics: { versionSupport: true },
            },
          },
        }),
        STARTUP_TIMEOUT_MS,
      );
    } catch (err) {
      connection.end();
      connection.dispose();
      proc.kill();
      this._broken.add(key);
      return null;
    }

    await connection.sendNotification('initialized', {});

    if (server.initialization && Object.keys(server.initialization).length > 0) {
      await connection.sendNotification('workspace/didChangeConfiguration', {
        settings: server.initialization,
      });
    }

    this._clients.set(key, client);
    return client;
  }

  /**
   * Notify LSP of a file change and optionally wait for diagnostics.
   * Runs on ALL matching clients (ported from OpenCode LSP.touchFile).
   * @param {string} filePath - Absolute path
   * @param {boolean} [waitDiag=true] - Wait for diagnostics after touch
   */
  async touchFile(filePath, waitDiag = true) {
    const clients = await this.ensureAllClients(filePath);
    if (clients.length === 0) return;

    await Promise.all(clients.map(async (client) => {
      const waitPromise = waitDiag ? client.waitForDiagnostics(filePath) : Promise.resolve();
      await client.touchFile(filePath);
      return waitPromise;
    })).catch(() => {});
  }

  /**
   * Wait for diagnostics on a file (from all applicable clients).
   * Runs on ALL matching clients (ported from OpenCode).
   * @param {string} filePath
   */
  async waitForDiagnostics(filePath) {
    const clients = await this.ensureAllClients(filePath);
    if (clients.length === 0) return;
    await Promise.all(clients.map(c => c.waitForDiagnostics(filePath)));
  }

  /**
   * Get all diagnostics from all clients.
   * @returns {Promise<Record<string, Array>>}
   */
  async diagnostics() {
    const result = {};
    for (const client of this._clients.values()) {
      const diags = client.getDiagnostics();
      for (const [filePath, issues] of Object.entries(diags)) {
        if (!result[filePath]) result[filePath] = [];
        result[filePath].push(...issues);
      }
    }
    return result;
  }

  /**
   * Get document symbols for a file.
   * Queries ALL matching clients and merges results (ported from OpenCode).
   * @param {string} filePath - Absolute path
   * @returns {Promise<Array>}
   */
  async documentSymbols(filePath) {
    const clients = await this.ensureAllClients(filePath);
    if (clients.length === 0) return [];
    const uri = pathToFileURL(filePath).href;
    const results = await Promise.all(clients.map(client =>
      client.connection.sendRequest('textDocument/documentSymbol', {
        textDocument: { uri },
      }).catch(() => [])
    ));
    return results.flat().filter(Boolean);
  }

  /**
   * Go to definition. Queries ALL matching clients and merges results.
   * Ported from OpenCode LSP.definition (packages/opencode/src/lsp/index.ts).
   * @param {string} filePath
   * @param {number} line - 0-based
   * @param {number} character - 0-based
   * @returns {Promise<Array>}
   */
  async definition(filePath, line, character) {
    const clients = await this.ensureAllClients(filePath);
    if (clients.length === 0) return [];
    const uri = pathToFileURL(filePath).href;
    const results = await Promise.all(clients.map(client =>
      client.connection.sendRequest('textDocument/definition', {
        textDocument: { uri },
        position: { line, character },
      }).catch(() => null)
    ));
    return results.flat().filter(Boolean);
  }

  /**
   * Find references. Queries ALL matching clients and merges results.
   * Ported from OpenCode LSP.references (packages/opencode/src/lsp/index.ts).
   * @param {string} filePath
   * @param {number} line - 0-based
   * @param {number} character - 0-based
   * @returns {Promise<Array>}
   */
  async references(filePath, line, character) {
    const clients = await this.ensureAllClients(filePath);
    if (clients.length === 0) return [];
    const uri = pathToFileURL(filePath).href;
    const results = await Promise.all(clients.map(client =>
      client.connection.sendRequest('textDocument/references', {
        textDocument: { uri },
        position: { line, character },
        context: { includeDeclaration: true },
      }).catch(() => [])
    ));
    return results.flat().filter(Boolean);
  }

  // ── Additional LSP methods ported from OpenCode (packages/opencode/src/lsp/index.ts) ──

  /**
   * Get hover information for a symbol at a given position.
   * Queries ALL matching clients; returns first non-null result.
   * Ported from OpenCode LSP.hover (packages/opencode/src/lsp/index.ts).
   * @param {string} filePath - Absolute path
   * @param {number} line - 0-based
   * @param {number} character - 0-based
   * @returns {Promise<Array>} Array of hover results from all clients
   */
  async hover(filePath, line, character) {
    const clients = await this.ensureAllClients(filePath);
    if (clients.length === 0) return null;
    const uri = pathToFileURL(filePath).href;
    const results = await Promise.all(clients.map(client =>
      client.connection.sendRequest('textDocument/hover', {
        textDocument: { uri },
        position: { line, character },
      }).catch(() => null)
    ));
    // Return first non-null hover (most clients return equivalent info)
    return results.find(r => r != null) || null;
  }

  /**
   * Go to implementation. Queries ALL matching clients and merges results.
   * Ported from OpenCode LSP.implementation (packages/opencode/src/lsp/index.ts).
   * @param {string} filePath
   * @param {number} line - 0-based
   * @param {number} character - 0-based
   * @returns {Promise<Array>}
   */
  async implementation(filePath, line, character) {
    const clients = await this.ensureAllClients(filePath);
    if (clients.length === 0) return [];
    const uri = pathToFileURL(filePath).href;
    const results = await Promise.all(clients.map(client =>
      client.connection.sendRequest('textDocument/implementation', {
        textDocument: { uri },
        position: { line, character },
      }).catch(() => null)
    ));
    return results.flat().filter(Boolean);
  }

  /**
   * Search for workspace symbols matching a query.
   * Ported from OpenCode LSP.workspaceSymbol.
   * @param {string} query - Search query
   * @returns {Promise<Array>}
   */
  async workspaceSymbol(query) {
    const RELEVANT_KINDS = new Set([5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 23]); // Class..Struct
    const results = [];
    for (const client of this._clients.values()) {
      try {
        const symbols = await client.connection.sendRequest('workspace/symbol', { query });
        const filtered = (symbols || [])
          .filter(s => RELEVANT_KINDS.has(s.kind))
          .slice(0, 10);
        results.push(...filtered);
      } catch { /* ignore */ }
    }
    return results;
  }

  /**
   * Prepare call hierarchy at a given position.
   * Queries ALL matching clients and merges results (ported from OpenCode).
   * @param {string} filePath
   * @param {number} line - 0-based
   * @param {number} character - 0-based
   * @returns {Promise<Array>}
   */
  async prepareCallHierarchy(filePath, line, character) {
    const clients = await this.ensureAllClients(filePath);
    if (clients.length === 0) return [];
    const uri = pathToFileURL(filePath).href;
    const results = await Promise.all(clients.map(client =>
      client.connection.sendRequest('textDocument/prepareCallHierarchy', {
        textDocument: { uri },
        position: { line, character },
      }).then(r => Array.isArray(r) ? r : r ? [r] : []).catch(() => [])
    ));
    return results.flat().filter(Boolean);
  }

  /**
   * Get incoming calls for a symbol at a given position.
   * Ported from OpenCode LSP.incomingCalls.
   * @param {string} filePath
   * @param {number} line - 0-based
   * @param {number} character - 0-based
   * @returns {Promise<Array>}
   */
  async incomingCalls(filePath, line, character) {
    return this._callHierarchyRequest(filePath, line, character, 'callHierarchy/incomingCalls');
  }

  /**
   * Get outgoing calls from a symbol at a given position.
   * Ported from OpenCode LSP.outgoingCalls.
   * @param {string} filePath
   * @param {number} line - 0-based
   * @param {number} character - 0-based
   * @returns {Promise<Array>}
   */
  async outgoingCalls(filePath, line, character) {
    return this._callHierarchyRequest(filePath, line, character, 'callHierarchy/outgoingCalls');
  }

  /**
   * Internal helper for call hierarchy requests (incoming/outgoing).
   * Queries ALL matching clients and merges results (ported from OpenCode).
   * @private
   */
  async _callHierarchyRequest(filePath, line, character, direction) {
    const clients = await this.ensureAllClients(filePath);
    if (clients.length === 0) return [];
    const uri = pathToFileURL(filePath).href;
    const results = await Promise.all(clients.map(async (client) => {
      try {
        const items = await client.connection.sendRequest('textDocument/prepareCallHierarchy', {
          textDocument: { uri },
          position: { line, character },
        });
        if (!Array.isArray(items) || items.length === 0) return [];
        const result = await client.connection.sendRequest(direction, { item: items[0] });
        return Array.isArray(result) ? result : result ? [result] : [];
      } catch { return []; }
    }));
    return results.flat().filter(Boolean);
  }

  /**
   * Shut down all LSP servers. Call when the coding task ends.
   */
  async shutdown() {
    const shutdowns = [];
    for (const client of this._clients.values()) {
      shutdowns.push((async () => {
        try {
          client.connection.end();
          client.connection.dispose();
        } catch { /* ignore */ }
        await terminateProcess(client.process);
      })());
    }
    await Promise.allSettled(shutdowns);
    this._clients.clear();
    this._spawning.clear();
  }
}

function terminateProcess(proc, graceMs = 1000) {
  if (!proc || proc.exitCode !== null || proc.signalCode) return Promise.resolve();
  return new Promise((resolve) => {
    let done = false;
    let timer = null;
    const finish = () => {
      if (done) return;
      done = true;
      if (timer) clearTimeout(timer);
      try { proc.off('exit', finish); } catch {}
      try { proc.off('close', finish); } catch {}
      resolve();
    };
    try { proc.once('exit', finish); } catch {}
    try { proc.once('close', finish); } catch {}
    try {
      proc.kill('SIGTERM');
    } catch {
      finish();
      return;
    }
    timer = setTimeout(() => {
      try {
        if (proc.exitCode === null && !proc.signalCode) proc.kill('SIGKILL');
      } catch {}
      finish();
    }, graceMs);
    if (typeof timer.unref === 'function') timer.unref();
  });
}

/** Promise with timeout helper. */
function withTimeout(promise, ms) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
    promise.then(
      (val) => { clearTimeout(timer); resolve(val); },
      (err) => { clearTimeout(timer); reject(err); },
    );
  });
}

/**
 * Format a diagnostic for display.
 * @param {object} diagnostic - LSP Diagnostic object
 * @returns {string}
 */
function prettyDiagnostic(diagnostic) {
  const severityMap = { 1: 'ERROR', 2: 'WARN', 3: 'INFO', 4: 'HINT' };
  const severity = severityMap[diagnostic.severity || 1];
  const line = (diagnostic.range?.start?.line || 0) + 1;
  const col = (diagnostic.range?.start?.character || 0) + 1;
  return `${severity} [${line}:${col}] ${diagnostic.message}`;
}

/**
 * Format diagnostics for a file as a report string (errors only, max 20).
 * @param {string} filePath
 * @param {Array} issues - LSP Diagnostic array
 * @returns {string}
 */
function diagnosticReport(filePath, issues) {
  const errors = issues.filter(d => d.severity === 1);
  if (errors.length === 0) return '';
  const limited = errors.slice(0, 20);
  const more = errors.length - 20;
  const suffix = more > 0 ? `\n... and ${more} more` : '';
  return `<diagnostics file="${filePath}">\n${limited.map(prettyDiagnostic).join('\n')}${suffix}\n</diagnostics>`;
}

module.exports = { LSPManager, LSPClient, prettyDiagnostic, diagnosticReport, withTimeout };
