import {
	act,
	fireEvent,
	render,
	screen,
	waitFor,
} from "@testing-library/react";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "../api/client";
import type { Message } from "../types";
import { Chat } from "./Chat";

// Module-level store for the WebSocket handlers captured during connect().
type WsHandlers = {
	onToken: (content: string) => void;
	onUpdate: (history: Message[]) => void;
	onSessionId: (sessionId: string) => void;
	onError: (message: string) => void;
	onActivity: (toolName: string) => void;
	onReconciliationProgress: (
		storyId: string,
		status: string,
		message: string,
	) => void;
};
let capturedWsHandlers: WsHandlers | null = null;
// Captures the last sendChat call's arguments for assertion.
let lastSendChatArgs: { messages: Message[]; config: unknown } | null = null;

vi.mock("../api/client", () => {
	const api = {
		getOllamaModels: vi.fn(),
		getAnthropicApiKeyExists: vi.fn(),
		getAnthropicModels: vi.fn(),
		getModelPreference: vi.fn(),
		setModelPreference: vi.fn(),
		cancelChat: vi.fn(),
		setAnthropicApiKey: vi.fn(),
		readFile: vi.fn(),
		listProjectFiles: vi.fn(),
		botCommand: vi.fn(),
	};
	class ChatWebSocket {
		connect(handlers: WsHandlers) {
			capturedWsHandlers = handlers;
		}
		close() {}
		sendChat(messages: Message[], config: unknown) {
			lastSendChatArgs = { messages, config };
		}
		cancel() {}
	}
	return { api, ChatWebSocket };
});

const mockedApi = {
	getOllamaModels: vi.mocked(api.getOllamaModels),
	getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists),
	getAnthropicModels: vi.mocked(api.getAnthropicModels),
	getModelPreference: vi.mocked(api.getModelPreference),
	setModelPreference: vi.mocked(api.setModelPreference),
	cancelChat: vi.mocked(api.cancelChat),
	setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
	readFile: vi.mocked(api.readFile),
	listProjectFiles: vi.mocked(api.listProjectFiles),
	botCommand: vi.mocked(api.botCommand),
};

function setupMocks() {
	mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]);
	mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true);
	mockedApi.getAnthropicModels.mockResolvedValue([]);
	mockedApi.getModelPreference.mockResolvedValue(null);
	mockedApi.setModelPreference.mockResolvedValue(true);
	mockedApi.readFile.mockResolvedValue("");
	mockedApi.listProjectFiles.mockResolvedValue([]);
	mockedApi.cancelChat.mockResolvedValue(true);
	mockedApi.setAnthropicApiKey.mockResolvedValue(true);
	mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
}

describe("Remove bubble styling from streaming messages (Story 163)", () => {
	beforeEach(() => {
		capturedWsHandlers = null;
		setupMocks();
	});

	it("AC1: streaming assistant message uses transparent background, no extra padding, no border-radius", async () => {
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		// Send a message to put chat into loading state
		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "Hello" } });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		// Simulate streaming tokens arriving
		await act(async () => {
			capturedWsHandlers?.onToken("Streaming response text");
		});

		// Find the streaming message container (the inner div wrapping the Markdown)
		const streamingText = await screen.findByText("Streaming response text");
		// The markdown-body wrapper is the parent, and the styled div is its parent
		const styledDiv = streamingText.closest(".markdown-body")
			?.parentElement as HTMLElement;

		expect(styledDiv).toBeTruthy();
		const styleAttr = styledDiv.getAttribute("style") ?? "";
		expect(styleAttr).toContain("background: transparent");
		expect(styleAttr).toContain("padding: 0px");
		expect(styleAttr).toContain("border-radius: 0px");
		expect(styleAttr).toContain("max-width: 100%");
	});

	it("AC1: streaming message wraps Markdown in markdown-body class", async () => {
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "Hello" } });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		await act(async () => {
			capturedWsHandlers?.onToken("Some markdown content");
		});

		const streamingText = await screen.findByText("Some markdown content");
		const markdownBody = streamingText.closest(".markdown-body");
		expect(markdownBody).toBeTruthy();
	});

	it("AC2: no visual change when streaming ends and message transitions to completed", async () => {
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		// Send a message to start streaming
		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "Hello" } });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		// Simulate streaming tokens
		await act(async () => {
			capturedWsHandlers?.onToken("Final response");
		});

		// Capture streaming message style attribute
		const streamingText = await screen.findByText("Final response");
		const streamingStyledDiv = streamingText.closest(".markdown-body")
			?.parentElement as HTMLElement;
		const streamingStyleAttr = streamingStyledDiv.getAttribute("style") ?? "";

		// Transition: onUpdate completes the message
		await act(async () => {
			capturedWsHandlers?.onUpdate([
				{ role: "user", content: "Hello" },
				{ role: "assistant", content: "Final response" },
			]);
		});

		// Find the completed message — it should have the same styling
		const completedText = await screen.findByText("Final response");
		const completedMarkdownBody = completedText.closest(".markdown-body");
		const completedStyledDiv =
			completedMarkdownBody?.parentElement as HTMLElement;

		expect(completedStyledDiv).toBeTruthy();
		const completedStyleAttr = completedStyledDiv.getAttribute("style") ?? "";

		// Both streaming and completed use transparent bg, 0 padding, 0 border-radius
		expect(completedStyleAttr).toContain("background: transparent");
		expect(completedStyleAttr).toContain("padding: 0px");
		expect(completedStyleAttr).toContain("border-radius: 0px");
		expect(streamingStyleAttr).toContain("background: transparent");
		expect(streamingStyleAttr).toContain("padding: 0px");
		expect(streamingStyleAttr).toContain("border-radius: 0px");

		// Both have the markdown-body class wrapper
		expect(streamingStyledDiv.querySelector(".markdown-body")).toBeTruthy();
	});

	it("AC3: completed assistant messages retain transparent background and no border-radius", async () => {
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		await act(async () => {
			capturedWsHandlers?.onUpdate([
				{ role: "user", content: "Hi" },
				{ role: "assistant", content: "Hello there!" },
			]);
		});

		const assistantText = await screen.findByText("Hello there!");
		const markdownBody = assistantText.closest(".markdown-body");
		const styledDiv = markdownBody?.parentElement as HTMLElement;

		expect(styledDiv).toBeTruthy();
		const styleAttr = styledDiv.getAttribute("style") ?? "";
		expect(styleAttr).toContain("background: transparent");
		expect(styleAttr).toContain("padding: 0px");
		expect(styleAttr).toContain("border-radius: 0px");
		expect(styleAttr).toContain("max-width: 100%");
	});

	it("AC3: completed user messages still have their bubble styling", async () => {
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		await act(async () => {
			capturedWsHandlers?.onUpdate([
				{ role: "user", content: "I am a user message" },
				{ role: "assistant", content: "I am a response" },
			]);
		});

		// findByText finds the text element; traverse up to the styled bubble div
		const userText = await screen.findByText("I am a user message");
		// User messages are rendered via markdown, so text is inside a <p> inside .user-markdown-body
		// Walk up to find the styled bubble container
		const bubbleDiv = userText.closest("[style*='padding: 10px 16px']");
		expect(bubbleDiv).toBeTruthy();
		const styleAttr = bubbleDiv?.getAttribute("style") ?? "";
		// User messages retain bubble: distinct background, padding, rounded corners
		expect(styleAttr).toContain("padding: 10px 16px");
		expect(styleAttr).toContain("border-radius: 20px");
		expect(styleAttr).not.toContain("background: transparent");
	});
});

describe("Slash command handling (Story 374)", () => {
	beforeEach(() => {
		capturedWsHandlers = null;
		lastSendChatArgs = null;
		setupMocks();
	});

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

	it("AC: /status calls botCommand and displays response", async () => {
		mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" });
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "/status" } });
		});
		// First Enter selects the command from the picker; second Enter submits it
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		await waitFor(() => {
			expect(mockedApi.botCommand).toHaveBeenCalledWith(
				"status",
				"",
			);
		});
		expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument();
		// Should NOT go to LLM
		expect(lastSendChatArgs).toBeNull();
	});

	it("AC: /status <number> passes args to botCommand", async () => {
		mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" });
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "/status 42" } });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		await waitFor(() => {
			expect(mockedApi.botCommand).toHaveBeenCalledWith(
				"status",
				"42",
			);
		});
	});

	it("AC: /start <number> calls botCommand", async () => {
		mockedApi.botCommand.mockResolvedValue({ response: "Started agent" });
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "/start 42 opus" } });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		await waitFor(() => {
			expect(mockedApi.botCommand).toHaveBeenCalledWith(
				"start",
				"42 opus",
			);
		});
		expect(await screen.findByText("Started agent")).toBeInTheDocument();
	});

	it("AC: /git calls botCommand", async () => {
		mockedApi.botCommand.mockResolvedValue({ response: "On branch main" });
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "/git" } });
		});
		// First Enter selects the command from the picker; second Enter submits it
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		await waitFor(() => {
			expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "");
		});
	});

	it("AC: /cost calls botCommand", async () => {
		mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" });
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "/cost" } });
		});
		// First Enter selects the command from the picker; second Enter submits it
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		await waitFor(() => {
			expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "");
		});
	});

	it("AC: /reset clears messages and session without LLM", async () => {
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		// First add a message so there is history to clear
		await act(async () => {
			capturedWsHandlers?.onUpdate([
				{ role: "user", content: "hello" },
				{ role: "assistant", content: "world" },
			]);
		});
		expect(await screen.findByText("world")).toBeInTheDocument();

		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "/reset" } });
		});
		// First Enter selects the command from the picker; second Enter submits it
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		// LLM must NOT be invoked
		expect(lastSendChatArgs).toBeNull();
		// botCommand must NOT be invoked (reset is frontend-only)
		expect(mockedApi.botCommand).not.toHaveBeenCalled();
		// Confirmation message should appear
		expect(await screen.findByText(/Session reset/)).toBeInTheDocument();
	});

	it("AC: unrecognised slash command shows error message", async () => {
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "/foobar" } });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		expect(await screen.findByText(/Unknown command/)).toBeInTheDocument();
		// Should NOT go to LLM
		expect(lastSendChatArgs).toBeNull();
		// Should NOT call botCommand
		expect(mockedApi.botCommand).not.toHaveBeenCalled();
	});

	it("AC: /help calls botCommand and displays response", async () => {
		mockedApi.botCommand.mockResolvedValue({
			response: "Available commands: status, help, ...",
		});
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "/help" } });
		});
		// First Enter selects the command from the picker; second Enter submits it
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		await waitFor(() => {
			expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "");
		});
		expect(lastSendChatArgs).toBeNull();
	});

	it("AC: botCommand API error shows error message in chat", async () => {
		mockedApi.botCommand.mockRejectedValue(new Error("Server error"));
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		const input = screen.getByPlaceholderText("Send a message...");
		await act(async () => {
			fireEvent.change(input, { target: { value: "/git" } });
		});
		// First Enter selects the command from the picker; second Enter submits it
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});
		await act(async () => {
			fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
		});

		expect(
			await screen.findByText(/Error running command/),
		).toBeInTheDocument();
	});
});

describe("Story 1058: WebSocket errors do not appear in chat", () => {
	let consoleSpy: ReturnType<typeof vi.spyOn>;

	beforeEach(() => {
		capturedWsHandlers = null;
		setupMocks();
		consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
	});

	afterEach(() => {
		consoleSpy.mockRestore();
	});

	it("does not add a chat message when onError is called", async () => {
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		await act(async () => {
			capturedWsHandlers?.onError("Something went wrong on the server.");
		});

		expect(
			screen.queryByText("Something went wrong on the server."),
		).not.toBeInTheDocument();
	});

	it("does not add a chat message for errors containing a URL", async () => {
		render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);

		await waitFor(() => expect(capturedWsHandlers).not.toBeNull());

		await act(async () => {
			capturedWsHandlers?.onError(
				"OAuth login required. Please visit: https://example.com/oauth/login",
			);
		});

		expect(
			screen.queryByRole("link", {
				name: /https:\/\/example\.com\/oauth\/login/,
			}),
		).not.toBeInTheDocument();
	});
});
