/**
 * Integration test for the LSP tool port.
 *
 * Spins up typescript-language-server against a temp TypeScript project
 * and exercises: initialize, didOpen, hover, definition, references,
 * documentSymbol, diagnostics, and shutdown.
 *
 * Run: node --experimental-strip-types --test src/core/lsp/lsp-integration.test.ts
 * (from packages/coding-agent/)
 */

import assert from "node:assert/strict";
import { execSync, spawn, spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterAll, beforeAll, describe, test } from "vitest";

function hasTypeScriptLanguageServer(): boolean {
	try {
		execSync("npx which typescript-language-server", { stdio: "ignore" });
	} catch {
		return false;
	}

	const initialize = encodeMessage({
		jsonrpc: "2.0",
		id: 1,
		method: "initialize",
		params: {
			processId: process.pid,
			rootUri: null,
			capabilities: {},
		},
	});
	const shutdown = encodeMessage({
		jsonrpc: "2.0",
		id: 2,
		method: "shutdown",
		params: null,
	});
	const exit = encodeMessage({
		jsonrpc: "2.0",
		method: "exit",
		params: null,
	});
	const probe = spawnSync("typescript-language-server", ["--stdio"], {
		input: initialize + shutdown + exit,
		encoding: "utf-8",
		timeout: 5_000,
	});

	return probe.stdout.includes('"id":1');
}

const describeOrSkip = hasTypeScriptLanguageServer() ? describe : describe.skip;

// ---------------------------------------------------------------------------
// Helpers — lightweight JSON-RPC over stdio (no dependency on our LSP code)
// ---------------------------------------------------------------------------

interface JsonRpcRequest {
	jsonrpc: "2.0";
	id: number;
	method: string;
	params: unknown;
}

interface JsonRpcNotification {
	jsonrpc: "2.0";
	method: string;
	params?: unknown;
}

interface JsonRpcResponse {
	jsonrpc: "2.0";
	id?: number;
	result?: unknown;
	error?: { code: number; message: string };
}

function encodeMessage(
	msg: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse,
): string {
	const body = JSON.stringify(msg);
	return `Content-Length: ${Buffer.byteLength(body, "utf-8")}\r\n\r\n${body}`;
}

/**
 * Minimal LSP harness: spawns a language server, sends requests, collects responses.
 */
class LspHarness {
	private proc;
	private nextId = 1;
	private buffer = Buffer.alloc(0);
	private pending = new Map<
		number,
		{ resolve: (v: unknown) => void; reject: (e: Error) => void }
	>();
	private notifications: Array<{ method: string; params: unknown }> = [];

	constructor(command: string, args: string[], cwd: string) {
		this.proc = spawn(command, args, {
			cwd,
			stdio: ["pipe", "pipe", "pipe"],
		});

		this.proc.stdout!.on("data", (chunk: Buffer) => {
			this.buffer = Buffer.concat([this.buffer, chunk]);
			this.drain();
		});

		this.proc.stderr!.on("data", (_chunk: Buffer) => {
			// Swallow stderr (server logs)
		});
	}

	private drain(): void {
		while (true) {
			const headerEnd = this.findHeaderEnd();
			if (headerEnd === -1) return;

			const headerText = this.buffer.subarray(0, headerEnd).toString("utf-8");
			const match = headerText.match(/Content-Length:\s*(\d+)/i);
			if (!match) return;

			const contentLength = parseInt(match[1], 10);
			const messageStart = headerEnd + 4; // past \r\n\r\n
			const messageEnd = messageStart + contentLength;
			if (this.buffer.length < messageEnd) return;

			const body = this.buffer
				.subarray(messageStart, messageEnd)
				.toString("utf-8");
			this.buffer = Buffer.from(this.buffer.subarray(messageEnd));

			const msg = JSON.parse(body) as JsonRpcResponse & {
				method?: string;
				params?: unknown;
			};

			if (msg.id !== undefined && this.pending.has(msg.id)) {
				const p = this.pending.get(msg.id)!;
				this.pending.delete(msg.id);
				if (msg.error) {
					p.reject(
						new Error(`LSP error ${msg.error.code}: ${msg.error.message}`),
					);
				} else {
					p.resolve(msg.result);
				}
			} else if (msg.method) {
				// Server request or notification
				this.notifications.push({ method: msg.method, params: msg.params });
				// Auto-respond to server requests that have an id
				if (msg.id !== undefined) {
					this.respond(msg.id, null);
				}
			}
		}
	}

	private findHeaderEnd(): number {
		for (let i = 0; i < this.buffer.length - 3; i++) {
			if (
				this.buffer[i] === 13 &&
				this.buffer[i + 1] === 10 &&
				this.buffer[i + 2] === 13 &&
				this.buffer[i + 3] === 10
			) {
				return i;
			}
		}
		return -1;
	}

	private respond(id: number, result: unknown): void {
		const msg: JsonRpcResponse = { jsonrpc: "2.0", id, result };
		this.proc.stdin!.write(encodeMessage(msg));
	}

	async request(
		method: string,
		params: unknown,
		timeoutMs = 15000,
	): Promise<unknown> {
		const id = this.nextId++;
		const msg: JsonRpcRequest = { jsonrpc: "2.0", id, method, params };
		this.proc.stdin!.write(encodeMessage(msg));

		return new Promise<unknown>((resolve, reject) => {
			const timer = setTimeout(() => {
				this.pending.delete(id);
				reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`));
			}, timeoutMs);

			this.pending.set(id, {
				resolve: (v) => {
					clearTimeout(timer);
					resolve(v);
				},
				reject: (e) => {
					clearTimeout(timer);
					reject(e);
				},
			});
		});
	}

	notify(method: string, params: unknown): void {
		const msg: JsonRpcNotification = { jsonrpc: "2.0", method, params };
		this.proc.stdin!.write(encodeMessage(msg));
	}

	getNotifications(
		method?: string,
	): Array<{ method: string; params: unknown }> {
		if (!method) return this.notifications;
		return this.notifications.filter((n) => n.method === method);
	}

	async waitForNotification(
		method: string,
		predicate: (notification: { method: string; params: unknown }) => boolean,
		timeoutMs = 10_000,
	): Promise<{ method: string; params: unknown } | undefined> {
		const startedAt = Date.now();
		while (Date.now() - startedAt < timeoutMs) {
			const found = this.getNotifications(method).find(predicate);
			if (found) return found;
			await new Promise((resolve) => setTimeout(resolve, 100));
		}
		return undefined;
	}

	async shutdown(): Promise<void> {
		try {
			await this.request("shutdown", null, 5000);
			this.notify("exit", null);
		} catch {
			// Best effort
		}
		this.proc.kill();
	}
}

// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------

function createTempProject(): { dir: string; cleanup: () => void } {
	const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lsp-test-"));

	// tsconfig.json
	fs.writeFileSync(
		path.join(dir, "tsconfig.json"),
		JSON.stringify(
			{
				compilerOptions: {
					target: "ES2022",
					module: "commonjs",
					strict: true,
					outDir: "./dist",
					rootDir: "./src",
				},
				include: ["src/**/*.ts"],
			},
			null,
			2,
		),
	);

	// package.json
	fs.writeFileSync(
		path.join(dir, "package.json"),
		JSON.stringify({ name: "lsp-test-project", version: "1.0.0" }, null, 2),
	);

	fs.mkdirSync(path.join(dir, "src"));

	// src/math.ts — module with exported functions
	fs.writeFileSync(
		path.join(dir, "src", "math.ts"),
		`export function add(a: number, b: number): number {
  return a + b;
}

export function subtract(a: number, b: number): number {
  return a - b;
}

export interface Calculator {
  add(a: number, b: number): number;
  subtract(a: number, b: number): number;
}
`,
	);

	// src/main.ts — imports from math, has a type error
	fs.writeFileSync(
		path.join(dir, "src", "main.ts"),
		`import { add, subtract, Calculator } from "./math";

const result: number = add(1, 2);
const diff: number = subtract(5, 3);

// Intentional type error: string assigned to number
const bad: number = "not a number";

export function compute(calc: Calculator): number {
  return calc.add(1, 2) + calc.subtract(5, 3);
}
`,
	);

	return {
		dir,
		cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
	};
}

function fileToUri(filePath: string): string {
	return `file://${path.resolve(filePath)}`;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describeOrSkip("LSP integration: typescript-language-server", () => {
	let dir: string;
	let cleanup: () => void;
	let mainPath: string;
	let mathPath: string;
	let mainUri: string;
	let mathUri: string;
	let lsp: LspHarness;

	beforeAll(async () => {
		const project = createTempProject();
		dir = project.dir;
		cleanup = project.cleanup;
		mainPath = path.join(dir, "src", "main.ts");
		mathPath = path.join(dir, "src", "math.ts");
		mainUri = fileToUri(mainPath);
		mathUri = fileToUri(mathPath);
		lsp = new LspHarness("typescript-language-server", ["--stdio"], dir);

		// Initialize
		const result = (await lsp.request("initialize", {
			processId: process.pid,
			rootUri: fileToUri(dir),
			rootPath: dir,
			capabilities: {
				textDocument: {
					hover: { contentFormat: ["markdown", "plaintext"] },
					definition: { linkSupport: true },
					references: {},
					documentSymbol: { hierarchicalDocumentSymbolSupport: true },
					publishDiagnostics: { relatedInformation: true },
				},
			},
			workspaceFolders: [{ uri: fileToUri(dir), name: "test" }],
		})) as { capabilities?: Record<string, unknown> };

		assert.ok(result, "initialize should return a result");
		assert.ok(result.capabilities, "result should have capabilities");
		assert.ok(
			result.capabilities.hoverProvider !== undefined,
			"should support hover",
		);
		assert.ok(
			result.capabilities.definitionProvider !== undefined,
			"should support definition",
		);

		lsp.notify("initialized", {});

		// Open both files
		const mainContent = fs.readFileSync(mainPath, "utf-8");
		const mathContent = fs.readFileSync(mathPath, "utf-8");

		lsp.notify("textDocument/didOpen", {
			textDocument: {
				uri: mainUri,
				languageId: "typescript",
				version: 1,
				text: mainContent,
			},
		});
		lsp.notify("textDocument/didOpen", {
			textDocument: {
				uri: mathUri,
				languageId: "typescript",
				version: 1,
				text: mathContent,
			},
		});

		// Give the server time to index
		await new Promise((r) => setTimeout(r, 3000));
	});

	afterAll(async () => {
		await lsp.shutdown().catch(() => {});
		cleanup();
	});

	test("initialize handshake", () => {
		// Assertions run in beforeAll; this test just confirms setup succeeded.
		assert.ok(lsp, "LSP harness should be initialized");
	});

	// ---- Hover ----
	test("hover on 'add' call", async () => {
		const result = (await lsp.request("textDocument/hover", {
			textDocument: { uri: mainUri },
			position: { line: 2, character: 24 }, // on 'add' in "add(1, 2)"
		})) as { contents?: unknown } | null;

		assert.ok(result, "hover should return a result");
		assert.ok(result.contents, "hover should have contents");
		const text = JSON.stringify(result.contents);
		assert.ok(
			text.includes("add") || text.includes("number"),
			`hover text should mention 'add' or 'number', got: ${text.slice(0, 200)}`,
		);
	});

	// ---- Go to Definition ----
	test("go to definition of 'add'", async () => {
		const result = (await lsp.request("textDocument/definition", {
			textDocument: { uri: mainUri },
			position: { line: 2, character: 24 }, // on 'add'
		})) as unknown;

		assert.ok(result, "definition should return a result");
		const locations = Array.isArray(result) ? result : [result];
		assert.ok(locations.length > 0, "should find at least one definition");
		// Response can be Location (uri) or LocationLink (targetUri)
		const loc = locations[0] as Record<string, unknown>;
		const uri = (loc.uri ?? loc.targetUri) as string;
		assert.ok(
			uri,
			`definition should have uri or targetUri, got keys: ${Object.keys(loc).join(", ")}`,
		);
		assert.ok(
			uri.includes("math.ts"),
			`definition should point to math.ts, got: ${uri}`,
		);
	});

	// ---- References ----
	test("find references of 'add'", async () => {
		const result = (await lsp.request("textDocument/references", {
			textDocument: { uri: mathUri },
			position: { line: 0, character: 16 }, // on 'add' definition
			context: { includeDeclaration: true },
		})) as Array<{ uri: string; range: unknown }> | null;

		assert.ok(result, "references should return a result");
		assert.ok(
			result.length >= 2,
			`should find at least 2 references (decl + usage), got ${result.length}`,
		);
	});

	// ---- Document Symbols ----
	test("document symbols in math.ts", async () => {
		const result = (await lsp.request("textDocument/documentSymbol", {
			textDocument: { uri: mathUri },
		})) as Array<{ name: string; kind: number }> | null;

		assert.ok(result, "documentSymbol should return a result");
		assert.ok(
			result.length >= 2,
			`should find at least 2 symbols, got ${result.length}`,
		);
		const names = result.map((s) => s.name);
		assert.ok(
			names.includes("add"),
			`symbols should include 'add', got: ${names.join(", ")}`,
		);
		assert.ok(
			names.includes("subtract"),
			`symbols should include 'subtract', got: ${names.join(", ")}`,
		);
	});

	// ---- Diagnostics (published via notification) ----
	test("diagnostics for type error", async () => {
		const mainContent = fs.readFileSync(mainPath, "utf-8");
		lsp.notify("textDocument/didChange", {
			textDocument: { uri: mainUri, version: 2 },
			contentChanges: [{ text: mainContent }],
		});

		const mainDiagNotification = await lsp.waitForNotification(
			"textDocument/publishDiagnostics",
			(n) => {
				const params = n.params as {
					uri: string;
					diagnostics?: Array<{ message: string; range: unknown }>;
				};
				return params.uri === mainUri && (params.diagnostics?.length ?? 0) > 0;
			},
		);

		assert.ok(mainDiagNotification, "should receive diagnostics for main.ts");

		const diagnostics = (
			mainDiagNotification.params as {
				diagnostics: Array<{ message: string; range: unknown }>;
			}
		).diagnostics;

		// Should catch the type error: string assigned to number
		const typeError = diagnostics.find(
			(d) => d.message.includes("not assignable") || d.message.includes("Type"),
		);
		assert.ok(
			typeError,
			`should find type error diagnostic, got: ${diagnostics.map((d) => d.message).join("; ")}`,
		);
	});

	// ---- Shutdown ----
	test("clean shutdown", async () => {
		// Should not throw
		await lsp.shutdown();
	});
});
