/**
 * @sanity/ailf-core — Pipeline schemas
 *
 * Zod schemas for runtime validation of task YAML files and related configs.
 * These schemas are the single source of truth for what constitutes a valid
 * task definition — the TypeScript interfaces in types.ts are derived from
 * (or should match) these schemas.
 *
 * Extracted from packages/eval/src/pipeline/schemas.ts (Phase 0d).
 */
import { z } from "zod";
// ---------------------------------------------------------------------------
// Rubric template schemas — validates config/rubrics.yaml
// ---------------------------------------------------------------------------
/**
 * Schema for a single rubric template (e.g., task-completion, code-correctness).
 *
 * The optional `dimension` field tags which scoring dimension this template
 * belongs to. When present, it propagates into assertion metadata so the
 * scoring engine can classify rubrics structurally instead of heuristically.
 * See docs/design-docs/structured-dimensions.md.
 */
export const RubricTemplateSchema = z.object({
    criteria_label: z
        .string()
        .min(1, "criteria_label must be a non-empty string")
        .nullish(),
    dimension: z.string().min(1).optional(),
    /**
     * Plan 03-02 — per-dimension legal failure-mode list. When present and
     * non-empty, the runtime rubric assembler announces the legal modes to
     * the grader before the structured-shape footer (Plan 03-01).
     * Stamped at config-load time by `failureModesForDimension(dimension)`
     * from `packages/eval/src/grader/index.ts`.
     */
    failureModes: z.array(z.string().min(1)).optional(),
    header: z.string().min(1, "header must be a non-empty string"),
    scale: z
        .array(z.string().min(1))
        .min(1, "scale must have at least one entry"),
});
/**
 * A named weight profile — maps dimension names to weights (must sum to 1.0).
 * Each profile is a self-contained scoring formula used for a specific
 * (mode, variant) pair.
 */
const WeightProfileSchema = z
    .record(z.string(), z.number().min(0).max(1))
    .refine((w) => {
    const sum = Object.values(w).reduce((s, v) => s + v, 0);
    return Math.abs(sum - 1.0) < 0.001;
}, { message: "profile weights must sum to 1.0" });
/**
 * Mode-to-profile bindings — maps (mode, perspective) pairs to profile names.
 *
 * Flat form (most modes):
 *   { "mcp-server": { gold: "mcp-behavior" } }
 *
 * Nested form (literacy mode with variant sub-keys):
 *   { literacy: { baseline: { gold: "default", baseline: "output-only" }, agentic: { gold: "default" } } }
 *
 * The nested form adds a variant level between mode and perspective,
 * allowing a single canonical mode to host multiple scoring variants.
 */
const ModeProfileEntrySchema = z.union([
    z.string(),
    z.record(z.string(), z.string()),
]);
const ModeProfilesSchema = z.record(z.string(), z.record(z.string(), ModeProfileEntrySchema));
/**
 * Schema for the full config/rubrics.yaml config file.
 *
 * Each dimension is scored on a uniform 0–100 scale. Named scoring profiles
 * define how dimensions are combined into composite scores. Mode-profile
 * bindings declare which profile to use for each (mode, variant) pair.
 *
 * Supports both the new `profiles` format and the legacy flat `weights`
 * format for backward compatibility.
 *
 * @see docs/design-docs/named-scoring-profiles.md
 */
export const RubricConfigSchema = z
    .object({
    footer: z.string().min(1, "footer must be a non-empty string"),
    "mode-profiles": ModeProfilesSchema.optional(),
    profiles: z
        .record(z.string(), WeightProfileSchema)
        .refine((p) => "default" in p, {
        message: "profiles must include a 'default' profile",
    })
        .optional(),
    templates: z
        .record(z.string(), RubricTemplateSchema)
        .refine((t) => Object.keys(t).length > 0, {
        message: "templates must have at least one entry",
    }),
    // Legacy: flat weight map. Treated as a single profile named "default".
    weights: WeightProfileSchema.optional(),
})
    .refine((c) => c.profiles !== undefined || c.weights !== undefined, {
    message: "rubrics.yaml must have either 'profiles' or 'weights'",
});
// ---------------------------------------------------------------------------
// Feature registry schema — validates config/features.yaml (Phase 3c)
// ---------------------------------------------------------------------------
/**
 * Schema for a single product feature in the feature registry.
 */
export const FeatureSchema = z.object({
    area: z.string().min(1).optional(),
    id: z
        .string()
        .min(1)
        .regex(/^[a-z0-9][a-z0-9-]*$/, "id must be lowercase alphanumeric with hyphens"),
    name: z.string().min(1),
    priority: z.enum(["critical", "high", "medium", "low"]),
    sections: z.array(z.string().min(1)).min(1),
    status: z.enum(["covered", "uncovered", "planned", "out-of-scope"]),
});
/**
 * Schema for the full config/features.yaml config file.
 */
export const FeatureRegistrySchema = z.object({
    features: z.array(FeatureSchema).min(1),
});
// ---------------------------------------------------------------------------
// Assertion schemas — one per Promptfoo assertion type
// ---------------------------------------------------------------------------
// TODO(GRAD-01 follow-up): This schema duplicates
// packages/eval/src/adapters/task-sources/repo-schemas.ts:TemplatedAssertionSchema.
// Retiring requires reverse-extracting the canonical schema into
// packages/core/src/schemas/ (D0048 prevents importing from packages/eval).
// Out of Phase 2 scope; tracked separately.
//
// The `satisfies z.ZodType<CriterionRef>` clause asserts this duplicate
// stays shape-compatible with the canonical domain type in
// `@sanity/ailf-core` (D0045). If a future edit adds a third field to one
// schema and not the other, this build error catches the drift.
const CriterionRefShape = z.object({
    id: z.string().min(1, "id must be a non-empty slug"),
    text: z.string().min(1, "text must be a non-empty string"),
});
/**
 * Templated llm-rubric assertion — references a rubric template by key
 * and provides task-specific criteria.
 */
const TemplatedLlmRubricAssertSchema = z.object({
    criteria: z
        .array(CriterionRefShape)
        .min(1, "criteria must have at least one entry"),
    template: z.string().min(1, "template must be a non-empty string"),
    type: z.literal("llm-rubric"),
    weight: z.number().optional(),
});
const ContainsAssertSchema = z.object({
    type: z.literal("contains"),
    value: z.string().min(1, "contains must have a non-empty value"),
    weight: z.number().optional(),
});
const ContainsAnyAssertSchema = z.object({
    type: z.literal("contains-any"),
    value: z
        .array(z.string().min(1))
        .min(1, "contains-any must have at least one value"),
    weight: z.number().optional(),
});
const NotContainsAssertSchema = z.object({
    type: z.literal("not-contains"),
    value: z.string().min(1, "not-contains must have a non-empty value"),
    weight: z.number().optional(),
});
const JavascriptAssertSchema = z.object({
    type: z.literal("javascript"),
    value: z.string().min(1, "javascript must have a non-empty value"),
    weight: z.number().optional(),
});
/**
 * Union of all supported Promptfoo assertion types.
 *
 * llm-rubric assertions use the templated format (template + criteria).
 * Other assertion types use their standard value-based format.
 *
 * Note: we use z.union instead of z.discriminatedUnion because multiple
 * schemas share the "llm-rubric" type literal (the old value-based format
 * is gone, but union is more flexible for future additions).
 */
export const AssertionSchema = z.union([
    TemplatedLlmRubricAssertSchema,
    ContainsAssertSchema,
    ContainsAnyAssertSchema,
    NotContainsAssertSchema,
    JavascriptAssertSchema,
]);
// ---------------------------------------------------------------------------
// Task variable schemas
// ---------------------------------------------------------------------------
const TaskVarsSchema = z
    .object({
    docs: z.string({
        message: "vars.docs must be a string (use empty string for baseline)",
    }),
    task: z.string().min(1, "vars.task must be a non-empty string"),
})
    .passthrough();
// ---------------------------------------------------------------------------
// Canonical doc mapping schema (inline in task files)
// ---------------------------------------------------------------------------
/**
 * Polymorphic canonical doc reference — discriminated by key presence.
 *
 * Exactly one resolution key must be present: slug, path, id, or perspective.
 * All carry an optional reason. The union uses Zod discriminatedUnion-like
 * semantics but keyed on field presence rather than a literal discriminator.
 *
 * @see docs/design-docs/canonical-doc-resolution.md
 */
const SlugDocRefSchema = z.object({
    reason: z.string().optional().default(""),
    slug: z.string().min(1, "slug must be a non-empty string"),
});
const PathDocRefSchema = z.object({
    path: z.string().min(1, "path must be a non-empty string"),
    reason: z.string().optional().default(""),
});
const IdDocRefSchema = z.object({
    id: z.string().min(1, "id must be a non-empty string"),
    reason: z.string().optional().default(""),
});
const PerspectiveDocRefSchema = z.object({
    perspective: z.string().min(1, "perspective must be a non-empty string"),
    reason: z.string().optional().default(""),
});
const CanonicalDocSchema = z.union([
    SlugDocRefSchema,
    PathDocRefSchema,
    IdDocRefSchema,
    PerspectiveDocRefSchema,
]);
// ---------------------------------------------------------------------------
// Baseline options schema
// ---------------------------------------------------------------------------
const BaselineOptionsSchema = z.object({
    enabled: z.boolean().optional(),
    rubric: z.enum(["abbreviated", "full", "none"]).optional(),
});
// ---------------------------------------------------------------------------
// Task entry schemas — new (single-definition) and legacy formats
// ---------------------------------------------------------------------------
/**
 * Schema for the new single-definition task format.
 * Each task is defined once with an id field; the pipeline auto-generates
 * gold + baseline variants.
 */
export const SingleTaskSchema = z.object({
    assert: z
        .array(AssertionSchema)
        .min(1, "assert must have at least one entry"),
    baseline: BaselineOptionsSchema.optional(),
    /** Canonical docs — which documentation articles are gold-standard for this task. */
    canonical_docs: z
        .array(CanonicalDocSchema)
        .min(1, "canonical_docs must have at least one entry"),
    description: z.string().min(1, "description must be a non-empty string"),
    /** Opt-in: auto-generate a documentation coverage rubric for the gold variant. */
    doc_coverage: z.boolean().optional(),
    id: z
        .string()
        .min(1, "id must be a non-empty string")
        .regex(/^[a-z0-9][a-z0-9-]*$/, "id must be lowercase alphanumeric with hyphens (e.g., 'groq-blog-queries')"),
    /** Path to the reference solution file, relative to canonical/. */
    reference_solution: z
        .string()
        .min(1, "reference_solution must be a non-empty path"),
    vars: TaskVarsSchema,
});
/**
 * Schema for legacy task entries (the old paired gold/baseline format).
 * These have description and vars but no id field.
 */
export const LegacyTaskSchema = z.object({
    assert: z.array(AssertionSchema).optional(),
    description: z.string().min(1, "description must be a non-empty string"),
    transform: z.string().optional(),
    vars: z
        .object({
        task: z.string().min(1, "vars.task must be a non-empty string"),
    })
        .passthrough(),
});
// ---------------------------------------------------------------------------
// Task entry — union of both formats
// ---------------------------------------------------------------------------
/**
 * A single task entry that can be either format.
 * Entries with an id field are validated as SingleTask;
 * entries without are validated as LegacyTask.
 */
export const TaskEntrySchema = z.union([SingleTaskSchema, LegacyTaskSchema]);
// ---------------------------------------------------------------------------
// Task file schema — the top-level array
// ---------------------------------------------------------------------------
/**
 * Schema for an entire task YAML file: an array of task entries.
 */
export const TaskFileSchema = z
    .array(TaskEntrySchema)
    .min(1, "Task file must contain at least one task entry");
// ---------------------------------------------------------------------------
// Threshold config schemas — validates config/thresholds.yaml (Phase 5a)
// ---------------------------------------------------------------------------
/**
 * Schema for per-dimension threshold values.
 * Uses a dynamic record to support all evaluation modes, not just literacy.
 * Keys use kebab-case to match YAML convention; the threshold engine
 * normalizes to camelCase for comparison against FeatureScore fields.
 */
export const ThresholdDimensionsSchema = z.record(z.string(), z.number().min(0).max(100));
/**
 * Schema for threshold defaults (and per-area overrides).
 * All fields are optional in per-area overrides; defaults must have composite.
 */
export const ThresholdDefaultsSchema = z.object({
    ceiling: z.number().min(0).max(100).optional(),
    composite: z.number().min(0).max(100),
    dimensions: ThresholdDimensionsSchema.optional(),
    "doc-lift": z.number().optional(),
});
/**
 * Schema for regression thresholds (all values should be negative or zero).
 */
export const RegressionThresholdsSchema = z.object({
    composite: z.number().max(0),
    "per-area": z.number().max(0),
    "per-dimension": z.number().max(0),
});
/**
 * Schema for severity classification rules.
 */
export const SeverityConfigSchema = z.object({
    critical: z
        .object({
        "composite-below": z.number().min(0).max(100).optional(),
        "negative-doc-lift": z.boolean().optional(),
    })
        .optional(),
    info: z
        .object({
        "composite-below": z.number().min(0).max(100).optional(),
    })
        .optional(),
    warning: z
        .object({
        "composite-below": z.number().min(0).max(100).optional(),
        "regression-exceeds": z.number().optional(),
    })
        .optional(),
});
/**
 * Schema for the complete config/thresholds.yaml file.
 */
export const ThresholdConfigSchema = z.object({
    areas: z.record(z.string(), ThresholdDefaultsSchema.partial()).optional(),
    defaults: ThresholdDefaultsSchema,
    regression: RegressionThresholdsSchema.optional(),
    severity: SeverityConfigSchema.optional(),
});
// ---------------------------------------------------------------------------
// Error formatting
// ---------------------------------------------------------------------------
/**
 * Format a Zod error into human-readable lines suitable for CLI output.
 * Each line includes the path (e.g., "[2].vars.task") and the error message.
 */
export function formatZodErrors(error) {
    return error.issues.map((issue) => {
        const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
        return "  [" + path + "]: " + issue.message;
    });
}
