import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { UseChatResult } from "@/hooks/use-chat";
import type { Conversation } from "@/lib/chat/types";

const useChatMock = vi.hoisted(() => vi.fn());
const useModelsMock = vi.hoisted(() => vi.fn());

vi.mock("@/hooks/use-chat", () => ({
  useChat: useChatMock,
}));
vi.mock("@/hooks/use-models", () => ({
  useModels: useModelsMock,
}));

import { ChatApp } from "@/components/chat/chat-app";

const identity = {
  id: "00000000-0000-4000-8000-000000000042",
  externalUserId: "42",
  login: "alice",
  fullName: "Alice Smith",
  position: "Engineer",
  email: "alice@example.com",
  firstName: "Alice",
  lastName: "Smith",
  middleName: "Jane",
  roles: ["admin", "employee"],
  lastModelId: "chatgpt" as const,
};

const conversations: Conversation[] = [
  {
    id: "conversation-1",
    title: "План запуска",
    modelId: "chatgpt",
    updatedAt: "2026-07-26T10:00:00.000Z",
    messages: [
      {
        id: "message-1",
        role: "user",
        content: "Составь план",
        createdAt: "2026-07-26T09:59:00.000Z",
        status: "complete",
      },
      {
        id: "message-2",
        role: "assistant",
        content: "Шаг 1\nШаг 2",
        createdAt: "2026-07-26T10:00:00.000Z",
        status: "complete",
      },
    ],
  },
  {
    id: "conversation-2",
    title: "Анализ отчёта",
    modelId: "deepseek",
    updatedAt: "2026-07-25T10:00:00.000Z",
    messages: [],
  },
];

function chatResult(overrides: Partial<UseChatResult> = {}): UseChatResult {
  return {
    conversations: [],
    activeConversation: null,
    activeModelId: "chatgpt",
    isGenerating: false,
    canRetryLast: false,
    quota: {
      status: "ready",
      snapshot: {
        object: "aegida.quota",
        model: "chatgpt",
        availableTokens: 42_000,
        unlimited: false,
        exhausted: false,
        resetsAt: null,
        asOf: "2026-08-04T12:00:00Z",
      },
    },
    selectConversation: vi.fn(),
    newConversation: vi.fn(),
    deleteConversation: vi.fn(),
    setModel: vi.fn(),
    sendMessage: vi.fn().mockResolvedValue(undefined),
    stopGenerating: vi.fn(),
    retryLast: vi.fn().mockResolvedValue(undefined),
    ...overrides,
  };
}

describe("ChatApp", () => {
  beforeEach(() => {
    useChatMock.mockReturnValue(chatResult());
    useModelsMock.mockReturnValue({
      status: "ready",
      effectiveModelId: "chatgpt",
      models: [
        { id: "auto", name: "Aegida Auto", description: "Auto" },
        { id: "chatgpt", name: "ChatGPT", description: "ChatGPT" },
        { id: "deepseek", name: "DeepSeek", description: "DeepSeek" },
        { id: "qwen", name: "Qwen", description: "Qwen" },
      ],
    });
    Object.defineProperty(navigator, "clipboard", {
      configurable: true,
      value: { writeText: vi.fn().mockResolvedValue(undefined) },
    });
  });

  afterEach(() => {
    cleanup();
    vi.clearAllMocks();
    vi.unstubAllGlobals();
  });

  it("renders the new-chat empty state with useful prompt suggestions", () => {
    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    expect(
      screen.getByRole("heading", { name: "Чем я могу помочь?" }),
    ).toBeInTheDocument();
    expect(screen.getByText("Здравствуйте, Alice!")).toBeInTheDocument();
    expect(screen.getByText("Alice Smith")).toBeInTheDocument();
    expect(screen.getByText("Engineer")).toBeInTheDocument();
    expect(screen.getByText("admin, employee")).toBeInTheDocument();
    expect(screen.getAllByRole("button", { name: /план|резюме|идеи/i })).toHaveLength(
      3,
    );
  });

  it("selects a model for later messages", async () => {
    const user = userEvent.setup();
    const result = chatResult();
    useChatMock.mockReturnValue(result);
    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    await user.click(screen.getByRole("button", { name: "Выбрать модель" }));
    await user.click(screen.getByRole("menuitem", { name: /Qwen/ }));

    expect(result.setModel).toHaveBeenCalledWith("qwen");
  });

  it("formats finite quota and omits a nullable reset", () => {
    render(<ChatApp user={identity} onLogout={vi.fn()} />);

    expect(screen.getByText(/42.000 токенов/)).toBeInTheDocument();
    expect(screen.queryByText(/Сброс:/)).not.toBeInTheDocument();
  });

  it("shows unlimited quota", () => {
    useChatMock.mockReturnValue(chatResult({
      quota: {
        status: "ready",
        snapshot: {
          object: "aegida.quota",
          model: "auto",
          availableTokens: null,
          unlimited: true,
          exhausted: false,
          resetsAt: null,
          asOf: "2026-08-04T12:00:00Z",
        },
      },
    }));

    render(<ChatApp user={identity} onLogout={vi.fn()} />);

    expect(screen.getByText("Без лимита")).toBeInTheDocument();
  });

  it("keeps send enabled when quota is unavailable", async () => {
    const user = userEvent.setup();
    const result = chatResult({ quota: { status: "unavailable" } });
    useChatMock.mockReturnValue(result);
    render(<ChatApp user={identity} onLogout={vi.fn()} />);

    expect(screen.getByText("Баланс недоступен")).toBeInTheDocument();
    await user.type(screen.getByLabelText("Сообщение"), "Продолжить");
    const send = screen.getByRole("button", { name: "Отправить сообщение" });
    expect(send).toBeEnabled();
    await user.click(send);
    expect(result.sendMessage).toHaveBeenCalledWith("Продолжить");
  });

  it("keeps send enabled while the selected-model balance is loading", async () => {
    const user = userEvent.setup();
    const result = chatResult({ quota: { status: "loading" } });
    useChatMock.mockReturnValue(result);
    render(<ChatApp user={identity} onLogout={vi.fn()} />);

    expect(screen.getByText("Баланс…")).toBeInTheDocument();
    await user.type(screen.getByLabelText("Сообщение"), "Продолжить");
    const send = screen.getByRole("button", { name: "Отправить сообщение" });
    expect(send).toBeEnabled();
    await user.click(send);
    expect(result.sendMessage).toHaveBeenCalledWith("Продолжить");
  });

  it("disables send only for an explicitly exhausted snapshot", async () => {
    const user = userEvent.setup();
    useChatMock.mockReturnValue(chatResult({
      quota: {
        status: "ready",
        snapshot: {
          object: "aegida.quota",
          model: "chatgpt",
          availableTokens: 0,
          unlimited: false,
          exhausted: true,
          resetsAt: null,
          asOf: "2026-08-04T12:00:00Z",
        },
      },
    }));
    render(<ChatApp user={identity} onLogout={vi.fn()} />);

    await user.type(screen.getByLabelText("Сообщение"), "Нельзя отправить");

    expect(screen.getByRole("button", { name: "Отправить сообщение" })).toBeDisabled();
    expect(screen.getByText("Лимит токенов исчерпан. Отправка недоступна.")).toBeInTheDocument();
  });

  it("renders only models supplied by the dynamic catalog", async () => {
    const user = userEvent.setup();
    const result = chatResult({ activeModelId: "auto" });
    useChatMock.mockReturnValue(result);
    useModelsMock.mockReturnValue({
      status: "ready",
      effectiveModelId: "auto",
      models: [
        { id: "auto", name: "Aegida Auto", description: "Auto" },
        { id: "claude-sonnet.4", name: "Claude Sonnet 4", description: "Claude" },
      ],
    });
    render(<ChatApp user={identity} onLogout={vi.fn()} />);

    await user.click(screen.getByRole("button", { name: "Выбрать модель" }));
    expect(screen.queryByRole("menuitem", { name: /Qwen/ })).not.toBeInTheDocument();
    await user.click(screen.getByRole("menuitem", { name: /Claude Sonnet 4/ }));

    expect(result.setModel).toHaveBeenCalledWith("claude-sonnet.4");
  });

  it("selects auto after a persisted model disappears from the catalog", async () => {
    const result = chatResult({ activeModelId: "removed-model" });
    useChatMock.mockReturnValue(result);
    useModelsMock.mockReturnValue({
      status: "ready",
      effectiveModelId: "auto",
      models: [{ id: "auto", name: "Aegida Auto", description: "Auto" }],
    });

    render(<ChatApp user={{ ...identity, lastModelId: "removed-model" }} onLogout={vi.fn()} />);

    await act(async () => undefined);
    expect(result.setModel).toHaveBeenCalledWith("auto");
  });

  it("dismisses the model menu with Escape and an outside click", async () => {
    const user = userEvent.setup();
    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    const trigger = screen.getByRole("button", { name: "Выбрать модель" });
    await user.click(trigger);
    await user.keyboard("{Escape}");
    expect(screen.queryByRole("menu", { name: "Доступные модели" })).not.toBeInTheDocument();
    expect(trigger).toHaveFocus();

    await user.click(trigger);
    await user.click(screen.getByRole("heading", { name: "Чем я могу помочь?" }));
    expect(screen.queryByRole("menu", { name: "Доступные модели" })).not.toBeInTheDocument();
  });

  it("sends with Enter, preserves Shift+Enter, and disables an empty send", async () => {
    const user = userEvent.setup();
    const result = chatResult();
    useChatMock.mockReturnValue(result);
    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    const textarea = screen.getByLabelText("Сообщение");
    expect(screen.getByRole("button", { name: "Отправить сообщение" })).toBeDisabled();
    await user.type(textarea, "Первая строка{shift>}{enter}{/shift}Вторая строка");
    expect(textarea).toHaveValue("Первая строка\nВторая строка");
    await user.type(textarea, "{enter}");

    expect(result.sendMessage).toHaveBeenCalledWith(
      "Первая строка\nВторая строка",
    );
    expect(textarea).toHaveValue("");
  });

  it("resets the composer height after sending", async () => {
    const user = userEvent.setup();
    useChatMock.mockReturnValue(chatResult());
    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    const textarea = screen.getByLabelText("Сообщение");
    Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 120 });
    fireEvent.change(textarea, { target: { value: "Длинное сообщение" } });
    expect(textarea).toHaveStyle({ height: "120px" });

    await user.click(screen.getByRole("button", { name: "Отправить сообщение" }));
    expect(textarea).toHaveStyle({ height: "auto" });
  });

  it("passes complete uploaded attachment metadata to the optimistic chat turn", async () => {
    const user = userEvent.setup();
    const result = chatResult();
    useChatMock.mockReturnValue(result);
    const attachment = {
      id: "00000000-0000-4000-8000-000000000077",
      name: "brief.txt",
      contentType: "text/plain",
      size: 5,
    };
    vi.stubGlobal("localStorage", {
      getItem: vi.fn().mockReturnValue("test-token"),
      setItem: vi.fn(),
      removeItem: vi.fn(),
      clear: vi.fn(),
      key: vi.fn(),
      length: 1,
    });
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue(
        Response.json({ attachment }, { status: 201 }),
      ),
    );
    render(<ChatApp user={identity} onLogout={vi.fn()} />);

    await user.upload(
      screen.getByLabelText("Прикрепить файлы", { selector: "input" }),
      new File(["brief"], "brief.txt", { type: "text/plain" }),
    );
    await screen.findByText("brief.txt");
    await user.type(screen.getByLabelText("Сообщение"), "Смотри");
    await user.click(screen.getByRole("button", { name: "Отправить сообщение" }));

    expect(result.sendMessage).toHaveBeenCalledWith("Смотри", [attachment]);
  });

  it("keeps an over-limit draft and does not submit it", async () => {
    const user = userEvent.setup();
    const result = chatResult();
    useChatMock.mockReturnValue(result);
    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    const textarea = screen.getByLabelText("Сообщение");
    const content = "x".repeat(32_001);
    await user.click(textarea);
    await user.paste(content);

    expect(textarea).toHaveValue(content);
    expect(screen.getByRole("alert")).toHaveTextContent("32 000");
    expect(screen.getByRole("button", { name: "Отправить сообщение" })).toBeDisabled();
    await user.type(textarea, "{enter}");
    expect(result.sendMessage).not.toHaveBeenCalled();
    expect(textarea).toHaveValue(content);
  });

  it("shows a stop action while text is streaming", async () => {
    const user = userEvent.setup();
    const result = chatResult({ isGenerating: true });
    useChatMock.mockReturnValue(result);
    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    await user.click(
      screen.getByRole("button", { name: "Остановить генерацию" }),
    );

    expect(result.stopGenerating).toHaveBeenCalledOnce();
  });

  it("copies assistant text and retries an errored response", async () => {
    const user = userEvent.setup();
    const writeText = vi.spyOn(navigator.clipboard, "writeText");
    const errored = {
      ...conversations[0],
      messages: [
        ...conversations[0].messages.slice(0, 1),
        {
          ...conversations[0].messages[1],
          content: "Не удалось получить ответ",
          status: "error" as const,
        },
      ],
    };
    const result = chatResult({
      conversations: [errored],
      activeConversation: errored,
      canRetryLast: true,
    });
    useChatMock.mockReturnValue(result);
    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    await user.click(screen.getByRole("button", { name: "Копировать ответ" }));
    await user.click(screen.getByRole("button", { name: "Повторить запрос" }));

    expect(writeText).toHaveBeenCalledWith("Не удалось получить ответ");
    expect(result.retryLast).toHaveBeenCalledOnce();
  });

  it("does not offer retry on a historical error that cannot be retried", () => {
    const conversation = {
      ...conversations[0],
      messages: [
        {
          ...conversations[0].messages[1],
          id: "historical-error",
          status: "error" as const,
        },
        ...conversations[0].messages,
      ],
    };
    useChatMock.mockReturnValue(
      chatResult({ conversations: [conversation], activeConversation: conversation }),
    );

    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    expect(
      screen.queryByRole("button", { name: "Повторить запрос" }),
    ).not.toBeInTheDocument();
  });

  it("offers retry when the final stopped response has no content", async () => {
    const user = userEvent.setup();
    const stopped = {
      ...conversations[0],
      messages: [
        conversations[0].messages[0],
        {
          ...conversations[0].messages[1],
          content: "",
          status: "stopped" as const,
        },
      ],
    };
    const result = chatResult({
      conversations: [stopped],
      activeConversation: stopped,
      canRetryLast: true,
    });
    useChatMock.mockReturnValue(result);

    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    await user.click(screen.getByRole("button", { name: "Повторить запрос" }));

    expect(result.retryLast).toHaveBeenCalledOnce();
    expect(
      screen.queryByRole("button", { name: "Копировать ответ" }),
    ).not.toBeInTheDocument();
  });

  it("selects and deletes conversations from the sidebar", async () => {
    const user = userEvent.setup();
    const result = chatResult({
      conversations,
      activeConversation: conversations[0],
    });
    useChatMock.mockReturnValue(result);
    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    const sidebar = screen.getByRole("navigation", { name: "История чатов" });
    await user.click(within(sidebar).getByRole("button", { name: "Анализ отчёта" }));
    await user.click(
      within(sidebar).getByRole("button", { name: "Удалить чат План запуска" }),
    );

    expect(result.selectConversation).toHaveBeenCalledWith("conversation-2");
    expect(result.deleteConversation).toHaveBeenCalledWith("conversation-1");
  });

  it("opens and closes the mobile history drawer", async () => {
    const user = userEvent.setup();
    useChatMock.mockReturnValue(chatResult({ conversations }));
    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    const opener = screen.getByRole("button", { name: "Открыть меню" });
    await user.click(opener);
    const drawer = screen.getByRole("dialog", { name: "История чатов" });
    expect(drawer).toBeInTheDocument();
    expect(within(drawer).getByRole("button", { name: "Закрыть меню" })).toHaveFocus();
    await user.keyboard("{Escape}");
    expect(screen.queryByRole("dialog", { name: "История чатов" })).not.toBeInTheDocument();
    expect(opener).toHaveFocus();

    await user.click(opener);
    const reopenedDrawer = screen.getByRole("dialog", { name: "История чатов" });
    await user.click(
      within(reopenedDrawer).getByRole("button", { name: "Закрыть меню" }),
    );
    expect(screen.queryByRole("dialog", { name: "История чатов" })).not.toBeInTheDocument();
  });

  it("wraps Tab focus within the inert mobile drawer", async () => {
    const user = userEvent.setup();
    const { container } = render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    await user.click(screen.getByRole("button", { name: "Открыть меню" }));
    const drawer = screen.getByRole("dialog", { name: "История чатов" });
    const close = within(drawer).getByRole("button", { name: "Закрыть меню" });
    const logout = within(drawer).getByRole("button", { name: "Выйти" });

    expect(container.querySelectorAll("[inert]")).toHaveLength(2);
    expect(close).toHaveFocus();
    await user.tab({ shift: true });
    expect(logout).toHaveFocus();
    await user.tab();
    expect(close).toHaveFocus();
  });

  it("keeps the latest streamed message in view", () => {
    const scrollIntoView = vi.fn();
    Element.prototype.scrollIntoView = scrollIntoView;
    useChatMock.mockReturnValue(
      chatResult({ conversations, activeConversation: conversations[0], isGenerating: true }),
    );

    render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    expect(scrollIntoView).toHaveBeenCalledWith({ behavior: "smooth", block: "end" });
  });

  it("removes mobile modal state when the viewport becomes desktop", async () => {
    const user = userEvent.setup();
    let onBreakpointChange: ((event: MediaQueryListEvent) => void) | undefined;
    vi.stubGlobal(
      "matchMedia",
      vi.fn(() => ({
        matches: false,
        media: "(min-width: 768px)",
        onchange: null,
        addEventListener: (_type: string, listener: (event: MediaQueryListEvent) => void) => {
          onBreakpointChange = listener;
        },
        removeEventListener: vi.fn(),
        addListener: vi.fn(),
        removeListener: vi.fn(),
        dispatchEvent: vi.fn(),
      })),
    );
    const { container } = render(
      <ChatApp
        user={identity}
        onLogout={vi.fn()}
      />,
    );

    await user.click(screen.getByRole("button", { name: "Открыть меню" }));
    expect(screen.getByRole("dialog", { name: "История чатов" })).toBeInTheDocument();
    expect(container.querySelectorAll("[inert]")).toHaveLength(2);

    act(() => onBreakpointChange?.({ matches: true } as MediaQueryListEvent));

    expect(screen.queryByRole("dialog", { name: "История чатов" })).not.toBeInTheDocument();
    expect(container.querySelectorAll("[inert]")).toHaveLength(0);
  });

  it("logs out from the authenticated sidebar", async () => {
    const user = userEvent.setup();
    const onLogout = vi.fn();
    render(
      <ChatApp
        user={identity}
        onLogout={onLogout}
      />,
    );

    await user.click(screen.getByRole("button", { name: "Выйти" }));

    expect(onLogout).toHaveBeenCalledOnce();
  });
});
