import assert from "node:assert/strict";
import {
	CUSTOM_SEARCH_TOOL_NAMES,
	MAX_NATIVE_SEARCHES_PER_SESSION,
	stripThinkingFromHistory,
	webSearchMiddleware,
} from "@singularity-forge/coding-agent";
import { afterEach, test } from "vitest";
import {
	BRAVE_TOOL_NAMES,
	registerNativeSearchHooks,
} from "../resources/extensions/search-the-web/native-search.ts";
import {
	getMiniMaxSearchApiKey,
	resolveSearchProvider,
} from "../resources/extensions/search-the-web/provider.ts";

/**
 * Tests for native Anthropic web search injection.
 *
 * Tests the hook logic in native-search.ts directly (no heavy tool deps).
 */

// ─── Mock ExtensionAPI ──────────────────────────────────────────────────────

interface MockHandler {
	event: string;
	handler: (...args: any[]) => any;
}

function createMockPI() {
	const handlers: MockHandler[] = [];
	let activeTools = [
		"search-the-web",
		"search_and_read",
		"google_search",
		"fetch_page",
		"bash",
		"read",
	];
	const notifications: Array<{ message: string; level: string }> = [];

	const mockCtx = {
		ui: {
			notify(message: string, level: string) {
				notifications.push({ message, level });
			},
		},
	};

	const pi: {
		handlers: MockHandler[];
		notifications: typeof notifications;
		mockCtx: typeof mockCtx;
		fire(event: string, eventData: any, ctx?: any): Promise<any>;
		on(event: string, handler: (...args: any[]) => any): void;
		getActiveTools(): string[];
		setActiveTools(tools: string[]): void;
	} = {
		handlers,
		notifications,
		mockCtx,
		on(event: string, handler: (...args: any[]) => any) {
			handlers.push({ event, handler });
		},
		getActiveTools() {
			return [...activeTools];
		},
		setActiveTools(tools: string[]) {
			activeTools = tools;
		},
		async fire(event: string, eventData: any, ctx?: any) {
			let lastResult: any;
			for (const h of handlers) {
				if (h.event === event) {
					const result = await h.handler(eventData, ctx ?? mockCtx);
					if (result !== undefined) lastResult = result;
				}
			}
			return lastResult;
		},
	};

	return pi;
}

// ─── Tests ──────────────────────────────────────────────────────────────────

// ─── webSearchMiddleware.applyToPayload tests ────────────────────────────────
// before_provider_request injection runs natively in sdk.ts; tests call the
// middleware directly instead of routing through the extension hook.

test("applyToPayload injects web_search for Anthropic provider", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi); // resets session counter

	const payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
	};

	const result = webSearchMiddleware.applyToPayload(payload, {
		provider: "anthropic",
	});

	const tools = (result as any)?.tools ?? payload.tools;
	const nativeTool = (tools as any[]).find(
		(t: any) => t.type === "web_search_20250305",
	);
	assert.ok(nativeTool, "Should inject web_search_20250305 tool");
	assert.equal(
		(tools as any[]).length,
		2,
		"Should have original + injected tool",
	);
	assert.equal(
		nativeTool.max_uses,
		5,
		"Should set max_uses to 5 to prevent search loops (#817)",
	);
});

test("applyToPayload injects web_search based on claude model name heuristic", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	const payload: Record<string, unknown> = {
		model: "claude-opus-4-6",
		tools: [
			{ name: "bash", type: "custom" },
			{ name: "search-the-web", type: "function" },
			{ name: "google_search", type: "function" },
		],
	};

	const result = webSearchMiddleware.applyToPayload(payload);

	const tools = ((result as any)?.tools ?? payload.tools) as any[];
	const names = tools.map((t: any) => t.name ?? t.type);

	assert.ok(
		names.includes("web_search"),
		"Should inject native web_search based on model name",
	);
	assert.ok(!names.includes("search-the-web"), "Should remove search-the-web");
	assert.ok(!names.includes("google_search"), "Should remove google_search");
	assert.ok(names.includes("bash"), "Should keep non-search tools");
});

test("applyToPayload does NOT inject for non-claude model names", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	const payload: Record<string, unknown> = {
		model: "gpt-4o",
		tools: [{ name: "bash", type: "custom" }],
	};

	const result = webSearchMiddleware.applyToPayload(payload);

	assert.equal(result, undefined, "Should not modify non-claude payload");
	const tools = payload.tools as any[];
	assert.equal(tools.length, 1, "Should not add tools to non-claude payload");
});

test("applyToPayload does NOT inject for claude model when provider is non-Anthropic", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	const payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
	};

	const result = webSearchMiddleware.applyToPayload(payload, {
		provider: "copilot",
	});

	assert.equal(
		result,
		undefined,
		"Should not modify payload for non-Anthropic provider",
	);
	const tools = payload.tools as any[];
	assert.equal(
		tools.length,
		1,
		"Should not inject web_search for non-Anthropic provider",
	);
	assert.ok(
		!tools.some((t: any) => t.type === "web_search_20250305"),
		"web_search_20250305 must NOT be present for non-Anthropic providers",
	);
});

// ─── Issue #444 regression: Copilot claude-* model ───────────────────────────

test("applyToPayload does NOT inject when provider is github-copilot", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	const payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
	};

	const result = webSearchMiddleware.applyToPayload(payload, {
		provider: "github-copilot",
	});

	assert.equal(
		result,
		undefined,
		"Should not modify payload when provider is Copilot",
	);
	const tools = payload.tools as any[];
	assert.equal(
		tools.length,
		1,
		"Should not inject web_search for Copilot provider",
	);
	assert.ok(
		!tools.some((t: any) => t.type === "web_search_20250305"),
		"web_search_20250305 must NOT be present for Copilot",
	);
});

test("applyToPayload DOES inject when provider is anthropic", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	const payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
	};

	const result = webSearchMiddleware.applyToPayload(payload, {
		provider: "anthropic",
	});

	const tools = ((result as any)?.tools ?? payload.tools) as any[];
	assert.ok(
		tools.some((t: any) => t.type === "web_search_20250305"),
		"Should inject web_search when provider is anthropic",
	);
});

test("applyToPayload does not double-inject", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	const payload: Record<string, unknown> = {
		model: "claude-opus-4-6-20250514",
		tools: [{ type: "web_search_20250305", name: "web_search" }],
	};

	const result = webSearchMiddleware.applyToPayload(payload, {
		provider: "anthropic",
	});

	assert.equal(result, undefined, "Should not modify when already injected");
	const tools = payload.tools as any[];
	assert.equal(tools.length, 1, "Should not duplicate web_search tool");
});

test("applyToPayload creates tools array if missing", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	const payload: Record<string, unknown> = {
		model: "claude-haiku-4-5-20251001",
	};

	const result = webSearchMiddleware.applyToPayload(payload, {
		provider: "anthropic",
	});

	const tools = (result as any)?.tools ?? payload.tools;
	assert.ok(Array.isArray(tools), "Should create tools array");
	assert.equal((tools as any[]).length, 1, "Should have exactly 1 tool");
	assert.equal((tools as any[])[0].type, "web_search_20250305");
	assert.equal(
		(tools as any[])[0].max_uses,
		5,
		"Should include max_uses limit",
	);
});

test("applyToPayload skips when payload is falsy", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	const result = webSearchMiddleware.applyToPayload(null);

	assert.equal(result, undefined, "Should return undefined for null payload");
});

test("model_select disables Brave tools when Anthropic + no BRAVE_API_KEY", async (_t) => {
	const originalKey = process.env.BRAVE_API_KEY;
	delete process.env.BRAVE_API_KEY;

	afterEach(() => {
		if (originalKey) process.env.BRAVE_API_KEY = originalKey;
		else delete process.env.BRAVE_API_KEY;
	});
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	const active = pi.getActiveTools();
	assert.ok(
		!active.includes("search-the-web"),
		"search-the-web should be disabled",
	);
	assert.ok(
		!active.includes("search_and_read"),
		"search_and_read should be disabled",
	);
	assert.ok(
		!active.includes("google_search"),
		"google_search should be disabled",
	);
	assert.ok(active.includes("fetch_page"), "fetch_page should remain active");
	assert.ok(active.includes("bash"), "Other tools should remain active");
});

test("model_select disables all custom search tools when Anthropic even with BRAVE_API_KEY", async (_t) => {
	const originalKey = process.env.BRAVE_API_KEY;
	process.env.BRAVE_API_KEY = "test-key";

	afterEach(() => {
		if (originalKey) process.env.BRAVE_API_KEY = originalKey;
		else delete process.env.BRAVE_API_KEY;
	});
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	const active = pi.getActiveTools();
	assert.ok(
		!active.includes("search-the-web"),
		"search-the-web should be disabled for Anthropic",
	);
	assert.ok(
		!active.includes("search_and_read"),
		"search_and_read should be disabled for Anthropic",
	);
	assert.ok(
		!active.includes("google_search"),
		"google_search should be disabled for Anthropic",
	);
	assert.ok(active.includes("fetch_page"), "fetch_page should remain active");
});

test("model_select re-enables Brave tools when switching away from Anthropic", async (_t) => {
	const originalKey = process.env.BRAVE_API_KEY;
	delete process.env.BRAVE_API_KEY;

	afterEach(() => {
		if (originalKey) process.env.BRAVE_API_KEY = originalKey;
		else delete process.env.BRAVE_API_KEY;
	});
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	// First: select Anthropic — disables Brave tools
	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	let active = pi.getActiveTools();
	assert.ok(
		!active.includes("search-the-web"),
		"Should disable after Anthropic select",
	);

	// Second: switch to non-Anthropic — re-enables
	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "openai", name: "gpt-4o" },
		previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" },
		source: "set",
	});

	active = pi.getActiveTools();
	assert.ok(
		active.includes("search-the-web"),
		"search-the-web should be re-enabled",
	);
	assert.ok(
		active.includes("search_and_read"),
		"search_and_read should be re-enabled",
	);
	assert.ok(
		active.includes("google_search"),
		"google_search should be re-enabled",
	);
});

test("model_select shows 'Native Anthropic web search active' for Anthropic provider", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	const infoNotif = pi.notifications.find(
		(n) => n.level === "info" && n.message.includes("Native"),
	);
	assert.ok(
		infoNotif,
		"Should notify about native search on Anthropic model_select",
	);
	assert.ok(
		infoNotif!.message.includes("Native Anthropic web search active"),
		`Should say 'Native Anthropic web search active' — got: ${infoNotif!.message}`,
	);
});

test("model_select shows warning for non-Anthropic without Brave key", async (_t) => {
	const keys = [
		"BRAVE_API_KEY",
		"TAVILY_API_KEY",
		"MINIMAX_API_KEY",
		"MINIMAX_CODE_PLAN_KEY",
		"MINIMAX_CODING_API_KEY",
		"SERPER_API_KEY",
		"EXA_API_KEY",
		"OLLAMA_API_KEY",
	];
	const originals: Record<string, string | undefined> = {};
	for (const k of keys) {
		originals[k] = process.env[k];
		delete process.env[k];
	}

	afterEach(() => {
		for (const k of keys) {
			if (originals[k] !== undefined) process.env[k] = originals[k];
			else delete process.env[k];
		}
	});
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "openai", name: "gpt-4o" },
		previousModel: undefined,
		source: "set",
	});

	const warning = pi.notifications.find((n) => n.level === "warning");
	assert.ok(warning, "Should show warning for non-Anthropic without Brave key");
	assert.ok(
		warning!.message.includes("Anthropic"),
		`Warning should mention Anthropic — got: ${warning!.message}`,
	);
});

test("session_start resets search count and shows no startup notification", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("session_start", { type: "session_start" });

	// Tool status is now shown in the welcome screen bar layout — no notification on session_start
	const infoNotif = pi.notifications.find(
		(n) => n.level === "info" && n.message.includes("v4"),
	);
	assert.equal(
		infoNotif,
		undefined,
		"Should NOT emit a v4 startup notification (welcome screen handles this)",
	);
});

test("BRAVE_TOOL_NAMES contains expected tool names", () => {
	assert.deepEqual(BRAVE_TOOL_NAMES, ["search-the-web", "search_and_read"]);
});

test("CUSTOM_SEARCH_TOOL_NAMES contains all custom search tools", () => {
	assert.deepEqual(CUSTOM_SEARCH_TOOL_NAMES, [
		"search-the-web",
		"search_and_read",
		"google_search",
	]);
});

test("before_provider_request removes Brave tools from payload when no BRAVE_API_KEY", async (_t) => {
	const originalKey = process.env.BRAVE_API_KEY;
	delete process.env.BRAVE_API_KEY;

	afterEach(() => {
		if (originalKey) process.env.BRAVE_API_KEY = originalKey;
		else delete process.env.BRAVE_API_KEY;
	});
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	const payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [
			{ name: "bash", type: "function" },
			{ name: "search-the-web", type: "function" },
			{ name: "search_and_read", type: "function" },
			{ name: "google_search", type: "function" },
			{ name: "fetch_page", type: "function" },
		],
	};

	const result = await pi.fire("before_provider_request", {
		type: "before_provider_request",
		payload,
	});

	const tools = ((result as any)?.tools ?? payload.tools) as any[];
	const names = tools.map((t: any) => t.name);

	assert.ok(
		!names.includes("search-the-web"),
		"search-the-web should be removed from payload",
	);
	assert.ok(
		!names.includes("search_and_read"),
		"search_and_read should be removed from payload",
	);
	assert.ok(
		!names.includes("google_search"),
		"google_search should be removed from payload",
	);
	assert.ok(names.includes("bash"), "bash should remain");
	assert.ok(names.includes("fetch_page"), "fetch_page should remain");
	assert.ok(
		names.includes("web_search"),
		"native web_search should be injected",
	);
});

test("before_provider_request removes all custom search tools from payload even with BRAVE_API_KEY", async (_t) => {
	const originalKey = process.env.BRAVE_API_KEY;
	process.env.BRAVE_API_KEY = "test-key";

	afterEach(() => {
		if (originalKey) process.env.BRAVE_API_KEY = originalKey;
		else delete process.env.BRAVE_API_KEY;
	});
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	const payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [
			{ name: "search-the-web", type: "function" },
			{ name: "search_and_read", type: "function" },
			{ name: "google_search", type: "function" },
			{ name: "fetch_page", type: "function" },
		],
	};

	const result = await pi.fire("before_provider_request", {
		type: "before_provider_request",
		payload,
	});

	const tools = ((result as any)?.tools ?? payload.tools) as any[];
	const names = tools.map((t: any) => t.name);

	assert.ok(
		!names.includes("search-the-web"),
		"search-the-web should be removed for Anthropic",
	);
	assert.ok(
		!names.includes("search_and_read"),
		"search_and_read should be removed for Anthropic",
	);
	assert.ok(
		!names.includes("google_search"),
		"google_search should be removed for Anthropic",
	);
	assert.ok(names.includes("fetch_page"), "fetch_page should remain");
	assert.ok(
		names.includes("web_search"),
		"native web_search should be injected",
	);
});

// ─── BUG-1 regression: duplicate Brave tools on repeated provider toggle ────

test("model_select re-enable does not duplicate Brave tools across toggle cycles", async (_t) => {
	const originalKey = process.env.BRAVE_API_KEY;
	delete process.env.BRAVE_API_KEY;

	afterEach(() => {
		if (originalKey) process.env.BRAVE_API_KEY = originalKey;
		else delete process.env.BRAVE_API_KEY;
	});
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	// Cycle 1: Anthropic disables Brave tools
	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});
	assert.ok(
		!pi.getActiveTools().includes("search-the-web"),
		"Disabled after 1st Anthropic select",
	);

	// Cycle 1: switch away re-enables
	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "openai", name: "gpt-4o" },
		previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" },
		source: "set",
	});
	let active = pi.getActiveTools();
	assert.equal(
		active.filter((t) => t === "search-the-web").length,
		1,
		"search-the-web exactly once after first re-enable",
	);

	// Cycle 2: Anthropic again
	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: { provider: "openai", name: "gpt-4o" },
		source: "set",
	});

	// Cycle 2: switch away again — must NOT accumulate duplicates
	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "openai", name: "gpt-4o" },
		previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" },
		source: "set",
	});
	active = pi.getActiveTools();
	assert.equal(
		active.filter((t) => t === "search-the-web").length,
		1,
		"search-the-web exactly once after second re-enable (no duplicates)",
	);
	assert.equal(
		active.filter((t) => t === "search_and_read").length,
		1,
		"search_and_read exactly once (no duplicates)",
	);
	assert.equal(
		active.filter((t) => t === "google_search").length,
		1,
		"google_search exactly once (no duplicates)",
	);
});

// ─── BUG-3 regression: mock fire() must call all handlers, not just first ───

test("mock fire() calls all handlers for the same event", async () => {
	const pi = createMockPI();
	const callOrder: number[] = [];

	// Register two handlers for the same event
	pi.on("test_event", async () => {
		callOrder.push(1);
		return "first";
	});
	pi.on("test_event", async () => {
		callOrder.push(2);
		return "second";
	});

	const result = await pi.fire("test_event", {});

	assert.deepEqual(callOrder, [1, 2], "Both handlers should be called");
	assert.equal(result, "second", "Should return last non-undefined result");
});

// ─── BUG-4 regression: no notification noise on session restore ─────────────

test("model_select suppresses 'Native search active' notification on session restore", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "restore", // session restore, not user action
	});

	const nativeNotif = pi.notifications.find((n) =>
		n.message.includes("Native Anthropic web search active"),
	);
	assert.equal(
		nativeNotif,
		undefined,
		"Should NOT show 'Native search active' on session restore",
	);
});

test("model_select DOES show notification on explicit user set", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	const nativeNotif = pi.notifications.find((n) =>
		n.message.includes("Native Anthropic web search active"),
	);
	assert.ok(nativeNotif, "Should show notification on explicit 'set' source");
});

// ─── Session-level search budget (#1309) ────────────────────────────────────

test("session search budget: max_uses decreases as history accumulates search results", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	// Simulate a conversation with 10 web_search_tool_result blocks in history
	const messages: any[] = [
		{ role: "user", content: "research this topic" },
		{
			role: "assistant",
			content: [
				{ type: "web_search_tool_result", tool_use_id: "ws1", content: [] },
				{ type: "web_search_tool_result", tool_use_id: "ws2", content: [] },
				{ type: "web_search_tool_result", tool_use_id: "ws3", content: [] },
				{ type: "web_search_tool_result", tool_use_id: "ws4", content: [] },
				{ type: "web_search_tool_result", tool_use_id: "ws5", content: [] },
				{ type: "text", text: "Here are some results..." },
			],
		},
		{ role: "user", content: "continue" },
		{
			role: "assistant",
			content: [
				{ type: "web_search_tool_result", tool_use_id: "ws6", content: [] },
				{ type: "web_search_tool_result", tool_use_id: "ws7", content: [] },
				{ type: "web_search_tool_result", tool_use_id: "ws8", content: [] },
				{ type: "web_search_tool_result", tool_use_id: "ws9", content: [] },
				{ type: "web_search_tool_result", tool_use_id: "ws10", content: [] },
				{ type: "text", text: "More results..." },
			],
		},
		{ role: "user", content: "keep going" },
	];

	const payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
		messages,
	};

	const result = await pi.fire("before_provider_request", {
		type: "before_provider_request",
		payload,
	});

	const tools = ((result as any)?.tools ?? payload.tools) as any[];
	const nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
	assert.ok(nativeTool, "Should still inject web_search when budget remaining");
	// 15 - 10 = 5 remaining, min(5, 5) = 5
	assert.equal(nativeTool.max_uses, 5, "Should cap at min(5, remaining)");
});

test("session search budget: reduces max_uses when close to limit", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	// 13 search results in history → only 2 remaining
	const searchBlocks = Array.from({ length: 13 }, (_, i) => ({
		type: "web_search_tool_result",
		tool_use_id: `ws${i}`,
		content: [],
	}));

	const messages: any[] = [
		{ role: "user", content: "research" },
		{
			role: "assistant",
			content: [...searchBlocks, { type: "text", text: "results" }],
		},
		{ role: "user", content: "more" },
	];

	const payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
		messages,
	};

	const result = await pi.fire("before_provider_request", {
		type: "before_provider_request",
		payload,
	});

	const tools = ((result as any)?.tools ?? payload.tools) as any[];
	const nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
	assert.ok(nativeTool, "Should still inject when budget > 0");
	// 15 - 13 = 2 remaining
	assert.equal(
		nativeTool.max_uses,
		2,
		"Should reduce max_uses to remaining budget",
	);
});

test("session search budget: omits web_search tool when budget exhausted", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	// 15+ search results in history → budget exhausted
	const searchBlocks = Array.from(
		{ length: MAX_NATIVE_SEARCHES_PER_SESSION },
		(_, i) => ({
			type: "web_search_tool_result",
			tool_use_id: `ws${i}`,
			content: [],
		}),
	);

	const messages: any[] = [
		{ role: "user", content: "research" },
		{
			role: "assistant",
			content: [...searchBlocks, { type: "text", text: "results" }],
		},
		{ role: "user", content: "more" },
	];

	const payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
		messages,
	};

	const result = await pi.fire("before_provider_request", {
		type: "before_provider_request",
		payload,
	});

	const tools = ((result as any)?.tools ?? payload.tools) as any[];
	const nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
	assert.equal(
		nativeTool,
		undefined,
		"Should NOT inject web_search when budget exhausted (#1309)",
	);
	// Other tools should remain
	assert.ok(
		tools.some((t: any) => t.name === "bash"),
		"Non-search tools should remain",
	);
});

test("session search budget: resets on session_start", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	// First session: exhaust budget
	const searchBlocks = Array.from(
		{ length: MAX_NATIVE_SEARCHES_PER_SESSION },
		(_, i) => ({
			type: "web_search_tool_result",
			tool_use_id: `ws${i}`,
			content: [],
		}),
	);

	let payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
		messages: [
			{ role: "user", content: "research" },
			{ role: "assistant", content: [...searchBlocks] },
			{ role: "user", content: "more" },
		],
	};

	await pi.fire("before_provider_request", {
		type: "before_provider_request",
		payload,
	});
	let tools = payload.tools as any[];
	assert.ok(
		!tools.some((t: any) => t.type === "web_search_20250305"),
		"Budget should be exhausted",
	);

	// New session starts — counter resets
	await pi.fire("session_start", { type: "session_start" });

	// New request with no history — full budget available
	payload = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
		messages: [{ role: "user", content: "new research" }],
	};

	const result = await pi.fire("before_provider_request", {
		type: "before_provider_request",
		payload,
	});
	tools = ((result as any)?.tools ?? payload.tools) as any[];
	const nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
	assert.ok(nativeTool, "Should inject web_search after session reset");
	assert.equal(
		nativeTool.max_uses,
		5,
		"Should have full per-turn budget after reset",
	);
});

test("MAX_NATIVE_SEARCHES_PER_SESSION is exported and equals 15", () => {
	assert.equal(
		MAX_NATIVE_SEARCHES_PER_SESSION,
		15,
		"Session budget should be 15 (#1309)",
	);
});

test("session search budget: survives context compaction (high-water mark)", async () => {
	const pi = createMockPI();
	registerNativeSearchHooks(pi);

	await pi.fire("model_select", {
		type: "model_select",
		model: { provider: "anthropic", name: "claude-sonnet-4-6" },
		previousModel: undefined,
		source: "set",
	});

	// First request: history has 12 web_search_tool_result blocks
	const searchBlocks = Array.from({ length: 12 }, (_, i) => ({
		type: "web_search_tool_result",
		tool_use_id: `ws${i}`,
		content: [],
	}));

	let payload: Record<string, unknown> = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
		messages: [
			{
				role: "user",
				content: [{ type: "text", text: "search" }, ...searchBlocks],
			},
		],
	};

	await pi.fire("before_provider_request", {
		type: "before_provider_request",
		payload,
	});
	let tools = payload.tools as any[];
	let nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
	assert.ok(nativeTool, "Should still inject web_search with 12/15 used");
	assert.equal(nativeTool.max_uses, 3, "Should have 3 remaining (15 - 12)");

	// Second request: context was compacted — search blocks gone from history.
	// Without high-water mark, the budget would reset to 15.
	payload = {
		model: "claude-sonnet-4-6-20250514",
		tools: [{ name: "bash", type: "custom" }],
		messages: [
			{ role: "user", content: "compacted context — no search blocks" },
		],
	};

	await pi.fire("before_provider_request", {
		type: "before_provider_request",
		payload,
	});
	tools = payload.tools as any[];
	nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
	assert.ok(
		nativeTool,
		"Should still inject web_search with 12/15 used (high-water mark)",
	);
	assert.equal(
		nativeTool.max_uses,
		3,
		"High-water mark should preserve 12 — only 3 remaining",
	);
});

// ─── stripThinkingFromHistory tests ─────────────────────────────────────────

test("stripThinkingFromHistory removes thinking from earlier assistant messages", () => {
	const messages: any[] = [
		{ role: "user", content: "hello" },
		{
			role: "assistant",
			content: [
				{ type: "thinking", thinking: "hmm", signature: "sig1" },
				{ type: "text", text: "Hi there" },
			],
		},
		{ role: "user", content: "search something" },
	];

	stripThinkingFromHistory(messages);

	// First assistant message (not latest) — thinking stripped
	assert.equal(messages[1].content.length, 1);
	assert.equal(messages[1].content[0].type, "text");
});

test("stripThinkingFromHistory strips thinking from all assistant messages", () => {
	const messages: any[] = [
		{ role: "user", content: "hello" },
		{
			role: "assistant",
			content: [
				{ type: "thinking", thinking: "first thought", signature: "sig1" },
				{ type: "text", text: "response 1" },
			],
		},
		{ role: "user", content: "follow up" },
		{
			role: "assistant",
			content: [
				{ type: "thinking", thinking: "second thought", signature: "sig2" },
				{ type: "text", text: "response 2" },
			],
		},
		{ role: "user", content: "another question" },
	];

	stripThinkingFromHistory(messages);

	// Both assistant messages — thinking stripped
	assert.equal(messages[1].content.length, 1);
	assert.equal(messages[1].content[0].type, "text");

	assert.equal(messages[3].content.length, 1);
	assert.equal(messages[3].content[0].type, "text");
});

test("stripThinkingFromHistory removes redacted_thinking too", () => {
	const messages: any[] = [
		{ role: "user", content: "hello" },
		{
			role: "assistant",
			content: [
				{ type: "redacted_thinking", data: "opaque" },
				{ type: "text", text: "response" },
			],
		},
		{ role: "user", content: "next" },
	];

	stripThinkingFromHistory(messages);

	assert.equal(messages[1].content.length, 1);
	assert.equal(messages[1].content[0].type, "text");
});

test("stripThinkingFromHistory strips even single assistant message", () => {
	const messages: any[] = [
		{ role: "user", content: "hello" },
		{
			role: "assistant",
			content: [
				{ type: "thinking", thinking: "thought", signature: "sig" },
				{ type: "text", text: "response" },
			],
		},
		{ role: "user", content: "follow up" },
	];

	stripThinkingFromHistory(messages);

	// Thinking stripped — all assistant messages are from stored history
	assert.equal(messages[1].content.length, 1);
	assert.equal(messages[1].content[0].type, "text");
});

test("stripThinkingFromHistory handles no assistant messages", () => {
	const messages: any[] = [{ role: "user", content: "hello" }];

	// Should not throw
	stripThinkingFromHistory(messages);
	assert.equal(messages.length, 1);
});

test("stripThinkingFromHistory handles string content (no array)", () => {
	const messages: any[] = [
		{ role: "user", content: "hello" },
		{ role: "assistant", content: "just a string" },
		{ role: "user", content: "next" },
	];

	// Should not throw — string content is skipped
	stripThinkingFromHistory(messages);
	assert.equal(messages[1].content, "just a string");
});

// ─── Minimax search tests (R115) ────────────────────────────────────────────

test("getMiniMaxSearchApiKey returns MINIMAX_CODE_PLAN_KEY when set", async (_t) => {
	const original = process.env.MINIMAX_CODE_PLAN_KEY;
	const original2 = process.env.MINIMAX_CODING_API_KEY;
	const original3 = process.env.MINIMAX_API_KEY;
	afterEach(() => {
		process.env.MINIMAX_CODE_PLAN_KEY = original;
		process.env.MINIMAX_CODING_API_KEY = original2;
		process.env.MINIMAX_API_KEY = original3;
	});

	process.env.MINIMAX_CODE_PLAN_KEY = "code-plan-key";
	process.env.MINIMAX_CODING_API_KEY = "coding-key";
	process.env.MINIMAX_API_KEY = "api-key";

	// Should return first in priority order
	assert.equal(
		getMiniMaxSearchApiKey(),
		"code-plan-key",
		"Should return MINIMAX_CODE_PLAN_KEY (highest priority)",
	);
});

test("getMiniMaxSearchApiKey falls back to MINIMAX_CODING_API_KEY", async (_t) => {
	const original = process.env.MINIMAX_CODE_PLAN_KEY;
	const original2 = process.env.MINIMAX_CODING_API_KEY;
	const original3 = process.env.MINIMAX_API_KEY;
	afterEach(() => {
		process.env.MINIMAX_CODE_PLAN_KEY = original;
		process.env.MINIMAX_CODING_API_KEY = original2;
		process.env.MINIMAX_API_KEY = original3;
	});

	delete process.env.MINIMAX_CODE_PLAN_KEY;
	process.env.MINIMAX_CODING_API_KEY = "coding-key";
	process.env.MINIMAX_API_KEY = "api-key";

	assert.equal(
		getMiniMaxSearchApiKey(),
		"coding-key",
		"Should return MINIMAX_CODING_API_KEY when CODE_PLAN_KEY is unset",
	);
});

test("getMiniMaxSearchApiKey falls back to MINIMAX_API_KEY", async (_t) => {
	const original = process.env.MINIMAX_CODE_PLAN_KEY;
	const original2 = process.env.MINIMAX_CODING_API_KEY;
	const original3 = process.env.MINIMAX_API_KEY;
	afterEach(() => {
		process.env.MINIMAX_CODE_PLAN_KEY = original;
		process.env.MINIMAX_CODING_API_KEY = original2;
		process.env.MINIMAX_API_KEY = original3;
	});

	delete process.env.MINIMAX_CODE_PLAN_KEY;
	delete process.env.MINIMAX_CODING_API_KEY;
	process.env.MINIMAX_API_KEY = "api-key";

	assert.equal(
		getMiniMaxSearchApiKey(),
		"api-key",
		"Should return MINIMAX_API_KEY when higher priority keys are unset",
	);
});

test("getMiniMaxSearchApiKey returns empty string when no keys set", async (_t) => {
	const original = process.env.MINIMAX_CODE_PLAN_KEY;
	const original2 = process.env.MINIMAX_CODING_API_KEY;
	const original3 = process.env.MINIMAX_API_KEY;
	afterEach(() => {
		process.env.MINIMAX_CODE_PLAN_KEY = original;
		process.env.MINIMAX_CODING_API_KEY = original2;
		process.env.MINIMAX_API_KEY = original3;
	});

	delete process.env.MINIMAX_CODE_PLAN_KEY;
	delete process.env.MINIMAX_CODING_API_KEY;
	delete process.env.MINIMAX_API_KEY;

	assert.equal(
		getMiniMaxSearchApiKey(),
		"",
		"Should return empty string when no Minimax keys are set",
	);
});

test("resolveSearchProvider returns minimax when MINIMAX_API_KEY is set and preference is auto", async (_t) => {
	const original = process.env.MINIMAX_CODE_PLAN_KEY;
	const original2 = process.env.MINIMAX_CODING_API_KEY;
	const original3 = process.env.MINIMAX_API_KEY;
	const originalTavily = process.env.TAVILY_API_KEY;
	afterEach(() => {
		process.env.MINIMAX_CODE_PLAN_KEY = original;
		process.env.MINIMAX_CODING_API_KEY = original2;
		process.env.MINIMAX_API_KEY = original3;
		process.env.TAVILY_API_KEY = originalTavily;
	});

	delete process.env.MINIMAX_CODE_PLAN_KEY;
	delete process.env.MINIMAX_CODING_API_KEY;
	delete process.env.TAVILY_API_KEY;
	process.env.MINIMAX_API_KEY = "test-minimax-key";

	// With no Tavily key, minimax should be selected in autonomous mode
	const result = resolveSearchProvider();
	assert.equal(
		result,
		"minimax",
		"Should return minimax when Minimax key exists and Tavily does not",
	);
});

test("resolveSearchProvider prefers tavily over minimax in autonomous mode", async (_t) => {
	const original = process.env.TAVILY_API_KEY;
	const original2 = process.env.MINIMAX_API_KEY;
	afterEach(() => {
		process.env.TAVILY_API_KEY = original;
		process.env.MINIMAX_API_KEY = original2;
	});

	process.env.TAVILY_API_KEY = "test-tavily-key";
	process.env.MINIMAX_API_KEY = "test-minimax-key";

	// In auto mode (no explicit preference), tavily wins by registry order.
	const result = resolveSearchProvider("auto");
	assert.equal(
		result,
		"tavily",
		"Should prefer tavily over minimax in autonomous mode",
	);
});

test("resolveSearchProvider with explicit minimax preference returns minimax when key exists", async (_t) => {
	const original = process.env.MINIMAX_API_KEY;
	const originalTavily = process.env.TAVILY_API_KEY;
	afterEach(() => {
		process.env.MINIMAX_API_KEY = original;
		process.env.TAVILY_API_KEY = originalTavily;
	});

	process.env.MINIMAX_API_KEY = "test-minimax-key";
	delete process.env.TAVILY_API_KEY;

	const result = resolveSearchProvider("minimax");
	assert.equal(
		result,
		"minimax",
		"Should return minimax when explicitly preferred and key exists",
	);
});

test("resolveSearchProvider minimax preference falls back when key missing", async (_t) => {
	const original = process.env.MINIMAX_API_KEY;
	const originalCP = process.env.MINIMAX_CODE_PLAN_KEY;
	const originalCA = process.env.MINIMAX_CODING_API_KEY;
	const originalTavily = process.env.TAVILY_API_KEY;
	const originalBrave = process.env.BRAVE_API_KEY;
	afterEach(() => {
		process.env.MINIMAX_API_KEY = original;
		process.env.MINIMAX_CODE_PLAN_KEY = originalCP;
		process.env.MINIMAX_CODING_API_KEY = originalCA;
		process.env.TAVILY_API_KEY = originalTavily;
		process.env.BRAVE_API_KEY = originalBrave;
	});

	delete process.env.MINIMAX_API_KEY;
	delete process.env.MINIMAX_CODE_PLAN_KEY;
	delete process.env.MINIMAX_CODING_API_KEY;
	delete process.env.TAVILY_API_KEY;
	process.env.BRAVE_API_KEY = "test-brave-key";

	// With explicit minimax preference but no key, should fall back to brave
	const result = resolveSearchProvider("minimax");
	assert.equal(
		result,
		"brave",
		"Should fall back to brave when minimax preference is set but key is missing",
	);
});

test("resolveSearchProvider returns null when no keys set", async (_t) => {
	const keys = [
		"TAVILY_API_KEY",
		"MINIMAX_API_KEY",
		"MINIMAX_CODE_PLAN_KEY",
		"MINIMAX_CODING_API_KEY",
		"BRAVE_API_KEY",
		"SERPER_API_KEY",
		"EXA_API_KEY",
		"OLLAMA_API_KEY",
	];
	const originals: Record<string, string | undefined> = {};
	for (const k of keys) {
		originals[k] = process.env[k];
		delete process.env[k];
	}
	afterEach(() => {
		for (const k of keys) {
			if (originals[k] !== undefined) process.env[k] = originals[k];
			else delete process.env[k];
		}
	});

	const result = resolveSearchProvider();
	assert.equal(
		result,
		null,
		"Should return null when no search provider keys are set",
	);
});
