import type { UIMessage } from '@convex-dev/agent/react';
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';

const mockLoadMore = vi.fn();

// The Convex `api` proxy returns a NEW object on every property access, so
// identity-based lookup doesn't work. Key by the function path string the
// proxy exposes under Symbol.for("functionName") (e.g.
// "threads/queries:isThreadGenerating").
const FUNCTION_NAME = Symbol.for('functionName');
const queryResults = new Map<string, unknown>();

function functionPath(queryRef: unknown): string {
  if (typeof queryRef === 'object' && queryRef !== null) {
    const name = (queryRef as Record<symbol, unknown>)[FUNCTION_NAME];
    if (typeof name === 'string') return name;
  }
  return String(queryRef);
}

function setQueryResult(queryRef: unknown, value: unknown) {
  queryResults.set(functionPath(queryRef), value);
}

vi.mock('@convex-dev/agent/react', () => ({
  useUIMessages: vi.fn(() => ({
    results: undefined,
    loadMore: mockLoadMore,
    status: 'Exhausted',
  })),
}));

vi.mock('./use-stream-buffer', () => ({}));

vi.mock('convex/react', () => ({
  useQuery: vi.fn((queryRef: unknown) =>
    queryResults.get(functionPath(queryRef)),
  ),
}));

const mockChatLayout: { pendingMessage: unknown } = { pendingMessage: null };
vi.mock('../context/chat-layout-context', () => ({
  useChatLayout: () => mockChatLayout,
}));

import { useUIMessages } from '@convex-dev/agent/react';

import { api } from '@/convex/_generated/api';
import { appendKbReferenceBlock } from '@/convex/lib/agent_chat/kb_reference_block';

import {
  useMessageProcessing,
  stripInternalFileReferences,
  extractFileAttachments,
} from './use-message-processing';

const mockUseUIMessages = vi.mocked(useUIMessages);

function createUIMessage(
  overrides: Partial<UIMessage> & { id: string; order: number },
): UIMessage {
  return {
    key: overrides.id,
    role: 'assistant',
    text: '',
    _creationTime: Date.now(),
    status: 'success',
    stepOrder: 0,
    parts: [],
    ...overrides,
  } satisfies UIMessage;
}

describe('useMessageProcessing', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    queryResults.clear();
    mockChatLayout.pendingMessage = null;
  });

  describe('streamingMessage', () => {
    it('returns undefined when no messages are streaming', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'assistant',
            text: 'Done',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.streamingMessage).toBeUndefined();
    });

    it('returns the streaming assistant message', () => {
      const streamingMsg = createUIMessage({
        id: 'msg-2',
        order: 1,
        role: 'assistant',
        text: 'Thinking...',
        status: 'streaming',
      });

      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          streamingMsg,
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.streamingMessage).toBe(streamingMsg);
    });
  });

  describe('pendingToolResponse', () => {
    it('returns undefined when no pending tool responses', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'assistant',
            status: 'success',
            text: 'Done',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.pendingToolResponse).toBeUndefined();
    });

    it('returns the pending assistant message', () => {
      const pendingMsg = createUIMessage({
        id: 'msg-2',
        order: 1,
        role: 'assistant',
        text: 'Searching...',
        status: 'pending',
      });

      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Search',
          }),
          pendingMsg,
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.pendingToolResponse).toBe(pendingMsg);
    });
  });

  describe('activeMessage', () => {
    it('returns streaming message when available', () => {
      const streamingMsg = createUIMessage({
        id: 'msg-2',
        order: 1,
        role: 'assistant',
        text: 'Hello...',
        status: 'streaming',
      });

      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          streamingMsg,
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.activeMessage).toBe(streamingMsg);
    });

    it('returns pending message when no streaming message exists', () => {
      const pendingMsg = createUIMessage({
        id: 'msg-2',
        order: 1,
        role: 'assistant',
        text: '',
        status: 'pending',
      });

      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Search',
          }),
          pendingMsg,
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.activeMessage).toBe(pendingMsg);
    });

    it('returns first non-terminal assistant in array order when both pending and streaming exist', () => {
      const pendingMsg = createUIMessage({
        id: 'msg-2',
        order: 1,
        role: 'assistant',
        text: '',
        status: 'pending',
      });
      const streamingMsg = createUIMessage({
        id: 'msg-3',
        order: 2,
        role: 'assistant',
        text: 'Results...',
        status: 'streaming',
      });

      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Search',
          }),
          pendingMsg,
          streamingMsg,
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.activeMessage).toBe(pendingMsg);
      expect(result.current.streamingMessage).toBeUndefined();
      expect(result.current.pendingToolResponse).toBe(pendingMsg);
    });

    it('returns undefined when all messages are terminal', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'assistant',
            text: 'Done',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.activeMessage).toBeUndefined();
    });
  });

  describe('hasActiveTools with pending messages', () => {
    it('detects active tools on pending message', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Search',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'pending',
            parts: [
              { type: 'step-start' },
              {
                type: 'tool-rag_search',
                toolCallId: 'call-1',
                input: { query: 'test' },
                state: 'input-available',
              },
            ],
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.hasActiveTools).toBe(true);
    });
  });

  describe('sticky isStreaming', () => {
    it('keeps isStreaming true when status transitions from streaming to pending', () => {
      const msgKey = 'msg-2';

      // First render: message is streaming
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          createUIMessage({
            id: msgKey,
            order: 1,
            role: 'assistant',
            text: 'Hello...',
            status: 'streaming',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result, rerender } = renderHook(() =>
        useMessageProcessing('thread-1'),
      );
      expect(
        result.current.messages.find((m) => m.key === msgKey)?.isStreaming,
      ).toBe(true);

      // Second render: status falls back to pending (reconnection)
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          createUIMessage({
            id: msgKey,
            order: 1,
            role: 'assistant',
            text: '',
            status: 'pending',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      rerender();
      expect(
        result.current.messages.find((m) => m.key === msgKey)?.isStreaming,
      ).toBe(true);
    });

    it('sets isStreaming false when status reaches success', () => {
      const msgKey = 'msg-2';

      // First render: streaming
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: msgKey,
            order: 1,
            role: 'assistant',
            text: 'Hello',
            status: 'streaming',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result, rerender } = renderHook(() =>
        useMessageProcessing('thread-1'),
      );
      expect(
        result.current.messages.find((m) => m.key === msgKey)?.isStreaming,
      ).toBe(true);

      // Second render: completed
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: msgKey,
            order: 1,
            role: 'assistant',
            text: 'Hello world!',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      rerender();
      expect(
        result.current.messages.find((m) => m.key === msgKey)?.isStreaming,
      ).toBe(false);
    });

    it('keeps isStreaming true for one cycle when empty-streaming message gets content at terminal status', () => {
      const msgKey = 'msg-2';

      // First render: streaming with no text (tool turn phase)
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Search my docs',
          }),
          createUIMessage({
            id: msgKey,
            order: 1,
            role: 'assistant',
            text: '',
            status: 'streaming',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result, rerender } = renderHook(() =>
        useMessageProcessing('thread-1'),
      );
      // Empty streaming message: isStreaming true but content is empty
      const emptyMsg = result.current.messages.find((m) => m.key === msgKey);
      expect(emptyMsg?.isStreaming).toBe(true);
      expect(emptyMsg?.content).toBe('');

      // Second render: jumps to success with full text (SDK batched update)
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Search my docs',
          }),
          createUIMessage({
            id: msgKey,
            order: 1,
            role: 'assistant',
            text: 'Here are the search results for your documents...',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      rerender();
      // Should keep isStreaming true so TypewriterText can mount and animate
      const filledMsg = result.current.messages.find((m) => m.key === msgKey);
      expect(filledMsg?.isStreaming).toBe(true);
      expect(filledMsg?.content).toBe(
        'Here are the search results for your documents...',
      );

      // Third render: same data (new reference to trigger useMemo), isStreaming should now be false
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Search my docs',
          }),
          createUIMessage({
            id: msgKey,
            order: 1,
            role: 'assistant',
            text: 'Here are the search results for your documents...',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      rerender();
      expect(
        result.current.messages.find((m) => m.key === msgKey)?.isStreaming,
      ).toBe(false);
    });

    it('does not extend isStreaming when message had text during streaming', () => {
      const msgKey = 'msg-2';

      // First render: streaming with text (normal text streaming)
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          createUIMessage({
            id: msgKey,
            order: 1,
            role: 'assistant',
            text: 'Hello there! How can I help?',
            status: 'streaming',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result, rerender } = renderHook(() =>
        useMessageProcessing('thread-1'),
      );
      expect(
        result.current.messages.find((m) => m.key === msgKey)?.isStreaming,
      ).toBe(true);

      // Second render: completed
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          createUIMessage({
            id: msgKey,
            order: 1,
            role: 'assistant',
            text: 'Hello there! How can I help you today?',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      rerender();
      // Should be false immediately — TypewriterText was already mounted
      expect(
        result.current.messages.find((m) => m.key === msgKey)?.isStreaming,
      ).toBe(false);
    });

    it('does not treat a never-streaming pending message as streaming', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'pending',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(
        result.current.messages.find((m) => m.key === 'msg-2')?.isStreaming,
      ).toBe(false);
    });
  });

  describe('isAborted flag', () => {
    it('sets isAborted true when failed message has empty text', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Tell me about RAG',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'failed',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const abortedMsg = result.current.messages.find((m) => m.key === 'msg-2');
      expect(abortedMsg?.isAborted).toBe(true);
      expect(abortedMsg?.content).toBe('');
    });

    it('sets isAborted true when failed message has whitespace-only text', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'assistant',
            text: '   \n  ',
            status: 'failed',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.messages[0]?.isAborted).toBe(true);
    });

    it('does not set isAborted for failed messages with real content', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'assistant',
            text: 'The history of computing begins with...',
            status: 'failed',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.messages[0]?.isAborted).toBe(false);
    });

    it('does not set isAborted for successful messages', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'assistant',
            text: 'Done!',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.messages[0]?.isAborted).toBe(false);
    });

    it('sets isAborted true for failed messages with null text', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'assistant',
            text: undefined,
            status: 'failed',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(result.current.messages[0]?.isAborted).toBe(true);
    });
  });

  describe('system messages', () => {
    it('includes system messages with info display for APPROVAL_REJECTED', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'system',
            text: '[APPROVAL_REJECTED]\nThe user rejected the request.',
            status: 'success',
          }),
          createUIMessage({
            id: 'msg-3',
            order: 2,
            role: 'assistant',
            text: 'Understood.',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const systemMsg = result.current.messages.find((m) => m.key === 'msg-2');
      expect(systemMsg).toBeDefined();
      expect(systemMsg?.role).toBe('system');
      expect(systemMsg?.systemMessageDisplay).toBe('info');
      expect(systemMsg?.systemMessageBody).toBe(
        'The user rejected the request.',
      );
    });

    it('detects human input response as pill display', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'system',
            text: '[HUMAN_INPUT_RESPONSE] Yes',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const systemMsg = result.current.messages.find((m) => m.key === 'msg-2');
      expect(systemMsg?.systemMessageDisplay).toBe('pill');
      expect(systemMsg?.systemMessageBody).toBe('Yes');
    });

    it('detects workflow cancelled as info display', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Run workflow',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'system',
            text: '[WORKFLOW_CANCELLED]\nWorkflow "test" was stopped.',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const systemMsg = result.current.messages.find((m) => m.key === 'msg-2');
      expect(systemMsg).toBeDefined();
      expect(systemMsg?.role).toBe('system');
      expect(systemMsg?.systemMessageDisplay).toBe('info');
    });

    it('detects workflow completed as info display', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Run workflow',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'system',
            text: '[WORKFLOW_COMPLETED]\nWorkflow completed.',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const systemMsg = result.current.messages.find((m) => m.key === 'msg-2');
      expect(systemMsg?.systemMessageDisplay).toBe('info');
    });

    it('detects workflow failed as error display', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Run workflow',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'system',
            text: '[WORKFLOW_FAILED]\nWorkflow failed with error.',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const systemMsg = result.current.messages.find((m) => m.key === 'msg-2');
      expect(systemMsg?.systemMessageDisplay).toBe('error');
    });

    it('detects response interrupted as warning display', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'system',
            text: '[RESPONSE_INTERRUPTED] Retrying…',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const systemMsg = result.current.messages.find((m) => m.key === 'msg-2');
      expect(systemMsg?.systemMessageDisplay).toBe('warning');
    });

    it('falls back to info display for unknown system messages', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-1',
            order: 0,
            role: 'user',
            text: 'Hi',
          }),
          createUIMessage({
            id: 'msg-2',
            order: 1,
            role: 'system',
            text: 'Some unknown system message',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const systemMsg = result.current.messages.find((m) => m.key === 'msg-2');
      expect(systemMsg?.systemMessageDisplay).toBe('info');
      expect(systemMsg?.systemMessageBody).toBe('Some unknown system message');
    });
  });

  describe('stripInternalFileReferences', () => {
    it('removes the attached files marker', () => {
      const input =
        'Hello\n\n[ATTACHED FILES - Pre-analysis was not available. Use your tools to process these files.]';
      expect(stripInternalFileReferences(input)).toBe('Hello');
    });

    it('removes fileId references', () => {
      const input =
        'Hello\n📎 **image.png** (image/png, fileId: kg24f801scvhvxnx320zcd9rcd81g8aj)';
      expect(stripInternalFileReferences(input)).toBe('Hello');
    });

    it('removes both marker and multiple file references', () => {
      const input = [
        'What is this?',
        '',
        '[ATTACHED FILES - Pre-analysis was not available. Use your tools to process these files.]',
        '📎 **photo.jpg** (image/jpeg, fileId: abc123def456)',
        '📎 **doc.pdf** (application/pdf, fileId: xyz789)',
      ].join('\n');
      expect(stripInternalFileReferences(input)).toBe('What is this?');
    });

    it('returns text unchanged when no internal references exist', () => {
      const input = 'Just a normal message with no file references';
      expect(stripInternalFileReferences(input)).toBe(input);
    });

    it('handles empty string', () => {
      expect(stripInternalFileReferences('')).toBe('');
    });

    it('strips fileId marker but preserves image markdown for renderer', () => {
      const input =
        '![photo.jpg](https://example.com/img.jpg)\n*(fileId: kg24f801scvhvxnx320zcd9rcd81g8aj)*';
      expect(stripInternalFileReferences(input)).toBe(
        '![photo.jpg](https://example.com/img.jpg)',
      );
    });

    it('strips fileId marker but preserves document link markdown', () => {
      const input =
        '📄 [report.pdf](https://example.com/file) (application/pdf, 1.2 MB)\n*(fileId: abc123def456)*';
      expect(stripInternalFileReferences(input)).toBe(
        '📄 [report.pdf](https://example.com/file) (application/pdf, 1.2 MB)',
      );
    });

    it('preserves user text and image markdown while stripping fileId', () => {
      const input = [
        'What is this image?',
        '',
        '![photo.jpg](https://example.com/img.jpg)',
        '*(fileId: abc123)*',
      ].join('\n');
      expect(stripInternalFileReferences(input)).toBe(
        'What is this image?\n\n![photo.jpg](https://example.com/img.jpg)',
      );
    });

    it('strips fileId markers from multiple attachments but preserves markdown', () => {
      const input = [
        'Please review',
        '',
        '📄 [report.pdf](https://example.com/pdf) (application/pdf, 2 MB)',
        '*(fileId: pdf123)*',
        '',
        '📄 [notes.txt](https://example.com/txt) (512 B)',
        '*(fileId: txt456)*',
      ].join('\n');
      expect(stripInternalFileReferences(input)).toBe(
        [
          'Please review',
          '',
          '📄 [report.pdf](https://example.com/pdf) (application/pdf, 2 MB)',
          '',
          '📄 [notes.txt](https://example.com/txt) (512 B)',
        ].join('\n'),
      );
    });

    it('strips entire enriched attachment block (markdown line + marker)', () => {
      const input =
        '![photo.jpg](https://example.com/img.jpg)\n*(fileId: abc123 | fileName: photo.jpg | fileType: image/jpeg | fileSize: 54321)*';
      expect(stripInternalFileReferences(input)).toBe('');
    });

    it('strips multiple enriched blocks from mixed attachment types', () => {
      const input = [
        'Check these files',
        '',
        '📄 [report.pdf](https://example.com/pdf) (application/pdf, 2 MB)',
        '*(fileId: pdf123 | fileName: report.pdf | fileType: application/pdf | fileSize: 2097152)*',
        '',
        '![screenshot.png](https://example.com/img.png)',
        '*(fileId: img456 | fileName: screenshot.png | fileType: image/png | fileSize: 102400)*',
      ].join('\n');
      expect(stripInternalFileReferences(input)).toBe('Check these files');
    });
  });

  describe('extractFileAttachments', () => {
    it('extracts a single image attachment', () => {
      const input =
        '![photo.jpg](https://example.com/img.jpg)\n*(fileId: abc123def456 | fileName: photo.jpg | fileType: image/jpeg | fileSize: 54321)*';
      const result = extractFileAttachments(input);
      expect(result).toEqual([
        {
          fileId: 'abc123def456',
          fileName: 'photo.jpg',
          fileType: 'image/jpeg',
          fileSize: 54321,
        },
      ]);
    });

    it('extracts multiple attachments of mixed types', () => {
      const input = [
        'Please review',
        '',
        '📎 [report.pdf](https://example.com/pdf) (application/pdf, 2 MB)',
        '*(fileId: pdf123 | fileName: report.pdf | fileType: application/pdf | fileSize: 2097152)*',
        '',
        '📄 [notes.txt](https://example.com/txt) (512 B)',
        '*(fileId: txt456 | fileName: notes.txt | fileType: text/plain | fileSize: 512)*',
        '',
        '![screenshot.png](https://example.com/img.png)',
        '*(fileId: img789 | fileName: screenshot.png | fileType: image/png | fileSize: 102400)*',
      ].join('\n');
      const result = extractFileAttachments(input);
      expect(result).toEqual([
        {
          fileId: 'pdf123',
          fileName: 'report.pdf',
          fileType: 'application/pdf',
          fileSize: 2097152,
        },
        {
          fileId: 'txt456',
          fileName: 'notes.txt',
          fileType: 'text/plain',
          fileSize: 512,
        },
        {
          fileId: 'img789',
          fileName: 'screenshot.png',
          fileType: 'image/png',
          fileSize: 102400,
        },
      ]);
    });

    it('returns empty array when no enriched markers exist', () => {
      const input = 'Just a normal message with no attachments';
      expect(extractFileAttachments(input)).toEqual([]);
    });

    it('returns empty array for empty string', () => {
      expect(extractFileAttachments('')).toEqual([]);
    });

    it('ignores legacy fileId-only markers', () => {
      const input =
        '![photo.jpg](https://example.com/img.jpg)\n*(fileId: abc123)*';
      expect(extractFileAttachments(input)).toEqual([]);
    });
  });

  // Locks the server marker format to the client regexes: the KB reference
  // block built in convex/lib/agent_chat/kb_reference_block.ts must round-trip
  // through extractFileAttachments (chips) and stripInternalFileReferences
  // (bubble body) — a drift on either side silently breaks sent-bubble chips
  // for `@`-mentioned documents.
  describe('KB reference block round-trip (kb_reference_block.ts)', () => {
    const refs = [
      {
        documentId: 'doc1',
        fileId: 'kd7abc123',
        fileName: 'Q3 Report.pdf',
        fileType: 'application/pdf',
        fileSize: 2048,
      },
      {
        documentId: 'doc2',
        fileId: 'kd7def456',
        fileName: 'Handbook.docx',
        fileType:
          'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        fileSize: 4096,
      },
    ];

    it('extractFileAttachments recovers every referenced file as a chip', () => {
      const message = appendKbReferenceBlock('Summarize @Q3 Report.pdf', refs);
      expect(extractFileAttachments(message)).toEqual([
        {
          fileId: 'kd7abc123',
          fileName: 'Q3 Report.pdf',
          fileType: 'application/pdf',
          fileSize: 2048,
        },
        {
          fileId: 'kd7def456',
          fileName: 'Handbook.docx',
          fileType:
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
          fileSize: 4096,
        },
      ]);
    });

    it('stripInternalFileReferences removes the whole block from the bubble body', () => {
      const userText = 'Summarize @Q3 Report.pdf';
      const message = appendKbReferenceBlock(userText, refs);
      expect(stripInternalFileReferences(message)).toBe(userText);
    });

    it('appendKbReferenceBlock is a no-op for zero refs', () => {
      expect(appendKbReferenceBlock('hello', [])).toBe('hello');
    });
  });

  describe('file-only message merging', () => {
    const pdfPart = {
      type: 'file' as const,
      mediaType: 'application/pdf',
      filename: 'report.pdf',
      url: 'https://example.com/report.pdf',
    };
    const pdfPart2 = {
      type: 'file' as const,
      mediaType: 'application/pdf',
      filename: 'appendix.pdf',
      url: 'https://example.com/appendix.pdf',
    };
    const imagePart = {
      type: 'file' as const,
      mediaType: 'image/png',
      filename: 'generated.png',
      url: 'https://example.com/generated.png',
    };

    it('hides file-only message while the thread is generating', () => {
      setQueryResult(api.threads.queries.getThreadMeta, {
        isGenerating: true,
        failedErrors: {},
        forkInfo: null,
        projectId: null,
      });
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-user',
            order: 0,
            role: 'user',
            text: 'Generate a PDF',
          }),
          createUIMessage({
            id: 'msg-streaming',
            order: 1,
            role: 'assistant',
            text: 'Working on it…',
            status: 'streaming',
          }),
          createUIMessage({
            id: 'file-1',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'success',
            parts: [pdfPart],
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(
        result.current.messages.find((m) => m.key === 'file-1'),
      ).toBeUndefined();
    });

    it('hides file-only during the between-step gap (pre-tool success, no streaming yet, isGenerating=true)', () => {
      // Regression case: shipped guard looked for streaming/pending assistant
      // status, which is undefined in the window between pre-tool `success`
      // and post-tool creation. The isGenerating-based guard covers it.
      setQueryResult(api.threads.queries.getThreadMeta, {
        isGenerating: true,
        failedErrors: {},
        forkInfo: null,
        projectId: null,
      });
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-user',
            order: 0,
            role: 'user',
            text: 'Generate a PDF',
          }),
          createUIMessage({
            id: 'msg-pre-tool',
            order: 1,
            role: 'assistant',
            text: 'Let me generate that PDF.',
            status: 'success',
          }),
          createUIMessage({
            id: 'file-1',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'success',
            parts: [pdfPart],
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(
        result.current.messages.find((m) => m.key === 'file-1'),
      ).toBeUndefined();
    });

    it('merges file-only parts into a text-bearing assistant in the same turn', () => {
      setQueryResult(api.threads.queries.getThreadMeta, {
        isGenerating: true,
        failedErrors: {},
        forkInfo: null,
        projectId: null,
      });
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-user',
            order: 0,
            role: 'user',
            text: 'Generate a PDF',
          }),
          createUIMessage({
            id: 'file-1',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'success',
            parts: [pdfPart],
          }),
          createUIMessage({
            id: 'msg-text',
            order: 1,
            role: 'assistant',
            text: 'Done. PDF below.',
            status: 'streaming',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(
        result.current.messages.find((m) => m.key === 'file-1'),
      ).toBeUndefined();
      const textMsg = result.current.messages.find((m) => m.key === 'msg-text');
      expect(textMsg?.fileParts?.map((p) => p.url)).toEqual([pdfPart.url]);
    });

    it('renders file-only message standalone when its turn has no active streaming', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-user',
            order: 0,
            role: 'user',
            text: 'Generate an image',
          }),
          createUIMessage({
            id: 'file-1',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'success',
            parts: [imagePart],
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const fileMsg = result.current.messages.find((m) => m.key === 'file-1');
      expect(fileMsg).toBeDefined();
      expect(fileMsg?.fileParts?.map((p) => p.url)).toEqual([imagePart.url]);
    });

    it('renders file-only standalone when the turn ended in failure (no active streaming)', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-user',
            order: 0,
            role: 'user',
            text: 'Generate a PDF',
          }),
          createUIMessage({
            id: 'msg-failed',
            order: 1,
            role: 'assistant',
            text: 'Partial text before failure',
            status: 'failed',
          }),
          createUIMessage({
            id: 'file-1',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'success',
            parts: [pdfPart],
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const fileMsg = result.current.messages.find((m) => m.key === 'file-1');
      expect(fileMsg).toBeDefined();
      expect(fileMsg?.fileParts?.map((p) => p.url)).toEqual([pdfPart.url]);
    });

    it('merges two file-only messages into the same text-bearing assistant', () => {
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-user',
            order: 0,
            role: 'user',
            text: 'Generate two PDFs',
          }),
          createUIMessage({
            id: 'file-1',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'success',
            parts: [pdfPart],
          }),
          createUIMessage({
            id: 'file-2',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'success',
            parts: [pdfPart2],
          }),
          createUIMessage({
            id: 'msg-text',
            order: 1,
            role: 'assistant',
            text: 'Both ready.',
            status: 'success',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      expect(
        result.current.messages.find((m) => m.key === 'file-1'),
      ).toBeUndefined();
      expect(
        result.current.messages.find((m) => m.key === 'file-2'),
      ).toBeUndefined();
      const textMsg = result.current.messages.find((m) => m.key === 'msg-text');
      expect(textMsg?.fileParts?.map((p) => p.url)).toEqual([
        pdfPart.url,
        pdfPart2.url,
      ]);
    });

    it('does not let a later-turn streaming message hide a prior-turn file-only', () => {
      setQueryResult(api.threads.queries.getThreadMeta, {
        isGenerating: true,
        failedErrors: {},
        forkInfo: null,
        projectId: null,
      });
      mockUseUIMessages.mockReturnValue({
        results: [
          createUIMessage({
            id: 'msg-user-1',
            order: 0,
            role: 'user',
            text: 'First',
          }),
          createUIMessage({
            id: 'msg-done',
            order: 1,
            role: 'assistant',
            text: 'Here is your file.',
            status: 'success',
          }),
          createUIMessage({
            id: 'file-1',
            order: 1,
            role: 'assistant',
            text: '',
            status: 'success',
            parts: [pdfPart],
          }),
          createUIMessage({
            id: 'msg-user-2',
            order: 2,
            role: 'user',
            text: 'Second',
          }),
          createUIMessage({
            id: 'msg-streaming-next',
            order: 3,
            role: 'assistant',
            text: 'Working…',
            status: 'streaming',
          }),
        ],
        loadMore: mockLoadMore,
        status: 'Exhausted',
      } as unknown as ReturnType<typeof useUIMessages>);

      const { result } = renderHook(() => useMessageProcessing('thread-1'));
      const fileMsg = result.current.messages.find((m) => m.key === 'file-1');
      expect(fileMsg).toBeDefined();
      expect(fileMsg?.fileParts?.map((p) => p.url)).toEqual([pdfPart.url]);
    });
  });
});
