/**
 * FileTree Component
 *
 * Adapted from bolt.new by StackBlitz
 * Copyright (c) 2024 StackBlitz, Inc.
 * Licensed under the MIT License
 *
 * Original source: https://github.com/stackblitz/bolt.new
 */

import {
	ChevronDown,
	ChevronRight,
	File as FileIcon,
	Folder as FolderIcon,
	FolderOpen,
	Loader2,
	MoreHorizontal,
} from "lucide-react";
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "~/client/lib/utils";
import { Checkbox } from "~/client/components/ui/checkbox";
import { ByteSize } from "~/client/components/bytes-size";

const NODE_PADDING_LEFT = 12;

export interface FileEntry {
	name: string;
	path: string;
	type: string;
	size?: number;
	modifiedAt?: number;
}

interface PaginationState {
	hasMore: boolean;
	isLoadingMore: boolean;
}

interface Props {
	files?: FileEntry[];
	selectedFile?: string;
	onFileSelect?: (filePath: string) => void;
	onFolderExpand?: (folderPath: string) => void;
	onFolderHover?: (folderPath: string) => void;
	onLoadMore?: (folderPath: string) => void;
	getFolderPagination?: (folderPath: string) => PaginationState;
	expandedFolders?: Set<string>;
	loadingFolders?: Set<string>;
	className?: string;
	withCheckboxes?: boolean;
	selectedPaths?: Set<string>;
	onSelectionChange?: (selectedPaths: Set<string>) => void;
	foldersOnly?: boolean;
	selectableFolders?: boolean;
	onFolderSelect?: (folderPath: string) => void;
	selectedFolder?: string;
}

export const FileTree = memo((props: Props) => {
	const {
		files = [],
		onFileSelect,
		selectedFile,
		onFolderExpand,
		onFolderHover,
		onLoadMore,
		getFolderPagination,
		expandedFolders = new Set(),
		loadingFolders = new Set(),
		className,
		withCheckboxes = false,
		selectedPaths = new Set(),
		onSelectionChange,
		foldersOnly = false,
		selectableFolders = false,
		onFolderSelect,
		selectedFolder,
	} = props;

	const fileList = useMemo(() => {
		return buildFileList(files, foldersOnly);
	}, [files, foldersOnly]);

	const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());

	const filteredFileList = useMemo(() => {
		const list = [];
		let lastDepth = Number.MAX_SAFE_INTEGER;

		for (const fileOrFolder of fileList) {
			const depth = fileOrFolder.depth;

			// if the depth is equal we reached the end of the collapsed group
			if (lastDepth === depth) {
				lastDepth = Number.MAX_SAFE_INTEGER;
			}

			// ignore collapsed folders
			if (collapsedFolders.has(fileOrFolder.fullPath)) {
				lastDepth = Math.min(lastDepth, depth);
			}

			// ignore files and folders below the last collapsed folder
			if (lastDepth < depth) {
				continue;
			}

			list.push(fileOrFolder);
		}

		return list;
	}, [fileList, collapsedFolders]);

	const toggleCollapseState = useCallback(
		(fullPath: string) => {
			const shouldExpand = collapsedFolders.has(fullPath);

			setCollapsedFolders((prevSet) => {
				const newSet = new Set(prevSet);

				if (newSet.has(fullPath)) {
					newSet.delete(fullPath);
				} else {
					newSet.add(fullPath);
				}

				return newSet;
			});

			if (shouldExpand) {
				onFolderExpand?.(fullPath);
			}
		},
		[collapsedFolders, onFolderExpand],
	);

	// Add new folders to collapsed set when file list changes
	useEffect(() => {
		setCollapsedFolders((prevSet) => {
			let hasChanges = false;
			const newSet = new Set(prevSet);
			for (const item of fileList) {
				if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
					newSet.add(item.fullPath);
					hasChanges = true;
				}
			}
			return hasChanges ? newSet : prevSet;
		});
	}, [fileList, expandedFolders]);

	const handleFileSelect = useCallback(
		(filePath: string) => {
			onFileSelect?.(filePath);
		},
		[onFileSelect],
	);

	const handleFolderSelect = useCallback(
		(folderPath: string) => {
			onFolderSelect?.(folderPath);
		},
		[onFolderSelect],
	);

	const handleSelectionChange = useCallback(
		(path: string, checked: boolean) => {
			const newSelection = new Set(selectedPaths);

			if (checked) {
				// Add the path itself
				newSelection.add(path);

				// Remove any descendants from selection since parent now covers them
				for (const selectedPath of newSelection) {
					if (selectedPath.startsWith(`${path}/`)) {
						newSelection.delete(selectedPath);
					}
				}
			} else {
				// Remove the path itself
				newSelection.delete(path);

				// Check if any parent is selected - if so, we need to add siblings back
				const pathSegments = path.split("/").filter(Boolean);
				let parentIsSelected = false;
				let selectedParentPath = "";

				// Check each parent level to see if any are selected
				for (let i = pathSegments.length - 1; i > 0; i--) {
					const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
					if (newSelection.has(parentPath)) {
						parentIsSelected = true;
						selectedParentPath = parentPath;
						break;
					}
				}

				if (parentIsSelected) {
					// Remove the selected parent
					newSelection.delete(selectedParentPath);

					// Add all siblings and descendants of the selected parent, except the unchecked path and its descendants
					for (const item of fileList) {
						if (
							item.fullPath.startsWith(`${selectedParentPath}/`) &&
							!item.fullPath.startsWith(`${path}/`) &&
							item.fullPath !== path &&
							!path.startsWith(`${item.fullPath}/`)
						) {
							newSelection.add(item.fullPath);
						}
					}
				}
			}

			let changed = true;
			while (changed) {
				changed = false;
				const childrenByParent = new Map<string, string[]>();
				for (const selectedPath of newSelection) {
					const lastSlashIndex = selectedPath.lastIndexOf("/");
					if (lastSlashIndex > 0) {
						const parentPath = selectedPath.slice(0, lastSlashIndex);
						if (!childrenByParent.has(parentPath)) {
							childrenByParent.set(parentPath, []);
						}
						childrenByParent.get(parentPath)?.push(selectedPath);
					}
				}

				// For each parent, check if all its children are selected
				for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
					// Get all children of this parent from the file list
					const allChildren = fileList.filter((item) => {
						const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
						return itemParentPath === parentPath;
					});

					// If all children are selected, replace them with the parent
					if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
						// Check that we have every child
						const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
						const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));

						if (allChildrenSelected) {
							// Remove all children
							for (const childPath of selectedChildren) {
								newSelection.delete(childPath);
							}
							// Add the parent
							newSelection.add(parentPath);
							changed = true;
							break;
						}
					}
				}
			}

			onSelectionChange?.(newSelection);
		},
		[selectedPaths, onSelectionChange, fileList],
	);

	// Helper to check if a path is selected (either directly or via parent)
	const isPathSelected = useCallback(
		(path: string): boolean => {
			// Check if directly selected
			if (selectedPaths.has(path)) {
				return true;
			}

			// Check if any parent is selected
			const pathSegments = path.split("/").filter(Boolean);
			for (let i = pathSegments.length - 1; i > 0; i--) {
				const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
				if (selectedPaths.has(parentPath)) {
					return true;
				}
			}

			return false;
		},
		[selectedPaths],
	);

	// Determine if a folder is partially selected (some children selected)
	const isPartiallySelected = useCallback(
		(folderPath: string): boolean => {
			// If the folder itself is selected, it's not partial
			if (selectedPaths.has(folderPath)) {
				return false;
			}

			// Check if this folder is implicitly selected via a parent
			const pathSegments = folderPath.split("/").filter(Boolean);
			for (let i = pathSegments.length - 1; i > 0; i--) {
				const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
				if (selectedPaths.has(parentPath)) {
					// Parent is selected, so this folder is fully selected, not partial
					return false;
				}
			}

			for (const selectedPath of selectedPaths) {
				if (selectedPath.startsWith(`${folderPath}/`)) {
					return true;
				}
			}

			return false;
		},
		[selectedPaths],
	);

	// Build a map of folder paths that need pagination to their last child's index
	const folderPaginationMap = useMemo(() => {
		const map = new Map<string, number>();

		for (let i = 0; i < filteredFileList.length; i++) {
			const item = filteredFileList[i];
			const parentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/")) || "/";
			const pagination = getFolderPagination?.(parentPath);
			if (pagination?.hasMore && !collapsedFolders.has(parentPath)) {
				// Update the last index for this parent
				map.set(parentPath, i);
			}
		}

		return map;
	}, [filteredFileList, getFolderPagination, collapsedFolders]);

	return (
		<div className={cn("text-sm", className)}>
			{filteredFileList.map((fileOrFolder, index) => {
				const elements: React.ReactNode[] = [];

				// Render the current file or folder
				switch (fileOrFolder.kind) {
					case "file": {
						elements.push(
							<File
								key={fileOrFolder.id}
								selected={selectedFile === fileOrFolder.fullPath}
								file={fileOrFolder}
								onFileSelect={handleFileSelect}
								withCheckbox={withCheckboxes}
								checked={isPathSelected(fileOrFolder.fullPath)}
								onCheckboxChange={handleSelectionChange}
							/>,
						);
						break;
					}
					case "folder": {
						elements.push(
							<Folder
								key={fileOrFolder.id}
								folder={fileOrFolder}
								collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
								loading={loadingFolders.has(fileOrFolder.fullPath)}
								onToggle={toggleCollapseState}
								onHover={onFolderHover}
								withCheckbox={withCheckboxes}
								checked={isPathSelected(fileOrFolder.fullPath) && !isPartiallySelected(fileOrFolder.fullPath)}
								partiallyChecked={isPartiallySelected(fileOrFolder.fullPath)}
								onCheckboxChange={handleSelectionChange}
								selectableMode={selectableFolders}
								onFolderSelect={handleFolderSelect}
								selected={selectedFolder === fileOrFolder.fullPath}
							/>,
						);
						break;
					}
				}

				// Check if this is the last child of any folder with more files to load
				for (const [folderPath, lastIndex] of folderPaginationMap.entries()) {
					if (lastIndex === index) {
						// This is the last loaded child of folderPath
						const pagination = getFolderPagination?.(folderPath);
						if (pagination?.hasMore) {
							elements.push(
								<LoadMoreButton
									key={`load-more-${folderPath}`}
									depth={fileOrFolder.depth}
									onClick={() => onLoadMore?.(folderPath)}
									isLoading={pagination.isLoadingMore}
								/>,
							);
						}
					}
				}

				return elements;
			})}
		</div>
	);
});

interface FolderProps {
	folder: FolderNode;
	collapsed: boolean;
	loading?: boolean;
	onToggle: (fullPath: string) => void;
	onHover?: (fullPath: string) => void;
	withCheckbox?: boolean;
	checked?: boolean;
	partiallyChecked?: boolean;
	onCheckboxChange?: (path: string, checked: boolean) => void;
	selectableMode?: boolean;
	onFolderSelect?: (folderPath: string) => void;
	selected?: boolean;
}

const Folder = memo(
	({
		folder,
		collapsed,
		loading,
		onToggle,
		onHover,
		withCheckbox,
		checked,
		partiallyChecked,
		onCheckboxChange,
		selectableMode,
		onFolderSelect,
		selected,
	}: FolderProps) => {
		const { depth, name, fullPath } = folder;
		const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;

		const handleChevronClick = useCallback(
			(e: React.MouseEvent) => {
				e.stopPropagation();
				onToggle(fullPath);
			},
			[onToggle, fullPath],
		);

		const handleMouseEnter = useCallback(() => {
			if (collapsed) {
				onHover?.(fullPath);
			}
		}, [onHover, fullPath, collapsed]);

		const handleCheckboxChange = useCallback(
			(value: boolean) => {
				onCheckboxChange?.(fullPath, value);
			},
			[onCheckboxChange, fullPath],
		);

		const handleFolderClick = useCallback(() => {
			if (selectableMode) {
				onFolderSelect?.(fullPath);
			}
		}, [selectableMode, onFolderSelect, fullPath]);

		return (
			<NodeButton
				className={cn("group", {
					"hover:bg-accent/50 text-foreground": !selected,
					"bg-accent text-accent-foreground": selected,
					"cursor-pointer": selectableMode,
				})}
				depth={depth}
				icon={
					loading ? (
						<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
					) : collapsed ? (
						<ChevronRight className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
					) : (
						<ChevronDown className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
					)
				}
				onClick={selectableMode ? handleFolderClick : undefined}
				onMouseEnter={handleMouseEnter}
			>
				{withCheckbox && (
					<Checkbox
						checked={checked ? true : partiallyChecked ? "indeterminate" : false}
						onCheckedChange={handleCheckboxChange}
						onClick={(e) => e.stopPropagation()}
					/>
				)}
				<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" />
				<span className="truncate">{name}</span>
			</NodeButton>
		);
	},
);

interface FileProps {
	file: FileNode;
	selected: boolean;
	onFileSelect: (filePath: string) => void;
	withCheckbox?: boolean;
	checked?: boolean;
	onCheckboxChange?: (path: string, checked: boolean) => void;
}

const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onCheckboxChange }: FileProps) => {
	const { depth, name, fullPath, size } = file;

	const handleClick = useCallback(() => {
		onFileSelect(fullPath);
	}, [onFileSelect, fullPath]);

	const handleCheckboxChange = useCallback(
		(value: boolean) => {
			onCheckboxChange?.(fullPath, value);
		},
		[onCheckboxChange, fullPath],
	);

	return (
		<NodeButton
			className={cn("group cursor-pointer", {
				"hover:bg-accent/50 text-foreground": !selected,
				"bg-accent text-accent-foreground": selected,
			})}
			depth={depth}
			icon={<FileIcon className="w-4 h-4 shrink-0 text-gray-500" />}
			onClick={handleClick}
		>
			{withCheckbox && (
				<Checkbox checked={checked} onCheckedChange={handleCheckboxChange} onClick={(e) => e.stopPropagation()} />
			)}
			<span className="truncate">{name}</span>
			{typeof size === "number" && (
				<span className="ml-auto shrink-0 text-xs text-muted-foreground">
					<ByteSize bytes={size} base={1024} />
				</span>
			)}
		</NodeButton>
	);
});

interface LoadMoreButtonProps {
	depth: number;
	onClick: () => void;
	isLoading?: boolean;
}

const LoadMoreButton = memo(({ depth, onClick, isLoading }: LoadMoreButtonProps) => {
	return (
		<NodeButton
			depth={depth}
			className="text-muted-foreground hover:bg-accent/50 cursor-pointer"
			icon={
				isLoading ? (
					<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
				) : (
					<MoreHorizontal className="w-4 h-4 shrink-0" />
				)
			}
			onClick={onClick}
		>
			<span className="text-xs">{isLoading ? "Loading more..." : "Load more files"}</span>
		</NodeButton>
	);
});

interface ButtonProps {
	depth: number;
	icon: ReactNode;
	children: ReactNode;
	className?: string;
	onClick?: () => void;
	onMouseEnter?: () => void;
}

const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
	const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);

	const handleKeyDown = useCallback(
		(e: React.KeyboardEvent) => {
			if (e.key === "Enter" || e.key === " ") {
				e.preventDefault();
				onClick?.();
			}
		},
		[onClick],
	);

	return (
		<div
			// oxlint-disable-next-line jsx_a11y/prefer-tag-over-role
			role="button"
			tabIndex={0}
			className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
			style={{ paddingLeft }}
			onClick={onClick}
			onMouseEnter={onMouseEnter}
			onKeyDown={handleKeyDown}
		>
			{icon}
			<div className="truncate w-full flex items-center gap-2">{children}</div>
		</div>
	);
});

type Node = FileNode | FolderNode;

interface BaseNode {
	id: number;
	depth: number;
	name: string;
	fullPath: string;
}

interface FileNode extends BaseNode {
	kind: "file";
	size?: number;
}

interface FolderNode extends BaseNode {
	kind: "folder";
}

function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
	const fileMap = new Map<string, Node>();

	for (const file of files) {
		if (foldersOnly && file.type === "file") {
			continue;
		}

		const segments = file.path.split("/").filter((segment) => segment);
		const depth = segments.length - 1;
		const name = segments[segments.length - 1];

		if (!name) {
			continue;
		}

		if (!fileMap.has(file.path)) {
			const isFile = file.type === "file";
			fileMap.set(file.path, {
				kind: isFile ? "file" : "folder",
				id: fileMap.size,
				name,
				fullPath: file.path,
				depth,
				size: file.size,
			});
		}

		let parentPath = file.path;
		while (true) {
			const lastSlashIndex = parentPath.lastIndexOf("/");
			if (lastSlashIndex <= 0) {
				break;
			}

			parentPath = parentPath.slice(0, lastSlashIndex);
			if (fileMap.has(parentPath)) {
				continue;
			}

			const parentSegments = parentPath.split("/").filter((segment) => segment);
			const parentName = parentSegments[parentSegments.length - 1];
			if (!parentName) {
				continue;
			}

			fileMap.set(parentPath, {
				kind: "folder",
				id: fileMap.size,
				name: parentName,
				fullPath: parentPath,
				depth: parentSegments.length - 1,
			});
		}
	}

	// Convert map to array and sort
	return sortFileList(Array.from(fileMap.values()));
}

function sortFileList(nodeList: Node[]): Node[] {
	const nodeMap = new Map<string, Node>();
	const childrenMap = new Map<string, Node[]>();

	// Pre-sort nodes by name and type
	nodeList.sort((a, b) => compareNodes(a, b));

	for (const node of nodeList) {
		nodeMap.set(node.fullPath, node);

		const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/";

		if (parentPath !== "/") {
			if (!childrenMap.has(parentPath)) {
				childrenMap.set(parentPath, []);
			}
			childrenMap.get(parentPath)?.push(node);
		}
	}

	const sortedList: Node[] = [];

	const depthFirstTraversal = (path: string): void => {
		const node = nodeMap.get(path);

		if (node) {
			sortedList.push(node);
		}

		const children = childrenMap.get(path);

		if (children) {
			for (const child of children) {
				if (child.kind === "folder") {
					depthFirstTraversal(child.fullPath);
				} else {
					sortedList.push(child);
				}
			}
		}
	};

	// Start with root level items
	const rootItems = nodeList.filter((node) => {
		const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/";
		return parentPath === "/";
	});

	for (const item of rootItems) {
		depthFirstTraversal(item.fullPath);
	}

	return sortedList;
}

function compareNodes(a: Node, b: Node): number {
	if (a.kind !== b.kind) {
		return a.kind === "folder" ? -1 : 1;
	}

	if (a.name < b.name) return -1;
	if (a.name > b.name) return 1;
	return 0;
}
