/**
 * select-baseline.ts
 *
 * Pure baseline-selection logic for pipeline auto-comparison.
 *
 * The scheduled pipeline auto-compares each new report against a prior
 * "comparable" baseline. Historically "comparable" meant only *same mode +
 * same source name, most recent*, which let a 9-area `baseline`-variant daily
 * run grab a 1-area `full`-variant ad-hoc GROQ run as its reference. Two runs
 * are only comparable when:
 *
 *   1. They share the same run `classification`. `classification` partitions
 *      runs into trend series (D0037): `official` is the canonical, trend-tracked
 *      series and `test`/`adhoc`/`experimental`/`external` are not. An `official`
 *      daily run that diffed against a `test` smoke run (e.g. report-019f0d09 on
 *      2026-06-29: a `testing-ground`-owned `test` run published with
 *      `source: production`) would corrupt the very series the comparison feeds.
 *      A classification
 *      mismatch is never comparable. The team-validity of an `official`
 *      candidate is already guaranteed by the D0073 publish gate, so this axis
 *      checks classification alone, not `owner.team`.
 *   2. They share the same literacy `variant`. The scoring methodology differs
 *      per variant (`baseline` vs `agentic` vs `full`), so a cross-variant
 *      delta is apples-to-oranges. A variant mismatch is never comparable.
 *   3. The candidate covers every area the current run scored
 *      (`current.areas ⊆ candidate.areas`). Otherwise the overall delta mixes a
 *      9-area average against, say, a 1-area average — and the missing areas
 *      have no honest baseline to diff against.
 *
 * Tie-break + fallback order (most-recent-first within each tier):
 *
 *   - Among same-variant candidates, the most recent whose area set is a
 *     superset of (or equal to) the current run's areas wins.
 *   - When no same-variant candidate fully covers the current areas, NOTHING
 *     is selected (`selected: null`). The caller skips auto-comparison rather
 *     than diffing a partial- or non-overlapping run. Per-area partial-baseline
 *     resolution is the scope of the sibling work item
 *     `W-resolve-per-area-baselines`; here we only refuse to fabricate a
 *     misleading overall delta. The best partial near-miss is still reported so
 *     the caller can log *which* candidate it declined.
 *
 * Pure: no I/O, no clock, no Sanity client. The caller (`ReportStore`) fetches
 * a slim candidate projection via GROQ (matching on the indexed scalar
 * dimensions) and feeds it here; area-coverage ranking can't ride GROQ's
 * indexed `Aggregate` path (array-superset predicates force a full `Fetch`), so
 * it lives in TypeScript where it is also directly unit-testable.
 */
/**
 * Most-recent-first comparator for baseline candidates. ISO 8601 UTC strings
 * sort lexicographically by time, so a plain string compare orders by
 * `completedAt` descending.
 */
export function byCompletedAtDesc(a, b) {
    return a.completedAt < b.completedAt
        ? 1
        : a.completedAt > b.completedAt
            ? -1
            : 0;
}
/**
 * Apply the comparability gate shared by whole-report selection
 * ({@link selectComparableBaseline}) and per-area resolution
 * (`resolvePerAreaBaselines`): a candidate is comparable only when it shares
 * the current run's `classification` (trend-series partition, D0037) and
 * `variant` (scoring methodology). `undefined === undefined` keeps a
 * pre-taxonomy candidate comparable to an unclassified query and non-literacy
 * runs (no variant) comparable to each other.
 *
 * On success returns the surviving candidates ordered most-recent-first; on
 * failure returns the reason naming which axis emptied the pool. Area coverage
 * is deliberately NOT applied here — whole-report selection requires full
 * coverage, per-area resolution requires only per-area coverage, so each layers
 * its own area check on top of this shared gate.
 */
export function filterComparableCandidates(current, candidates) {
    if (candidates.length === 0) {
        return { ok: false, reason: "no-candidates" };
    }
    const sameClassification = candidates.filter((c) => c.classification === current.classification);
    if (sameClassification.length === 0) {
        return { ok: false, reason: "no-classification-match" };
    }
    const sameVariant = sameClassification.filter((c) => c.variant === current.variant);
    if (sameVariant.length === 0) {
        return { ok: false, reason: "no-variant-match" };
    }
    return { ok: true, ordered: [...sameVariant].sort(byCompletedAtDesc) };
}
/**
 * Classify how a candidate's area set covers the current run's areas.
 *
 * An empty current area set carries no area constraint, so it is a subset of
 * everything: an empty candidate is `exact`, a non-empty candidate is
 * `superset`. This preserves the pre-existing "fall back to most recent" intent
 * for degenerate runs that scored no areas.
 */
export function classifyAreaCoverage(currentAreas, candidateAreas) {
    const candidateSet = new Set(candidateAreas);
    if (currentAreas.size === 0) {
        return candidateSet.size === 0 ? "exact" : "superset";
    }
    let covered = 0;
    for (const area of currentAreas) {
        if (candidateSet.has(area))
            covered++;
    }
    if (covered === 0)
        return "disjoint";
    if (covered < currentAreas.size)
        return "partial";
    // Every current area is covered. Equal sets are `exact`; a candidate with
    // extra areas is a `superset` (its extra areas show as `not-evaluated` in
    // compare() rather than fabricated deltas).
    return candidateSet.size === currentAreas.size ? "exact" : "superset";
}
/**
 * Select the most recent comparable baseline from a candidate list.
 *
 * The candidate list need not be pre-sorted — selection sorts defensively by
 * `completedAt` descending. See the module header for the full comparability
 * contract and tie-break order.
 */
export function selectComparableBaseline(current, candidates) {
    // Same-classification + same-variant gate (D0037 trend series, per-variant
    // scoring methodology), shared with per-area resolution. The team-validity of
    // an `official` candidate is guaranteed upstream by the D0073 publish gate, so
    // this checks comparability axes only, not attribution.
    const gate = filterComparableCandidates(current, candidates);
    if (!gate.ok) {
        return { reason: gate.reason, selected: null };
    }
    // Most-recent-first within the comparable pool.
    const ordered = gate.ordered;
    const currentAreas = new Set(current.areas);
    let bestPartial;
    for (const candidate of ordered) {
        const coverage = classifyAreaCoverage(currentAreas, candidate.areas);
        if (coverage === "exact" || coverage === "superset") {
            return {
                reason: "selected",
                selected: {
                    completedAt: candidate.completedAt,
                    coverage,
                    id: candidate.id,
                },
            };
        }
        if (coverage === "partial" && !bestPartial) {
            const candidateSet = new Set(candidate.areas);
            const sharedAreas = [...currentAreas].filter((a) => candidateSet.has(a)).length;
            bestPartial = {
                candidateAreas: candidate.areas.length,
                id: candidate.id,
                sharedAreas,
            };
        }
    }
    return {
        reason: "no-area-coverage",
        selected: null,
        ...(bestPartial ? { bestPartial } : {}),
    };
}
