/**
 * SearchPanel — cross-channel message search rendered to the right of
 * the chat column (replacing the members list) whenever the channel
 * header's search input has a non-empty query. Pulls the latest N
 * messages from every non-DM channel via `api.messages.searchScan`
 * in one round-trip, decrypts them client-side using the per-channel
 * key bundles we already hold, and does a case-insensitive substring
 * match against the decrypted plaintext.
 *
 * Results are grouped by channel (with a `# channel-name` header),
 * and each result row renders the author / timestamp / content plus
 * any attachments (via `EncryptedAttachment`) and link embeds (via
 * `LinkEmbed`) — matching the Brycord reference layout so messages
 * read the same in search as they do in the timeline.
 *
 * Clicking a result dispatches `brycord:scroll-to-message` so the
 * Messages component can jump to the corresponding message in its
 * channel. Navigation to a different channel is the caller's
 * responsibility (the same event already pops open the right view).
 */
import { useQuery } from 'convex/react';
import { Hash, MagnifyingGlass, X } from '@phosphor-icons/react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import {
	EncryptedAttachment,
	type AttachmentMetadata,
} from './EncryptedAttachment';
import { LinkEmbed } from './LinkEmbed';
import styles from './SearchPanel.module.css';

interface SearchPanelProps {
	channelId: string;
	query: string;
	onClose: () => void;
}

const TAG_LENGTH = 32;
const PER_CHANNEL_LIMIT = 100;
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;

interface SearchResult {
	id: string;
	channelId: string;
	authorName: string;
	authorAvatarUrl: string | null;
	content: string;
	attachments: AttachmentMetadata[];
	timestamp: number;
}

interface ChannelGroup {
	channelId: string;
	channelName: string;
	results: SearchResult[];
}

function stripFilters(raw: string): string {
	return raw
		.split(/\s+/)
		.filter(
			(tok) =>
				!/^(from:|mentions:|has:|before:|during:|after:|pinned:)/i.test(tok),
		)
		.join(' ')
		.trim();
}

function extractUrls(text: string): string[] {
	const matches = text.match(URL_REGEX) ?? [];
	const cleaned = matches.map((m) => m.replace(/[),.;!?]+$/, ''));
	return Array.from(new Set(cleaned));
}

function highlight(text: string, needle: string): React.ReactNode {
	if (!needle) return text;
	const lower = text.toLowerCase();
	const target = needle.toLowerCase();
	const out: React.ReactNode[] = [];
	let cursor = 0;
	let idx = lower.indexOf(target, cursor);
	let key = 0;
	while (idx !== -1) {
		if (idx > cursor) out.push(text.slice(cursor, idx));
		out.push(
			<mark key={`h-${key++}`} className={styles.highlight}>
				{text.slice(idx, idx + needle.length)}
			</mark>,
		);
		cursor = idx + needle.length;
		idx = lower.indexOf(target, cursor);
	}
	if (cursor < text.length) out.push(text.slice(cursor));
	return out;
}

function formatTimestamp(ms: number): string {
	const date = new Date(ms);
	const now = new Date();
	const isToday = date.toDateString() === now.toDateString();
	const yesterday = new Date(now);
	yesterday.setDate(yesterday.getDate() - 1);
	const isYesterday = date.toDateString() === yesterday.toDateString();
	const time = date.toLocaleTimeString([], {
		hour: 'numeric',
		minute: '2-digit',
	});
	if (isToday) return `Today at ${time}`;
	if (isYesterday) return `Yesterday at ${time}`;
	return `${date.toLocaleDateString()} ${time}`;
}

/**
 * Parse a decrypted message blob and split it into plaintext + a
 * (possibly empty) list of attachments. Mirrors the exact shape
 * handling used by `Messages.tsx`, so attachments in search results
 * render the same way they do in the live timeline.
 */
function parseDecrypted(raw: string): {
	text: string;
	attachments: AttachmentMetadata[];
} {
	if (!raw) return { text: '', attachments: [] };
	let text = raw;
	const attachments: AttachmentMetadata[] = [];
	try {
		const parsed = JSON.parse(raw);
		if (parsed && typeof parsed === 'object') {
			if (Array.isArray(parsed)) {
				for (const item of parsed) {
					if (
						item?.type === 'attachment' &&
						item.url &&
						item.key &&
						item.iv
					) {
						attachments.push(item as AttachmentMetadata);
					}
				}
				text = '';
			} else if (
				parsed.type === 'attachment' &&
				parsed.url &&
				parsed.key &&
				parsed.iv
			) {
				attachments.push(parsed as AttachmentMetadata);
				text = '';
			} else if (parsed.text !== undefined) {
				text = String(parsed.text);
			}
		}
	} catch {
		/* plain string */
	}
	return { text, attachments };
}

export function SearchPanel({ channelId, query, onClose }: SearchPanelProps) {
	const { crypto } = usePlatform();
	const navigate = useNavigate();
	const needle = useMemo(() => stripFilters(query).toLowerCase(), [query]);

	const userId =
		typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
	const privateKeyPem =
		typeof sessionStorage !== 'undefined'
			? sessionStorage.getItem('privateKey')
			: null;

	// --- Channel metadata -----------------------------------------
	const allChannels = useQuery(api.channels.list, {}) ?? [];
	const textChannels = useMemo(
		() => allChannels.filter((c) => c.type === 'text'),
		[allChannels],
	);
	const channelNameById = useMemo(() => {
		const map = new Map<string, string>();
		for (const c of textChannels) map.set(c._id, c.name);
		return map;
	}, [textChannels]);

	// --- Channel key decryption -----------------------------------
	const allKeys = useQuery(
		api.channelKeys.getKeysForUser,
		userId ? { userId: userId as any } : 'skip',
	);
	const [keyByChannel, setKeyByChannel] = useState<Map<string, string>>(
		new Map(),
	);

	useEffect(() => {
		let cancelled = false;
		if (!allKeys || !privateKeyPem) {
			setKeyByChannel(new Map());
			return;
		}
		(async () => {
			const merged: Record<string, string> = {};
			for (const item of allKeys) {
				try {
					const bundleJson = await crypto.privateDecrypt(
						privateKeyPem,
						item.encrypted_key_bundle,
					);
					Object.assign(merged, JSON.parse(bundleJson));
				} catch (err) {
					console.error(
						`Failed to decrypt key bundle for ${item.channel_id}`,
						err,
					);
				}
			}
			if (cancelled) return;
			setKeyByChannel(new Map(Object.entries(merged)));
		})();
		return () => {
			cancelled = true;
		};
	}, [allKeys, privateKeyPem, crypto]);

	// --- Multi-channel message fetch ------------------------------
	const channelIdList = useMemo(
		() => textChannels.map((c) => c._id),
		[textChannels],
	);
	const scan = useQuery(
		api.messages.searchScan,
		channelIdList.length > 0
			? {
					channelIds: channelIdList as any,
					perChannelLimit: PER_CHANNEL_LIMIT,
					userId: (userId as any) ?? undefined,
				}
			: 'skip',
	) as Array<{ channelId: string; messages: any[] }> | undefined;

	// --- Decrypt ---------------------------------------------------
	// Keyed by message id so decryption is incremental — new scan
	// results only redo the rows we haven't seen yet.
	const [decryptedMap, setDecryptedMap] = useState<Map<string, string>>(
		new Map(),
	);

	useEffect(() => {
		if (!scan || keyByChannel.size === 0) return;
		let cancelled = false;
		(async () => {
			const next = new Map(decryptedMap);
			let changed = false;
			for (const group of scan) {
				const key = keyByChannel.get(group.channelId);
				if (!key) continue;
				for (const msg of group.messages) {
					const id = msg.id as string;
					if (next.has(id)) continue;
					if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
						next.set(id, '');
						changed = true;
						continue;
					}
					const tag = msg.ciphertext.slice(-TAG_LENGTH);
					const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH);
					try {
						const plaintext = await crypto.decryptData(
							contentHex,
							key,
							msg.nonce,
							tag,
						);
						if (cancelled) return;
						next.set(id, plaintext);
						changed = true;
					} catch {
						next.set(id, '');
						changed = true;
					}
				}
			}
			if (changed && !cancelled) setDecryptedMap(next);
		})();
		return () => {
			cancelled = true;
		};
		// decryptedMap intentionally excluded — we mutate a copy
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [scan, keyByChannel, crypto]);

	// --- Filter + shape results -----------------------------------
	const grouped: ChannelGroup[] = useMemo(() => {
		if (!scan || !needle) return [];
		const groups: ChannelGroup[] = [];
		for (const group of scan) {
			const channelName = channelNameById.get(group.channelId) ?? 'channel';
			const hits: SearchResult[] = [];
			for (const msg of group.messages) {
				const id = msg.id as string;
				const raw = decryptedMap.get(id);
				if (!raw) continue;
				const { text, attachments } = parseDecrypted(raw);
				// Match against the visible text OR an attachment filename so
				// queries like "screenshot.png" still surface the message.
				const filenameHit = attachments.some((a) =>
					a.filename?.toLowerCase().includes(needle),
				);
				if (!text.toLowerCase().includes(needle) && !filenameHit) continue;
				hits.push({
					id,
					channelId: group.channelId,
					authorName: msg.displayName || msg.username || 'User',
					authorAvatarUrl: msg.avatarUrl ?? null,
					content: text,
					attachments,
					timestamp: msg.created_at
						? new Date(msg.created_at).getTime()
						: Date.now(),
				});
			}
			if (hits.length > 0) {
				hits.sort((a, b) => b.timestamp - a.timestamp);
				groups.push({ channelId: group.channelId, channelName, results: hits });
			}
		}
		// Channel containing the most matches first; within a channel,
		// most-recent first (already sorted above).
		groups.sort((a, b) => b.results.length - a.results.length);
		return groups;
	}, [scan, decryptedMap, needle, channelNameById]);

	const totalResults = useMemo(
		() => grouped.reduce((acc, g) => acc + g.results.length, 0),
		[grouped],
	);

	const handleJump = (targetChannelId: string, messageId: string) => {
		// Server channels all live under the single `home` route in
		// this app (convex is single-server). If the hit is in a
		// different channel, navigate there first, then dispatch the
		// scroll-to event so Messages.tsx can find and scroll into view.
		if (targetChannelId !== channelId) {
			navigate(`/channels/home/${targetChannelId}`);
		}
		window.dispatchEvent(
			new CustomEvent('brycord:scroll-to-message', {
				detail: { messageId, channelId: targetChannelId },
			}),
		);
	};

	const isLoading =
		!scan || (keyByChannel.size === 0 && (allKeys?.length ?? 0) > 0);

	return (
		<div className={styles.panel}>
			<div className={styles.header}>
				<h2 className={styles.headerTitle}>
					{needle && !isLoading
						? `${totalResults} ${totalResults === 1 ? 'Result' : 'Results'}`
						: 'Search'}
				</h2>
				<button
					type="button"
					onClick={onClose}
					aria-label="Close search"
					className={styles.closeButton}
				>
					<X size={18} weight="bold" />
				</button>
			</div>

			<div className={styles.resultsList}>
				{!needle ? (
					<div className={styles.emptyState}>
						<MagnifyingGlass size={40} className={styles.emptyIcon} />
						<p className={styles.emptyTitle}>Search Messages</p>
						<p className={styles.emptyDescription}>
							Type in the search bar above to find messages.
						</p>
					</div>
				) : isLoading ? (
					<div className={styles.emptyState}>
						<MagnifyingGlass size={40} className={styles.emptyIcon} />
						<p className={styles.emptyTitle}>Searching…</p>
						<p className={styles.emptyDescription}>
							Scanning your channels.
						</p>
					</div>
				) : grouped.length === 0 ? (
					<div className={styles.emptyState}>
						<MagnifyingGlass size={40} className={styles.emptyIcon} />
						<p className={styles.emptyTitle}>No Results</p>
						<p className={styles.emptyDescription}>
							Try a different search term.
						</p>
					</div>
				) : (
					grouped.map((group) => (
						<div key={group.channelId} className={styles.channelGroup}>
							<div className={styles.channelGroupHeader}>
								<Hash
									size={16}
									weight="bold"
									className={styles.channelGroupIcon}
								/>
								<span className={styles.channelGroupName}>
									{group.channelName}
								</span>
								<span className={styles.channelGroupCount}>
									{group.results.length}{' '}
									{group.results.length === 1 ? 'result' : 'results'}
								</span>
							</div>
							{group.results.map((r) => {
								const urls = r.content ? extractUrls(r.content).slice(0, 2) : [];
								return (
									<div
										key={r.id}
										role="button"
										tabIndex={0}
										className={styles.resultItem}
										onClick={() => handleJump(r.channelId, r.id)}
										onKeyDown={(e) => {
											if (e.key === 'Enter' || e.key === ' ') {
												e.preventDefault();
												handleJump(r.channelId, r.id);
											}
										}}
									>
										<div className={styles.resultAvatar}>
											<Avatar
												src={r.authorAvatarUrl ?? undefined}
												size={40}
												fallback={r.authorName}
											/>
										</div>
										<div className={styles.resultBody}>
											<div className={styles.resultHeader}>
												<span className={styles.resultAuthor}>
													{r.authorName}
												</span>
												<span className={styles.resultTimestamp}>
													{formatTimestamp(r.timestamp)}
												</span>
												<button
													type="button"
													className={styles.jumpButton}
													onClick={(e) => {
														e.stopPropagation();
														handleJump(r.channelId, r.id);
													}}
												>
													Jump
												</button>
											</div>
											{r.content && (
												<div className={styles.resultContent}>
													{highlight(r.content, needle)}
												</div>
											)}
											{urls.length > 0 && (
												<div className={styles.resultEmbeds}>
													{urls.map((url) => (
														<LinkEmbed key={url} url={url} />
													))}
												</div>
											)}
											{r.attachments.length > 0 && (
												<div className={styles.resultAttachments}>
													{r.attachments.map((att, j) => (
														<EncryptedAttachment
															key={`${r.id}-${j}`}
															metadata={att}
														/>
													))}
												</div>
											)}
										</div>
									</div>
								);
							})}
						</div>
					))
				)}
			</div>
		</div>
	);
}
