// Unit tests for the typed lobby.ts wrappers. They invoke the
// wrappers against a minimal stub of `GalaxyClient.executeCommand`
// that captures the message type and FlatBuffers request payload,
// then returns a forged FlatBuffers response payload built with the
// generated TS bindings. No network, no signing — the test confirms
// the encoder/decoder shape matches the gateway contract and that
// non-`ok` result codes are surfaced as a `LobbyError`.

import { Builder, ByteBuffer } from "flatbuffers";
import { describe, expect, test, vi } from "vitest";

import {
	LobbyError,
	createGame,
	declineInvite,
	listMyApplications,
	listMyGames,
	listMyInvites,
	listPublicGames,
	redeemInvite,
	submitApplication,
} from "../src/api/lobby";
import {
	ApplicationSubmitResponse,
	ApplicationSummary,
	ErrorBody,
	ErrorResponse,
	GameCreateResponse,
	GameSummary,
	InviteDeclineResponse,
	InviteRedeemResponse,
	InviteSummary,
	MyApplicationsListResponse,
	MyGamesListResponse,
	MyInvitesListResponse,
	PublicGamesListResponse,
} from "../src/proto/galaxy/fbs/lobby";
import type { GalaxyClient } from "../src/api/galaxy-client";
import {
	GameCreateRequest,
	PublicGamesListRequest,
	ApplicationSubmitRequest,
	InviteRedeemRequest,
	InviteDeclineRequest,
} from "../src/proto/galaxy/fbs/lobby";

interface Captured {
	messageType: string;
	payload: Uint8Array;
}

function makeStub(
	respondWith: (c: Captured) => { resultCode?: string; payloadBytes: Uint8Array },
): {
	client: GalaxyClient;
	captured: Captured[];
} {
	const captured: Captured[] = [];
	const stub = {
		executeCommand: vi.fn(async (messageType: string, payload: Uint8Array) => {
			const c = { messageType, payload };
			captured.push(c);
			const result = respondWith(c);
			return {
				resultCode: result.resultCode ?? "ok",
				payloadBytes: result.payloadBytes,
			};
		}),
	} as unknown as GalaxyClient;
	return { client: stub, captured };
}

function encodeGameSummary(builder: Builder, currentTurn: number = 0): number {
	const gameId = builder.createString("g-1");
	const gameName = builder.createString("Test Game");
	const gameType = builder.createString("private");
	const status = builder.createString("draft");
	const ownerUserId = builder.createString("user-1");
	GameSummary.startGameSummary(builder);
	GameSummary.addGameId(builder, gameId);
	GameSummary.addGameName(builder, gameName);
	GameSummary.addGameType(builder, gameType);
	GameSummary.addStatus(builder, status);
	GameSummary.addOwnerUserId(builder, ownerUserId);
	GameSummary.addMinPlayers(builder, 2);
	GameSummary.addMaxPlayers(builder, 8);
	GameSummary.addEnrollmentEndsAtMs(builder, 1_780_000_000_000n);
	GameSummary.addCreatedAtMs(builder, 1_770_000_000_000n);
	GameSummary.addUpdatedAtMs(builder, 1_770_000_000_000n);
	GameSummary.addCurrentTurn(builder, currentTurn);
	return GameSummary.endGameSummary(builder);
}

function encodeApplicationSummary(builder: Builder, status: string): number {
	const applicationId = builder.createString("app-1");
	const gameId = builder.createString("g-1");
	const applicantUserId = builder.createString("user-1");
	const raceName = builder.createString("Vegan Federation");
	const statusOff = builder.createString(status);
	ApplicationSummary.startApplicationSummary(builder);
	ApplicationSummary.addApplicationId(builder, applicationId);
	ApplicationSummary.addGameId(builder, gameId);
	ApplicationSummary.addApplicantUserId(builder, applicantUserId);
	ApplicationSummary.addRaceName(builder, raceName);
	ApplicationSummary.addStatus(builder, statusOff);
	ApplicationSummary.addCreatedAtMs(builder, 1_770_000_000_000n);
	ApplicationSummary.addDecidedAtMs(builder, status === "pending" ? 0n : 1_770_010_000_000n);
	return ApplicationSummary.endApplicationSummary(builder);
}

function encodeInviteSummary(builder: Builder, status: string): number {
	const inviteId = builder.createString("invite-1");
	const gameId = builder.createString("g-1");
	const inviter = builder.createString("user-host");
	const invited = builder.createString("user-1");
	const code = builder.createString("");
	const race = builder.createString("Vegan Federation");
	const statusOff = builder.createString(status);
	InviteSummary.startInviteSummary(builder);
	InviteSummary.addInviteId(builder, inviteId);
	InviteSummary.addGameId(builder, gameId);
	InviteSummary.addInviterUserId(builder, inviter);
	InviteSummary.addInvitedUserId(builder, invited);
	InviteSummary.addCode(builder, code);
	InviteSummary.addRaceName(builder, race);
	InviteSummary.addStatus(builder, statusOff);
	InviteSummary.addCreatedAtMs(builder, 1_770_000_000_000n);
	InviteSummary.addExpiresAtMs(builder, 1_780_000_000_000n);
	InviteSummary.addDecidedAtMs(builder, status === "pending" ? 0n : 1_770_010_000_000n);
	return InviteSummary.endInviteSummary(builder);
}

describe("lobby.ts wrappers", () => {
	test("listMyGames decodes the response and reports the message type", async () => {
		const { client, captured } = makeStub(() => {
			const builder = new Builder(256);
			const item = encodeGameSummary(builder, 5);
			const items = MyGamesListResponse.createItemsVector(builder, [item]);
			MyGamesListResponse.startMyGamesListResponse(builder);
			MyGamesListResponse.addItems(builder, items);
			builder.finish(MyGamesListResponse.endMyGamesListResponse(builder));
			return { payloadBytes: builder.asUint8Array() };
		});

		const games = await listMyGames(client);
		expect(captured[0]!.messageType).toBe("lobby.my.games.list");
		expect(games.length).toBe(1);
		expect(games[0]!.gameId).toBe("g-1");
		expect(games[0]!.minPlayers).toBe(2);
		expect(games[0]!.currentTurn).toBe(5);
	});

	test("listPublicGames passes pagination and decodes pageSize/total", async () => {
		const { client, captured } = makeStub(() => {
			const builder = new Builder(256);
			const item = encodeGameSummary(builder);
			const items = PublicGamesListResponse.createItemsVector(builder, [item]);
			PublicGamesListResponse.startPublicGamesListResponse(builder);
			PublicGamesListResponse.addItems(builder, items);
			PublicGamesListResponse.addPage(builder, 2);
			PublicGamesListResponse.addPageSize(builder, 25);
			PublicGamesListResponse.addTotal(builder, 51);
			builder.finish(PublicGamesListResponse.endPublicGamesListResponse(builder));
			return { payloadBytes: builder.asUint8Array() };
		});

		const page = await listPublicGames(client, { page: 2, pageSize: 25 });
		expect(captured[0]!.messageType).toBe("lobby.public.games.list");
		const decodedRequest = PublicGamesListRequest.getRootAsPublicGamesListRequest(
			new ByteBuffer(captured[0]!.payload),
		);
		expect(decodedRequest.page()).toBe(2);
		expect(decodedRequest.pageSize()).toBe(25);

		expect(page.items.length).toBe(1);
		expect(page.page).toBe(2);
		expect(page.pageSize).toBe(25);
		expect(page.total).toBe(51);
	});

	test("listMyApplications decodes pending and decided records", async () => {
		const { client } = makeStub(() => {
			const builder = new Builder(256);
			const pending = encodeApplicationSummary(builder, "pending");
			const approved = encodeApplicationSummary(builder, "approved");
			const items = MyApplicationsListResponse.createItemsVector(builder, [pending, approved]);
			MyApplicationsListResponse.startMyApplicationsListResponse(builder);
			MyApplicationsListResponse.addItems(builder, items);
			builder.finish(MyApplicationsListResponse.endMyApplicationsListResponse(builder));
			return { payloadBytes: builder.asUint8Array() };
		});

		const applications = await listMyApplications(client);
		expect(applications.length).toBe(2);
		expect(applications[0]!.status).toBe("pending");
		expect(applications[0]!.decidedAt).toBeNull();
		expect(applications[1]!.status).toBe("approved");
		expect(applications[1]!.decidedAt).not.toBeNull();
	});

	test("listMyInvites decodes user-bound invites", async () => {
		const { client } = makeStub(() => {
			const builder = new Builder(256);
			const invite = encodeInviteSummary(builder, "pending");
			const items = MyInvitesListResponse.createItemsVector(builder, [invite]);
			MyInvitesListResponse.startMyInvitesListResponse(builder);
			MyInvitesListResponse.addItems(builder, items);
			builder.finish(MyInvitesListResponse.endMyInvitesListResponse(builder));
			return { payloadBytes: builder.asUint8Array() };
		});

		const invites = await listMyInvites(client);
		expect(invites.length).toBe(1);
		expect(invites[0]!.invitedUserId).toBe("user-1");
		expect(invites[0]!.status).toBe("pending");
		expect(invites[0]!.decidedAt).toBeNull();
	});

	test("createGame encodes every field and decodes the returned summary", async () => {
		const { client, captured } = makeStub(() => {
			const builder = new Builder(256);
			const game = encodeGameSummary(builder);
			GameCreateResponse.startGameCreateResponse(builder);
			GameCreateResponse.addGame(builder, game);
			builder.finish(GameCreateResponse.endGameCreateResponse(builder));
			return { payloadBytes: builder.asUint8Array() };
		});

		const enrollment = new Date(1_780_000_000_000);
		const result = await createGame(client, {
			gameName: "First Contact",
			description: "",
			minPlayers: 2,
			maxPlayers: 8,
			startGapHours: 24,
			startGapPlayers: 2,
			enrollmentEndsAt: enrollment,
			turnSchedule: "0 0 * * *",
			targetEngineVersion: "v1",
		});

		expect(captured[0]!.messageType).toBe("lobby.game.create");
		const request = GameCreateRequest.getRootAsGameCreateRequest(
			new ByteBuffer(captured[0]!.payload),
		);
		expect(request.gameName()).toBe("First Contact");
		expect(request.turnSchedule()).toBe("0 0 * * *");
		expect(request.targetEngineVersion()).toBe("v1");
		expect(request.minPlayers()).toBe(2);
		expect(request.maxPlayers()).toBe(8);
		expect(request.enrollmentEndsAtMs()).toBe(BigInt(enrollment.getTime()));

		expect(result.gameId).toBe("g-1");
	});

	test("submitApplication encodes game_id and race_name", async () => {
		const { client, captured } = makeStub(() => {
			const builder = new Builder(128);
			const app = encodeApplicationSummary(builder, "pending");
			ApplicationSubmitResponse.startApplicationSubmitResponse(builder);
			ApplicationSubmitResponse.addApplication(builder, app);
			builder.finish(ApplicationSubmitResponse.endApplicationSubmitResponse(builder));
			return { payloadBytes: builder.asUint8Array() };
		});

		const submitted = await submitApplication(client, "public-1", "Vegan Federation");
		expect(captured[0]!.messageType).toBe("lobby.application.submit");
		const decoded = ApplicationSubmitRequest.getRootAsApplicationSubmitRequest(
			new ByteBuffer(captured[0]!.payload),
		);
		expect(decoded.gameId()).toBe("public-1");
		expect(decoded.raceName()).toBe("Vegan Federation");
		expect(submitted.applicationId).toBe("app-1");
	});

	test("redeemInvite and declineInvite hit their respective message types", async () => {
		const stubRedeem = makeStub(() => {
			const builder = new Builder(128);
			const invite = encodeInviteSummary(builder, "accepted");
			InviteRedeemResponse.startInviteRedeemResponse(builder);
			InviteRedeemResponse.addInvite(builder, invite);
			builder.finish(InviteRedeemResponse.endInviteRedeemResponse(builder));
			return { payloadBytes: builder.asUint8Array() };
		});
		const redeemed = await redeemInvite(stubRedeem.client, "private-1", "invite-1");
		expect(stubRedeem.captured[0]!.messageType).toBe("lobby.invite.redeem");
		const redeemReq = InviteRedeemRequest.getRootAsInviteRedeemRequest(
			new ByteBuffer(stubRedeem.captured[0]!.payload),
		);
		expect(redeemReq.gameId()).toBe("private-1");
		expect(redeemReq.inviteId()).toBe("invite-1");
		expect(redeemed.status).toBe("accepted");

		const stubDecline = makeStub(() => {
			const builder = new Builder(128);
			const invite = encodeInviteSummary(builder, "declined");
			InviteDeclineResponse.startInviteDeclineResponse(builder);
			InviteDeclineResponse.addInvite(builder, invite);
			builder.finish(InviteDeclineResponse.endInviteDeclineResponse(builder));
			return { payloadBytes: builder.asUint8Array() };
		});
		const declined = await declineInvite(stubDecline.client, "private-1", "invite-1");
		expect(stubDecline.captured[0]!.messageType).toBe("lobby.invite.decline");
		const declineReq = InviteDeclineRequest.getRootAsInviteDeclineRequest(
			new ByteBuffer(stubDecline.captured[0]!.payload),
		);
		expect(declineReq.gameId()).toBe("private-1");
		expect(declineReq.inviteId()).toBe("invite-1");
		expect(declined.status).toBe("declined");
	});

	test("non-ok result codes are surfaced as a LobbyError with code and message", async () => {
		const { client } = makeStub(() => {
			const builder = new Builder(128);
			const code = builder.createString("conflict");
			const message = builder.createString("game is not in enrollment_open");
			ErrorBody.startErrorBody(builder);
			ErrorBody.addCode(builder, code);
			ErrorBody.addMessage(builder, message);
			const errorOff = ErrorBody.endErrorBody(builder);
			ErrorResponse.startErrorResponse(builder);
			ErrorResponse.addError(builder, errorOff);
			builder.finish(ErrorResponse.endErrorResponse(builder));
			return { resultCode: "conflict", payloadBytes: builder.asUint8Array() };
		});

		await expect(submitApplication(client, "public-1", "race")).rejects.toThrow(LobbyError);
		try {
			await submitApplication(client, "public-1", "race");
		} catch (err) {
			const lobbyError = err as LobbyError;
			expect(lobbyError.code).toBe("conflict");
			expect(lobbyError.message).toBe("game is not in enrollment_open");
			expect(lobbyError.resultCode).toBe("conflict");
		}
	});
});
