import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  Text,
  Box,
  Icon,
  Icons,
  color,
  config,
  Spinner,
  IconButton,
  Line,
  toRem,
  Button,
  Switch,
  Chip,
} from 'folds';
import { useAtom, useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router';
import { EventTimeline, EventType, Room, SearchOrderBy } from 'matrix-js-sdk';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { _SearchPathSearchParams } from '../../pages/paths';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { SequenceCard } from '../../components/sequence-card';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { ScrollTopContainer } from '../../components/scroll-top-container';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
import { useRooms } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { getStateEvent } from '../../utils/room';
import { StateEvent } from '../../../types/matrix/room';
import {
  filterGroupsByMsgType,
  filterGroupsByPinned,
  MessageSearchParams,
  MsgTypeFilter,
  ResultGroup,
  useMessageSearch,
} from './useMessageSearch';
import { LocalSearchResult, useLocalMessageSearch } from './useLocalMessageSearch';
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
import { clearAll as clearSearchCache } from '../../utils/searchCache';
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { SearchFilters } from './SearchFilters';
import { VirtualTile } from '../../components/virtualizer';

const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
  useMemo(
    () => ({
      global: searchParams.get('global') ?? undefined,
      term: searchParams.get('term') ?? undefined,
      order: searchParams.get('order') ?? undefined,
      rooms: searchParams.get('rooms') ?? undefined,
      senders: searchParams.get('senders') ?? undefined,
      fromTs: searchParams.get('fromTs') ?? undefined,
      toTs: searchParams.get('toTs') ?? undefined,
      containsUrl: searchParams.get('containsUrl') ?? undefined,
    }),
    [searchParams],
  );

type EncryptedRoomCachePanelProps = {
  roomIds: string[];
  onLoaded: () => void;
};
function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelProps) {
  const mx = useMatrixClient();
  const [loadingRooms, setLoadingRooms] = useState<Set<string>>(new Set());

  const encryptedRooms = useMemo(
    () =>
      roomIds
        .map((id) => mx.getRoom(id))
        .filter(
          (room): room is Room =>
            !!room && !!room.currentState.getStateEvents(EventType.RoomEncryption, ''),
        ),
    [mx, roomIds],
  );

  const handleLoad = useCallback(
    async (roomId: string) => {
      const room = mx.getRoom(roomId);
      if (!room) return;
      setLoadingRooms((prev) => new Set([...prev, roomId]));
      try {
        await mx.paginateEventTimeline(room.getLiveTimeline(), {
          backwards: true,
          limit: 100,
        });
        onLoaded();
      } catch {
        // ignore — room may have no more history or be rate-limited
      } finally {
        setLoadingRooms((prev) => {
          const next = new Set(prev);
          next.delete(roomId);
          return next;
        });
      }
    },
    [mx, onLoaded],
  );

  if (encryptedRooms.length === 0) return null;

  return (
    <Box direction="Column" gap="100">
      {encryptedRooms.map((room) => {
        const events = room.getLiveTimeline().getEvents();
        const msgEvents = events.filter((e) => !e.isDecryptionFailure() && !e.isRedacted());
        const oldest = msgEvents.length > 0 ? msgEvents[0] : null;
        const canLoadMore = !!room.getLiveTimeline().getPaginationToken(EventTimeline.BACKWARDS);
        const isLoading = loadingRooms.has(room.roomId);

        return (
          <Box
            key={room.roomId}
            alignItems="Center"
            gap="200"
            style={{
              padding: config.space.S200,
              background: color.SurfaceVariant.Container,
              borderRadius: config.radii.R300,
            }}
          >
            <Icon size="100" src={Icons.Lock} style={{ flexShrink: 0, opacity: 0.6 }} />
            <Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
              <Text size="T300" truncate>
                {room.name}
              </Text>
              <Text size="T200" priority="300">
                {msgEvents.length > 0
                  ? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}`
                  : 'No messages cached yet'}
              </Text>
            </Box>
            {(canLoadMore || events.length === 0) && (
              <Button
                size="300"
                variant="Secondary"
                fill="Soft"
                radii="300"
                onClick={() => handleLoad(room.roomId)}
                disabled={isLoading}
                before={isLoading ? <Spinner size="100" variant="Secondary" /> : undefined}
              >
                <Text size="B300">{events.length === 0 ? 'Load messages' : 'Load more'}</Text>
              </Button>
            )}
            {!canLoadMore && events.length > 0 && (
              <Text size="T200" priority="300" style={{ flexShrink: 0 }}>
                Fully cached
              </Text>
            )}
          </Box>
        );
      })}
    </Box>
  );
}

type MessageSearchProps = {
  defaultRoomsFilterName: string;
  allowGlobal?: boolean;
  rooms: string[];
  senders?: string[];
  scrollRef: RefObject<HTMLDivElement>;
};
export function MessageSearch({
  defaultRoomsFilterName,
  allowGlobal,
  rooms,
  senders,
  scrollRef,
}: MessageSearchProps) {
  const mx = useMatrixClient();
  const mDirects = useAtomValue(mDirectAtom);
  const allRooms = useRooms(mx, allRoomsAtom, mDirects);
  const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
  const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');

  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
  const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');

  const searchInputRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>;
  const scrollTopAnchorRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;

  // Client-side msgtype post-filter. Kept local — the Matrix search API cannot
  // filter by msgtype server-side, so the server request is unaffected.
  const [msgTypeFilters, setMsgTypeFilters] = useState<MsgTypeFilter[]>([]);
  // Client-side "pinned only" post-filter. Narrows displayed results to events
  // currently pinned in their room (`m.room.pinned_events`). Server-unaffected.
  const [pinnedOnly, setPinnedOnly] = useState(false);
  const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);

  const [searchParams, setSearchParams] = useSearchParams();
  const searchPathSearchParams = useSearchPathSearchParams(searchParams);
  const { navigateRoom } = useRoomNavigate();

  const searchParamRooms = useMemo(() => {
    if (searchPathSearchParams.rooms) {
      const joinedRoomIds = decodeSearchParamValueArray(searchPathSearchParams.rooms).filter(
        (rId) => allRooms.includes(rId),
      );
      return joinedRoomIds;
    }
    return undefined;
  }, [allRooms, searchPathSearchParams.rooms]);
  const searchParamsSenders = useMemo(() => {
    if (searchPathSearchParams.senders) {
      return decodeSearchParamValueArray(searchPathSearchParams.senders);
    }
    return undefined;
  }, [searchPathSearchParams.senders]);

  const msgSearchParams: MessageSearchParams = useMemo(() => {
    const isGlobal = searchPathSearchParams.global === 'true';
    const defaultRooms = isGlobal ? undefined : rooms;

    return {
      term: searchPathSearchParams.term,
      order: searchPathSearchParams.order ?? SearchOrderBy.Recent,
      rooms: searchParamRooms ?? defaultRooms,
      senders: searchParamsSenders ?? senders,
      fromTs: searchPathSearchParams.fromTs ? Number(searchPathSearchParams.fromTs) : undefined,
      toTs: searchPathSearchParams.toTs ? Number(searchPathSearchParams.toTs) : undefined,
      containsUrl: searchPathSearchParams.containsUrl === 'true' ? true : undefined,
    };
  }, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);

  const searchMessages = useMessageSearch(msgSearchParams);
  const searchLocalMessages = useLocalMessageSearch();

  // Bump this whenever more messages are loaded so localResult re-computes
  const [cacheVersion, setCacheVersion] = useState(0);
  const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
  // Explicit wipe of the persistent on-disk index, then re-run the merge.
  const handleClearSearchCache = useCallback(() => {
    clearSearchCache().then(() => setCacheVersion((v) => v + 1));
  }, []);

  // The rooms actually in scope for this search (mirrors server-side logic)
  const localSearchRooms = useMemo(
    () => msgSearchParams.rooms ?? (searchPathSearchParams.global === 'true' ? allRooms : rooms),
    [msgSearchParams.rooms, searchPathSearchParams.global, allRooms, rooms],
  );

  // term === undefined  → no search started
  // term === ''         → sender-only search (from:user with no body text)
  // term === 'foo'      → normal text search
  const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
  const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;

  // Run the client-side search whenever inputs change.
  // In text-search mode: covers encrypted rooms only (server handles plaintext).
  // In sender-only mode: covers all rooms (server has no sender-only search).
  // The scan is async because — when the persistent cache is enabled — it also
  // reads cached rows from IndexedDB and merges them with the in-memory hits.
  // cacheVersion in deps so it re-runs after "Load more" paginates new events;
  // searchCacheEnabled so toggling the cache re-runs the merge.
  const [searchCacheEnabled, setSearchCacheEnabled] = useAtom(searchCacheEnabledAtom);
  const [localResult, setLocalResult] = useState<LocalSearchResult | null>(null);
  useEffect(() => {
    if (!hasActiveSearch) {
      setLocalResult(null);
      return undefined;
    }
    let cancelled = false;
    searchLocalMessages({
      term: msgSearchParams.term ?? '',
      roomIds: localSearchRooms,
      senders: msgSearchParams.senders,
      fromTs: msgSearchParams.fromTs,
      toTs: msgSearchParams.toTs,
    }).then((result) => {
      if (!cancelled) setLocalResult(result);
    });
    return () => {
      cancelled = true;
    };
  }, [
    searchLocalMessages,
    localSearchRooms,
    msgSearchParams.term,
    msgSearchParams.senders,
    msgSearchParams.fromTs,
    msgSearchParams.toTs,
    hasActiveSearch,
    cacheVersion,
    searchCacheEnabled,
  ]);

  const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    enabled: !!msgSearchParams.term,
    queryKey: [
      'search',
      msgSearchParams.term,
      msgSearchParams.order,
      msgSearchParams.rooms,
      msgSearchParams.senders,
      msgSearchParams.fromTs,
      msgSearchParams.toTs,
    ],
    queryFn: ({ pageParam }) => searchMessages(pageParam),
    initialPageParam: '',
    getNextPageParam: (lastPage) => lastPage.nextToken,
  });

  // Shared client-side post-filter (msgtype + pinned) applied to BOTH the
  // server results and the local/encrypted-cache results, so the filter chips
  // narrow the whole UI consistently rather than only the server section.
  const applyResultFilters = useCallback(
    (allGroups: ResultGroup[]): ResultGroup[] => {
      const byMsgType = filterGroupsByMsgType(allGroups, msgTypeFilters);
      if (!pinnedOnly) return byMsgType;
      // Build a per-room pinned-event lookup. Heavy Matrix reads stay here
      // (where `mx` is available); the pure helper only consumes the predicate.
      const pinnedByRoom = new Map<string, Set<string>>();
      const isPinned = (roomId: string, eventId: string): boolean => {
        let pinned = pinnedByRoom.get(roomId);
        if (!pinned) {
          const room = mx.getRoom(roomId);
          const content = room
            ? getStateEvent(
                room,
                StateEvent.RoomPinnedEvents,
              )?.getContent<RoomPinnedEventsEventContent>()
            : undefined;
          pinned = new Set(content?.pinned ?? []);
          pinnedByRoom.set(roomId, pinned);
        }
        return pinned.has(eventId);
      };
      return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned);
    },
    [msgTypeFilters, pinnedOnly, mx],
  );

  const groups = useMemo(() => {
    const allGroups = data?.pages.flatMap((result) => result.groups) ?? [];
    return applyResultFilters(allGroups);
  }, [data, applyResultFilters]);

  const localGroups = useMemo(
    () => (localResult ? applyResultFilters(localResult.groups) : []),
    [localResult, applyResultFilters],
  );
  const highlights = useMemo(() => {
    const mixed = data?.pages.flatMap((result) => result.highlights);
    return Array.from(new Set(mixed));
  }, [data]);

  const virtualizer = useVirtualizer({
    count: groups.length,
    getScrollElement: () => scrollRef.current,
    estimateSize: () => 40,
    overscan: 1,
  });
  const vItems = virtualizer.getVirtualItems();

  const handleSearch = (term: string) => {
    setSearchParams((prevParams) => {
      const newParams = new URLSearchParams(prevParams);
      newParams.delete('term');
      newParams.append('term', term);
      return newParams;
    });
    setRecentSearches((prev) => addRecentSearch(prev, term));
  };

  const handleRecentSearch = (term: string) => {
    if (searchInputRef.current) {
      searchInputRef.current.value = term;
    }
    handleSearch(term);
  };

  const handleToggleMsgTypeFilter = useCallback((msgType: MsgTypeFilter) => {
    setMsgTypeFilters((prev) =>
      prev.includes(msgType) ? prev.filter((t) => t !== msgType) : [...prev, msgType],
    );
  }, []);

  const handleTogglePinnedOnly = useCallback(() => {
    setPinnedOnly((prev) => !prev);
  }, []);

  const handleClearRecentSearches = useCallback(() => {
    setRecentSearches([]);
  }, [setRecentSearches]);
  const handleSearchClear = () => {
    if (searchInputRef.current) {
      searchInputRef.current.value = '';
    }
    setSearchParams((prevParams) => {
      const newParams = new URLSearchParams(prevParams);
      newParams.delete('term');
      return newParams;
    });
  };

  const handleSelectedRoomsChange = (selectedRooms?: string[]) => {
    setSearchParams((prevParams) => {
      const newParams = new URLSearchParams(prevParams);
      newParams.delete('rooms');
      if (selectedRooms && selectedRooms.length > 0) {
        newParams.append('rooms', encodeSearchParamValueArray(selectedRooms));
      }
      return newParams;
    });
  };
  const handleGlobalChange = (global?: boolean) => {
    setSearchParams((prevParams) => {
      const newParams = new URLSearchParams(prevParams);
      newParams.delete('global');
      if (global) {
        newParams.append('global', 'true');
      }
      return newParams;
    });
  };

  const handleOrderChange = (order?: string) => {
    setSearchParams((prevParams) => {
      const newParams = new URLSearchParams(prevParams);
      newParams.delete('order');
      if (order) {
        newParams.append('order', order);
      }
      return newParams;
    });
  };

  const handleSelectedSendersChange = useCallback(
    (newSenders?: string[]) => {
      setSearchParams((prevParams) => {
        const p = new URLSearchParams(prevParams);
        p.delete('senders');
        if (newSenders && newSenders.length > 0) {
          p.append('senders', encodeSearchParamValueArray(newSenders));
        }
        return p;
      });
    },
    [setSearchParams],
  );

  const handleSenderAdd = useCallback(
    (userId: string) => {
      const current = searchParamsSenders ?? [];
      if (current.includes(userId)) return;
      handleSelectedSendersChange([...current, userId]);
    },
    [searchParamsSenders, handleSelectedSendersChange],
  );

  const handleDateRangeChange = useCallback(
    (fromTs?: number, toTs?: number) => {
      setSearchParams((prevParams) => {
        const p = new URLSearchParams(prevParams);
        p.delete('fromTs');
        p.delete('toTs');
        if (fromTs) p.append('fromTs', String(fromTs));
        if (toTs) p.append('toTs', String(toTs));
        return p;
      });
    },
    [setSearchParams],
  );

  const handleContainsUrlChange = useCallback(
    (value?: boolean) => {
      setSearchParams((prevParams) => {
        const p = new URLSearchParams(prevParams);
        p.delete('containsUrl');
        if (value) p.append('containsUrl', 'true');
        return p;
      });
    },
    [setSearchParams],
  );

  const lastVItem = vItems[vItems.length - 1];
  const lastVItemIndex: number | undefined = lastVItem?.index;
  const lastGroupIndex = groups.length - 1;
  useEffect(() => {
    if (
      lastGroupIndex > -1 &&
      lastGroupIndex === lastVItemIndex &&
      !isFetchingNextPage &&
      hasNextPage
    ) {
      fetchNextPage();
    }
  }, [lastVItemIndex, lastGroupIndex, fetchNextPage, isFetchingNextPage, hasNextPage]);

  return (
    <Box direction="Column" gap="700">
      <ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
        <IconButton
          onClick={() => virtualizer.scrollToOffset(0)}
          variant="SurfaceVariant"
          radii="Pill"
          outlined
          size="300"
          aria-label="Scroll to Top"
        >
          <Icon src={Icons.ChevronTop} size="300" />
        </IconButton>
      </ScrollTopContainer>
      <Box ref={scrollTopAnchorRef} direction="Column" gap="300">
        <SearchInput
          active={!!msgSearchParams.term}
          loading={status === 'pending'}
          searchInputRef={searchInputRef}
          onSearch={handleSearch}
          onReset={handleSearchClear}
          onSenderAdd={handleSenderAdd}
          recentSearches={recentSearches}
          onRecentSearch={handleRecentSearch}
          onClearRecentSearches={handleClearRecentSearches}
        />
        <SearchFilters
          defaultRoomsFilterName={defaultRoomsFilterName}
          allowGlobal={allowGlobal}
          roomList={searchPathSearchParams.global === 'true' ? allRooms : rooms}
          selectedRooms={searchParamRooms}
          onSelectedRoomsChange={handleSelectedRoomsChange}
          global={searchPathSearchParams.global === 'true'}
          onGlobalChange={handleGlobalChange}
          order={msgSearchParams.order}
          onOrderChange={handleOrderChange}
          selectedSenders={searchParamsSenders}
          onSelectedSendersChange={handleSelectedSendersChange}
          fromTs={msgSearchParams.fromTs}
          toTs={msgSearchParams.toTs}
          onDateRangeChange={handleDateRangeChange}
          containsUrl={msgSearchParams.containsUrl}
          onContainsUrlChange={handleContainsUrlChange}
          msgTypeFilters={msgTypeFilters}
          onToggleMsgTypeFilter={handleToggleMsgTypeFilter}
          pinnedOnly={pinnedOnly}
          onTogglePinnedOnly={handleTogglePinnedOnly}
        />
      </Box>

      {!hasActiveSearch && (
        <PageHeroEmpty>
          <PageHeroSection>
            <PageHero
              icon={<Icon size="600" src={Icons.Message} />}
              title="Search Messages"
              subTitle="Find helpful messages in your community by searching with related keywords, or type from:@user to see all messages from someone."
            />
          </PageHeroSection>
        </PageHeroEmpty>
      )}

      {hasActiveSearch && !senderOnlyMode && groups.length === 0 && status === 'success' && (
        <Box direction="Column" gap="200">
          <Box
            className={ContainerColor({ variant: 'Warning' })}
            style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
            alignItems="Center"
            gap="200"
          >
            <Icon size="200" src={Icons.Info} />
            <Text>
              No results found for <b>{`"${msgSearchParams.term}"`}</b>
            </Text>
          </Box>
          {localResult &&
            localResult.encryptedRoomsCount > 0 &&
            localResult.groups.length === 0 && (
              <Box
                className={ContainerColor({ variant: 'Surface' })}
                style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
                alignItems="Center"
                gap="200"
              >
                <Icon size="200" src={Icons.Lock} />
                <Text size="T300" priority="300">
                  {`${localResult.encryptedRoomsCount} encrypted room${localResult.encryptedRoomsCount !== 1 ? 's' : ''} in scope — the server cannot search E2EE messages. `}
                  {localResult.searchedRoomsCount > 0
                    ? `No matches found in your locally cached messages from ${localResult.searchedRoomsCount} room${localResult.searchedRoomsCount !== 1 ? 's' : ''}.`
                    : `Open those rooms to cache messages locally, then search again.`}
                </Text>
              </Box>
            )}
        </Box>
      )}

      {((!senderOnlyMode && msgSearchParams.term && status === 'pending') ||
        (groups.length > 0 && vItems.length === 0)) && (
        <Box direction="Column" gap="100">
          {[...Array(8).keys()].map((key) => (
            <SequenceCard variant="SurfaceVariant" key={key} style={{ minHeight: toRem(80) }} />
          ))}
        </Box>
      )}

      {msgSearchParams.term &&
        !senderOnlyMode &&
        localResult &&
        localResult.encryptedRoomsCount > 0 &&
        vItems.length > 0 && (
          <Box
            className={ContainerColor({ variant: 'Surface' })}
            style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
            alignItems="Center"
            gap="200"
          >
            <Icon size="200" src={Icons.Lock} />
            <Text size="T300" priority="300">
              {`${localResult.encryptedRoomsCount} encrypted room${localResult.encryptedRoomsCount !== 1 ? 's' : ''} in scope — server results are from unencrypted rooms only. Encrypted room results appear below from your local cache.`}
            </Text>
          </Box>
        )}

      {vItems.length > 0 && (
        <Box direction="Column" gap="300">
          <Box direction="Column" gap="200">
            <Text size="H5">{`Results for "${msgSearchParams.term}"`}</Text>
            <Line size="300" variant="Surface" />
          </Box>
          <div
            style={{
              position: 'relative',
              height: virtualizer.getTotalSize(),
            }}
          >
            {vItems.map((vItem) => {
              const group = groups[vItem.index];
              if (!group) return null;
              const groupRoom = mx.getRoom(group.roomId);
              if (!groupRoom) return null;

              return (
                <VirtualTile
                  virtualItem={vItem}
                  style={{ paddingBottom: config.space.S500 }}
                  ref={virtualizer.measureElement}
                  key={vItem.index}
                >
                  <SearchResultGroup
                    room={groupRoom}
                    highlights={highlights}
                    items={group.items}
                    mediaAutoLoad={mediaAutoLoad}
                    urlPreview={urlPreview}
                    onOpen={navigateRoom}
                    legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
                    hour24Clock={hour24Clock}
                    dateFormatString={dateFormatString}
                  />
                </VirtualTile>
              );
            })}
          </div>
          {isFetchingNextPage && (
            <Box justifyContent="Center" alignItems="Center">
              <Spinner size="600" variant="Secondary" />
            </Box>
          )}
        </Box>
      )}

      {localResult &&
        (senderOnlyMode ? localGroups.length > 0 : localResult.encryptedRoomsCount > 0) && (
          <Box direction="Column" gap="300">
            <Box direction="Column" gap="200">
              <Box alignItems="Center" gap="200">
                <Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
                <Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
                {!senderOnlyMode && (
                  <Text size="T200" priority="300">
                    {`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
                  </Text>
                )}
              </Box>
              <Text size="T300" priority="300">
                {senderOnlyMode
                  ? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.`
                  : localGroups.length > 0
                    ? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
                    : `No matches in your local cache. Load messages below to search further back.`}
              </Text>
              <Box
                alignItems="Center"
                gap="200"
                style={{
                  padding: config.space.S200,
                  background: color.SurfaceVariant.Container,
                  borderRadius: config.radii.R300,
                }}
              >
                <Switch
                  variant="Primary"
                  value={searchCacheEnabled}
                  onChange={setSearchCacheEnabled}
                />
                <Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
                  <Text size="T300">Persist search index on this device</Text>
                  <Text size="T200" priority="300">
                    Stores decrypted text on this device
                  </Text>
                </Box>
                {searchCacheEnabled && (
                  <Chip
                    variant="Secondary"
                    radii="Pill"
                    onClick={handleClearSearchCache}
                    before={<Icon size="100" src={Icons.Delete} />}
                  >
                    <Text size="T200">Clear cached index</Text>
                  </Chip>
                )}
              </Box>
              <Line size="300" variant="Surface" />
            </Box>
            {localGroups.length > 0 && (
              <Box direction="Column" gap="300">
                {localGroups.map((group) => {
                  const groupRoom = mx.getRoom(group.roomId);
                  if (!groupRoom) return null;
                  return (
                    <SearchResultGroup
                      key={group.roomId}
                      room={groupRoom}
                      highlights={[msgSearchParams.term ?? '']}
                      items={group.items}
                      mediaAutoLoad={mediaAutoLoad}
                      urlPreview={urlPreview}
                      onOpen={navigateRoom}
                      legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
                      hour24Clock={hour24Clock}
                      dateFormatString={dateFormatString}
                    />
                  );
                })}
              </Box>
            )}
            <EncryptedRoomCachePanel roomIds={localSearchRooms} onLoaded={handleCacheLoaded} />
          </Box>
        )}

      {error && (
        <Box
          className={ContainerColor({ variant: 'Critical' })}
          style={{
            padding: config.space.S300,
            borderRadius: config.radii.R400,
          }}
          direction="Column"
          gap="200"
        >
          <Text size="L400">{error.name}</Text>
          <Text size="T300">{error.message}</Text>
        </Box>
      )}
    </Box>
  );
}
