import { createContext, useContextSelector } from 'use-context-selector';
import { ReactNode, useMemo, useCallback, useRef, useEffect } from 'react';
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { API_BASE_URL, TARGET_ENV, ALLOW_ACTIONPANEL_AI_REQUESTS, PLUGIN_REST_ENDPOINTS, getRestNonce } from '../constants';
import { useState } from 'react';
import { usePluginSettings } from './PluginSettingsContext';
import {
  type ConversationsPage,
  type ConversationsPagesData,
  flattenConversationPages,
  normalizeConversationsPage,
  prependConversationToPages,
  removeConversationFromPages,
  updateConversationTitleInPages,
} from '../utils/conversationPages';
import type {
  ChatActivity,
  ChatApprovalState,
  ChatBackendStreamEvent,
  ChatRuntimeUiEvent,
  FinalizedThinkingSummary,
  ThinkingTimelineEntry,
} from '../types/chatStream';
import { useSetIsRateLimitModalOpen } from './UIContext';
import { useOpenProposeChanges } from './UIContext';
import type { ChatAttachmentInput } from '../services/attachments';
import { createBrowserCommandRunner } from '../services/v1BrowserCommands';
import {
  isV1ErrorEvent,
  isV1FinalEvent,
  isV1PauseEvent,
  type V1ChatHttpResponse,
  type V1ChatResponseEvent,
  type V1ChatStreamEnvelope,
} from '../services/v1ChatProtocol';
import {
  buildV1RuntimeContext,
  createFetchV1CommandClient,
  executeV1PauseEvent,
  fetchPluginV1RuntimeDiscovery,
  type V1CommandApprovalRequest,
  type V1CommandApprovalPolicy,
  type V1CommandRuntimeEvent,
  type V1PauseEvent,
  type V1PauseResumePayload,
  type V1RuntimeContext,
} from '../services/v1CommandRuntime';
const DEBUG_UI = false;
const CONVERSATIONS_PAGE_SIZE = 30;
const TITLE_REFRESH_DELAYS_MS = [1200, 2400, 4800, 8000];

declare global {
  interface Window {
    __actionpanelV1DeterministicHelper?: {
      executePause: (
        pause: V1PauseEvent,
        options?: { conversationId?: string }
      ) => Promise<V1PauseResumePayload>;
    };
  }
}


// Empty arrays for stable references
const EMPTY_MESSAGES: Message[] = [];
const EMPTY_CONVERSATIONS: Conversation[] = [];
const EMPTY_ARRAY: any[] = [];

// Message interface
export interface Message {
  message_id: string;
  sent_at: number;
  role: 'user' | 'assistant' | 'assistant-streaming' | 'error';
  content: string;
  attachments?: Array<{
    kind: 'image';
    name?: string;
    url?: string;
    display_url?: string;
    mime?: string;
    attachment_id?: number;
    width?: number;
    height?: number;
  }>;
  status?: 'complete' | 'interrupted';
  finishReason?: string | null;
  userMessageId?: string;
}

function toDisplayAttachments(attachments?: ChatAttachmentInput[]): Message['attachments'] {
  if (!attachments?.length) return undefined;
  return attachments.map(attachment => ({
    kind: attachment.kind,
    name: attachment.name,
    url: attachment.url,
    display_url: attachment.url ?? attachment.data_url,
    mime: attachment.mime,
    attachment_id: attachment.attachment_id,
    width: attachment.width,
    height: attachment.height,
  }));
}

// Conversation metadata interface
export interface Conversation {
  conversation_id: string;
  title: string;
  created_at: number;
  updated_at: number;
}

// Client-only conversation state
interface EphemeralConversationState {
  phase: 'IDLE' | 'THINKING' | 'STREAMING' | 'ERROR';
  isPersisted: boolean;  // On server
  activities: ChatActivity[];
  approvalState?: ChatApprovalState;
  timeline: ThinkingTimelineEntry[];
  hasStartedStreaming: boolean;
  startedAt?: number;
  thoughtDurationMs?: number;
}

// Chat provider props interface
interface ChatProviderProps {
  children: ReactNode;
}

// Types for currently streaming message
type PartialContentsMap = Record<string, string>;
type LatestChunksMap = Record<string, string>;

interface OptimisticCancelledMessage {
  latestUserSentAt: number;
  latestUserContent: string;
}

// Chat context interface
interface ChatContextProps {
  currentConversationId: string | null;
  setCurrentConversationId: (conversationId: string | null) => void;
  conversations: Conversation[];
  messages: Message[];
  conversationsLoading: boolean;
  hasMoreConversations: boolean;
  isFetchingMoreConversations: boolean;
  messagesLoading: boolean;
  ephemeralMap: Record<string, EphemeralConversationState>;
  finalizedThinkingMap: Record<string, FinalizedThinkingSummary>;
  stoppedWorkingMap: Record<string, FinalizedThinkingSummary>;
  sendMessage: (message: string, attachments?: ChatAttachmentInput[]) => Promise<void>;
  cancelCurrentStream: () => void;
  editAndResubmitLatestUserMessage: (messageId: string, text: string) => Promise<void>;
  retryError: (userMessageId: string) => Promise<void>;
  dismissError: (userMessageId: string) => Promise<void>;
  deleteConversation: (conversationId: string) => Promise<void>;
  renameConversation: (conversationId: string, title: string) => Promise<void>;
  loadMoreConversations: () => Promise<void>;
  setOnStreamChunk?: (cb?: (msgId: string, partialText: string, opts?: { isFinal?: boolean }) => void) => void;
  partialContents: PartialContentsMap;
  latestChunks: LatestChunksMap;
}


// Default context value (for typescript)
const defaultContextValue: ChatContextProps = {
  currentConversationId: null,
  setCurrentConversationId: () => {},
  conversations: [],
  messages: [],
  conversationsLoading: false,
  hasMoreConversations: false,
  isFetchingMoreConversations: false,
  messagesLoading: false,
  ephemeralMap: {},
  finalizedThinkingMap: {},
  stoppedWorkingMap: {},
  sendMessage: async () => {},
  cancelCurrentStream: () => {},
  editAndResubmitLatestUserMessage: async () => {},
  retryError: async () => {},
  dismissError: async () => {},
  deleteConversation: async () => {},
  renameConversation: async () => {},
  loadMoreConversations: async () => {},
  partialContents: {},
  latestChunks: {},
};

// Chat context
const ChatContext = createContext<ChatContextProps>(defaultContextValue);

const DEFAULT_EPHEMERAL_STATE: EphemeralConversationState = {
  phase: 'IDLE',
  isPersisted: false,
  activities: [],
  timeline: [],
  hasStartedStreaming: false,
};
const CANCEL_REFETCH_DELAYS_MS = [750, 2000, 5000];

const isVisibleToolActivity = (name?: string) => (
  name === 'site_read'
  || name === 'content_read'
  || name === 'content_write'
  || name === 'site_write'
  || name === 'v1.command'
);

const buildActivityLabel = (name?: string, target?: string) => {
  const targetLabel = target?.trim();
  if (name === 'site_read') return targetLabel ? `Checking ${targetLabel}` : 'Checking site';
  if (name === 'content_read') return targetLabel ? `Reading ${targetLabel}` : 'Reading page content';
  if (name === 'content_write') return targetLabel ? `Editing ${targetLabel}` : 'Editing page content';
  if (name === 'site_write') return targetLabel ? `Preparing ${targetLabel}` : 'Preparing changes';
  if (name === 'v1.command') return 'Working with your site';
  if (targetLabel) return targetLabel;
  return 'Working';
};

const compactStepLabel = (value?: unknown, fallback = 'Working') => {
  const label = (value == null ? '' : String(value)).replace(/\s+/g, ' ').trim();
  if (!label) return fallback;
  if (label.length <= 140) return label;
  const boundary = label.lastIndexOf(' ', 136);
  return `${label.slice(0, boundary > 48 ? boundary : 137).trim()}...`;
};

const toolTimelineId = (requestId: string, batchId?: string) => (
  `tool-${batchId || 'direct'}-${requestId}`
);

const buildConversationTurnKey = (conversationId: string, userTurnIndex: number) =>
  `${conversationId}:turn:${userTurnIndex}`;

const buildFallbackConversationTitle = (messageContent: string) => {
  const words = messageContent.trim().split(/\s+/).filter(Boolean);
  if (words.length === 0) return 'New Chat';

  const title = words.slice(0, 6).join(' ').replace(/^[.,!?;:\s]+|[.,!?;:\s]+$/g, '');
  if (!title) return 'New Chat';
  return title.length <= 60 ? title : `${title.slice(0, 57).trimEnd()}...`;
};

const isToolHarnessBridgeEnabled = () => (
  typeof window !== 'undefined'
  && new URLSearchParams(window.location.search).get('actionpanel_tool_harness') === '1'
);

const getV1CallingSurface = () => (
  TARGET_ENV === 'wordpress' ? 'wp_admin_plugin_ui' : 'hosted_web_app'
);

type V1ChatSource = 'web' | 'plugin';

const getV1ChatSource = (): V1ChatSource => (
  TARGET_ENV === 'wordpress' ? 'plugin' : 'web'
);

type V1RuntimeApprovalEvent = Extract<
  V1CommandRuntimeEvent,
  { type: 'approval_needed' | 'approval_granted' | 'approval_rejected' }
>;

const getV1RuntimeApprovalState = (
  event: V1RuntimeApprovalEvent
): Extract<ChatRuntimeUiEvent, { type: 'approval' }>['state'] => {
  switch (event.type) {
    case 'approval_needed':
      return 'needed';
    case 'approval_granted':
      return 'granted';
    case 'approval_rejected':
      return 'rejected';
  }
};

const readV1NdjsonResponse = async (
  response: Response,
  onStreamEnvelope?: (envelope: V1ChatStreamEnvelope) => Promise<void>,
): Promise<V1ChatHttpResponse | null> => {
  const reader = response.body?.getReader();
  if (!reader) {
    return await response.json();
  }

  const decoder = new TextDecoder();
  let buffered = '';
  let terminalEnvelope: V1ChatHttpResponse | null = null;

  const consumeLine = async (line: string) => {
    const trimmed = line.trim();
    if (!trimmed) return;
    const envelope = JSON.parse(trimmed) as V1ChatStreamEnvelope;
    if (envelope.outcome === 'streaming') {
      await onStreamEnvelope?.(envelope);
      return;
    }
    terminalEnvelope = envelope as V1ChatHttpResponse;
  };

  while (true) {
    const { done, value } = await reader.read();
    if (value) {
      buffered += decoder.decode(value, { stream: !done });
      let newlineIndex = buffered.indexOf('\n');
      while (newlineIndex >= 0) {
        const line = buffered.slice(0, newlineIndex);
        buffered = buffered.slice(newlineIndex + 1);
        await consumeLine(line);
        newlineIndex = buffered.indexOf('\n');
      }
    }
    if (done) break;
  }

  buffered += decoder.decode();
  await consumeLine(buffered);
  return terminalEnvelope;
};

// Chat provider component
export const ChatProvider = ({ children }: ChatProviderProps) => {

  // React Query client
  const queryClient = useQueryClient();

  // State variables
  const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
  const [partialContents, setPartialContents] = useState<PartialContentsMap>({});
  const [latestChunks, setLatestChunks] = useState<LatestChunksMap>({});
  const [ephemeralMap, setEphemeralMap] = useState<Record<string, EphemeralConversationState>>({});
  const [finalizedThinkingMap, setFinalizedThinkingMap] = useState<Record<string, FinalizedThinkingSummary>>({});
  const [stoppedWorkingMap, setStoppedWorkingMap] = useState<Record<string, FinalizedThinkingSummary>>({});
  const setIsRateLimitModalOpen = useSetIsRateLimitModalOpen();
  const openProposeChanges = useOpenProposeChanges();
  const pluginSettings = usePluginSettings();

  // Reader and abort controller refs
  const readerRefs = useRef<Record<string, ReadableStreamDefaultReader | null>>({});
  const abortRefs = useRef<Record<string, AbortController | null>>({});  
  const activeTurnRefs = useRef<Record<string, string>>({});
  const cancellationRequestedRef = useRef<Record<string, boolean>>({});
  const optimisticCancelledMessagesRef = useRef<Record<string, OptimisticCancelledMessage>>({});
  const ephemeralMapRef = useRef<Record<string, EphemeralConversationState>>({});
  const v1ApprovalPolicyByConversationRef = useRef<Record<string, V1CommandApprovalPolicy>>({});
  const titleRefreshTimersRef = useRef<Record<string, number[]>>({});
  // Stream callback ref to update partial content
  const streamCallbackRef = useRef<(msgId: string, partialText: string, opts?: { isFinal?: boolean }) => void>();

  useEffect(() => () => {
    Object.values(titleRefreshTimersRef.current)
      .flat()
      .forEach(timerId => window.clearTimeout(timerId));
    titleRefreshTimersRef.current = {};
  }, []);

  const setStoppedWorkingSummary = useCallback((conversationId: string, summary: FinalizedThinkingSummary | null) => {
    setStoppedWorkingMap(prev => {
      if (!summary) {
        if (!(conversationId in prev)) return prev;
        const copy = { ...prev };
        delete copy[conversationId];
        return copy;
      }
      return { ...prev, [conversationId]: summary };
    });
  }, []);


    // ================= //
   // REACT QUERY STUFF //
  // ================= //

  const allowActionPanelAiRequests = TARGET_ENV === 'wordpress'
    ? pluginSettings.allowActionPanelAiRequests
    : ALLOW_ACTIONPANEL_AI_REQUESTS;
  const requireReadApproval = Boolean(pluginSettings.requireReadApproval);

  const updateConversationState = useCallback((
    conversationId: string,
    partial: Partial<EphemeralConversationState>
  ): EphemeralConversationState => {
    let newState: EphemeralConversationState;
    setEphemeralMap(prev => {
      const oldState = prev[conversationId] ?? DEFAULT_EPHEMERAL_STATE;
      newState = { ...oldState, ...partial };
      if (
        oldState.phase === newState.phase &&
        oldState.isPersisted === newState.isPersisted &&
        oldState.timeline === newState.timeline &&
        oldState.hasStartedStreaming === newState.hasStartedStreaming &&
        oldState.startedAt === newState.startedAt &&
        oldState.thoughtDurationMs === newState.thoughtDurationMs &&
        oldState.activities === newState.activities &&
        oldState.approvalState === newState.approvalState
      ) {
        return prev;
      }
      const nextMap = { ...prev, [conversationId]: newState };
      ephemeralMapRef.current = nextMap;
      return nextMap;
    });
    return newState!;
  }, []);

  // Function to fetch one page of conversations
  const fetchConversationsPage = useCallback(async (cursor: string | null): Promise<ConversationsPage> => {
    if (!allowActionPanelAiRequests) {
      return { items: [], next_cursor: null };
    }
    const params = new URLSearchParams({ limit: String(CONVERSATIONS_PAGE_SIZE) });
    if (cursor) {
      params.set('cursor', cursor);
    }
    const response = await fetch(`${API_BASE_URL}/conversations?${params.toString()}`, {
      method: 'GET',
      credentials: 'include',
    });
    if (!response.ok) {
      throw new Error('Failed to fetch conversations');
    }
    const page = normalizeConversationsPage(await response.json());
    page.items.forEach((conversation: Conversation) => {
        updateConversationState(conversation.conversation_id, {
          ...DEFAULT_EPHEMERAL_STATE,
          isPersisted: true,
        });
    });
    return page;
  }, [allowActionPanelAiRequests, updateConversationState]);

  const fetchMessagesForConversation = useCallback(async (conversationId: string): Promise<Message[]> => {
    if (!allowActionPanelAiRequests) return [];
    const response = await fetch(`${API_BASE_URL}/conversations/${conversationId}`, {
      method: 'GET',
      credentials: 'include',
    });
    if (!response.ok) {
      throw new Error('Failed to fetch messages');
    }
    const data = await response.json();
    // Map `message_id` to `id`
    const mappedMessages: Message[] = data.messages.map((msg: any) => ({
      message_id: msg.message_id,
      sent_at: msg.sent_at,
      role: msg.role,
      content: typeof msg.content === 'string' ? msg.content : '',
      attachments: Array.isArray(msg.attachments) ? msg.attachments : undefined,
      status: msg.status ?? 'complete',
      finishReason: msg.finish_reason ?? null,
    }));
    const optimisticCancellation = optimisticCancelledMessagesRef.current[conversationId];
    if (!optimisticCancellation) {
      return mappedMessages;
    }

    const serverCancelledUserIndex = mappedMessages.reduce((latestIndex, message, index) => (
      message.role === 'user' && (
        message.sent_at >= optimisticCancellation.latestUserSentAt ||
        message.content === optimisticCancellation.latestUserContent
      )
        ? index
        : latestIndex
    ), -1);
    const serverAssistantAfterCancelledTurn = mappedMessages.find((message, index) => {
      const isAfterCancelledTurn = (
        message.sent_at > optimisticCancellation.latestUserSentAt ||
        (serverCancelledUserIndex >= 0 && index > serverCancelledUserIndex)
      );
      const isDisplayableAssistant = (
        message.role === 'assistant' &&
        (message.content.trim() || message.status === 'interrupted')
      );
      return isAfterCancelledTurn && isDisplayableAssistant;
    });
    if (serverAssistantAfterCancelledTurn) {
      delete optimisticCancelledMessagesRef.current[conversationId];
      setStoppedWorkingSummary(conversationId, null);
      return mappedMessages;
    }

    const cachedMessages = queryClient.getQueryData<Message[]>(['messages', conversationId]) || EMPTY_MESSAGES;
    const hasServerUserAfterCancelledTurn = serverCancelledUserIndex >= 0;
    const localCancelledTurnMessages = cachedMessages.filter(message => (
      message.sent_at >= optimisticCancellation.latestUserSentAt &&
      !(hasServerUserAfterCancelledTurn && message.role === 'user') &&
      !mappedMessages.some(serverMessage => serverMessage.message_id === message.message_id)
    ));
    return [...mappedMessages, ...localCancelledTurnMessages].sort((left, right) => left.sent_at - right.sent_at);
  }, [allowActionPanelAiRequests, queryClient, setStoppedWorkingSummary]);

  // Function to fetch messages for current conversation
  const fetchMessages = useCallback(async (): Promise<Message[]> => {
    if (!currentConversationId) return [];
    return fetchMessagesForConversation(currentConversationId);
  }, [currentConversationId, fetchMessagesForConversation]);

  // React Query hook to fetch conversations
  const {
    data: conversationPages,
    isFetching: conversationsFetching,
    fetchNextPage: fetchNextConversationsPage,
    hasNextPage: hasMoreConversations = false,
    isFetchingNextPage: isFetchingMoreConversations,
  } = useInfiniteQuery<ConversationsPage, Error, ConversationsPagesData, string[], string | null>({
    queryKey: ['conversations'],
    queryFn: ({ pageParam }) => fetchConversationsPage(pageParam),
    initialPageParam: null,
    getNextPageParam: (lastPage) => lastPage.next_cursor,
    enabled: allowActionPanelAiRequests && Object.values(ephemeralMap).every(state => state.phase == 'IDLE'),
    retry: false,
    staleTime: 30000,
  });

  // Conversation state variable
  const conversations = useMemo(() => {
    const flattened = flattenConversationPages(conversationPages);
    return flattened.length > 0 ? flattened : EMPTY_CONVERSATIONS;
  }, [conversationPages]);

  // React Query hook to fetch messages for current conversation
  const {
    data: rawMessages = EMPTY_MESSAGES,
    isFetching: messagesFetching,
  } = useQuery<Message[], Error>({
    queryKey: ['messages', currentConversationId],
    queryFn: fetchMessages,
    enabled: (
      allowActionPanelAiRequests &&
      !!currentConversationId &&
      (ephemeralMap[currentConversationId]?.phase ?? 'IDLE') === 'IDLE' &&
      (ephemeralMap[currentConversationId]?.isPersisted ?? true)
    ),
    staleTime: 1000 * 60, // 1 minute
    gcTime: 1000 * 60, // 1 minute
  });


    // ================ //
   // HELPER FUNCTIONS //
  // ================ //

  // Update the ephemeral state of a conversation
  // Add user message to an existing conversation
  const addNewMessage = useCallback((
    role: 'user' | 'assistant' | 'error',
    messageContent: string,
    targetConversationId: string,
    userMessageId?: string,
    metadata?: Pick<Message, 'status' | 'finishReason' | 'attachments'>
  ) => {
    // Create user message
    const newMessageId = uuidv4();
    const newMessage: Message = {
      message_id: newMessageId,
      sent_at: Date.now(),
      role: role,
      content: messageContent,
      status: metadata?.status,
      finishReason: metadata?.finishReason,
      attachments: metadata?.attachments,
      userMessageId: userMessageId,
    };
    // Add user message to react query
    queryClient.setQueryData<Message[]>(
      ['messages', targetConversationId],
      (old = []) => [...old, newMessage]
    );
    return newMessageId;
  }, [queryClient]);

  const clearTitleRefreshTimers = useCallback((conversationId: string) => {
    titleRefreshTimersRef.current[conversationId]?.forEach(timerId => window.clearTimeout(timerId));
    delete titleRefreshTimersRef.current[conversationId];
  }, []);

  const applyConversationTitle = useCallback((conversationId: string, title: string) => {
    queryClient.setQueryData<ConversationsPagesData>(
      ['conversations'],
      (old) => updateConversationTitleInPages(old, conversationId, title)
    );
  }, [queryClient]);

  const scheduleGeneratedTitleRefresh = useCallback((conversationId: string, fallbackTitle: string) => {
    clearTitleRefreshTimers(conversationId);

    titleRefreshTimersRef.current[conversationId] = TITLE_REFRESH_DELAYS_MS.map((delayMs, index) => (
      window.setTimeout(async () => {
        try {
          const response = await fetch(`${API_BASE_URL}/conversations/${conversationId}/metadata`, {
            method: 'GET',
            credentials: 'include',
          });
          if (!response.ok) return;

          const payload = await response.json();
          const generatedTitle = typeof payload.title === 'string' ? payload.title.trim() : '';
          if (generatedTitle && generatedTitle !== fallbackTitle) {
            applyConversationTitle(conversationId, generatedTitle);
            clearTitleRefreshTimers(conversationId);
          }
        } catch {
          // Generated titles are a progressive enhancement; fallback titles remain valid.
        } finally {
          if (index === TITLE_REFRESH_DELAYS_MS.length - 1) {
            delete titleRefreshTimersRef.current[conversationId];
          }
        }
      }, delayMs)
    ));
  }, [applyConversationTitle, clearTitleRefreshTimers]);

  // Add a new conversation to react query
  const addNewConversation = useCallback((messageContent: string) => {
    // Create new conversation
    const newConversationId = uuidv4();
    const fallbackTitle = buildFallbackConversationTitle(messageContent);
    const newConversation: Conversation = {
      conversation_id: newConversationId,
      title: fallbackTitle,
      created_at: Date.now(),
      updated_at: Date.now(),
    };
    // Add it to react query
    queryClient.setQueryData<ConversationsPagesData>(
      ['conversations'],
      (old) => prependConversationToPages(old, newConversation)
    );
    return { conversationId: newConversationId, fallbackTitle };
  }, [queryClient]);

  const buildCurrentV1RuntimeContext = useCallback(async (): Promise<V1RuntimeContext> => {
    if (TARGET_ENV !== 'wordpress') {
      throw new Error('V1 Capability Snapshots are only available in WordPress runtime.');
    }
    return buildV1RuntimeContext({
      fetchJson: () => fetchPluginV1RuntimeDiscovery({
        endpoint: PLUGIN_REST_ENDPOINTS.capabilities,
        restNonce: getRestNonce(),
      }),
      callingSurface: getV1CallingSurface(),
      assistantSettings: {
        allow_log_reads: Boolean(pluginSettings.allowLogReads),
        allow_write_operations: Boolean(pluginSettings.allowWriteOperations),
      },
      requireReadApproval,
    });
  }, [
    pluginSettings.allowLogReads,
    pluginSettings.allowWriteOperations,
    requireReadApproval,
  ]);

  const parseV1ChatResponse = useCallback(async (
    response: Response,
    onStreamEnvelope?: (envelope: V1ChatStreamEnvelope) => Promise<void>,
  ): Promise<V1ChatHttpResponse> => {
    if (!response.ok) {
      if (response.status === 429) {
        const error = new Error('Rate limit exceeded');
        error.name = 'RateLimitError';
        throw error;
      }
      throw new Error(response.statusText);
    }
    const contentType = response.headers.get('content-type') || '';
    if (contentType.includes('application/x-ndjson')) {
      const terminalEnvelope = await readV1NdjsonResponse(response, onStreamEnvelope);
      if (!terminalEnvelope) {
        throw new Error('V1 chat stream ended without a terminal event.');
      }
      return terminalEnvelope;
    }
    return await response.json();
  }, []);

  const sendV1ChatStartRequest = useCallback(async (
    messageContent: string,
    conversationId: string,
    signal: AbortSignal,
    onStreamEnvelope?: (envelope: V1ChatStreamEnvelope) => Promise<void>,
    editTarget?: { message_id: string; sent_at: number },
    attachments?: ChatAttachmentInput[],
  ): Promise<V1ChatHttpResponse> => {
    if (!allowActionPanelAiRequests) {
      const error = new Error('AssistantDisabled');
      error.name = 'AssistantDisabledError';
      throw error;
    }

    const source = getV1ChatSource();
    const runtimeContext = source === 'plugin'
      ? await buildCurrentV1RuntimeContext()
      : null;
    const payload: Record<string, unknown> = {
      source,
      message: messageContent,
      conversation_id: conversationId,
      ...(editTarget ? { edit_target: editTarget } : {}),
      ...(attachments?.length ? { attachments } : {}),
    };
    if (runtimeContext) {
      payload.capability_snapshot = runtimeContext.capabilitySnapshot;
      v1ApprovalPolicyByConversationRef.current[conversationId] = runtimeContext.approvalPolicy;
    }

    const response = await fetch(`${API_BASE_URL}/v1/chat`, {
      method: 'POST',
      credentials: 'include',
      headers: {
        'Accept': 'application/x-ndjson',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
      signal: signal,
    });
    return parseV1ChatResponse(response, onStreamEnvelope);
  }, [allowActionPanelAiRequests, buildCurrentV1RuntimeContext, parseV1ChatResponse]);

  const sendV1ChatResumeRequest = useCallback(async (
    turnId: string,
    resumePayload: V1PauseResumePayload,
    signal: AbortSignal,
    onStreamEnvelope?: (envelope: V1ChatStreamEnvelope) => Promise<void>,
  ): Promise<V1ChatHttpResponse> => {
    const response = await fetch(`${API_BASE_URL}/v1/chat/${turnId}/resume`, {
      method: 'POST',
      credentials: 'include',
      headers: {
        'Accept': 'application/x-ndjson',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(resumePayload),
      signal,
    });
    return parseV1ChatResponse(response, onStreamEnvelope);
  }, [parseV1ChatResponse]);

  const requestTurnCancellation = useCallback(async (
    turnId: string,
    partialContent = '',
  ) => {
    try {
      await fetch(`${API_BASE_URL}/v1/chat/${turnId}/cancel`, {
        method: 'POST',
        credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ partial_content: partialContent }),
      });
	    } catch {
	      // Local abort cancels active streams. The cancel endpoint covers paused
	      // turns and pause/cancel races, so refetch still reconciles server state.
	    }
	  }, []);

  // Helper to set or clear partial text and recent chunks
  const setPartialContent = useCallback(
    (targetConversationId: string, content: string | null, chunk: string | null) => {
      // Update partial contents
      setPartialContents(old => {
        const oldContent = old[targetConversationId] ?? null;
        // If the new content is identical to old content, skip the update:
        if (oldContent === content) {
          return old;
        }
        const copy = { ...old };
        if (content === null) {
          delete copy[targetConversationId];
        } else {
          copy[targetConversationId] = content;
        }
        return copy;
      });
      // Update recent chunks
      setLatestChunks(old => {
        const copy = { ...old };
        if (content === null) {
          delete copy[targetConversationId];
        } else if (chunk) {
          copy[targetConversationId] = chunk;
        }
        return copy;
      });
    },
    []
  );

  const patchConversationState = useCallback((
    targetConversationId: string,
    updater: (current: EphemeralConversationState) => EphemeralConversationState
  ) => {
    setEphemeralMap(prev => {
      const current = prev[targetConversationId] ?? DEFAULT_EPHEMERAL_STATE;
      const next = updater(current);
      const nextMap = { ...prev, [targetConversationId]: next };
      ephemeralMapRef.current = nextMap;
      return nextMap;
    });
  }, []);

  const setFinalizedThinkingSummary = useCallback((messageId: string, summary: FinalizedThinkingSummary | null) => {
    setFinalizedThinkingMap(prev => {
      if (!summary) {
        if (!(messageId in prev)) return prev;
        const copy = { ...prev };
        delete copy[messageId];
        return copy;
      }
      return { ...prev, [messageId]: summary };
    });
  }, []);

  const applyStreamEvent = useCallback((targetConversationId: string, event: ChatBackendStreamEvent | ChatRuntimeUiEvent) => {
    if (event.type === 'tool_start') {
      patchConversationState(targetConversationId, (current) => {
        let activities = current.activities;
        let timeline = current.timeline;
        if (isVisibleToolActivity(event.name)) {
          const displayId = toolTimelineId(event.id, event.batchId);
          const label = event.label ? compactStepLabel(event.label) : buildActivityLabel(event.name, event.target);
          const existingIdx = current.activities.findIndex(activity => activity.id === displayId);
          const nextActivity: ChatActivity = {
            id: displayId,
            requestId: event.id,
            name: event.name,
            surface: event.surface,
            state: 'running',
            label,
            target: event.target,
            batchId: event.batchId,
          };
          activities = existingIdx < 0
            ? [...current.activities, nextActivity]
            : current.activities.map((activity, idx) => idx === existingIdx ? { ...activity, ...nextActivity } : activity);
          const existingTimelineIdx = current.timeline.findIndex(item => item.id === displayId);
          const nextTimelineEntry: ThinkingTimelineEntry = {
            id: displayId,
            kind: 'tool',
            name: event.name,
            label,
            state: 'running',
            target: event.target,
          };
          timeline = existingTimelineIdx < 0
            ? [...current.timeline, nextTimelineEntry]
            : current.timeline.map((item, idx) => idx === existingTimelineIdx ? nextTimelineEntry : item);
        }
        return {
          ...current,
          phase: 'THINKING',
          approvalState: undefined,
          activities,
          timeline,
        };
      });
      return;
    }

    if (event.type === 'tool_end') {
      patchConversationState(targetConversationId, (current) => {
        const displayId = toolTimelineId(event.id, event.batchId);
        const activities = current.activities.map(activity => (
          activity.id === displayId
            ? {
                ...activity,
                state: event.ok ? 'done' as const : 'error' as const,
                summary: compactStepLabel(event.summary, activity.summary || activity.label),
              }
            : activity
        ));
        const timeline = current.timeline.map(item => (
          item.id === displayId && item.kind === 'tool'
            ? {
                ...item,
                state: event.ok ? 'done' as const : 'error' as const,
                label: compactStepLabel(event.summary, item.label),
              }
            : item
        ));
        return {
          ...current,
          activities,
          timeline,
        };
      });
      return;
    }

    if (event.type === 'work_step') {
      patchConversationState(targetConversationId, (current) => {
        const label = compactStepLabel(event.label);
        const id = `work-${event.id}`;
        const nextEntry: ThinkingTimelineEntry = {
          id,
          kind: 'work_step',
          label,
          state: event.state || 'done',
          source: event.source,
        };
        const existingIdx = current.timeline.findIndex(item => item.id === id);
        const existingEntry = existingIdx >= 0 ? current.timeline[existingIdx] : null;
        const shouldUpdateExisting = existingEntry?.kind === 'work_step' && existingEntry.state === 'running';
        const timeline = existingIdx < 0
          ? [...current.timeline, nextEntry]
          : shouldUpdateExisting
            ? current.timeline.map((item, idx) => idx === existingIdx ? nextEntry : item)
            : [...current.timeline, { ...nextEntry, id: `${id}-${current.timeline.length}` }];
        return {
          ...current,
          timeline,
        };
      });
      return;
    }

    if (event.type === 'approval') {
      patchConversationState(targetConversationId, (current) => {
        const displayId = toolTimelineId(event.id, event.batchId);
        const state = event.state === 'rejected' ? 'error' as const : 'running' as const;
        const approvalLabel = event.state === 'needed'
          ? 'Waiting for approval'
          : event.state === 'granted'
            ? 'Approval granted'
            : 'Approval rejected';
        const updateMatchingTool = <T extends { id: string; state?: 'running' | 'done' | 'error'; label: string }>(item: T): T => (
          item.id === displayId
            ? { ...item, state, label: approvalLabel }
            : item
        );
        const activities = current.activities.map(updateMatchingTool);
        const timeline = current.timeline.map(item => (
          item.kind === 'tool' ? updateMatchingTool(item) : item
        ));
        return {
          ...current,
          phase: 'THINKING',
          approvalState: event.state === 'needed'
            ? {
                state: event.state,
                tool: event.tool,
                target: event.target,
              }
            : undefined,
          activities,
          timeline,
        };
      });
      return;
    }

    if (event.type === 'message_delta') {
      patchConversationState(targetConversationId, (current) => ({
        ...current,
        phase: 'STREAMING',
        hasStartedStreaming: true,
        thoughtDurationMs: current.thoughtDurationMs ?? (
          current.startedAt ? Math.max(1, Date.now() - current.startedAt) : current.thoughtDurationMs
        ),
      }));
      return;
    }
  }, [patchConversationState]);

  const clearConversationLocally = useCallback((conversationId: string) => {
    clearTitleRefreshTimers(conversationId);
    queryClient.setQueryData<ConversationsPagesData>(
      ['conversations'],
      (old) => removeConversationFromPages(old, conversationId)
    );
    queryClient.removeQueries({ queryKey: ['messages', conversationId], exact: true });
    setPartialContent(conversationId, null, null);
    delete optimisticCancelledMessagesRef.current[conversationId];
    setStoppedWorkingSummary(conversationId, null);
    setEphemeralMap(prev => {
      if (!(conversationId in prev)) return prev;
      const copy = { ...prev };
      delete copy[conversationId];
      ephemeralMapRef.current = copy;
      return copy;
    });
    setFinalizedThinkingMap(prev => {
      const prefix = `${conversationId}:turn:`;
      let changed = false;
      const next = Object.fromEntries(
        Object.entries(prev).filter(([key]) => {
          if (key.startsWith(prefix)) {
            changed = true;
            return false;
          }
          return true;
        })
      );
      return changed ? next : prev;
    });
  }, [clearTitleRefreshTimers, queryClient, setPartialContent, setStoppedWorkingSummary]);

  const deleteConversationSilently = useCallback(async (conversationId: string) => {
    try {
      await fetch(`${API_BASE_URL}/conversations/${conversationId}`, {
        method: 'DELETE',
        credentials: 'include',
      });
    } catch (error) {
      console.error('Error deleting conversation:', error);
    } finally {
      clearConversationLocally(conversationId);
      if (currentConversationId === conversationId) {
        setCurrentConversationId(null);
      }
    }
  }, [clearConversationLocally, currentConversationId, setCurrentConversationId]);

  const buildFinalizedThinkingSummary = useCallback((
    conversationId: string,
    options?: { includeEmpty?: boolean }
  ): FinalizedThinkingSummary | null => {
    const state = ephemeralMapRef.current[conversationId] ?? DEFAULT_EPHEMERAL_STATE;
    const durationMs = state.thoughtDurationMs ?? (
      state.startedAt ? Math.max(1, Date.now() - state.startedAt) : 0
    );
    const visibleTimeline = state.timeline;
    if (visibleTimeline.length === 0 && !options?.includeEmpty) {
      return null;
    }
    return {
      durationMs: Math.max(1, durationMs),
      activities: state.activities.filter(activity => isVisibleToolActivity(activity.name)),
      approvalState: state.approvalState,
      timeline: visibleTimeline,
    };
  }, []);

  const getCurrentUserTurnKey = useCallback((conversationId: string): string | null => {
    const conversationMessages = queryClient.getQueryData<Message[]>(['messages', conversationId]) || EMPTY_MESSAGES;
    let userTurnIndex = 0;
    for (const message of conversationMessages) {
      if (message.role === 'user') {
        userTurnIndex += 1;
      }
    }
    if (userTurnIndex <= 0) return null;
    return buildConversationTurnKey(conversationId, userTurnIndex);
  }, [queryClient]);

  const isFailedFirstTurnConversation = useCallback((conversationMessages: Message[], userMessageId: string) => {
    const nonErrorMessages = conversationMessages.filter(msg => msg.role !== 'error');
    return (
      nonErrorMessages.length === 1 &&
      nonErrorMessages[0].role === 'user' &&
      nonErrorMessages[0].message_id === userMessageId
    );
  }, []);

  const deletePersistedUserMessage = useCallback(async (
    conversationId: string,
    userMessageId: string,
    sentAt: number
  ) => {
    try {
      await fetch(
        `${API_BASE_URL}/conversations/${conversationId}/messages/${userMessageId}?sent_at=${sentAt}`,
        {
          method: 'DELETE',
          credentials: 'include',
        }
      );
    } catch (error) {
      console.error('Error deleting persisted user message:', error);
    }
  }, []);

  const emitV1RuntimeEvent = useCallback((
    targetConversationId: string,
    event: V1CommandRuntimeEvent
  ) => {
    if (event.type === 'command_started') {
      applyStreamEvent(targetConversationId, {
        type: 'tool_start',
        id: event.id,
        name: 'v1.command',
        surface: 'site',
        target: event.target,
        batchId: event.batch_id,
        label: event.label,
      });
      return;
    }

    if (event.type === 'command_completed') {
      applyStreamEvent(targetConversationId, {
        type: 'tool_end',
        id: event.id,
        ok: event.ok,
        summary: event.summary,
        batchId: event.batch_id,
      });
      return;
    }

    applyStreamEvent(targetConversationId, {
      type: 'approval',
      id: event.id,
      state: getV1RuntimeApprovalState(event),
      tool: event.label,
      target: event.target,
      batchId: event.batch_id,
    });
  }, [applyStreamEvent]);

  const requestV1CommandApproval = useCallback(async (
    request: V1CommandApprovalRequest
  ) => {
    const decision = await openProposeChanges({
      protocol: 'v1',
      approval: request.approval,
      commands: request.commands,
      results: request.results,
      display: request.display,
    });
    return {
      approved: decision.approved,
      reason: decision.user_reason,
      approval: decision.approved
        ? {
            approved_at: new Date().toISOString(),
            ...(decision.user_reason ? { user_reason: decision.user_reason } : {}),
          }
        : undefined,
    };
  }, [openProposeChanges]);

  const v1BrowserCommandRunner = useMemo(() => createBrowserCommandRunner(), []);

  const executeV1PauseRequest = useCallback(async (
    pause: V1PauseEvent,
    targetConversationId: string,
  ) => (
    executeV1PauseEvent(pause, {
      commandClient: createFetchV1CommandClient({
        endpoint: PLUGIN_REST_ENDPOINTS.commands,
        restNonce: getRestNonce(),
      }),
      browserCommandRunner: v1BrowserCommandRunner,
      approvalPolicy: v1ApprovalPolicyByConversationRef.current[targetConversationId],
      requestApproval: requestV1CommandApproval,
      emitEvent: event => emitV1RuntimeEvent(targetConversationId, event),
    })
  ), [emitV1RuntimeEvent, requestV1CommandApproval, v1BrowserCommandRunner]);

  useEffect(() => {
    if (!isToolHarnessBridgeEnabled()) return undefined;
    window.__actionpanelV1DeterministicHelper = {
      executePause: async (pause, options = {}) => {
        const conversationId = options.conversationId || currentConversationId || `v1-deterministic-${uuidv4()}`;
        return executeV1PauseRequest(pause, conversationId);
      },
    };
    return () => {
      delete window.__actionpanelV1DeterministicHelper;
    };
  }, [currentConversationId, executeV1PauseRequest]);

  // Handles the V1 turn envelope from the Backend route family.
  const handleV1ChatResponse = useCallback(async (
    startTurn: (onStreamEnvelope: (envelope: V1ChatStreamEnvelope) => Promise<void>) => Promise<V1ChatHttpResponse>,
    targetConversationId: string,
  ) => {
    let assistantMessageContent = '';
    let envelope: V1ChatHttpResponse | null = null;

    const appendAssistantMessageContent = (chunk: string) => {
      assistantMessageContent += chunk;
      setPartialContent(targetConversationId, assistantMessageContent, chunk);
      streamCallbackRef.current?.(targetConversationId, assistantMessageContent);
    };

    const resumeAfterPause = async (
      pause: V1PauseEvent,
      turnId: string,
    ): Promise<V1ChatHttpResponse> => {
      const resumePayload = await executeV1PauseRequest(pause, targetConversationId);
      return await sendV1ChatResumeRequest(
        turnId,
        resumePayload,
        abortRefs.current[targetConversationId]?.signal ?? new AbortController().signal,
        handleStreamEnvelope,
      );
    };

    const handleEvent = async (
      event: V1ChatResponseEvent,
      turnId: string,
    ): Promise<V1ChatHttpResponse | null> => {
      if (isV1PauseEvent(event)) {
        return await resumeAfterPause(event, turnId);
      }
      if (isV1ErrorEvent(event)) {
        throw new Error(event.message);
      }
      if (isV1FinalEvent(event)) {
        if (event.message) {
          appendAssistantMessageContent(event.message);
        }
        return null;
      }
      if (event.type === 'v1.cancelled') {
        return null;
      }
      applyStreamEvent(targetConversationId, event);
      if (event.type === 'message_delta') {
        appendAssistantMessageContent(event.text);
      }
      return null;
    };

    const handleStreamEnvelope = async (streamEnvelope: V1ChatStreamEnvelope) => {
      activeTurnRefs.current[targetConversationId] = streamEnvelope.turn_id;
      for (const event of streamEnvelope.events || []) {
        if (
          isV1PauseEvent(event)
          || isV1FinalEvent(event)
          || isV1ErrorEvent(event)
          || event.type === 'v1.cancelled'
        ) {
          continue;
        }
        applyStreamEvent(targetConversationId, event);
        if (event.type === 'message_delta') {
          appendAssistantMessageContent(event.text);
        }
      }
    };

    envelope = await startTurn(handleStreamEnvelope);

    while (envelope) {
      activeTurnRefs.current[targetConversationId] = envelope.turn_id;
      const events = Array.isArray(envelope.events) ? envelope.events : [];
      let nextEnvelope: V1ChatHttpResponse | null = null;
      for (const event of events) {
        nextEnvelope = await handleEvent(event, envelope.turn_id);
        if (nextEnvelope) break;
      }
      if (nextEnvelope) {
        envelope = nextEnvelope;
        continue;
      }
      if (envelope.outcome === 'error') {
        throw new Error('V1 chat turn failed.');
      }
      if (envelope.outcome === 'paused') {
        throw new Error('V1 chat turn paused without a resumable pause event.');
      }
      envelope = null;
    }

    if (cancellationRequestedRef.current[targetConversationId]) {
      return;
    }

    // Finalize assistant message
    if (assistantMessageContent) {
      const finalizedThinkingSummary = buildFinalizedThinkingSummary(targetConversationId);
      addNewMessage('assistant', assistantMessageContent, targetConversationId);
      if (finalizedThinkingSummary) {
        const turnKey = getCurrentUserTurnKey(targetConversationId);
        if (turnKey) {
          setFinalizedThinkingSummary(turnKey, finalizedThinkingSummary);
        }
      }
    }
    try {
      const persistedMessages = await fetchMessagesForConversation(targetConversationId);
      queryClient.setQueryData<Message[]>(['messages', targetConversationId], persistedMessages);
    } catch {
      queryClient.invalidateQueries({ queryKey: ['messages', targetConversationId] });
    }
    updateConversationState(targetConversationId, {
      ...DEFAULT_EPHEMERAL_STATE,
      isPersisted: true,
    });
    queryClient.invalidateQueries({ queryKey: ['conversations'] });
    if (DEBUG_UI) console.log('[UI] phase=IDLE', { conv: targetConversationId });
    setPartialContent(targetConversationId, null, null);
  }, [
    addNewMessage,
    applyStreamEvent,
    executeV1PauseRequest,
    fetchMessagesForConversation,
    sendV1ChatResumeRequest,
    buildFinalizedThinkingSummary,
    getCurrentUserTurnKey,
    queryClient,
    setPartialContent,
    setFinalizedThinkingSummary,
    updateConversationState,
  ]);

  // Handles rate limit errors and other errors
  const handleChatError = useCallback((
    error: Error,
    targetConversationId: string,
    userMessageId: string
  ) => {
      setPartialContent(targetConversationId!, null, null);
      // If it's a rate limit error...
      if (error.name === 'RateLimitError') {
        updateConversationState(targetConversationId!, { ...DEFAULT_EPHEMERAL_STATE, isPersisted: true }); // Set conversation as idle
        setIsRateLimitModalOpen(true); // Show rate limit modal
        // Refresh conversations and messages (deletes any optimistic updates not saved to server)
        queryClient.invalidateQueries({ queryKey: ['conversations'] });
        queryClient.invalidateQueries({ queryKey: ['messages'] });
        setCurrentConversationId(null); // Reset current conversation ID
      // Otherwise, it's a different error...
      } else if (userMessageId) {
        // Set error state
        updateConversationState(targetConversationId!, {
          ...DEFAULT_EPHEMERAL_STATE,
          phase: 'ERROR',
        });
        // Add error message to conversation
        addNewMessage('error', 'Something went wrong. Please try again.', targetConversationId!, userMessageId);
      }
  }, [addNewMessage, queryClient, setCurrentConversationId, setIsRateLimitModalOpen, setPartialContent, updateConversationState]);

  // Cleanup reader and abort controller refs for a conversation
  const cleanup = useCallback((conversationId: string) => {
    if (abortRefs.current[conversationId]) {
      abortRefs.current[conversationId]?.abort();
      abortRefs.current[conversationId] = null;
    }
    if (readerRefs.current[conversationId]) {
      readerRefs.current[conversationId]?.cancel();
      readerRefs.current[conversationId]?.releaseLock();
      readerRefs.current[conversationId] = null;
    }
    delete activeTurnRefs.current[conversationId];
  }, []);


    // ================== //
   // EXPORTED FUNCTIONS //
  // ================== //

  // Function to send a message and handle streaming
  const sendMessageInternal = useCallback(async (
    messageContent: string,
    options?: {
      forceNewConversation?: boolean;
      editTarget?: { message_id: string; sent_at: number };
      attachments?: ChatAttachmentInput[];
      throwOnError?: boolean;
    }
  ) => {
    if (!allowActionPanelAiRequests) {
      return Promise.reject(new Error('actionpanel_ai_requests_disabled'));
    }
    // No current conversation ID means this is the first message in a new conversation
    const isNewConversation = Boolean(options?.forceNewConversation) || !currentConversationId;
    // Initialize variables
    let userMessageId: string | null = null;
    let targetConversationId: string | null = null;
    let newConversationFallbackTitle: string | null = null;

    // If this is the first message in a new conversation...
    if (isNewConversation) {
      // Setup new conversation
      const conversation = addNewConversation(messageContent);
      targetConversationId = conversation.conversationId;
      newConversationFallbackTitle = conversation.fallbackTitle;
      userMessageId = addNewMessage('user', messageContent, targetConversationId, undefined, {
        attachments: toDisplayAttachments(options?.attachments),
      });
      setCurrentConversationId(targetConversationId);
    // If this is NOT the first message in a new conversation...
    } else {
      // Add message to existing conversation
      userMessageId = addNewMessage('user', messageContent, currentConversationId!, undefined, {
        attachments: toDisplayAttachments(options?.attachments),
      });
      targetConversationId = currentConversationId!;
    }
    setStoppedWorkingSummary(targetConversationId, null);

    // Set conversation as thinking.
    const existingConversationState = ephemeralMap[targetConversationId!] ?? DEFAULT_EPHEMERAL_STATE;
    updateConversationState(targetConversationId!, {
      phase: 'THINKING',
      isPersisted: existingConversationState.isPersisted,
      activities: [],
      approvalState: undefined,
      timeline: [],
      hasStartedStreaming: false,
      startedAt: Date.now(),
      thoughtDurationMs: undefined,
    });
    if (DEBUG_UI) console.log('[UI] phase=THINKING', { conv: targetConversationId });
    // Create an abort controller
    const abortController = new AbortController();
    abortRefs.current[targetConversationId!] = abortController;

    // Try to send the message
    try {
      // Send the message
      await handleV1ChatResponse(
        (onStreamEnvelope) => sendV1ChatStartRequest(
          messageContent,
          targetConversationId!,
          abortController.signal,
          onStreamEnvelope,
          options?.editTarget,
          options?.attachments,
        ),
        targetConversationId!,
      );
      if (newConversationFallbackTitle) {
        scheduleGeneratedTitleRefresh(targetConversationId!, newConversationFallbackTitle);
      }
      
      // Handle the error (rate limit modal or error message)
    } catch (error: any) {
      const wasUserCancelled = cancellationRequestedRef.current[targetConversationId!];
      if (!(wasUserCancelled && error?.name === 'AbortError')) {
        handleChatError(error, targetConversationId!, userMessageId!);
      }
      if (options?.throwOnError) {
        throw error;
      }

      // Cleanup
    } finally {
      delete cancellationRequestedRef.current[targetConversationId!];
      cleanup(targetConversationId!);
    }
  }, [
    addNewConversation,
    addNewMessage,
    allowActionPanelAiRequests,
    cleanup,
    currentConversationId,
    handleChatError,
    handleV1ChatResponse,
    scheduleGeneratedTitleRefresh,
    sendV1ChatStartRequest,
    setCurrentConversationId,
    setStoppedWorkingSummary,
    updateConversationState,
    ephemeralMap,
  ]);

  const sendMessage = useCallback(async (messageContent: string, attachments?: ChatAttachmentInput[]) => {
    await sendMessageInternal(messageContent, { attachments });
  }, [sendMessageInternal]);

  const refetchMessagesSoon = useCallback((conversationId: string) => {
    CANCEL_REFETCH_DELAYS_MS.forEach(delay => {
      window.setTimeout(() => {
        queryClient.invalidateQueries({ queryKey: ['messages', conversationId] });
        queryClient.invalidateQueries({ queryKey: ['conversations'] });
      }, delay);
    });
  }, [queryClient]);

  const cancelCurrentStream = useCallback(() => {
    if (!currentConversationId) return;
    const state = ephemeralMapRef.current[currentConversationId] ?? DEFAULT_EPHEMERAL_STATE;
    if (state.phase !== 'THINKING' && state.phase !== 'STREAMING') return;

    cancellationRequestedRef.current[currentConversationId] = true;
    const partialContent = partialContents[currentConversationId] ?? '';
    const partialText = partialContent.trim();
    const activeTurnId = activeTurnRefs.current[currentConversationId];
    if (activeTurnId) {
      void requestTurnCancellation(activeTurnId, partialContent);
    }
    const existingMessages = queryClient.getQueryData<Message[]>(['messages', currentConversationId]) || EMPTY_MESSAGES;
    const latestUserMessage = [...existingMessages]
      .reverse()
      .find(message => message.role === 'user');
    const latestUserSentAt = latestUserMessage?.sent_at ?? 0;
    const latestUserContent = latestUserMessage?.content ?? '';
    const finalizedThinkingSummary = buildFinalizedThinkingSummary(
      currentConversationId,
      { includeEmpty: !partialText }
    );
    const currentTurnKey = getCurrentUserTurnKey(currentConversationId);

    if (partialText) {
      const optimisticMessageId = addNewMessage('assistant', partialContent, currentConversationId, undefined, {
        status: 'interrupted',
        finishReason: 'cancelled',
      });
      const optimisticMessage = queryClient
        .getQueryData<Message[]>(['messages', currentConversationId])
        ?.find(message => message.message_id === optimisticMessageId);
      if (optimisticMessage) {
        optimisticCancelledMessagesRef.current[currentConversationId] = {
          latestUserSentAt,
          latestUserContent,
        };
      }
      if (currentTurnKey && finalizedThinkingSummary) {
        setFinalizedThinkingSummary(currentTurnKey, finalizedThinkingSummary);
      }
      setStoppedWorkingSummary(currentConversationId, null);
    } else if (finalizedThinkingSummary) {
      setStoppedWorkingSummary(currentConversationId, finalizedThinkingSummary);
    }
    setPartialContent(currentConversationId, null, null);
    updateConversationState(currentConversationId, {
      ...DEFAULT_EPHEMERAL_STATE,
      isPersisted: true,
    });

    abortRefs.current[currentConversationId]?.abort();
    abortRefs.current[currentConversationId] = null;

    if (readerRefs.current[currentConversationId]) {
      readerRefs.current[currentConversationId]?.cancel().catch(() => {});
      try {
        readerRefs.current[currentConversationId]?.releaseLock();
      } catch {
        // Reader may already be released by the streaming loop.
      }
      readerRefs.current[currentConversationId] = null;
    }
    refetchMessagesSoon(currentConversationId);
  }, [
    addNewMessage,
    currentConversationId,
    partialContents,
    queryClient,
    refetchMessagesSoon,
    requestTurnCancellation,
    buildFinalizedThinkingSummary,
    getCurrentUserTurnKey,
    setPartialContent,
    setFinalizedThinkingSummary,
    setStoppedWorkingSummary,
    updateConversationState,
  ]);

  const editAndResubmitLatestUserMessage = useCallback(async (messageId: string, text: string) => {
    if (!allowActionPanelAiRequests || !currentConversationId) return;
    const trimmed = text.trim();
    if (!trimmed) return;
    const state = ephemeralMapRef.current[currentConversationId] ?? DEFAULT_EPHEMERAL_STATE;
    if (state.phase !== 'IDLE') return;

    const existingMessages = queryClient.getQueryData<Message[]>(['messages', currentConversationId]) || EMPTY_MESSAGES;
    const latestUserIndex = existingMessages.reduce((latestIndex, message, index) => (
      message.role === 'user' ? index : latestIndex
    ), -1);
    const latestUserMessage = latestUserIndex >= 0 ? existingMessages[latestUserIndex] : null;
    if (!latestUserMessage || latestUserMessage.message_id !== messageId) return;

    queryClient.setQueryData<Message[]>(
      ['messages', currentConversationId],
      existingMessages.slice(0, latestUserIndex)
    );

    try {
      await sendMessageInternal(trimmed, {
        editTarget: {
          message_id: latestUserMessage.message_id,
          sent_at: latestUserMessage.sent_at,
        },
        throwOnError: true,
      });
    } catch {
      queryClient.setQueryData<Message[]>(['messages', currentConversationId], existingMessages);
      updateConversationState(currentConversationId, { ...DEFAULT_EPHEMERAL_STATE, isPersisted: true });
    }
  }, [
    allowActionPanelAiRequests,
    currentConversationId,
    queryClient,
    sendMessageInternal,
    updateConversationState,
  ]);

  // Function to retry a message
  const retryError = useCallback(async (userMessageId: string) => {
    if (!allowActionPanelAiRequests) return;
    if (!currentConversationId) return;
    const messages = queryClient.getQueryData<Message[]>(['messages', currentConversationId]) || EMPTY_ARRAY;
    const userMessage = messages.find((msg) => msg.message_id === userMessageId && msg.role === 'user');
    // Remove error and user messages
    if (userMessage) {
      if (isFailedFirstTurnConversation(messages, userMessageId)) {
        await deleteConversationSilently(currentConversationId);
        await sendMessageInternal(userMessage.content, { forceNewConversation: true });
        return;
      }
      await deletePersistedUserMessage(currentConversationId, userMessageId, userMessage.sent_at);
      queryClient.setQueryData<Message[]>(['messages', currentConversationId], (old = []) =>
        old?.filter(msg => 
          msg.message_id !== userMessageId && 
          !(msg.role === 'error' && msg.userMessageId === userMessageId)
        )
      );
      // Resend message
      await sendMessageInternal(userMessage.content);
    }
  }, [
    allowActionPanelAiRequests,
    currentConversationId,
    deleteConversationSilently,
    deletePersistedUserMessage,
    isFailedFirstTurnConversation,
    queryClient,
    sendMessageInternal,
  ]);

  // Function to dismiss an error message (will also delete the user message that caused it)
  const dismissError = useCallback(async (userMessageId: string) => {
    if (!currentConversationId) return;
    const messages = queryClient.getQueryData<Message[]>(['messages', currentConversationId]) || EMPTY_ARRAY;
    const userMessage = messages.find((msg) => msg.message_id === userMessageId && msg.role === 'user');
    if (isFailedFirstTurnConversation(messages, userMessageId)) {
      await deleteConversationSilently(currentConversationId);
      return;
    }
    if (userMessage) {
      await deletePersistedUserMessage(currentConversationId, userMessageId, userMessage.sent_at);
    }
    // Remove user message and error message
    queryClient.setQueryData<Message[]>(['messages', currentConversationId], (old = []) =>
      old?.filter(msg => 
        msg.message_id !== userMessageId && 
        !(msg.role === 'error' && msg.userMessageId === userMessageId)
      )
    );
    // Set conversation as idle
    updateConversationState(currentConversationId, { ...DEFAULT_EPHEMERAL_STATE, isPersisted: true });
    // If no messages left, reset current conversation
    const remainingMessages = queryClient.getQueryData<Message[]>(['messages', currentConversationId]) || [];
    if (remainingMessages.length === 0) {
      setCurrentConversationId(null);
    }
  }, [
    currentConversationId,
    deleteConversationSilently,
    deletePersistedUserMessage,
    isFailedFirstTurnConversation,
    queryClient,
    setCurrentConversationId,
    updateConversationState,
  ]);

  // Function to delete a conversation
  const deleteConversation = useCallback(async (conversationId: string) => {
    if (!allowActionPanelAiRequests) return;
    try {
      const response = await fetch(`${API_BASE_URL}/conversations/${conversationId}`, {
        method: 'DELETE',
        credentials: 'include',
      });
      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.detail || 'Failed to delete conversation');
      }
      clearConversationLocally(conversationId);
      queryClient.invalidateQueries({ queryKey: ['conversations'] });
      queryClient.invalidateQueries({ queryKey: ['messages', conversationId] });
      if (currentConversationId === conversationId) {
        setCurrentConversationId(null);
      }
    } catch (error) {
      console.error('Error deleting conversation:', error);
    }
  }, [allowActionPanelAiRequests, clearConversationLocally, currentConversationId, queryClient, setCurrentConversationId]);

  const renameConversation = useCallback(async (conversationId: string, title: string) => {
    if (!allowActionPanelAiRequests) return;
    const trimmedTitle = title.trim().replace(/\s+/g, ' ');
    if (!trimmedTitle) return;

    const previousTitle = conversations.find(
      conversation => conversation.conversation_id === conversationId
    )?.title ?? 'New Chat';

    applyConversationTitle(conversationId, trimmedTitle);
    try {
      const response = await fetch(`${API_BASE_URL}/conversations/${conversationId}/metadata`, {
        method: 'PATCH',
        credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: trimmedTitle }),
      });
      if (!response.ok) {
        throw new Error('Failed to rename conversation');
      }
      const payload = await response.json();
      const persistedTitle = typeof payload.title === 'string' ? payload.title.trim() : trimmedTitle;
      applyConversationTitle(conversationId, persistedTitle || trimmedTitle);
    } catch (error) {
      applyConversationTitle(conversationId, previousTitle);
      console.error('Error renaming conversation:', error);
    }
  }, [applyConversationTitle, allowActionPanelAiRequests, conversations]);

  const loadMoreConversations = useCallback(async () => {
    if (!hasMoreConversations || isFetchingMoreConversations) return;
    await fetchNextConversationsPage();
  }, [fetchNextConversationsPage, hasMoreConversations, isFetchingMoreConversations]);


  // Memoize conversations and messages
  const memoizedConversations = useMemo(() => 
    conversations.length > 0 ? conversations : EMPTY_CONVERSATIONS
  , [conversations]);

  // Memoize messages
  const memoizedMessages = useMemo(() =>
    rawMessages.length > 0 ? rawMessages : EMPTY_MESSAGES
  , [rawMessages]);

  // Initial-load flags: fetching with nothing to show yet (background refetches,
  // which keep existing data, don't count).
  const conversationsLoading = conversationsFetching && memoizedConversations.length === 0;
  const messagesLoading = Boolean(currentConversationId) && messagesFetching && memoizedMessages.length === 0;

  // Memoize the context value
  const contextValue = useMemo(() => ({
    currentConversationId,
    setCurrentConversationId,
    conversations: memoizedConversations,
    messages: memoizedMessages,
    conversationsLoading,
    hasMoreConversations,
    isFetchingMoreConversations,
    messagesLoading,
    ephemeralMap,
    finalizedThinkingMap,
    stoppedWorkingMap,
    sendMessage,
    cancelCurrentStream,
    editAndResubmitLatestUserMessage,
    retryError,
    dismissError,
    deleteConversation,
    renameConversation,
    loadMoreConversations,
    partialContents,
    setPartialContent,
    latestChunks,
  }), [
    currentConversationId,
    setCurrentConversationId,
    memoizedConversations,
    memoizedMessages,
    conversationsLoading,
    hasMoreConversations,
    isFetchingMoreConversations,
    messagesLoading,
    ephemeralMap,
    finalizedThinkingMap,
    stoppedWorkingMap,
    sendMessage,
    cancelCurrentStream,
    editAndResubmitLatestUserMessage,
    retryError,
    dismissError,
    deleteConversation,
    renameConversation,
    loadMoreConversations,
    partialContents,
    setPartialContent,
    latestChunks,
  ]);

  return (
    <ChatContext.Provider value={contextValue}>
      {children}
    </ChatContext.Provider>
  );
};


  // ================= //
 // CONTEXT SELECTORS //
// ================= //

// Base selector hook
export const useChatSelector = <T,>(selector: (state: ChatContextProps) => T): T => {
  return useContextSelector(ChatContext, selector);
};

// Memoize individual selectors
const currentConversationIdSelector = (state: ChatContextProps) => state.currentConversationId;
const setCurrentConversationIdSelector = (state: ChatContextProps) => state.setCurrentConversationId;
const conversationsSelector = (state: ChatContextProps) => state.conversations;
const messagesSelector = (state: ChatContextProps) => state.messages;
const conversationsLoadingSelector = (state: ChatContextProps) => state.conversationsLoading;
const hasMoreConversationsSelector = (state: ChatContextProps) => state.hasMoreConversations;
const isFetchingMoreConversationsSelector = (state: ChatContextProps) => state.isFetchingMoreConversations;
const messagesLoadingSelector = (state: ChatContextProps) => state.messagesLoading;
const sendMessageSelector = (state: ChatContextProps) => state.sendMessage;
const cancelCurrentStreamSelector = (state: ChatContextProps) => state.cancelCurrentStream;
const editAndResubmitLatestUserMessageSelector = (state: ChatContextProps) => state.editAndResubmitLatestUserMessage;
const retryErrorSelector = (state: ChatContextProps) => state.retryError;
const dismissErrorSelector = (state: ChatContextProps) => state.dismissError;
const deleteConversationSelector = (state: ChatContextProps) => state.deleteConversation;
const renameConversationSelector = (state: ChatContextProps) => state.renameConversation;
const loadMoreConversationsSelector = (state: ChatContextProps) => state.loadMoreConversations;

// Individual selector hooks using memoized selectors
export const useCurrentConversationId = () => useChatSelector(currentConversationIdSelector);
export const useSetCurrentConversationId = () => useChatSelector(setCurrentConversationIdSelector);
export const useConversations = () => useChatSelector(conversationsSelector);
export const useMessages = () => useChatSelector(messagesSelector);
export const useConversationsLoading = () => useChatSelector(conversationsLoadingSelector);
export const useHasMoreConversations = () => useChatSelector(hasMoreConversationsSelector);
export const useIsFetchingMoreConversations = () => useChatSelector(isFetchingMoreConversationsSelector);
export const useMessagesLoading = () => useChatSelector(messagesLoadingSelector);
export const useSendMessage = () => useChatSelector(sendMessageSelector);
export const useCancelCurrentStream = () => useChatSelector(cancelCurrentStreamSelector);
export const useEditAndResubmitLatestUserMessage = () => useChatSelector(editAndResubmitLatestUserMessageSelector);
export const useRetryError = () => useChatSelector(retryErrorSelector);
export const useDismissError = () => useChatSelector(dismissErrorSelector);
export const useDeleteConversation = () => useChatSelector(deleteConversationSelector);
export const useRenameConversation = () => useChatSelector(renameConversationSelector);
export const useLoadMoreConversations = () => useChatSelector(loadMoreConversationsSelector);

// Ephemeral conversation state for only current conversation
export function useCurrentConversationState() {
  const currentId = useContextSelector(ChatContext, ctx => ctx.currentConversationId);
  return useContextSelector(ChatContext, ctx => {
    if (!currentId) return null;
    return ctx.ephemeralMap[currentId] ?? DEFAULT_EPHEMERAL_STATE;
  });
}

// Streaming message content only for the current conversation
export function useCurrentPartialContent() {
  const currentId = useContextSelector(ChatContext, ctx => ctx.currentConversationId);
  return useContextSelector(ChatContext, ctx => {
    if (!currentId) return null;
    return ctx.partialContents[currentId] ?? '';
  });
}

export function useAssistantThinkingSummary(messageId?: string) {
  return useContextSelector(ChatContext, ctx => {
    if (!messageId) return null;
    return ctx.finalizedThinkingMap[messageId] ?? null;
  });
}

export function useCurrentStoppedWorkingSummary() {
  const currentId = useContextSelector(ChatContext, ctx => ctx.currentConversationId);
  return useContextSelector(ChatContext, ctx => {
    if (!currentId) return null;
    return ctx.stoppedWorkingMap[currentId] ?? null;
  });
}

// Latest streaming chunk for current conversation
export function useCurrentLatestChunk() {
  const currentId = useContextSelector(ChatContext, ctx => ctx.currentConversationId);
  return useContextSelector(ChatContext, ctx => {
    if (!currentId) return null;
    return ctx.latestChunks[currentId] ?? '';
  });
}
