import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { configureDebug, ensureWindowDebugApi } from '@/debug/invocationEventDebug';
import { useAgentMessages } from '@/hooks/useAgentMessages';

const mockAddMessage = vi.fn();
const mockAppendToMessage = vi.fn();
const mockAppendToolEvent = vi.fn();
const mockAppendRichBlock = vi.fn();
const mockSetStreaming = vi.fn();
const mockSetLoading = vi.fn();
const mockSetHasActiveInvocation = vi.fn();
const mockSetIntentMode = vi.fn();
const mockSetCatStatus = vi.fn();
const mockClearCatStatuses = vi.fn();
const mockSetCatInvocation = vi.fn();
const mockSetMessageUsage = vi.fn();
const mockRequestStreamCatchUp = vi.fn();
const mockSetMessageMetadata = vi.fn();
const mockSetMessageThinking = vi.fn();
const mockSetMessageStreamInvocation = vi.fn();
const mockPatchMessage = vi.fn();

const mockAddMessageToThread = vi.fn();
const mockClearThreadActiveInvocation = vi.fn();
const mockResetThreadInvocationState = vi.fn();
const mockSetThreadMessageStreaming = vi.fn();
const mockGetThreadState = vi.fn(() => ({ messages: [] }));
// F183 B1.2.2: active text stream → reducer → replaceMessages
const mockReplaceMessages = vi.fn((msgs: unknown[]) => {
  storeState.messages = msgs as typeof storeState.messages;
});

const storeState = {
  messages: [] as Array<{
    id: string;
    type: string;
    catId?: string;
    content: string;
    isStreaming?: boolean;
    origin?: 'stream' | 'callback';
    extra?: { stream?: { invocationId?: string; turnInvocationId?: string } };
    timestamp: number;
  }>,
  catInvocations: {} as Record<string, { invocationId?: string }>,
  activeInvocations: {} as Record<string, { catId: string; mode: string }>,
  addMessage: mockAddMessage,
  appendToMessage: mockAppendToMessage,
  appendToolEvent: mockAppendToolEvent,
  appendRichBlock: mockAppendRichBlock,
  setStreaming: mockSetStreaming,
  setLoading: mockSetLoading,
  setHasActiveInvocation: mockSetHasActiveInvocation,
  setIntentMode: mockSetIntentMode,
  setCatStatus: mockSetCatStatus,
  clearCatStatuses: mockClearCatStatuses,
  setCatInvocation: mockSetCatInvocation,
  setMessageUsage: mockSetMessageUsage,
  requestStreamCatchUp: mockRequestStreamCatchUp,
  setMessageMetadata: mockSetMessageMetadata,
  setMessageThinking: mockSetMessageThinking,
  setMessageStreamInvocation: mockSetMessageStreamInvocation,
  patchMessage: mockPatchMessage,

  addMessageToThread: mockAddMessageToThread,
  replaceMessages: mockReplaceMessages,
  hasMore: true,
  clearThreadActiveInvocation: mockClearThreadActiveInvocation,
  resetThreadInvocationState: mockResetThreadInvocationState,
  setThreadMessageStreaming: mockSetThreadMessageStreaming,
  getThreadState: mockGetThreadState,
  currentThreadId: 'thread-1',
};

let captured: ReturnType<typeof useAgentMessages> | undefined;

vi.mock('@/stores/chatStore', () => {
  const useChatStoreMock = Object.assign(() => storeState, { getState: () => storeState });
  return {
    useChatStore: useChatStoreMock,
  };
});

function Harness() {
  captured = useAgentMessages();
  return null;
}

describe('useAgentMessages placeholder recovery', () => {
  let container: HTMLDivElement;
  let root: Root;

  beforeAll(() => {
    (globalThis as { React?: typeof React }).React = React;
    (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
  });

  afterAll(() => {
    delete (globalThis as { React?: typeof React }).React;
    delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
  });

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);
    root = createRoot(container);
    captured = undefined;
    configureDebug({ enabled: false });
    delete (window as typeof window & { __catCafeDebug?: unknown }).__catCafeDebug;
    storeState.messages = [];
    storeState.catInvocations = {};
    storeState.activeInvocations = {};
    mockAddMessage.mockClear();
    mockAppendToMessage.mockClear();
    mockAppendToolEvent.mockClear();
    mockAppendRichBlock.mockClear();
    mockPatchMessage.mockClear();
    mockSetMessageThinking.mockClear();
  });

  afterEach(() => {
    act(() => {
      root.unmount();
    });
    container.remove();
    configureDebug({ enabled: false });
    delete (window as typeof window & { __catCafeDebug?: unknown }).__catCafeDebug;
  });

  it('reuses an existing streaming bubble when thinking arrives after active refs were lost', () => {
    storeState.messages = [
      {
        id: 'msg-live-1',
        type: 'assistant',
        catId: 'opus',
        content: 'partial reply',
        isStreaming: true,
        timestamp: Date.now(),
      },
    ];

    act(() => {
      root.render(React.createElement(Harness));
    });

    act(() => {
      captured?.handleAgentMessage({
        type: 'system_info',
        catId: 'opus',
        content: JSON.stringify({ type: 'thinking', text: 'still thinking' }),
      });
    });

    expect(mockAddMessage).not.toHaveBeenCalled();
    expect(mockSetMessageThinking).toHaveBeenCalledWith('msg-live-1', 'still thinking');
  });

  it('reuses an existing streaming bubble when rich_block arrives after active refs were lost', () => {
    storeState.messages = [
      {
        id: 'msg-live-2',
        type: 'assistant',
        catId: 'opus',
        content: 'partial reply',
        isStreaming: true,
        timestamp: Date.now(),
      },
    ];

    act(() => {
      root.render(React.createElement(Harness));
    });

    act(() => {
      captured?.handleAgentMessage({
        type: 'system_info',
        catId: 'opus',
        content: JSON.stringify({
          type: 'rich_block',
          block: { id: 'rb-1', kind: 'card', v: 1, title: 'hello', body: 'world' },
        }),
      });
    });

    expect(mockAddMessage).not.toHaveBeenCalled();
    expect(mockAppendRichBlock).toHaveBeenCalledWith('msg-live-2', expect.objectContaining({ id: 'rb-1' }));
  });

  it('seeds a new stream bubble with invocationId when tool_use carries msg.invocationId explicitly', () => {
    // F173 hotfix: bubble creation uses ONLY explicit msg.invocationId (no catInvocations /
    // activeInvocations fallback). Tool events that carry invocationId bind directly.

    act(() => {
      root.render(React.createElement(Harness));
    });

    act(() => {
      captured?.handleAgentMessage({
        type: 'tool_use',
        catId: 'opus',
        invocationId: 'inv-active-1',
        toolName: 'command_execution',
        toolInput: { command: 'git status' },
      });
    });

    expect(mockAddMessage).toHaveBeenCalledWith(
      expect.objectContaining({
        type: 'assistant',
        catId: 'opus',
        origin: 'stream',
        extra: { stream: { invocationId: 'inv-active-1' } },
      }),
    );
  });

  it('creates an UNBOUND placeholder when tool_use arrives before invocation_created (no msg.invocationId)', () => {
    // F173 hotfix: without explicit invocationId, bubble is unbound. invocation_created's
    // rebind step (exercised in useAgentMessages-invocation-created.test.ts) will bind it.

    act(() => {
      root.render(React.createElement(Harness));
    });

    act(() => {
      captured?.handleAgentMessage({
        type: 'tool_use',
        catId: 'opus',
        toolName: 'command_execution',
        toolInput: { command: 'git status' },
      });
    });

    const created = mockAddMessage.mock.calls.find(
      ([m]) => m.type === 'assistant' && m.catId === 'opus' && m.origin === 'stream',
    )?.[0];
    expect(created).toBeTruthy();
    expect(created?.extra?.stream?.invocationId).toBeUndefined();
  });

  it('does not reuse an existing post_msg callback bubble as the active stream/tool container', () => {
    storeState.messages = [
      {
        id: 'msg-callback-post',
        type: 'assistant',
        catId: 'opus',
        content: 'post_msg speech should stay separate',
        isStreaming: false,
        origin: 'callback',
        extra: { stream: { invocationId: 'inv-parent', turnInvocationId: 'turn-active' } },
        timestamp: Date.now() - 1000,
      },
    ];

    act(() => {
      root.render(React.createElement(Harness));
    });

    act(() => {
      captured?.handleAgentMessage({
        type: 'tool_use',
        catId: 'opus',
        invocationId: 'inv-parent',
        turnInvocationId: 'turn-active',
        toolName: 'command_execution',
        toolInput: { command: 'git status' },
      });
    });

    expect(mockAppendToolEvent).not.toHaveBeenCalledWith('msg-callback-post', expect.anything());
    const streamBubble = mockAddMessage.mock.calls
      .map(([m]) => m)
      .find((m) => m.type === 'assistant' && m.origin === 'stream');
    const callbackBubble = storeState.messages.find((m) => m.id === 'msg-callback-post');
    expect(streamBubble).toMatchObject({
      type: 'assistant',
      catId: 'opus',
      origin: 'stream',
      isStreaming: true,
      extra: { stream: { invocationId: 'inv-parent', turnInvocationId: 'turn-active' } },
    });
    expect(callbackBubble).toMatchObject({
      type: 'assistant',
      catId: 'opus',
      origin: 'callback',
      content: 'post_msg speech should stay separate',
      isStreaming: false,
    });
  });

  it('records bubble timeline with explicit invocationId when tool_use binds the bubble', () => {
    configureDebug({ enabled: true });
    ensureWindowDebugApi();

    act(() => {
      root.render(React.createElement(Harness));
    });

    act(() => {
      captured?.handleAgentMessage({
        type: 'tool_use',
        catId: 'opus',
        invocationId: 'inv-active-1',
        toolName: 'command_execution',
        toolInput: { command: 'git status' },
      });
    });

    const debugApi = (
      window as typeof window & {
        __catCafeDebug?: { dumpBubbleTimeline?: (options?: { rawThreadId?: boolean }) => string };
      }
    ).__catCafeDebug;
    const dump = JSON.parse(debugApi!.dumpBubbleTimeline!({ rawThreadId: true })) as {
      events: Array<Record<string, unknown>>;
    };

    expect(dump.events).toEqual([
      expect.objectContaining({
        event: 'bubble_lifecycle',
        threadId: 'thread-1',
        action: 'create',
        reason: 'active_late_bind',
        catId: 'opus',
        invocationId: 'inv-active-1',
        origin: 'stream',
      }),
    ]);
    expect(dump.events[0]?.messageId).toEqual(expect.any(String));
  });

  it('recovers when replace hydration swaps the local stream id to a persisted server id mid-stream', () => {
    storeState.catInvocations = { opus: { invocationId: 'inv-live-1' } };

    act(() => {
      root.render(React.createElement(Harness));
    });

    act(() => {
      captured?.handleAgentMessage({
        type: 'text',
        catId: 'opus',
        content: 'hello',
        origin: 'stream',
      });
    });

    // F183 B1.2.3: new stream bubble may go via reducer + replaceMessages instead of addMessage
    const localBubble =
      mockAddMessage.mock.calls.at(-1)?.[0] ??
      (mockReplaceMessages.mock.calls.at(-1)?.[0] as Array<{ id?: string; catId?: string }> | undefined)?.find?.(
        (m) => m.catId === 'opus',
      );
    expect(localBubble?.id).toBeTruthy();

    // Hydration replaces the optimistic/local bubble with the persisted server message.
    storeState.messages = [
      {
        id: 'msg-server-1',
        type: 'assistant',
        catId: 'opus',
        content: 'hello',
        origin: 'stream',
        extra: { stream: { invocationId: 'inv-live-1' } },
        isStreaming: false,
        timestamp: Date.now(),
      },
    ];
    mockAppendToMessage.mockClear();
    mockSetStreaming.mockClear();

    act(() => {
      captured?.handleAgentMessage({
        type: 'text',
        catId: 'opus',
        content: ' world',
        origin: 'stream',
      });
    });

    expect(mockSetStreaming).toHaveBeenCalledWith('msg-server-1', true);
    expect(mockAppendToMessage).toHaveBeenCalledWith('msg-server-1', ' world');
  });

  it('preserves reply threading metadata on new stream bubbles', () => {
    act(() => {
      root.render(React.createElement(Harness));
    });

    act(() => {
      captured?.handleAgentMessage({
        type: 'text',
        catId: 'codex',
        content: '收到，我来处理',
        origin: 'stream',
        replyTo: 'msg-parent-1',
        replyPreview: { senderCatId: 'opus', content: '@缅因猫 帮忙看一下' },
      });
    });

    // F183 B1.2.3 wire-up: new stream bubble 走 reducer + replaceMessages，
    // replyTo/replyPreview 走 mockPatchMessage 单独 patch。检查最终状态而非 API。
    const newBubble =
      storeState.messages.find((m) => m.catId === 'codex' && m.origin === 'stream') ??
      mockAddMessage.mock.calls.map((c) => c[0]).find((m) => m.catId === 'codex' && m.origin === 'stream');
    expect(newBubble).toMatchObject({ type: 'assistant', catId: 'codex', origin: 'stream' });
    // replyTo/replyPreview should be set via either addMessage payload or follow-up patchMessage
    const patchedReply = mockPatchMessage.mock.calls.find(
      (c) => c[1]?.replyTo === 'msg-parent-1' && c[1]?.replyPreview?.senderCatId === 'opus',
    );
    expect(
      newBubble?.replyTo === 'msg-parent-1' || !!patchedReply,
      'replyTo + replyPreview must be applied via addMessage or follow-up patchMessage',
    ).toBe(true);
  });

  it('replaces stream bubble content instead of appending on replace-mode text', () => {
    storeState.messages = [
      {
        id: 'msg-live-rewrite',
        type: 'assistant',
        catId: 'opus',
        content: '第一段。第二段。',
        isStreaming: true,
        origin: 'stream',
        timestamp: Date.now(),
      },
    ];

    act(() => {
      root.render(React.createElement(Harness));
    });

    act(() => {
      captured?.handleAgentMessage({
        type: 'text',
        catId: 'opus',
        content: '第一段。插入一句。第二段。',
        textMode: 'replace',
        origin: 'stream',
      });
    });

    expect(mockPatchMessage).toHaveBeenCalledWith('msg-live-rewrite', { content: '第一段。插入一句。第二段。' });
    expect(mockAppendToMessage).not.toHaveBeenCalled();
  });
});
