/**
 * proxy.ts
 *
 * A lightweight HTTP recording proxy that captures all outbound requests
 * made by an AI agent during evaluation. Instead of being an actual network
 * proxy (which requires HTTPS MITM certificates), this module provides:
 *
 *   1. A `fetch` wrapper that records every request/response
 *   2. A session manager that groups observations by test case
 *
 * This design works with any agent framework that uses `fetch` or can be
 * configured to use a custom HTTP client. For agents that use a real browser,
 * see the Chrome DevTools Protocol integration (future work).
 *
 * Usage:
 *   const recorder = new RequestRecorder()
 *   recorder.start('session-123')
 *
 *   // The agent makes requests through the recorder's fetch wrapper
 *   const response = await recorder.fetch('https://sanity.io/docs/groq')
 *
 *   const log = recorder.stop()
 *   // → AgentBehaviorLog with all requests classified
 *
 * W0133 — per-class preview byte caps
 *
 * `responsePreview` is capped at `previewLimits.default` (4 KB) for most
 * responses, with per-class overrides for two payloads whose contents are
 * the ground truth for trace audits:
 *
 *   - `previewLimits.search` (16 KB) — Jina-wrapped DuckDuckGo, Google CSE,
 *     bing.com/search, duckduckgo.com, google.com/search responses. Captures
 *     the full result list (typical 8–10 KB) so trace audits can resolve
 *     which result the model fetched next.
 *   - `previewLimits.llmsTxt` (128 KB) — `/llms.txt` responses. The Sanity
 *     index is ~110 KB. Capturing the full body lets trace audits
 *     distinguish "model fetched a path that wasn't in the index" from
 *     "model fetched a path that was in the index but the page is missing".
 *
 * The slim Content Lake report (W0051) does not inline previews — they
 * live in the GCS `traces` NDJSON artifact only, so bumping these caps
 * has no effect on the 10 MB Sanity document budget.
 */
import { classifyRequests } from "./classifier.js";
/** Per-class preview-byte defaults (W0133). */
const DEFAULT_PREVIEW_LIMITS = {
    default: 4096,
    llmsTxt: 131072, // ~128 KB — covers Sanity's ~110 KB llms.txt
    search: 16384, // ~16 KB — Jina/Google CSE/duckduckgo result lists
};
/**
 * URL patterns for the `search` response class (W0133). These cover the
 * search providers the agentic loop actually hits; new providers can be
 * added here without changing the recorder API surface.
 */
const SEARCH_URL_PATTERNS = [
    /r\.jina\.ai\/https?:\/\/(www\.)?duckduckgo\.com/i,
    /r\.jina\.ai\/https?:\/\/(www\.)?google\.com\/search/i,
    /r\.jina\.ai\/https?:\/\/(www\.)?bing\.com\/search/i,
    /^https?:\/\/(www\.)?googleapis\.com\/customsearch/i,
    /^https?:\/\/(www\.)?google\.com\/search/i,
    /^https?:\/\/(www\.)?bing\.com\/search/i,
    /^https?:\/\/(www\.)?duckduckgo\.com/i,
];
/** URL pattern for the `llmsTxt` response class (W0133). */
const LLMS_TXT_PATTERN = /\/llms\.txt(\?|$|\/)/i;
const DEFAULT_OPTIONS = {
    captureHeaders: [
        "accept",
        "content-type",
        "user-agent",
        "authorization",
        "x-sanity-token",
    ],
    captureResponsePreview: true,
    excludePatterns: [
        // Skip common noise
        /\.(woff2?|ttf|eot)(\?|$)/i,
        /\.(png|jpg|jpeg|gif|svg|ico|webp)(\?|$)/i,
        /favicon/i,
    ],
    includePatterns: [],
    maxBodyBytes: 4096,
    maxPreviewBytes: DEFAULT_PREVIEW_LIMITS.default,
    previewLimits: { ...DEFAULT_PREVIEW_LIMITS },
    statusOnlyForUnmatched: true,
};
// ---------------------------------------------------------------------------
// RequestRecorder
// ---------------------------------------------------------------------------
export class RequestRecorder {
    observations = [];
    options;
    provider = "";
    running = false;
    seq = 0;
    sessionId = "";
    startTime = 0;
    taskDescription = "";
    constructor(options) {
        const merged = { ...DEFAULT_OPTIONS, ...options };
        // Coerce string patterns from YAML config into RegExp instances
        if (merged.includePatterns) {
            merged.includePatterns = merged.includePatterns.map(toRegExp);
        }
        if (merged.excludePatterns) {
            merged.excludePatterns = merged.excludePatterns.map(toRegExp);
        }
        // Resolve per-class preview caps. `previewLimits.default` wins over
        // `maxPreviewBytes`; missing entries fall through to module defaults
        // (W0133).
        const userLimits = options?.previewLimits ?? {};
        const resolvedDefault = userLimits.default ??
            options?.maxPreviewBytes ??
            DEFAULT_PREVIEW_LIMITS.default;
        merged.previewLimits = {
            default: resolvedDefault,
            llmsTxt: userLimits.llmsTxt ?? DEFAULT_PREVIEW_LIMITS.llmsTxt,
            search: userLimits.search ?? DEFAULT_PREVIEW_LIMITS.search,
        };
        merged.maxPreviewBytes = resolvedDefault;
        this.options = merged;
    }
    /**
     * A `fetch`-compatible wrapper that records the request and response,
     * then returns the real response unmodified.
     *
     * Drop-in replacement: `const response = await recorder.fetch(url, init)`
     */
    async fetch(input, init) {
        const reqStart = Date.now();
        const url = typeof input === "string"
            ? input
            : input instanceof URL
                ? input.toString()
                : input.url;
        const method = init?.method ??
            (typeof input !== "string" && !(input instanceof URL)
                ? input.method
                : "GET") ??
            "GET";
        const captureMode = this.classifyCaptureMode(url);
        let response;
        let error = null;
        try {
            response = await globalThis.fetch(input, init);
        }
        catch (err) {
            error = err;
            if (captureMode === "drop")
                throw error;
            // Record the failed request — status-only captures skip body/headers
            // entirely (W0132).
            this.record(captureMode === "full"
                ? {
                    body: await this.extractBody(init?.body),
                    capture: "full",
                    contentType: undefined,
                    headers: this.extractHeaders(init?.headers),
                    latencyMs: Date.now() - reqStart,
                    method: method.toUpperCase(),
                    responsePreview: `Error: ${error.message}`,
                    responseSize: 0,
                    statusCode: 0,
                    timestamp: new Date(reqStart).toISOString(),
                    url,
                }
                : {
                    capture: "status-only",
                    headers: {},
                    latencyMs: Date.now() - reqStart,
                    method: method.toUpperCase(),
                    responseSize: 0,
                    statusCode: 0,
                    timestamp: new Date(reqStart).toISOString(),
                    url,
                });
            throw error;
        }
        const latencyMs = Date.now() - reqStart;
        if (captureMode === "drop")
            return response;
        if (captureMode === "status-only") {
            // No body read, no header capture, no preview — only the metadata
            // needed to know the call happened (W0132).
            this.record({
                capture: "status-only",
                headers: {},
                latencyMs,
                method: method.toUpperCase(),
                responseSize: 0,
                statusCode: response.status,
                timestamp: new Date(reqStart).toISOString(),
                url,
            });
            return response;
        }
        // Clone the response so we can read the body without consuming it
        const clone = response.clone();
        let responseSize = 0;
        let responsePreview;
        if (this.options.captureResponsePreview) {
            const previewBytes = this.resolvePreviewBytes(url);
            try {
                const text = await clone.text();
                responseSize = new TextEncoder().encode(text).length;
                responsePreview = text.slice(0, previewBytes);
            }
            catch {
                // Body might not be text — that's fine
                responseSize = parseInt(response.headers.get("content-length") ?? "0", 10);
            }
        }
        this.record({
            body: await this.extractBody(init?.body),
            capture: "full",
            contentType: response.headers.get("content-type") ?? undefined,
            headers: this.extractHeaders(init?.headers),
            latencyMs,
            method: method.toUpperCase(),
            responsePreview,
            responseSize,
            statusCode: response.status,
            timestamp: new Date(reqStart).toISOString(),
            url,
        });
        return response;
    }
    /**
     * Whether the recorder is currently active.
     */
    isRunning() {
        return this.running;
    }
    /**
     * Get current observations without stopping the recorder.
     */
    peek() {
        return [...this.observations];
    }
    /**
     * Record an externally-observed request/response pair.
     *
     * Use this when you can't wrap `fetch` directly but can observe traffic
     * (e.g., via browser DevTools Protocol, mitmproxy logs, etc.).
     *
     * Filter behavior (W0132):
     * - `excludePatterns` always drops the observation entirely.
     * - `includePatterns` mismatch produces a slim `capture: "status-only"`
     *   record when `statusOnlyForUnmatched` is true (default), or drops it
     *   when false.
     * - The discriminator on the input is honored: callers that already
     *   know they're emitting a slim record (e.g., the fetch wrapper) can
     *   set `capture: "status-only"` themselves.
     */
    record(observation) {
        if (!this.running)
            return;
        const url = observation.url;
        if (this.options.excludePatterns.some((p) => p.test(url)))
            return;
        let capture = observation.capture ?? "full";
        if (this.options.includePatterns.length > 0) {
            const matchesIncludes = this.options.includePatterns.some((p) => p.test(url));
            if (!matchesIncludes) {
                if (!this.options.statusOnlyForUnmatched)
                    return;
                capture = "status-only";
            }
        }
        if (capture === "status-only") {
            // Slim shape — strip body/headers/contentType/responsePreview so a
            // caller that passed full data still produces a sanitized record.
            this.observations.push({
                capture: "status-only",
                headers: {},
                latencyMs: observation.latencyMs,
                method: observation.method,
                responseSize: 0,
                seq: this.seq++,
                statusCode: observation.statusCode,
                timestamp: observation.timestamp,
                url,
            });
            return;
        }
        const previewBytes = this.resolvePreviewBytes(url);
        this.observations.push({
            ...observation,
            capture: "full",
            // Truncate body if needed
            body: observation.body?.slice(0, this.options.maxBodyBytes),
            responsePreview: observation.responsePreview?.slice(0, previewBytes),
            seq: this.seq++,
        });
    }
    /**
     * Resolve the preview byte cap for a given URL using per-class overrides
     * (W0133). Order of preference:
     *   1. `previewLimits.llmsTxt` for `/llms.txt` URLs.
     *   2. `previewLimits.search` for known search providers.
     *   3. `previewLimits.default`.
     */
    resolvePreviewBytes(url) {
        if (LLMS_TXT_PATTERN.test(url))
            return this.options.previewLimits.llmsTxt;
        if (SEARCH_URL_PATTERNS.some((p) => p.test(url))) {
            return this.options.previewLimits.search;
        }
        return this.options.previewLimits.default;
    }
    /**
     * Decide how to record a URL given the current filter configuration.
     *
     * - `"drop"` — `excludePatterns` matched, or `includePatterns` failed
     *   and `statusOnlyForUnmatched` is false.
     * - `"status-only"` — `includePatterns` failed but
     *   `statusOnlyForUnmatched` is true (default). Skip body/headers.
     * - `"full"` — record everything.
     *
     * See W0132.
     */
    classifyCaptureMode(url) {
        if (this.options.excludePatterns.some((p) => p.test(url)))
            return "drop";
        if (this.options.includePatterns.length === 0)
            return "full";
        const matchesIncludes = this.options.includePatterns.some((p) => p.test(url));
        if (matchesIncludes)
            return "full";
        return this.options.statusOnlyForUnmatched ? "status-only" : "drop";
    }
    /**
     * Reset the recorder for reuse without creating a new instance.
     */
    reset() {
        this.observations = [];
        this.seq = 0;
        this.sessionId = "";
        this.provider = "";
        this.taskDescription = "";
        this.startTime = 0;
        this.running = false;
    }
    /**
     * Start a new recording session. Call this before the agent runs a task.
     */
    start(sessionId, provider, taskDescription) {
        this.sessionId = sessionId;
        this.provider = provider;
        this.taskDescription = taskDescription;
        this.observations = [];
        this.seq = 0;
        this.startTime = Date.now();
        this.running = true;
    }
    /**
     * Stop recording and return the complete behavior log with
     * classified observations and summary statistics.
     */
    stop() {
        this.running = false;
        const endTime = Date.now();
        const classified = classifyRequests(this.observations);
        const summary = this.computeSummary();
        return {
            endedAt: new Date(endTime).toISOString(),
            networkDurationMs: this.observations.reduce((sum, r) => sum + r.latencyMs, 0),
            provider: this.provider,
            requests: this.observations,
            sessionId: this.sessionId,
            startedAt: new Date(this.startTime).toISOString(),
            taskDescription: this.taskDescription,
            totalDurationMs: endTime - this.startTime,
            ...classified,
            summary,
        };
    }
    // -------------------------------------------------------------------------
    // Private helpers
    // -------------------------------------------------------------------------
    computeSummary() {
        const classified = classifyRequests(this.observations);
        const uniqueUrls = new Set(this.observations.map((r) => r.url));
        const docSlugs = [
            ...new Set(classified.docPageVisits.map((d) => d.slug)),
        ].filter(Boolean);
        const searchQueries = [
            ...new Set(classified.searchQueries.map((s) => s.query)),
        ].filter(Boolean);
        const extDomains = [
            ...new Set(classified.externalRequests.map((e) => e.domain)),
        ];
        return {
            apiCallCount: classified.apiCalls.length,
            docPagesVisited: classified.docPageVisits.length,
            docSlugsVisited: docSlugs,
            externalDomains: extDomains,
            externalRequestCount: classified.externalRequests.length,
            searchesPerformed: classified.searchQueries.length,
            totalBytesDownloaded: this.observations.reduce((sum, r) => sum + r.responseSize, 0),
            totalNetworkMs: this.observations.reduce((sum, r) => sum + r.latencyMs, 0),
            totalRequests: this.observations.length,
            uniqueSearchQueries: searchQueries,
            uniqueUrls: uniqueUrls.size,
            usedDocs: classified.docPageVisits.length > 0,
            usedSearch: classified.searchQueries.length > 0,
        };
    }
    async extractBody(body) {
        if (!body)
            return undefined;
        if (typeof body === "string") {
            return body.slice(0, this.options.maxBodyBytes);
        }
        // For other body types, try to convert
        try {
            if (body instanceof ArrayBuffer) {
                return new TextDecoder()
                    .decode(body)
                    .slice(0, this.options.maxBodyBytes);
            }
            if (body instanceof Blob) {
                const text = await body.text();
                return text.slice(0, this.options.maxBodyBytes);
            }
        }
        catch {
            return "[binary body]";
        }
        return undefined;
    }
    extractHeaders(headers) {
        const result = {};
        if (!headers)
            return result;
        const allowed = new Set(this.options.captureHeaders.map((h) => h.toLowerCase()));
        if (headers instanceof Headers) {
            headers.forEach((value, key) => {
                if (allowed.has(key.toLowerCase())) {
                    result[key.toLowerCase()] = this.redactSensitive(key, value);
                }
            });
        }
        else if (Array.isArray(headers)) {
            for (const [key, value] of headers) {
                if (allowed.has(key.toLowerCase())) {
                    result[key.toLowerCase()] = this.redactSensitive(key, value);
                }
            }
        }
        else {
            for (const [key, value] of Object.entries(headers)) {
                if (allowed.has(key.toLowerCase())) {
                    result[key.toLowerCase()] = this.redactSensitive(key, value);
                }
            }
        }
        return result;
    }
    redactSensitive(key, value) {
        const sensitive = ["authorization", "x-sanity-token", "cookie"];
        if (sensitive.includes(key.toLowerCase())) {
            return value.slice(0, 8) + "...[REDACTED]";
        }
        return value;
    }
}
/**
 * Coerce a pattern that may be a string (from YAML config) into a RegExp.
 * If it is already a RegExp, return as-is.
 */
function toRegExp(pattern) {
    if (pattern instanceof RegExp)
        return pattern;
    // Simple containment match — treat string as a literal substring
    const escaped = String(pattern).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
    return new RegExp(escaped, "i");
}
