// Quality Intelligence generated-candidate artifact (Issue #274/#280, Epic #270, ADR-0023 D8).
//
// The run manifest (`<runId>.qi.json`) carries only counts + refs. The reviewable, exportable
// product — the generated test-case bodies — is persisted here as a companion artifact
// `<runId>.candidates.json`. Bodies are redacted (every string leaf) BEFORE persist so a candidate
// that echoed a secret-shaped source string cannot reach disk, preview, or export unredacted
// (Issue #284). Stored as plain rows (branded IDs collapse to strings on the wire).
import { QualityIntelligence } from "@oscharko-dev/keiko-contracts";
import { createNodeContainedJsonArtifactStore, } from "./companionStore.js";
import { EvidenceReadError } from "../errors.js";
export const QUALITY_INTELLIGENCE_CANDIDATES_SCHEMA_VERSION = 1;
const CANDIDATES_SUFFIX = ".candidates.json";
const toRow = (candidate) => ({
    id: String(candidate.id),
    title: candidate.title,
    preconditions: [...candidate.preconditions],
    steps: [...candidate.steps],
    expectedResults: [...candidate.expectedResults],
    priority: candidate.priority,
    riskClass: candidate.riskClass,
    tags: [...candidate.tags],
    status: candidate.status,
    derivedFromAtomIds: candidate.derivedFromAtomIds.map(String),
});
const PRIORITIES = new Set(QualityIntelligence.QUALITY_INTELLIGENCE_PRIORITIES);
const RISK_CLASSES = new Set(QualityIntelligence.QUALITY_INTELLIGENCE_RISK_CLASSES);
const TEST_CASE_STATUSES = new Set(QualityIntelligence.QUALITY_INTELLIGENCE_TEST_CASE_STATUSES);
const isObjectRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
const isNonEmptyString = (value) => typeof value === "string" && value.length > 0;
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
const isPriority = (value) => typeof value === "string" && PRIORITIES.has(value);
const isRiskClass = (value) => typeof value === "string" && RISK_CLASSES.has(value);
const isTestCaseStatus = (value) => typeof value === "string" && TEST_CASE_STATUSES.has(value);
const isEditedBy = (value) => value === "human" || value === "api";
// An edited list field persists as an array of non-blank strings — the edit route rejects blank
// ("") items before persist (editRoutes.ts isListField). Empty arrays are accepted on read so a
// legitimately-cleared preconditions/tags list still loads: strict on write, fail-open on read.
const isListOfNonBlankStrings = (value) => Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0);
const EDITABLE_FIELD_VALIDATORS = {
    title: isNonEmptyString,
    preconditions: isListOfNonBlankStrings,
    steps: isListOfNonBlankStrings,
    expectedResults: isListOfNonBlankStrings,
    priority: isPriority,
    riskClass: isRiskClass,
    tags: isListOfNonBlankStrings,
};
function isEditableFields(value) {
    if (!isObjectRecord(value))
        return false;
    const keys = Object.keys(value);
    return keys.every((key) => EDITABLE_FIELD_VALIDATORS[key]?.(value[key]) ?? false);
}
const CANDIDATE_ROW_VALIDATORS = [
    (row) => isNonEmptyString(row.id),
    (row) => isNonEmptyString(row.title),
    (row) => isStringArray(row.preconditions),
    (row) => isStringArray(row.steps),
    (row) => isStringArray(row.expectedResults),
    (row) => isPriority(row.priority),
    (row) => isRiskClass(row.riskClass),
    (row) => isStringArray(row.tags),
    (row) => isTestCaseStatus(row.status),
    (row) => isStringArray(row.derivedFromAtomIds),
];
function isCandidateRow(value) {
    return isObjectRecord(value) && CANDIDATE_ROW_VALIDATORS.every((validate) => validate(value));
}
function isEditedRevision(value) {
    if (!isObjectRecord(value))
        return false;
    const provenance = value.provenance;
    return (isNonEmptyString(value.candidateId) &&
        isObjectRecord(provenance) &&
        isNonEmptyString(provenance.editedAt) &&
        isEditedBy(provenance.editedBy) &&
        isNonEmptyString(provenance.editorLabel) &&
        isEditableFields(value.editedFields));
}
function invalidArtifact(message) {
    throw new EvidenceReadError(message);
}
function readArtifactStringField(record, key, message) {
    const value = record[key];
    if (!isNonEmptyString(value))
        invalidArtifact(message);
    return value;
}
function readArtifactCandidates(record) {
    const { candidates } = record;
    if (!Array.isArray(candidates) || !candidates.every(isCandidateRow)) {
        invalidArtifact("QI candidates companion schema invalid: candidates[] has an invalid row");
    }
    return candidates;
}
function readArtifactEditedRevisions(record) {
    const { editedRevisions } = record;
    if (editedRevisions === undefined)
        return undefined;
    if (!Array.isArray(editedRevisions) || !editedRevisions.every(isEditedRevision)) {
        invalidArtifact("QI candidates companion schema invalid: editedRevisions[] has an invalid revision");
    }
    return editedRevisions;
}
// Strict-schema gate on read: reject any artifact whose version literal drifts so a stale or
// tampered file fails closed instead of surfacing a wrong shape to the BFF.
const parseArtifact = (value) => {
    if (!isObjectRecord(value)) {
        invalidArtifact("QI candidates companion schema invalid: expected an object");
    }
    const record = value;
    if (record.qiCandidatesSchemaVersion !== QUALITY_INTELLIGENCE_CANDIDATES_SCHEMA_VERSION) {
        invalidArtifact("QI candidates companion schema invalid: unsupported schema version");
    }
    const runId = readArtifactStringField(record, "runId", "QI candidates companion schema invalid: runId must be a string");
    const generatedAt = readArtifactStringField(record, "generatedAt", "QI candidates companion schema invalid: generatedAt must be a string");
    const candidates = readArtifactCandidates(record);
    const editedRevisions = readArtifactEditedRevisions(record);
    return {
        qiCandidatesSchemaVersion: QUALITY_INTELLIGENCE_CANDIDATES_SCHEMA_VERSION,
        runId,
        generatedAt,
        candidates,
        ...(editedRevisions !== undefined ? { editedRevisions } : {}),
    };
};
const storeFor = (evidenceDir) => createNodeContainedJsonArtifactStore(evidenceDir, CANDIDATES_SUFFIX, { parse: parseArtifact });
/**
 * Persist the generated candidate bodies for a run. Redacts every string leaf first, then writes
 * the companion artifact atomically. Returns the on-disk location.
 */
export const recordQualityIntelligenceCandidates = (input) => {
    const rows = input.candidates.map(toRow);
    const redactedRows = input.redact(rows);
    const redactedEditedRevisions = input.editedRevisions === undefined
        ? undefined
        : input.redact(input.editedRevisions);
    const artifact = {
        qiCandidatesSchemaVersion: QUALITY_INTELLIGENCE_CANDIDATES_SCHEMA_VERSION,
        runId: input.runId,
        generatedAt: input.generatedAt,
        candidates: redactedRows,
        ...(redactedEditedRevisions !== undefined ? { editedRevisions: redactedEditedRevisions } : {}),
    };
    return storeFor(input.evidenceDir).record(input.runId, artifact);
};
export const loadQualityIntelligenceCandidates = (runId, options) => storeFor(options.evidenceDir).load(runId);
export const deleteQualityIntelligenceCandidates = (runId, options) => storeFor(options.evidenceDir).delete(runId);
const EDITABLE_KEYS = [
    "title",
    "preconditions",
    "steps",
    "expectedResults",
    "priority",
    "riskClass",
    "tags",
];
const hasEditedField = (fields) => EDITABLE_KEYS.some((key) => fields[key] !== undefined);
const mergeRow = (row, fields) => ({
    ...row,
    ...(fields.title !== undefined ? { title: fields.title } : {}),
    ...(fields.preconditions !== undefined ? { preconditions: [...fields.preconditions] } : {}),
    ...(fields.steps !== undefined ? { steps: [...fields.steps] } : {}),
    ...(fields.expectedResults !== undefined ? { expectedResults: [...fields.expectedResults] } : {}),
    ...(fields.priority !== undefined ? { priority: fields.priority } : {}),
    ...(fields.riskClass !== undefined ? { riskClass: fields.riskClass } : {}),
    ...(fields.tags !== undefined ? { tags: [...fields.tags] } : {}),
});
const sameStringArray = (left, right) => left.length === right.length && left.every((item, index) => item === right[index]);
const sameRow = (left, right) => left.id === right.id &&
    left.title === right.title &&
    sameStringArray(left.preconditions, right.preconditions) &&
    sameStringArray(left.steps, right.steps) &&
    sameStringArray(left.expectedResults, right.expectedResults) &&
    left.priority === right.priority &&
    left.riskClass === right.riskClass &&
    sameStringArray(left.tags, right.tags) &&
    left.status === right.status &&
    sameStringArray(left.derivedFromAtomIds, right.derivedFromAtomIds);
export const applyQualityIntelligenceCandidateEdit = (input) => {
    if (!hasEditedField(input.editedFields))
        return { ok: false, reason: "no-edited-fields" };
    const store = storeFor(input.evidenceDir);
    const artifact = store.load(input.runId);
    if (artifact === undefined)
        return { ok: false, reason: "artifact-not-found" };
    const existing = artifact.candidates.find((row) => row.id === input.candidateId);
    if (existing === undefined)
        return { ok: false, reason: "candidate-not-found" };
    const redactedFields = input.redact(input.editedFields);
    const updatedRow = mergeRow(existing, redactedFields);
    if (sameRow(existing, updatedRow)) {
        return { ok: true, candidate: existing, changed: false };
    }
    const candidates = artifact.candidates.map((row) => row.id === input.candidateId ? updatedRow : row);
    const revision = {
        candidateId: input.candidateId,
        provenance: input.provenance,
        editedFields: redactedFields,
    };
    store.record(input.runId, {
        ...artifact,
        candidates,
        editedRevisions: [...(artifact.editedRevisions ?? []), revision],
    });
    return { ok: true, candidate: updatedRow, changed: true };
};
