import {
  createContext,
  type ReactNode,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import {
  type RoomCategoryResponse,
  type RoomMemberResponse,
  type RoomMessageResponse,
  type RoomPinResponse,
  type RoomResponse,
  type RoomThreadResponse,
} from '@/client';
import {
  createRoomWsClient,
  type RoomWsClient,
  type RoomWsStatus,
  type RoomMessagePayload,
  type RoomCategoryResponse as WsRoomCategoryResponse,
  type RoomReactionUpdatedPayload,
} from '@/lib/room-ws-client';
import { requestWsToken } from '@/lib/ws-token';
import { useUser } from '@/contexts';
import {
  saveMessage,
  saveMessages,
  loadMessages as loadMessagesFromIdb,
  loadOlderMessagesFromIdb,
  deleteMessage as deleteMessageFromIdb,
} from '@/lib/storage/indexed-db';

export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';

export interface ReactionGroup {
  emoji: string;
  count: number;
  reacted_by_me: boolean;
  users: string[];
}

const MAX_MESSAGES_IN_MEMORY = 1000;

function handleRoomError(context: string, error: unknown): void {
  const message = error instanceof Error ? error.message : 'Operation failed';
  console.error(`[RoomContext] ${context}:`, error);
  toast.error(`${context} failed`, {
    description: message,
  });
}

export type MessageWithMeta = RoomMessageResponse & {
  thread_id?: string;
  display_content?: string;
  is_streaming?: boolean;
  isOptimisticError?: boolean;
  /** True for messages sent by the current user that haven't been confirmed by the server */
  isOptimistic?: boolean;
  reactions?: ReactionGroup[];
};

export type RoomWithCategory = RoomResponse & {
  category_info?: RoomCategoryResponse | null;
};

export type UiMessage = MessageWithMeta;

function wsMessageToUiMessage(wsMsg: RoomMessagePayload): MessageWithMeta {
  return {
    id: wsMsg.id,
    seq: wsMsg.seq,
    room: wsMsg.room_id,
    sender_type: wsMsg.sender_type,
    sender_id: wsMsg.sender_id,
    display_name: wsMsg.display_name,
    thread: wsMsg.thread_id,
    thread_id: wsMsg.thread_id,
    content: wsMsg.content,
    content_type: wsMsg.content_type,
    send_at: wsMsg.send_at,
    display_content: wsMsg.content,
    is_streaming: false,
  };
}

interface RoomContextValue {
  wsStatus: RoomWsStatus;
  wsError: string | null;
  wsClient: RoomWsClient | null;
  connectWs: () => Promise<void>;
  disconnectWs: () => void;

  rooms: RoomWithCategory[];
  roomsLoading: boolean;
  roomsError: Error | null;
  refreshRooms: () => Promise<void>;

  categories: RoomCategoryResponse[];
  categoriesLoading: boolean;
  refreshCategories: () => Promise<void>;
  createCategory: (name: string, position?: number) => Promise<WsRoomCategoryResponse>;
  updateCategory: (id: string, name?: string, position?: number) => Promise<void>;
  deleteCategory: (id: string) => Promise<void>;

  activeRoom: RoomResponse | null;
  activeRoomId: string | null;
  setActiveRoom: (roomId: string | null) => void;

  messages: MessageWithMeta[];
  messagesLoading: boolean;
  isHistoryLoaded: boolean;
  isLoadingMore: boolean;
  isTransitioningRoom: boolean;
  nextCursor: number | null;
  loadMore: (cursor?: number | null) => void;
  sendMessage: (content: string, contentType?: string, inReplyTo?: string) => Promise<void>;
  editMessage: (messageId: string, content: string) => Promise<void>;
  revokeMessage: (messageId: string) => Promise<void>;
  updateReadSeq: (seq: number) => Promise<void>;

  members: RoomMemberResponse[];
  membersLoading: boolean;
  refreshMembers: () => Promise<void>;
  addMember: (userId: string, role?: string) => Promise<void>;
  removeMember: (userId: string) => Promise<void>;
  updateMemberRole: (userId: string, role: string) => Promise<void>;

  pins: RoomPinResponse[];
  pinsLoading: boolean;
  refreshPins: () => Promise<void>;
  pinMessage: (messageId: string) => Promise<void>;
  unpinMessage: (messageId: string) => Promise<void>;

  threads: RoomThreadResponse[];
  refreshThreads: () => Promise<void>;
  createThread: (parentSeq: number) => Promise<RoomThreadResponse>;

  createRoom: (name: string, isPublic: boolean, categoryId?: string) => Promise<RoomResponse>;
  updateRoom: (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => Promise<void>;
  deleteRoom: (roomId: string) => Promise<void>;
  streamingMessages: Map<string, string>;
}

const RoomContext = createContext<RoomContextValue | null>(null);

interface RoomProviderProps {
  projectName: string | null;
  /** Room to open immediately on mount */
  initialRoomId?: string | null;
  children: ReactNode;
}

export function RoomProvider({
  projectName,
  initialRoomId = null,
  children,
}: RoomProviderProps) {
  const { user } = useUser();
  const navigate = useNavigate();

  const [activeRoomId, setActiveRoomId] = useState<string | null>(initialRoomId);
  const [wsClient, setWsClient] = useState<RoomWsClient | null>(null);
  const wsClientRef = useRef<RoomWsClient | null>(null);
  const activeRoomIdRef = useRef<string | null>(activeRoomId);
  const [wsStatus, setWsStatus] = useState<RoomWsStatus>('idle');
  const [wsError, setWsError] = useState<string | null>(null);
  const [wsToken, setWsToken] = useState<string | null>(null);

  // Keep ref updated with latest activeRoomId
  activeRoomIdRef.current = activeRoomId;

  useEffect(() => {
    let cancelled = false;

    const fetchToken = async () => {
      try {
        const token = await requestWsToken();
        if (!cancelled) {
          setWsToken(token);
        }
      } catch (error) {
        console.error('[RoomContext] Failed to fetch WS token:', error);
        // Token fetch is not critical - client can fall back to cookie auth
        if (!cancelled) {
          setWsToken(null);
        }
      }
    };

    fetchToken();

    return () => {
      cancelled = true;
    };
  }, []);

  const [rooms, setRooms] = useState<RoomWithCategory[]>([]);
  const [roomsLoading, setRoomsLoading] = useState(false);
  const [roomsError, setRoomsError] = useState<Error | null>(null);

  const [categories, setCategories] = useState<RoomCategoryResponse[]>([]);
  const [categoriesLoading, setCategoriesLoading] = useState(false);

  const [activeRoom, setActiveRoomState] = useState<RoomResponse | null>(null);

  const [messages, setMessages] = useState<MessageWithMeta[]>([]);
  const [isHistoryLoaded, setIsHistoryLoaded] = useState(false);
  const [isLoadingMore, setIsLoadingMore] = useState(false);
  const [nextCursor, setNextCursor] = useState<number | null>(null);

  const loadMessagesAbortRef = useRef<AbortController | null>(null);

  const prevRoomIdRef = useRef<string | null>(null);
  const [isTransitioningRoom, setIsTransitioningRoom] = useState(false);

  useEffect(() => {
    if (prevRoomIdRef.current !== activeRoomId) {
      const oldRoomId = prevRoomIdRef.current;
      prevRoomIdRef.current = activeRoomId;
      loadMessagesAbortRef.current?.abort();
      loadMessagesAbortRef.current = null;
      // Mark as transitioning room, show loading state instead of clearing immediately
      setIsTransitioningRoom(true);
      // Immediately clear old room's messages from state to prevent flicker
      setMessages([]);
      setIsHistoryLoaded(false);
      setNextCursor(null);
      // NOTE: intentionally NOT clearing IndexedDB — keeping it enables instant
      // load when the user returns to this room without waiting for API.
    }
  }, [activeRoomId]);

  useEffect(() => {
    const client = wsClientRef.current;
    if (!activeRoomId || !client) return;

    const setup = async () => {
      // IDB load does NOT need WS — show cached messages immediately.
      // loadMore checks IDB first, then falls back to API (WS-first + HTTP).
      loadMore(null);

      // Connect WS in parallel for real-time push + reactions batch-fetch.
      // connect() is idempotent — no-op if already connecting/open.
      // subscribeRoom uses WS-first request() with HTTP fallback.
      await client.connect();
      if (activeRoomIdRef.current !== activeRoomId) return;
      client.subscribeRoom(activeRoomId).catch(() => {});
    };
    setup().catch(() => {});

    return () => {
      client.unsubscribeRoom(activeRoomId).catch(() => {});
    };
  }, [activeRoomId, wsClient]);

  /**
   * Fetch reactions for a batch of messages via WS (with HTTP fallback),
   * then merge them into the messages state.  Fires-and-forgets so it
   * does not block the caller.
   */
  const thisLoadReactions = (
    roomId: string,
    client: NonNullable<ReturnType<typeof wsClientRef.current>>,
    msgs: MessageWithMeta[],
  ) => {
    const msgIds = msgs.map((m) => m.id);
    if (msgIds.length === 0) return;
    client
      .reactionListBatch(roomId, msgIds)
      .then((reactionResults) => {
        const reactionMap = new Map<string, import('@/lib/room-ws-client').ReactionItem[]>();
        for (const result of reactionResults) {
          if (result.reactions.length > 0) {
            reactionMap.set(result.message_id, result.reactions);
          }
        }
        if (reactionMap.size > 0) {
          setMessages((prev) =>
            prev.map((m) =>
              reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m,
            ),
          );
        }
      })
      .catch(() => {
        // Non-fatal: WS push will keep reactions up to date
      });
  };

  const loadMore = useCallback(
    async (cursor?: number | null) => {
      const client = wsClientRef.current;
      if (!activeRoomId || !client) return;

      // Cancel any in-flight request
      loadMessagesAbortRef.current?.abort();
      loadMessagesAbortRef.current = new AbortController();
      const abortController = loadMessagesAbortRef.current;

      setIsLoadingMore(true);
      try {
        const isInitial = cursor === null || cursor === undefined;
        const limit = isInitial ? 200 : 50;

        // --- Initial load: try IndexedDB first for instant render ---
        if (isInitial) {
          const cached = await loadMessagesFromIdb(activeRoomId);
          if (cached.length > 0) {
            setMessages(cached);
            setIsTransitioningRoom(false);
            const minSeq = cached[0].seq;
            setNextCursor(minSeq > 0 ? minSeq - 1 : null);
            setIsLoadingMore(false);
            // No API call needed — WS will push any new messages that arrived while away.
            // Fetch reactions via WS (with HTTP fallback) so reactions appear without extra latency.
            thisLoadReactions(activeRoomId, client, cached);
            return;
          }
        }

        // --- Load older history: try IDB first, then fall back to API ---
        if (!isInitial && cursor != null) {
          const idbMessages = await loadOlderMessagesFromIdb(activeRoomId, cursor, limit);
          if (idbMessages.length > 0) {
            setMessages((prev) => {
              if (abortController.signal.aborted) return prev;
              const existingIds = new Set(prev.map((m) => m.id));
              const filtered = idbMessages.filter((m) => !existingIds.has(m.id));
              let merged = [...filtered, ...prev];
              merged.sort((a, b) => a.seq - b.seq);
              if (merged.length > MAX_MESSAGES_IN_MEMORY) {
                merged = merged.slice(-MAX_MESSAGES_IN_MEMORY);
              }
              return merged;
            });
            const oldest = idbMessages[0];
            setNextCursor(oldest.seq > 0 ? oldest.seq - 1 : null);
            if (idbMessages.length < limit) {
              setIsHistoryLoaded(true);
            }
            setIsLoadingMore(false);
            // Also fetch reactions for the IDB-loaded history messages.
            thisLoadReactions(activeRoomId, client, idbMessages);
            return;
          }
          // IDB empty for this range — fall through to API
        }

        // --- API fetch ---
        const resp = await client.messageList(activeRoomId, {
          beforeSeq: cursor ?? undefined,
          limit,
        });

        if (abortController.signal.aborted) return;

        const newMessages = resp.messages.map((m) => ({
          ...m,
          thread_id: m.thread,
          display_content: m.content,
          is_streaming: false,
        }));

        setMessages((prev) => {
          if (abortController.signal.aborted) return prev;
          if (isInitial) {
            setIsTransitioningRoom(false);
            return newMessages;
          }
          const existingIds = new Set(prev.map((m) => m.id));
          const filtered = newMessages.filter((m) => !existingIds.has(m.id));
          let merged = [...filtered, ...prev];
          merged.sort((a, b) => a.seq - b.seq);
          if (merged.length > MAX_MESSAGES_IN_MEMORY) {
            merged = merged.slice(-MAX_MESSAGES_IN_MEMORY);
          }
          return merged;
        });

        if (newMessages.length > 0) {
          saveMessages(activeRoomId, newMessages).catch(() => {});
        }

        if (resp.messages.length < limit) {
          setIsHistoryLoaded(true);
        }
        setNextCursor(resp.messages.length > 0 ? resp.messages[resp.messages.length - 1].seq : null);

        // Fetch reactions for all loaded messages (WS-first with HTTP fallback)
        thisLoadReactions(activeRoomId, client, newMessages);
      } catch (error) {
        if (abortController.signal.aborted) return;
        handleRoomError('Load messages', error);
      } finally {
        setIsLoadingMore(false);
        loadMessagesAbortRef.current = null;
      }
    },
    [activeRoomId],
  );

  const [members, setMembers] = useState<RoomMemberResponse[]>([]);
  const [membersLoading, setMembersLoading] = useState(false);

  const [pins, setPins] = useState<RoomPinResponse[]>([]);
  const [pinsLoading, setPinsLoading] = useState(false);

  const [threads, setThreads] = useState<RoomThreadResponse[]>([]);

  const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());

  useEffect(() => {
    const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
    const client = createRoomWsClient(
      baseUrl,
      {
        onRoomMessage: (payload) => {
          // Use ref to get current activeRoomId to avoid stale closure
          if (payload.room_id === activeRoomIdRef.current) {
            setMessages((prev) => {
              // Deduplicate by both ID (for normal) and seq (for optimistic replacement)
              if (prev.some((m) => m.id === payload.id)) {
                return prev;
              }
              // Also check if there's an optimistic message with the same seq that should be replaced
              const optimisticIdx = prev.findIndex(
                (m) => m.isOptimistic && m.seq === payload.seq && m.seq !== 0,
              );
              if (optimisticIdx !== -1) {
                // Replace optimistic message with confirmed one
                const confirmed: MessageWithMeta = {
                  ...wsMessageToUiMessage(payload),
                  reactions: prev[optimisticIdx].reactions,
                };
                const next = [...prev];
                next[optimisticIdx] = confirmed;
                // Remove optimistic from IDB, save confirmed
                deleteMessageFromIdb(prev[optimisticIdx].id).catch(() => {});
                saveMessage(confirmed).catch(() => {});
                return next;
              }
              const newMsg = wsMessageToUiMessage(payload);
              let updated = [...prev, newMsg];
              updated.sort((a, b) => a.seq - b.seq);
              if (updated.length > MAX_MESSAGES_IN_MEMORY) {
                updated = updated.slice(-MAX_MESSAGES_IN_MEMORY);
              }
              return updated;
            });
            // Persist to IndexedDB
            const msg = wsMessageToUiMessage(payload);
            saveMessage(msg).catch(() => {});
          }
        },
        onAiStreamChunk: (chunk) => {
          if (chunk.done) {
            // When streaming is done, update the message content and remove from streaming
            setStreamingContent((prev) => {
              const next = new Map(prev);
              next.delete(chunk.message_id);
              return next;
            });
            setMessages((prev) => {
              const updated = prev.map((m) =>
                m.id === chunk.message_id
                  ? { ...m, content: chunk.content, is_streaming: false }
                  : m,
              );
              // Persist final content to IndexedDB
              const msg = updated.find((m) => m.id === chunk.message_id);
              if (msg) saveMessage(msg).catch(() => {});
              return updated;
            });
          } else {
            // Accumulate streaming content
            setStreamingContent((prev) => {
              const next = new Map(prev);
              const existing = next.get(chunk.message_id) ?? '';
              next.set(chunk.message_id, existing + chunk.content);
              return next;
            });
            // Create streaming message placeholder if it doesn't exist
            setMessages((prev) => {
              if (prev.some((m) => m.id === chunk.message_id)) {
                return prev;
              }
              const newMsg: MessageWithMeta = {
                id: chunk.message_id,
                room: chunk.room_id,
                seq: 0,
                sender_type: 'ai',
                content: '',
                content_type: 'text',
                send_at: new Date().toISOString(),
                is_streaming: true,
              };
              return [...prev, newMsg];
            });
          }
        },
        onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
          setMessages((prev) => {
            const updated = prev.map((m) =>
              m.id === payload.message_id
                ? { ...m, reactions: payload.reactions }
                : m,
            );
            // Persist reaction update to IndexedDB
            const msg = updated.find((m) => m.id === payload.message_id);
            if (msg) saveMessage(msg).catch(() => {});
            return updated;
          });
        },
        onMessageEdited: async (payload) => {
          // The event only contains message_id and edited_at.
          // Optimistically update edited_at, then fetch the full message from the API.
          if (payload.room_id !== activeRoomIdRef.current) return;

          const client = wsClientRef.current;
          if (!client) return;

          // Capture original edited_at for rollback if fetch fails
          let rollbackEditedAt: string | null = null;
          setMessages((prev) => {
            const msg = prev.find((m) => m.id === payload.message_id);
            rollbackEditedAt = msg?.edited_at ?? null;
            const updated = prev.map((m) =>
              m.id === payload.message_id ? { ...m, edited_at: payload.edited_at } : m,
            );
            const saved = updated.find((m) => m.id === payload.message_id);
            if (saved) saveMessage(saved).catch(() => {});
            return updated;
          });

          // Fetch full updated message from API
          try {
            const updatedMsg = await client.messageGet(payload.message_id);
            if (!updatedMsg) return;
            setMessages((prev) => {
              const merged = prev.map((m) =>
                m.id === payload.message_id
                  ? {
                      ...m,
                      content: updatedMsg.content,
                      display_content: updatedMsg.content,
                      edited_at: payload.edited_at,
                    }
                  : m,
              );
              // Persist to IndexedDB
              const saved = merged.find((m) => m.id === payload.message_id);
              if (saved) saveMessage(saved).catch(() => {});
              return merged;
            });
          } catch {
            // Revert edited_at if the fetch failed
            if (rollbackEditedAt !== null) {
              setMessages((prev) =>
                prev.map((m) =>
                  m.id === payload.message_id ? { ...m, edited_at: rollbackEditedAt! } : m,
                ),
              );
            }
          }
        },
        onMessageRevoked: async (payload) => {
          if (payload.room_id !== activeRoomIdRef.current) return;
          setMessages((prev) => {
            const updated = prev.map((m) =>
              m.id === payload.message_id
                ? {
                    ...m,
                    revoked: payload.revoked_at,
                    revoked_by: payload.revoked_by,
                    content: '',
                    display_content: '',
                  }
                : m,
            );
            // Persist to IndexedDB
            const msg = updated.find((m) => m.id === payload.message_id);
            if (msg) saveMessage(msg).catch(() => {});
            return updated;
          });
        },
        onMessagePinned: async (payload) => {
          if (payload.room_id !== activeRoomIdRef.current) return;
          const client = wsClientRef.current;
          if (!client) return;
          try {
            const pins = await client.pinList(payload.room_id);
            setPins(pins.map((p) => ({
              room: p.room,
              message: p.message,
              pinned_by: p.pinned_by,
              pinned_at: p.pinned_at,
            })));
          } catch {
            // Silently ignore
          }
        },
        onMessageUnpinned: async (payload) => {
          if (payload.room_id !== activeRoomIdRef.current) return;
          setPins((prev) => prev.filter((p) => p.message !== payload.message_id));
        },
        onStatusChange: (status) => {
          setWsStatus(status);
          if (status === 'closed' || status === 'error') {
            setWsError('Connection lost');
          } else {
            setWsError(null);
          }
        },
        onError: (error) => {
          setWsError(error.message);
        },
      },
      { wsToken: wsToken ?? undefined },
    );

    setWsClient(client);
    wsClientRef.current = client;

    return () => {
      client.disconnect();
      wsClientRef.current = null;
    };
  }, [wsToken]);

  useEffect(() => {
    // NOTE: intentionally omitted [wsClient] from deps.
    // In React StrictMode the component mounts twice — if wsClient were a dep,
    // the first mount's effect would connect client-1, then StrictMode cleanup
    // would disconnect it, then the second mount's effect would connect client-2,
    // then immediately the first mount's *second* cleanup would fire and
    // disconnect client-2 — leaving WS unconnected. Using a ref for the initial
    // connect avoids this. The client is always ready by the time this runs.
    wsClientRef.current?.connect().catch((e) => {
      console.error('[RoomContext] WS connect error:', e);
    });
  }, []);

  const connectWs = useCallback(async () => {
    const client = wsClientRef.current;
    if (!client) return;
    try {
      await client.connect();
      setWsError(null);
    } catch (e) {
      setWsError(e instanceof Error ? e.message : 'Failed to connect');
    }
  }, []);

  const disconnectWs = useCallback(() => {
    wsClientRef.current?.disconnect();
  }, []);

  const fetchRooms = useCallback(async () => {
    const client = wsClientRef.current;
    if (!projectName || !client) {
      setRooms([]);
      return;
    }
    setRoomsLoading(true);
    setRoomsError(null);
    try {
      const resp = await client.roomList(projectName);
      setRooms(resp.map((r) => ({ ...r, category_info: null })));
    } catch (err) {
      setRoomsError(err instanceof Error ? err : new Error('Failed to load rooms'));
    } finally {
      setRoomsLoading(false);
    }
  }, [projectName]);

  useEffect(() => {
    fetchRooms();
  }, [fetchRooms]);

  const fetchCategories = useCallback(async () => {
    const client = wsClientRef.current;
    if (!projectName || !client) return;
    setCategoriesLoading(true);
    try {
      const resp = await client.categoryList(projectName);
      setCategories(resp);
    } catch (error) {
      handleRoomError('Load categories', error);
    } finally {
      setCategoriesLoading(false);
    }
  }, [projectName]);

  useEffect(() => {
    fetchCategories();
  }, [fetchCategories]);

  const createCategory = useCallback(
    async (name: string, position?: number) => {
      const client = wsClientRef.current;
      if (!projectName || !client) throw new Error('No project');
      const cat = await client.categoryCreate(projectName, name, position);
      setCategories((prev) => [...prev, cat]);
      return cat;
    },
    [projectName],
  );

  const updateCategory = useCallback(
    async (id: string, name?: string, position?: number) => {
      const client = wsClientRef.current;
      if (!client) return;
      await client.categoryUpdate(id, { name, position });
      setCategories((prev) =>
        prev.map((c) =>
          c.id === id
            ? { ...c, ...(name !== undefined && { name }), ...(position !== undefined && { position }) }
            : c,
        ),
      );
    },
    [],
  );

  const deleteCategory = useCallback(
    async (id: string) => {
      const client = wsClientRef.current;
      if (!client) return;
      await client.categoryDelete(id);
      setCategories((prev) => prev.filter((c) => c.id !== id));
    },
    [],
  );

  const setActiveRoom = useCallback(
    (roomId: string | null) => {
      setActiveRoomId(roomId);
      if (roomId) {
        navigate(`../room/${roomId}`, { replace: true });
      } else {
        navigate('../room', { replace: true });
      }
    },
    [navigate],
  );

  useEffect(() => {
    const client = wsClientRef.current;
    if (!activeRoomId || !client) {
      setActiveRoomState(null);
      return;
    }
    client
      .roomGet(activeRoomId)
      .then((resp) => resp && setActiveRoomState(resp))
      .catch(() => setActiveRoomState(null));
  }, [activeRoomId]);

  const fetchMembers = useCallback(async () => {
    const client = wsClientRef.current;
    if (!activeRoomId || !client) {
      setMembers([]);
      return;
    }
    setMembersLoading(true);
    try {
      const resp = await client.memberList(activeRoomId);
      setMembers(resp.map((m) => ({ ...m, do_not_disturb: false })));
    } catch (error) {
      handleRoomError('Load members', error);
      setMembers([]);
    } finally {
      setMembersLoading(false);
    }
  }, [activeRoomId]);

  useEffect(() => {
    fetchMembers();
  }, [fetchMembers]);

  const addMember = useCallback(
    async (userId: string, role = 'member') => {
      const client = wsClientRef.current;
      if (!activeRoomId || !client) return;

      const tempMember: RoomMemberResponse = {
        room: activeRoomId,
        user: userId,
        role,
        user_info: undefined,
        do_not_disturb: false,
      };
      setMembers((prev) => [...prev, tempMember]);

      try {
        await client.memberAdd(activeRoomId, userId, role);
        await fetchMembers();
      } catch (error) {
        handleRoomError('Add member', error);
        setMembers((prev) => prev.filter((m) => m.user !== userId));
      }
    },
    [activeRoomId, fetchMembers],
  );

  const removeMember = useCallback(
    async (userId: string) => {
      const client = wsClientRef.current;
      if (!activeRoomId || !client) return;
      await client.memberRemove(activeRoomId, userId);
      setMembers((prev) => prev.filter((m) => m.user !== userId));
    },
    [activeRoomId],
  );

  const updateMemberRole = useCallback(
    async (userId: string, role: string) => {
      const client = wsClientRef.current;
      if (!activeRoomId || !client) return;
      await client.memberUpdateRole(activeRoomId, userId, role);
      setMembers((prev) => prev.map((m) => (m.user === userId ? { ...m, role } : m)));
    },
    [activeRoomId],
  );

  // Guard against double-sending while a previous send is in-flight.
  // Without this, rapid clicking can queue multiple optimistic messages and
  // create duplicate sends on the server.
  const sendingRef = useRef(false);

  const sendMessage = useCallback(
    async (content: string, contentType = 'text', inReplyTo?: string) => {
      const client = wsClientRef.current;
      if (!activeRoomId || !client) return;
      if (sendingRef.current) return;
      sendingRef.current = true;

      // Optimistic update: add message immediately so user sees it instantly
      const optimisticId = `optimistic-${crypto.randomUUID()}`;
      const optimisticMsg: MessageWithMeta = {
        id: optimisticId,
        room: activeRoomId,
        seq: 0,
        sender_type: 'member',
        sender_id: user?.uid ?? null,
        content,
        content_type: contentType,
        send_at: new Date().toISOString(),
        display_content: content,
        is_streaming: false,
        isOptimistic: true,
        thread: inReplyTo,
        thread_id: inReplyTo,
        in_reply_to: inReplyTo,
        reactions: [],
      };

      setMessages((prev) => [...prev, optimisticMsg]);
      // Persist optimistic message to IndexedDB so it's not lost on refresh
      saveMessage(optimisticMsg).catch(() => {});

      try {
        const confirmedMsg = await client.messageCreate(activeRoomId, content, {
          contentType,
          inReplyTo,
        });
        // Replace optimistic message with server-confirmed one
        setMessages((prev) => {
          const without = prev.filter((m) => m.id !== optimisticId);
          const confirmed: MessageWithMeta = {
            ...confirmedMsg,
            thread_id: confirmedMsg.thread,
            display_content: confirmedMsg.content,
            is_streaming: false,
            isOptimistic: false,
            reactions: [],
          };
          // Remove optimistic from IDB
          deleteMessageFromIdb(optimisticId).catch(() => {});
          // Save confirmed to IDB
          saveMessage(confirmed).catch(() => {});
          return [...without, confirmed];
        });
      } catch (err) {
        // Mark optimistic message as failed
        setMessages((prev) =>
          prev.map((m) =>
            m.id === optimisticId ? { ...m, isOptimisticError: true } : m,
          ),
        );
        handleRoomError('Send message', err);
      } finally {
        sendingRef.current = false;
      }
    },
    [activeRoomId, user],
  );

  const editMessage = useCallback(
    async (messageId: string, content: string) => {
      const client = wsClientRef.current;
      if (!client) return;

      // Capture original content for rollback on server rejection
      let rollbackContent: string | null = null;
      setMessages((prev) => {
        const msg = prev.find((m) => m.id === messageId);
        rollbackContent = msg?.content ?? null;
        return prev.map((m) =>
          m.id === messageId ? { ...m, content, display_content: content } : m,
        );
      });

      try {
        await client.messageUpdate(messageId, content);
        // Persist updated content to IndexedDB
        setMessages((prev) => {
          const msg = prev.find((m) => m.id === messageId);
          if (msg) saveMessage(msg).catch(() => {});
          return prev;
        });
      } catch (err) {
        // Rollback optimistic update on server rejection
        if (rollbackContent !== null) {
          setMessages((prev) =>
            prev.map((m) =>
              m.id === messageId ? { ...m, content: rollbackContent!, display_content: rollbackContent! } : m,
            ),
          );
        }
        handleRoomError('Edit message', err);
      }
    },
    [],
  );

  const revokeMessage = useCallback(
    async (messageId: string) => {
      const client = wsClientRef.current;
      if (!client) return;

      // Optimistic removal: hide message immediately
      let rollbackMsg: MessageWithMeta | null = null;
      setMessages((prev) => {
        rollbackMsg = prev.find((m) => m.id === messageId) ?? null;
        return prev.filter((m) => m.id !== messageId);
      });

      try {
        await client.messageRevoke(messageId);
        deleteMessageFromIdb(messageId).catch(() => {});
      } catch (err) {
        // Rollback: restore message on server rejection
        if (rollbackMsg) {
          setMessages((prev) => [...prev, rollbackMsg!]);
          saveMessage(rollbackMsg!).catch(() => {});
        }
        handleRoomError('Delete message', err);
      }
    },
    [],
  );

  const updateReadSeq = useCallback(
    async (seq: number) => {
      const client = wsClientRef.current;
      if (!activeRoomId || !user || !client) return;
      try {
        await client.memberSetReadSeq(activeRoomId, seq);
      } catch (error) {
        console.warn('[RoomContext] Failed to update read position:', error);
      }
    },
    [activeRoomId, user],
  );

  const fetchPins = useCallback(async () => {
    const client = wsClientRef.current;
    if (!activeRoomId || !client) {
      setPins([]);
      return;
    }
    setPinsLoading(true);
    try {
      const resp = await client.pinList(activeRoomId);
      setPins(
        resp.map((p) => ({
          room: p.room,
          message: p.message,
          pinned_by: p.pinned_by,
          pinned_at: p.pinned_at,
        })),
      );
    } catch (error) {
      handleRoomError('Load pinned messages', error);
      setPins([]);
    } finally {
      setPinsLoading(false);
    }
  }, [activeRoomId]);

  useEffect(() => {
    fetchPins();
  }, [fetchPins]);

  const pinMessage = useCallback(
    async (messageId: string) => {
      const client = wsClientRef.current;
      if (!activeRoomId || !client) return;

      const tempPin: RoomPinResponse = {
        room: activeRoomId,
        message: messageId,
        pinned_by: '',
        pinned_at: new Date().toISOString(),
      };
      setPins((prev) => [...prev, tempPin]);

      try {
        await client.pinAdd(activeRoomId, messageId);
        await fetchPins();
      } catch (error) {
        handleRoomError('Pin message', error);
        setPins((prev) => prev.filter((p) => p.message !== messageId));
      }
    },
    [activeRoomId, fetchPins],
  );

  const unpinMessage = useCallback(
    async (messageId: string) => {
      const client = wsClientRef.current;
      if (!activeRoomId || !client) return;

      setPins((prev) => prev.filter((p) => p.message !== messageId));

      try {
        await client.pinRemove(activeRoomId, messageId);
      } catch (error) {
        handleRoomError('Unpin message', error);
        fetchPins();
      }
    },
    [activeRoomId, fetchPins],
  );

  const fetchThreads = useCallback(async () => {
    const client = wsClientRef.current;
    if (!activeRoomId || !client) {
      setThreads([]);
      return;
    }
    try {
      const resp = await client.threadList(activeRoomId);
      setThreads(resp);
    } catch (error) {
      handleRoomError('Load threads', error);
      setThreads([]);
    }
  }, [activeRoomId]);

  useEffect(() => {
    fetchThreads();
  }, [fetchThreads]);

  const createThread = useCallback(
    async (parentSeq: number) => {
      const client = wsClientRef.current;
      if (!activeRoomId || !client) throw new Error('No active room');
      const thread = await client.threadCreate(activeRoomId, parentSeq);
      setThreads((prev) => [...prev, thread]);
      return thread;
    },
    [activeRoomId],
  );

  const createRoom = useCallback(
    async (name: string, isPublic: boolean, categoryId?: string) => {
      const client = wsClientRef.current;
      if (!projectName || !client) throw new Error('No project');
      const room = await client.roomCreate(projectName, name, isPublic, categoryId);
      setRooms((prev) => [...prev, { ...room, category_info: null }]);
      return room;
    },
    [projectName],
  );

  const updateRoom = useCallback(
    async (roomId: string, name?: string, isPublic?: boolean, categoryId?: string) => {
      const client = wsClientRef.current;
      if (!client) return;

      // Save previous values for rollback (use functional update to avoid closure issues)
      let rollbackRooms: RoomWithCategory[] | null = null;
      let rollbackActiveRoom: RoomResponse | null = null;

      // Capture current state for rollback
      setRooms((prev) => {
        rollbackRooms = prev;
        return prev.map((r) =>
          r.id === roomId
            ? {
                ...r,
                ...(name !== undefined && { room_name: name }),
                ...(isPublic !== undefined && { public: isPublic }),
                ...(categoryId !== undefined && { category: categoryId }),
              }
            : r,
        );
      });
      if (activeRoomId === roomId) {
        setActiveRoomState((prev) => {
          rollbackActiveRoom = prev;
          return prev
            ? {
                ...prev,
                ...(name !== undefined && { room_name: name }),
                ...(isPublic !== undefined && { public: isPublic }),
                ...(categoryId !== undefined && { category: categoryId }),
              }
            : prev;
        });
      }

      try {
        await client.roomUpdate(roomId, { roomName: name, isPublic, categoryId });
      } catch (error) {
        handleRoomError('Update room', error);
        // Use captured snapshot for rollback, not the old value in closure
        if (rollbackRooms) {
          setRooms(rollbackRooms);
        }
        if (activeRoomId === roomId && rollbackActiveRoom) {
          setActiveRoomState(rollbackActiveRoom);
        }
      }
    },
    [activeRoomId],
  );

  const deleteRoom = useCallback(
    async (roomId: string) => {
      const client = wsClientRef.current;
      if (!client) return;
      await client.roomDelete(roomId);
      setRooms((prev) => prev.filter((r) => r.id !== roomId));
      if (activeRoomId === roomId) {
        setActiveRoomId(null);
        setActiveRoom(null);
      }
    },
    [activeRoomId],
  );

  const value = useMemo<RoomContextValue>(
    () => ({
      wsStatus,
      wsError,
      wsClient: wsClientRef.current,
      connectWs,
      disconnectWs,
      rooms,
      roomsLoading,
      roomsError,
      refreshRooms: fetchRooms,
      categories,
      categoriesLoading,
      refreshCategories: fetchCategories,
      createCategory,
      updateCategory,
      deleteCategory,
      activeRoom,
      activeRoomId,
      setActiveRoom,
      messages,
      messagesLoading: false,
      isHistoryLoaded,
      isLoadingMore,
      isTransitioningRoom,
      nextCursor,
      loadMore,
      sendMessage,
      editMessage,
      revokeMessage,
      updateReadSeq,
      members,
      membersLoading,
      refreshMembers: fetchMembers,
      addMember,
      removeMember,
      updateMemberRole,
      pins,
      pinsLoading,
      refreshPins: fetchPins,
      pinMessage,
      unpinMessage,
      threads,
      refreshThreads: fetchThreads,
      createThread,
      createRoom,
      updateRoom,
      deleteRoom,
      streamingMessages: streamingContent,
    }),
    [
      wsStatus,
      wsError,
      connectWs,
      disconnectWs,
      wsClientRef.current,
      rooms,
      roomsLoading,
      roomsError,
      fetchRooms,
      categories,
      categoriesLoading,
      fetchCategories,
      createCategory,
      updateCategory,
      deleteCategory,
      activeRoom,
      activeRoomId,
      setActiveRoom,
      messages,
      isHistoryLoaded,
      isLoadingMore,
      isTransitioningRoom,
      nextCursor,
      loadMore,
      sendMessage,
      editMessage,
      revokeMessage,
      updateReadSeq,
      members,
      membersLoading,
      fetchMembers,
      addMember,
      removeMember,
      updateMemberRole,
      pins,
      pinsLoading,
      fetchPins,
      pinMessage,
      unpinMessage,
      threads,
      fetchThreads,
      createThread,
      createRoom,
      updateRoom,
      deleteRoom,
      streamingContent,
    ],
  );

  return <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
}

export function useRoom() {
  const ctx = useContext(RoomContext);
  if (!ctx) throw new Error('useRoom must be used within RoomProvider');
  return ctx;
}
