/**
 * Hold the gates declaration against the ruleset that enforces it.
 *
 * Three surfaces answer "what must pass before this merges" — the project's
 * `gates` declarations, the shipped ruleset template, and the live GitHub
 * ruleset — and until this module existed no comparator joined the first to
 * either of the others. `contextsFor()` derived contexts from declarations,
 * `lisa health` compared template against live, and the two halves never met,
 * so a declaration and the protection enforcing it could disagree indefinitely
 * with every check green.
 *
 * Two properties are load-bearing, and both are structural rather than
 * conventional:
 *
 * 1. **`off` and undeclared are different findings.** `off` is a decision that
 *    a property is not proved here, so protection requiring it is a live
 *    contradiction. Undeclared is silence — the registry still has gates whose
 *    jobs run with no declaration at all, so silence must never be read as
 *    "not required", and must never justify removing live protection.
 * 2. **Agreement is not proof.** A `matched` verdict means the declaration and
 *    the ruleset name the same required context. It says nothing about whether
 *    that context proves anything: one required context in this repository
 *    ships a skip step printing "This job going green does NOT mean any
 *    ast-grep rule has test coverage", and a required review context has gone
 *    green carrying "Review skipped". A drift report that could not tell
 *    "agreed and proved" from "agreed and unproved" would itself be a control
 *    reporting more than it measured, so the word this module uses is
 *    `matched` — the two surfaces agree — and never `enforced` or `proved`.
 * 3. **No remedy in this module can say "remove the context".** Third-party
 *    contexts are enforced by construction and declared by nobody; a
 *    comparator whose vocabulary contained a removal would eventually propose
 *    deleting one. The `DriftRemedy` union simply has no such member, so the
 *    guarantee holds for every caller rather than for the careful ones.
 * @module core/gate-declaration-drift
 */
/** The moment a branch ruleset guards. */
export const MERGE_MOMENT = "pull-request";
/** Every verdict, named once so no two mentions can drift apart. */
const MATCHED = "matched";
const DECLARED_NOT_ENFORCED = "declared-not-enforced";
const ENFORCED_DECLARED_OPTIONAL = "enforced-declared-optional";
const ENFORCED_DECLARED_OFF = "enforced-declared-off";
const ENFORCED_UNDECLARED = "enforced-undeclared";
const ENFORCED_NOT_LISA_OWNED = "enforced-not-lisa-owned";
/** Verdicts where the two surfaces state opposite things. */
const CONTRADICTIONS = new Set([
    DECLARED_NOT_ENFORCED,
    ENFORCED_DECLARED_OFF,
]);
/** Verdicts where one surface is silent rather than contrary. */
const GAPS = new Set([
    ENFORCED_DECLARED_OPTIONAL,
    ENFORCED_UNDECLARED,
]);
/** The remedy each verdict calls for. */
const REMEDIES = {
    [MATCHED]: "none",
    [DECLARED_NOT_ENFORCED]: "enforce-the-context",
    [ENFORCED_DECLARED_OPTIONAL]: "decide-which-surface-wins",
    [ENFORCED_DECLARED_OFF]: "resolve-the-contradiction",
    [ENFORCED_UNDECLARED]: "declare-the-gate",
    [ENFORCED_NOT_LISA_OWNED]: "none",
};
/** Every verdict, so a count of zero is still a stated zero. */
const VERDICTS = Object.keys(REMEDIES);
/** How the enforcing surface is named in a sentence. */
const SURFACE_NAMES = {
    "ruleset-templates": "the shipped ruleset template",
    "live-ruleset": "the live branch-protection ruleset",
};
/**
 * Sort strings the same way everywhere, so two runs emit the same bytes.
 * @param values - Caller-owned strings
 * @returns A sorted copy
 */
function sorted(values) {
    return [...values].sort((left, right) => left.localeCompare(right));
}
/**
 * The declaration state one resolved level names.
 *
 * Anything that is not one of the three levels is `not-declared`, because an
 * unknown level is a typo rather than a claim.
 * @param level - The resolved level
 * @returns The declaration state
 */
function asDeclaration(level) {
    return level === "required" || level === "optional" || level === "off"
        ? level
        : "not-declared";
}
/**
 * Map every context a registry gate can produce to that gate.
 *
 * Built from the whole registry rather than from the declared gates, which is
 * the difference between "protection requires a context whose gate nobody
 * declared" and "protection requires a context Lisa knows nothing about". Those
 * are different findings with different remedies, and a map built only from
 * declarations could not tell them apart.
 * @param options - Inputs
 * @param options.registry - The shipped registry
 * @param options.gates - The project's gates block
 * @param options.workflowName - The workflow whose name prefixes a context
 * @returns Context string to owning gate
 */
export function contextOwners(options) {
    const { registry, gates, workflowName } = options;
    const resolved = resolveMergeMoment(registry, gates);
    const family = registry.momentFamily(MERGE_MOMENT);
    const owners = Object.entries(registry.REGISTRY).map(([gateId, definition]) => {
        const hit = resolved.get(gateId);
        return [
            `${workflowName} / ${definition.label}`,
            {
                gateId,
                declaration: asDeclaration(hit?.level),
                legalAtMerge: definition.moments.includes(family),
            },
        ];
    });
    const awaited = [...resolved.values()].flatMap(hit => hit.mode === "await" && hit.awaits !== null
        ? [
            [
                hit.awaits,
                {
                    gateId: hit.id,
                    declaration: asDeclaration(hit.level),
                    legalAtMerge: registry.REGISTRY[hit.id]?.moments.includes(family) === true,
                },
            ],
        ]
        : []);
    return new Map([...owners, ...awaited]);
}
/**
 * Resolve the merge moment, treating a refusal as "nothing resolved".
 *
 * A refusal is never swallowed by the caller: `readTemplateEnforcement` and the
 * health check both report the declaration problems separately. What must not
 * happen here is a throw that turns a comparison into no comparison at all.
 * @param registry - The shipped registry
 * @param gates - The project's gates block
 * @returns Resolved gates keyed by id
 */
function resolveMergeMoment(registry, gates) {
    try {
        return new Map(registry
            .resolveMoment({ gates, moment: MERGE_MOMENT, includeOff: true })
            .map(entry => [entry.id, { ...entry }]));
    }
    catch {
        return new Map();
    }
}
/**
 * The contexts a `required` declaration promises protection will require.
 * @param owners - Context to owning gate
 * @returns The contexts, sorted
 */
export function declaredRequiredContexts(owners) {
    return sorted([...owners.entries()]
        .filter(([, owner]) => owner.declaration === "required")
        .map(([context]) => context));
}
/**
 * The verdict for one enforced context.
 * @param owner - The gate producing it, when one does
 * @returns The verdict
 */
function verdictForEnforced(owner) {
    if (owner === undefined)
        return ENFORCED_NOT_LISA_OWNED;
    if (owner.declaration === "required")
        return MATCHED;
    if (owner.declaration === "optional")
        return ENFORCED_DECLARED_OPTIONAL;
    if (owner.declaration === "off")
        return ENFORCED_DECLARED_OFF;
    return ENFORCED_UNDECLARED;
}
/**
 * The sentence one verdict is explained with.
 * @param options - Inputs
 * @param options.verdict - The verdict
 * @param options.surface - The enforcing surface
 * @param options.gateId - The owning gate, when there is one
 * @param options.sources - Where the requirement was read from
 * @returns One operator-readable sentence
 */
// eslint-disable-next-line sonarjs/cognitive-complexity -- one branch per verdict keeps the wording auditable
function detailFor(options) {
    const { verdict, gateId, sources } = options;
    const surface = SURFACE_NAMES[options.surface];
    const where = sources.length === 0 ? "" : ` (${sources.join(", ")})`;
    if (verdict === MATCHED) {
        return `Declared required, and ${surface} requires it${where}. The two surfaces agree; that is not evidence the check proves its property.`;
    }
    if (verdict === DECLARED_NOT_ENFORCED) {
        return `The settings file declares "${String(gateId)}" required at ${MERGE_MOMENT}, but ${surface} does not require this context, so the declaration blocks nothing.`;
    }
    if (verdict === ENFORCED_DECLARED_OPTIONAL) {
        return `${surface} requires this context${where}, while the settings file declares "${String(gateId)}" optional at ${MERGE_MOMENT}. Two surfaces, two answers — name which one wins.`;
    }
    if (verdict === ENFORCED_DECLARED_OFF) {
        return `The settings file declares "${String(gateId)}" off at ${MERGE_MOMENT} — an explicit decision not to prove it here — yet ${surface} requires this context${where}. That is a contradiction, not a gap.`;
    }
    if (verdict === ENFORCED_UNDECLARED) {
        return `${surface} requires this context${where}, and it is produced by the gate "${String(gateId)}", which the settings file never declares at any moment. Undeclared is silence, not permission to stop requiring it.`;
    }
    return `${surface} requires this context${where}, and no gate in Lisa's registry produces it — it comes from a third-party app or from a job this project's own CI defines. Matching is on the exact context string, never on a job name that merely resembles it: a third-party app status and a Lisa job with a similar name are different things, and treating them as one produces a false drift report, or worse a false clean one. Reported so it can be told apart from a Lisa gate that fell out of the settings file, never so it can be removed.`;
}
/**
 * Group enforced contexts, keeping every ruleset and source that named one.
 * @param enforced - Enforced contexts, possibly repeating a context
 * @returns Context to its rulesets and sources
 */
function groupEnforced(enforced) {
    return enforced.reduce((grouped, entry) => {
        const existing = grouped.get(entry.context) ?? {
            rulesets: [],
            sources: [],
        };
        return new Map(grouped).set(entry.context, {
            rulesets: [...existing.rulesets, entry.ruleset],
            sources: [...existing.sources, entry.source],
        });
    }, new Map());
}
/**
 * Compare a project's declarations with one enforcing surface.
 *
 * Total and pure — the same inputs always produce the same entries in the same
 * order. Reaching the surface is the caller's job, and a caller that could not
 * reach it must report an unknown rather than call this with an empty list: a
 * comparison against nothing would report every declaration as unenforced,
 * which is a different and false claim.
 * @param options - Inputs
 * @param options.surface - Which surface `enforced` was read from
 * @param options.owners - Context to owning gate
 * @param options.enforced - Contexts the surface requires
 * @returns The comparison
 */
export function classifyDeclarationDrift(options) {
    const { surface, owners, enforced } = options;
    const grouped = groupEnforced(enforced);
    const enforcedEntries = sorted(grouped.keys()).map((context) => {
        const owner = owners.get(context);
        const verdict = verdictForEnforced(owner);
        const found = grouped.get(context);
        const sources = sorted(new Set(found?.sources ?? []));
        return {
            context,
            verdict,
            remedy: REMEDIES[verdict],
            gateId: owner?.gateId ?? null,
            declaration: owner?.declaration ?? null,
            rulesets: sorted(new Set(found?.rulesets ?? [])),
            sources,
            detail: detailFor({
                verdict,
                surface,
                gateId: owner?.gateId ?? null,
                sources,
            }),
        };
    });
    const unenforced = declaredRequiredContexts(owners)
        .filter(context => !grouped.has(context))
        .map((context) => {
        const gateId = owners.get(context)?.gateId ?? null;
        return {
            context,
            verdict: DECLARED_NOT_ENFORCED,
            remedy: REMEDIES[DECLARED_NOT_ENFORCED],
            gateId,
            declaration: "required",
            rulesets: [],
            sources: [],
            detail: detailFor({
                verdict: DECLARED_NOT_ENFORCED,
                surface,
                gateId,
                sources: [],
            }),
        };
    });
    const entries = [...enforcedEntries, ...unenforced].sort((left, right) => left.context.localeCompare(right.context));
    return {
        surface,
        entries,
        counts: Object.fromEntries(VERDICTS.map(verdict => [
            verdict,
            entries.filter(entry => entry.verdict === verdict).length,
        ])),
        contradictions: entries.filter(entry => CONTRADICTIONS.has(entry.verdict))
            .length,
        gaps: entries.filter(entry => GAPS.has(entry.verdict)).length,
    };
}
//# sourceMappingURL=gate-declaration-drift.js.map