/*
 * Copyright OpenSearch Contributors
 * SPDX-License-Identifier: Apache-2.0
 */
import { recordVerdict } from '../matchers/session.js';
import { readEnv } from '../envCompat.js';
const verdictCache = new Map();
/** Clear the in-process judge verdict cache (test isolation / new run). */
export function clearJudgeCache() {
    verdictCache.clear();
}
/**
 * Stable, collision-resistant cache key over the judging inputs. Uses
 * `sha256` when Node's crypto is available, falling back to a fast JS hash
 * so the SDK stays bundler-safe.
 */
function judgeCacheKey(input) {
    const canonical = JSON.stringify({
        evaluatorId: input.evaluatorId ?? '',
        model: input.model ?? '',
        claims: input.claims,
        trajectory: input.trajectory,
    });
    try {
        // eslint-disable-next-line @typescript-eslint/no-require-imports
        const { createHash } = require('crypto');
        return createHash('sha256').update(canonical).digest('hex');
    }
    catch {
        // FNV-1a fallback — non-cryptographic but fine for cache addressing.
        let h = 0x811c9dc5;
        for (let i = 0; i < canonical.length; i++) {
            h ^= canonical.charCodeAt(i);
            h = Math.imul(h, 0x01000193);
        }
        return (h >>> 0).toString(16);
    }
}
/** Whether judging is globally skipped via env (CLI `--no-judge`). */
function judgeSkippedByEnv() {
    const v = readEnv('AH_SKIP_JUDGE', 'AGENT_HEALTH_SKIP_JUDGE');
    return v === '1' || v === 'true' || v === 'yes';
}
let judgeCalledInCurrentEval = false;
export function wasJudgeCalled() {
    return judgeCalledInCurrentEval;
}
export function resetJudgeFlag() {
    judgeCalledInCurrentEval = false;
}
function isTrajectory(x) {
    return Array.isArray(x);
}
function isResultLike(x) {
    return typeof x === 'object' && x !== null && 'trajectory' in x;
}
/**
 * Build a {@link Verdict} object with the `orThrow()` escape hatch attached.
 */
function makeVerdict(v) {
    const verdict = {
        ...v,
        pass: v.passFailStatus === 'passed',
        score: v.score ?? (typeof v.accuracy === 'number' ? v.accuracy / 100 : 0),
        orThrow() {
            // Passing or skipped verdicts never throw. Failed/errored ones do.
            if (this.pass || this.skipped)
                return this;
            const label = this.errored ? 'errored' : 'FAILED';
            throw new Error(`LLM Judge: ${label} (accuracy: ${this.accuracy})\n${this.errorMessage ?? this.reasoning}`);
        },
    };
    return verdict;
}
/**
 * Core judge implementation. Records exactly one MatcherResult (carrying
 * `role`) and returns a non-throwing {@link Verdict}.
 */
async function runJudge(resultOrTrajectory, claimOrClaims, options, role) {
    judgeCalledInCurrentEval = true;
    const trajectory = isTrajectory(resultOrTrajectory)
        ? resultOrTrajectory
        : (isResultLike(resultOrTrajectory) ? resultOrTrajectory.trajectory ?? [] : []);
    // Agent run id for trace/log correlation — lets the agentic trace judge
    // scope its read-only tools to this single run. Only present when the
    // caller passed a RunResult (not a bare trajectory array).
    const runId = !isTrajectory(resultOrTrajectory) && isResultLike(resultOrTrajectory)
        ? resultOrTrajectory.runId
        : undefined;
    const claims = Array.isArray(claimOrClaims) ? claimOrClaims : [claimOrClaims];
    const serverUrl = options?.serverUrl ?? `http://localhost:${readEnv('AH_PORT', 'AGENT_HEALTH_PORT') || '4001'}`;
    const description = claims.length === 1 ? `judge: ${claims[0]}` : `judge: ${claims.length} claims`;
    // Body shape matches the UI's /api/judge POST exactly: trajectory,
    // expectedOutcomes, expectedTrajectory, optional modelId, optional
    // evaluatorId. The server applies the same evaluator-resolution and
    // provider-routing logic regardless of caller, so SDK and UI runs
    // produce comparable verdicts.
    const requestBody = {
        trajectory,
        expectedOutcomes: claims,
        expectedTrajectory: [],
    };
    if (options?.model)
        requestBody.modelId = options.model;
    if (options?.evaluatorId)
        requestBody.evaluatorId = options.evaluatorId;
    if (runId)
        requestBody.runId = runId;
    // Record the success path identically whether the verdict came fresh from
    // the endpoint or from the in-process cache.
    const finalize = (raw, durationMs) => {
        const accuracy = raw.metrics?.accuracy ?? 0;
        const verdict = makeVerdict({
            passFailStatus: raw.passFailStatus ?? 'failed',
            accuracy,
            reasoning: raw.llmJudgeReasoning ?? '',
            role,
            skipped: false,
            errored: false,
        });
        recordVerdict({
            description,
            pass: verdict.pass,
            method: 'llm-judge',
            role,
            durationMs,
            score: verdict.score,
            reasoning: verdict.reasoning,
            model: options?.model,
            errorMessage: verdict.passFailStatus === 'failed' ? verdict.reasoning : undefined,
            // Preserve the rest of the judge payload — these were silently
            // dropped before, which made SDK `judge()` calls strictly less
            // informative than the legacy auto-judge path. See MatcherResult.
            ...(Array.isArray(raw.improvementStrategies) && raw.improvementStrategies.length > 0
                ? { improvementStrategies: raw.improvementStrategies }
                : {}),
            ...(raw.metrics && typeof raw.metrics === 'object'
                ? { judgeMetrics: { ...raw.metrics } }
                : {}),
        });
        return verdict;
    };
    // SKIP: no LLM call. `skip` is tri-state and takes precedence over the env:
    //   skip === true   → always skip
    //   skip === false  → never skip (force the judge to run, even if AH_SKIP_JUDGE is set)
    //   skip undefined  → defer to AH_SKIP_JUDGE
    // This makes a per-call/bound `skip: false` a meaningful override rather than
    // having the env unconditionally win.
    const shouldSkip = options?.skip === true || (options?.skip !== false && judgeSkippedByEnv());
    if (shouldSkip) {
        recordVerdict({
            description: `${description} (skipped)`,
            pass: true,
            method: 'llm-judge',
            role: 'observe',
            durationMs: 0,
            reasoning: 'Judge skipped (AH_SKIP_JUDGE / skip option).',
        });
        return makeVerdict({
            passFailStatus: 'passed',
            accuracy: 0,
            reasoning: 'Judge skipped.',
            role,
            skipped: true,
            errored: false,
        });
    }
    // CACHE: identical (evaluator, model, claims, trajectory) → reuse verdict.
    const cacheKey = judgeCacheKey({
        evaluatorId: options?.evaluatorId,
        model: options?.model,
        claims,
        trajectory,
    });
    const cached = verdictCache.get(cacheKey);
    if (cached) {
        return finalize(cached, 0);
    }
    const startedAt = Date.now();
    let response;
    try {
        response = await fetch(`${serverUrl}/api/judge`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(requestBody),
        });
    }
    catch (err) {
        const errMsg = err?.message || String(err);
        // Endpoint unreachable — the judge could not run. This is `errored`,
        // not a clean `pass: false`. The matcher is recorded with errored=true
        // so the run is bucketed as `errored` (excluded from pass-rate).
        recordVerdict({
            description,
            pass: false,
            method: 'llm-judge',
            role,
            errored: true,
            durationMs: Date.now() - startedAt,
            errorMessage: `Judge request failed: ${errMsg}`,
            reasoning: '',
        });
        return makeVerdict({
            passFailStatus: 'failed',
            accuracy: 0,
            reasoning: '',
            role,
            skipped: false,
            errored: true,
            errorMessage: `Judge request failed: ${errMsg}`,
        });
    }
    if (!response.ok) {
        const text = await response.text();
        recordVerdict({
            description,
            pass: false,
            method: 'llm-judge',
            role,
            errored: true,
            durationMs: Date.now() - startedAt,
            errorMessage: `Judge HTTP ${response.status}: ${text}`,
            reasoning: '',
        });
        return makeVerdict({
            passFailStatus: 'failed',
            accuracy: 0,
            reasoning: '',
            role,
            skipped: false,
            errored: true,
            errorMessage: `Judge HTTP ${response.status}: ${text}`,
        });
    }
    const raw = (await response.json());
    // Only successful, non-errored verdicts are cached.
    verdictCache.set(cacheKey, raw);
    return finalize(raw, Date.now() - startedAt);
}
/**
 * Single-claim ergonomic form (gate role).
 * @example
 *   const v = await judge(result, 'identifies the failing dependency');
 *   if (!v.pass) { ... }                       // non-throwing
 *   (await judge(result, claim)).orThrow();      // explicit hard-stop
 *   await judge.observe(result, claim);          // score-only, never gates
 */
export const judge = Object.assign((resultOrTrajectory, claimOrClaims, options) => runJudge(resultOrTrajectory, claimOrClaims, options, 'gate'), {
    observe: (resultOrTrajectory, claimOrClaims, options) => runJudge(resultOrTrajectory, claimOrClaims, options, 'observe'),
});
/**
 * Bind run-level defaults to `judge` and return a callable with the same
 * signature. Used by the SDK runner to inject `run.evaluatorId` (and
 * optionally a default judge model) into the `TestFixtures.judge` slot,
 * so test bodies that destructure `({ judge })` automatically inherit
 * the run's evaluator selection — matching the UI's behaviour where the
 * evaluator picked on the run config applies to every judged test case.
 *
 * Per-call options always win over the bound defaults; pass an empty
 * object (or omit the field) to fall through to the bound value.
 *
 *   const boundJudge = bindJudge({ evaluatorId: run.evaluatorId });
 *   await boundJudge(result, claim);                                  // uses run.evaluatorId
 *   await boundJudge(result, claim, { evaluatorId: 'other' });        // overrides
 *   await boundJudge(result, claim, { evaluatorId: undefined });      // still uses bound default
 */
export function bindJudge(defaults) {
    // No defaults set → return the unbound function unchanged. Keeps zero
    // overhead for tests that don't use a run-level evaluator. Note `skip` is
    // compared against `undefined` (not falsiness) so a binding of
    // `{ skip: false }` — a meaningful "force the judge to run" — is preserved
    // rather than short-circuited to the unbound judge.
    if (!defaults ||
        (!defaults.evaluatorId && !defaults.model && !defaults.serverUrl && defaults.skip === undefined)) {
        return judge;
    }
    const mergeOptions = (options) => ({
        // Per-call options win on every field that's actually set. We treat
        // an explicit `undefined` the same as a missing field — callers who
        // want to *clear* a bound default should pass an empty string or
        // call the unbound `judge` directly.
        serverUrl: options?.serverUrl ?? defaults.serverUrl,
        model: options?.model ?? defaults.model,
        evaluatorId: options?.evaluatorId ?? defaults.evaluatorId,
        skip: options?.skip ?? defaults.skip,
    });
    const bound = Object.assign((resultOrTrajectory, claimOrClaims, options) => runJudge(resultOrTrajectory, claimOrClaims, mergeOptions(options), 'gate'), {
        observe: (resultOrTrajectory, claimOrClaims, options) => runJudge(resultOrTrajectory, claimOrClaims, mergeOptions(options), 'observe'),
    });
    return bound;
}
//# sourceMappingURL=judge.js.map