import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import crypto from "node:crypto";
import { PassThrough } from "node:stream";
import { createApp } from "~/server/app";
import { db } from "~/server/db/db";
import { repositoriesTable } from "~/server/db/schema";
import { generateShortId } from "~/server/utils/id";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
import type { RepositoryConfig } from "@zerobyte/core/restic";
import { restic } from "~/server/core/restic";
import { Effect } from "effect";

const app = createApp();

let session: Awaited<ReturnType<typeof createTestSession>>;

beforeAll(async () => {
	session = await createTestSession();
});

beforeEach(() => {
	vi.spyOn(restic, "init").mockReturnValue(Effect.succeed({ success: true, error: null }));
});

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

const createRepositoryRecord = async (organizationId: string) => {
	const [repository] = await db
		.insert(repositoriesTable)
		.values({
			id: crypto.randomUUID(),
			shortId: generateShortId(),
			name: `Repository-${crypto.randomUUID()}`,
			type: "local",
			config: {
				backend: "local",
				name: generateShortId(),
				path: `/tmp/repository-${crypto.randomUUID()}`,
				isExistingRepository: true,
			},
			compressionMode: "off",
			status: "error",
			lastChecked: Date.now(),
			lastError: "old error",
			doctorResult: {
				success: false,
				steps: [],
				completedAt: Date.now(),
			},
			organizationId,
		})
		.returning();

	return repository;
};

const createManagedRepositoryRecord = async (organizationId: string) => {
	const [repository] = await db
		.insert(repositoriesTable)
		.values({
			id: Bun.randomUUIDv7(),
			provisioningId: `provisioned:${crypto.randomUUID()}`,
			shortId: generateShortId(),
			name: `Managed-${crypto.randomUUID()}`,
			type: "local",
			config: {
				backend: "local",
				path: `/tmp/repository-${crypto.randomUUID()}`,
				isExistingRepository: true,
			},
			compressionMode: "off",
			status: "healthy",
			organizationId,
		})
		.returning();

	return repository;
};

describe("repositories security", () => {
	test("should return 401 if no session cookie is provided", async () => {
		const res = await app.request("/api/v1/repositories");
		expect(res.status).toBe(401);
		const body = await res.json();
		expect(body.message).toBe("Invalid or expired session");
	});

	test("should return 401 if session is invalid", async () => {
		const res = await app.request("/api/v1/repositories", {
			headers: getAuthHeaders("invalid-session"),
		});
		expect(res.status).toBe(401);
		const body = await res.json();
		expect(body.message).toBe("Invalid or expired session");
	});

	test("should return 200 if session is valid", async () => {
		const res = await app.request("/api/v1/repositories", {
			headers: session.headers,
		});

		expect(res.status).toBe(200);
	});

	describe("unauthenticated access", () => {
		const endpoints: { method: string; path: string }[] = [
			{ method: "GET", path: "/api/v1/repositories" },
			{ method: "POST", path: "/api/v1/repositories" },
			{ method: "GET", path: "/api/v1/repositories/rclone-remotes" },
			{ method: "GET", path: "/api/v1/repositories/test-repo" },
			{ method: "GET", path: "/api/v1/repositories/test-repo/stats" },
			{ method: "POST", path: "/api/v1/repositories/test-repo/stats/refresh" },
			{ method: "DELETE", path: "/api/v1/repositories/test-repo" },
			{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots" },
			{ method: "POST", path: "/api/v1/repositories/test-repo/snapshots/refresh" },
			{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" },
			{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/files" },
			{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/dump" },
			{ method: "POST", path: "/api/v1/repositories/test-repo/restore" },
			{ method: "POST", path: "/api/v1/repositories/test-repo/doctor" },
			{ method: "DELETE", path: "/api/v1/repositories/test-repo/doctor" },
			{ method: "POST", path: "/api/v1/repositories/test-repo/unlock" },
			{ method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" },
			{ method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots" },
			{ method: "POST", path: "/api/v1/repositories/test-repo/snapshots/tag" },
			{ method: "PATCH", path: "/api/v1/repositories/test-repo" },
		];

		for (const { method, path } of endpoints) {
			test(`${method} ${path} should return 401`, async () => {
				const res = await app.request(path, { method });
				expect(res.status).toBe(401);
				const body = await res.json();
				expect(body.message).toBe("Invalid or expired session");
			});
		}
	});

	describe("dev panel endpoint - requires dev panel access", () => {
		test("POST /api/v1/repositories/:shortId/exec should return 401 when unauthenticated", async () => {
			const res = await app.request("/api/v1/repositories/test-repo/exec", {
				method: "POST",
				headers: {
					"Content-Type": "application/json",
				},
				body: JSON.stringify({ command: "version" }),
			});
			expect(res.status).toBe(401);
			const body = await res.json();
			expect(body.message).toBe("Invalid or expired session");
		});
	});

	describe("information disclosure", () => {
		test("should not disclose if a repository exists when unauthenticated", async () => {
			const res = await app.request("/api/v1/repositories/non-existent-repo");
			expect(res.status).toBe(401);
			const body = await res.json();
			expect(body.message).toBe("Invalid or expired session");
		});
	});

	describe("input validation", () => {
		test("should return 404 for non-existent repository", async () => {
			const res = await app.request("/api/v1/repositories/non-existent-repo", {
				headers: session.headers,
			});

			expect(res.status).toBe(404);
			const body = await res.json();
			expect(body.message).toBe("Repository not found");
		});

		test("should return 404 for stats of non-existent repository", async () => {
			const res = await app.request("/api/v1/repositories/non-existent-repo/stats", {
				headers: session.headers,
			});

			expect(res.status).toBe(404);
			const body = await res.json();
			expect(body.message).toBe("Repository not found");
		});

		test("should return 404 for stats refresh of non-existent repository", async () => {
			const res = await app.request("/api/v1/repositories/non-existent-repo/stats/refresh", {
				method: "POST",
				headers: session.headers,
			});

			expect(res.status).toBe(404);
			const body = await res.json();
			expect(body.message).toBe("Repository not found");
		});

		test("should return 400 for invalid payload on create", async () => {
			const res = await app.request("/api/v1/repositories", {
				method: "POST",
				headers: {
					...session.headers,
					"Content-Type": "application/json",
				},
				body: JSON.stringify({
					name: "Test",
				}),
			});

			expect(res.status).toBe(400);
		});

		test("should accept env:// values as plain secrets on create", async () => {
			const res = await app.request("/api/v1/repositories", {
				method: "POST",
				headers: {
					...session.headers,
					"Content-Type": "application/json",
				},
				body: JSON.stringify({
					name: "S3 repo",
					compressionMode: "auto",
					config: {
						backend: "s3",
						endpoint: "https://s3.amazonaws.com",
						bucket: "bucket-name",
						accessKeyId: "access-key",
						secretAccessKey: "env://ZEROBYTE_REPOSITORY_SECRET",
					},
				}),
			});

			expect(res.status).not.toBe(400);
		});
	});
});

describe("repositories updates", () => {
	test("PATCH updates full config and metadata using shortId", async () => {
		const repository = await createRepositoryRecord(session.organizationId);
		const nextPath = `/tmp/updated-${crypto.randomUUID()}`;

		const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
			method: "PATCH",
			headers: {
				...session.headers,
				"Content-Type": "application/json",
			},
			body: JSON.stringify({
				name: "Updated repository",
				compressionMode: "max",
				config: {
					backend: "local",
					path: nextPath,
					isExistingRepository: true,
				},
			}),
		});

		expect(res.status).toBe(200);
		const body = await res.json();
		expect(body.name).toBe("Updated repository");
		expect(body.compressionMode).toBe("max");
		expect(body.config.backend).toBe("local");
		expect(body.config.path).toBe(nextPath);
		expect(body.status).toBe("unknown");
		expect(body.lastChecked).toBeNull();
		expect(body.lastError).toBeNull();
		expect(body.doctorResult).toBeNull();

		const updated = await db.query.repositoriesTable.findFirst({
			where: { id: repository.id },
		});

		const config = updated?.config as Extract<RepositoryConfig, { backend: "local" }>;

		expect(updated).toBeTruthy();
		expect(updated?.name).toBe("Updated repository");
		expect(updated?.compressionMode).toBe("max");
		expect(config.path).toBe(nextPath);
		expect(updated?.status).toBe("unknown");
		expect(updated?.lastChecked).toBeNull();
		expect(updated?.lastError).toBeNull();
		expect(updated?.doctorResult).toBeNull();
	});

	test("PATCH rejects backend changes", async () => {
		const repository = await createRepositoryRecord(session.organizationId);

		const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
			method: "PATCH",
			headers: {
				...session.headers,
				"Content-Type": "application/json",
			},
			body: JSON.stringify({
				config: {
					backend: "s3",
					endpoint: "s3.amazonaws.com",
					bucket: "bucket-name",
					accessKeyId: "access-key",
					secretAccessKey: "secret-key",
				},
			}),
		});

		expect(res.status).toBe(400);
		const body = await res.json();
		expect(body.message).toBe("Repository backend cannot be changed");
	});

	test("PATCH rejects invalid config payload", async () => {
		const repository = await createRepositoryRecord(session.organizationId);

		const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
			method: "PATCH",
			headers: {
				...session.headers,
				"Content-Type": "application/json",
			},
			body: JSON.stringify({
				config: {
					backend: "local",
				},
			}),
		});

		expect(res.status).toBe(400);
	});

	describe("delete snapshot", () => {
		test("should return ResticError details when restic deleteSnapshot fails", async () => {
			const repository = await createRepositoryRecord(session.organizationId);

			const { restic } = await import("~/server/core/restic");
			const { ResticError } = await import("@zerobyte/core/restic");

			const deleteSnapshotSpy = vi
				.spyOn(restic, "deleteSnapshot")
				.mockImplementation(() =>
					Effect.fail(new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden")),
				);

			try {
				const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, {
					method: "DELETE",
					headers: session.headers,
				});

				expect(res.status).toBe(500);
				const body = await res.json();
				expect(body).toEqual({
					message: "Command failed: An error occurred while executing the command.",
					details: "Fatal: unexpected HTTP response (403): 403 Forbidden",
				});
			} finally {
				deleteSnapshotSpy.mockRestore();
			}
		});
	});

	describe("dump snapshot", () => {
		test("continues streaming a download after the request signal aborts", async () => {
			const repository = await createRepositoryRecord(session.organizationId);
			const stream = new PassThrough();
			const expectedContent = "downloaded snapshot contents";

			const snapshotsSpy = vi.spyOn(restic, "snapshots").mockReturnValue(
				Effect.succeed([
					{
						id: "test-snapshot",
						short_id: "test-snapshot",
						time: new Date().toISOString(),
						paths: ["/mnt/project"],
						hostname: "host",
					},
				]),
			);
			const dumpSpy = vi.spyOn(restic, "dump").mockReturnValue(
				Effect.succeed({
					stream: stream as never,
					completion: Promise.resolve(),
					abort: vi.fn(),
				}),
			);

			try {
				const controller = new AbortController();
				const response = await app.request(
					`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`,
					{
						headers: session.headers,
						signal: controller.signal,
					},
				);

				queueMicrotask(() => {
					controller.abort();
					stream.end(expectedContent);
				});

				await expect(response.text()).resolves.toBe(expectedContent);
			} finally {
				snapshotsSpy.mockRestore();
				dumpSpy.mockRestore();
			}
		});

		test("returns a valid content-disposition header for non-ascii filenames", async () => {
			const repository = await createRepositoryRecord(session.organizationId);

			const stream = new PassThrough();
			const snapshotsSpy = vi.spyOn(restic, "snapshots").mockReturnValue(
				Effect.succeed([
					{
						id: "test-snapshot",
						short_id: "test-snapshot",
						time: new Date().toISOString(),
						paths: ["/mnt/project"],
						hostname: "host",
					},
				]),
			);
			const dumpSpy = vi.spyOn(restic, "dump").mockReturnValue(
				Effect.succeed({
					stream: stream as never,
					completion: Promise.resolve(),
					abort: vi.fn(),
				}),
			);

			try {
				stream.end("downloaded snapshot contents");

				const encodedPath = encodeURIComponent("/mnt/project/möte.txt");
				const response = await app.request(
					`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump?path=${encodedPath}&kind=file`,
					{
						headers: session.headers,
					},
				);

				expect(response.status).toBe(200);
				expect(response.headers.get("Content-Disposition")).toBe(
					`attachment; filename="m?te.txt"; filename*=UTF-8''m%C3%B6te.txt`,
				);
			} finally {
				snapshotsSpy.mockRestore();
				dumpSpy.mockRestore();
			}
		});
	});

	test("GET marks provisioned repositories as managed", async () => {
		const repository = await createManagedRepositoryRecord(session.organizationId);

		const res = await app.request(`/api/v1/repositories/${repository.shortId}`, { headers: session.headers });

		expect(res.status).toBe(200);
		const body = await res.json();
		expect(body.provisioningId).toBeDefined();
	});

	test("PATCH allows updates for managed repositories", async () => {
		const repository = await createManagedRepositoryRecord(session.organizationId);

		const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
			method: "PATCH",
			headers: {
				...session.headers,
				"Content-Type": "application/json",
			},
			body: JSON.stringify({
				name: "Updated repository",
			}),
		});

		expect(res.status).toBe(200);
		const body = await res.json();
		expect(body.name).toBe("Updated repository");
	});

	test("DELETE allows managed repositories", async () => {
		const repository = await createManagedRepositoryRecord(session.organizationId);

		const res = await app.request(`/api/v1/repositories/${repository.shortId}`, {
			method: "DELETE",
			headers: session.headers,
		});

		expect(res.status).toBe(200);
	});
});
