/**
 * MCP-specific assertion types — ergonomic assertions for MCP server testing.
 *
 * Each assertion type compiles down to a Promptfoo `javascript` assertion
 * with the appropriate validation logic. The developer writes:
 *
 * ```typescript
 * assertions: [
 *   { type: "tool-called", value: "getDocument" },
 *   { type: "tool-input-matches", value: { documentId: "doc-123" } },
 *   { type: "tool-output-matches", value: { title: "Hello" } },
 *   { type: "tool-input-schema", value: { type: "object", required: ["query"] } },
 *   { type: "tool-output-schema", value: { type: "array", minItems: 1 } },
 *   { type: "error-returned", value: { code: -32602 } },
 * ]
 * ```
 *
 * `tool-input-matches` / `tool-output-matches` test partial value-equality
 * (does a recorded call contain these exact key/value pairs?). The
 * `*-schema` matchers test shape/type conformance against a JSON Schema
 * (is `query` a string? is the result a non-empty array?) — see D0079.
 *
 * The compiler transforms these into Promptfoo-compatible `javascript`
 * assertions that inspect the tool call trace in the evaluation output.
 *
 * @see docs/archive/exec-plans/architecture-overhaul/phase-3-mcp-server-mode.md
 */
import { resolveTemplatedAssertion } from "../../rubric-resolution.js";
import { compileSchemaValidatorFactory, inlineSchemaValidator, schemaErrorFormatterSource, } from "./schema-validator.js";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Build MCP-specific assertions from task assertion definitions.
 *
 * Handles both MCP-specific types (tool-called, tool-input-matches, etc.)
 * and standard assertion types (contains, llm-rubric, etc.) which are
 * passed through unchanged.
 */
export function buildMCPAssertions(assertions, context) {
    const result = [];
    const warnings = [];
    for (const assertion of assertions) {
        const mapped = mapMCPAssertion(assertion, context, warnings);
        if (mapped) {
            result.push(mapped);
        }
    }
    return { assertions: result, warnings };
}
// ---------------------------------------------------------------------------
// Assertion mapping
// ---------------------------------------------------------------------------
function mapMCPAssertion(assertion, context, warnings) {
    switch (assertion.type) {
        case "tool-called":
            return buildToolCalledAssertion(assertion, context);
        case "tool-input-matches":
            return buildToolInputMatchesAssertion(assertion, context);
        case "tool-output-matches":
            return buildToolOutputMatchesAssertion(assertion, context);
        case "tool-input-schema":
            return buildToolInputSchemaAssertion(assertion, context, warnings);
        case "tool-output-schema":
            return buildToolOutputSchemaAssertion(assertion, context, warnings);
        case "error-returned":
            return buildErrorReturnedAssertion(assertion, context);
        case "capability-available":
            return buildCapabilityAssertion(assertion, context);
        case "llm-rubric":
            return mapLlmRubricAssertion(assertion, context, warnings);
        // Standard assertions — pass through
        case "contains":
        case "equals":
        case "regex":
        case "is-json":
        case "javascript":
        case "python":
            return {
                type: assertion.type,
                ...("value" in assertion ? { value: assertion.value } : {}),
            };
        default:
            warnings.push(`MCP task "${context.taskId}": unknown assertion type "${assertion.type}" — passed through`);
            return {
                type: assertion.type,
                ...("value" in assertion ? { value: assertion.value } : {}),
            };
    }
}
// ---------------------------------------------------------------------------
// llm-rubric — resolve templated rubrics, pass through inline-value rubrics
// ---------------------------------------------------------------------------
/**
 * Map an `llm-rubric` assertion to a Promptfoo assertion.
 *
 * Templated form (`template` + `criteria`, e.g. the live MCP task's
 * `mcp-output-correctness` rubric) goes through the shared rubric resolver —
 * the same rendering literacy and knowledge-probe modes apply — so the
 * compiled assertion carries the full rubric text plus `metadata.dimension`.
 * The earlier pass-through copied only `value`, leaving templated rubrics with
 * `value: null`; the grader then scored an empty rubric and passed vacuously.
 *
 * Inline-value form (`value` supplied directly) is passed through with the
 * grader provider injected — back-compat for tasks that author rubric text
 * inline rather than via a template.
 */
function mapLlmRubricAssertion(assertion, context, warnings) {
    if ("template" in assertion && "criteria" in assertion) {
        return resolveTemplatedAssertion(assertion, context.rubricConfig, context.graderProvider, warnings);
    }
    return {
        type: "llm-rubric",
        ...("value" in assertion ? { value: assertion.value } : {}),
        ...(context.graderProvider ? { provider: context.graderProvider } : {}),
    };
}
// ---------------------------------------------------------------------------
// tool-called — asserts the model called a specific tool by name
// ---------------------------------------------------------------------------
function buildToolCalledAssertion(assertion, _context) {
    const toolName = String(assertion.value ?? "");
    // Strategy: check multiple sources for tool call evidence.
    // 1. context.vars.__toolCalls (structured, if Promptfoo populates it)
    // 2. Response metadata toolCallLog (from custom mcp-tool-provider)
    // 3. Response output text (LLM+MCP providers embed tool_use JSON blocks)
    return {
        type: "javascript",
        value: buildJsAssertion(`tool-called: ${toolName}`, `
      var toolName = ${JSON.stringify(toolName)};

      // Strategy 1: structured tool-call log (provider metadata or injected var)
      ${toolCallsSource()}
      if (toolCalls.length > 0) {
        var called = toolCalls.some(function(tc) { return tc.name === toolName; });
        return {
          pass: called,
          score: called ? 1 : 0,
          reason: called
            ? 'Tool "' + toolName + '" was called (via __toolCalls)'
            : 'Expected tool "' + toolName + '" but found: ' + toolCalls.map(function(tc) { return tc.name; }).join(', '),
        };
      }

      // Strategy 2: MCP_TOOLS_CALLED summary appended by custom mcp-tool-provider
      var outputStr = typeof output === 'string' ? output : JSON.stringify(output || '');
      var summaryMatch = outputStr.match(/<!-- MCP_TOOLS_CALLED: (\\[.*?\\]) -->/);
      if (summaryMatch) {
        try {
          var calledTools = JSON.parse(summaryMatch[1]);
          var called = calledTools.includes(toolName);
          var count = calledTools.filter(function(n) { return n === toolName; }).length;
          return {
            pass: called,
            score: called ? 1 : 0,
            reason: called
              ? 'Tool "' + toolName + '" was called ' + count + ' time(s)'
              : 'Expected tool "' + toolName + '" but found: ' + calledTools.join(', '),
          };
        } catch (e) { /* fall through to Strategy 3 */ }
      }

      // Strategy 3: parse output for tool_use blocks (built-in provider fallback)
      var outputStr = typeof output === 'string' ? output : JSON.stringify(output || '');
      var toolUsePattern = /"type"\\s*:\\s*"tool_use"[^}]*"name"\\s*:\\s*"([^"]+)"/g;
      var foundTools = [];
      var match;
      while ((match = toolUsePattern.exec(outputStr)) !== null) {
        foundTools.push(match[1]);
      }
      var fnCallPattern = /"function"\\s*:\\s*\\{[^}]*"name"\\s*:\\s*"([^"]+)"/g;
      while ((match = fnCallPattern.exec(outputStr)) !== null) {
        foundTools.push(match[1]);
      }
      if (foundTools.length === 0 && outputStr.includes(toolName) && outputStr.includes('tool_use')) {
        foundTools.push(toolName);
      }

      var called = foundTools.includes(toolName);
      return {
        pass: called,
        score: called ? 1 : 0,
        reason: called
          ? 'Tool "' + toolName + '" was called (detected in output)'
          : 'Expected tool "' + toolName + '" to be called. ' +
            (foundTools.length > 0
              ? 'Tools found in output: ' + foundTools.join(', ')
              : 'No tool calls detected in output'),
      };`),
    };
}
// ---------------------------------------------------------------------------
// tool-input-matches — asserts tool call inputs match a schema/value
// ---------------------------------------------------------------------------
function buildToolInputMatchesAssertion(assertion, _context) {
    const expected = assertion.value;
    const toolName = assertion.toolName ?? assertion.tool;
    return {
        type: "javascript",
        value: buildJsAssertion(`tool-input-matches${toolName ? `: ${toolName}` : ""}`, `
      ${toolCallsSource()}
      const expected = ${JSON.stringify(expected)};
      const toolFilter = ${JSON.stringify(toolName ?? null)};

      const targetCalls = toolFilter
        ? toolCalls.filter(tc => tc.name === toolFilter)
        : toolCalls;

      if (targetCalls.length === 0) {
        return {
          pass: false,
          score: 0,
          reason: toolFilter
            ? 'No calls to tool "' + toolFilter + '" found'
            : 'No tool calls found',
        };
      }

      // Check if any call's input matches the expected value
      const match = targetCalls.some(tc => {
        const input = tc.input || tc.arguments || {};
        return Object.entries(expected).every(([k, v]) =>
          JSON.stringify(input[k]) === JSON.stringify(v)
        );
      });

      return {
        pass: match,
        score: match ? 1 : 0,
        reason: match
          ? 'Tool input matches expected values'
          : 'Tool input does not match. Expected: ' + JSON.stringify(expected) +
            ', Got: ' + JSON.stringify(targetCalls.map(tc => tc.input || tc.arguments)),
      };`),
    };
}
// ---------------------------------------------------------------------------
// tool-output-matches — asserts tool outputs match expected shape/values
// ---------------------------------------------------------------------------
function buildToolOutputMatchesAssertion(assertion, _context) {
    const expected = assertion.value;
    const toolName = assertion.toolName ?? assertion.tool;
    return {
        type: "javascript",
        value: buildJsAssertion(`tool-output-matches${toolName ? `: ${toolName}` : ""}`, `
      ${toolCallsSource()}
      const expected = ${JSON.stringify(expected)};
      const toolFilter = ${JSON.stringify(toolName ?? null)};

      const targetCalls = toolFilter
        ? toolCalls.filter(tc => tc.name === toolFilter)
        : toolCalls;

      if (targetCalls.length === 0) {
        return {
          pass: false,
          score: 0,
          reason: toolFilter
            ? 'No calls to tool "' + toolFilter + '" found'
            : 'No tool calls found',
        };
      }

      // Tool output is recorded as a string. Parse it as JSON when possible so
      // expected keys can be matched against the structured result; otherwise
      // fall back to substring containment against the raw text.
      const match = targetCalls.some(tc => {
        const raw = tc.output != null ? tc.output : tc.result;
        const rawStr = typeof raw === 'string' ? raw : JSON.stringify(raw);
        let parsed;
        if (raw && typeof raw === 'object') {
          parsed = raw;
        } else if (typeof raw === 'string') {
          try { parsed = JSON.parse(raw); } catch (e) { parsed = undefined; }
        }
        return Object.entries(expected).every(([k, v]) => {
          if (parsed && typeof parsed === 'object' &&
              JSON.stringify(parsed[k]) === JSON.stringify(v)) {
            return true;
          }
          const needle = typeof v === 'string' ? v : JSON.stringify(v);
          return typeof rawStr === 'string' && rawStr.indexOf(needle) !== -1;
        });
      });

      return {
        pass: match,
        score: match ? 1 : 0,
        reason: match
          ? 'Tool output matches expected values'
          : 'Tool output does not match. Expected: ' + JSON.stringify(expected),
      };`),
    };
}
// ---------------------------------------------------------------------------
// tool-input-schema — asserts a recorded tool input conforms to a JSON Schema
// ---------------------------------------------------------------------------
/**
 * Validate a recorded call's input (its arguments) against a JSON Schema.
 *
 * Distinct from `tool-input-matches` (partial value-equality): this answers a
 * shape/type question — "is `query` a string?", "are the required fields
 * present?" — regardless of exact values. Passes when ANY targeted call's
 * input conforms (mirroring the `.some()` semantics of `tool-input-matches`).
 */
function buildToolInputSchemaAssertion(assertion, context, warnings) {
    const toolName = assertion.toolName ?? assertion.tool;
    const label = `tool-input-schema${toolName ? `: ${String(toolName)}` : ""}`;
    let base64;
    try {
        base64 = compileSchemaValidatorFactory(assertion.value, `MCP task "${context.taskId}": ${label}`);
    }
    catch (err) {
        return buildSchemaCompileFailure(label, err, warnings);
    }
    return {
        type: "javascript",
        value: buildJsAssertion(label, `
      ${toolCallsSource()}
      ${inlineSchemaValidator(base64)}
      ${schemaErrorFormatterSource()}
      var toolFilter = ${JSON.stringify(toolName ?? null)};
      var targetCalls = toolFilter
        ? toolCalls.filter(function(tc) { return tc.name === toolFilter; })
        : toolCalls;

      if (targetCalls.length === 0) {
        return {
          pass: false,
          score: 0,
          reason: toolFilter
            ? 'No calls to tool "' + toolFilter + '" found'
            : 'No tool calls found',
        };
      }

      var lastError = '';
      var match = targetCalls.some(function(tc) {
        var input = tc.input || tc.arguments || {};
        var ok = validate(input);
        if (!ok) { lastError = formatSchemaErrors(validate.errors); }
        return ok;
      });

      return {
        pass: match,
        score: match ? 1 : 0,
        reason: match
          ? 'Tool input conforms to the schema'
          : 'Tool input does not conform to the schema: ' + lastError,
      };`),
    };
}
// ---------------------------------------------------------------------------
// tool-output-schema — asserts a recorded tool output conforms to a JSON Schema
// ---------------------------------------------------------------------------
/**
 * Validate a recorded call's output against a JSON Schema.
 *
 * Output is recorded as a string. It is parsed as JSON when possible so the
 * schema validates the structured result; a non-JSON output is validated as a
 * raw string (so `{ type: "string" }` passes and `{ type: "array" }` fails
 * with a clear reason). Passes when ANY targeted call's output conforms.
 */
function buildToolOutputSchemaAssertion(assertion, context, warnings) {
    const toolName = assertion.toolName ?? assertion.tool;
    const label = `tool-output-schema${toolName ? `: ${String(toolName)}` : ""}`;
    let base64;
    try {
        base64 = compileSchemaValidatorFactory(assertion.value, `MCP task "${context.taskId}": ${label}`);
    }
    catch (err) {
        return buildSchemaCompileFailure(label, err, warnings);
    }
    return {
        type: "javascript",
        value: buildJsAssertion(label, `
      ${toolCallsSource()}
      ${inlineSchemaValidator(base64)}
      ${schemaErrorFormatterSource()}
      var toolFilter = ${JSON.stringify(toolName ?? null)};
      var targetCalls = toolFilter
        ? toolCalls.filter(function(tc) { return tc.name === toolFilter; })
        : toolCalls;

      if (targetCalls.length === 0) {
        return {
          pass: false,
          score: 0,
          reason: toolFilter
            ? 'No calls to tool "' + toolFilter + '" found'
            : 'No tool calls found',
        };
      }

      var lastError = '';
      var match = targetCalls.some(function(tc) {
        var raw = tc.output != null ? tc.output : tc.result;
        var data;
        if (raw && typeof raw === 'object') {
          data = raw;
        } else if (typeof raw === 'string') {
          try { data = JSON.parse(raw); } catch (e) { data = raw; }
        } else {
          data = raw;
        }
        var ok = validate(data);
        if (!ok) { lastError = formatSchemaErrors(validate.errors); }
        return ok;
      });

      return {
        pass: match,
        score: match ? 1 : 0,
        reason: match
          ? 'Tool output conforms to the schema'
          : 'Tool output does not conform to the schema: ' + lastError,
      };`),
    };
}
/**
 * Build a deterministically-failing `javascript` assertion for a schema that
 * could not be compiled (invalid schema or unsupported keyword). Surfaces the
 * cause both as a compile-time warning and as a runtime failure reason — a
 * bad schema must never silently vanish into a vacuous pass.
 */
function buildSchemaCompileFailure(label, err, warnings) {
    const message = err instanceof Error ? err.message : String(err);
    warnings.push(message);
    return {
        type: "javascript",
        value: buildJsAssertion(label, `return { pass: false, score: 0, reason: ${JSON.stringify(message)} };`),
    };
}
// ---------------------------------------------------------------------------
// error-returned — asserts the server returned a specific error
// ---------------------------------------------------------------------------
function buildErrorReturnedAssertion(assertion, _context) {
    const expected = assertion.value;
    return {
        type: "javascript",
        value: buildJsAssertion("error-returned", `
      ${toolCallsSource()}
      const expected = ${JSON.stringify(expected ?? {})};

      // A failed call records its message on tc.error (and a JSON-RPC code on
      // tc.errorCode when the thrown error carried one).
      const errorCall = toolCalls.find(tc => tc.error !== undefined && tc.error !== null);
      if (!errorCall) {
        return {
          pass: false,
          score: 0,
          reason: 'Expected an error response but no tool call returned an error',
        };
      }

      const message = typeof errorCall.error === 'string'
        ? errorCall.error
        : JSON.stringify(errorCall.error);
      let code = typeof errorCall.errorCode === 'number' ? errorCall.errorCode : undefined;
      // Best-effort: recover a code from a JSON error payload in the message.
      if (code === undefined) {
        try {
          const parsed = JSON.parse(message);
          if (parsed && typeof parsed.code === 'number') {
            code = parsed.code;
          } else if (parsed && parsed.error && typeof parsed.error.code === 'number') {
            code = parsed.error.code;
          }
        } catch (e) { /* message is not JSON — no code available */ }
      }

      let pass = true;
      const reasons = [];

      if (expected.code !== undefined) {
        if (code === undefined) {
          pass = false;
          reasons.push('Expected error code ' + expected.code + ' but the tool error carried no code');
        } else if (code !== expected.code) {
          pass = false;
          reasons.push('Expected error code ' + expected.code + ', got ' + code);
        }
      }

      if (expected.message !== undefined) {
        const msgMatch = typeof message === 'string' && message.indexOf(expected.message) !== -1;
        if (!msgMatch) {
          pass = false;
          reasons.push('Expected error message containing "' + expected.message +
            '", got "' + message + '"');
        }
      }

      if (pass) {
        reasons.push('Error matches expected pattern');
      }

      return {
        pass,
        score: pass ? 1 : 0,
        reason: reasons.join('; '),
      };`),
    };
}
// ---------------------------------------------------------------------------
// capability-available — asserts the server advertises a capability
// ---------------------------------------------------------------------------
function buildCapabilityAssertion(assertion, _context) {
    const capability = String(assertion.value ?? "");
    return {
        type: "javascript",
        value: buildJsAssertion(`capability-available: ${capability}`, `
      ${capabilitiesSource()}
      const expected = ${JSON.stringify(capability)};
      const available = capabilities.includes(expected);

      return {
        pass: available,
        score: available ? 1 : 0,
        reason: available
          ? 'Server advertises capability "' + expected + '"'
          : 'Server does not advertise capability "' + expected + '". ' +
            'Available: ' + (capabilities.join(', ') || 'none'),
      };`),
    };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * Build a Promptfoo-compatible JavaScript assertion string.
 *
 * Wraps the assertion body in a function that receives `output` and `context`
 * from Promptfoo's assertion runner.
 */
function buildJsAssertion(label, body) {
    // No IIFE wrapper — Promptfoo wraps the assertion in its own function via
    // new Function('output', 'context', ...). The body must use `return` at
    // the top level for the result to reach Promptfoo's validator.
    return `// MCP assertion: ${label}\n${body.trim()}`;
}
/**
 * JS snippet that resolves the recorded tool-call log into a `toolCalls` array.
 *
 * The MCP tool provider exposes the log as `metadata.toolCalls` on its
 * response; Promptfoo passes that through as `context.providerResponse.metadata`.
 * `context.vars.__toolCalls` is checked first so a caller can inject the log
 * explicitly (e.g. in tests or a future transform). Without this bridge the
 * structured assertions read an unpopulated var and always score 0.
 */
function toolCallsSource() {
    return `var toolCalls = (context && context.vars && context.vars.__toolCalls) ||
        (context && context.providerResponse && context.providerResponse.metadata &&
          context.providerResponse.metadata.toolCalls) || [];
      if (!Array.isArray(toolCalls)) { toolCalls = []; }`;
}
/**
 * JS snippet that resolves the server's discovered capabilities into a
 * `capabilities` array, mirroring {@link toolCallsSource}. Populated from the
 * provider's `metadata.serverCapabilities` (the tool names returned by the
 * server's `tools/list`).
 */
function capabilitiesSource() {
    return `var capabilities = (context && context.vars && context.vars.__serverCapabilities) ||
        (context && context.providerResponse && context.providerResponse.metadata &&
          context.providerResponse.metadata.serverCapabilities) || [];
      if (!Array.isArray(capabilities)) { capabilities = []; }`;
}
