import {
  ChangeEventHandler,
  KeyboardEventHandler,
  MouseEventHandler,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { isKeyHotkey } from 'is-hotkey';
import { MatrixError, Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '../../state/hooks/roomList';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { useListFocusIndex } from '../../hooks/useListFocusIndex';
import {
  SearchItemStrGetter,
  useAsyncSearch,
  UseAsyncSearchOptions,
} from '../../hooks/useAsyncSearch';
import { mDirectAtom } from '../../state/mDirectList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { factoryRoomIdByActivity } from '../../utils/sort';
import {
  getDMRoomFor,
  getMxIdLocalPart,
  guessDmRoomUserId,
  parseUserAddress,
} from '../../utils/matrix';
import { makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { useDebounce } from '../../hooks/useDebounce';
import { useAlive } from '../../hooks/useAlive';
import { useCreateDirect } from '../create-chat/useCreateDirect';
import { ErrorCode } from '../../cs-errorcode';

export enum SearchRoomType {
  Rooms = '#',
  Spaces = '*',
  Directs = '@',
}

const PREFIX_TO_TYPE: Record<string, SearchRoomType | undefined> = {
  '#': SearchRoomType.Rooms,
  '*': SearchRoomType.Spaces,
  '@': SearchRoomType.Directs,
};

const SEARCH_OPTIONS: UseAsyncSearchOptions = {
  matchOptions: { contain: true },
  normalizeOptions: { ignoreWhitespace: false },
};

// User-directory search tuning. The directory query goes over the
// network (POST /_matrix/client/v3/user_directory/search), so debounce
// it and gate it behind a minimum length to avoid hammering the server
// (and tripping its M_LIMIT_EXCEEDED rate limit) on every keystroke. An
// exact MXID bypasses the minimum-length gate — the user clearly means
// that person, so we always resolve it (and fall back to a profile
// lookup so they're reachable even when not indexed in the directory).
const DIRECTORY_LIMIT = 20;
const DIRECTORY_MIN_CHARS = 2;
const DIRECTORY_DEBOUNCE_MS = 300;

// A person surfaced from the homeserver user directory. Clicking one
// opens-or-creates a DM.
export type RoomSearchUser = {
  userId: string;
  displayName?: string;
  avatarUrl?: string;
};

// An inline "message this address" card for an explicitly typed full
// `@name:server`. The profile lookup is the only existence signal Matrix
// has, so the three states map to its outcome (verified against the
// Matrix spec `profile.yaml` + Synapse: 200 = the account exists even
// with an empty `{}` profile; a clean 404 = no such account):
//  • `resolved`    — 200 → the account exists → messageable.
//  • `unreachable` — the lookup failed for a NON-404 reason (server down /
//                    federation off / restricted) → we genuinely can't
//                    tell, so we still let the user try (never false-block).
//  • `notfound`    — a clean 404 M_NOT_FOUND → no such account → NOT
//                    messageable; rendered as a non-clickable note so we
//                    can't create a chat (and a ghost room) for someone
//                    who doesn't exist.
// We NEVER fabricate an address from a bare name — that's how you'd end up
// inviting made-up users; reaching a non-directory user requires typing
// their full address (which then resolves through this same path).
export type AddressPreview = {
  state: 'resolved' | 'unreachable' | 'notfound';
  userId: string;
  displayName?: string;
  avatarUrl?: string;
};

// Failure of the open-or-create-DM round-trip, surfaced under the input
// instead of being swallowed. `rateLimited` flags a 429 (the only case
// worth its own message); everything else is a generic create failure.
export type DmError = { rateLimited?: boolean };

// The person a pending "start chat" confirmation dialog is about. Seeded
// with whatever the clicked row already knows; the dialog refines it with
// a fresh profile lookup (so a cross-server avatar/name always shows).
export type PendingDirect = { userId: string; displayName?: string; avatarUrl?: string };

export const getDmUserId = (
  roomId: string,
  getRoom: (roomId: string) => Room | undefined,
  myUserId: string
): string | undefined => {
  const room = getRoom(roomId);
  return room && guessDmRoomUserId(room, myUserId);
};

type UseRoomSearchOptions = {
  // Called after the user commits a room selection (click or Enter), or
  // after a People hit resolves to a DM room (existing or freshly
  // created). Modal Search uses this to close itself; the inline header
  // form uses it to retract the curtain. The hook itself doesn't
  // navigate — the caller decides via `onOpenRoomId(roomId, isSpace)`.
  onOpenRoomId: (roomId: string, isSpace: boolean) => void;
};

// Single source of truth for the unified search panel — local
// rooms/DMs/spaces (prefix detection `#`/`*`/`@`, filtered scope,
// top-active fallback) AND people from the homeserver user directory
// (the "People" section, which is how you reach someone you haven't
// chatted with yet). Keyboard navigation spans both sections, rooms
// first then people. Used by both the global `Search` modal and the
// inline header form.
export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
  const mx = useMatrixClient();
  const alive = useAlive();
  const createDirect = useCreateDirect();
  const inputRef = useRef<HTMLInputElement>(null);
  const scrollRef = useRef<HTMLDivElement>(null);

  const [searchRoomType, setSearchRoomType] = useState<SearchRoomType>();

  const allRoomsSet = useAllJoinedRoomsSet();
  const getRoom = useGetRoom(allRoomsSet);
  const roomToParents = useAtomValue(roomToParentsAtom);
  const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
  const mDirects = useAtomValue(mDirectAtom);
  const rooms = useRooms(mx, allRoomsAtom, mDirects);
  const spaces = useSpaces(mx, allRoomsAtom);
  const directs = useDirects(mx, allRoomsAtom, mDirects);
  const roomToUnread = useAtomValue(roomToUnreadAtom);

  const topActiveRooms = useMemo(() => {
    if (searchRoomType === SearchRoomType.Spaces) return spaces;
    if (searchRoomType === SearchRoomType.Directs) {
      return [...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
    }
    if (searchRoomType === SearchRoomType.Rooms) {
      return [...rooms].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
    }
    return [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
  }, [mx, rooms, directs, spaces, searchRoomType]);

  const targetRooms = useMemo(() => {
    if (searchRoomType === undefined) return [...rooms, ...directs, ...spaces];
    if (searchRoomType === SearchRoomType.Rooms) return rooms;
    if (searchRoomType === SearchRoomType.Spaces) return spaces;
    if (searchRoomType === SearchRoomType.Directs) return directs;
    return [];
  }, [rooms, spaces, directs, searchRoomType]);

  const myUserId = mx.getSafeUserId();
  const getTargetStr: SearchItemStrGetter<string> = useCallback(
    (roomId: string) => {
      const roomName = getRoom(roomId)?.name ?? roomId;
      if (mDirects.has(roomId)) {
        const targetUserId = getDmUserId(roomId, getRoom, myUserId);
        const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
        if (targetUsername) return [roomName, targetUsername];
      }
      return roomName;
    },
    [getRoom, mDirects, myUserId]
  );

  const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
  const roomsToRender = result ? result.items : topActiveRooms;

  // ── User-directory ("People") search ──────────────────────────────
  // `dirGenRef` is a monotonic generation counter bumped on every input
  // change. Each (debounced) directory request carries the generation
  // it was issued under and drops its result if a newer keystroke has
  // since bumped the counter — this serialises out-of-order network
  // responses without an AbortController.
  const [directoryResults, setDirectoryResults] = useState<RoomSearchUser[]>([]);
  // Federated profile preview for a typed full address (see AddressPreview).
  const [addressPreview, setAddressPreview] = useState<AddressPreview | null>(null);
  const [directoryLoading, setDirectoryLoading] = useState(false);
  const dirGenRef = useRef(0);
  // Current input text — drives the clear (✕) button's visibility.
  const [query, setQuery] = useState('');
  // Surfaced open-or-create-DM failure (cleared on the next keystroke).
  const [dmError, setDmError] = useState<DmError | null>(null);

  const runQuery = useCallback(
    async (term: string, parsedMxid: string | null, gen: number) => {
      if (gen !== dirGenRef.current) return;

      // Local homeserver directory list (display-name / localpart match).
      let users: RoomSearchUser[] = [];
      try {
        if (term) {
          const { results } = await mx.searchUserDirectory({ term, limit: DIRECTORY_LIMIT });
          users = results.map((r) => ({
            userId: r.user_id,
            displayName: r.display_name,
            avatarUrl: r.avatar_url,
          }));
        }
      } catch {
        users = [];
      }
      if (gen !== dirGenRef.current || !alive()) return;

      // Address card — ONLY for an explicitly typed full address (never
      // fabricated from a bare name). 200 → resolved; a clean 404 →
      // notfound (no such account → non-messageable note, no ghost room);
      // any other failure → unreachable (can't tell → still messageable).
      let preview: AddressPreview | null = null;
      if (
        parsedMxid &&
        parsedMxid !== myUserId &&
        !getDMRoomFor(mx, parsedMxid) &&
        !users.some((u) => u.userId === parsedMxid)
      ) {
        try {
          const profile = await mx.getProfileInfo(parsedMxid);
          preview = {
            state: 'resolved',
            userId: parsedMxid,
            displayName: profile.displayname,
            avatarUrl: profile.avatar_url,
          };
        } catch (err) {
          const notFound = err instanceof MatrixError && err.errcode === ErrorCode.M_NOT_FOUND;
          preview = { state: notFound ? 'notfound' : 'unreachable', userId: parsedMxid };
        }
      }
      if (gen !== dirGenRef.current || !alive()) return;
      setDirectoryResults(users);
      setAddressPreview(preview);
      setDirectoryLoading(false);
    },
    [mx, alive, myUserId]
  );
  const debouncedRunQuery = useDebounce(runQuery, { wait: DIRECTORY_DEBOUNCE_MS });

  // People already in a DM with us are reached through their existing
  // room row, so drop them (and self) from the directory section to
  // avoid showing the same person twice. `mDirects` in the deps re-runs
  // the filter the moment a new DM lands (so a just-created chat leaves
  // the People list immediately).
  const peopleToRender = useMemo(
    () => directoryResults.filter((u) => u.userId !== myUserId && !getDMRoomFor(mx, u.userId)),
    // `mDirects` isn't read directly but is the reactive mirror of the
    // `m.direct` account data that `getDMRoomFor` consults — keeping it
    // in the deps re-runs the filter the instant a new DM lands so the
    // just-chatted person drops out of the People section.
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [directoryResults, mx, myUserId, mDirects]
  );

  // The typed-address card always renders at the TOP (it's the most
  // specific thing the user asked for). Only an ACTIONABLE card (the
  // account exists, or we couldn't verify) is clickable and takes a
  // keyboard-focus slot — a clean 404 renders as a non-focusable "not
  // found" note, so the focus list stays: [address?, rooms…, people…].
  const roomCount = roomsToRender.length;
  const peopleCount = peopleToRender.length;
  const addressActionable =
    addressPreview?.state === 'resolved' || addressPreview?.state === 'unreachable';
  const roomIndexBase = addressActionable ? 1 : 0;
  // -1 when there's no clickable address card.
  const addressIndex = addressActionable ? 0 : -1;
  const focusCount = roomCount + peopleCount + (addressActionable ? 1 : 0);
  const listFocus = useListFocusIndex(focusCount, 0);

  const queryHighlightRegex = result?.query
    ? makeHighlightRegex(result.query.split(' '))
    : undefined;

  // Clicking a person (directory hit or typed address) opens a small
  // confirm dialog instead of creating the chat outright — the user gets
  // an identity preview (avatar/name/server) and an E2EE choice first.
  // `confirmDirect` does the actual open-or-create. `creatingUserId`
  // drives the dialog button's in-flight spinner.
  const [creatingUserId, setCreatingUserId] = useState<string | null>(null);
  const [pendingDirect, setPendingDirect] = useState<PendingDirect | null>(null);

  const requestDirect = useCallback((target: PendingDirect) => {
    setDmError(null);
    setPendingDirect(target);
  }, []);

  const cancelDirect = useCallback(() => {
    setPendingDirect(null);
    setDmError(null);
  }, []);

  const confirmDirect = useCallback(
    (encrypt: boolean) => {
      const target = pendingDirect;
      if (!target) return;
      setDmError(null);
      setCreatingUserId(target.userId);
      createDirect(target.userId, { encrypt })
        .then((roomId) => {
          if (!alive()) return;
          setCreatingUserId(null);
          setPendingDirect(null);
          onOpenRoomId(roomId, false);
        })
        .catch((err: unknown) => {
          if (!alive()) return;
          setCreatingUserId(null);
          // Surface the failure instead of swallowing it. `isRateLimitError`
          // also catches a 429 carrying `M_UNKNOWN`/no errcode. We don't show
          // a countdown — the retry window is unreliable (header vs body,
          // usually sub-minute) and a wrong "0 min" reads worse than a plain
          // "try again shortly".
          setDmError({ rateLimited: err instanceof MatrixError && err.isRateLimitError() });
        });
    },
    [pendingDirect, createDirect, alive, onOpenRoomId]
  );

  const handleInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
    (evt) => {
      listFocus.reset();
      setDmError(null);
      setQuery(evt.currentTarget.value);
      const raw = evt.currentTarget.value.trim();

      // A deliberate full address (`@a:b`, `a:b`, `a@b`) becomes a
      // federated profile preview, NOT the `@` directs-filter prefix —
      // so pasting (or loosely typing) someone's address finds exactly
      // them. Bare `@name` (no server) stays the directs-filter prefix.
      const parsedMxid = parseUserAddress(raw);

      let value = raw;
      let nextType: SearchRoomType | undefined;
      if (parsedMxid) {
        // Address mode: still fuzzy-search local rooms/DMs by the
        // localpart, but apply no prefix scope.
        value = getMxIdLocalPart(parsedMxid) ?? raw;
      } else {
        const prefix = raw.match(/^[#@*]/)?.[0];
        nextType = prefix ? PREFIX_TO_TYPE[prefix] : undefined;
        if (nextType) value = raw.slice(1);
      }
      setSearchRoomType(nextType);

      // Local room/DM/space search.
      if (value === '') resetSearch();
      else search(value);

      // Directory people + address preview — only when nothing scopes us
      // away from people (`#` rooms / `*` spaces suppress it). Bump the
      // generation so any in-flight request from a prior keystroke is
      // discarded. An address always runs (the user clearly means that
      // person); a plain term waits for the minimum length.
      dirGenRef.current += 1;
      const directoryEnabled = nextType === undefined || nextType === SearchRoomType.Directs;
      if (directoryEnabled && (parsedMxid || value.length >= DIRECTORY_MIN_CHARS)) {
        setDirectoryLoading(true);
        debouncedRunQuery(value, parsedMxid, dirGenRef.current);
      } else {
        setDirectoryResults([]);
        setAddressPreview(null);
        setDirectoryLoading(false);
      }
    },
    [listFocus, search, resetSearch, debouncedRunQuery]
  );

  // Wipe the whole query in one tap (the ✕ button): reset local search,
  // drop directory results, discard any in-flight directory request
  // (gen bump), clear the input element and refocus it.
  const clearSearch = useCallback(() => {
    setQuery('');
    setSearchRoomType(undefined);
    resetSearch();
    dirGenRef.current += 1;
    setDirectoryResults([]);
    setAddressPreview(null);
    setDirectoryLoading(false);
    setDmError(null);
    listFocus.reset();
    const input = inputRef.current;
    if (input) {
      input.value = '';
      input.focus();
    }
  }, [resetSearch, listFocus]);

  const handleInputKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
    (evt) => {
      if (isKeyHotkey('enter', evt)) {
        const { index } = listFocus;
        if (addressActionable && addressPreview && index === addressIndex) {
          // The actionable address card (top): open the confirm dialog.
          // A `notfound` card isn't focusable, so it never lands here.
          requestDirect({
            userId: addressPreview.userId,
            displayName: addressPreview.displayName,
            avatarUrl: addressPreview.avatarUrl,
          });
          return;
        }
        const ri = index - roomIndexBase;
        if (ri >= 0 && ri < roomCount) {
          const roomId = roomsToRender[ri];
          if (roomId) onOpenRoomId(roomId, spaces.includes(roomId));
        } else {
          const user = peopleToRender[ri - roomCount];
          if (user) {
            requestDirect({
              userId: user.userId,
              displayName: user.displayName,
              avatarUrl: user.avatarUrl,
            });
          }
        }
        return;
      }
      if (isKeyHotkey('arrowdown', evt)) {
        evt.preventDefault();
        listFocus.next();
        return;
      }
      if (isKeyHotkey('arrowup', evt)) {
        evt.preventDefault();
        listFocus.previous();
      }
    },
    [
      roomsToRender,
      roomCount,
      roomIndexBase,
      peopleToRender,
      addressPreview,
      addressActionable,
      addressIndex,
      listFocus,
      onOpenRoomId,
      spaces,
      requestDirect,
    ]
  );

  const handleRoomClick: MouseEventHandler<HTMLButtonElement> = useCallback(
    (evt) => {
      const target = evt.currentTarget;
      const roomId = target.getAttribute('data-room-id');
      const isSpace = target.getAttribute('data-space') === 'true';
      if (!roomId) return;
      onOpenRoomId(roomId, isSpace);
    },
    [onOpenRoomId]
  );

  // Auto-scroll the highlighted item into view as the user arrows.
  useEffect(() => {
    const scrollView = scrollRef.current;
    const focusedItem = scrollView?.querySelector(`[data-focus-index="${listFocus.index}"]`);
    if (focusedItem && scrollView) {
      focusedItem.scrollIntoView({ block: 'nearest' });
    }
  }, [listFocus.index]);

  return {
    inputRef,
    scrollRef,
    roomsToRender,
    searchRoomType,
    result,
    listFocus,
    queryHighlightRegex,
    query,
    clearSearch,
    handleInputChange,
    handleInputKeyDown,
    handleRoomClick,
    // People (user directory) section:
    peopleToRender,
    directoryLoading,
    // Open the confirm dialog for a person (directory hit or address card).
    requestDirect,
    creatingUserId,
    // Typed-address card (always rendered at the top) + whether it's
    // actionable (clickable/focusable — `notfound` is a plain note) +
    // surfaced DM-create error.
    addressPreview,
    addressActionable,
    dmError,
    // Pending confirm-dialog target + its actions.
    pendingDirect,
    confirmDirect,
    cancelDirect,
    // Flat focus-index layout so consumers can tag rows: the actionable
    // address card sits at `addressIndex` (0, at the top), then rooms
    // start at `roomIndexBase`, then people right after.
    roomCount,
    roomIndexBase,
    addressIndex,
    // Per-row context the consumer needs to render rows:
    getRoom,
    mDirects,
    spaces,
    orphanSpaces,
    roomToParents,
    roomToUnread,
    myUserId,
  };
}
