/**
 * Remote Access Server — HTTP + WebSocket server for phone/remote control.
 *
 * Embeds a lightweight HTTP server inside the sidecar process that exposes
 * the same JSON protocol over HTTP/WebSocket. Connected clients (phone
 * browsers, companion apps, etc.) can send commands and receive streaming
 * events just like the Tauri desktop frontend.
 *
 * Architecture:
 * ```
 * Phone Browser ─── POST /api/command ──► command queue ──► sidecar dispatch
 *                 ◄── SSE  /api/events ◄── EventBus (from send())
 *                 ◄── WS   /ws        ◄── EventBus (from send())
 * ```
 *
 * Security:
 * - Default: binds to 127.0.0.1 (local machine only)
 * - LAN access: user must explicitly enable via Settings
 * - PIN pairing: first-time auth via single-use PIN shown in desktop UI
 *
 * @module remote-server
 */

import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { randomBytes } from "node:crypto";
import { fileURLToPath } from "node:url";
import { WebSocketServer, WebSocket } from "ws";
import { eventBus, type BusEvent } from "./event-bus.js";
import { commandQueue } from "./command-queue.js";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface RemoteServerConfig {
	port: number;
	host: string;
}

interface RemoteServerState {
	server: http.Server;
	wss: WebSocketServer;
	config: RemoteServerConfig;
	pin: string;
	pinExpiresAt: number;
}

// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------

let state: RemoteServerState | null = null;

/** Track SSE client connections (separate from WebSocket clients) */
const sseClients = new Set<http.ServerResponse>();

/** Generate a random 6-digit PIN */
function generatePin(): string {
	// Rejection-sampled: unbiased 6-digit PIN from 32-bit random
	const MAX = 1000000;
	const CEIL = Math.floor(0xffffffff / MAX) * MAX;
	let val: number;
	do {
		val = randomBytes(4).readUInt32BE(0);
	} while (val >= CEIL);
	return String(val % MAX).padStart(6, "0");
}

// ---------------------------------------------------------------------------
// CORS and common headers
// ---------------------------------------------------------------------------

const CORS_HEADERS: Record<string, string> = {
	"Access-Control-Allow-Origin": "*",
	"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
	"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Remote-Pin",
};

function writeJson(res: http.ServerResponse, status: number, data: unknown): void {
	res.writeHead(status, {
		"Content-Type": "application/json",
		...CORS_HEADERS,
	});
	res.end(JSON.stringify(data));
}

// ---------------------------------------------------------------------------
// Request router
// ---------------------------------------------------------------------------

async function handleRequest(
	req: http.IncomingMessage,
	res: http.ServerResponse,
	zosmaDir: string,
): Promise<void> {
	// Parse URL path without query string for route matching.
	// Node.js req.url includes the query string (e.g. "/api/events?pin=123456").
	const requestPath = (req.url || "/").split("?")[0];
	const method = req.method || "GET";

	// CORS preflight
	if (method === "OPTIONS") {
		res.writeHead(204, CORS_HEADERS);
		res.end();
		return;
	}

	// Mobile web UI at /m/* — loads the mobile companion app
	// Exact match: /m or /m/* (not /manifest.webmanifest, /main.js, etc.)
	if (method === "GET" && (requestPath === "/m" || requestPath.startsWith("/m/"))) {
		serveMobile(req, res);
		return;
	}

	// Static file serving (skip API and mobile paths)
	if (
		method === "GET" &&
		!requestPath.startsWith("/api/") &&
		requestPath !== "/m" &&
		!requestPath.startsWith("/m/")
	) {
		serveStatic(req, res);
		return;
	}

	// ── POST /api/command ──────────────────────────────────────────────
	if (method === "POST" && requestPath === "/api/command") {
		if (!verifyRequestAuth(req)) {
			writeJson(res, 401, { type: "error", id: "auth", message: "Invalid or missing PIN" });
			return;
		}
		await handleApiCommand(req, res);
		return;
	}

	// ── GET /api/events (SSE — Server-Sent Events) ─────────────────────
	if (method === "GET" && requestPath === "/api/events") {
		if (!verifyRequestAuth(req)) {
			writeJson(res, 401, { type: "error", id: "auth", message: "Invalid or missing PIN" });
			return;
		}
		handleSSE(req, res);
		return;
	}

	// ── GET /api/status ────────────────────────────────────────────────
	if (method === "GET" && requestPath === "/api/status") {
		const st = getRemoteStatus();
		writeJson(res, 200, {
			running: st.running,
			port: st.port,
			host: st.host,
			connectedClients: st.connectedClients ?? 0,
			needsPin: !isLocalRequest(req),
		});
		return;
	}

	// ── POST /api/verify-pin ──────────────────────────────────────────
	if (method === "POST" && requestPath === "/api/verify-pin") {
		await handlePinVerification(req, res);
		return;
	}

	// ── 404 ────────────────────────────────────────────────────────────
	writeJson(res, 404, { error: "Not found" });
}

// ---------------------------------------------------------------------------
// PIN authentication
// ---------------------------------------------------------------------------

function isLocalRequest(req: http.IncomingMessage): boolean {
	const addr = req.socket.remoteAddress;
	if (addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1") {
		return true;
	}
	return false;
}

function verifyRequestAuth(req: http.IncomingMessage): boolean {
	// Local requests are always trusted
	if (isLocalRequest(req)) return true;

	// If no PIN set (shouldn't happen), fail closed
	if (!state?.pin) return false;

	// Check PIN from header or query param
	const pinHeader = req.headers["x-remote-pin"] as string | undefined;
	const reqUrl = req.url || "/";
	const queryIndex = reqUrl.indexOf("?");
	const pinQuery = queryIndex >= 0
		? new URLSearchParams(reqUrl.slice(queryIndex)).get("pin")
		: null;

	const pin = pinHeader || pinQuery || "";
	if (pin === state.pin && Date.now() < state.pinExpiresAt) {
		return true;
	}

	return false;
}

async function handlePinVerification(
	req: http.IncomingMessage,
	res: http.ServerResponse,
): Promise<void> {
	const body = await readBody(req);
	let parsed: { pin?: string };
	try {
		parsed = JSON.parse(body);
	} catch {
		writeJson(res, 400, { success: false, message: "Invalid JSON" });
		return;
	}

	if (!state) {
		writeJson(res, 503, { success: false, message: "Remote server not running" });
		return;
	}

	if (parsed.pin === state.pin && Date.now() < state.pinExpiresAt) {
		// Generate a session token for this connection
		const token = randomBytes(16).toString("hex");
		writeJson(res, 200, { success: true, token });
		// NOTE: PIN is NOT regenerated here. The original PIN stays valid
		// until its natural 2-minute expiry so the mobile app can continue
		// using it for subsequent API calls (SSE connection, commands).
		// If the user needs a fresh PIN, they can toggle remote access off/on.
	} else {
		writeJson(res, 401, { success: false, message: "Invalid or expired PIN" });
	}
}

// ---------------------------------------------------------------------------
// API command handler (enqueues commands for the sidecar's main loop)
// ---------------------------------------------------------------------------

async function handleApiCommand(
	req: http.IncomingMessage,
	res: http.ServerResponse,
): Promise<void> {
	const body = await readBody(req);

	let command: Record<string, unknown>;
	try {
		command = JSON.parse(body);
	} catch {
		writeJson(res, 400, { type: "error", id: "parse", message: "Invalid JSON" });
		return;
	}

	const commandId = (command.id as string) || `http-${randomBytes(4).toString("hex")}`;
	command.id = commandId;

	log("[remote] POST /api/command: %s (id=%s)", command.type, commandId);

	// Tag and enqueue for the main loop
	(command as Record<string, unknown>)._origin = "remote";
	const queued = commandQueue.enqueue(command as Record<string, unknown>);

	if (!queued) {
		writeJson(res, 503, { type: "error", id: commandId, message: "Command queue full" });
		return;
	}

	// For synchronous commands, we could wait for the result on the event bus.
	// For Phase 6.0, we acknowledge the command and let the client use
	// WebSocket/SSE for streaming results.
	writeJson(res, 202, {
		type: "accepted",
		id: commandId,
		message: "Command queued. Use WebSocket or SSE for streaming response.",
	});
}

// ---------------------------------------------------------------------------
// SSE endpoint (Server-Sent Events)
// ---------------------------------------------------------------------------

function handleSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
	res.writeHead(200, {
		"Content-Type": "text/event-stream",
		"Cache-Control": "no-cache",
		"Connection": "keep-alive",
		...CORS_HEADERS,
	});

	// Track this SSE connection
	sseClients.add(res);

	// Send initial connection event
	res.write(`data: ${JSON.stringify({ type: "connected" })}\n\n`);

	// Subscribe to event bus
	const unsubscribe = eventBus.subscribe((event: BusEvent) => {
		try {
			res.write(`data: ${JSON.stringify(event)}\n\n`);
		} catch {
			// Client disconnected
			unsubscribe();
		}
	});

	// Clean up on client disconnect
	req.on("close", () => {
		sseClients.delete(res);
		unsubscribe();
	});
}

// ---------------------------------------------------------------------------
// Static file serving (for mobile web UI)
// ---------------------------------------------------------------------------

/** Get the directory where the built frontend assets are served from */
function getWebDistDir(): string {
	// In the sidecar process, the agent-sidecar dir is two levels up from src/
	const __dirname = path.dirname(fileURLToPath(import.meta.url));
	const sidecarRoot = path.resolve(__dirname, "..");
	// The web dist is at the project root: zosma-cowork/dist/
	// From agent-sidecar/src/ -> ../../dist
	const projectRoot = path.resolve(sidecarRoot, "..");
	return path.join(projectRoot, "dist");
}

/** Get the project root directory (parent of agent-sidecar/) */
function getProjectRoot(): string {
	const __dirname = path.dirname(fileURLToPath(import.meta.url));
	const sidecarRoot = path.resolve(__dirname, "..");
	return path.resolve(sidecarRoot, "..");
}

function serveStatic(req: http.IncomingMessage, res: http.ServerResponse): void {
	const url = req.url || "/";

	// Security: prevent directory traversal
	if (url.includes("..")) {
		writeJson(res, 403, { error: "Forbidden" });
		return;
	}

	let filePath = url;
	if (url === "/") filePath = "/index.html";

	const distDir = getWebDistDir();
	const fullPath = path.join(distDir, filePath);

	try {
		if (!fs.existsSync(fullPath)) {
			// Fall back to index.html for SPA routing
			const indexPath = path.join(distDir, "index.html");
			if (fs.existsSync(indexPath)) {
				const content = fs.readFileSync(indexPath);
				res.writeHead(200, {
					"Content-Type": getMimeType("html"),
					...CORS_HEADERS,
				});
				res.end(content);
			} else {
				writeJson(res, 404, {
					error: "Web UI not built. Run 'npm run build:frontend' first.",
				});
			}
			return;
		}

		const ext = filePath.split(".").pop() || "bin";
		const content = fs.readFileSync(fullPath);
		res.writeHead(200, {
			"Content-Type": getMimeType(ext),
			...CORS_HEADERS,
		});
		res.end(content);
	} catch {
		writeJson(res, 500, { error: "Internal error serving static file" });
	}
}

/**
 * Serve the mobile web app at /m/*.
 *
 * Routes /m and /m/* to dist/mobile.html with SPA fallback so the
 * mobile React app&#39;s internal routing (e.g. /m/chat) works correctly.
 */
function serveMobile(req: http.IncomingMessage, res: http.ServerResponse): void {
	const url = req.url || "/m/";

	// Security: prevent directory traversal
	if (url.includes("..")) {
		writeJson(res, 403, { error: "Forbidden" });
		return;
	}

	const distDir = getWebDistDir();

	// Try exact path match first (e.g. /m/assets/foo.js -> dist/assets/foo.js)
	// Strip /m prefix to serve from dist root
	const relative = url.startsWith("/m/") ? url.slice(3) : "/index.html";
	const fullPath = path.join(distDir, relative);

	try {
		if (fs.existsSync(fullPath)) {
			const ext = relative.split(".").pop() || "bin";
			const content = fs.readFileSync(fullPath);
			res.writeHead(200, {
				"Content-Type": getMimeType(ext),
				...CORS_HEADERS,
			});
			res.end(content);
			return;
		}
	} catch {
		// Fall through to SPA fallback
	}

	// SPA fallback: serve mobile.html
	const mobilePath = path.join(distDir, "mobile.html");
	try {
		if (fs.existsSync(mobilePath)) {
			const content = fs.readFileSync(mobilePath);
			res.writeHead(200, {
				"Content-Type": getMimeType("html"),
				...CORS_HEADERS,
			});
			res.end(content);
		} else {
			writeJson(res, 404, {
				error: "Mobile UI not built. Run 'npm run build:frontend' first.",
			});
		}
	} catch {
		writeJson(res, 500, { error: "Internal error serving mobile app" });
	}
}

// ---------------------------------------------------------------------------
// MIME type map
// ---------------------------------------------------------------------------

const MIME_TYPES: Record<string, string> = {
	html: "text/html",
	css: "text/css",
	js: "application/javascript",
	mjs: "application/javascript",
	json: "application/json",
	png: "image/png",
	svg: "image/svg+xml",
	ico: "image/x-icon",
	woff2: "font/woff2",
	ttf: "font/ttf",
};

function getMimeType(ext: string): string {
	return MIME_TYPES[ext] || "application/octet-stream";
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function readBody(req: http.IncomingMessage): Promise<string> {
	return new Promise((resolve) => {
		const chunks: Buffer[] = [];
		req.on("data", (chunk: Buffer) => chunks.push(chunk));
		req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
		req.on("error", () => resolve(""));
	});
}

function log(...args: unknown[]): void {
	process.stderr.write(`[remote-server] ${args.join(" ")}\n`);
}

// ---------------------------------------------------------------------------
// WebSocket server
// ---------------------------------------------------------------------------

function setupWebSocket(wss: WebSocketServer): void {
	wss.on("connection", (ws: WebSocket, req: http.IncomingMessage) => {
		// Verify PIN for non-local connections
		if (!isLocalRequest(req)) {
			const reqUrl = req.url || "/";
			const queryIndex = reqUrl.indexOf("?");
			const pin = queryIndex >= 0
				? new URLSearchParams(reqUrl.slice(queryIndex)).get("pin") || ""
				: "";
			if (pin !== state?.pin || Date.now() >= (state?.pinExpiresAt || 0)) {
				ws.close(4001, "Invalid or expired PIN");
				return;
			}
		}

		log("WebSocket client connected");

		// Send initial connection event
		ws.send(JSON.stringify({ type: "connected" }));

		// Subscribe to event bus
		const unsubscribe = eventBus.subscribe((event: BusEvent) => {
			if (ws.readyState === WebSocket.OPEN) {
				try {
					ws.send(JSON.stringify(event));
				} catch {
					unsubscribe();
				}
			}
		});

		// Receive commands from client — enqueue for main loop
		ws.on("message", (data: Buffer) => {
			try {
				const msg = JSON.parse(data.toString());
				log("WebSocket command: %s (id=%s)", msg.type, msg.id || "-");

				// Tag and enqueue for the main loop
				msg._origin = "remote";
				const queued = commandQueue.enqueue(msg);
				if (!queued) {
					ws.send(JSON.stringify({
						type: "error",
						id: msg.id || "unknown",
						message: "Command queue full",
					}));
				}
			} catch {
				log("Invalid WebSocket message");
				ws.send(JSON.stringify({
					type: "error",
					id: "parse",
					message: "Invalid JSON",
				}));
			}
		});

		ws.on("close", () => {
			unsubscribe();
			log("WebSocket client disconnected");
		});

		ws.on("error", (err: Error) => {
			log("WebSocket error: %s", err.message);
			unsubscribe();
		});
	});
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
 * Start the remote access HTTP/WS server.
 *
 * @param zosmaDir - Path to the zosma config directory
 * @param config - Server configuration (port, host)
 */
export function startRemoteServer(
	zosmaDir: string,
	config: RemoteServerConfig = { port: 8765, host: "127.0.0.1" },
): void {
	if (state) {
		log("Remote server already running on %s:%d", state.config.host, state.config.port);
		return;
	}

	const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
		handleRequest(req, res, zosmaDir).catch((err: Error) => {
			log("Request error: %s", err.message);
			if (!res.headersSent) {
				writeJson(res, 500, { error: "Internal server error" });
			}
		});
	});

	const wss = new WebSocketServer({ server, path: "/ws" });
	setupWebSocket(wss);

	// Generate initial PIN
	const pin = generatePin();
	const pinExpiresAt = Date.now() + 120_000; // 2 minutes

	server.listen(config.port, config.host, () => {
		state = { server, wss, config, pin, pinExpiresAt };
		log("Remote server listening on %s:%d (PIN: %s)", config.host, config.port, pin);

		// Emit remote server ready event via event bus (goes to Tauri + WebSocket clients)
		eventBus.publish({
			type: "event",
			data: {
				type: "event",
				event: {
					kind: "remote_server_ready",
					port: config.port,
					host: config.host,
					pin,
				},
			},
		});
	});

	server.on("error", (err: Error) => {
		log("Remote server error: %s", err.message);
		state = null;
	});
}

/**
 * Stop the remote access server.
 */
export function stopRemoteServer(): void {
	if (!state) {
		log("Remote server not running");
		return;
	}

	log("Stopping remote server on %s:%d", state.config.host, state.config.port);

	// Close all WebSocket connections
	for (const client of state.wss.clients) {
		client.close(1001, "Server shutting down");
	}

	// Close all SSE connections
	for (const sseRes of sseClients) {
		try {
			sseRes.end();
		} catch {
			// ignore
		}
	}
	sseClients.clear();

	state.wss.close();
	state.server.close();

	state = null;

	eventBus.publish({
		type: "event",
		data: {
			type: "event",
			event: { kind: "remote_server_stopped" },
		},
	});
}

/**
 * Get all non-internal IPv4 addresses of this machine.
 */
function getLocalIPs(): string[] {
	const interfaces = os.networkInterfaces();
	const ips: string[] = [];
	for (const addrs of Object.values(interfaces)) {
		if (!addrs) continue;
		for (const addr of addrs) {
			if (addr.family === "IPv4" && !addr.internal) {
				ips.push(addr.address);
			}
		}
	}
	return ips;
}

/**
 * Get the current remote server status.
 */
export function getRemoteStatus(): {
	running: boolean;
	port?: number;
	host?: string;
	connectedClients?: number;
	pin?: string;
	localIPs?: string[];
} {
	if (!state) {
		return { running: false };
	}
	return {
		running: true,
		port: state.config.port,
		host: state.config.host,
		// Count both WebSocket AND SSE connections
		connectedClients: state.wss.clients.size + sseClients.size,
		pin: state.pin,
		localIPs: getLocalIPs(),
	};
}
