import clsx from 'clsx';
import hljs from 'highlight.js';
import { For, Show, createSignal, createMemo, createEffect, onCleanup, onMount, type Component, type JSX } from 'solid-js';
import {
	ChevronRight,
	Circle,
	CircleCheck,
	Columns2,
	FileText,
	FoldVertical,
	Folder,
	PanelLeftClose,
	PanelLeftOpen,
	PanelRightClose,
	PanelRightOpen,
	UnfoldVertical,
} from 'lucide-solid';

const splitCodeLines = (value: string): string[] => {
	const normalized = value.endsWith('\n') ? value.slice(0, -1) : value;
	return normalized.length === 0 ? [''] : normalized.split('\n');
};

const escapeHtml = (value: string) =>
	value
		.replace(/&/g, '&amp;')
		.replace(/</g, '&lt;')
		.replace(/>/g, '&gt;')
		.replace(/"/g, '&quot;')
		.replace(/'/g, '&#39;');

const customExtensionLanguages: Record<string, string> = {
	cjs: 'javascript',
	mjs: 'javascript',
	cts: 'typescript',
	mts: 'typescript',
	conf: 'ini',
	env: 'ini',
};

const filenameLanguages: Record<string, string> = {
	dockerfile: 'bash',
	gemfile: 'ruby',
	makefile: 'makefile',
	rakefile: 'ruby',
};

const languageFromPath = (path?: string) => {
	if (!path) return undefined;

	const filename = path.split('/').pop()?.toLowerCase() ?? '';
	if (filenameLanguages[filename]) return filenameLanguages[filename];

	const extension = filename.includes('.') ? filename.split('.').pop() : filename;
	if (!extension) return undefined;

	// Check if highlight.js registers this extension directly or as a built-in alias
	if (hljs.getLanguage(extension)) {
		return extension;
	}

	return customExtensionLanguages[extension] ?? extension;
};

const resolveHighlightLanguage = (language?: string, path?: string) => {
	const candidate = (language ?? languageFromPath(path))?.toLowerCase();
	if (!candidate || candidate === 'plaintext' || candidate === 'text') return undefined;
	return hljs.getLanguage(candidate) ? candidate : undefined;
};

const closeOpenSpans = (count: number) => '</span>'.repeat(count);

const splitHighlightedHtmlLines = (html: string): string[] => {
	const lineParts = html.split('\n');
	const activeTags: string[] = [];

	return lineParts.map((linePart) => {
		const prefix = activeTags.join('');

		for (const tag of linePart.match(/<\/?span\b[^>]*>/g) ?? []) {
			if (tag.startsWith('</')) {
				activeTags.pop();
			} else {
				activeTags.push(tag);
			}
		}

		return `${prefix}${linePart}${closeOpenSpans(activeTags.length)}`;
	});
};

const highlightCodeLines = (text: string, language?: string): string[] => {
	const normalized = text.endsWith('\n') ? text.slice(0, -1) : text;
	const fallback = () => splitCodeLines(normalized).map(escapeHtml);

	if (!language) return fallback();

	try {
		const highlighted = hljs.highlight(normalized, { language, ignoreIllegals: true }).value;
		return splitHighlightedHtmlLines(highlighted);
	} catch {
		return fallback();
	}
};

const diffMetadataLine = (line: string) =>
	line.startsWith('diff --git ') ||
	line.startsWith('index ') ||
	line.startsWith('---') ||
	line.startsWith('+++') ||
	line.startsWith('@@') ||
	line.startsWith('Binary files ') ||
	line.startsWith('new file mode ') ||
	line.startsWith('deleted file mode ') ||
	line.startsWith('old mode ') ||
	line.startsWith('new mode ') ||
	line.startsWith('similarity index ') ||
	line.startsWith('dissimilarity index ') ||
	line.startsWith('rename from ') ||
	line.startsWith('rename to ');

const highlightDiffLine = (line: string, language?: string) => {
	if (diffMetadataLine(line)) return escapeHtml(line);

	const marker = line[0];
	const isChangedLine = marker === '+' || marker === '-' || marker === ' ';
	if (!isChangedLine) return escapeHtml(line);

	const content = line.slice(1);
	const highlighted = highlightCodeLines(content, language)[0] ?? escapeHtml(content);
	return `<span class="untangled-code-diff-op">${escapeHtml(marker)}</span>${highlighted}`;
};

/** Parse a URL hash like #L5 or #L3-L8 into {start, end} (1-based, inclusive). */
const parseLineHash = (hash: string): { start: number; end: number } | null => {
	const m = /^#L(\d+)(?:-L?(\d+))?$/.exec(hash);
	if (!m) return null;
	const start = parseInt(m[1], 10);
	const end = m[2] ? parseInt(m[2], 10) : start;
	if (isNaN(start) || isNaN(end)) return null;
	return { start: Math.min(start, end), end: Math.max(start, end) };
};

export const CodeView: Component<{
	text: string;
	id?: string;
	wrap?: boolean;
	maxHeight?: string;
	diff?: boolean;
	path?: string;
	language?: string;
}> = (props) => {
	const lines = createMemo(() => splitCodeLines(props.text));
	const highlightedLines = createMemo(() => {
		const language = resolveHighlightLanguage(props.language, props.path);
		if (props.diff) return lines().map((line) => highlightDiffLine(line, language));
		return highlightCodeLines(props.text, language);
	});
	const lineClass = (line: string) =>
		props.diff
			? clsx(
					line.startsWith('+') && !line.startsWith('+++') && 'untangled-code-line-add',
					line.startsWith('-') && !line.startsWith('---') && 'untangled-code-line-del',
					line.startsWith('@@') && 'untangled-code-line-hunk',
				)
			: undefined;

	// Line selection state (only used in non-diff mode)
	const [selection, setSelection] = createSignal<{ start: number; end: number } | null>(null);
	// The line number of the last plain click (anchor for shift-click ranges)
	let anchorLine: number | null = null;

	const applyHashSelection = (scroll = false) => {
		const parsed = parseLineHash(window.location.hash);
		setSelection(parsed);
		if (scroll && parsed) {
			requestAnimationFrame(() => {
				const target = document.getElementById(`L${parsed.start}`);
				target?.scrollIntoView({ behavior: 'instant', block: 'center' });
			});
		}
	};

	const handleLineNumberClick = (lineNum: number, event: MouseEvent) => {
		event.preventDefault();
		let hash: string;
		if (event.shiftKey && anchorLine !== null) {
			const lo = Math.min(anchorLine, lineNum);
			const hi = Math.max(anchorLine, lineNum);
			hash = `#L${lo}-L${hi}`;
		} else {
			anchorLine = lineNum;
			hash = `#L${lineNum}`;
		}
		history.pushState(null, '', hash);
		setSelection(parseLineHash(hash));
	};

	onMount(() => {
		if (props.diff) return;

		applyHashSelection(true);

		const onHashChange = () => applyHashSelection(false);
		window.addEventListener('hashchange', onHashChange);
		window.addEventListener('popstate', onHashChange);
		onCleanup(() => {
			window.removeEventListener('hashchange', onHashChange);
			window.removeEventListener('popstate', onHashChange);
		});
	});

	const isLineHighlighted = (lineNum: number) => {
		const s = selection();
		if (!s) return false;
		return lineNum >= s.start && lineNum <= s.end;
	};

	return (
		<div class="untangled-code-scroll" style={{ 'max-height': props.maxHeight }}>
			<div
				id={props.id}
				class={clsx('untangled-code-view', props.wrap && 'untangled-code-view-wrap')}
			>
				<For each={lines()}>
					{(line, index) => {
						const lineNum = index() + 1;
						return (
							<div class={clsx('untangled-code-line', lineClass(line), !props.diff && isLineHighlighted(lineNum) && 'untangled-code-line--hl')}>
								<a
									id={`L${lineNum}`}
									href={`#L${lineNum}`}
									class="untangled-code-line-number"
									onClick={props.diff ? undefined : (e) => handleLineNumberClick(lineNum, e)}
								>
									{lineNum}
								</a>
								<span class="untangled-code-line-content" innerHTML={highlightedLines()[index()] || ' '} />
							</div>
						);
					}}
				</For>
			</div>
		</div>
	);
};

interface DiffFile {
	path: string;
	text: string;
	additions: number;
	deletions: number;
}

const parseDiffFiles = (patch: string): DiffFile[] => {
	const files: DiffFile[] = [];
	let current: { path: string; lines: string[]; additions: number; deletions: number } | null = null;

	const pushCurrent = () => {
		if (!current) return;
		const hasRenderableDiff = current.lines.some(
			(line) =>
				line.startsWith('@@') ||
				line.startsWith('Binary files ') ||
				line.startsWith('new file mode ') ||
				line.startsWith('deleted file mode ') ||
				line.startsWith('old mode ') ||
				line.startsWith('similarity index ') ||
				line.startsWith('rename from '),
		);
		if (!hasRenderableDiff && current.additions === 0 && current.deletions === 0) {
			current = null;
			return;
		}
		files.push({
			path: current.path,
			text: current.lines.join('\n'),
			additions: current.additions,
			deletions: current.deletions,
		});
	};

	for (const line of splitCodeLines(patch)) {
		if (line.startsWith('diff --git ')) {
			pushCurrent();
			const match = /^diff --git a\/(.*?) b\/(.*)$/.exec(line);
			current = {
				path: match?.[2] ?? line.replace('diff --git ', ''),
				lines: [line],
				additions: 0,
				deletions: 0,
			};
			continue;
		}

		if (!current) {
			current = { path: 'patch', lines: [], additions: 0, deletions: 0 };
		}

		if (line.startsWith('+') && !line.startsWith('+++')) current.additions += 1;
		if (line.startsWith('-') && !line.startsWith('---')) current.deletions += 1;
		current.lines.push(line);
	}

	pushCurrent();
	return files;
};

const changedFilesLabel = (count: number) => `${count} changed file${count === 1 ? '' : 's'}`;

const DiffToolbarStats: Component<{ additions: number; deletions: number; fileCount: number; class?: string }> = (props) => (
	<>
		<DiffStatPill additions={props.additions} deletions={props.deletions} />
		<span class={props.class ?? 'text-xs text-gray-600 dark:text-gray-300'}>{changedFilesLabel(props.fileCount)}</span>
	</>
);

const DiffCollapseButton: Component<{ expanded: boolean; onToggle: () => void }> = (props) => (
	<button type="button" class="untangled-diff-tool-button" onClick={props.onToggle}>
		<Show when={props.expanded} fallback={<UnfoldVertical class="size-4" />}>
			<FoldVertical class="size-4" />
		</Show>
		<span class="hidden md:inline">{props.expanded ? 'collapse all' : 'expand all'}</span>
	</button>
);

const totalDiffStats = (files: DiffFile[]) =>
	files.reduce(
		(acc, file) => ({
			additions: acc.additions + file.additions,
			deletions: acc.deletions + file.deletions,
		}),
		{ additions: 0, deletions: 0 },
	);

type DiffFileTreeNode = {
	name: string;
	path: string;
	file?: DiffFile;
	children?: DiffFileTreeNode[];
};

const sortDiffFileTree = (nodes: DiffFileTreeNode[]): DiffFileTreeNode[] =>
	nodes
		.sort((a, b) => a.name.localeCompare(b.name))
		.map((node) => ({
			...node,
			children: node.children ? sortDiffFileTree(node.children) : undefined,
		}));

const diffFileTree = (files: DiffFile[]) => {
	const roots: DiffFileTreeNode[] = [];

	for (const file of files) {
		const parts = file.path.split('/').filter(Boolean);
		const pathParts = parts.length > 0 ? parts : [file.path || 'patch'];
		let siblings = roots;
		let path = '';

		for (const [index, part] of pathParts.entries()) {
			path = path ? `${path}/${part}` : part;
			const isFile = index === pathParts.length - 1;

			if (isFile) {
				siblings.push({ name: part, path: file.path, file });
				continue;
			}

			let node = siblings.find((child) => !child.file && child.name === part);
			if (!node) {
				node = { name: part, path, children: [] };
				siblings.push(node);
			}

			siblings = node.children!;
		}
	}

	return sortDiffFileTree(roots);
};

const diffFileElementId = (path: string) =>
	`diff-${Array.from(path, (char) => char.codePointAt(0)!.toString(16).padStart(4, '0')).join('-')}`;

const scrollToDiffFile = (path: string, event: MouseEvent) => {
	if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;

	const target = document.getElementById(diffFileElementId(path));
	if (!target) return;

	event.preventDefault();
	target.scrollIntoView({ behavior: 'smooth', block: 'start' });

	const hash = `#${target.id}`;
	if (window.location.hash !== hash) {
		window.history.pushState(null, '', `${window.location.pathname}${window.location.search}${hash}`);
	}
};

const DiffFileTreeNodes: Component<{
	nodes: DiffFileTreeNode[];
	reviewedFiles?: () => Set<string>;
}> = (props) => (
	<div class="untangled-pr-file-tree-list">
		<For each={props.nodes}>
			{(node) => <DiffFileTreeNodeView node={node} reviewedFiles={props.reviewedFiles} />}
		</For>
	</div>
);

const DiffFileTreeNodeView: Component<{
	node: DiffFileTreeNode;
	reviewedFiles?: () => Set<string>;
}> = (props) => (
	<div class="untangled-pr-file-tree-node">
		<Show
			when={props.node.file}
			fallback={
				<>
					<div class="untangled-pr-file-tree-entry" title={props.node.path}>
						<Folder class="size-4 shrink-0" />
						<span class="truncate">{props.node.name}</span>
					</div>
					<Show when={props.node.children?.length}>
						<div class="untangled-pr-file-tree-children">
							<DiffFileTreeNodes nodes={props.node.children!} reviewedFiles={props.reviewedFiles} />
						</div>
					</Show>
				</>
			}
		>
			{(file) => {
				const isReviewed = () => props.reviewedFiles?.().has(file().path) ?? false;
				return (
					<a
						href={`#${diffFileElementId(file().path)}`}
						class="untangled-pr-file-tree-entry untangled-pr-file-tree-link"
						title={file().path}
						onClick={(event) => scrollToDiffFile(file().path, event)}
					>
						<FileText class="size-4 shrink-0" />
						<span class="truncate">{props.node.name}</span>
						<Show when={isReviewed()}>
							<span class="review-indicator text-green-600 dark:text-green-400 flex-shrink-0 ml-1">
								✓
							</span>
						</Show>
					</a>
				);
			}}
		</Show>
	</div>
);

const DiffStatPill: Component<{ additions: number; deletions: number }> = (props) => (
	<div class="untangled-diff-stat-pill">
		<Show when={props.additions > 0}>
			<span class="untangled-diff-stat-add">+{props.additions}</span>
		</Show>
		<Show when={props.deletions > 0}>
			<span class="untangled-diff-stat-del">-{props.deletions}</span>
		</Show>
	</div>
);

const SidebarToggleButton: Component<{
	visible: boolean;
	onToggle: () => void;
	class?: string;
	ariaLabelCollapse: string;
	ariaLabelExpand: string;
	panel?: 'left' | 'right';
}> = (props) => (
	<button
		type="button"
		class={props.class}
		aria-label={props.visible ? props.ariaLabelCollapse : props.ariaLabelExpand}
		aria-pressed={props.visible}
		onClick={props.onToggle}
	>
		<Show when={props.visible} fallback={
			props.panel === 'right' ? <PanelRightOpen class="size-4" /> : <PanelLeftOpen class="size-4" />
		}>
			{props.panel === 'right' ? <PanelRightClose class="size-4" /> : <PanelLeftClose class="size-4" />}
		</Show>
	</button>
);

const DiffFileEntry: Component<{
	file: DiffFile;
	open: boolean;
	onToggle?: (open: boolean) => void;
	isReviewed?: boolean;
	onToggleReview?: () => void;
	wrap?: boolean;
	maxHeight?: string;
	className?: string;
	headerClassName?: string;
	chevronClassName?: string;
	pathClassName?: string;
	showEllipsis?: boolean;
}> = (props) => (
	<details
		id={diffFileElementId(props.file.path)}
		class={clsx(props.className, props.isReviewed && 'opacity-60')}
		open={props.open}
		onToggle={props.onToggle ? (e) => props.onToggle?.(e.currentTarget.open) : undefined}
	>
		<summary class={props.headerClassName}>
			<div class="flex min-w-0 items-center gap-2">
				<ChevronRight class={props.chevronClassName} />
				<DiffStatPill additions={props.file.additions} deletions={props.file.deletions} />
				<span class={clsx('truncate', props.pathClassName)}>{props.file.path}</span>
			</div>
			<Show when={props.onToggleReview}>
				<label
					class="flex shrink-0 items-center gap-1 text-xs text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer"
					title={props.isReviewed ? 'Mark as unreviewed' : 'Mark as reviewed'}
					onClick={(e) => {
						e.stopPropagation();
					}}
				>
					<input
						type="checkbox"
						class="sr-only"
						checked={props.isReviewed}
						onChange={props.onToggleReview}
					/>
					<Show
						when={props.isReviewed}
						fallback={<Circle class="size-4" />}
					>
						<CircleCheck class="size-4 text-green-600 dark:text-green-400" />
					</Show>
					<span class="hidden md:inline">reviewed</span>
				</label>
			</Show>
		</summary>
		<Show when={props.showEllipsis}>
			<div class="untangled-pr-diff-ellipsis">...</div>
		</Show>
		<CodeView text={props.file.text} wrap={props.wrap} maxHeight={props.maxHeight} diff path={props.file.path} />
	</details>
);

export const DiffView: Component<{
	patch: string;
	maxHeight?: string;
	wrap?: boolean;
	defaultExpanded?: boolean;
	showFileTree?: boolean;
	variant?: 'default' | 'commit';
}> = (props) => {
	const files = createMemo(() => parseDiffFiles(props.patch));
	const stats = createMemo(() => totalDiffStats(files()));
	const [expanded, setExpanded] = createSignal(props.defaultExpanded ?? false);
	const [filesVisible, setFilesVisible] = createSignal(true);
	const previewLimit = 5;
	const showFileTree = createMemo(() => Boolean(props.showFileTree && files().length > 0));
	const tree = createMemo(() => diffFileTree(files()));
	const visibleFiles = createMemo(() => (showFileTree() ? files() : files().slice(0, previewLimit)));
	const hiddenFiles = createMemo(() => (showFileTree() ? [] : files().slice(previewLimit)));

	return (
		<div class={clsx('untangled-diff-view', props.variant === 'commit' && 'untangled-diff-view-commit')}>
			<div class="untangled-diff-panel">
				<div class="untangled-diff-toolbar">
					<div class="flex min-w-0 items-center gap-2">
						<Show when={showFileTree()}>
							<SidebarToggleButton
								visible={filesVisible()}
								onToggle={() => setFilesVisible(!filesVisible())}
								class="untangled-diff-sidebar-toggle"
								ariaLabelCollapse="collapse changed files sidebar"
								ariaLabelExpand="expand changed files sidebar"
							/>
						</Show>
						<DiffToolbarStats additions={stats().additions} deletions={stats().deletions} fileCount={files().length} />
					</div>
					<div class="flex items-center gap-2">
						<DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} />
						<div class="untangled-diff-mode-group">
							<button type="button" class="untangled-diff-mode-button untangled-diff-mode-button-active">
								<FileText class="w-4 h-4" />
								unified
							</button>
							<button type="button" class="untangled-diff-mode-button" disabled title="Split diff is not available yet">
								<Columns2 class="w-4 h-4" />
								split
							</button>
						</div>
					</div>
				</div>
				<div class={clsx('untangled-diff-panel-body', showFileTree() && filesVisible() && 'untangled-diff-panel-body-with-tree')}>
					<Show when={showFileTree() && filesVisible()}>
						<aside class="untangled-pr-file-tree" aria-label="changed files">
							<DiffFileTreeNodes nodes={tree()} />
						</aside>
					</Show>

					<div class={showFileTree() ? 'untangled-pr-diff-files' : 'untangled-diff-files'}>
						<For each={visibleFiles()}>
							{(file) => (
								<DiffFileEntry
									file={file}
									open={expanded()}
									wrap={props.wrap}
									maxHeight={props.maxHeight}
									className="untangled-diff-file"
									headerClassName="untangled-diff-file-header"
									chevronClassName="untangled-diff-chevron size-4 shrink-0"
									pathClassName="font-mono text-sm text-gray-900 dark:text-gray-100"
								/>
							)}
						</For>
						<Show when={hiddenFiles().length > 0}>
							<details class="untangled-diff-show-more">
								<summary class="untangled-diff-show-more-summary">
									<span class="untangled-diff-show-more-closed">
										<ChevronRight class="untangled-diff-chevron size-4 shrink-0" />
										Show {hiddenFiles().length} more file{hiddenFiles().length === 1 ? '' : 's'}
									</span>
									<span class="untangled-diff-show-more-open">
										<ChevronRight class="untangled-diff-chevron size-4 shrink-0" />
										Hide {hiddenFiles().length} file{hiddenFiles().length === 1 ? '' : 's'}
									</span>
								</summary>
								<div class="untangled-diff-show-more-files">
									<For each={hiddenFiles()}>
										{(file) => (
											<DiffFileEntry
												file={file}
												open={expanded()}
												wrap={props.wrap}
												maxHeight={props.maxHeight}
												className="untangled-diff-file"
												headerClassName="untangled-diff-file-header"
												chevronClassName="untangled-diff-chevron size-4 shrink-0"
												pathClassName="font-mono text-sm text-gray-900 dark:text-gray-100"
											/>
										)}
									</For>
								</div>
							</details>
						</Show>
						<Show when={files().length === 0}>
							<div class="px-4 py-8 text-center text-sm text-gray-400">No differences found.</div>
						</Show>
					</div>
				</div>
			</div>
		</div>
	);
};

export const PullDiffView: Component<{
	patch: string;
	roundLabel: string;
	storageKey: string;
	history: JSX.Element;
	mobileDrawerOpen?: boolean;
	onMobileDrawerClose?: () => void;
}> = (props) => {
	const files = createMemo(() => parseDiffFiles(props.patch));
	const stats = createMemo(() => totalDiffStats(files()));
	const tree = createMemo(() => diffFileTree(files()));
	const [expanded, setExpanded] = createSignal(true);
	const [filesVisible, setFilesVisible] = createSignal(true);
	const [historyVisible, setHistoryVisible] = createSignal(true);

	const [isDesktop, setIsDesktop] = createSignal(window.matchMedia('(min-width: 768px)').matches);
	onMount(() => {
		const media = window.matchMedia('(min-width: 768px)');
		const listener = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
		media.addEventListener('change', listener);
		onCleanup(() => media.removeEventListener('change', listener));
	});

	createEffect(() => {
		if (!isDesktop() && props.mobileDrawerOpen) {
			document.body.style.overflow = 'hidden';
		} else {
			document.body.style.overflow = '';
		}
	});

	onCleanup(() => {
		document.body.style.overflow = '';
	});

	const loadReviewed = (): Set<string> => {
		try {
			const data = localStorage.getItem(props.storageKey);
			if (!data) return new Set();
			const entry = JSON.parse(data);
			return new Set(Array.isArray(entry) ? entry : (entry.files || []));
		} catch {
			return new Set();
		}
	};

	const [reviewed, setReviewed] = createSignal<Set<string>>(new Set());

	createEffect(() => {
		setReviewed(loadReviewed());
	});

	const saveReviewed = (newSet: Set<string>) => {
		const currentFilePaths = new Set(files().map((f) => f.path));
		const filesToSave = Array.from(newSet).filter((path) => currentFilePaths.has(path));
		try {
			localStorage.setItem(
				props.storageKey,
				JSON.stringify({
					files: filesToSave,
					ts: Date.now(),
				}),
			);
		} catch (e) {
			console.error('Failed to save reviewed files:', e);
		}
	};

	const pruneStale = () => {
		const now = Date.now();
		const REVIEWED_PREFIX = 'reviewed:';
		const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
		for (let i = 0; i < localStorage.length; i++) {
			const key = localStorage.key(i);
			if (key && key.startsWith(REVIEWED_PREFIX) && key !== props.storageKey) {
				try {
					const entry = JSON.parse(localStorage.getItem(key) || '{}');
					if (!entry.ts || now - entry.ts > MAX_AGE_MS) {
						localStorage.removeItem(key);
					}
				} catch {
					localStorage.removeItem(key);
				}
			}
		}
	};

	createEffect(() => {
		if (Math.random() < 0.1) {
			pruneStale();
		}
	});

	const [localOpen, setLocalOpen] = createSignal<Record<string, boolean>>({});
	const [hasSetInitial, setHasSetInitial] = createSignal(false);

	createEffect(() => {
		if (files().length > 0) {
			const isExpanded = expanded();
			const next: Record<string, boolean> = {};
			for (const f of files()) {
				if (!hasSetInitial() && reviewed().has(f.path)) {
					next[f.path] = false;
				} else {
					next[f.path] = isExpanded;
				}
			}
			setLocalOpen(next);
			setHasSetInitial(true);
		}
	});

	const toggleReview = (path: string) => {
		const nextReviewed = new Set(reviewed());
		if (nextReviewed.has(path)) {
			nextReviewed.delete(path);
		} else {
			nextReviewed.add(path);
			setLocalOpen((prev) => ({ ...prev, [path]: false }));
		}
		setReviewed(nextReviewed);
		saveReviewed(nextReviewed);
	};

	return (
		<section class="untangled-pr-diff-breakout">
			{/* backdrop overlay - only visible on mobile when open */}
			<div
				id="bottomSheetBackdrop"
				class={clsx(
					"fixed inset-0 bg-black/50 md:hidden opacity-0 pointer-events-none transition-opacity duration-300 z-40",
					props.mobileDrawerOpen && "opacity-100 pointer-events-auto"
				)}
				onClick={() => props.onMobileDrawerClose?.()}
			/>
			<div
				class={clsx(
					'untangled-pr-diff-toolbar',
					!filesVisible() && 'untangled-pr-diff-toolbar-sidebar-collapsed',
					!historyVisible() && 'untangled-pr-diff-toolbar-history-collapsed',
				)}
			>
				<div class="untangled-pr-diff-toolbar-main">
					<SidebarToggleButton
						visible={filesVisible()}
						onToggle={() => setFilesVisible(!filesVisible())}
						class="untangled-pr-sidebar-toggle"
						ariaLabelCollapse="collapse changed files sidebar"
						ariaLabelExpand="expand changed files sidebar"
					/>
					<DiffStatPill additions={stats().additions} deletions={stats().deletions} />
					<span
						class={clsx(
							'text-xs font-medium transition-colors duration-150 hidden sm:inline-block',
							reviewed().size === files().length && files().length > 0
								? 'text-green-600 dark:text-green-400'
								: 'text-gray-600 dark:text-gray-400'
						)}
					>
						{reviewed().size > 0
							? `${reviewed().size}/${files().length} file${files().length === 1 ? '' : 's'} reviewed`
							: `${files().length} changed file${files().length === 1 ? '' : 's'}`
						}
					</span>
					<span class="hidden h-5 w-px bg-gray-300 dark:bg-gray-700 sm:inline-block" />
					<span class="uppercase tracking-wide hidden sm:inline-block">diff</span>
					<span class="rounded border border-gray-300 px-2 py-0.5 font-mono text-xs text-gray-700 dark:border-gray-600 dark:text-gray-200">
						{props.roundLabel}
					</span>
				</div>
				<div class="untangled-pr-diff-toolbar-actions flex items-center gap-2">
					<DiffCollapseButton expanded={expanded()} onToggle={() => setExpanded(!expanded())} />
					<div class="untangled-diff-mode-group">
						<button type="button" class="untangled-diff-mode-button untangled-diff-mode-button-active">
							<FileText class="w-4 h-4" />
							<span class="hidden md:inline">unified</span>
						</button>
						<button type="button" class="untangled-diff-mode-button" disabled title="Split diff is not available yet">
							<Columns2 class="w-4 h-4" />
							<span class="hidden md:inline">split</span>
						</button>
					</div>
				</div>
				<div class="untangled-pr-diff-toolbar-history">
					<SidebarToggleButton
						visible={historyVisible()}
						onToggle={() => setHistoryVisible(!historyVisible())}
						class="untangled-pr-sidebar-toggle"
						ariaLabelCollapse="collapse history panel"
						ariaLabelExpand="expand history panel"
						panel="right"
					/>
				</div>
			</div>

			<div
				class={clsx(
					'untangled-pr-diff-grid',
					!filesVisible() && 'untangled-pr-diff-grid-sidebar-collapsed',
					!historyVisible() && 'untangled-pr-diff-grid-history-collapsed',
				)}
			>
				<Show when={filesVisible()}>
					<aside class="untangled-pr-file-tree" aria-label="changed files">
						<DiffFileTreeNodes nodes={tree()} reviewedFiles={reviewed} />
					</aside>
				</Show>

				<div class="untangled-pr-diff-files">
					<For each={files()}>
						{(file) => (
							<DiffFileEntry
								file={file}
								open={Boolean(localOpen()[file.path])}
								onToggle={(open) => {
									setLocalOpen((prev) => ({ ...prev, [file.path]: open }));
								}}
								isReviewed={reviewed().has(file.path)}
								onToggleReview={() => toggleReview(file.path)}
								className="untangled-pr-diff-file"
								headerClassName="untangled-pr-diff-file-header"
								chevronClassName="untangled-pr-diff-chevron size-4 shrink-0"
								pathClassName="text-base text-gray-100"
								showEllipsis
							/>
						)}
					</For>
				</div>

				<Show when={historyVisible()}>
					<aside
						class={clsx(
							"untangled-pr-history",
							"fixed bottom-0 left-0 right-0 z-50 w-full md:static md:z-auto md:max-h-screen md:sticky md:top-12 overflow-hidden",
							props.mobileDrawerOpen && "open"
						)}
					>
						{props.history}
					</aside>
				</Show>
			</div>
		</section>
	);
};
