import * as React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, cleanup, render, screen, fireEvent, renderHook, waitFor } from '@testing-library/react';

import { MessageBubble } from '../MessageBubble.js';
import { PromptComposer } from '../PromptComposer.js';
import { ChatStream } from '../ChatStream.js';
import { useChat } from '../useChat.js';
import { streamText } from '../useStreamingText.js';
import type { Message } from '../types.js';

// ── Test helpers ─────────────────────────────────────────────────────────────

/**
 * Build a streaming Response that yields each chunk after a tick, so the
 * hook's async-iteration loop actually runs through multiple React updates.
 */
function makeStreamingResponse(chunks: string[], ok = true, status = 200): Response {
  const encoder = new TextEncoder();
  const body = new ReadableStream<Uint8Array>({
    async start(controller) {
      for (const chunk of chunks) {
        await Promise.resolve(); // microtask yield
        controller.enqueue(encoder.encode(chunk));
      }
      controller.close();
    },
  });
  return new Response(body, {
    status,
    headers: { 'Content-Type': 'text/plain' },
  }) as Response & { ok: boolean };
}

// ── MessageBubble ────────────────────────────────────────────────────────────

describe('MessageBubble', () => {
  beforeEach(() => cleanup());

  it('renders user message with content', () => {
    const msg: Message = { id: '1', role: 'user', content: 'Hi there' };
    render(<MessageBubble message={msg} />);
    expect(screen.getByTestId('bl-message-1')).toBeDefined();
    expect(screen.getByText('Hi there')).toBeDefined();
  });

  it('renders the role label (capitalized) by default', () => {
    const msg: Message = { id: '1', role: 'assistant', content: 'Hello' };
    render(<MessageBubble message={msg} />);
    expect(screen.getByText('Assistant')).toBeDefined();
  });

  it('shows thinking dots while assistant is streaming with empty content', () => {
    const msg: Message = { id: '1', role: 'assistant', content: '', isStreaming: true };
    render(<MessageBubble message={msg} />);
    expect(screen.getByRole('status', { name: /typing/i })).toBeDefined();
  });

  it('does NOT show thinking dots for streaming user messages', () => {
    // User messages don't stream — guard against accidental indicator.
    const msg: Message = { id: '1', role: 'user', content: '', isStreaming: true };
    render(<MessageBubble message={msg} />);
    expect(screen.queryByRole('status')).toBeNull();
  });

  it('renders custom actions slot', () => {
    const msg: Message = { id: '1', role: 'assistant', content: 'X' };
    render(
      <MessageBubble
        message={msg}
        actions={m => <button data-testid="act">copy {m.id}</button>}
      />,
    );
    expect(screen.getByTestId('act').textContent).toBe('copy 1');
  });

  it('exposes data-role and data-streaming on the article', () => {
    const msg: Message = { id: '1', role: 'tool', content: '{}', isStreaming: false };
    render(<MessageBubble message={msg} />);
    const el = screen.getByTestId('bl-message-1');
    expect(el.getAttribute('data-role')).toBe('tool');
    expect(el.getAttribute('data-streaming')).toBe('false');
  });
});

// ── PromptComposer ───────────────────────────────────────────────────────────

describe('PromptComposer', () => {
  beforeEach(() => cleanup());

  it('renders textarea + send button', () => {
    render(<PromptComposer value="" onChange={() => {}} onSubmit={() => {}} />);
    expect(screen.getByTestId('bl-prompt-textarea')).toBeDefined();
    expect(screen.getByTestId('bl-prompt-send')).toBeDefined();
  });

  it('propagates input into the controlled value via onChange', () => {
    // Stateful wrapper exercises the actual controlled-input round-trip,
    // which is more meaningful than asserting on the synthetic event shape
    // (and avoids a happy-dom + React 19 controlled-input quirk).
    function Wrapper() {
      const [v, setV] = React.useState('');
      return (
        <>
          <PromptComposer
            value={v}
            onChange={e => setV(e.target.value)}
            onSubmit={() => {}}
          />
          <output data-testid="echo">{v}</output>
        </>
      );
    }
    render(<Wrapper />);
    const ta = screen.getByTestId('bl-prompt-textarea') as HTMLTextAreaElement;
    fireEvent.input(ta, { target: { value: 'hello' } });
    expect(screen.getByTestId('echo').textContent).toBe('hello');
  });

  it('submits on plain Enter when value is non-empty', () => {
    const onSubmit = vi.fn();
    render(<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} />);
    fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), { key: 'Enter' });
    expect(onSubmit).toHaveBeenCalledOnce();
  });

  it('does NOT submit on Shift+Enter (newline)', () => {
    const onSubmit = vi.fn();
    render(<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} />);
    fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), {
      key: 'Enter',
      shiftKey: true,
    });
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it('submits on Cmd+Enter', () => {
    const onSubmit = vi.fn();
    render(<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} />);
    fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), {
      key: 'Enter',
      metaKey: true,
    });
    expect(onSubmit).toHaveBeenCalledOnce();
  });

  it('does not submit when disabled', () => {
    const onSubmit = vi.fn();
    render(
      <PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} disabled />,
    );
    fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), { key: 'Enter' });
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it('shows Stop instead of Send when showStop=true and calls onStop', () => {
    const onStop = vi.fn();
    render(
      <PromptComposer
        value=""
        onChange={() => {}}
        onSubmit={() => {}}
        showStop
        onStop={onStop}
      />,
    );
    expect(screen.queryByTestId('bl-prompt-send')).toBeNull();
    fireEvent.click(screen.getByTestId('bl-prompt-stop'));
    expect(onStop).toHaveBeenCalledOnce();
  });

  it('blurs on Escape', () => {
    render(<PromptComposer value="hi" onChange={() => {}} onSubmit={() => {}} />);
    const ta = screen.getByTestId('bl-prompt-textarea') as HTMLTextAreaElement;
    ta.focus();
    expect(document.activeElement).toBe(ta);
    fireEvent.keyDown(ta, { key: 'Escape' });
    expect(document.activeElement).not.toBe(ta);
  });
});

// ── streamText (protocol parsing) ────────────────────────────────────────────

describe('streamText', () => {
  it('yields plain text chunks verbatim', async () => {
    const r = makeStreamingResponse(['Hel', 'lo ', 'world']);
    const out: string[] = [];
    for await (const c of streamText(r, 'text')) out.push(c);
    expect(out.join('')).toBe('Hello world');
  });

  it('parses SSE data: lines and skips [DONE]', async () => {
    const r = makeStreamingResponse([
      'data: "Hello"\n\n',
      'data: " there"\n\n',
      'data: [DONE]\n\n',
    ]);
    const out: string[] = [];
    for await (const c of streamText(r, 'sse')) out.push(c);
    expect(out.join('')).toBe('Hello there');
  });

  it('unwraps JSON SSE objects with content/text/delta fields', async () => {
    const r = makeStreamingResponse([
      'data: {"content":"A"}\n\n',
      'data: {"text":"B"}\n\n',
      'data: {"delta":"C"}\n\n',
    ]);
    const out: string[] = [];
    for await (const c of streamText(r, 'sse')) out.push(c);
    expect(out.join('')).toBe('ABC');
  });

  it("parses Vercel 'data' prefix protocol 0: text frames", async () => {
    const r = makeStreamingResponse(['0:"Hi"\n', '0:" there"\n', '2:[{"x":1}]\n']);
    const out: string[] = [];
    for await (const c of streamText(r, 'data')) out.push(c);
    expect(out.join('')).toBe('Hi there');
  });

  it('terminates cleanly when the abort signal fires mid-stream', async () => {
    const ac = new AbortController();
    const r = makeStreamingResponse(['one ', 'two ', 'three']);
    const out: string[] = [];
    let i = 0;
    for await (const c of streamText(r, 'text', ac.signal)) {
      out.push(c);
      if (++i === 1) ac.abort();
    }
    expect(out.length).toBe(1);
  });
});

// ── useChat ──────────────────────────────────────────────────────────────────

describe('useChat', () => {
  it('starts with empty messages by default', () => {
    const { result } = renderHook(() => useChat());
    expect(result.current.messages).toEqual([]);
    expect(result.current.isLoading).toBe(false);
    expect(result.current.input).toBe('');
  });

  it('honors initialMessages + initialInput', () => {
    const init: Message[] = [{ id: 'a', role: 'user', content: 'seed' }];
    const { result } = renderHook(() =>
      useChat({ initialMessages: init, initialInput: 'draft' }),
    );
    expect(result.current.messages).toEqual(init);
    expect(result.current.input).toBe('draft');
  });

  it('handleSubmit POSTs the conversation and streams a reply', async () => {
    const onFinish = vi.fn();
    const fetcher = vi.fn(async () =>
      makeStreamingResponse(['Hello', ' ', 'world']),
    );
    const { result } = renderHook(() =>
      useChat({ fetcher: fetcher as unknown as typeof fetch, onFinish }),
    );

    act(() => result.current.setInput('Hi'));
    await act(async () => {
      await result.current.handleSubmit();
    });

    await waitFor(() => expect(result.current.isLoading).toBe(false));

    expect(fetcher).toHaveBeenCalledOnce();
    const args = (fetcher.mock.calls[0] as unknown) as [string, RequestInit];
    expect(args[0]).toBe('/api/chat');
    expect(args[1].method).toBe('POST');

    expect(result.current.messages.length).toBe(2);
    expect(result.current.messages[0]).toMatchObject({ role: 'user', content: 'Hi' });
    expect(result.current.messages[1]).toMatchObject({
      role: 'assistant',
      content: 'Hello world',
      isStreaming: false,
    });
    expect(onFinish).toHaveBeenCalledOnce();
    expect(result.current.input).toBe('');
  });

  it('surfaces transport errors via the error field', async () => {
    const fetcher = vi.fn(async () => new Response('boom', { status: 500 }));
    const onError = vi.fn();
    const { result } = renderHook(() =>
      useChat({ fetcher: fetcher as unknown as typeof fetch, onError }),
    );

    act(() => result.current.setInput('die'));
    await act(async () => {
      await result.current.handleSubmit();
    });

    await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
    expect(result.current.error?.message).toMatch(/500/);
    expect(onError).toHaveBeenCalledOnce();
    expect(result.current.isLoading).toBe(false);
  });

  it('does not submit empty input', async () => {
    const fetcher = vi.fn();
    const { result } = renderHook(() =>
      useChat({ fetcher: fetcher as unknown as typeof fetch }),
    );
    await act(async () => {
      await result.current.handleSubmit();
    });
    expect(fetcher).not.toHaveBeenCalled();
  });

  it('stop() aborts the in-flight request', async () => {
    let sawAbort = false;
    const fetcher = vi.fn(async (_url: RequestInfo, init?: RequestInit) => {
      init?.signal?.addEventListener('abort', () => {
        sawAbort = true;
      });
      // Keep the stream open indefinitely until aborted.
      const body = new ReadableStream({
        start(controller) {
          controller.enqueue(new TextEncoder().encode('partial'));
          // Never closes.
        },
      });
      return new Response(body, { status: 200 });
    });
    const { result } = renderHook(() =>
      useChat({ fetcher: fetcher as unknown as typeof fetch }),
    );

    act(() => result.current.setInput('long'));
    void result.current.handleSubmit();
    await waitFor(() => expect(result.current.isLoading).toBe(true));
    act(() => result.current.stop());
    await waitFor(() => expect(result.current.isLoading).toBe(false));
    expect(sawAbort).toBe(true);
    // Trailing assistant bubble should have isStreaming cleared.
    const trailing = result.current.messages.at(-1);
    expect(trailing?.isStreaming).toBeFalsy();
  });
});

// ── ChatStream (integration) ─────────────────────────────────────────────────

describe('ChatStream', () => {
  beforeEach(() => cleanup());

  it('renders empty state when there are no messages', () => {
    const fetcher = vi.fn();
    render(
      <ChatStream
        fetcher={fetcher as unknown as typeof fetch}
        emptyState={<div data-testid="empty">Say hi 👋</div>}
      />,
    );
    expect(screen.getByTestId('empty')).toBeDefined();
  });

  it('end-to-end: type → submit → see user + assistant bubble', async () => {
    const fetcher = vi.fn(async () =>
      makeStreamingResponse(['Hi', ' there!']),
    );
    render(
      <ChatStream fetcher={fetcher as unknown as typeof fetch} />,
    );

    fireEvent.change(screen.getByTestId('bl-prompt-textarea'), {
      target: { value: 'hello' },
    });
    fireEvent.click(screen.getByTestId('bl-prompt-send'));

    await waitFor(() => {
      expect(screen.getByText('hello')).toBeDefined();
      expect(screen.getByText('Hi there!')).toBeDefined();
    });
  });
});
