/**
 * Tests that plugin HTTP functions strip credential headers on cross-origin redirects.
 *
 * Both createHttpAccess and createUnrestrictedHttpAccess manually follow redirects.
 * When a redirect crosses origins, Authorization/Cookie/Proxy-Authorization headers
 * must be stripped to prevent credential leakage to untrusted hosts.
 */

import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";

import { setDefaultDnsResolver } from "../../../src/import/ssrf.js";
import { createHttpAccess, createUnrestrictedHttpAccess } from "../../../src/plugins/context.js";

// Intercept globalThis.fetch so we can simulate redirect chains
const mockFetch = vi.fn<typeof globalThis.fetch>();
vi.stubGlobal("fetch", mockFetch);

// Bypass DoH so the fetch mock only sees the calls these tests model.
// Returns a fixed public IP so resolveAndValidateExternalUrl passes.
const STUB_RESOLVER = async () => ["93.184.216.34"];
let previousResolver: ReturnType<typeof setDefaultDnsResolver> | undefined;

beforeAll(() => {
	previousResolver = setDefaultDnsResolver(STUB_RESOLVER);
});

afterAll(() => {
	setDefaultDnsResolver(previousResolver ?? null);
});

afterEach(() => {
	mockFetch.mockReset();
});

/** Build a minimal redirect response */
function redirectResponse(location: string, status = 302): Response {
	return new Response(null, {
		status,
		headers: { Location: location },
	});
}

/** Build a 200 response */
function okResponse(body = "ok"): Response {
	return new Response(body, { status: 200 });
}

/** Extract the headers passed to the Nth fetch call */
function headersOfCall(callIndex: number): Headers {
	const init = mockFetch.mock.calls[callIndex]?.[1] as RequestInit | undefined;
	return new Headers(init?.headers);
}

// =============================================================================
// createHttpAccess – host-restricted
// =============================================================================

describe("createHttpAccess host allowlist matching", () => {
	const pluginId = "test-plugin";

	it('allows any hostname when allowedHosts contains standalone "*"', async () => {
		mockFetch.mockResolvedValue(okResponse());

		const http = createHttpAccess(pluginId, ["*"]);
		await expect(http.fetch("https://api.example.com/v1")).resolves.toBeInstanceOf(Response);
		await expect(http.fetch("https://random.host.io/path")).resolves.toBeInstanceOf(Response);
	});

	it('allows requests when "*" is mixed with explicit hosts', async () => {
		mockFetch.mockResolvedValue(okResponse());

		const http = createHttpAccess(pluginId, ["*", "api.example.com"]);
		await expect(http.fetch("https://another.example.net/ok")).resolves.toBeInstanceOf(Response);
	});

	it('still supports "*.domain" wildcard matching', async () => {
		mockFetch.mockResolvedValue(okResponse());

		const http = createHttpAccess(pluginId, ["*.example.com"]);
		await expect(http.fetch("https://api.example.com/v1")).resolves.toBeInstanceOf(Response);
		await expect(http.fetch("https://evil.com")).rejects.toThrow(
			'is not allowed to fetch from host "evil.com"',
		);
	});
});

describe("createHttpAccess credential stripping", () => {
	const pluginId = "test-plugin";
	const allowedHosts = ["a.example.com", "b.example.com"];

	it("preserves credentials on same-origin redirect", async () => {
		mockFetch
			.mockResolvedValueOnce(redirectResponse("https://a.example.com/page2"))
			.mockResolvedValueOnce(okResponse());

		const http = createHttpAccess(pluginId, allowedHosts);
		await http.fetch("https://a.example.com/page1", {
			headers: { Authorization: "Bearer secret", Cookie: "session=abc" },
		});

		// Second call should still have credentials (same origin)
		const h = headersOfCall(1);
		expect(h.get("authorization")).toBe("Bearer secret");
		expect(h.get("cookie")).toBe("session=abc");
	});

	it("strips credentials on cross-origin redirect", async () => {
		mockFetch
			.mockResolvedValueOnce(redirectResponse("https://b.example.com/landing"))
			.mockResolvedValueOnce(okResponse());

		const http = createHttpAccess(pluginId, allowedHosts);
		await http.fetch("https://a.example.com/start", {
			headers: {
				Authorization: "Bearer secret",
				Cookie: "session=abc",
				"Proxy-Authorization": "Basic creds",
				"X-Custom": "keep-me",
			},
		});

		const h = headersOfCall(1);
		expect(h.get("authorization")).toBeNull();
		expect(h.get("cookie")).toBeNull();
		expect(h.get("proxy-authorization")).toBeNull();
		// Non-credential headers survive
		expect(h.get("x-custom")).toBe("keep-me");
	});

	it("strips credentials only once even with multiple same-origin hops after cross-origin", async () => {
		// a.example.com -> b.example.com -> b.example.com/final
		mockFetch
			.mockResolvedValueOnce(redirectResponse("https://b.example.com/step1"))
			.mockResolvedValueOnce(redirectResponse("https://b.example.com/step2"))
			.mockResolvedValueOnce(okResponse());

		const http = createHttpAccess(pluginId, allowedHosts);
		await http.fetch("https://a.example.com/start", {
			headers: { Authorization: "Bearer secret" },
		});

		// Call 0: original (has auth)
		expect(headersOfCall(0).get("authorization")).toBe("Bearer secret");
		// Call 1: after cross-origin hop (stripped)
		expect(headersOfCall(1).get("authorization")).toBeNull();
		// Call 2: same-origin hop on b (still stripped -- not re-added)
		expect(headersOfCall(2).get("authorization")).toBeNull();
	});
});

// =============================================================================
// createUnrestrictedHttpAccess – SSRF-protected but no host list
// =============================================================================

describe("createUnrestrictedHttpAccess credential stripping", () => {
	const pluginId = "unrestricted-plugin";

	it("preserves credentials on same-origin redirect", async () => {
		mockFetch
			.mockResolvedValueOnce(redirectResponse("https://api.example.com/v2"))
			.mockResolvedValueOnce(okResponse());

		const http = createUnrestrictedHttpAccess(pluginId);
		await http.fetch("https://api.example.com/v1", {
			headers: { Authorization: "Bearer token" },
		});

		expect(headersOfCall(1).get("authorization")).toBe("Bearer token");
	});

	it("strips credentials on cross-origin redirect", async () => {
		mockFetch
			.mockResolvedValueOnce(redirectResponse("https://evil.example.com/steal"))
			.mockResolvedValueOnce(okResponse());

		const http = createUnrestrictedHttpAccess(pluginId);
		await http.fetch("https://api.example.com/start", {
			headers: {
				Authorization: "Bearer token",
				Cookie: "session=xyz",
				"Proxy-Authorization": "Basic pw",
				Accept: "application/json",
			},
		});

		const h = headersOfCall(1);
		expect(h.get("authorization")).toBeNull();
		expect(h.get("cookie")).toBeNull();
		expect(h.get("proxy-authorization")).toBeNull();
		expect(h.get("accept")).toBe("application/json");
	});

	it("handles redirect with no init gracefully", async () => {
		mockFetch
			.mockResolvedValueOnce(redirectResponse("https://other.example.com/"))
			.mockResolvedValueOnce(okResponse());

		const http = createUnrestrictedHttpAccess(pluginId);
		// No init at all -- should not throw
		await http.fetch("https://api.example.com/bare");

		expect(headersOfCall(1).get("authorization")).toBeNull();
	});
});
