/**
 * @sanity/ailf-core — Run-provenance ownership policy evaluator
 *
 * The single, pure policy evaluator both enforcement gates call — T1 (API
 * gateway, before dispatch) and T2 (eval runner, before the canonical store
 * write) — so verdicts and user-facing messages can never drift between the
 * two call sites. Zero I/O: typed input + policy in, typed verdict out.
 *
 * `owner.team` is freeform-by-design (D0037): external teams name themselves
 * and internal names drift, so it was never validated against a closed enum.
 * This evaluator refines that into "freeform-but-required for all, known-slug
 * for `official`" — a completeness/validity gate, not an authorization gate
 * (it checks that a run *declares* a valid team, not that the caller is
 * *entitled* to it). See D0055 on why authorization stays with Sanity roles.
 *
 * The policy is data (`SERVER_FLOOR` is option B encoded as a value), so a
 * consumer repo can *strengthen* the floor via `mergeProvenancePolicy` but
 * never weaken it. Reuses the shared owner-team helpers
 * (`KNOWN_OWNER_TEAMS` / `normalizeOwnerTeam` / `isKnownOwnerTeam`) so the
 * known-list and alias-folding stay in one place.
 *
 * @see docs/design-docs/run-provenance-ownership-enforcement.md
 * @see docs/exec-plans/run-provenance-ownership-enforcement.md
 * @see docs/decisions/D0037-run-classification-and-ownership-taxonomy.md
 */
import { isKnownOwnerTeam, KNOWN_OWNER_TEAMS, normalizeOwnerTeam, } from "../../ailf-shared/index.js";
import { PLACEHOLDER_OWNER_TEAM } from "../constants.js";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
 * A well-formed owner-team slug: lowercase letters/digits/hyphens, starting
 * with an alphanumeric. Non-global so it is safe to reuse with `.test()`.
 */
export const OWNER_TEAM_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
/** The sentinel `normalizeOwnerTeam` returns for empty/absent input (D0037). */
const UNKNOWN_OWNER_TEAM = "unknown";
/** Recursively freeze an object so a shared singleton cannot be mutated in place. */
function deepFreeze(value) {
    if (value !== null && typeof value === "object") {
        Object.values(value).forEach(deepFreeze);
        Object.freeze(value);
    }
    return value;
}
/**
 * Option B, encoded as data: every run needs a present, well-formed,
 * non-placeholder, non-"unknown" `owner.team`; `classification: official`
 * additionally requires a known team slug and an `owner.individual`. Everything
 * else is floor-only — unknown slugs are accepted and soft-warned, faithful to
 * D0037's freeform spirit. External teams are never blocked.
 *
 * Frozen: it is the non-negotiable floor both gates import, so it must not be
 * mutated in place (vitest reuses one process per worker — accidental mutation
 * would leak across tests). Strengthen it via {@link mergeProvenancePolicy},
 * which returns a fresh policy.
 */
export const SERVER_FLOOR = {
    team: "error",
    individual: "off",
    classification: "off",
    purpose: "off",
    rules: [
        {
            when: { classification: "official" },
            require: { team: "known", individual: "error" },
        },
    ],
};
deepFreeze(SERVER_FLOOR);
const SEVERITY_RANK = {
    off: 0,
    warn: 1,
    error: 2,
};
const SEVERITY_BY_RANK = ["off", "warn", "error"];
/** Strengthen-only severity combine: the stricter of two over `off < warn < error`. */
function maxSeverity(a, b) {
    return SEVERITY_BY_RANK[Math.max(SEVERITY_RANK[a], SEVERITY_RANK[b])];
}
// ---------------------------------------------------------------------------
// Remediation strings — team-centric, naming every fix surface + the command.
// ---------------------------------------------------------------------------
const TEAM_REMEDIATION = [
    "Set a real team slug via one of:",
    "  --owner-team <slug>",
    "  AILF_OWNER_TEAM=<slug>",
    "  owner.team in .ailf/ailf.config.ts",
    "See the known Sanity teams: npx @sanity/ailf teams",
    "(External team? Any lowercase slug works — e.g. --owner-team acme-docs)",
].join("\n");
const INDIVIDUAL_REMEDIATION = [
    "Name the person accountable for the run via one of:",
    "  --owner-individual <email-or-handle>",
    "  AILF_OWNER_INDIVIDUAL=<email-or-handle>",
    "  owner.individual in .ailf/ailf.config.ts",
].join("\n");
const PURPOSE_REMEDIATION = [
    "State why the run exists via one of:",
    "  --purpose <text>",
    "  AILF_PURPOSE=<text>",
    "  purpose in .ailf/ailf.config.ts",
].join("\n");
// ---------------------------------------------------------------------------
// did-you-mean — nearest known slug by Levenshtein distance (for `official` typos)
// ---------------------------------------------------------------------------
/** Classic Levenshtein edit distance. Small inputs (team slugs), so O(n·m) is fine. */
function levenshtein(a, b) {
    if (a === b)
        return 0;
    if (a.length === 0)
        return b.length;
    if (b.length === 0)
        return a.length;
    let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
    let curr = new Array(b.length + 1);
    for (let i = 1; i <= a.length; i++) {
        curr[0] = i;
        for (let j = 1; j <= b.length; j++) {
            const cost = a[i - 1] === b[j - 1] ? 0 : 1;
            curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
        }
        ;
        [prev, curr] = [curr, prev];
    }
    return prev[b.length];
}
/** Max edit distance for which a suggestion is offered (avoids absurd suggestions). */
const SUGGESTION_MAX_DISTANCE = 3;
/**
 * Nearest known team slug to a misspelling, or `undefined` when nothing is close
 * enough. Ties broken by `KNOWN_OWNER_TEAMS` order (stable, declaration order).
 * An already-known slug is not a misspelling, so it returns `undefined`.
 */
export function suggestKnownTeam(slug) {
    if (isKnownOwnerTeam(slug))
        return undefined;
    let best;
    let bestDistance = SUGGESTION_MAX_DISTANCE + 1;
    for (const known of KNOWN_OWNER_TEAMS) {
        const distance = levenshtein(slug, known);
        if (distance > 0 && distance < bestDistance) {
            best = known;
            bestDistance = distance;
        }
    }
    return bestDistance <= SUGGESTION_MAX_DISTANCE ? best : undefined;
}
// ---------------------------------------------------------------------------
// Evaluator
// ---------------------------------------------------------------------------
function violation(field, severity, code, message, remediation) {
    return { field, severity, code, message, remediation };
}
/**
 * Evaluate a run's declared provenance against a policy. Pure and deterministic.
 *
 * Evaluation order: normalize `team` (folding aliases like `core-docs → docs`),
 * apply the baseline team check at `policy.team` severity (raised to `error`
 * when a matching rule requires the team be present/known), then the
 * classification-conditional known/individual rules, then the unconditional
 * `individual`/`purpose` baselines. A team that fails the baseline short-circuits
 * the known/soft checks (no point reporting "unknown team" for a missing one).
 */
export function evaluateProvenance(input, policy) {
    const violations = [];
    const matchingRules = policy.rules.filter((rule) => rule.when.classification === input.classification);
    const requiresKnown = matchingRules.some((rule) => rule.require.team === "known");
    const requiresTeamPresent = matchingRules.some((rule) => rule.require.team === "present" || rule.require.team === "known");
    // A matching rule's team requirement forces at least presence even if the
    // baseline `policy.team` is `off`.
    const teamSeverity = requiresTeamPresent
        ? maxSeverity(policy.team, "error")
        : policy.team;
    const rawTeam = input.owner?.team;
    const trimmedTeam = rawTeam?.trim() ?? "";
    let normalizedTeam;
    if (trimmedTeam === "") {
        if (teamSeverity !== "off") {
            violations.push(violation("team", teamSeverity, "team_missing", "owner.team is not set.", TEAM_REMEDIATION));
        }
    }
    else if (trimmedTeam === PLACEHOLDER_OWNER_TEAM) {
        if (teamSeverity !== "off") {
            violations.push(violation("team", teamSeverity, "team_placeholder", `owner.team is still the init placeholder "${PLACEHOLDER_OWNER_TEAM}".`, TEAM_REMEDIATION));
        }
    }
    else {
        const normalized = normalizeOwnerTeam(trimmedTeam);
        if (normalized === UNKNOWN_OWNER_TEAM) {
            if (teamSeverity !== "off") {
                violations.push(violation("team", teamSeverity, "team_missing", 'owner.team is "unknown" (treated as not set).', TEAM_REMEDIATION));
            }
        }
        else if (!OWNER_TEAM_SLUG_PATTERN.test(normalized)) {
            if (teamSeverity !== "off") {
                violations.push(violation("team", teamSeverity, "team_malformed", `owner.team "${rawTeam}" is not a valid slug (expected lowercase letters, digits, and hyphens).`, TEAM_REMEDIATION));
            }
        }
        else {
            // Baseline passed: this is the canonical slug we would record.
            normalizedTeam = normalized;
            const known = isKnownOwnerTeam(normalized);
            if (requiresKnown && !known) {
                const suggestion = suggestKnownTeam(normalized);
                const didYouMean = suggestion ? ` Did you mean "${suggestion}"?` : "";
                violations.push(violation("team", "error", "team_unknown_for_official", `owner.team "${normalized}" is not a known Sanity team, and official runs require a known team.${didYouMean}`, TEAM_REMEDIATION));
            }
            else if (!requiresKnown && !known && teamSeverity !== "off") {
                violations.push(violation("team", "warn", "team_unknown_soft", `owner.team "${normalized}" is not a known Sanity team; it will be recorded as-is.`, TEAM_REMEDIATION));
            }
        }
    }
    // Individual — strictest of the unconditional baseline and any matching rule.
    const ruleIndividualSeverity = matchingRules.reduce((acc, rule) => maxSeverity(acc, rule.require.individual ?? "off"), "off");
    const individualSeverity = maxSeverity(policy.individual, ruleIndividualSeverity);
    const hasIndividual = (input.owner?.individual?.trim() ?? "") !== "";
    if (individualSeverity !== "off" && !hasIndividual) {
        const forOfficial = input.classification === "official" && ruleIndividualSeverity !== "off";
        violations.push(forOfficial
            ? violation("individual", individualSeverity, "individual_missing_for_official", "official runs require owner.individual (a person accountable for the run).", INDIVIDUAL_REMEDIATION)
            : violation("individual", individualSeverity, "individual_missing", "owner.individual is required by this project's provenance policy.", INDIVIDUAL_REMEDIATION));
    }
    // Purpose — unconditional baseline only (floor leaves it off).
    if (policy.purpose !== "off" && (input.purpose?.trim() ?? "") === "") {
        violations.push(violation("purpose", policy.purpose, "purpose_missing", "purpose is required by this project's provenance policy.", PURPOSE_REMEDIATION));
    }
    const ok = !violations.some((v) => v.severity === "error");
    return normalizedTeam === undefined
        ? { ok, violations }
        : { ok, violations, normalizedTeam };
}
// ---------------------------------------------------------------------------
// Policy merge — strengthen only
// ---------------------------------------------------------------------------
/**
 * Combine the server floor with an optional project policy. A project may only
 * *strengthen*: each field's severity is the stricter of the two
 * (`max` over `off < warn < error`), and rules are unioned — a project can add
 * rules but cannot remove a floor rule. This is what makes the floor
 * non-negotiable while allowing per-repo opt-in strictness on top.
 */
export function mergeProvenancePolicy(floor, project) {
    if (!project)
        return { ...floor, rules: [...floor.rules] };
    return {
        team: maxSeverity(floor.team, project.team ?? "off"),
        individual: maxSeverity(floor.individual, project.individual ?? "off"),
        classification: maxSeverity(floor.classification, project.classification ?? "off"),
        purpose: maxSeverity(floor.purpose, project.purpose ?? "off"),
        rules: [...floor.rules, ...(project.rules ?? [])],
    };
}
