"use client";

import { ImagePlus, Loader2, Plus, TerminalSquare, X } from "lucide-react";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useRef, useState } from "react";
import { appendAuthParam, authFetch } from "@/lib/auth";
import { validateImageFile } from "@/lib/image-utils";
import { filterInitialSfHeader } from "@/lib/initial-sf-header-filter";
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url";
import { cn } from "@/lib/utils";
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme";
import "@xterm/xterm/css/xterm.css";

type XTerminal = import("@xterm/xterm").Terminal;
type XFitAddon = import("@xterm/addon-fit").FitAddon;

const MIN_TERMINAL_ATTACH_WIDTH = 180;
const MIN_TERMINAL_ATTACH_HEIGHT = 120;
const MIN_TERMINAL_ATTACH_COLS = 20;
const MIN_TERMINAL_ATTACH_ROWS = 8;

// ─── Types ────────────────────────────────────────────────────────────────────

interface TerminalTab {
	id: string;
	label: string;
	connected: boolean;
}

interface ShellTerminalProps {
	className?: string;
	command?: string;
	commandArgs?: string[];
	sessionPrefix?: string;
	hideSidebar?: boolean;
	fontSize?: number;
	hideInitialSfHeader?: boolean;
	projectCwd?: string;
}

function getRenderableTerminalSize(
	container: HTMLDivElement | null,
	terminal: XTerminal | null,
): { cols: number; rows: number } | null {
	if (!container || !terminal) return null;

	const rect = container.getBoundingClientRect();
	if (
		rect.width < MIN_TERMINAL_ATTACH_WIDTH ||
		rect.height < MIN_TERMINAL_ATTACH_HEIGHT
	) {
		return null;
	}

	if (
		terminal.cols < MIN_TERMINAL_ATTACH_COLS ||
		terminal.rows < MIN_TERMINAL_ATTACH_ROWS
	) {
		return null;
	}

	return { cols: terminal.cols, rows: terminal.rows };
}

async function settleTerminalLayout(
	container: HTMLDivElement | null,
	terminal: XTerminal | null,
	fitAddon: XFitAddon | null,
	isDisposed: () => boolean,
): Promise<{ cols: number; rows: number } | null> {
	if (typeof document !== "undefined" && "fonts" in document) {
		try {
			await Promise.race([
				document.fonts.ready,
				new Promise<void>((resolve) => setTimeout(resolve, 1000)),
			]);
		} catch {
			// Ignore font loading failures and fall through to repeated fit attempts.
		}
	}

	for (let attempt = 0; attempt < 12; attempt++) {
		if (isDisposed()) return null;

		await new Promise<void>((resolve) => {
			requestAnimationFrame(() => resolve());
		});

		if (isDisposed()) return null;

		try {
			fitAddon?.fit();
		} catch {
			/* hidden or detached */
		}

		const size = getRenderableTerminalSize(container, terminal);
		if (size) {
			return size;
		}

		await new Promise((resolve) => setTimeout(resolve, 50));
	}

	return getRenderableTerminalSize(container, terminal);
}

function deriveCommandLabel(command?: string): string {
	if (!command?.trim()) return "zsh";
	const token = command.trim().split(/\s+/)[0] || command;
	const normalized = token.replace(/\\/g, "/");
	const parts = normalized.split("/");
	return parts[parts.length - 1] || token;
}

// ─── Single terminal instance (internal) ──────────────────────────────────────

interface TerminalInstanceProps {
	sessionId: string;
	visible: boolean;
	command?: string;
	commandArgs?: string[];
	isDark: boolean;
	fontSize?: number;
	hideInitialSfHeader?: boolean;
	projectCwd?: string;
	onConnectionChange: (connected: boolean) => void;
}

function TerminalInstance({
	sessionId,
	visible,
	command,
	commandArgs,
	isDark,
	fontSize,
	hideInitialSfHeader = false,
	projectCwd,
	onConnectionChange,
}: TerminalInstanceProps) {
	const containerRef = useRef<HTMLDivElement>(null);
	const termRef = useRef<XTerminal | null>(null);
	const fitAddonRef = useRef<XFitAddon | null>(null);
	const eventSourceRef = useRef<EventSource | null>(null);
	const inputQueueRef = useRef<string[]>([]);
	const flushingRef = useRef(false);
	const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
	const onConnectionChangeRef = useRef(onConnectionChange);
	const initialHeaderSettledRef = useRef(!hideInitialSfHeader);
	const initialHeaderBufferRef = useRef("");
	const _commandArgsKey = (commandArgs ?? []).join("\u0000");
	const [hasOutput, setHasOutput] = useState(false);

	const sendResize = useCallback(
		(cols: number, rows: number) => {
			if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
			resizeTimeoutRef.current = setTimeout(() => {
				void authFetch(buildProjectPath("/api/terminal/resize", projectCwd), {
					method: "POST",
					headers: { "Content-Type": "application/json" },
					body: JSON.stringify({ id: sessionId, cols, rows }),
				});
			}, 100);
		},
		[projectCwd, sessionId],
	);

	const flushInputQueue = useCallback(async () => {
		if (flushingRef.current) return;
		flushingRef.current = true;
		while (inputQueueRef.current.length > 0) {
			const data = inputQueueRef.current.shift()!;
			try {
				await authFetch(buildProjectPath("/api/terminal/input", projectCwd), {
					method: "POST",
					headers: { "Content-Type": "application/json" },
					body: JSON.stringify({ id: sessionId, data }),
				});
			} catch {
				inputQueueRef.current.unshift(data);
				break;
			}
		}
		flushingRef.current = false;
	}, [projectCwd, sessionId]);

	const sendInput = useCallback(
		(data: string) => {
			inputQueueRef.current.push(data);
			void flushInputQueue();
		},
		[flushInputQueue],
	);

	useEffect(() => {
		onConnectionChangeRef.current = onConnectionChange;
	}, [onConnectionChange]);

	useEffect(() => {
		initialHeaderSettledRef.current = !hideInitialSfHeader;
		initialHeaderBufferRef.current = "";
	}, [hideInitialSfHeader]);

	// Update xterm theme when isDark changes
	useEffect(() => {
		if (termRef.current) {
			termRef.current.options.theme = getXtermTheme(isDark);
		}
	}, [isDark]);

	// Update xterm font size when fontSize changes
	useEffect(() => {
		if (termRef.current) {
			termRef.current.options.fontSize = fontSize ?? 13;
			try {
				fitAddonRef.current?.fit();
				if (termRef.current) {
					sendResize(termRef.current.cols, termRef.current.rows);
				}
			} catch {
				/* not visible yet */
			}
		}
	}, [fontSize, sendResize]);

	// Re-fit when visibility changes
	useEffect(() => {
		if (visible && fitAddonRef.current && termRef.current) {
			// Small delay to let the DOM settle
			const t = setTimeout(() => {
				try {
					fitAddonRef.current?.fit();
					if (termRef.current) {
						sendResize(termRef.current.cols, termRef.current.rows);
					}
				} catch {
					/* not visible yet */
				}
			}, 50);
			return () => clearTimeout(t);
		}
	}, [visible, sendResize]);

	useEffect(() => {
		if (!containerRef.current) return;

		let disposed = false;
		let terminal: XTerminal | null = null;
		let fitAddon: XFitAddon | null = null;
		let resizeObserver: ResizeObserver | null = null;

		const init = async () => {
			const [{ Terminal }, { FitAddon }] = await Promise.all([
				import("@xterm/xterm"),
				import("@xterm/addon-fit"),
			]);

			if (disposed) return;

			terminal = new Terminal(getXtermOptions(isDark, fontSize));
			fitAddon = new FitAddon();
			terminal.loadAddon(fitAddon);
			terminal.open(containerRef.current!);

			termRef.current = terminal;
			fitAddonRef.current = fitAddon;

			await settleTerminalLayout(
				containerRef.current,
				terminal,
				fitAddon,
				() => disposed,
			);
			if (disposed) return;

			terminal.onData((data) => sendInput(data));
			terminal.onBinary((data) => sendInput(data));

			// SSE stream
			const streamUrl = buildProjectAbsoluteUrl(
				"/api/terminal/stream",
				window.location.origin,
				projectCwd,
			);
			streamUrl.searchParams.set("id", sessionId);
			if (command) streamUrl.searchParams.set("command", command);
			for (const arg of commandArgs ?? []) {
				streamUrl.searchParams.append("arg", arg);
			}
			const es = new EventSource(appendAuthParam(streamUrl.toString()));
			eventSourceRef.current = es;

			es.onmessage = (event) => {
				try {
					const msg = JSON.parse(event.data) as {
						type: string;
						data?: string;
					};
					if (msg.type === "connected") {
						onConnectionChangeRef.current(true);
						void settleTerminalLayout(
							containerRef.current,
							terminal,
							fitAddon,
							() => disposed,
						).then((size) => {
							if (!size) return;
							sendResize(size.cols, size.rows);
						});
					} else if (msg.type === "output" && msg.data) {
						let output = msg.data;

						if (hideInitialSfHeader && !initialHeaderSettledRef.current) {
							initialHeaderBufferRef.current += output;
							const filtered = filterInitialSfHeader(
								initialHeaderBufferRef.current,
							);

							if (filtered.status === "needs-more") {
								return;
							}

							initialHeaderSettledRef.current = true;
							initialHeaderBufferRef.current = "";
							output = filtered.text;
						}

						if (output) {
							terminal?.write(output);
							setHasOutput(true);
						}
					}
				} catch {
					/* malformed */
				}
			};

			es.onerror = () => onConnectionChangeRef.current(false);

			// Resize observer
			resizeObserver = new ResizeObserver(() => {
				if (disposed) return;
				try {
					fitAddon?.fit();
					if (terminal) sendResize(terminal.cols, terminal.rows);
				} catch {
					/* not visible */
				}
			});
			resizeObserver.observe(containerRef.current!);
		};

		void init();

		return () => {
			disposed = true;
			if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
			eventSourceRef.current?.close();
			eventSourceRef.current = null;
			resizeObserver?.disconnect();
			terminal?.dispose();
			termRef.current = null;
			fitAddonRef.current = null;
		};
	}, [
		sessionId,
		command,
		commandArgs,
		fontSize,
		hideInitialSfHeader,
		isDark,
		projectCwd,
		sendInput,
		sendResize,
	]);

	// Focus on click
	const wrapperRef = useRef<HTMLDivElement>(null);
	const handleClick = useCallback(() => {
		termRef.current?.focus();
	}, []);

	// Shift+Enter → newline (native DOM, capture phase)
	// xterm.js sends \r for both Enter and Shift+Enter. The pi TUI editor
	// recognizes \n (LF) as "insert newline".
	useEffect(() => {
		const el = wrapperRef.current;
		if (!el) return;

		const onKeyDown = (e: KeyboardEvent) => {
			if (
				e.key === "Enter" &&
				e.shiftKey &&
				!e.ctrlKey &&
				!e.altKey &&
				!e.metaKey
			) {
				e.preventDefault();
				e.stopPropagation();
				sendInput("\n");
			}
		};

		el.addEventListener("keydown", onKeyDown, true);
		return () => el.removeEventListener("keydown", onKeyDown, true);
	}, [sendInput]);

	// Auto-focus when this tab becomes visible
	useEffect(() => {
		if (visible) {
			// Small delay to let layout settle
			const t = setTimeout(() => termRef.current?.focus(), 80);
			return () => clearTimeout(t);
		}
	}, [visible]);

	return (
		<div
			ref={wrapperRef}
			className={cn("relative h-full w-full bg-terminal", !visible && "hidden")}
			onClick={handleClick}
		>
			{/* Loading overlay — visible until first output arrives */}
			{!hasOutput && (
				<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-terminal">
					<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
					<span className="text-xs text-muted-foreground">
						{command ? "Starting SF…" : "Connecting…"}
					</span>
				</div>
			)}
			<div
				ref={containerRef}
				className="h-full w-full"
				style={{ padding: "8px 4px 4px 8px" }}
			/>
		</div>
	);
}

// ─── Image upload helpers ─────────────────────────────────────────────────────

const ALLOWED_IMAGE_TYPES = new Set([
	"image/jpeg",
	"image/png",
	"image/gif",
	"image/webp",
]);

/**
 * Upload an image file to the server's temp directory and inject the `@filepath`
 * text into the PTY session's stdin.
 *
 * Observability:
 * - console.warn on client-side validation failure
 * - console.error on upload or inject failure
 */
async function uploadAndInjectImage(
	file: File,
	sessionId: string,
	projectCwd?: string,
): Promise<void> {
	// Client-side validation
	const validation = validateImageFile(file);
	if (!validation.valid) {
		console.warn("[terminal-upload] validation failed:", validation.error);
		return;
	}

	// Upload to temp dir
	const formData = new FormData();
	formData.append("file", file);

	let uploadPath: string;
	try {
		const res = await authFetch(
			buildProjectPath("/api/terminal/upload", projectCwd),
			{
				method: "POST",
				body: formData,
			},
		);
		const data = (await res.json()) as {
			ok?: boolean;
			path?: string;
			error?: string;
		};
		if (!res.ok || !data.path) {
			console.error(
				"[terminal-upload] upload failed:",
				data.error ?? `HTTP ${res.status}`,
			);
			return;
		}
		uploadPath = data.path;
	} catch (err) {
		console.error("[terminal-upload] upload request failed:", err);
		return;
	}

	// Inject @filepath into PTY stdin
	try {
		const res = await authFetch(
			buildProjectPath("/api/terminal/input", projectCwd),
			{
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({ id: sessionId, data: `@${uploadPath} ` }),
			},
		);
		if (!res.ok) {
			const data = (await res.json().catch(() => ({}))) as { error?: string };
			console.error(
				"[terminal-upload] inject failed:",
				data.error ?? `HTTP ${res.status}`,
			);
		}
	} catch (err) {
		console.error("[terminal-upload] inject request failed:", err);
	}
}

// ─── Multi-instance terminal panel ────────────────────────────────────────────

/**
 * Derive a session ID that is scoped to the project path.  This ensures
 * that switching projects creates a separate PTY session per project, and
 * switching back reconnects to the *same* server-side PTY instead of
 * spawning a new one (the server's getOrCreateSession returns the existing
 * live session when the ID matches).
 */
function deriveProjectScopedSessionId(
	projectCwd: string | undefined,
	sessionPrefix?: string,
	command?: string,
): string {
	const base = sessionPrefix ?? (command ? "sf-default" : "default");
	if (!projectCwd) return base;
	return `${base}:${projectCwd}`;
}

export function ShellTerminal({
	className,
	command,
	commandArgs,
	sessionPrefix,
	hideSidebar = false,
	fontSize,
	hideInitialSfHeader = false,
	projectCwd,
}: ShellTerminalProps) {
	const { resolvedTheme } = useTheme();
	const isDark = resolvedTheme !== "light";
	const defaultId = deriveProjectScopedSessionId(
		projectCwd,
		sessionPrefix,
		command,
	);
	const commandLabel = deriveCommandLabel(command);
	const [tabs, setTabs] = useState<TerminalTab[]>([
		{ id: defaultId, label: commandLabel, connected: false },
	]);
	const [activeTabId, setActiveTabId] = useState(defaultId);
	const [isDragOver, setIsDragOver] = useState(false);
	const terminalAreaRef = useRef<HTMLDivElement>(null);

	// When the project changes, the defaultId changes.  Reset tabs so the
	// terminal reconnects to the project-scoped PTY session on the server.
	// The server's getOrCreateSession will return the existing live session
	// when the session ID matches, preserving terminal state.
	const prevDefaultIdRef = useRef(defaultId);
	useEffect(() => {
		if (prevDefaultIdRef.current !== defaultId) {
			prevDefaultIdRef.current = defaultId;
			setTabs([{ id: defaultId, label: commandLabel, connected: false }]);
			setActiveTabId(defaultId);
		}
	}, [defaultId, commandLabel]);

	// ── Drag-and-drop handlers (native DOM, capture phase) ──────────────────
	// React synthetic events don't reliably fire through xterm's internal DOM.
	// Native capture-phase listeners intercept before xterm can swallow them —
	// same pattern used for paste below.

	useEffect(() => {
		const el = terminalAreaRef.current;
		if (!el) return;

		let counter = 0;

		const onDragEnter = (e: DragEvent) => {
			e.preventDefault();
			e.stopPropagation();
			counter += 1;
			if (counter === 1) setIsDragOver(true);
		};

		const onDragOver = (e: DragEvent) => {
			e.preventDefault();
			e.stopPropagation();
		};

		const onDragLeave = (e: DragEvent) => {
			e.preventDefault();
			e.stopPropagation();
			counter -= 1;
			if (counter <= 0) {
				counter = 0;
				setIsDragOver(false);
			}
		};

		const onDrop = (e: DragEvent) => {
			e.preventDefault();
			e.stopPropagation();
			counter = 0;
			setIsDragOver(false);

			if (!activeTabId) return;
			const files = Array.from(e.dataTransfer?.files ?? []);
			const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type));
			if (imageFile) {
				void uploadAndInjectImage(imageFile, activeTabId, projectCwd);
			}
		};

		el.addEventListener("dragenter", onDragEnter, true);
		el.addEventListener("dragover", onDragOver, true);
		el.addEventListener("dragleave", onDragLeave, true);
		el.addEventListener("drop", onDrop, true);
		return () => {
			el.removeEventListener("dragenter", onDragEnter, true);
			el.removeEventListener("dragover", onDragOver, true);
			el.removeEventListener("dragleave", onDragLeave, true);
			el.removeEventListener("drop", onDrop, true);
		};
	}, [activeTabId, projectCwd]);

	// ── Paste handler for images ──────────────────────────────────────────────

	useEffect(() => {
		const el = terminalAreaRef.current;
		if (!el) return;

		const handlePaste = (e: ClipboardEvent) => {
			if (!e.clipboardData) return;
			const files = Array.from(e.clipboardData.files);
			const imageFile = files.find((f) => ALLOWED_IMAGE_TYPES.has(f.type));
			if (imageFile) {
				e.preventDefault();
				e.stopPropagation();
				if (activeTabId) {
					void uploadAndInjectImage(imageFile, activeTabId, projectCwd);
				}
			}
			// If no image files, don't prevent default — let xterm.js handle text paste
		};

		el.addEventListener("paste", handlePaste, true); // capture phase to fire before xterm
		return () => el.removeEventListener("paste", handlePaste, true);
	}, [activeTabId, projectCwd]);

	const createTab = useCallback(async () => {
		try {
			const res = await authFetch(
				buildProjectPath("/api/terminal/sessions", projectCwd),
				{
					method: "POST",
					headers: { "Content-Type": "application/json" },
					body: JSON.stringify(command ? { command } : {}),
				},
			);
			const data = (await res.json()) as { id: string };
			const newTab: TerminalTab = {
				id: data.id,
				label: commandLabel,
				connected: false,
			};
			setTabs((prev) => [...prev, newTab]);
			setActiveTabId(data.id);
		} catch {
			/* network error */
		}
	}, [command, commandLabel, projectCwd]);

	const closeTab = useCallback(
		(id: string) => {
			// Don't close the last tab
			if (tabs.length <= 1) return;
			const deleteUrl = buildProjectAbsoluteUrl(
				"/api/terminal/sessions",
				window.location.origin,
				projectCwd,
			);
			deleteUrl.searchParams.set("id", id);
			void authFetch(deleteUrl.toString(), {
				method: "DELETE",
			});
			const remaining = tabs.filter((t) => t.id !== id);
			setTabs(remaining);
			if (activeTabId === id) {
				setActiveTabId(remaining[remaining.length - 1]?.id ?? defaultId);
			}
		},
		[tabs, activeTabId, defaultId, projectCwd],
	);

	const updateConnection = useCallback((id: string, connected: boolean) => {
		setTabs((prev) => prev.map((t) => (t.id === id ? { ...t, connected } : t)));
	}, []);

	return (
		<div className={cn("flex bg-terminal", className)}>
			{/* Terminal area — receives drag/drop and paste for images */}
			<div ref={terminalAreaRef} className="relative flex-1 min-w-0">
				{tabs.map((tab) => (
					<TerminalInstance
						key={tab.id}
						sessionId={tab.id}
						visible={tab.id === activeTabId}
						command={command}
						commandArgs={tab.id === defaultId ? commandArgs : undefined}
						isDark={isDark}
						fontSize={fontSize}
						hideInitialSfHeader={hideInitialSfHeader}
						projectCwd={projectCwd}
						onConnectionChange={(c) => updateConnection(tab.id, c)}
					/>
				))}

				{/* Drop overlay */}
				{isDragOver && (
					<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-background backdrop-blur-sm border-2 border-dashed border-primary rounded-md pointer-events-none">
						<ImagePlus className="h-8 w-8 text-primary" />
						<span className="text-sm font-medium text-primary">
							Drop image here
						</span>
					</div>
				)}
			</div>

			{!hideSidebar && (
				<div className="flex w-[34px] flex-shrink-0 flex-col border-l border-border/50 bg-terminal">
					{/* New terminal button */}
					<button
						type="button"
						onClick={createTab}
						className="flex h-[30px] w-full items-center justify-center text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
						title="New terminal"
					>
						<Plus className="h-3 w-3" />
					</button>

					<div className="h-px bg-border/50" />

					{/* Tab list */}
					<div className="flex-1 overflow-y-auto">
						{tabs.map((tab, index) => (
							<button
								type="button"
								key={tab.id}
								onClick={() => setActiveTabId(tab.id)}
								className={cn(
									"group relative flex h-[30px] w-full items-center justify-center transition-colors",
									tab.id === activeTabId
										? "bg-accent text-accent-foreground"
										: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
								)}
								title={`${tab.label} ${index + 1}`}
							>
								{/* Active indicator bar */}
								{tab.id === activeTabId && (
									<div className="absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-full bg-muted-foreground" />
								)}

								<div className="relative flex items-center">
									<TerminalSquare className="h-3 w-3" />
									{/* Connection dot */}
									<span
										className={cn(
											"absolute -bottom-0.5 -right-0.5 h-1.5 w-1.5 rounded-full border border-terminal",
											tab.connected ? "bg-success" : "bg-muted-foreground/40",
										)}
									/>
								</div>

								{/* Close button — shows on hover as small badge in corner */}
								{tabs.length > 1 && (
									<button
										type="button"
										onClick={(e) => {
											e.stopPropagation();
											closeTab(tab.id);
										}}
										className="absolute -right-0.5 -top-0.5 z-10 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-accent text-muted-foreground hover:bg-destructive/20 hover:text-destructive group-hover:flex"
										title="Kill terminal"
									>
										<X className="h-2 w-2" />
									</button>
								)}
							</button>
						))}
					</div>
				</div>
			)}
		</div>
	);
}
