// Source-fingerprint drift detection and per-test staleness model (Epic #735, Issue #742).
//
// Pure function, no IO, deterministic. Compares persisted envelope fingerprints from a previous
// QI run against current source fingerprints to classify each test-case candidate as fresh or
// stale. A candidate is stale on ANY of its source envelopes changing (hash differs → "source-changed")
// or disappearing from the current scan (envelope absent → "source-removed"). The removed case takes
// precedence over changed. Candidates with no resolvable envelope (unknown atoms) are treated as
// stale to prevent silently keeping invalid tests.
/** Build a map from atomId → envelopeId from the manifest evidenceRefs. */
function buildAtomToEnvelopeMap(evidenceRefs) {
    const map = new Map();
    for (const ref of evidenceRefs) {
        map.set(ref.atomId, ref.envelopeId);
    }
    return map;
}
/** Build a map from envelopeId → hash for a fingerprint array. */
function buildFingerprintMap(fingerprints) {
    const map = new Map();
    for (const fp of fingerprints) {
        map.set(fp.envelopeId, fp.integrityHashSha256Hex);
    }
    return map;
}
/** Distinct envelopeIds in first-seen evidenceRefs order (deterministic tie-breaking). */
function envelopeOrderOf(evidenceRefs) {
    const order = [];
    const seen = new Set();
    for (const ref of evidenceRefs) {
        if (!seen.has(ref.envelopeId)) {
            order.push(ref.envelopeId);
            seen.add(ref.envelopeId);
        }
    }
    return order;
}
const UNKNOWN_ENVELOPE = "unknown";
const REQUIREMENTS_ENVELOPE_PREFIX = "qi-src-req-";
const staleReason = (candidateId, reason, envelopeId) => ({ candidateId, reason, envelopeId });
function buildAtomFingerprintMap(fingerprints) {
    const map = new Map();
    for (const fp of fingerprints ?? []) {
        map.set(fp.atomId, {
            envelopeId: fp.envelopeId,
            canonicalHashSha256Hex: fp.canonicalHashSha256Hex,
        });
    }
    return map;
}
function classifyMissingCurrentAtom(candidateId, envelopeId, ctx) {
    if (!ctx.currentMap.has(envelopeId)) {
        return staleReason(candidateId, "source-removed", envelopeId);
    }
    return envelopeId.startsWith(REQUIREMENTS_ENVELOPE_PREFIX)
        ? staleReason(candidateId, "source-changed", envelopeId)
        : staleReason(candidateId, "source-removed", envelopeId);
}
function classifyCandidateWithAtomFingerprints(candidate, ctx) {
    if (candidate.derivedFromAtomIds.length === 0) {
        return staleReason(candidate.id, "source-removed", UNKNOWN_ENVELOPE);
    }
    let changedEnvelopeId;
    for (const atomId of candidate.derivedFromAtomIds) {
        const oldAtom = ctx.oldAtoms.get(atomId);
        if (oldAtom === undefined) {
            return staleReason(candidate.id, "source-changed", UNKNOWN_ENVELOPE);
        }
        const currentAtom = ctx.currentAtoms.get(atomId);
        if (currentAtom !== undefined) {
            if (currentAtom.canonicalHashSha256Hex !== oldAtom.canonicalHashSha256Hex) {
                changedEnvelopeId ??= oldAtom.envelopeId;
            }
            continue;
        }
        const missingCurrentAtomReason = classifyMissingCurrentAtom(candidate.id, oldAtom.envelopeId, ctx);
        if (missingCurrentAtomReason.reason === "source-changed") {
            changedEnvelopeId ??= oldAtom.envelopeId;
            continue;
        }
        return missingCurrentAtomReason;
    }
    return changedEnvelopeId === undefined
        ? null
        : staleReason(candidate.id, "source-changed", changedEnvelopeId);
}
function orderedCandidateEnvelopeIds(candidate, ctx) {
    const envelopeIds = new Set();
    for (const atomId of candidate.derivedFromAtomIds) {
        const envelopeId = ctx.atomToEnvelope.get(atomId);
        if (envelopeId !== undefined)
            envelopeIds.add(envelopeId);
    }
    return ctx.envelopeOrder.filter((envelopeId) => envelopeIds.has(envelopeId));
}
function classifyEnvelopeLevelCandidate(candidateId, orderedEnvelopeIds, ctx) {
    for (const envelopeId of orderedEnvelopeIds) {
        if (!ctx.currentMap.has(envelopeId)) {
            return staleReason(candidateId, "source-removed", envelopeId);
        }
    }
    for (const envelopeId of orderedEnvelopeIds) {
        const oldHash = ctx.oldMap.get(envelopeId);
        const currentHash = ctx.currentMap.get(envelopeId);
        if (currentHash !== undefined && oldHash !== currentHash) {
            return staleReason(candidateId, "source-changed", envelopeId);
        }
    }
    return null;
}
/**
 * Classify ONE candidate. Returns the dominant staleness reason, or null when fresh.
 * Removed (envelope gone from the current scan) takes precedence over changed (hash differs);
 * an unresolvable atom is treated as changed so an invalid test is never silently kept fresh.
 */
function classifyCandidate(candidate, ctx) {
    const hasAtomMetadata = ctx.oldAtoms.size > 0 && candidate.derivedFromAtomIds.every((atomId) => ctx.oldAtoms.has(atomId));
    if (hasAtomMetadata) {
        return classifyCandidateWithAtomFingerprints(candidate, ctx);
    }
    if (candidate.derivedFromAtomIds.length === 0) {
        return staleReason(candidate.id, "source-removed", UNKNOWN_ENVELOPE);
    }
    if (candidate.derivedFromAtomIds.some((atomId) => !ctx.atomToEnvelope.has(atomId))) {
        return staleReason(candidate.id, "source-changed", UNKNOWN_ENVELOPE);
    }
    return classifyEnvelopeLevelCandidate(candidate.id, orderedCandidateEnvelopeIds(candidate, ctx), ctx);
}
/**
 * Classify each candidate as fresh or stale based on whether their source envelopes have changed
 * since the run was persisted. Pure, deterministic; candidate input order is preserved.
 */
export function compareStaleness(args) {
    const ctx = {
        atomToEnvelope: buildAtomToEnvelopeMap(args.evidenceRefs),
        oldMap: buildFingerprintMap(args.oldFingerprints),
        currentMap: buildFingerprintMap(args.currentFingerprints),
        envelopeOrder: envelopeOrderOf(args.evidenceRefs),
        oldAtoms: buildAtomFingerprintMap(args.oldAtomFingerprints),
        currentAtoms: buildAtomFingerprintMap(args.currentAtomFingerprints),
    };
    const fresh = [];
    const changedStale = [];
    const orphanedStale = [];
    for (const candidate of args.candidates) {
        const reason = classifyCandidate(candidate, ctx);
        if (reason === null) {
            fresh.push(candidate.id);
        }
        else if (reason.reason === "source-removed") {
            orphanedStale.push(reason);
        }
        else {
            changedStale.push(reason);
        }
    }
    return { fresh, changedStale, orphanedStale };
}
