// @vitest-environment jsdom
//
// In-place session switch (chat-remount-flicker F2).
//
// Contract under test (see the switch effect in useChat):
//   1. Changing `initialSessionId` after mount dispatches one atomic
//      SESSION_SWITCH — id + transcript swap together; with a warm
//      `initialMessages` seed the feed never renders empty.
//   2. The background history read commits only for the session the user is
//      still on — a slow page for an abandoned session is dropped
//      (sessionId-guarded HISTORY_LOAD_DONE).
//   3. `sendMessage` racing a switch waits for the new session's page, so the
//      optimistic bubble can't be replaced by the arriving history.
import { act, createElement, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;

import { useChat, type UseChatReturn } from '../useChat';
import type { ChatMessage } from '../../types';
import type { ChatTransport } from '../../types/transport';
import type { HistoryPage, SessionInfo } from '../../types/session';

function msg(id: string, content: string, role: ChatMessage['role'] = 'user'): ChatMessage {
  return { id, role, content, createdAt: 1 };
}

/** Transport whose per-session history resolves when the test releases it. */
function gatedTransport(pages: Record<string, HistoryPage>): {
  transport: ChatTransport;
  release: (sessionId: string) => void;
} {
  const gates = new Map<string, { promise: Promise<void>; release: () => void }>();
  const gateFor = (sessionId: string) => {
    let g = gates.get(sessionId);
    if (!g) {
      let release!: () => void;
      const promise = new Promise<void>((r) => {
        release = r;
      });
      g = { promise, release };
      gates.set(sessionId, g);
    }
    return g;
  };
  const transport: ChatTransport = {
    async createSession(): Promise<SessionInfo> {
      return { sessionId: 'created' };
    },
    async loadHistory(sessionId: string): Promise<HistoryPage> {
      await gateFor(sessionId).promise;
      return pages[sessionId] ?? { messages: [], hasMore: false, nextCursor: null };
    },
    async *stream() {
      /* replies are irrelevant here */
    },
    async send(): Promise<ChatMessage> {
      return msg('x', 'x', 'assistant');
    },
    async closeSession() {
      /* unused */
    },
  };
  return { transport, release: (id) => gateFor(id).release() };
}

interface Harness {
  chat: () => UseChatReturn;
  setSession: (id: string, seed?: readonly ChatMessage[]) => void;
}

function mount(
  roots: Root[],
  transport: ChatTransport,
  initial: { id: string; seed?: readonly ChatMessage[] },
): Harness {
  let latest!: UseChatReturn;
  let set!: (v: { id: string; seed?: readonly ChatMessage[] }) => void;
  const root = createRoot(document.createElement('div'));
  roots.push(root);
  act(() => {
    root.render(
      createElement(function W() {
        const [session, setSession] = useState(initial);
        set = setSession;
        latest = useChat({
          transport,
          initialSessionId: session.id,
          initialMessages: session.seed,
        });
        return null;
      }),
    );
  });
  return {
    chat: () => latest,
    setSession: (id, seed) => act(() => set({ id, seed })),
  };
}

describe('useChat — in-place session switch', () => {
  let roots: Root[] = [];
  beforeEach(() => {
    roots = [];
  });
  afterEach(() => {
    act(() => roots.forEach((r) => r.unmount()));
  });

  it('switches atomically with a warm seed — never renders an empty frame', async () => {
    const { transport, release } = gatedTransport({
      'room-a': { messages: [msg('a1', 'A history')], hasMore: false, nextCursor: null },
      'room-b': { messages: [msg('b1', 'B history')], hasMore: false, nextCursor: null },
    });
    const h = mount(roots, transport, {
      id: 'room-a',
      seed: [msg('a1', 'A history')],
    });
    expect(h.chat().messages.map((m) => m.content)).toEqual(['A history']);

    h.setSession('room-b', [msg('b1', 'B cached')]);
    // Seeded swap: new id + cached transcript in the same commit.
    expect(h.chat().sessionId).toBe('room-b');
    expect(h.chat().historyLoaded).toBe(true);
    expect(h.chat().messages.map((m) => m.content)).toEqual(['B cached']);

    // Server page replaces the seed once it lands.
    await act(async () => {
      release('room-b');
      await Promise.resolve();
    });
    expect(h.chat().messages.map((m) => m.content)).toEqual(['B history']);
  });

  it('switches without a seed into the loading state, then commits the page', async () => {
    const { transport, release } = gatedTransport({
      'room-a': { messages: [], hasMore: false, nextCursor: null },
      'room-b': { messages: [msg('b1', 'B history')], hasMore: false, nextCursor: null },
    });
    const h = mount(roots, transport, { id: 'room-a', seed: [msg('a1', 'A seed')] });

    h.setSession('room-b');
    expect(h.chat().sessionId).toBe('room-b');
    expect(h.chat().historyLoaded).toBe(false);
    expect(h.chat().isLoading).toBe(true);

    await act(async () => {
      release('room-b');
      await Promise.resolve();
    });
    expect(h.chat().historyLoaded).toBe(true);
    expect(h.chat().messages.map((m) => m.content)).toEqual(['B history']);
  });

  it('drops a slow page for a session the user already left (A→B→A)', async () => {
    const { transport, release } = gatedTransport({
      'room-a': { messages: [msg('a1', 'A history')], hasMore: false, nextCursor: null },
      'room-b': { messages: [msg('b1', 'B history')], hasMore: false, nextCursor: null },
    });
    const h = mount(roots, transport, { id: 'room-a', seed: [msg('a1', 'A seed')] });

    h.setSession('room-b'); // B's read is gated — still in flight
    h.setSession('room-a', [msg('a1', 'A cached')]); // back before B landed
    expect(h.chat().sessionId).toBe('room-a');

    // B's page finally arrives — it must NOT clobber room-a's transcript.
    await act(async () => {
      release('room-b');
      await Promise.resolve();
    });
    expect(h.chat().sessionId).toBe('room-a');
    expect(h.chat().messages.map((m) => m.content)).toEqual(['A cached']);

    await act(async () => {
      release('room-a');
      await Promise.resolve();
    });
    expect(h.chat().messages.map((m) => m.content)).toEqual(['A history']);
  });

  it('switching mid-stream aborts the old turn and leaves the new room clean', async () => {
    // A stream that emits one chunk, then hangs until aborted.
    let started!: () => void;
    const streamStarted = new Promise<void>((r) => {
      started = r;
    });
    const transport: ChatTransport = {
      async createSession(): Promise<SessionInfo> {
        return { sessionId: 'created' };
      },
      async loadHistory(sessionId: string): Promise<HistoryPage> {
        return sessionId === 'room-b'
          ? { messages: [msg('b1', 'B history')], hasMore: false, nextCursor: null }
          : { messages: [], hasMore: false, nextCursor: null };
      },
      async *stream(_s: string, _c: string, opts: { signal?: AbortSignal }) {
        yield { type: 'chunk' as const, delta: 'partial…' };
        started();
        // Hang until the switch aborts us.
        await new Promise<void>((resolve) => {
          opts.signal?.addEventListener('abort', () => resolve(), { once: true });
        });
        if (opts.signal?.aborted) return;
      },
      async send(): Promise<ChatMessage> {
        return msg('x', 'x', 'assistant');
      },
      async closeSession() {
        /* unused */
      },
    };

    const h = mount(roots, transport, { id: 'room-a', seed: [msg('a1', 'A seed')] });
    await act(async () => {
      await Promise.resolve();
    });

    let send!: Promise<void>;
    act(() => {
      send = h.chat().sendMessage('trigger');
    });
    await act(async () => {
      await streamStarted;
    });
    expect(h.chat().isStreaming).toBe(true);

    // Switch away while the assistant is mid-stream.
    h.setSession('room-b', [msg('b1', 'B cached')]);
    await act(async () => {
      await send;
      await Promise.resolve();
    });

    const c = h.chat();
    expect(c.sessionId).toBe('room-b');
    expect(c.isStreaming).toBe(false);
    // Nothing from room-a's aborted turn bleeds into room-b's feed.
    expect(c.messages.some((m) => m.content.includes('partial'))).toBe(false);
    expect(c.messages.some((m) => m.content === 'trigger')).toBe(false);
  });

  it('a send racing the switch waits for the page — the optimistic bubble survives', async () => {
    const { transport, release } = gatedTransport({
      'room-a': { messages: [], hasMore: false, nextCursor: null },
      'room-b': { messages: [msg('b1', 'B history')], hasMore: false, nextCursor: null },
    });
    const h = mount(roots, transport, { id: 'room-a', seed: [msg('a1', 'A seed')] });

    h.setSession('room-b');
    // Send while the switch's history read is still gated.
    let sendDone = false;
    let send!: Promise<void>;
    act(() => {
      send = h.chat().sendMessage('hi from the race').then(() => {
        sendDone = true;
      });
    });
    await act(async () => {
      await Promise.resolve();
    });
    // Blocked on the switch — no optimistic bubble yet, nothing to clobber.
    expect(sendDone).toBe(false);
    expect(h.chat().messages.some((m) => m.content === 'hi from the race')).toBe(false);

    await act(async () => {
      release('room-b');
      await send;
    });
    const contents = h.chat().messages.map((m) => m.content);
    // History first, then the user's message — nothing lost.
    expect(contents[0]).toBe('B history');
    expect(contents).toContain('hi from the race');
  });
});
