/**
 * OwlCoda Native LSP Provider
 *
 * A real Language Server Protocol client over stdio JSON-RPC, wired into the
 * LSP tool (see lsp.ts). Before this, the LSP tool was registered with a
 * permanent no-op provider (`isAvailable() => false`) and the only documented
 * activation path ("plugin with lspServers config") was never implemented —
 * so the tool was dead weight that misled the model into wasting a turn
 * calling it.
 *
 * This provider lazily spawns a language server per workspace root, performs
 * the initialize handshake, and routes the six LSP tool actions
 * (diagnostics / hover / definition / references / symbols / completion) to
 * the corresponding `textDocument/*` requests. Diagnostics are collected from
 * the server-pushed `textDocument/publishDiagnostics` notification within a
 * settle window.
 *
 * Server selection is by file extension + binary availability on PATH:
 *   - .ts/.tsx/.js/.jsx/.mts/.cts → typescript-language-server --stdio
 *   - (extensible: add more rows to LANGUAGE_SERVERS)
 *
 * When no server binary is available for a file, the provider reports
 * unavailable gracefully (same shape the old no-op default produced), so
 * non-TS/JS files don't error in a confusing way.
 */
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, extname, join } from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
/** Extension → language server spec. Extend here to support more languages. */
function resolveServerSpec(filePath) {
    const ext = extname(filePath).toLowerCase();
    switch (ext) {
        case '.ts':
        case '.mts':
        case '.cts':
            return { command: 'typescript-language-server', args: ['--stdio'], languageId: 'typescript' };
        case '.tsx':
            return { command: 'typescript-language-server', args: ['--stdio'], languageId: 'typescriptreact' };
        case '.js':
        case '.mjs':
        case '.cjs':
            return { command: 'typescript-language-server', args: ['--stdio'], languageId: 'javascript' };
        case '.jsx':
            return { command: 'typescript-language-server', args: ['--stdio'], languageId: 'javascriptreact' };
        default:
            return null;
    }
}
const binaryAvailableCache = new Map();
function isBinaryAvailable(command) {
    const cached = binaryAvailableCache.get(command);
    if (cached !== undefined)
        return cached;
    let available = false;
    try {
        // `which` (POSIX) / `where` (win32). execFileSync throws on non-zero exit.
        const probe = process.platform === 'win32' ? 'where' : 'which';
        execFileSync(probe, [command], { stdio: 'ignore' });
        available = true;
    }
    catch {
        available = false;
    }
    binaryAvailableCache.set(command, available);
    return available;
}
/** Walk up from the file's directory to find a project root marker. */
function resolveWorkspaceRoot(filePath) {
    const markers = ['tsconfig.json', 'jsconfig.json', 'package.json', '.git'];
    let dir = dirname(filePath);
    for (let i = 0; i < 50; i++) {
        for (const marker of markers) {
            if (existsSync(join(dir, marker)))
                return dir;
        }
        const parent = dirname(dir);
        if (parent === dir)
            break;
        dir = parent;
    }
    return dirname(filePath);
}
/**
 * One language-server process for one workspace root. Handles JSON-RPC framing
 * over stdio, the initialize handshake, didOpen tracking, and request/notify.
 */
class LspSession {
    rootUri;
    proc;
    buffer = Buffer.alloc(0);
    nextId = 1;
    pending = new Map();
    diagnosticsByUri = new Map();
    diagnosticsListeners = new Set();
    openDocs = new Set();
    initializePromise;
    dead = false;
    constructor(spec, rootUri) {
        this.rootUri = rootUri;
        this.proc = spawn(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'] });
        this.proc.stdout.on('data', (chunk) => this.onData(chunk));
        // LS stderr is noisy progress/log output — intentionally ignored.
        this.proc.stderr.on('data', () => { });
        this.proc.on('exit', () => {
            this.dead = true;
            for (const [, p] of this.pending) {
                clearTimeout(p.timer);
                p.reject(new Error('Language server exited'));
            }
            this.pending.clear();
        });
        this.proc.on('error', () => { this.dead = true; });
        this.initializePromise = this.initialize();
    }
    isDead() {
        return this.dead;
    }
    onData(chunk) {
        this.buffer = Buffer.concat([this.buffer, chunk]);
        for (;;) {
            const headerEnd = this.buffer.indexOf('\r\n\r\n');
            if (headerEnd === -1)
                return;
            const header = this.buffer.subarray(0, headerEnd).toString('utf8');
            const match = /Content-Length:\s*(\d+)/i.exec(header);
            if (!match) {
                // Unparseable header — drop it and resync.
                this.buffer = this.buffer.subarray(headerEnd + 4);
                continue;
            }
            const len = Number.parseInt(match[1], 10);
            const bodyStart = headerEnd + 4;
            if (this.buffer.length < bodyStart + len)
                return; // wait for more data
            const body = this.buffer.subarray(bodyStart, bodyStart + len).toString('utf8');
            this.buffer = this.buffer.subarray(bodyStart + len);
            let msg;
            try {
                msg = JSON.parse(body);
            }
            catch {
                continue;
            }
            this.onMessage(msg);
        }
    }
    onMessage(msg) {
        // Response to one of our requests.
        if (typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined) && !msg.method) {
            const pending = this.pending.get(msg.id);
            if (pending) {
                this.pending.delete(msg.id);
                clearTimeout(pending.timer);
                if (msg.error)
                    pending.reject(new Error(msg.error.message ?? 'LSP error'));
                else
                    pending.resolve(msg.result);
            }
            return;
        }
        // Server-pushed diagnostics.
        if (msg.method === 'textDocument/publishDiagnostics') {
            const uri = msg.params?.['uri'];
            const diags = msg.params?.['diagnostics'] ?? [];
            if (uri) {
                this.diagnosticsByUri.set(uri, diags);
                for (const listener of this.diagnosticsListeners)
                    listener(uri);
            }
            return;
        }
        // Server → client request. We must reply or some servers block.
        if (typeof msg.id === 'number' && msg.method) {
            if (msg.method === 'workspace/configuration') {
                const items = msg.params?.['items'] ?? [];
                this.sendRaw({ jsonrpc: '2.0', id: msg.id, result: items.map(() => ({})) });
            }
            else {
                // registerCapability, workDoneProgress/create, applyEdit, etc. — ack with null.
                this.sendRaw({ jsonrpc: '2.0', id: msg.id, result: null });
            }
        }
    }
    sendRaw(msg) {
        if (this.dead)
            return;
        const json = JSON.stringify(msg);
        const frame = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
        try {
            this.proc.stdin.write(frame);
        }
        catch {
            this.dead = true;
        }
    }
    request(method, params, timeoutMs = 8000) {
        if (this.dead)
            return Promise.reject(new Error('Language server is not running'));
        const id = this.nextId++;
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                this.pending.delete(id);
                reject(new Error(`LSP request '${method}' timed out after ${timeoutMs}ms`));
            }, timeoutMs);
            this.pending.set(id, { resolve, reject, timer });
            this.sendRaw({ jsonrpc: '2.0', id, method, params });
        });
    }
    notify(method, params) {
        this.sendRaw({ jsonrpc: '2.0', method, params });
    }
    async initialize() {
        await this.request('initialize', {
            processId: process.pid,
            rootUri: this.rootUri,
            workspaceFolders: [{ uri: this.rootUri, name: 'workspace' }],
            capabilities: {
                textDocument: {
                    synchronization: { didSave: true, dynamicRegistration: false },
                    publishDiagnostics: { relatedInformation: true },
                    hover: { contentFormat: ['markdown', 'plaintext'] },
                    definition: { dynamicRegistration: false },
                    references: { dynamicRegistration: false },
                    documentSymbol: { hierarchicalDocumentSymbolSupport: true },
                    completion: { completionItem: { snippetSupport: false } },
                },
                workspace: { configuration: true, workspaceFolders: true },
            },
        }, 15000);
        this.notify('initialized', {});
    }
    ensureOpen(filePath, languageId) {
        const uri = pathToFileURL(filePath).toString();
        if (this.openDocs.has(uri))
            return uri;
        const text = readFileSync(filePath, 'utf8');
        this.notify('textDocument/didOpen', {
            textDocument: { uri, languageId, version: 1, text },
        });
        this.openDocs.add(uri);
        return uri;
    }
    async diagnostics(filePath, languageId, settleMs = 2500) {
        await this.initializePromise;
        const uri = this.ensureOpen(filePath, languageId);
        return new Promise((resolve) => {
            let settleTimer = null;
            let hardCap = null;
            let done = false;
            const finish = () => {
                if (done)
                    return;
                done = true;
                this.diagnosticsListeners.delete(listener);
                if (settleTimer)
                    clearTimeout(settleTimer);
                if (hardCap)
                    clearTimeout(hardCap);
                resolve(this.diagnosticsByUri.get(uri) ?? []);
            };
            const listener = (changed) => {
                if (changed !== uri)
                    return;
                // Debounce: publishDiagnostics may arrive empty-then-populated, so
                // wait 400ms after the LAST update to return the settled set.
                if (settleTimer)
                    clearTimeout(settleTimer);
                settleTimer = setTimeout(finish, 400);
            };
            this.diagnosticsListeners.add(listener);
            // Hard cap so we never hang if the server never publishes for this uri.
            hardCap = setTimeout(finish, settleMs);
        });
    }
    async positionRequest(method, filePath, languageId, line, character) {
        await this.initializePromise;
        const uri = this.ensureOpen(filePath, languageId);
        return this.request(method, {
            textDocument: { uri },
            position: { line, character },
            ...(method === 'textDocument/references' ? { context: { includeDeclaration: true } } : {}),
        });
    }
    async documentRequest(method, filePath, languageId) {
        await this.initializePromise;
        const uri = this.ensureOpen(filePath, languageId);
        return this.request(method, { textDocument: { uri } });
    }
    dispose() {
        if (this.dead)
            return;
        try {
            this.notify('shutdown', {});
            this.notify('exit', {});
            this.proc.kill();
        }
        catch {
            // best effort
        }
        this.dead = true;
    }
}
const SEVERITY_LABELS = { 1: 'error', 2: 'warning', 3: 'info', 4: 'hint' };
function formatDiagnostics(filePath, diags) {
    if (diags.length === 0)
        return `No diagnostics for ${filePath}.`;
    const lines = diags.map((d) => {
        const line = (d.range?.start?.line ?? 0) + 1;
        const col = (d.range?.start?.character ?? 0) + 1;
        const sev = SEVERITY_LABELS[d.severity ?? 1] ?? 'error';
        const code = d.code !== undefined ? ` [${d.code}]` : '';
        const src = d.source ? `${d.source}: ` : '';
        return `${filePath}:${line}:${col} ${sev}${code} — ${src}${(d.message ?? '').trim()}`;
    });
    const errorCount = diags.filter((d) => (d.severity ?? 1) === 1).length;
    const warnCount = diags.filter((d) => d.severity === 2).length;
    return `${diags.length} diagnostic(s) (${errorCount} error, ${warnCount} warning):\n` + lines.join('\n');
}
function formatLocations(result) {
    const locs = Array.isArray(result) ? result : result ? [result] : [];
    if (locs.length === 0)
        return 'No locations found.';
    const out = [];
    for (const loc of locs) {
        const uri = (loc['uri'] ?? loc['targetUri']);
        const range = (loc['range'] ?? loc['targetRange']);
        if (!uri)
            continue;
        let path = uri;
        try {
            path = fileURLToPath(uri);
        }
        catch { /* keep uri */ }
        const line = (range?.start?.line ?? 0) + 1;
        const col = (range?.start?.character ?? 0) + 1;
        out.push(`${path}:${line}:${col}`);
    }
    return out.length > 0 ? out.join('\n') : 'No locations found.';
}
function formatHover(result) {
    if (!result || typeof result !== 'object')
        return 'No hover information.';
    const contents = result['contents'];
    if (typeof contents === 'string')
        return contents;
    if (Array.isArray(contents)) {
        return contents.map((c) => (typeof c === 'string' ? c : c['value'] ?? '')).join('\n');
    }
    if (contents && typeof contents === 'object') {
        return String(contents['value'] ?? 'No hover information.');
    }
    return 'No hover information.';
}
function formatSymbols(result) {
    const syms = Array.isArray(result) ? result : [];
    if (syms.length === 0)
        return 'No symbols found.';
    const out = [];
    const SYMBOL_KINDS = {
        5: 'class', 6: 'method', 8: 'field', 9: 'constructor', 11: 'interface',
        12: 'function', 13: 'variable', 14: 'constant', 23: 'struct',
    };
    const walk = (nodes, depth) => {
        for (const n of nodes) {
            const name = n['name'];
            const kind = SYMBOL_KINDS[n['kind'] ?? 0] ?? 'symbol';
            const range = (n['range'] ?? n['location']?.['range']);
            const line = (range?.start?.line ?? 0) + 1;
            out.push(`${'  '.repeat(depth)}${kind} ${name ?? '?'}  (L${line})`);
            const children = n['children'];
            if (Array.isArray(children))
                walk(children, depth + 1);
        }
    };
    walk(syms, 0);
    return out.join('\n');
}
function formatCompletions(result) {
    const items = Array.isArray(result)
        ? result
        : result?.['items'];
    const list = Array.isArray(items) ? items : [];
    if (list.length === 0)
        return 'No completions.';
    const labels = list.slice(0, 40).map((i) => String(i['label'] ?? ''));
    const more = list.length > 40 ? `\n… +${list.length - 40} more` : '';
    return `${list.length} completion(s):\n` + labels.join(', ') + more;
}
/**
 * The native LSP provider. Manages one LspSession per (root × server) and
 * routes tool actions to the right textDocument request.
 */
export class NativeLspProvider {
    sessions = new Map();
    isAvailable() {
        // Available if at least one known server binary is on PATH. The
        // per-file check in execute() is the authoritative gate; this coarse
        // check lets the tool advertise itself only when something can run.
        return isBinaryAvailable('typescript-language-server');
    }
    sessionFor(filePath, spec) {
        const root = resolveWorkspaceRoot(filePath);
        const key = `${spec.command}::${root}`;
        const existing = this.sessions.get(key);
        if (existing && !existing.isDead())
            return existing;
        const session = new LspSession(spec, pathToFileURL(root).toString());
        this.sessions.set(key, session);
        return session;
    }
    async execute(action, params) {
        const filePath = String(params['file_path'] ?? '');
        if (!filePath)
            return { content: 'LSP: file_path is required.', isError: true };
        if (!existsSync(filePath))
            return { content: `LSP: file not found: ${filePath}`, isError: true };
        const spec = resolveServerSpec(filePath);
        if (!spec) {
            return {
                content: `LSP: no language server configured for ${extname(filePath) || 'this file type'}. ` +
                    `Supported: .ts/.tsx/.js/.jsx (typescript-language-server).`,
                isError: true,
            };
        }
        if (!isBinaryAvailable(spec.command)) {
            return {
                content: `LSP: '${spec.command}' is not installed. ` +
                    `Install it (e.g. \`npm i -g typescript-language-server typescript\`) to enable code intelligence.`,
                isError: true,
            };
        }
        const session = this.sessionFor(filePath, spec);
        const line = typeof params['line'] === 'number' ? params['line'] : 0;
        const character = typeof params['character'] === 'number' ? params['character'] : 0;
        try {
            switch (action) {
                case 'diagnostics': {
                    const diags = await session.diagnostics(filePath, spec.languageId);
                    return { content: formatDiagnostics(filePath, diags) };
                }
                case 'hover': {
                    const r = await session.positionRequest('textDocument/hover', filePath, spec.languageId, line, character);
                    return { content: formatHover(r) };
                }
                case 'definition': {
                    const r = await session.positionRequest('textDocument/definition', filePath, spec.languageId, line, character);
                    return { content: formatLocations(r) };
                }
                case 'references': {
                    const r = await session.positionRequest('textDocument/references', filePath, spec.languageId, line, character);
                    return { content: formatLocations(r) };
                }
                case 'symbols': {
                    const r = await session.documentRequest('textDocument/documentSymbol', filePath, spec.languageId);
                    return { content: formatSymbols(r) };
                }
                case 'completion': {
                    const r = await session.positionRequest('textDocument/completion', filePath, spec.languageId, line, character);
                    return { content: formatCompletions(r) };
                }
                default:
                    return { content: `LSP: unknown action '${action}'.`, isError: true };
            }
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            return { content: `LSP ${action} failed: ${msg}`, isError: true };
        }
    }
    disposeAll() {
        for (const [, session] of this.sessions)
            session.dispose();
        this.sessions.clear();
    }
}
let sharedProvider = null;
/** Lazy singleton — one provider process-wide, sessions reused across calls. */
export function getNativeLspProvider() {
    if (!sharedProvider)
        sharedProvider = new NativeLspProvider();
    return sharedProvider;
}
//# sourceMappingURL=lsp-provider.js.map