import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Message, WsFrame } from '@/api/types';
import { useSessionStream } from './useSessionStream';

// ── Hoisted mock values ──────────────────────────────────────────────────────

const { mockMessagesList, mockEmit, mockSubscribe, mockRecordUsage } = vi.hoisted(
  () => ({
    mockMessagesList: vi.fn(),
    mockEmit: vi.fn(),
    mockSubscribe: vi.fn().mockReturnValue(vi.fn()),
    mockRecordUsage: vi.fn(),
  }),
);

// ── Module mocks ─────────────────────────────────────────────────────────────

vi.mock('sonner', () => ({
  toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
}));

vi.mock('@/api/client', () => ({
  api: { messages: { list: mockMessagesList } },
}));

vi.mock('./sessionEvents', () => ({
  sessionEvents: { emit: mockEmit, subscribe: mockSubscribe },
}));

vi.mock('./useChatThroughput', () => ({
  recordUsage: mockRecordUsage,
}));

// ── Test constants ───────────────────────────────────────────────────────────

const SESSION_ID = '00000000-0000-0000-0000-000000000001';
const CHAT_ID = '00000000-0000-0000-0000-000000000002';
const MSG_ID = '00000000-0000-0000-0000-000000000003';
const TOOL_MSG_ID = '00000000-0000-0000-0000-000000000004';

// ── Frame builder helpers ────────────────────────────────────────────────────

function textDelta(seq: number, content: string): WsFrame {
  return {
    type: 'channel_delta', seq, channel: 'text', message_id: MSG_ID, chat_id: CHAT_ID, content,
  } as unknown as WsFrame;
}

function toolCallDelta(
  seq: number,
  tc: { id: string; name: string; args: Record<string, unknown> },
): WsFrame {
  return {
    type: 'channel_delta', seq, channel: 'tool_call', message_id: MSG_ID, chat_id: CHAT_ID, tool_call: tc,
  } as unknown as WsFrame;
}

function toolResultDelta(seq: number, callId: string, output: unknown): WsFrame {
  return {
    type: 'channel_delta', seq, channel: 'tool_result',
    tool_message_id: TOOL_MSG_ID, chat_id: CHAT_ID, tool_call_id: callId,
    output, truncated: false,
  } as unknown as WsFrame;
}

function statusDelta(
  seq: number, status: 'running' | 'complete' | 'cancelled' | 'failed',
  overrides?: Record<string, unknown>,
): WsFrame {
  return {
    type: 'channel_delta', seq, channel: 'status', message_id: MSG_ID, chat_id: CHAT_ID, status,
    ...(overrides ?? {}),
  } as unknown as WsFrame;
}

function errorDelta(seq: number, error: string): WsFrame {
  return {
    type: 'channel_delta', seq, channel: 'error', message_id: MSG_ID, chat_id: CHAT_ID, error,
  } as unknown as WsFrame;
}

// ── WebSocket mock globals ───────────────────────────────────────────────────

interface MockWs {
  onopen: (() => void) | null;
  onmessage: ((ev: { data: string }) => void) | null;
  onclose: ((ev: { code?: number; reason?: string }) => void) | null;
  onerror: (() => void) | null;
  send: ReturnType<typeof vi.fn>;
  close: ReturnType<typeof vi.fn>;
  readyState: number;
  url: string;
}

let currentMockWs: MockWs | null = null;
let wsConstructCount = 0;

function createWsMock(): MockWs {
  return {
    onopen: null, onmessage: null, onclose: null, onerror: null,
    send: vi.fn(), close: vi.fn(), readyState: 1, url: '',
  };
}

function triggerWsOpen(): void { currentMockWs?.onopen?.(); }
function triggerWsMessage(frame: WsFrame): void {
  currentMockWs?.onmessage?.({ data: JSON.stringify(frame) });
}
function triggerWsClose(): void { currentMockWs?.onclose?.({}); }
function getWsSendCalls(): string[] {
  return (currentMockWs?.send.mock.calls ?? []).map((c: unknown[]) => String(c[0]));
}

// ── React test harness ───────────────────────────────────────────────────────

function createHarness() {
  const states: Array<{ messages: Message[]; connected: boolean; error: string | null }> = [];

  function Wrapper({ sessionId }: { sessionId: string | undefined }) {
    const state = useSessionStream(sessionId);
    states.push(state);
    return React.createElement('div');
  }

  return {
    Wrapper,
    lastState: () => states[states.length - 1] ?? null,
  };
}

let root: Root;
let container: HTMLDivElement;

beforeEach(() => {
  currentMockWs = null;
  wsConstructCount = 0;
  mockMessagesList.mockReset().mockResolvedValue([]);
  mockEmit.mockReset();
  mockSubscribe.mockReset().mockReturnValue(vi.fn());
  vi.clearAllMocks();

  vi.spyOn(window, 'WebSocket').mockImplementation(function (this: WebSocket, url: string | URL) {
    const ws = createWsMock();
    ws.url = String(url);
    currentMockWs = ws;
    wsConstructCount++;
    const proto = {
      get onopen() { return ws.onopen; },
      set onopen(fn) { ws.onopen = fn as () => void; },
      get onmessage() { return ws.onmessage; },
      set onmessage(fn) { ws.onmessage = fn as (ev: { data: string }) => void; },
      get onclose() { return ws.onclose; },
      set onclose(fn) { ws.onclose = fn as (ev: { code?: number; reason?: string }) => void; },
      get onerror() { return ws.onerror; },
      set onerror(fn) { ws.onerror = fn as () => void; },
      send: vi.fn((d: string) => ws.send(d)),
      close: vi.fn(() => { ws.close(); ws.onclose?.({}); }),
      readyState: 1, url: ws.url,
      CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3,
    };
    return proto as unknown as WebSocket;
  });

  container = document.createElement('div');
  document.body.appendChild(container);
  root = createRoot(container);
});

afterEach(() => {
  if (root) {
    act(() => { root.unmount(); });
  }
  if (container.parentNode) {
    container.parentNode.removeChild(container);
  }
  vi.restoreAllMocks();
  currentMockWs = null;
});

async function renderHook(sessionId: string | undefined) {
  const harness = createHarness();
  await act(async () => {
    root.render(
      React.createElement(harness.Wrapper, {
        sessionId: sessionId ?? (null as unknown as undefined),
      }),
    );
  });
  await act(async () => {});
  return harness;
}

async function flush() {
  await act(async () => {});
}

// ── Tests ────────────────────────────────────────────────────────────────────

describe('useSessionStream', () => {
  describe('connection lifecycle', () => {
    it('does not connect when sessionId is undefined', async () => {
      const h = await renderHook(undefined);
      const s = h.lastState();
      expect(s).not.toBeNull();
      expect(s!.connected).toBe(false);
      expect(s!.messages).toEqual([]);
    });

    it('connects when sessionId is provided', async () => {
      await renderHook(SESSION_ID);
      expect(wsConstructCount).toBe(1);
    });

    it('sets connected=true on WebSocket open', async () => {
      const h = await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      expect(h.lastState()!.connected).toBe(true);
      expect(h.lastState()!.error).toBeNull();
    });

    it('sends reconnect frame with lastSeqPerChannel on open', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      const sends = getWsSendCalls();
      const reconnectMsg = sends.find((s) => s.includes('reconnect'));
      expect(reconnectMsg).toBeDefined();
      if (reconnectMsg) {
        const parsed = JSON.parse(reconnectMsg);
        expect(parsed.type).toBe('reconnect');
        expect(parsed.lastSeqPerChannel).toEqual({});
      }
    });
  });

  describe('non-channel frames', () => {
    it('processes snapshot frame', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      const msg: Message = {
        id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'user', content: 'hi',
        kind: 'message', tool_calls: null, tool_results: null, status: 'complete',
        last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
        cache_tokens: null, reasoning_tokens: null, model: null,
        started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
      };
      act(() => { triggerWsMessage({ type: 'snapshot', messages: [msg] } as WsFrame); });
      await flush();
    });

    it('processes delta frame for existing message', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      const streamingMsg: Message = {
        id: MSG_ID, session_id: SESSION_ID, chat_id: CHAT_ID, role: 'assistant', content: '',
        kind: 'message', tool_calls: null, tool_results: null, status: 'streaming',
        last_seq: 0, tokens_used: null, ctx_used: null, ctx_max: null,
        cache_tokens: null, reasoning_tokens: null, model: null,
        started_at: null, finished_at: null, created_at: '2026-01-01T00:00:00Z', metadata: null,
      };
      act(() => { triggerWsMessage({ type: 'snapshot', messages: [streamingMsg] } as WsFrame); });
      await flush();
      act(() => { triggerWsMessage({ type: 'delta', message_id: MSG_ID, content: 'Hello!' } as WsFrame); });
      await flush();
    });

    it('emits git_diff_refresh on message_complete', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => {
        triggerWsMessage({
          type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
        } as WsFrame);
      });
      await flush();
      act(() => {
        triggerWsMessage({
          type: 'message_complete', message_id: MSG_ID, chat_id: CHAT_ID,
        } as WsFrame);
      });
      await flush();
      expect(mockEmit).toHaveBeenCalledWith(
        expect.objectContaining({ type: 'git_diff_refresh' }),
      );
    });

    it('processes messages_deleted frame', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => {
        triggerWsMessage({
          type: 'messages_deleted', message_ids: [MSG_ID], chat_id: CHAT_ID,
        } as WsFrame);
      });
      await flush();
    });

    it('processes compacted frame and refetches messages', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => {
        triggerWsMessage({
          type: 'compacted', session_id: SESSION_ID, chat_id: CHAT_ID, summary_message_id: MSG_ID,
        } as WsFrame);
      });
      await flush();
      expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
    });

    it('processes usage frame and calls recordUsage', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => {
        triggerWsMessage({
          type: 'usage', message_id: MSG_ID, chat_id: CHAT_ID,
          completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
        } as WsFrame);
      });
      await flush();
      expect(mockRecordUsage).toHaveBeenCalledWith(CHAT_ID, {
        completion_tokens: 100, ctx_used: 5000, ctx_max: 32768,
      });
    });

    it('emits chat_updated on chat_renamed frame', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => {
        triggerWsMessage({
          type: 'chat_renamed', chat_id: CHAT_ID, name: 'New Chat Name',
        } as WsFrame);
      });
      await flush();
      expect(mockEmit).toHaveBeenCalledWith(
        expect.objectContaining({ type: 'chat_updated', chat_id: CHAT_ID, name: 'New Chat Name' }),
      );
    });

    it('handles pass-through frames: agent_snapshot, agent_status_updated, flow_run*, battle*', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();

      act(() => {
        triggerWsMessage({ type: 'agent_snapshot', chat_id: CHAT_ID, model: 'qwen', turn_number: 3 } as WsFrame);
        triggerWsMessage({
          type: 'agent_status_updated', chat_id: CHAT_ID, agent: 'coder', status: 'working',
          at: '2026-01-01T00:00:00Z',
        } as WsFrame);
        triggerWsMessage({
          type: 'flow_run_started', run_id: MSG_ID, flow_name: 'test', band: 'small', steps: [],
        } as unknown as WsFrame);
        triggerWsMessage({
          type: 'flow_run_step_updated', run_id: MSG_ID, step_id: 's1', status: 'completed',
        } as unknown as WsFrame);
        triggerWsMessage({
          type: 'battle_started', battle_id: MSG_ID, battle_type: 'coding', prompt: 'test', contestants: [],
        } as unknown as WsFrame);
        triggerWsMessage({
          type: 'contestant_updated', battle_id: MSG_ID, contestant_id: CHAT_ID,
        } as unknown as WsFrame);
        triggerWsMessage({ type: 'battle_updated', battle_id: MSG_ID } as unknown as WsFrame);
      });
      await flush();
    });
  });

  describe('channel_delta frames', () => {
    async function setup() {
      const h = await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => {
        triggerWsMessage({
          type: 'message_started', message_id: MSG_ID, chat_id: CHAT_ID, role: 'assistant',
        } as WsFrame);
      });
      await flush();
      return h;
    }

    it('processes text channel delta', async () => {
      await setup();
      act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
      await flush();
      act(() => { triggerWsMessage(textDelta(1, 'World!')); });
      await flush();
    });

    it('processes out-of-order deltas (seq=1 before seq=0)', async () => {
      await setup();
      act(() => { triggerWsMessage(textDelta(1, 'World!')); });
      await flush();
      act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
      await flush();
    });

    it('ignores duplicate seq', async () => {
      await setup();
      act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
      await flush();
      act(() => { triggerWsMessage(textDelta(0, 'Hello ')); });
      await flush();
    });

    it('fills gap and flushes in order (seq=0, seq=2, then seq=1)', async () => {
      await setup();
      act(() => { triggerWsMessage(textDelta(0, 'First ')); });
      await flush();
      act(() => { triggerWsMessage(textDelta(2, 'Third ')); });
      await flush();
      act(() => { triggerWsMessage(textDelta(1, 'Second ')); });
      await flush();
    });

    it('processes tool_call channel delta', async () => {
      await setup();
      act(() => {
        triggerWsMessage(toolCallDelta(0, { id: 'call_1', name: 'read', args: { path: '/' } }));
      });
      await flush();
    });

    it('processes tool_result channel delta', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => { triggerWsMessage(toolResultDelta(0, 'call_1', { data: 'file content' })); });
      await flush();
    });

    it('processes error channel delta', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => { triggerWsMessage(errorDelta(0, 'Something went wrong')); });
      await flush();
    });

    it('processes status delta: running creates message, complete terminates it', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => { triggerWsMessage(statusDelta(0, 'running')); });
      await flush();
      act(() => {
        triggerWsMessage(statusDelta(1, 'complete', { tokens_used: 42, ctx_used: 1000 }));
      });
      await flush();
    });

    it('handles multi-channel interleaved deltas with independent seq', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      const tId1 = '00000000-0000-0000-0000-000000000010';
      const tId2 = '00000000-0000-0000-0000-000000000011';
      act(() => {
        triggerWsMessage({ type: 'message_started', message_id: tId1, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
        triggerWsMessage({ type: 'message_started', message_id: tId2, chat_id: CHAT_ID, role: 'assistant' } as WsFrame);
      });
      await flush();
      act(() => {
        triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'A' } as unknown as WsFrame);
        triggerWsMessage({ type: 'channel_delta', seq: 1, channel: 'text', message_id: tId1, chat_id: CHAT_ID, content: 'B' } as unknown as WsFrame);
        triggerWsMessage({ type: 'channel_delta', seq: 0, channel: 'tool_call', message_id: tId2, chat_id: CHAT_ID, tool_call: { id: 'c1', name: 'ls', args: {} } } as unknown as WsFrame);
      });
      await flush();
    });
  });

  describe('status delta with metadata', () => {
    it('applies tokens_used, ctx_used, model to message via status delta', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => { triggerWsMessage(statusDelta(0, 'running')); });
      await flush();
      act(() => {
        triggerWsMessage(
          statusDelta(1, 'running', {
            tokens_used: 150, ctx_used: 5000, ctx_max: 32768, model: 'qwen-2.5-32b',
          }),
        );
      });
      await flush();
    });
  });

  describe('reconnection behavior', () => {
    it('reconnects on WebSocket close with backoff', async () => {
      vi.useFakeTimers();
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      const initialCount = wsConstructCount;
      act(() => { triggerWsClose(); });
      // Before 1000ms, no reconnect yet
      await vi.advanceTimersByTimeAsync(500);
      expect(wsConstructCount).toBe(initialCount);
      // After 1000ms from close, reconnect fires
      await vi.advanceTimersByTimeAsync(600);
      expect(wsConstructCount).toBe(initialCount + 1);
      vi.useRealTimers();
    });

    it('sends lastSeqPerChannel on reconnect', async () => {
      vi.useFakeTimers();
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => { triggerWsMessage(statusDelta(0, 'running')); });
      await flush();
      act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
      await flush();
      act(() => { triggerWsClose(); });
      await vi.advanceTimersByTimeAsync(1100);
      vi.clearAllMocks();
      act(() => { triggerWsOpen(); });
      await flush();
      const sends = getWsSendCalls();
      const reconnectMsg = sends.find((s) => s.includes('reconnect'));
      expect(reconnectMsg).toBeDefined();
      if (reconnectMsg) {
        const parsed = JSON.parse(reconnectMsg);
        expect(parsed.type).toBe('reconnect');
        expect(parsed.lastSeqPerChannel).toBeDefined();
        expect(parsed.lastSeqPerChannel.status).toBe(2);
      }
      vi.useRealTimers();
    });
  });

  describe('channel stall detection', () => {
    it('emits refetch_messages when channel stalls for 5s', async () => {
      vi.useFakeTimers();
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      act(() => { triggerWsMessage(statusDelta(1, 'complete')); });
      await flush();
      await vi.advanceTimersByTimeAsync(6000);
      const refetchCalls = mockEmit.mock.calls.filter(
        (c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
      );
      expect(refetchCalls.length).toBeGreaterThanOrEqual(1);
      vi.useRealTimers();
    });

    it('does not stall when buffer is empty', async () => {
      vi.useFakeTimers();
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      await vi.advanceTimersByTimeAsync(10000);
      const refetchCalls = mockEmit.mock.calls.filter(
        (c: unknown[]) => (c[0] as { type: string }).type === 'refetch_messages',
      );
      expect(refetchCalls.length).toBe(0);
      vi.useRealTimers();
    });
  });

  describe('sessionEvents subscription', () => {
    it('calls api.messages.list when refetch_messages event fires', async () => {
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      expect(mockSubscribe).toHaveBeenCalled();
      const fn = mockSubscribe.mock.calls[0]?.[0] as (e: { type: string }) => void;
      fn({ type: 'refetch_messages' });
      await flush();
      expect(mockMessagesList).toHaveBeenCalledWith(SESSION_ID);
    });
  });

  describe('invalid frames', () => {
    it('ignores bad JSON', async () => {
      const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      currentMockWs?.onmessage?.({ data: 'not-json-at-all' });
      await flush();
      expect(spy).toHaveBeenCalled();
      spy.mockRestore();
    });

    it('ignores schema-invalid frames', async () => {
      const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
      await renderHook(SESSION_ID);
      act(() => { triggerWsOpen(); });
      await flush();
      currentMockWs?.onmessage?.({ data: JSON.stringify({ type: 'unknown_type' }) });
      await flush();
      expect(spy).toHaveBeenCalled();
      spy.mockRestore();
    });
  });

  // Note: git_diff_refresh on status delta 'complete' is not testable through
  // the hook because the ChannelDeltaFrame Zod schema strips the `status` field
  // (it's not in the schema's field list — only StatusChannelPayload has it).
  // The message_complete → git_diff_refresh path is tested above and passes.
  // The status delta path requires a schema fix in @boocode/contracts/ws-frames.
});
