/**
 * core/services/scoring.ts — Pure scoring functions.
 *
 * These functions operate on typed inputs and produce typed outputs
 * with zero I/O (no filesystem, network, or console access).
 *
 * Extracted from packages/eval/src/lib/calculate-scores.ts during
 * the Ports & Adapters migration (Phase 4e).
 */
// ---------------------------------------------------------------------------
// Dimension extraction
// ---------------------------------------------------------------------------
/**
 * Extract dimension names from a scoring profile's weight map.
 *
 * Scoring profiles (defined in config/rubrics.ts) map dimension names
 * to numeric weights. This function returns those dimension names so
 * callers can work with dynamic dimensions instead of hardcoded ones.
 */
export function extractDimensions(profile) {
    return Object.keys(profile);
}
// ---------------------------------------------------------------------------
// Rubric classification
// ---------------------------------------------------------------------------
/**
 * Classify a grading component into a scoring dimension.
 *
 * Prefers structured metadata (Approach 5) over heuristic string matching.
 * Returns the dimension as a kebab-case string, or null if the component
 * doesn't map to any dimension.
 *
 * Returns `string | null` so non-literacy scoring profiles (MCP, agent,
 * knowledge-probe) can define arbitrary dimension names in metadata
 * without requiring changes here.
 */
export function classifyRubric(component) {
    // Prefer structured metadata — any dimension name is valid, enabling
    // non-literacy profiles to pass through names like 'input-validation'
    const metadata = component.assertion?.metadata;
    if (metadata?.dimension) {
        return metadata.dimension;
    }
    // Fallback: heuristic name matching (for backward compatibility)
    const value = (component.assertion?.value ?? "").toLowerCase();
    if (value.includes("task completion")) {
        return "task-completion";
    }
    if (value.includes("code correctness")) {
        return "code-correctness";
    }
    if (value.includes("documentation coverage") ||
        value.includes("hallucinate")) {
        return "doc-coverage";
    }
    return null;
}
// ---------------------------------------------------------------------------
// Feature area detection
// ---------------------------------------------------------------------------
/**
 * Detect the feature area from a test description string.
 *
 * Uses keyword matching on the lowercased description.
 */
export function detectFeatureArea(description) {
    const desc = description.toLowerCase();
    if (desc.includes("portable text")) {
        return "portable-text";
    }
    if (desc.includes("content lake")) {
        return "content-lake";
    }
    if (desc.includes("image handling") || desc.includes("image asset")) {
        return "image-handling";
    }
    if (desc.includes("studio")) {
        return "studio-setup";
    }
    if (desc.includes("visual") ||
        desc.includes("presentation") ||
        desc.includes("live preview")) {
        return "visual-editing";
    }
    if (desc.includes("groq")) {
        return "groq";
    }
    if (desc.includes("function") || desc.includes("webhook")) {
        return "functions";
    }
    if (desc.includes("next") || desc.includes("app router")) {
        return "nextjs-live";
    }
    if (desc.includes("remix") ||
        desc.includes("nuxt") ||
        desc.includes("svelte")) {
        return "frameworks";
    }
    return "other";
}
// ---------------------------------------------------------------------------
// Score parsing
// ---------------------------------------------------------------------------
/**
 * Extract a numeric score (0–100) from a grading component result.
 *
 * Tries (in order):
 *   1. JSON-parsed reason (grader's native 0–100 scale — most reliable)
 *   2. Direct score field (may be Promptfoo-normalized to 0–1)
 *   3. Bare number in reason text
 *
 * Promptfoo's `llm-rubric` assertion normalizes `component.score` to
 * the 0–1 range for some providers (notably GPT models, ~50% of the
 * time) while leaving others in the grader's native 0–100 range. The
 * `reason` field always contains the raw grader JSON, so we prefer it.
 *
 * When falling back to `component.score`, values in (0, 1] are rescaled
 * to 0–100 since the rubric explicitly requests a 0–100 score and a
 * true score of 0 or 1 out of 100 is vanishingly unlikely.
 */
export function parseRubricScore(component) {
    // 1. Prefer reason-extracted score — always in the grader's native 0–100 scale
    if (component.reason) {
        try {
            const parsed = JSON.parse(component.reason);
            const obj = parsed;
            if (typeof obj.score === "number") {
                return obj.score;
            }
        }
        catch {
            // Not valid JSON — fall through to direct score or bare number extraction
        }
    }
    // 2. Direct score field — may be Promptfoo-normalized to 0–1
    if (typeof component.score === "number") {
        return normalizeScore(component.score);
    }
    // 3. Last resort: bare number in reason text
    if (component.reason) {
        const match = component.reason.match(/(\d+)/);
        if (match) {
            return parseInt(match[1], 10);
        }
    }
    return 0;
}
/**
 * Normalize a score that may be in either the 0–1 or 0–100 range.
 *
 * Promptfoo's `llm-rubric` assertion inconsistently normalizes
 * `component.score` to 0–1 for some providers. Since the rubric
 * explicitly requests scores on a 0–100 scale:
 *   - Scores in (0, 1] are rescaled to 0–100 (e.g., 0.95 → 95)
 *   - Score of exactly 0 stays 0 (genuine zero)
 *   - Scores > 1 are already on the 0–100 scale
 */
function normalizeScore(score) {
    if (score > 0 && score <= 1) {
        return Math.round(score * 100);
    }
    return score;
}
// ---------------------------------------------------------------------------
// URL metadata extraction
// ---------------------------------------------------------------------------
/**
 * Extract URL metadata from a test result's JavaScript assertion output.
 *
 * Returns null if the test doesn't contain URL extraction data.
 */
export function extractUrlMetadata(test) {
    for (const comp of test.gradingResult.componentResults) {
        if (comp.assertion?.type !== "javascript") {
            continue;
        }
        if (!comp.reason) {
            continue;
        }
        try {
            const parsed = JSON.parse(comp.reason);
            const obj = parsed;
            if (Array.isArray(obj.sanityUrls) &&
                typeof obj.totalUrlCount === "number") {
                return parsed;
            }
        }
        catch {
            // Not the URL-extraction assertion - skip
        }
    }
    return null;
}
// ---------------------------------------------------------------------------
// Score merging (three-layer decomposition)
// ---------------------------------------------------------------------------
/**
 * Merge baseline FeatureScore[] with agentic actual scores to produce
 * the full three-layer decomposition.
 *
 * The merge is per feature area. For each area:
 * - If baseline data exists: floor, ceiling, docLift, docQualityGap are populated
 * - If agentic data exists: actualScore is populated
 * - If both exist: retrievalGap and infrastructureEfficiency are computed
 *
 * @param baselineScores  Floor/ceiling scores from baseline evaluation (may be empty)
 * @param agenticScores   Actual scores from agentic evaluation (may be empty)
 */
export function mergeScores(baselineScores, agenticScores) {
    // Build a map of baseline scores by feature
    const baselineMap = new Map();
    for (const score of baselineScores) {
        baselineMap.set(score.feature, score);
    }
    // Collect all feature areas from both sources
    const allFeatures = new Set([
        ...baselineScores.map((s) => s.feature),
        ...Object.keys(agenticScores),
    ]);
    const merged = [];
    for (const feature of allFeatures) {
        const baseline = baselineMap.get(feature);
        const agentic = agenticScores[feature];
        if (baseline && agentic) {
            // Both data sources — full decomposition
            const retrievalGap = baseline.ceilingScore - agentic.actualScore;
            const negativeDocLift = baseline.docLift < 0;
            // Infrastructure efficiency: actual / ceiling
            // Null when ceiling ≤ 0 or negative Doc Lift (the metric is meaningless
            // when docs hurt performance — see evaluation-ceiling.md)
            let infrastructureEfficiency = null;
            if (!negativeDocLift && baseline.ceilingScore > 0) {
                infrastructureEfficiency = agentic.actualScore / baseline.ceilingScore;
            }
            // Inverted retrieval gap: agents outperform injected docs.
            // This happens when Doc Lift is negative AND actual > ceiling.
            // It means retrieval failure is masking a doc quality problem.
            const invertedRetrievalGap = negativeDocLift && retrievalGap < 0;
            merged.push({
                ...baseline,
                actualScore: agentic.actualScore,
                infrastructureEfficiency,
                invertedRetrievalGap: invertedRetrievalGap || undefined,
                retrievalGap,
                totalCost: baseline.totalCost + agentic.totalCost,
            });
        }
        else if (baseline) {
            // Baseline only — no agentic data (partial summary)
            merged.push({ ...baseline });
        }
        else if (agentic) {
            // Agentic only — no baseline data (partial summary)
            merged.push({
                actualScore: agentic.actualScore,
                ceilingScore: 0,
                codeCorrectness: agentic.codeCorrectness,
                docCoverage: agentic.docCoverage,
                docLift: 0,
                docQualityGap: 100,
                feature,
                floorScore: 0,
                negativeDocLift: false,
                taskCompletion: agentic.taskCompletion,
                testCount: agentic.testCount,
                totalCost: agentic.totalCost,
                totalScore: agentic.actualScore,
            });
        }
    }
    return merged.sort((a, b) => a.feature.localeCompare(b.feature));
}
