import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname, isAbsolute, join } from 'node:path';
import { parseYaml } from './load-samples.js';
const VALID_ROLES = ['control', 'treatment'];
/**
 * Load and validate an eval.yaml (or .json) config file.
 * All relative paths in the config are resolved against the config file's directory.
 */
export function loadEvalConfig(configPath) {
    const absPath = resolve(configPath);
    if (!existsSync(absPath)) {
        throw new Error(`--config file does not exist: ${absPath}`);
    }
    const raw = readFileSync(absPath, 'utf-8');
    const isJson = absPath.endsWith('.json');
    const parsed = isJson ? JSON.parse(raw) : parseYaml(raw);
    const config = validateEvalConfig(parsed, configPath);
    return resolveConfigPaths(config, dirname(absPath));
}
/**
 * Convert EvalConfig.variants into VariantSpec[] (the CLI-internal representation).
 * A variant's `cwd` (if present) is merged into the expression as `artifact@cwd`
 * so the downstream variant resolver can treat CLI and config uniformly.
 */
export function configVariantsToSpecs(variants) {
    return variants.map((v) => ({
        name: v.name,
        role: v.role,
        expr: v.cwd ? `${v.artifact}@${v.cwd}` : v.artifact,
    }));
}
function validateEvalConfig(parsed, configPath) {
    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
        throw new Error(`${configPath}: top level must be an object`);
    }
    const obj = parsed;
    if (typeof obj.samples !== 'string' || !obj.samples) {
        throw new Error(`${configPath}: 'samples' is required and must be a string`);
    }
    if (!Array.isArray(obj.variants) || obj.variants.length === 0) {
        throw new Error(`${configPath}: 'variants' is required and must be a non-empty array`);
    }
    const variants = [];
    const seen = new Set();
    let hasAnyRole = false;
    for (const [i, raw] of obj.variants.entries()) {
        if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
            throw new Error(`${configPath}: variants[${i}] must be an object`);
        }
        const v = raw;
        if (typeof v.name !== 'string' || !v.name) {
            throw new Error(`${configPath}: variants[${i}].name is required and must be a string`);
        }
        if (typeof v.role !== 'string' || !VALID_ROLES.includes(v.role)) {
            throw new Error(`${configPath}: variants[${i}].role must be 'control' or 'treatment' (got: ${JSON.stringify(v.role)})`);
        }
        if (typeof v.artifact !== 'string' || !v.artifact) {
            throw new Error(`${configPath}: variants[${i}].artifact is required and must be a string`);
        }
        if (v.cwd !== undefined && typeof v.cwd !== 'string') {
            throw new Error(`${configPath}: variants[${i}].cwd must be a string`);
        }
        //  — allowedSkills schema check. YAML key without value parses as null,
        // which is ambiguous ("none" vs "default"); reject so users must write `[]`
        // explicitly. undefined (key absent) means "use --strict-baseline default".
        let allowedSkills;
        if (v.allowedSkills !== undefined) {
            if (v.allowedSkills === null) {
                throw new Error(`${configPath}: variants[${i}].allowedSkills must be an array (use [] to disable skill discovery, or omit the key for default behavior). YAML \`allowedSkills:\` without a value parses as null, which is ambiguous.`);
            }
            if (!Array.isArray(v.allowedSkills)) {
                throw new Error(`${configPath}: variants[${i}].allowedSkills must be an array of strings`);
            }
            for (const [j, name] of v.allowedSkills.entries()) {
                if (typeof name !== 'string' || !name) {
                    throw new Error(`${configPath}: variants[${i}].allowedSkills[${j}] must be a non-empty string`);
                }
            }
            allowedSkills = v.allowedSkills;
        }
        if (seen.has(v.name)) {
            throw new Error(`${configPath}: variants[${i}].name "${v.name}" is duplicated`);
        }
        seen.add(v.name);
        hasAnyRole = true;
        variants.push({
            name: v.name,
            role: v.role,
            artifact: v.artifact,
            cwd: v.cwd,
            ...(allowedSkills !== undefined && { allowedSkills }),
        });
    }
    if (!hasAnyRole) {
        throw new Error(`${configPath}: variants must contain at least one control or treatment entry`);
    }
    const assertStringOpt = (key) => {
        if (obj[key] !== undefined && typeof obj[key] !== 'string') {
            throw new Error(`${configPath}: ${key} must be a string`);
        }
    };
    const assertNumberOpt = (key) => {
        if (obj[key] !== undefined && typeof obj[key] !== 'number') {
            throw new Error(`${configPath}: ${key} must be a number`);
        }
    };
    const assertBoolOpt = (key) => {
        if (obj[key] !== undefined && typeof obj[key] !== 'boolean') {
            throw new Error(`${configPath}: ${key} must be a boolean`);
        }
    };
    assertStringOpt('executor');
    assertStringOpt('model');
    if (obj.judgeModel !== undefined || obj.judgeExecutor !== undefined) {
        throw new Error(`${configPath}: \`judgeModel\` and \`judgeExecutor\` were removed in v0.25 — use \`judgeModels: [{executor, model}]\` instead (single judge is the 1-entry case). See README.`);
    }
    assertNumberOpt('concurrency');
    assertNumberOpt('timeoutMs');
    assertBoolOpt('noCache');
    assertBoolOpt('noJudge');
    assertBoolOpt('blind');
    assertStringOpt('mcpConfig');
    assertStringOpt('goldDir');
    assertBoolOpt('bootstrap');
    assertBoolOpt('lengthDebias');
    assertBoolOpt('strictBaseline');
    // experiment-design integers ≥ 1
    const assertPositiveIntOpt = (key) => {
        if (obj[key] === undefined)
            return;
        if (typeof obj[key] !== 'number' || !Number.isFinite(obj[key]) || obj[key] < 1 || !Number.isInteger(obj[key])) {
            throw new Error(`${configPath}: ${key} must be a positive integer (≥ 1)`);
        }
    };
    assertPositiveIntOpt('repeat');
    assertPositiveIntOpt('judgeRepeat');
    if (obj.bootstrapSamples !== undefined) {
        if (typeof obj.bootstrapSamples !== 'number' || !Number.isFinite(obj.bootstrapSamples) || obj.bootstrapSamples < 100) {
            throw new Error(`${configPath}: bootstrapSamples must be a number ≥ 100`);
        }
    }
    // judgeModels: array of { executor, model } — unified judge config.
    // 1 条 = single judge (no ensemble); ≥ 2 条 = ensemble + inter-judge agreement。空数组 reject。
    // 重复 executor:model 拒绝:ensemble 聚合按 judge id 去重(参见 schema.ts buildEnsembleAggregate),
    // 重复条目会让 N 不可信、agreement 失真,而 grading 又会照样跑 N 次。
    let judgeModelsParsed;
    if (obj.judgeModels !== undefined) {
        if (!Array.isArray(obj.judgeModels)) {
            throw new Error(`${configPath}: judgeModels must be an array of {executor, model}`);
        }
        if (obj.judgeModels.length === 0) {
            throw new Error(`${configPath}: judgeModels must have ≥ 1 entry (omit the field for default judge)`);
        }
        judgeModelsParsed = [];
        const seenJudgeKeys = new Set();
        for (const [i, raw] of obj.judgeModels.entries()) {
            if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
                throw new Error(`${configPath}: judgeModels[${i}] must be an object {executor, model}`);
            }
            const j = raw;
            if (typeof j.executor !== 'string' || !j.executor) {
                throw new Error(`${configPath}: judgeModels[${i}].executor must be a non-empty string`);
            }
            if (typeof j.model !== 'string' || !j.model) {
                throw new Error(`${configPath}: judgeModels[${i}].model must be a non-empty string`);
            }
            const key = `${j.executor}:${j.model}`;
            if (seenJudgeKeys.has(key)) {
                throw new Error(`${configPath}: judgeModels[${i}] is a duplicate entry "${key}"; ensemble 聚合按 executor:model 去重,重复条目会让 N 不可信、agreement 失真`);
            }
            seenJudgeKeys.add(key);
            judgeModelsParsed.push({ executor: j.executor, model: j.model });
        }
    }
    //  — budget validation. Top-level `budget: { totalUSD?, perSampleUSD?, perSampleMs? }`.
    let budget;
    if (obj.budget !== undefined) {
        if (typeof obj.budget !== 'object' || obj.budget === null || Array.isArray(obj.budget)) {
            throw new Error(`${configPath}: budget must be an object`);
        }
        const b = obj.budget;
        for (const k of ['totalUSD', 'perSampleUSD', 'perSampleMs']) {
            if (b[k] !== undefined && (typeof b[k] !== 'number' || b[k] < 0)) {
                throw new Error(`${configPath}: budget.${k} must be a non-negative number`);
            }
        }
        budget = {
            totalUSD: b.totalUSD,
            perSampleUSD: b.perSampleUSD,
            perSampleMs: b.perSampleMs,
        };
    }
    // effort 字段:可选,合法值检查
    let effort;
    if (obj.effort !== undefined) {
        const VALID_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
        if (typeof obj.effort !== 'string' || !VALID_EFFORTS.has(obj.effort)) {
            throw new Error(`${configPath}: effort must be one of low/medium/high/xhigh/max (got ${JSON.stringify(obj.effort)})`);
        }
        effort = obj.effort;
    }
    // noDiagnostic / skipDoctor:可选 boolean
    if (obj.noDiagnostic !== undefined && typeof obj.noDiagnostic !== 'boolean') {
        throw new Error(`${configPath}: noDiagnostic must be boolean (got ${typeof obj.noDiagnostic})`);
    }
    if (obj.skipDoctor !== undefined && typeof obj.skipDoctor !== 'boolean') {
        throw new Error(`${configPath}: skipDoctor must be boolean (got ${typeof obj.skipDoctor})`);
    }
    return {
        samples: obj.samples,
        executor: obj.executor,
        model: obj.model,
        judgeModels: judgeModelsParsed,
        concurrency: obj.concurrency,
        timeoutMs: obj.timeoutMs,
        noCache: obj.noCache,
        noJudge: obj.noJudge,
        blind: obj.blind,
        mcpConfig: obj.mcpConfig,
        variants,
        budget,
        repeat: obj.repeat,
        judgeRepeat: obj.judgeRepeat,
        bootstrap: obj.bootstrap,
        bootstrapSamples: obj.bootstrapSamples,
        goldDir: obj.goldDir,
        lengthDebias: obj.lengthDebias,
        strictBaseline: obj.strictBaseline,
        effort,
        noDiagnostic: obj.noDiagnostic,
        skipDoctor: obj.skipDoctor,
    };
}
function resolveConfigPaths(config, configDir) {
    const resolveRel = (p) => (isAbsolute(p) ? p : join(configDir, p));
    // Artifact expressions that are not file paths (baseline / git: / plain names) stay as-is.
    const looksLikePath = (expr) => expr.startsWith('./') || expr.startsWith('../') || expr.startsWith('/') || /\.(md|yaml|yml|json)$/i.test(expr);
    const isNonPathExpr = (expr) => expr === 'baseline' || expr.startsWith('git:');
    return {
        ...config,
        samples: resolveRel(config.samples),
        mcpConfig: config.mcpConfig ? resolveRel(config.mcpConfig) : undefined,
        goldDir: config.goldDir ? resolveRel(config.goldDir) : undefined,
        variants: config.variants.map((v) => ({
            ...v,
            artifact: isNonPathExpr(v.artifact) ? v.artifact : (looksLikePath(v.artifact) ? resolveRel(v.artifact) : v.artifact),
            cwd: v.cwd ? resolveRel(v.cwd) : undefined,
        })),
    };
}
//# sourceMappingURL=eval-config.js.map