/**
 * ci-watch/stop-matrix.mjs — pure stop-matrix classifier (D-06).
 *
 * `classify({checks, state, prMetadata})` returns
 *   { action, counts_toward_budget, reason }
 *
 * No I/O, no fs, no gh CLI shell-outs — caller pre-fetches `checks` (the
 * payload from `gh pr checks --json name,bucket,state,link,workflow,completedAt`)
 * and `prMetadata` (`gh pr view --json state,reviewDecision`) and passes them
 * in. The orchestrator in commands/ci-watch.mjs handles the side effects
 * implied by the action.
 *
 * Primary per-check signal is `state` (uppercase per gh-CLI: SUCCESS,
 * FAILURE, CANCELLED, TIMED_OUT, SKIPPED, IN_PROGRESS, QUEUED, PENDING,
 * NEUTRAL). The legacy lowercase per-check field that gh dropped circa
 * 2.65 is kept as a soft fallback for in-memory test fixtures only —
 * production gh payloads no longer expose it (FRAMEWORK-BUG-016).
 *
 * Predicate order matters — short-circuits go first (merged/closed/green/cap
 * fire before any per-check inspection). See spec.md §9 table for the full
 * ordering.
 *
 * The 11 actions:
 *   merged, closed, green, attempts-cap, wall-cap, reviewer-pause,
 *   identical-log, flake-skip, infra-backoff, diverged-rebased,
 *   diverged-bailed, fix
 *
 * `counts_toward_budget` is `true` for fix attempts (including rebase
 * attempts and rebase-bail); `false` for flake-skip / infra-backoff; `false`
 * (with a sentinel meaning "n/a") for terminal actions.
 */
const ATTEMPTS_CAP = 5;
const INFRA_FAILURE_CLASSES = new Set(['runner-oom', 'runner-cancelled', 'gh-5xx', 'timeout']);
/**
 * @typedef {Object} CheckRow
 * @property {string} [name]
 * @property {string} [state] Primary per-check status (gh-CLI uppercase enum):
 *   'SUCCESS' | 'FAILURE' | 'CANCELLED' | 'TIMED_OUT' | 'SKIPPED' |
 *   'IN_PROGRESS' | 'QUEUED' | 'PENDING' | 'NEUTRAL'.
 * @property {string} [conclusion] Legacy lowercase per-check field
 *   (deprecated — gh dropped it from `pr checks --json` schema circa 2.65).
 *   Retained only as a soft fallback for in-memory test fixtures
 *   (FRAMEWORK-BUG-016).
 * @property {string} [bucket]
 * @property {string} [failure_class] — optional pre-classified hint (test-only seam)
 * @property {string} [log_fingerprint] — optional digest for identical-log detection
 *
 * @typedef {Object} ClassifyInput
 * @property {CheckRow[]} checks
 * @property {Object} state — per-PR state (see state.mjs §8)
 * @property {Object} prMetadata — { state: 'OPEN'|'CLOSED'|'MERGED', reviewDecision?: string }
 *
 * @typedef {Object} ClassifyOutput
 * @property {string} action
 * @property {boolean} counts_toward_budget
 * @property {string} reason
 *
 * @param {ClassifyInput} input
 * @returns {ClassifyOutput}
 */
export function classify({ checks, state, prMetadata }) {
    const pr = prMetadata || {};
    const st = state || {};
    const rows = Array.isArray(checks) ? checks : [];
    // 1. MERGED.
    if (pr.state === 'MERGED') {
        return { action: 'merged', counts_toward_budget: false, reason: 'pr is merged' };
    }
    // 2. CLOSED (not merged).
    if (pr.state === 'CLOSED') {
        return { action: 'closed', counts_toward_budget: false, reason: 'pr is closed unmerged' };
    }
    const failed = rows.filter((c) => isFailing(c));
    // FRAMEWORK-BUG-016: `state` is gh-CLI's primary per-check field post-2.65.
    // SKIPPED rows (workflow `if:` guards that didn't trip) are
    // success-equivalent — they intentionally did not run and that is OK.
    // Soft fallback to the legacy lowercase field keeps the in-memory test
    // fixtures green (production gh payloads never hit it).
    const isGreenRow = (c) => c.state === 'SUCCESS' ||
        c.state === 'SKIPPED' ||
        (c.state == null && c.conclusion === 'success');
    const allGreen = rows.length > 0 && rows.every(isGreenRow);
    // 3. All checks green.
    if (allGreen) {
        return { action: 'green', counts_toward_budget: false, reason: 'all checks succeeded' };
    }
    // 3b. No checks registered yet — CI hasn't enqueued jobs. Wait without
    // burning a fix attempt.
    if (rows.length === 0) {
        return { action: 'wait', counts_toward_budget: false, reason: 'no checks registered yet' };
    }
    // 4. Attempts cap (only when red present — otherwise green/merged/closed would have fired).
    const attempts = st.attempts || 0;
    if (attempts >= ATTEMPTS_CAP && failed.length > 0) {
        return {
            action: 'attempts-cap',
            counts_toward_budget: false,
            reason: `${attempts} fix attempts used; cap is ${ATTEMPTS_CAP}`,
        };
    }
    // 5. Wall-clock cap.
    if (st.deadline && Date.now() > new Date(st.deadline).getTime() && failed.length > 0) {
        return {
            action: 'wall-cap',
            counts_toward_budget: false,
            reason: 'past 4h deadline',
        };
    }
    // 6. Reviewer asked for changes.
    if (pr.reviewDecision === 'CHANGES_REQUESTED') {
        return {
            action: 'reviewer-pause',
            counts_toward_budget: false,
            reason: 'reviewer requested changes — pausing',
        };
    }
    // 6b. BUG-031 defensive wait branch — if no checks are actually
    // failing (everything is success / skipped / in_progress / neutral),
    // never fall through to the default-red 'fix' at the bottom. Placed
    // AFTER reviewer-pause so CHANGES_REQUESTED + no-failures still
    // produces 'reviewer-pause' (locks existing semantics), BEFORE the
    // remaining checks (identical-log, flake-skip, infra-backoff, fix)
    // which all assume at least one failing row. Pre-fix, a PR with a
    // success + a SKIPPED conditional CI job fell through to 'fix' and
    // burned a fix attempt every tick on a non-existent failure.
    if (failed.length === 0) {
        return {
            action: 'wait',
            counts_toward_budget: false,
            reason: 'no failing checks; awaiting in-flight or skipped',
        };
    }
    // 7. Identical-log → non-converging fixer.
    // Compare aggregate fingerprints (same format as the write side in ci-watch.mjs).
    if (attempts > 0 && st.last_status && failed.length > 0) {
        const currentFingerprint = failed
            .map((f) => `${f.name}:${f.log_fingerprint ?? ''}`)
            .sort()
            .join('|');
        if (currentFingerprint === st.last_status) {
            return {
                action: 'identical-log',
                counts_toward_budget: false,
                reason: 'failed-check fingerprint identical to last attempt',
            };
        }
    }
    // 8. Flake-skip — same check failed then passed on rerun, no commit between.
    // The classifier sees this as an explicit hint from the caller: failed rows
    // classified as `bucket === 'flake'` or `failure_class === 'flake'` are the
    // flaky-test variety and must not burn a fix attempt. Fires when EVERY
    // remaining failure is a flake — if any non-flake failure is present, we
    // fall through and the real failure governs.
    if (failed.length > 0 &&
        failed.every((c) => c.bucket === 'flake' || c.failure_class === 'flake')) {
        return {
            action: 'flake-skip',
            counts_toward_budget: false,
            reason: 'check transient — passed on rerun',
        };
    }
    // 9. Infra failure class.
    if (failed.some((c) => INFRA_FAILURE_CLASSES.has(c.failure_class))) {
        return {
            action: 'infra-backoff',
            counts_toward_budget: false,
            reason: 'infra-class failure — backoff and retry',
        };
    }
    // 10. Diverged branch — caller has already done the fetch + dry-rebase and
    // passes the outcome in `prMetadata.diverged`:
    //   { rebased: true }   → action 'diverged-rebased' (counts)
    //   { rebased: false }  → action 'diverged-bailed'  (counts)
    if (pr.diverged && typeof pr.diverged === 'object') {
        if (pr.diverged.rebased === true) {
            return {
                action: 'diverged-rebased',
                counts_toward_budget: true,
                reason: 'origin ahead; rebased successfully',
            };
        }
        return {
            action: 'diverged-bailed',
            counts_toward_budget: true,
            reason: 'origin ahead; rebase conflict',
        };
    }
    // 11. Default red — go fix.
    return {
        action: 'fix',
        counts_toward_budget: true,
        reason: 'red checks present — attempting fix',
    };
}
export function isFailing(check) {
    // FRAMEWORK-BUG-016 / BUG-025: prefer the uppercase `state` field that gh
    // 2.65+ exposes. SKIPPED / NEUTRAL / IN_PROGRESS / QUEUED / PENDING /
    // SUCCESS are explicitly NOT red — SKIPPED in particular must never
    // burn a fix attempt (workflow `if:` guards routinely skip jobs on PRs
    // that don't trip them). The legacy lowercase field is a soft fallback
    // for in-memory test fixtures only.
    const s = check.state;
    if (s === 'SUCCESS' ||
        s === 'SKIPPED' ||
        s === 'NEUTRAL' ||
        s === 'IN_PROGRESS' ||
        s === 'QUEUED' ||
        s === 'PENDING') {
        return false;
    }
    if (s === 'FAILURE' || s === 'CANCELLED' || s === 'TIMED_OUT') {
        return true;
    }
    const c = check.conclusion;
    return c === 'failure' || c === 'cancelled' || c === 'timed_out' || c === 'action_required';
}
//# sourceMappingURL=stop-matrix.mjs.map