// Quality Intelligence — model-output → candidate parser (Epic #270, Issue #272/#279).
//
// Pure, deterministic recovery of `QualityIntelligenceTestCaseCandidate` records from the raw
// text a model returns. Robust to: code fences, a reasoning preamble before the JSON, a bare
// array vs the `{ testCases: [...] }` wrapper, and missing / out-of-range fields. NO IO, NO
// model call, NO randomness — IDs are content-hash derived so the same model output yields the
// same candidate IDs (round-trip stable, mutation-detectable).
import { QualityIntelligence } from "@oscharko-dev/keiko-contracts";
import { sha256Hex } from "@oscharko-dev/keiko-security";
import { normaliseText } from "../domain/assertions.js";
import { regressionDefault } from "../domain/policyProfile.js";
const PRIORITIES = new Set(QualityIntelligence.QUALITY_INTELLIGENCE_PRIORITIES);
const RISK_CLASSES = new Set(QualityIntelligence.QUALITY_INTELLIGENCE_RISK_CLASSES);
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
// Strip a single ```json … ``` or ``` … ``` fence if the whole payload is fenced.
const stripCodeFence = (raw) => {
    const fence = /^```(?:json)?\s*([\s\S]*?)\s*```$/u.exec(raw.trim());
    return fence?.[1] ?? raw;
};
// Advance the in-string scanner one character (honours backslash escapes).
const consumeStringChar = (ch, escaped) => {
    if (escaped)
        return { inString: true, escaped: false };
    if (ch === "\\")
        return { inString: true, escaped: true };
    if (ch === '"')
        return { inString: false, escaped: false };
    return { inString: true, escaped: false };
};
// Scan for the first balanced JSON value (object or array) honouring string literals + escapes,
// so a `}` inside a quoted step does not terminate the scan early.
const extractFirstJsonValue = (text) => {
    const open = firstOpenIndex(text);
    if (open === -1)
        return undefined;
    const openChar = text[open];
    const closeChar = openChar === "{" ? "}" : "]";
    let depth = 0;
    let scan = { inString: false, escaped: false };
    for (let i = open; i < text.length; i += 1) {
        const ch = text[i] ?? "";
        if (scan.inString) {
            scan = consumeStringChar(ch, scan.escaped);
            continue;
        }
        if (ch === '"')
            scan = { inString: true, escaped: false };
        else if (ch === openChar)
            depth += 1;
        else if (ch === closeChar) {
            depth -= 1;
            if (depth === 0)
                return text.slice(open, i + 1);
        }
    }
    return undefined;
};
const firstOpenIndex = (text) => {
    const obj = text.indexOf("{");
    const arr = text.indexOf("[");
    if (obj === -1)
        return arr;
    if (arr === -1)
        return obj;
    return Math.min(obj, arr);
};
const parseJsonLoose = (raw) => {
    const stripped = stripCodeFence(raw);
    const slice = extractFirstJsonValue(stripped);
    if (slice === undefined)
        return undefined;
    try {
        return JSON.parse(slice);
    }
    catch {
        return undefined;
    }
};
// Accept either a bare array of test cases or the documented `{ testCases: [...] }` wrapper.
const toRawItems = (parsed) => {
    if (Array.isArray(parsed))
        return parsed;
    if (isObject(parsed) && Array.isArray(parsed.testCases))
        return parsed.testCases;
    return [];
};
const toStringList = (value) => {
    const source = Array.isArray(value)
        ? value
        : typeof value === "string"
            ? value.split(/\r?\n/u)
            : [];
    const out = [];
    for (const entry of source) {
        if (typeof entry !== "string")
            continue;
        const text = normaliseText(entry);
        if (text.length > 0)
            out.push(text);
    }
    return out;
};
const clampPriority = (value, profile) => typeof value === "string" && PRIORITIES.has(value)
    ? value
    : profile.defaultPriority;
const clampRiskClass = (value, profile) => typeof value === "string" && RISK_CLASSES.has(value)
    ? value
    : profile.defaultRiskClass;
// Map the model's 1-based evidence indexes to atom IDs. Out-of-range / non-integer entries are
// dropped. When the model supplied none, fall back to a positional atom so every candidate keeps
// at least one provenance link (traceability invariant) without faking full coverage.
const resolveDerivedAtomIds = (value, atomIds, positionalIndex) => {
    const ids = [];
    if (Array.isArray(value)) {
        for (const entry of value) {
            const idx = typeof entry === "number" ? Math.trunc(entry) : Number.NaN;
            const atom = Number.isInteger(idx) ? atomIds[idx - 1] : undefined;
            if (atom !== undefined && !ids.includes(atom))
                ids.push(atom);
        }
    }
    if (ids.length > 0)
        return Object.freeze(ids);
    if (atomIds.length === 0)
        return Object.freeze([]);
    const fallback = atomIds[positionalIndex % atomIds.length];
    return fallback === undefined ? Object.freeze([]) : Object.freeze([fallback]);
};
const deriveCandidateId = (runId, index, title) => {
    const digest = sha256Hex(`qi-cand-v1|${String(runId)}|${String(index)}|${title}`).slice(0, 32);
    return `qi-candidate-${digest}`;
};
const buildCandidate = (raw, index, input, profile) => {
    const title = normaliseText(typeof raw.title === "string" ? raw.title : "");
    const steps = toStringList(raw.steps);
    if (title.length === 0 || steps.length === 0)
        return undefined;
    const expectedResults = toStringList(raw.expectedResults);
    const tags = toStringList(raw.tags);
    return Object.freeze({
        id: QualityIntelligence.asQualityIntelligenceTestCaseId(deriveCandidateId(input.runId, index, title)),
        runId: input.runId,
        derivedFromAtomIds: resolveDerivedAtomIds(raw.derivedFromEvidenceIndexes, input.atomIds, index),
        title,
        preconditions: toStringList(raw.preconditions),
        steps,
        expectedResults: expectedResults.length > 0
            ? expectedResults
            : Object.freeze(["The behaviour matches the cited evidence."]),
        priority: clampPriority(raw.priority, profile),
        riskClass: clampRiskClass(raw.riskClass, profile),
        tags,
        status: "proposed",
    });
};
/**
 * Parse raw model output into validated candidates. Returns `recovered: false` when no JSON value
 * could be located, so the orchestrator can fail the run with a clear, non-secret reason instead
 * of silently emitting zero candidates.
 */
export const parseGeneratedCandidates = (rawText, input) => {
    const profile = input.profile ?? regressionDefault;
    const parsed = parseJsonLoose(typeof rawText === "string" ? rawText : "");
    if (parsed === undefined) {
        return { candidates: Object.freeze([]), recovered: false, skipped: 0 };
    }
    const rawItems = toRawItems(parsed);
    const cap = Math.max(0, Math.trunc(input.maxCandidates));
    const candidates = [];
    let skipped = 0;
    for (let i = 0; i < rawItems.length && candidates.length < cap; i += 1) {
        const item = rawItems[i];
        if (!isObject(item)) {
            skipped += 1;
            continue;
        }
        const candidate = buildCandidate(item, i, input, profile);
        if (candidate === undefined)
            skipped += 1;
        else
            candidates.push(candidate);
    }
    return { candidates: Object.freeze(candidates), recovered: true, skipped };
};
