// @vitest-environment jsdom
//
// Stream-end + send refocus contract (ChatProvider + composer registry).
//
// The full UX has three layers (see Composer.tsx handleSend / ComposerButton):
//   1. buttons never TAKE focus (mousedown preventDefault) — nothing to give back;
//   2. handleSend focuses the registered composer immediately at click;
//   3. ChatProvider's `useStreamEndFocus` refocuses on the streaming → idle edge.
// This file locks layer 3 — the engine-side edge — plus the focus-on-switch
// effect, through the SAME registry instance ChatProvider reads
// (`@djangocfg/ui-tools/composer-registry` — importing the registry by any
// other path would create a second module instance and test nothing).
import { act, createElement, useEffect } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

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

// jsdom has no matchMedia; ChatProvider's useChatLayout reads it via
// ui-core's useMediaQuery. A minimal always-false stub is enough.
if (!window.matchMedia) {
  window.matchMedia = ((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addEventListener: () => undefined,
    removeEventListener: () => undefined,
    addListener: () => undefined,
    removeListener: () => undefined,
    dispatchEvent: () => false,
  })) as typeof window.matchMedia;
}

import { registerComposer } from '@djangocfg/ui-tools/composer-registry';
import { ChatProvider, useChatContext } from '../ChatProvider';
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 stream stays open until the test releases it. */
function gatedStreamTransport(): { transport: ChatTransport; endStream: () => void } {
  let release!: () => void;
  const gate = new Promise<void>((r) => {
    release = r;
  });
  const transport: ChatTransport = {
    async createSession(): Promise<SessionInfo> {
      return { sessionId: 's1' };
    },
    async loadHistory(): Promise<HistoryPage> {
      return { messages: [], hasMore: false, nextCursor: null };
    },
    async *stream() {
      yield { type: 'chunk' as const, delta: 'hello ' };
      await gate;
      yield { type: 'chunk' as const, delta: 'world' };
    },
    async send(): Promise<ChatMessage> {
      return msg('a', 'a', 'assistant');
    },
    async closeSession() {
      /* unused */
    },
  };
  return { transport, endStream: release };
}

async function frame(): Promise<void> {
  await new Promise<void>((r) => requestAnimationFrame(() => r()));
}

describe('ChatProvider — refocus edges', () => {
  let roots: Root[] = [];
  beforeEach(() => {
    roots = [];
  });
  afterEach(() => {
    act(() => roots.forEach((r) => r.unmount()));
    registerComposer(null);
  });

  it('focuses the registered composer on the streaming → idle edge', async () => {
    const focus = vi.fn();
    registerComposer({ focus });

    const { transport, endStream } = gatedStreamTransport();
    let send!: (text: string) => Promise<void>;
    const Grab = () => {
      const chat = useChatContext();
      send = chat.sendMessage;
      return null;
    };
    const root = createRoot(document.createElement('div'));
    roots.push(root);
    await act(async () => {
      root.render(
        createElement(
          ChatProvider,
          { transport, initialSessionId: 's1' },
          createElement(Grab),
        ),
      );
    });

    let turn!: Promise<void>;
    act(() => {
      turn = send('hi');
    });
    await act(async () => {
      await Promise.resolve();
    });
    focus.mockClear(); // ignore any mount-path focus — this test is the EDGE

    await act(async () => {
      endStream();
      await turn;
    });
    await act(frame); // the stream-end refocus fires on the next frame

    expect(focus).toHaveBeenCalled();
  });

  it('autoFocusOnStreamEnd={false} leaves focus alone', async () => {
    const focus = vi.fn();
    registerComposer({ focus });

    const { transport, endStream } = gatedStreamTransport();
    let send!: (text: string) => Promise<void>;
    const Grab = () => {
      const chat = useChatContext();
      send = chat.sendMessage;
      return null;
    };
    const root = createRoot(document.createElement('div'));
    roots.push(root);
    await act(async () => {
      root.render(
        createElement(
          ChatProvider,
          { transport, initialSessionId: 's1', autoFocusOnStreamEnd: false, autoFocusOnMount: false },
          createElement(Grab),
        ),
      );
    });

    let turn!: Promise<void>;
    act(() => {
      turn = send('hi');
    });
    focus.mockClear();
    await act(async () => {
      endStream();
      await turn;
    });
    await act(frame);

    expect(focus).not.toHaveBeenCalled();
  });

  it('focuses the composer after an in-place session switch', async () => {
    const focus = vi.fn();
    registerComposer({ focus });

    const { transport } = gatedStreamTransport();
    const Host = ({ sid }: { sid: string }) =>
      createElement(ChatProvider, { transport, initialSessionId: sid });
    const root = createRoot(document.createElement('div'));
    roots.push(root);
    await act(async () => {
      root.render(createElement(Host, { sid: 'room-a' }));
    });
    focus.mockClear();

    await act(async () => {
      root.render(createElement(Host, { sid: 'room-b' }));
    });
    await act(frame);

    expect(focus).toHaveBeenCalled();
  });
});

// Silence unused-import lint for useEffect (kept for parity with sibling tests).
void useEffect;
