/**
 * search-the-web tool — Rich web search with Tavily, MiniMax, Ollama, Serper, Exa, and legacy Brave support.
 *
 * v3 improvements:
 * - Structured error taxonomy (auth_error, rate_limited, network_error, etc.)
 * - Spellcheck/query correction surfacing
 * - Latency tracking in details
 * - more_results_available from Brave response
 * - Adaptive snippet budget (fewer results = more snippets each)
 * - Rate limit info in details
 */
import { Type } from "@sinclair/typebox";
import { StringEnum } from "@singularity-forge/ai";
import {
	DEFAULT_MAX_BYTES,
	DEFAULT_MAX_LINES,
	formatSize,
	truncateHead,
} from "@singularity-forge/coding-agent";
import { Text } from "@singularity-forge/tui";
import { duplicateSearchSignal } from "../shared/loop-signals.js";
import { recordRetrievalEvidence } from "../sf/retrieval-evidence.js";
import { LRUTTLCache } from "./cache.js";
import { formatSearchResults } from "./format.js";
import { classifyError, fetchWithRetry, fetchWithRetryTimed } from "./http.js";
import {
	braveHeaders,
	getBraveApiKey,
	getExaApiKey,
	getMiniMaxSearchApiKey,
	getOllamaApiKey,
	getSerperApiKey,
	getTavilyApiKey,
	resolveSearchProvider,
} from "./provider.js";
import { mapFreshnessToTavily, normalizeTavilyResult } from "./tavily.js";
import { detectFreshness, normalizeQuery, toDedupeKey } from "./url-utils.js";

// =============================================================================
// Caches
// =============================================================================
// Search results: max 100 entries, 10-minute TTL
const searchCache = new LRUTTLCache({
	max: 100,
	ttlMs: 600_000,
});
searchCache.startPurgeInterval(60_000);
// Consecutive duplicate search guard (#949)
// Tracks recent query keys to detect and break search loops.
const MAX_CONSECUTIVE_DUPES = 1;
let lastSearchKey = "";
let consecutiveDupeCount = 0;
// Session-level total search budget (all queries, not just duplicates).
// Prevents unbounded search accumulation across varied queries.
const MAX_SEARCHES_PER_SESSION = 15;
let sessionTotalSearches = 0;
/** Reset session-scoped search guard state (both duplicate and budget). */
export function resetSearchLoopGuardState() {
	lastSearchKey = "";
	consecutiveDupeCount = 0;
	sessionTotalSearches = 0;
}
function summarizeWebResults(results) {
	return results.map((result) => ({
		title: result.title,
		url: result.url,
		age: result.age,
	}));
}
async function recordWebSearchEvidence(projectRoot, entry) {
	await recordRetrievalEvidence(projectRoot, {
		backend: "search-the-web",
		sourceKind: "web",
		freshness: "external-live",
		...entry,
	});
}
// Summarizer responses: max 50 entries, 15-minute TTL
const summarizerCache = new LRUTTLCache({ max: 50, ttlMs: 900_000 });
// =============================================================================
// Brave API helpers
// =============================================================================
/**
 * Normalize a Brave result into our formatted result type.
 */
function normalizeBraveResult(r) {
	return {
		title: r.title || "(untitled)",
		url: r.url,
		description: r.description || "",
		age: r.age || r.page_age || undefined,
		extra_snippets: r.extra_snippets || undefined,
	};
}
/**
 * Deduplicate results by URL (first occurrence wins).
 */
function deduplicateResults(results) {
	const seen = new Map();
	for (const result of results) {
		const key = toDedupeKey(result.url);
		if (key !== null && !seen.has(key)) {
			seen.set(key, result);
		}
	}
	return Array.from(seen.values());
}
function freshnessToExaStartPublishedDate(freshness) {
	if (!freshness) return undefined;
	const now = Date.now();
	const offsets = {
		pd: 24 * 60 * 60 * 1000,
		pw: 7 * 24 * 60 * 60 * 1000,
		pm: 30 * 24 * 60 * 60 * 1000,
		py: 365 * 24 * 60 * 60 * 1000,
	};
	const offset = offsets[freshness];
	return offset ? new Date(now - offset).toISOString() : undefined;
}
/**
 * Fetch AI summary from Brave Summarizer API (best-effort, free).
 */
async function fetchSummary(summarizerKey, signal) {
	const cached = summarizerCache.get(summarizerKey);
	if (cached !== undefined) return cached;
	try {
		const url = `https://api.search.brave.com/res/v1/summarizer/search?key=${encodeURIComponent(summarizerKey)}&entity_info=false`;
		const response = await fetchWithRetry(
			url,
			{
				method: "GET",
				headers: braveHeaders(),
				signal,
			},
			1,
		);
		const data = await response.json();
		let summaryText = "";
		if (data.summary && Array.isArray(data.summary)) {
			summaryText = data.summary
				.filter((s) => s.type === "token" || s.type === "text")
				.map((s) => s.data)
				.join("");
		}
		if (summaryText) {
			summarizerCache.set(summarizerKey, summaryText);
			return summaryText;
		}
		return null;
	} catch {
		return null;
	}
}
// =============================================================================
// Tavily API execution
// =============================================================================
/**
 * Execute a search against the Tavily API.
 * Returns a CachedSearchResult with normalized, deduplicated results.
 */
async function executeTavilySearch(params, signal) {
	const requestBody = {
		query: params.query,
		max_results: 10,
		search_depth: "basic",
	};
	const tavilyTimeRange = mapFreshnessToTavily(params.freshness);
	if (tavilyTimeRange) {
		requestBody.time_range = tavilyTimeRange;
	}
	if (params.domain) {
		requestBody.include_domains = [params.domain];
	}
	if (params.wantSummary) {
		requestBody.include_answer = true;
	}
	const timed = await fetchWithRetryTimed(
		"https://api.tavily.com/search",
		{
			method: "POST",
			headers: {
				"Content-Type": "application/json",
				Authorization: `Bearer ${getTavilyApiKey()}`,
			},
			body: JSON.stringify(requestBody),
			signal,
		},
		2,
	);
	const data = await timed.response.json();
	const normalized = data.results.map(normalizeTavilyResult);
	const deduplicated = deduplicateResults(normalized);
	return {
		results: {
			results: deduplicated,
			summaryText: data.answer || undefined,
			queryCorrected: false,
			moreResultsAvailable: false,
		},
		latencyMs: timed.latencyMs,
		rateLimit: timed.rateLimit,
	};
}
/**
 * Execute a search against the Ollama web_search API.
 * Returns a CachedSearchResult with normalized, deduplicated results.
 */
async function executeOllamaSearch(params, signal) {
	const timed = await fetchWithRetryTimed(
		"https://ollama.com/api/web_search",
		{
			method: "POST",
			headers: {
				"Content-Type": "application/json",
				Authorization: `Bearer ${getOllamaApiKey()}`,
			},
			body: JSON.stringify({ query: params.query, max_results: params.count }),
			signal,
		},
		2,
	);
	const data = await timed.response.json();
	const normalized = (data.results || []).map((r) => ({
		title: r.title || "(untitled)",
		url: r.url,
		description: r.content || "",
	}));
	const deduplicated = deduplicateResults(normalized);
	return {
		results: {
			results: deduplicated,
			queryCorrected: false,
			moreResultsAvailable: false,
		},
		latencyMs: timed.latencyMs,
		rateLimit: timed.rateLimit,
	};
}
/**
 * Execute a search against the MiniMax Coding Plan search API.
 */
async function executeMiniMaxSearch(params, signal) {
	const timed = await fetchWithRetryTimed(
		"https://api.minimax.io/v1/coding_plan/search",
		{
			method: "POST",
			headers: {
				"Content-Type": "application/json",
				Authorization: `Bearer ${getMiniMaxSearchApiKey()}`,
				"MM-API-Source": "SF",
			},
			body: JSON.stringify({ q: params.query }),
			signal,
		},
		2,
	);
	const data = await timed.response.json();
	if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
		throw new Error(
			`MiniMax search failed: ${data.base_resp.status_msg ?? data.base_resp.status_code}`,
		);
	}
	const normalized = (data.organic || [])
		.filter((r) => typeof r.link === "string" && r.link.length > 0)
		.map((r) => ({
			title: r.title || "(untitled)",
			url: r.link,
			description: r.snippet || "",
			age: r.date || undefined,
		}));
	const deduplicated = deduplicateResults(normalized);
	return {
		results: {
			results: deduplicated,
			queryCorrected: false,
			moreResultsAvailable: (data.related_searches?.length ?? 0) > 0,
		},
		latencyMs: timed.latencyMs,
		rateLimit: timed.rateLimit,
	};
}
async function executeSerperSearch(params, signal) {
	const query = params.domain
		? `site:${params.domain} ${params.query}`
		: params.query;
	const timed = await fetchWithRetryTimed(
		"https://google.serper.dev/search",
		{
			method: "POST",
			headers: {
				"Content-Type": "application/json",
				"X-API-KEY": getSerperApiKey(),
			},
			body: JSON.stringify({
				q: query,
				num: Math.max(1, Math.min(10, params.count)),
			}),
			signal,
		},
		2,
	);
	const data = await timed.response.json();
	const normalized = (data.organic || [])
		.map((r) => ({
			title: r.title || "(untitled)",
			url: r.link || "",
			description: r.snippet || "",
			age: r.date || undefined,
		}))
		.filter((r) => r.url.length > 0);
	const deduplicated = deduplicateResults(normalized);
	return {
		results: {
			results: deduplicated,
			queryCorrected: false,
			moreResultsAvailable: false,
		},
		latencyMs: timed.latencyMs,
		rateLimit: timed.rateLimit,
	};
}
async function executeExaSearch(params, signal) {
	const requestBody = {
		query: params.query,
		numResults: Math.max(1, Math.min(10, params.count)),
	};
	if (params.domain) {
		requestBody.includeDomains = [params.domain];
	}
	const startPublishedDate = freshnessToExaStartPublishedDate(params.freshness);
	if (startPublishedDate) {
		requestBody.startPublishedDate = startPublishedDate;
	}
	if (params.wantSummary) {
		requestBody.contents = {
			summary: {
				query: params.query,
			},
		};
	}
	const timed = await fetchWithRetryTimed(
		"https://api.exa.ai/search",
		{
			method: "POST",
			headers: {
				"Content-Type": "application/json",
				"x-api-key": getExaApiKey(),
			},
			body: JSON.stringify(requestBody),
			signal,
		},
		2,
	);
	const data = await timed.response.json();
	const normalized = (data.results || [])
		.map((r) => ({
			title: r.title || "(untitled)",
			url: r.url || "",
			description: r.summary || r.text || "",
			age: r.publishedDate || undefined,
		}))
		.filter((r) => r.url.length > 0);
	const deduplicated = deduplicateResults(normalized);
	const summaryText = params.wantSummary
		? (data.results || [])
				.map((r) => r.summary)
				.filter((value) => typeof value === "string" && value.trim().length > 0)
				.slice(0, 3)
				.join("\n\n") || undefined
		: undefined;
	return {
		results: {
			results: deduplicated,
			summaryText,
			queryCorrected: false,
			moreResultsAvailable: false,
		},
		latencyMs: timed.latencyMs,
		rateLimit: timed.rateLimit,
	};
}
async function executeBraveSearch(params, signal) {
	const url = new URL("https://api.search.brave.com/res/v1/web/search");
	url.searchParams.append("q", params.effectiveQuery);
	url.searchParams.append("count", "10");
	url.searchParams.append("extra_snippets", "true");
	url.searchParams.append("text_decorations", "false");
	if (params.freshness) {
		url.searchParams.append("freshness", params.freshness);
	}
	if (params.wantSummary) {
		url.searchParams.append("summary", "1");
	}
	const timed = await fetchWithRetryTimed(
		url.toString(),
		{
			method: "GET",
			headers: braveHeaders(),
			signal,
		},
		2,
	);
	const data = await timed.response.json();
	const rawResults = data.web?.results ?? [];
	const summarizerKey = data.summarizer?.key;
	const queryInfo = data.query;
	const queryCorrected = !!(
		queryInfo?.altered && queryInfo.altered !== queryInfo.original
	);
	const originalQuery = queryCorrected
		? (queryInfo?.original ?? params.query)
		: undefined;
	const correctedQuery = queryCorrected ? queryInfo?.altered : undefined;
	const moreResultsAvailable = queryInfo?.more_results_available ?? false;
	const normalized = rawResults.map(normalizeBraveResult);
	const deduplicated = deduplicateResults(normalized);
	return {
		results: {
			results: deduplicated,
			summarizerKey,
			queryCorrected,
			originalQuery,
			correctedQuery,
			moreResultsAvailable,
		},
		latencyMs: timed.latencyMs,
		rateLimit: timed.rateLimit,
	};
}
function availableComboProviders() {
	const providers = [];
	if (getTavilyApiKey()) providers.push("tavily");
	if (getMiniMaxSearchApiKey()) providers.push("minimax");
	if (getBraveApiKey()) providers.push("brave");
	if (getSerperApiKey()) providers.push("serper");
	if (getExaApiKey()) providers.push("exa");
	if (getOllamaApiKey()) providers.push("ollama");
	return providers;
}
async function executeComboSearch(params, signal) {
	const providers = availableComboProviders();
	const tasks = providers.map(async (provider) => {
		if (provider === "tavily") {
			return executeTavilySearch(
				{
					query: params.query,
					freshness: params.freshness,
					domain: params.domain,
					wantSummary: params.wantSummary,
				},
				signal,
			);
		}
		if (provider === "minimax") {
			return executeMiniMaxSearch({ query: params.query }, signal);
		}
		if (provider === "ollama") {
			return executeOllamaSearch(
				{ query: params.query, count: Math.max(10, params.count) },
				signal,
			);
		}
		if (provider === "serper") {
			return executeSerperSearch(
				{
					query: params.query,
					domain: params.domain,
					count: Math.max(10, params.count),
				},
				signal,
			);
		}
		if (provider === "exa") {
			return executeExaSearch(
				{
					query: params.query,
					freshness: params.freshness,
					domain: params.domain,
					wantSummary: params.wantSummary,
					count: Math.max(10, params.count),
				},
				signal,
			);
		}
		let effectiveQuery = params.query;
		if (params.domain && !effectiveQuery.toLowerCase().includes("site:")) {
			effectiveQuery = `site:${params.domain} ${effectiveQuery}`;
		}
		return executeBraveSearch(
			{
				query: params.query,
				effectiveQuery,
				freshness: params.freshness,
				wantSummary: params.wantSummary,
			},
			signal,
		);
	});
	const settled = await Promise.allSettled(tasks);
	const fulfilled = settled.filter((entry) => entry.status === "fulfilled");
	if (fulfilled.length === 0) {
		const firstRejected = settled.find((entry) => entry.status === "rejected");
		throw firstRejected?.reason ?? new Error("combosearch failed");
	}
	const merged = deduplicateResults(
		fulfilled.flatMap((entry) => entry.value.results.results),
	);
	const summaryParts = fulfilled
		.map((entry) => entry.value.results.summaryText)
		.filter((value) => typeof value === "string" && value.trim().length > 0);
	const summarizerKey = fulfilled.find(
		(entry) => entry.value.results.summarizerKey,
	)?.value.results.summarizerKey;
	const latencyMs = Math.max(
		...fulfilled.map((entry) => entry.value.latencyMs),
	);
	const rateLimit = fulfilled.find((entry) => entry.value.rateLimit)?.value
		.rateLimit;
	return {
		results: {
			results: merged,
			summaryText:
				summaryParts.length > 0 ? summaryParts.join("\n\n") : undefined,
			summarizerKey,
			queryCorrected: fulfilled.some(
				(entry) => entry.value.results.queryCorrected,
			),
			originalQuery: fulfilled.find(
				(entry) => entry.value.results.originalQuery,
			)?.value.results.originalQuery,
			correctedQuery: fulfilled.find(
				(entry) => entry.value.results.correctedQuery,
			)?.value.results.correctedQuery,
			moreResultsAvailable: fulfilled.some(
				(entry) => entry.value.results.moreResultsAvailable,
			),
		},
		latencyMs,
		rateLimit,
	};
}
// =============================================================================
// Tool Registration
// =============================================================================
export function registerSearchTool(pi) {
	pi.registerTool({
		name: "search-the-web",
		label: "Web Search",
		description:
			"Search the web using Tavily, MiniMax, Ollama, Serper, Exa, or an existing Brave Search API key. " +
			"Returns top results with titles, URLs, descriptions, " +
			"extra contextual snippets, result ages, and optional AI summary. " +
			"Supports freshness filtering, domain filtering, and auto-detects recency-sensitive queries.",
		promptSnippet: "Search the web for information",
		promptGuidelines: [
			"Use this tool when the user asks about current events, facts, or external knowledge not in the codebase.",
			"Always provide the search query to the user in your response.",
			"Limit to 3-5 results unless more context is needed.",
			"Use freshness='week' or 'month' for queries about recent events, releases, or updates.",
			"Use the fetch_page tool to read the full content of promising URLs from search results.",
		],
		parameters: Type.Object({
			query: Type.String({
				description: "Search query (e.g., 'latest AI news')",
			}),
			count: Type.Optional(
				Type.Number({
					minimum: 1,
					maximum: 10,
					default: 5,
					description: "Number of results to return (default: 5)",
				}),
			),
			freshness: Type.Optional(
				StringEnum(["auto", "day", "week", "month", "year"], {
					description:
						"Filter by recency. 'auto' (default) detects from query. 'day'=past 24h, 'week'=past 7d, 'month'=past 30d, 'year'=past 365d.",
				}),
			),
			domain: Type.Optional(
				Type.String({
					description:
						"Limit results to a specific domain (e.g., 'stackoverflow.com', 'github.com')",
				}),
			),
			summary: Type.Optional(
				Type.Boolean({
					description:
						"Request an AI-generated summary of the search results (default: false). Adds latency but provides a concise answer.",
					default: false,
				}),
			),
		}),
		async execute(_toolCallId, params, signal, onUpdate, _ctx) {
			const projectRoot = process.cwd();
			const startedAt = Date.now();
			if (signal?.aborted) {
				await recordWebSearchEvidence(projectRoot, {
					query: params.query ?? "",
					strategy: "aborted",
					scope: params.domain ?? "",
					status: "aborted",
					hitCount: 0,
					elapsedMs: Date.now() - startedAt,
					error: "Search cancelled",
				});
				return {
					content: [{ type: "text", text: "Search cancelled." }],
					details: undefined,
				};
			}
			// ------------------------------------------------------------------
			// Resolve search provider
			// ------------------------------------------------------------------
			const provider = resolveSearchProvider();
			if (!provider) {
				await recordWebSearchEvidence(projectRoot, {
					query: params.query,
					strategy: "none",
					scope: params.domain ?? "",
					status: "error",
					hitCount: 0,
					elapsedMs: Date.now() - startedAt,
					error: "No search API key set",
				});
				return {
					content: [
						{
							type: "text",
							text: "Web search unavailable: No search API key is set. Use secure_env_collect to set TAVILY_API_KEY, MINIMAX_CODE_PLAN_KEY, BRAVE_API_KEY, SERPER_API_KEY, EXA_API_KEY, or OLLAMA_API_KEY.",
						},
					],
					isError: true,
					details: {
						errorKind: "auth_error",
						error: "No search API key set",
					},
				};
			}
			// ------------------------------------------------------------------
			// Session-level search budget
			// ------------------------------------------------------------------
			if (sessionTotalSearches >= MAX_SEARCHES_PER_SESSION) {
				await recordWebSearchEvidence(projectRoot, {
					query: params.query,
					strategy: provider,
					scope: params.domain ?? "",
					status: "budget_exhausted",
					hitCount: 0,
					elapsedMs: Date.now() - startedAt,
					error: `Session search budget exhausted (${MAX_SEARCHES_PER_SESSION})`,
					result: {
						sessionTotalSearches,
						maxSearches: MAX_SEARCHES_PER_SESSION,
					},
				});
				return {
					content: [
						{
							type: "text",
							text: `⚠️ Search budget exhausted: ${sessionTotalSearches}/${MAX_SEARCHES_PER_SESSION} searches used this session. The information you need should already be in previous search results. Stop searching and use those results to proceed with your task.`,
						},
					],
					isError: true,
					details: {
						errorKind: "budget_exhausted",
						error: `Session search budget exhausted (${MAX_SEARCHES_PER_SESSION})`,
					},
				};
			}
			const count = params.count ?? 5;
			const wantSummary = params.summary ?? false;
			// ------------------------------------------------------------------
			// Resolve freshness (shared — Brave format, converted for Tavily later)
			// ------------------------------------------------------------------
			let freshness = null;
			if (params.freshness && params.freshness !== "auto") {
				const freshnessMap = {
					day: "pd",
					week: "pw",
					month: "pm",
					year: "py",
				};
				freshness = freshnessMap[params.freshness] || null;
			} else {
				freshness = detectFreshness(params.query);
			}
			// ------------------------------------------------------------------
			// Handle domain filter (provider-specific)
			// ------------------------------------------------------------------
			let effectiveQuery = params.query;
			if ((provider === "brave" || provider === "serper") && params.domain) {
				if (!effectiveQuery.toLowerCase().includes("site:")) {
					effectiveQuery = `site:${params.domain} ${effectiveQuery}`;
				}
			}
			// Tavily uses include_domains in request body — no query modification
			// ------------------------------------------------------------------
			// Cache lookup (provider-prefixed key)
			// ------------------------------------------------------------------
			const cacheKey =
				normalizeQuery(effectiveQuery) +
				`|d:${params.domain || ""}|f:${freshness || ""}|s:${wantSummary}|p:${provider}`;
			// ── Consecutive duplicate search guard (#949, #1671) ─────────────────
			// If the LLM keeps calling the same search query, break the loop
			// with an explicit warning instead of returning the same results.
			// After the threshold is hit, do NOT reset the state — this keeps the
			// guard armed so every subsequent duplicate immediately re-triggers it,
			// preventing the "sawtooth" pattern where resetting allowed infinite loops
			// with brief interruptions every MAX_CONSECUTIVE_DUPES+1 calls.
			if (cacheKey === lastSearchKey) {
				consecutiveDupeCount++;
				if (consecutiveDupeCount > MAX_CONSECUTIVE_DUPES) {
					const loopSignal = duplicateSearchSignal({
						query: params.query,
						provider,
						domain: params.domain ?? "",
						consecutiveDupeCount,
					});
					await recordWebSearchEvidence(projectRoot, {
						query: params.query,
						strategy: provider,
						scope: params.domain ?? "",
						status: "search_loop",
						hitCount: 0,
						elapsedMs: Date.now() - startedAt,
						error: "Consecutive duplicate search detected",
						result: { consecutiveDupeCount, loopSignal },
					});
					return {
						content: [
							{
								type: "text",
								text: `⚠️ Search loop detected: the query "${params.query}" has been searched ${consecutiveDupeCount} times consecutively with identical results. The information you need is already in the previous search results above. Stop searching and use those results to proceed with your task.`,
							},
						],
						isError: true,
						details: {
							errorKind: "search_loop",
							error: "Consecutive duplicate search detected",
							loopSignal,
						},
					};
				}
			} else {
				lastSearchKey = cacheKey;
				consecutiveDupeCount = 1;
			}
			// Count every search that passes the guards toward the session budget.
			sessionTotalSearches++;
			const cached = searchCache.get(cacheKey);
			if (cached) {
				const limited = cached.results.slice(0, count);
				let summaryText;
				if (wantSummary) {
					if (cached.summaryText) {
						summaryText = cached.summaryText;
					} else if (cached.summarizerKey) {
						summaryText =
							(await fetchSummary(cached.summarizerKey, signal)) ?? undefined;
					}
				}
				const formatOpts = {
					cached: true,
					summary: summaryText,
					queryCorrected: cached.queryCorrected,
					originalQuery: cached.originalQuery,
					correctedQuery: cached.correctedQuery,
					moreResultsAvailable: cached.moreResultsAvailable,
				};
				const output = formatSearchResults(params.query, limited, formatOpts);
				const truncation = truncateHead(output, {
					maxLines: DEFAULT_MAX_LINES,
					maxBytes: DEFAULT_MAX_BYTES,
				});
				let content = truncation.content;
				if (truncation.truncated) {
					const tempFile = await pi.writeTempFile(output, {
						prefix: "web-search-",
					});
					content += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}). Full results: ${tempFile}]`;
				}
				const details = {
					query: params.query,
					effectiveQuery,
					results: limited,
					count: limited.length,
					cached: true,
					freshness: freshness || "none",
					hasSummary: !!summaryText,
					queryCorrected: cached.queryCorrected,
					originalQuery: cached.originalQuery,
					correctedQuery: cached.correctedQuery,
					moreResultsAvailable: cached.moreResultsAvailable,
					provider,
				};
				await recordWebSearchEvidence(projectRoot, {
					query: params.query,
					strategy: provider,
					scope: params.domain ?? "",
					status: "ok",
					hitCount: limited.length,
					elapsedMs: Date.now() - startedAt,
					result: {
						cached: true,
						effectiveQuery,
						freshness: freshness || "none",
						hasSummary: !!summaryText,
						results: summarizeWebResults(limited),
					},
				});
				return { content: [{ type: "text", text: content }], details };
			}
			onUpdate?.({
				content: [{ type: "text", text: `Searching for "${params.query}"...` }],
				details: undefined,
			});
			try {
				// ------------------------------------------------------------------
				// Provider-specific fetch
				// ------------------------------------------------------------------
				let searchResult;
				let latencyMs;
				let rateLimit;
				if (provider === "combosearch") {
					const comboResult = await executeComboSearch(
						{
							query: params.query,
							freshness,
							domain: params.domain,
							wantSummary,
							count,
						},
						signal,
					);
					searchResult = comboResult.results;
					latencyMs = comboResult.latencyMs;
					rateLimit = comboResult.rateLimit;
				} else if (provider === "tavily") {
					const tavilyResult = await executeTavilySearch(
						{
							query: params.query,
							freshness,
							domain: params.domain,
							wantSummary,
						},
						signal,
					);
					searchResult = tavilyResult.results;
					latencyMs = tavilyResult.latencyMs;
					rateLimit = tavilyResult.rateLimit;
				} else if (provider === "ollama") {
					const ollamaResult = await executeOllamaSearch(
						{ query: params.query, count: 10 },
						signal,
					);
					searchResult = ollamaResult.results;
					latencyMs = ollamaResult.latencyMs;
					rateLimit = ollamaResult.rateLimit;
				} else if (provider === "minimax") {
					const minimaxResult = await executeMiniMaxSearch(
						{ query: params.query },
						signal,
					);
					searchResult = minimaxResult.results;
					latencyMs = minimaxResult.latencyMs;
					rateLimit = minimaxResult.rateLimit;
				} else if (provider === "serper") {
					const serperResult = await executeSerperSearch(
						{ query: params.query, domain: params.domain, count: 10 },
						signal,
					);
					searchResult = serperResult.results;
					latencyMs = serperResult.latencyMs;
					rateLimit = serperResult.rateLimit;
				} else if (provider === "exa") {
					const exaResult = await executeExaSearch(
						{
							query: params.query,
							freshness,
							domain: params.domain,
							wantSummary,
							count: 10,
						},
						signal,
					);
					searchResult = exaResult.results;
					latencyMs = exaResult.latencyMs;
					rateLimit = exaResult.rateLimit;
				} else {
					const braveResult = await executeBraveSearch(
						{ query: params.query, effectiveQuery, freshness, wantSummary },
						signal,
					);
					searchResult = braveResult.results;
					latencyMs = braveResult.latencyMs;
					rateLimit = braveResult.rateLimit;
				}
				// ------------------------------------------------------------------
				// Shared post-fetch: cache, summary, format, return
				// ------------------------------------------------------------------
				searchCache.set(cacheKey, searchResult);
				const results = searchResult.results.slice(0, count);
				let summaryText;
				if (wantSummary) {
					if (searchResult.summaryText) {
						summaryText = searchResult.summaryText;
					} else if (searchResult.summarizerKey) {
						summaryText =
							(await fetchSummary(searchResult.summarizerKey, signal)) ??
							undefined;
					}
				}
				const formatOpts = {
					summary: summaryText,
					queryCorrected: searchResult.queryCorrected,
					originalQuery: searchResult.originalQuery,
					correctedQuery: searchResult.correctedQuery,
					moreResultsAvailable: searchResult.moreResultsAvailable,
				};
				const output = formatSearchResults(params.query, results, formatOpts);
				const truncation = truncateHead(output, {
					maxLines: DEFAULT_MAX_LINES,
					maxBytes: DEFAULT_MAX_BYTES,
				});
				let content = truncation.content;
				if (truncation.truncated) {
					const tempFile = await pi.writeTempFile(output, {
						prefix: "web-search-",
					});
					content += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}). Full results: ${tempFile}]`;
				}
				const details = {
					query: params.query,
					effectiveQuery,
					results,
					count: results.length,
					cached: false,
					freshness: freshness || "none",
					hasSummary: !!summaryText,
					latencyMs,
					rateLimit,
					queryCorrected: searchResult.queryCorrected,
					originalQuery: searchResult.originalQuery,
					correctedQuery: searchResult.correctedQuery,
					moreResultsAvailable: searchResult.moreResultsAvailable,
					provider,
				};
				await recordWebSearchEvidence(projectRoot, {
					query: params.query,
					strategy: provider,
					scope: params.domain ?? "",
					status: "ok",
					hitCount: results.length,
					elapsedMs: Date.now() - startedAt,
					result: {
						cached: false,
						effectiveQuery,
						freshness: freshness || "none",
						hasSummary: !!summaryText,
						latencyMs,
						results: summarizeWebResults(results),
					},
				});
				return { content: [{ type: "text", text: content }], details };
			} catch (error) {
				const classified = classifyError(error);
				await recordWebSearchEvidence(projectRoot, {
					query: params.query,
					strategy: provider,
					scope: params.domain ?? "",
					status: "error",
					hitCount: 0,
					elapsedMs: Date.now() - startedAt,
					error: classified.message,
					result: {
						errorKind: classified.kind,
						retryAfterMs: classified.retryAfterMs,
					},
				});
				return {
					content: [
						{ type: "text", text: `Search failed: ${classified.message}` },
					],
					details: {
						errorKind: classified.kind,
						error: classified.message,
						retryAfterMs: classified.retryAfterMs,
						query: params.query,
						provider,
					},
					isError: true,
				};
			}
		},
		renderCall(args, theme) {
			let text = theme.fg("toolTitle", theme.bold("search-the-web "));
			text += theme.fg("muted", `"${args.query}"`);
			const meta = [];
			if (args.count && args.count !== 5) meta.push(`${args.count} results`);
			if (args.freshness && args.freshness !== "auto")
				meta.push(`freshness:${args.freshness}`);
			if (args.domain) meta.push(`site:${args.domain}`);
			if (args.summary) meta.push("+ summary");
			if (meta.length > 0) {
				text += " " + theme.fg("dim", `(${meta.join(", ")})`);
			}
			return new Text(text, 0, 0);
		},
		renderResult(result, { expanded }, theme) {
			const details = result.details;
			if (details?.errorKind || details?.error) {
				const kindTag = details.errorKind
					? theme.fg("dim", ` [${details.errorKind}]`)
					: "";
				return new Text(
					theme.fg("error", `✗ ${details.error ?? "Search failed"}`) + kindTag,
					0,
					0,
				);
			}
			const providerTag = details?.provider
				? theme.fg("dim", ` [${details.provider}]`)
				: "";
			const cacheTag = details?.cached ? theme.fg("dim", " [cached]") : "";
			const freshTag =
				details?.freshness && details.freshness !== "none"
					? theme.fg("dim", ` [${details.freshness}]`)
					: "";
			const summaryTag = details?.hasSummary
				? theme.fg("dim", " [+summary]")
				: "";
			const latencyTag = details?.latencyMs
				? theme.fg("dim", ` ${details.latencyMs}ms`)
				: "";
			const correctedTag = details?.queryCorrected
				? theme.fg("warning", ` [corrected→"${details.correctedQuery}"]`)
				: "";
			let text =
				theme.fg(
					"success",
					`✓ ${details?.count ?? 0} results for "${details?.query}"`,
				) +
				providerTag +
				cacheTag +
				freshTag +
				summaryTag +
				latencyTag +
				correctedTag;
			if (expanded && details?.results) {
				text += "\n\n";
				for (const r of details.results.slice(0, 3)) {
					const age = r.age ? theme.fg("dim", ` (${r.age})`) : "";
					text += `${theme.bold(r.title)}${age}\n${r.url}\n${r.description}\n\n`;
				}
				if (details.results.length > 3) {
					text += theme.fg("dim", `... and ${details.results.length - 3} more`);
				}
			}
			return new Text(text, 0, 0);
		},
	});
}
