/**
 * @vitest-environment jsdom
 */
import "fake-indexeddb/auto";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createStore, Provider } from "jotai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { apiClient } from "../api/client";
import { authLoadingAtom, type Deck, decksAtom } from "../atoms";
import { clearAtomFamilyCaches } from "../atoms/utils";
import { HomePage } from "./HomePage";

const mockDeckPut = vi.fn();
const mockDeckDelete = vi.fn();
const mockHandleResponse = vi.fn();

vi.mock("../api/client", () => ({
	apiClient: {
		login: vi.fn(),
		logout: vi.fn(),
		isAuthenticated: vi.fn(),
		getTokens: vi.fn(),
		getAuthHeader: vi.fn(),
		onSessionExpired: vi.fn(() => vi.fn()),
		rpc: {
			api: {
				decks: {
					$get: vi.fn(),
					$post: vi.fn(),
					":id": {
						$put: (args: unknown) => mockDeckPut(args),
						$delete: (args: unknown) => mockDeckDelete(args),
					},
				},
			},
		},
		handleResponse: (res: unknown) => mockHandleResponse(res),
	},
	ApiClientError: class ApiClientError extends Error {
		constructor(
			message: string,
			public status: number,
			public code?: string,
		) {
			super(message);
			this.name = "ApiClientError";
		}
	},
}));

// Mock fetch globally for Edit/Delete modals
const mockFetch = vi.fn();
global.fetch = mockFetch;

// Helper to create mock responses compatible with Hono's ClientResponse
function mockResponse(data: {
	ok: boolean;
	status?: number;
	// biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
	json: () => Promise<any>;
}) {
	return data as unknown as Awaited<
		ReturnType<typeof apiClient.rpc.api.decks.$get>
	>;
}

function mockPostResponse(data: {
	ok: boolean;
	status?: number;
	// biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
	json: () => Promise<any>;
}) {
	return data as unknown as Awaited<
		ReturnType<typeof apiClient.rpc.api.decks.$post>
	>;
}

const mockDecks = [
	{
		id: "deck-1",
		name: "Japanese Vocabulary",
		description: "Common Japanese words",
		newCardsPerDay: 20,
		createdAt: "2024-01-01T00:00:00Z",
		updatedAt: "2024-01-01T00:00:00Z",
	},
	{
		id: "deck-2",
		name: "Spanish Verbs",
		description: null,
		newCardsPerDay: 10,
		createdAt: "2024-01-02T00:00:00Z",
		updatedAt: "2024-01-02T00:00:00Z",
	},
];

function renderWithProviders({
	path = "/",
	initialDecks,
}: {
	path?: string;
	initialDecks?: Deck[];
} = {}) {
	const { hook } = memoryLocation({ path });
	const store = createStore();
	store.set(authLoadingAtom, false);

	// If initialDecks provided, hydrate the atom to skip Suspense
	if (initialDecks !== undefined) {
		store.set(decksAtom, initialDecks);
	}

	return render(
		<Provider store={store}>
			<Router hook={hook}>
				<HomePage />
			</Router>
		</Provider>,
	);
}

describe("HomePage", () => {
	beforeEach(() => {
		vi.clearAllMocks();
		clearAtomFamilyCaches();
		vi.mocked(apiClient.getTokens).mockReturnValue({
			accessToken: "access-token",
			refreshToken: "refresh-token",
		});
		vi.mocked(apiClient.isAuthenticated).mockReturnValue(true);
		vi.mocked(apiClient.getAuthHeader).mockReturnValue({
			Authorization: "Bearer access-token",
		});

		// handleResponse simulates actual behavior: throws on !ok, returns json() on ok
		mockHandleResponse.mockImplementation(async (res) => {
			if (!res.ok) {
				const body = await res.json().catch(() => ({}));
				throw new Error(
					body.error || `Request failed with status ${res.status}`,
				);
			}
			return res.json();
		});
	});

	afterEach(() => {
		cleanup();
		vi.restoreAllMocks();
		clearAtomFamilyCaches();
	});

	it("renders page title and logout button", () => {
		renderWithProviders({ initialDecks: [] });

		expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
		expect(screen.getByRole("button", { name: "Logout" })).toBeDefined();
	});

	it("shows loading state while fetching decks", async () => {
		vi.mocked(apiClient.rpc.api.decks.$get).mockImplementation(
			() => new Promise(() => {}), // Never resolves
		);

		renderWithProviders();

		// Loading state shows spinner (svg with animate-spin class)
		expect(document.querySelector(".animate-spin")).toBeDefined();
	});

	it("displays empty state when no decks exist", () => {
		renderWithProviders({ initialDecks: [] });

		expect(screen.getByText("No decks yet")).toBeDefined();
		expect(
			screen.getByText("Create your first deck to start learning"),
		).toBeDefined();
	});

	it("displays list of decks", () => {
		renderWithProviders({ initialDecks: mockDecks });

		expect(
			screen.getByRole("heading", { name: "Japanese Vocabulary" }),
		).toBeDefined();
		expect(
			screen.getByRole("heading", { name: "Spanish Verbs" }),
		).toBeDefined();
		expect(screen.getByText("Common Japanese words")).toBeDefined();
	});

	// Note: Error display tests are skipped because Jotai async atoms with
	// rejected Promises don't propagate errors to ErrorBoundary in the test
	// environment correctly. The actual error handling works in the browser.
	it.skip("displays error on API failure", async () => {
		vi.mocked(apiClient.rpc.api.decks.$get).mockRejectedValue(
			new Error("Internal server error"),
		);

		renderWithProviders();

		await waitFor(
			() => {
				expect(screen.getByRole("alert").textContent).toContain(
					"Internal server error",
				);
			},
			{ timeout: 3000 },
		);
	});

	it.skip("displays generic error on unexpected failure", async () => {
		vi.mocked(apiClient.rpc.api.decks.$get).mockRejectedValue(
			new Error("Network error"),
		);

		renderWithProviders();

		await waitFor(() => {
			expect(screen.getByRole("alert").textContent).toContain("Network error");
		});
	});

	it("calls logout when logout button is clicked", async () => {
		const user = userEvent.setup();
		renderWithProviders({ initialDecks: [] });

		await user.click(screen.getByRole("button", { name: "Logout" }));

		expect(apiClient.logout).toHaveBeenCalled();
	});

	it("does not show description if deck has none", () => {
		const deckWithoutDescription = {
			id: "deck-1",
			name: "No Description Deck",
			description: null,
			newCardsPerDay: 20,
			createdAt: "2024-01-01T00:00:00Z",
			updatedAt: "2024-01-01T00:00:00Z",
		};

		renderWithProviders({ initialDecks: [deckWithoutDescription] });

		expect(
			screen.getByRole("heading", { name: "No Description Deck" }),
		).toBeDefined();

		// The deck card should only contain the heading, no description paragraph
		const deckCard = screen
			.getByRole("heading", { name: "No Description Deck" })
			.closest("div[class*='bg-white']");
		expect(deckCard?.querySelectorAll("p").length).toBe(0);
	});

	it("passes auth header when fetching decks", async () => {
		vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
			mockResponse({
				ok: true,
				json: async () => ({ decks: [] }),
			}),
		);

		renderWithProviders();

		await waitFor(() => {
			expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledWith(undefined, {
				headers: { Authorization: "Bearer access-token" },
			});
		});
	});

	describe("Create Deck", () => {
		it("shows New Deck button", () => {
			renderWithProviders({ initialDecks: [] });

			expect(screen.getByText("No decks yet")).toBeDefined();
			expect(screen.getByRole("button", { name: /New Deck/i })).toBeDefined();
		});

		it("opens modal when New Deck button is clicked", async () => {
			const user = userEvent.setup();
			renderWithProviders({ initialDecks: [] });

			await user.click(screen.getByRole("button", { name: /New Deck/i }));

			expect(screen.getByRole("dialog")).toBeDefined();
			expect(
				screen.getByRole("heading", { name: "Create New Deck" }),
			).toBeDefined();
		});

		it("closes modal when Cancel is clicked", async () => {
			const user = userEvent.setup();
			renderWithProviders({ initialDecks: [] });

			await user.click(screen.getByRole("button", { name: /New Deck/i }));
			expect(screen.getByRole("dialog")).toBeDefined();

			await user.click(screen.getByRole("button", { name: "Cancel" }));

			expect(screen.queryByRole("dialog")).toBeNull();
		});

		it("creates deck and refreshes list", async () => {
			const user = userEvent.setup();
			const newDeck = {
				id: "deck-new",
				name: "New Deck",
				description: "A new deck",
				newCardsPerDay: 20,
				createdAt: "2024-01-03T00:00:00Z",
				updatedAt: "2024-01-03T00:00:00Z",
			};

			// After mutation, the list will refetch
			vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
				mockResponse({
					ok: true,
					json: async () => ({ decks: [newDeck] }),
				}),
			);

			vi.mocked(apiClient.rpc.api.decks.$post).mockResolvedValue(
				mockPostResponse({
					ok: true,
					json: async () => ({ deck: newDeck }),
				}),
			);

			// Start with empty decks (hydrated)
			renderWithProviders({ initialDecks: [] });

			// Open modal
			await user.click(screen.getByRole("button", { name: /New Deck/i }));

			// Fill in form
			await user.type(screen.getByLabelText("Name"), "New Deck");
			await user.type(
				screen.getByLabelText("Description (optional)"),
				"A new deck",
			);

			// Submit
			await user.click(screen.getByRole("button", { name: "Create Deck" }));

			// Modal should close
			await waitFor(() => {
				expect(screen.queryByRole("dialog")).toBeNull();
			});

			// Deck list should be refreshed with new deck
			await waitFor(() => {
				expect(screen.getByRole("heading", { name: "New Deck" })).toBeDefined();
			});
			expect(screen.getByText("A new deck")).toBeDefined();

			// API should have been called once (refresh after creation)
			expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledTimes(1);
		});
	});

	describe("Edit Deck", () => {
		it("shows Edit button for each deck", () => {
			renderWithProviders({ initialDecks: mockDecks });

			expect(
				screen.getByRole("heading", { name: "Japanese Vocabulary" }),
			).toBeDefined();

			const editButtons = screen.getAllByRole("button", { name: "Edit deck" });
			expect(editButtons.length).toBe(2);
		});

		it("opens edit modal when Edit button is clicked", async () => {
			const user = userEvent.setup();
			renderWithProviders({ initialDecks: mockDecks });

			const editButtons = screen.getAllByRole("button", { name: "Edit deck" });
			await user.click(editButtons.at(0) as HTMLElement);

			expect(screen.getByRole("dialog")).toBeDefined();
			expect(screen.getByRole("heading", { name: "Edit Deck" })).toBeDefined();
			expect(screen.getByLabelText("Name")).toHaveProperty(
				"value",
				"Japanese Vocabulary",
			);
		});

		it("closes edit modal when Cancel is clicked", async () => {
			const user = userEvent.setup();
			renderWithProviders({ initialDecks: mockDecks });

			const editButtons = screen.getAllByRole("button", { name: "Edit deck" });
			await user.click(editButtons.at(0) as HTMLElement);

			expect(screen.getByRole("dialog")).toBeDefined();

			await user.click(screen.getByRole("button", { name: "Cancel" }));

			expect(screen.queryByRole("dialog")).toBeNull();
		});

		it("edits deck and refreshes list", async () => {
			const user = userEvent.setup();
			const updatedDeck = {
				...mockDecks[0],
				name: "Updated Japanese",
			};

			// After mutation, the list will refetch
			vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
				mockResponse({
					ok: true,
					json: async () => ({ decks: [updatedDeck, mockDecks[1]] }),
				}),
			);

			mockDeckPut.mockResolvedValue({
				ok: true,
				json: async () => ({ deck: updatedDeck }),
			});

			// Start with initial decks (hydrated)
			renderWithProviders({ initialDecks: mockDecks });

			// Click Edit on first deck
			const editButtons = screen.getAllByRole("button", { name: "Edit deck" });
			await user.click(editButtons.at(0) as HTMLElement);

			// Update name
			const nameInput = screen.getByLabelText("Name");
			await user.clear(nameInput);
			await user.type(nameInput, "Updated Japanese");

			// Save
			await user.click(screen.getByRole("button", { name: "Save Changes" }));

			// Modal should close
			await waitFor(() => {
				expect(screen.queryByRole("dialog")).toBeNull();
			});

			// Deck list should be refreshed with updated name
			await waitFor(() => {
				expect(
					screen.getByRole("heading", { name: "Updated Japanese" }),
				).toBeDefined();
			});

			// API should have been called once (refresh after update)
			expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledTimes(1);
		});
	});

	describe("Delete Deck", () => {
		it("shows Delete button for each deck", () => {
			renderWithProviders({ initialDecks: mockDecks });

			expect(
				screen.getByRole("heading", { name: "Japanese Vocabulary" }),
			).toBeDefined();

			const deleteButtons = screen.getAllByRole("button", {
				name: "Delete deck",
			});
			expect(deleteButtons.length).toBe(2);
		});

		it("opens delete modal when Delete button is clicked", async () => {
			const user = userEvent.setup();
			renderWithProviders({ initialDecks: mockDecks });

			const deleteButtons = screen.getAllByRole("button", {
				name: "Delete deck",
			});
			await user.click(deleteButtons.at(0) as HTMLElement);

			expect(screen.getByRole("dialog")).toBeDefined();
			expect(
				screen.getByRole("heading", { name: "Delete Deck" }),
			).toBeDefined();
			// The deck name appears in both the list and the modal, so check specifically within the dialog
			const dialog = screen.getByRole("dialog");
			expect(dialog.textContent).toContain("Japanese Vocabulary");
		});

		it("closes delete modal when Cancel is clicked", async () => {
			const user = userEvent.setup();
			renderWithProviders({ initialDecks: mockDecks });

			const deleteButtons = screen.getAllByRole("button", {
				name: "Delete deck",
			});
			await user.click(deleteButtons.at(0) as HTMLElement);

			expect(screen.getByRole("dialog")).toBeDefined();

			await user.click(screen.getByRole("button", { name: "Cancel" }));

			expect(screen.queryByRole("dialog")).toBeNull();
		});

		it("deletes deck and refreshes list", async () => {
			const user = userEvent.setup();

			// After mutation, the list will refetch
			vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
				mockResponse({
					ok: true,
					json: async () => ({ decks: [mockDecks[1]] }),
				}),
			);

			mockDeckDelete.mockResolvedValue({
				ok: true,
				json: async () => ({ success: true }),
			});

			// Start with initial decks (hydrated)
			renderWithProviders({ initialDecks: mockDecks });

			// Click Delete on first deck
			const deleteButtons = screen.getAllByRole("button", {
				name: "Delete deck",
			});
			await user.click(deleteButtons.at(0) as HTMLElement);

			// Wait for modal to appear
			await waitFor(() => {
				expect(screen.getByRole("dialog")).toBeDefined();
			});

			// Confirm deletion - get the Delete button inside the dialog
			const dialog = screen.getByRole("dialog");
			const dialogButtons = dialog.querySelectorAll("button");
			const deleteButton = Array.from(dialogButtons).find(
				(btn) => btn.textContent === "Delete",
			);
			await user.click(deleteButton as HTMLElement);

			// Modal should close
			await waitFor(() => {
				expect(screen.queryByRole("dialog")).toBeNull();
			});

			// Deck list should be refreshed without deleted deck
			await waitFor(() => {
				expect(
					screen.queryByRole("heading", { name: "Japanese Vocabulary" }),
				).toBeNull();
			});
			expect(
				screen.getByRole("heading", { name: "Spanish Verbs" }),
			).toBeDefined();

			// API should have been called once (refresh after deletion)
			expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledTimes(1);
		});
	});
});
