/**
 * 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/pi-coding-agent/)
 */
import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";

// ---------------------------------------------------------------------------
// 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 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
// ---------------------------------------------------------------------------

test("LSP integration: typescript-language-server", async (t) => {
	const { dir, cleanup } = createTempProject();
	const mainPath = path.join(dir, "src", "main.ts");
	const mathPath = path.join(dir, "src", "math.ts");
	const mainUri = fileToUri(mainPath);
	const mathUri = fileToUri(mathPath);

	const lsp = new LspHarness("typescript-language-server", ["--stdio"], dir);

	try {
		// ---- Initialize ----
		await t.test("initialize handshake", async () => {
			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));

		// ---- Hover ----
		await t.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 ----
		await t.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 ----
		await t.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 ----
		await t.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) ----
		await t.test("diagnostics for type error", async () => {
			// Wait a bit more for diagnostics to arrive
			await new Promise((r) => setTimeout(r, 2000));

			const diagNotifications = lsp.getNotifications("textDocument/publishDiagnostics");
			const mainDiags = diagNotifications.filter(
				(n) => (n.params as { uri: string }).uri === mainUri,
			);

			assert.ok(mainDiags.length > 0, "should receive diagnostics for main.ts");

			const lastDiag = mainDiags[mainDiags.length - 1];
			const diagnostics = (lastDiag.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 ----
		await t.test("clean shutdown", async () => {
			// Should not throw
			await lsp.shutdown();
		});
	} catch (err) {
		await lsp.shutdown().catch(() => {});
		cleanup();
		throw err;
	}

	cleanup();
});
