/**
 * HookBridge — lightweight HTTP server that receives Claude Code hook events
 * and translates them into Yjs awareness updates via AbracadabraMCPServer.
 *
 * Claude Code hooks (PreToolUse, PostToolUse, SubagentStart, SubagentStop, Stop)
 * POST JSON to http://127.0.0.1:{port}/hook. The bridge maps these to awareness
 * fields (status, activeToolCall) so the cou-sh dashboard shows real-time activity.
 */

import * as fs from "node:fs";
import * as http from "node:http";
import * as os from "node:os";
import * as path from "node:path";
import type { AbracadabraMCPServer } from "./server.ts";

/**
 * Expandable detail shown when a tool card is clicked in the dashboard
 * (mirrors the Claude CLI's inline tool views). Strings are capped so the
 * awareness payload stays bounded.
 */
export type ToolDetail =
	| { kind: "diff"; file?: string; old?: string; new?: string }
	| { kind: "code"; file?: string; text?: string }
	| { kind: "command"; text?: string }
	| { kind: "text"; text?: string };

/** Per-field cap for detail strings (chars). Keeps awareness lean. */
const DETAIL_MAX = 4000;

function cap(s: string | undefined): string | undefined {
	if (typeof s !== "string") return undefined;
	return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… (truncated)` : s;
}

/** Map Claude Code tool names to awareness-friendly names + target + detail. */
function mapToolCall(
	toolName: string,
	toolInput: Record<string, any>,
): { name: string; target?: string; detail?: ToolDetail } | null {
	switch (toolName) {
		case "Bash":
			return {
				name: "bash",
				target: truncate(toolInput.command ?? toolInput.description, 60),
				detail: { kind: "command", text: cap(toolInput.command) },
			};
		case "Read":
			return {
				name: "read_file",
				target: basename(toolInput.file_path),
				detail: { kind: "text", text: toolInput.file_path },
			};
		case "Edit": {
			// Single-edit ({old_string,new_string}) or multi-edit ({edits:[…]}).
			let oldText: string | undefined = toolInput.old_string;
			let newText: string | undefined = toolInput.new_string;
			if (!oldText && Array.isArray(toolInput.edits)) {
				oldText = toolInput.edits
					.map((e: any) => e?.old_string ?? "")
					.join("\n");
				newText = toolInput.edits
					.map((e: any) => e?.new_string ?? "")
					.join("\n");
			}
			return {
				name: "edit_file",
				target: basename(toolInput.file_path),
				detail: {
					kind: "diff",
					file: toolInput.file_path,
					old: cap(oldText),
					new: cap(newText),
				},
			};
		}
		case "Write":
			return {
				name: "write_file",
				target: basename(toolInput.file_path),
				detail: {
					kind: "code",
					file: toolInput.file_path,
					text: cap(toolInput.content),
				},
			};
		case "Grep":
			return {
				name: "grep",
				target: truncate(toolInput.pattern, 40),
				detail: { kind: "text", text: toolInput.pattern },
			};
		case "Glob":
			return {
				name: "glob",
				target: truncate(toolInput.pattern, 40),
				detail: { kind: "text", text: toolInput.pattern },
			};
		case "Agent":
			return {
				name: "subagent",
				target: toolInput.description || toolInput.subagent_type || "agent",
				detail: { kind: "text", text: cap(toolInput.prompt) },
			};
		case "WebFetch":
			return {
				name: "web_fetch",
				target: hostname(toolInput.url),
				detail: { kind: "text", text: toolInput.url },
			};
		case "WebSearch":
			return {
				name: "web_search",
				target: truncate(toolInput.query, 40),
				detail: { kind: "text", text: toolInput.query },
			};
		default:
			// Unknown tool — use the name as-is, lowercased with underscores
			return {
				name: toolName.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(),
			};
	}
}

function truncate(str: string | undefined, max: number): string | undefined {
	if (!str) return undefined;
	return str.length > max ? str.slice(0, max) + "..." : str;
}

function basename(filePath: string | undefined): string | undefined {
	if (!filePath) return undefined;
	return path.basename(filePath);
}

function hostname(url: string | undefined): string | undefined {
	if (!url) return undefined;
	try {
		return new URL(url).hostname;
	} catch {
		return url.slice(0, 30);
	}
}

export class HookBridge {
	private httpServer: http.Server | null = null;
	private _port: number | null = null;
	private portFilePath: string;

	constructor(private server: AbracadabraMCPServer) {
		// Stable, predictable path (NOT tmpdir) so the Claude Code hook command
		// can read it without depending on a possibly-different $TMPDIR at hook
		// execution time. Override with ABRA_HOOK_PORT_FILE if needed.
		this.portFilePath =
			process.env.ABRA_HOOK_PORT_FILE ||
			path.join(os.homedir(), ".abracadabra-mcp-hook.port");
	}

	get port(): number | null {
		return this._port;
	}

	/** Absolute path of the file the current listening port is written to. */
	get portFile(): string {
		return this.portFilePath;
	}

	/** Start the HTTP server on a random port and write the port file. */
	async start(): Promise<number> {
		return new Promise((resolve, reject) => {
			const srv = http.createServer((req, res) => this.handleRequest(req, res));

			srv.on("error", reject);

			srv.listen(0, "127.0.0.1", () => {
				const addr = srv.address();
				if (!addr || typeof addr === "string") {
					reject(new Error("Failed to get server address"));
					return;
				}

				this._port = addr.port;
				this.httpServer = srv;

				// Write port file for hook discovery
				try {
					fs.writeFileSync(this.portFilePath, String(this._port), "utf-8");
				} catch (err: any) {
					console.error(
						`[hook-bridge] Warning: could not write port file: ${err.message}`,
					);
				}

				console.error(`[hook-bridge] Listening on 127.0.0.1:${this._port}`);
				console.error(`[hook-bridge] Port file: ${this.portFilePath}`);
				resolve(this._port);
			});
		});
	}

	/** Shut down the HTTP server and remove the port file. */
	async destroy(): Promise<void> {
		if (this.httpServer) {
			await new Promise<void>((resolve) => {
				this.httpServer!.close(() => resolve());
			});
			this.httpServer = null;
		}

		// Remove port file
		try {
			fs.unlinkSync(this.portFilePath);
		} catch {
			// Already gone or never written
		}

		this._port = null;
		console.error("[hook-bridge] Shut down");
	}

	private handleRequest(
		req: http.IncomingMessage,
		res: http.ServerResponse,
	): void {
		// Only accept POST /hook
		if (req.method !== "POST" || req.url !== "/hook") {
			res.writeHead(404);
			res.end();
			return;
		}

		let body = "";
		req.on("data", (chunk: Buffer) => {
			body += chunk.toString();
		});
		req.on("end", () => {
			res.writeHead(200, { "Content-Type": "application/json" });
			res.end("{}");

			try {
				const payload = JSON.parse(body);
				this.routeEvent(payload);
			} catch {
				// Invalid JSON — ignore silently (fire-and-forget)
			}
		});
	}

	private routeEvent(payload: Record<string, any>): void {
		const event = payload.hook_event_name;
		switch (event) {
			case "UserPromptSubmit":
				this.onUserPromptSubmit();
				break;
			case "PreToolUse":
				this.onPreToolUse(payload);
				break;
			case "PostToolUse":
				this.onPostToolUse(payload);
				break;
			case "SubagentStart":
				this.onSubagentStart(payload);
				break;
			case "SubagentStop":
				this.onSubagentStop();
				break;
			case "Stop":
				this.onStop();
				break;
		}
	}

	/**
	 * UserPromptSubmit — intentionally a NO-OP for channel-driven turns.
	 *
	 * The MCP dispatch (`_beginTurn` + `setAutoStatus('thinking')`) runs FIRST
	 * when a channel message arrives, then the notification is injected into the
	 * Claude Code session, which fires THIS hook. If we cleared status/turn here
	 * we'd wipe the "thinking" turn the dispatch just set up — killing the
	 * incantation phrase AND the typing heartbeat. Each new turn already resets
	 * cleanly via `_beginTurn` (fresh turnId + empty toolHistory), so there's
	 * nothing to clear here.
	 */
	private onUserPromptSubmit(): void {
		// no-op — see doc comment.
	}

	private onPreToolUse(payload: Record<string, any>): void {
		const toolName: string = payload.tool_name ?? "";
		// Skip Abracadabra MCP tools — they set awareness themselves
		if (toolName.startsWith("mcp__abracadabra__")) return;

		const toolInput = payload.tool_input ?? {};
		const mapped = mapToolCall(toolName, toolInput);
		if (mapped) {
			this.server.setActiveToolCall(mapped);
			this.server.setAutoStatus("working");
		}
	}

	private onPostToolUse(payload: Record<string, any>): void {
		// Clear the live tool pill for EVERY tool (native + MCP) when it
		// finishes. This is what flips the inline card from running (spinner) →
		// done (check) in the dashboard, and — because no tool is "active"
		// between calls — lets the typing-dots show while Claude composes its
		// reply. The completed call stays in the persistent toolHistory; only
		// the transient `activeToolCall` pill is cleared. Status is left intact
		// (the turn is still live) so the dashboard knows Claude is still working.
		this.server.setActiveToolCall(null);
	}

	private onSubagentStart(payload: Record<string, any>): void {
		const agentType: string = payload.agent_type ?? "agent";
		this.server.setActiveToolCall({ name: "subagent", target: agentType });
		this.server.setAutoStatus("thinking");
	}

	private onSubagentStop(): void {
		// No-op — the parent agent continues working; the next Pre/PostToolUse or
		// Stop will update state. Previously this reset status to 'thinking',
		// which kept the auto-clear timer resetting forever.
	}

	private onStop(): void {
		this.server.setAutoStatus(null);
		this.server.setActiveToolCall(null);
	}
}
