import "fake-indexeddb/auto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { openDB } from "../storage/db";
import type { GeminiConfig } from "./config";
import {
	checkRateLimit,
	generateImage,
	generateText,
	incrementApiCall,
} from "./gemini";

const TEST_CONFIG: GeminiConfig = {
	geminiApiKey: "test-api-key",
	geminiModel: "gemini-2.5-flash",
	imageModel: "gemini-3.1-flash-preview-image",
};

let testCounter = 0;

function makeGeminiTextResponse(text: string) {
	return {
		candidates: [
			{
				content: {
					parts: [{ text }],
				},
			},
		],
	};
}

// Minimal 1x1 red PNG as base64
const TINY_PNG_BASE64 =
	"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";

function makeGeminiImageResponse(base64: string) {
	return {
		candidates: [
			{
				content: {
					parts: [
						{
							inlineData: {
								mimeType: "image/png",
								data: base64,
							},
						},
					],
				},
			},
		],
	};
}

describe("generateText", () => {
	beforeEach(() => {
		vi.restoreAllMocks();
	});

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

	it("returns extracted text from Gemini response", async () => {
		vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
			new Response(JSON.stringify(makeGeminiTextResponse("Hello World")), {
				status: 200,
			}),
		);

		const result = await generateText("prompt", "system prompt", TEST_CONFIG);
		expect(result).toBe("Hello World");
	});

	it("sends correct request body with model, contents and systemInstruction", async () => {
		const fetchSpy = vi
			.spyOn(globalThis, "fetch")
			.mockResolvedValueOnce(
				new Response(
					JSON.stringify(makeGeminiTextResponse("response")),
					{ status: 200 },
				),
			);

		await generateText("my prompt", "my system prompt", TEST_CONFIG);

		expect(fetchSpy).toHaveBeenCalledTimes(1);
		const call = fetchSpy.mock.calls[0]!;
		const [url, options] = call;
		expect(url).toContain("gemini-2.5-flash");
		expect(url).toContain("generateContent");
		expect(url).toContain("key=test-api-key");

		const body = JSON.parse(options?.body as string);
		expect(body.contents[0].parts[0].text).toBe("my prompt");
		expect(body.systemInstruction.parts[0].text).toBe("my system prompt");
	});

	it("retries on 500 error up to 3 times with backoff", async () => {
		const fetchSpy = vi
			.spyOn(globalThis, "fetch")
			.mockResolvedValueOnce(new Response("error", { status: 500 }))
			.mockResolvedValueOnce(new Response("error", { status: 500 }))
			.mockResolvedValueOnce(
				new Response(
					JSON.stringify(makeGeminiTextResponse("success after retries")),
					{ status: 200 },
				),
			);

		vi.useFakeTimers();
		const promise = generateText("prompt", "system", TEST_CONFIG);
		// Advance through retry delays
		await vi.advanceTimersByTimeAsync(1000);
		await vi.advanceTimersByTimeAsync(2000);
		const result = await promise;
		vi.useRealTimers();

		expect(result).toBe("success after retries");
		expect(fetchSpy).toHaveBeenCalledTimes(3);
	});

	it("returns null after 3 failed attempts", async () => {
		vi.spyOn(globalThis, "fetch")
			.mockResolvedValueOnce(new Response("error", { status: 500 }))
			.mockResolvedValueOnce(new Response("error", { status: 500 }))
			.mockResolvedValueOnce(new Response("error", { status: 500 }));

		vi.useFakeTimers();
		const promise = generateText("prompt", "system", TEST_CONFIG);
		await vi.advanceTimersByTimeAsync(1000);
		await vi.advanceTimersByTimeAsync(2000);
		await vi.advanceTimersByTimeAsync(4000);
		const result = await promise;
		vi.useRealTimers();

		expect(result).toBeNull();
	});
});

describe("generateImage", () => {
	beforeEach(() => {
		vi.restoreAllMocks();
	});

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

	it("returns Blob from base64 image in Gemini response", async () => {
		vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
			new Response(
				JSON.stringify(makeGeminiImageResponse(TINY_PNG_BASE64)),
				{ status: 200 },
			),
		);

		const result = await generateImage("a forest", [], TEST_CONFIG);
		expect(result).toBeInstanceOf(Blob);
		expect(result?.type).toBe("image/png");
	});

	it("sends reference images as inlineData parts", async () => {
		const fetchSpy = vi
			.spyOn(globalThis, "fetch")
			.mockResolvedValueOnce(
				new Response(
					JSON.stringify(makeGeminiImageResponse(TINY_PNG_BASE64)),
					{ status: 200 },
				),
			);

		// Create a small test blob
		const refBlob = new Blob(["fake-image-data"], { type: "image/png" });
		await generateImage("a forest", [refBlob], TEST_CONFIG);

		const [url, options] = fetchSpy.mock.calls[0]!;
		expect(url).toContain("gemini-3.1-flash-preview-image");
		const body = JSON.parse(options?.body as string);
		// First part should be inlineData (reference image), last part should be text prompt
		const parts = body.contents[0].parts;
		expect(parts[0].inlineData).toBeDefined();
		expect(parts[0].inlineData.mimeType).toBe("image/png");
		expect(parts[parts.length - 1].text).toBe("a forest");
	});

	it("sets responseModalities to ['IMAGE'] in generationConfig", async () => {
		const fetchSpy = vi
			.spyOn(globalThis, "fetch")
			.mockResolvedValueOnce(
				new Response(
					JSON.stringify(makeGeminiImageResponse(TINY_PNG_BASE64)),
					{ status: 200 },
				),
			);

		await generateImage("a tree", [], TEST_CONFIG);

		const body = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
		expect(body.generationConfig.responseModalities).toEqual(["IMAGE"]);
	});

	it("returns null on failure", async () => {
		vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
			new Response("error", { status: 500 }),
		);

		const result = await generateImage("a tree", [], TEST_CONFIG);
		expect(result).toBeNull();
	});
});

describe("Rate limiting", () => {
	let db: IDBDatabase;

	beforeEach(async () => {
		vi.restoreAllMocks();
		testCounter++;
		db = await openDB(`zauberwald-rate-test-${testCounter}`);
	});

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

	it("checkRateLimit returns true when under limit (0 calls today)", async () => {
		const canCallText = await checkRateLimit(db, "text");
		const canCallImage = await checkRateLimit(db, "image");
		expect(canCallText).toBe(true);
		expect(canCallImage).toBe(true);
	});

	it("checkRateLimit returns false when at limit (10 text, 5 image)", async () => {
		for (let i = 0; i < 10; i++) await incrementApiCall(db, "text");
		for (let i = 0; i < 5; i++) await incrementApiCall(db, "image");

		const canCallText = await checkRateLimit(db, "text");
		const canCallImage = await checkRateLimit(db, "image");
		expect(canCallText).toBe(false);
		expect(canCallImage).toBe(false);
	});

	it("incrementApiCall increments correct counter", async () => {
		for (let i = 0; i < 10; i++) await incrementApiCall(db, "text");

		const canCallText = await checkRateLimit(db, "text");
		const canCallImage = await checkRateLimit(db, "image");
		expect(canCallText).toBe(false);
		expect(canCallImage).toBe(true);
	});

	it("rate limit resets when lastApiCallDate differs from today", async () => {
		// Increment to hit limit
		for (let i = 0; i < 10; i++) await incrementApiCall(db, "text");
		for (let i = 0; i < 5; i++) await incrementApiCall(db, "image");

		// Manually set lastApiCallDate to yesterday
		const { getSettings, saveSettings } = await import("../storage/db");
		const settings = await getSettings(db);
		if (settings) {
			settings.lastApiCallDate = "2020-01-01";
			await saveSettings(db, settings);
		}

		// Should be reset now
		const canCallText = await checkRateLimit(db, "text");
		const canCallImage = await checkRateLimit(db, "image");
		expect(canCallText).toBe(true);
		expect(canCallImage).toBe(true);
	});
});
