import { isAudioResponse, isErrorResponse, isImageResponse, isJSONResponse, isTranscriptionResponse, isVideoResponse } from "./helpers.js";

//#region src/router.ts
function getLastMessageByRole(messages, role) {
	for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === role) return messages[i];
	return null;
}
/**
* Concatenate the text content of every `system` role message in order.
* Hosts that build a system context from multiple sources (persona, agent
* context entries, tool guidance) often emit several system messages in one
* request; this joins SEPARATE system messages with newlines so a substring
* matcher sees the whole context as one body.
*
* Empty handling is symmetric with {@link getTextContent}: a system message
* with no extractable text (`null`) contributes nothing, while a message that
* extracts to an empty string is a present-but-empty body. We skip only the
* `null` (no-text) case so a genuinely empty system message does not inject a
* stray newline; this matches getTextContent treating "no text" and "empty
* text" consistently.
*/
function getSystemText(messages) {
	const parts = [];
	for (const m of messages) {
		if (m.role !== "system") continue;
		const text = getTextContent(m.content);
		if (text === null) continue;
		parts.push(text);
	}
	return parts.join("\n");
}
/**
* Extract the text content from a message's content field.
* Handles both plain string content and array-of-parts content
* (e.g. `[{type: "text", text: "..."}]` as sent by some SDKs).
*
* Multi-part text is joined with `""` (the parts form one logical body split
* across segments). Empty handling is symmetric with the string path: a string
* `""` returns `""`, and an array containing at least one text part whose
* combined text is empty likewise returns `""` (NOT `null`). `null` is reserved
* for "no text content at all" — null content, or an array with no text parts —
* so callers can distinguish "absent" from "present but empty" the same way for
* both content shapes.
*/
function getTextContent(content) {
	if (typeof content === "string") return content;
	if (Array.isArray(content)) {
		const texts = content.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text);
		return texts.length > 0 ? texts.join("") : null;
	}
	return null;
}
/**
* Process-level opt-out: when `AIMOCK_STRICT_TURN_INDEX=1` (or `true`) is set,
* REPLAY selection restores the legacy hard turnIndex gate — a content-matching
* fixture whose `turnIndex` is defined and `!== assistantCount` is rejected,
* reproducing origin/main semantics. Follows the `AIMOCK_ALLOW_PRIVATE_URLS`
* precedent for parsing/precedence. Read per-call (not cached) so tests can flip
* it. Does NOT affect the record path, which is already strict regardless.
*/
function strictTurnIndexEnv() {
	const v = process.env.AIMOCK_STRICT_TURN_INDEX;
	return v === "1" || v === "true";
}
/**
* Process-level set of fixtures for which the relaxed-turnIndex divergence
* warning has already fired, so each divergent fixture warns at most ONCE per
* process (throttle). Keyed by the selected fixture's OBJECT IDENTITY: the
* `Fixture` references in the server's fixtures array are stable across replays
* (the array is held by reference and only fully replaced on a fixtures reset),
* so identity uniquely distinguishes divergent fixtures and warns each exactly
* once. A `WeakSet` was chosen over the previous `JSON.stringify(match)` key for
* two reasons: (1) stringifying the match DROPS `predicate` functions and
* serialises any RegExp matcher to `{}`, so two distinct fixtures differing only
* by a predicate/regex collided to one key and the second's warning was silently
* suppressed; and (2) a string `Set` only grows, accumulating an entry per
* divergent shape on a long-lived server, whereas a `WeakSet` auto-evicts when a
* fixture object is released (e.g. after a fixtures reset drops the references).
* `let` because `WeakSet` has no `.clear()`, so the test hook reassigns a fresh
* one.
*/
let warnedRelaxedFixtures = /* @__PURE__ */ new WeakSet();
/**
* Build the {@link MatchOptions} a request handler must pass to
* {@link matchFixtureDiagnostic} / {@link matchFixture}, derived from whether
* the handler is about to record on a miss.
*
* EVERY record-capable handler (OpenAI chat, Anthropic messages, Responses,
* Gemini, Bedrock, Bedrock-Converse, Cohere, Ollama, …) must build its match
* options through THIS helper rather than hand-rolling `{ strictTurnIndex }` at
* the call site. Recording proxies upstream on a miss to capture a fresh turn;
* if `strictTurnIndex` is left false during recording, an earlier-turn fixture
* can content-shadow a longer request, the `if (!fixture)` record branch never
* fires, and the new turn is SILENTLY never recorded. Funnelling the decision
* through one helper makes that wiring impossible for a future handler to miss:
* pass `recording = true` whenever the handler's own record gate is satisfied
* (i.e. it will call `proxyAndRecord` on a miss), `false` otherwise.
*/
function recordMatchOptions(recording, logger) {
	return {
		strictTurnIndex: recording,
		logger
	};
}
/**
* Match a fixture against a request, additionally reporting WHY a `null` result
* occurred. Shares the exact predicate loop with {@link matchFixture}; a fixture
* that passes every shape predicate but fails ONLY the sequenceIndex or
* turnIndex gate increments `skippedBySequenceOrTurn`. {@link matchFixture} is a
* thin wrapper that returns the `.fixture` field, so existing callers are
* unaffected.
*/
function matchFixtureDiagnostic(fixtures, req, matchCounts, requestTransform, options) {
	const effective = requestTransform ? requestTransform(req) : req;
	const useExactMatch = !!requestTransform;
	const strictTurnIndex = (options?.strictTurnIndex ?? false) || strictTurnIndexEnv();
	let skippedBySequenceOrTurn = 0;
	const contentMatches = [];
	for (const fixture of fixtures) {
		const { match } = fixture;
		if (match.predicate !== void 0) {
			if (!match.predicate(req)) continue;
		}
		const reqEndpoint = effective._endpointType;
		if (match.endpoint !== void 0) {
			if (match.endpoint !== reqEndpoint) continue;
		} else if (reqEndpoint && reqEndpoint !== "chat" && reqEndpoint !== "embedding" && !reqEndpoint.startsWith("realtime")) {
			const r = fixture.response;
			if (typeof r !== "function") {
				if (!(reqEndpoint === "image" && isImageResponse(r) || reqEndpoint === "speech" && isAudioResponse(r) || reqEndpoint === "elevenlabs-tts" && isAudioResponse(r) || reqEndpoint === "audio-gen" && isAudioResponse(r) || reqEndpoint === "fal-audio" && isAudioResponse(r) || reqEndpoint === "fal" && (isJSONResponse(r) || isErrorResponse(r)) || reqEndpoint === "transcription" && isTranscriptionResponse(r) || reqEndpoint === "translation" && isTranscriptionResponse(r) || reqEndpoint === "video" && isVideoResponse(r))) continue;
			}
		}
		if (match.context !== void 0) {
			if (effective._context !== match.context) continue;
		}
		if (match.userMessage !== void 0) {
			const msg = getLastMessageByRole(effective.messages, "user");
			const text = msg ? getTextContent(msg.content) : null;
			if (!text) continue;
			if (typeof match.userMessage === "string") {
				if (useExactMatch) {
					if (text !== match.userMessage) continue;
				} else if (!text.includes(match.userMessage)) continue;
			} else {
				match.userMessage.lastIndex = 0;
				if (!match.userMessage.test(text)) continue;
			}
		}
		if (match.systemMessage !== void 0) {
			const sm = match.systemMessage;
			if (Array.isArray(sm) && sm.length === 0) {} else {
				const text = getSystemText(effective.messages);
				if (!text) continue;
				if (Array.isArray(sm)) {
					let allPresent = true;
					for (const needle of sm) if (!text.includes(needle)) {
						allPresent = false;
						break;
					}
					if (!allPresent) continue;
				} else if (typeof sm === "string") {
					if (useExactMatch) {
						if (text !== sm) continue;
					} else if (!text.includes(sm)) continue;
				} else {
					sm.lastIndex = 0;
					if (!sm.test(text)) continue;
				}
			}
		}
		if (match.toolCallId !== void 0) {
			const last = effective.messages[effective.messages.length - 1];
			if (!last || last.role !== "tool" || last.tool_call_id !== match.toolCallId) continue;
		}
		if (match.toolResultContains !== void 0) {
			const last = effective.messages[effective.messages.length - 1];
			if (!last || last.role !== "tool") continue;
			const text = getTextContent(last.content);
			if (text === null || !text.includes(match.toolResultContains)) continue;
		}
		if (match.toolName !== void 0) {
			if (!(effective.tools ?? []).some((t) => t.function.name === match.toolName)) continue;
		}
		if (match.inputText !== void 0) {
			const embeddingInput = effective.embeddingInput;
			if (!embeddingInput) continue;
			if (typeof match.inputText === "string") {
				if (useExactMatch) {
					if (embeddingInput !== match.inputText) continue;
				} else if (!embeddingInput.includes(match.inputText)) continue;
			} else {
				match.inputText.lastIndex = 0;
				if (!match.inputText.test(embeddingInput)) continue;
			}
		}
		if (match.responseFormat !== void 0) {
			if (effective.response_format?.type !== match.responseFormat) continue;
		}
		if (match.model !== void 0) if (typeof match.model === "string") {
			if (effective.model !== match.model) {
				if (!effective.model?.startsWith(match.model)) continue;
				const rest = effective.model.slice(match.model.length);
				if (!/^-\d/.test(rest)) continue;
			}
		} else {
			match.model.lastIndex = 0;
			if (!match.model.test(effective.model ?? "")) continue;
		}
		if (match.hasToolResult !== void 0) {
			if (effective.messages.some((m) => m.role === "tool") !== match.hasToolResult) continue;
		}
		if (match.sequenceIndex !== void 0 && matchCounts !== void 0) {
			if ((matchCounts.get(fixture) ?? 0) !== match.sequenceIndex) {
				skippedBySequenceOrTurn++;
				continue;
			}
		}
		if (strictTurnIndex && match.turnIndex !== void 0) {
			if (effective.messages.filter((m) => m.role === "assistant").length !== match.turnIndex) {
				skippedBySequenceOrTurn++;
				continue;
			}
		}
		contentMatches.push(fixture);
	}
	if (contentMatches.length === 0) return {
		fixture: null,
		skippedBySequenceOrTurn
	};
	const assistantCount = effective.messages.filter((m) => m.role === "assistant").length;
	const { fixture: selected, byUniquePosition } = selectByTurnIndex(contentMatches, assistantCount);
	const selectedTurn = selected.match.turnIndex;
	const turnIndexRelaxed = selectedTurn !== void 0 && selectedTurn !== assistantCount;
	const matchedBy = byUniquePosition ? "turnIndex" : "content";
	if (turnIndexRelaxed && options?.logger) {
		if (!warnedRelaxedFixtures.has(selected)) {
			warnedRelaxedFixtures.add(selected);
			const idx = fixtures.indexOf(selected);
			const desc = describeMatch(selected.match, idx);
			options.logger.warn(`turnIndex relaxed: served fixture ${desc} at assistantCount=${assistantCount} (scripted turnIndex=${selectedTurn}); set AIMOCK_STRICT_TURN_INDEX=1 to restore strict matching`);
		}
	}
	return {
		fixture: selected,
		skippedBySequenceOrTurn,
		turnIndexRelaxed,
		matchedBy
	};
}
/**
* Build a stable, human-readable identifier for a fixture's match shape for the
* relaxed-turnIndex warning. The previous `JSON.stringify(match)` was unfit: it
* DROPS `predicate` functions (non-serialisable) and serialises any RegExp
* matcher to `{}`, so a predicate- or regex-gated fixture's warning collapsed to
* an uninformative "served fixture {}" / `{"userMessage":{}}` blob.
*
* Instead we list the PRESENT matcher keys in declaration order, annotating each
* by VALUE KIND so predicates and regexes survive: `predicate(fn)`,
* `userMessage(regex)`, `userMessage("hello")`, `turnIndex=0`, etc. The
* fixture's array `index` (when known, i.e. `>= 0`) is prefixed as the stable
* positional identifier — the `Fixture` type carries no `id`/`name`, so its
* registration index is the only stable handle. String/number values are shown
* inline (truncated) so a content match remains recognisable; the whole string
* is capped to keep the log line bounded.
*/
function describeMatch(match, index) {
	const parts = [];
	for (const [key, value] of Object.entries(match)) {
		if (value === void 0) continue;
		if (typeof value === "function") parts.push(`${key}(fn)`);
		else if (value instanceof RegExp) parts.push(`${key}(${value})`);
		else if (typeof value === "string") {
			const v = value.length > 40 ? `${value.slice(0, 40)}…` : value;
			parts.push(`${key}(${JSON.stringify(v)})`);
		} else if (Array.isArray(value)) parts.push(`${key}(${value.length} item${value.length === 1 ? "" : "s"})`);
		else parts.push(`${key}=${String(value)}`);
	}
	const keys = parts.length > 0 ? parts.join(", ") : "no matchers";
	return `${index >= 0 ? `#${index} ` : ""}{ ${keys} }`.slice(0, 160);
}
/**
* Choose one fixture from a set that all CONTENT-matched the same request,
* using `turnIndex` purely as a position disambiguator (never as a reject
* gate).
*
* The selection rule is applied UNIFORMLY regardless of candidate count (a
* single candidate is NOT special-cased), so the same request never flips its
* answer just because an unrelated content-matching fixture was registered.
* Within every tier ties are broken by REGISTRATION ORDER — the
* earliest-registered eligible candidate wins — preserving the historical
* greedy "first matching fixture wins" contract.
*
*  1. Prefer the turnIndexed candidate whose `turnIndex` is closest to
*     `assistantCount` WITHOUT exceeding it (the highest `turnIndex <=
*     assistantCount`). A behind-the-count scripted turn (turnIndex <
*     assistantCount) beats a plain fallback — an explicit position is a
*     stronger signal than an unpositioned default. A negative `turnIndex` such
*     as -1 is a valid at/behind position (the seed is `-Infinity`, never a `-1`
*     sentinel that would mis-skip it). Earlier registration breaks ties among
*     equal turnIndexes.
*  2. EXACT-turn tie-break: when the best at/behind scripted turn sits at the
*     EXACT current position (`turnIndex === assistantCount`) a plain fallback
*     also answers "right now", so the two are equally eligible and REGISTRATION
*     ORDER decides — a later-registered `turnIndex:0` does NOT override an
*     earlier-registered fallback, and vice-versa.
*  3. Otherwise every turnIndexed candidate is still AHEAD of the conversation.
*     An explicit future turn must NOT answer an earlier point, so a plain
*     fallback (eligible at every position) is the better answer — applied
*     uniformly, INCLUDING when the fallback is the sole partner of a single
*     future-turn fixture (the single/multi asymmetry this fixes).
*  4. Otherwise (pure script, every candidate turnIndexed and all ahead) the
*     script genuinely has no earlier answer, so serve the lowest `turnIndex`
*     candidate — the false-red-kill for a lone scripted turn whose run has
*     FEWER assistant bubbles than its `turnIndex`; registration order breaks
*     ties.
*
* A future-turn fixture therefore NEVER answers an earlier-point request when an
* eligible alternative (a fallback, or an at/behind scripted turn) exists — the
* future-turn guard is enforced uniformly for single and multiple candidates.
*
* Returns the selected fixture alongside `byUniquePosition`: `true` ONLY when the
* choice was decided by a UNIQUE positional criterion — the served fixture's
* `turnIndex` sits EXACTLY at `assistantCount`, no earlier fallback overrode it
* (tier 2), and no other candidate shared that exact position (so registration
* order did not break a tie). `matchFixtureDiagnostic` maps this to
* `matchedBy === "turnIndex"`; every other selection path (tie-break,
* registration order, behind/ahead scripted turn, fallback) is `"content"`.
*/
function selectByTurnIndex(candidates, assistantCount) {
	const fallbackIdx = candidates.findIndex((f) => f.match.turnIndex === void 0);
	let bestIdx = -1;
	let bestTurn = -Infinity;
	for (let i = 0; i < candidates.length; i++) {
		const t = candidates[i].match.turnIndex;
		if (t === void 0) continue;
		if (t <= assistantCount && t > bestTurn) {
			bestIdx = i;
			bestTurn = t;
		}
	}
	if (bestIdx !== -1) {
		if (bestTurn === assistantCount && fallbackIdx !== -1 && fallbackIdx < bestIdx) return {
			fixture: candidates[fallbackIdx],
			byUniquePosition: false
		};
		const atExactPosition = bestTurn === assistantCount && candidates.filter((f) => f.match.turnIndex === assistantCount).length === 1;
		return {
			fixture: candidates[bestIdx],
			byUniquePosition: atExactPosition
		};
	}
	if (fallbackIdx !== -1) return {
		fixture: candidates[fallbackIdx],
		byUniquePosition: false
	};
	let lowest = candidates[0];
	for (const f of candidates) if (f.match.turnIndex < lowest.match.turnIndex) lowest = f;
	return {
		fixture: lowest,
		byUniquePosition: false
	};
}
/**
* Match a fixture against a request, returning the fixture or `null`. Thin
* wrapper over {@link matchFixtureDiagnostic} that discards the skip diagnostic
* — preserves the historical signature for all existing callers.
*/
function matchFixture(fixtures, req, matchCounts, requestTransform, options) {
	return matchFixtureDiagnostic(fixtures, req, matchCounts, requestTransform, options).fixture;
}

//#endregion
export { getLastMessageByRole, getTextContent, matchFixture, matchFixtureDiagnostic, recordMatchOptions };
//# sourceMappingURL=router.js.map