/**
 * coverage-check/user-outcomes.mjs — `## User Outcomes` PRD section parser.
 *
 * Pure, file-IO-free. Mirrors the `coverage-check/acceptance-criteria.mjs`
 * pattern: takes spec.md content as a string and returns a structured
 * `{header_present, body_kind, epic?, outcomes[], validation_warnings?}`
 * envelope the coverage-check command consumes.
 *
 * The `## User Outcomes` section is the verify-proof input contract
 * introduced in v0.8.6 (spec.md §ID-01 / §ID-07). The grammar:
 *
 *   ## User Outcomes
 *
 *   **Epic:** {one-sentence headline}
 *
 *   - **ON** [surface]: {prose}
 *   - **ON** [surface]: {prose} *(requires ON N)*
 *
 *   (refactor-only escape — INSTEAD of the outcome list:)
 *   _No user-observable changes — pure refactor._
 *
 * `surface` is the closed set: electron | web | cli | api | electron+web.
 *
 * **Positional ID synthesis (§ID-07 / M1).** The parser counts outcome
 * rows in document order and synthesizes `id = "O" + (i+1)`. The literal
 * `**ON**` regex stays — IDs are NOT author-written. The `requires N`
 * digit captured from `*(requires ON N)*` is validated against the set
 * of synthesized IDs; on mismatch the parser emits a
 * `validation_warnings[]` entry of kind `unknown_requires_target`,
 * skips the dep edge, but still includes the outcome row.
 *
 * See packages/framework/skills/apt-plan/SKILL.md Section 5.B for the
 * authoring contract.
 */
const VALID_SURFACES = new Set(['electron', 'web', 'cli', 'api', 'electron+web']);
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
const USER_OUTCOMES_HEADING_RE = /^user\s+outcomes\s*$/i;
const EPIC_RE = /^\*\*Epic:\*\*\s+(.+)$/;
const EMPTY_WITH_NOTE_RE = /^_No user-observable changes — pure refactor\._\s*$/;
const OUTCOME_RE = /^-\s+\*\*ON\*\*\s+\[(electron|web|cli|api|electron\+web)\]:\s+(.+?)(\s+\*\(requires\s+ON\s+(\d+)\)\*)?\s*$/;
/**
 * @typedef {Object} UserOutcome
 * @property {string} id                — synthesized positional ID (O1, O2, ...)
 * @property {('electron'|'web'|'cli'|'api'|'electron+web')} surface
 * @property {string} text              — outcome prose (sans the surface tag and requires suffix)
 * @property {number} [requires]        — the digit captured from `*(requires ON N)*`, when present and valid
 */
/**
 * @typedef {Object} ValidationWarning
 * @property {'unknown_requires_target'} kind
 * @property {string} outcome_id        — synthesized ID of the outcome carrying the bad `requires`
 * @property {number} requires          — the offending digit
 * @property {string} reason            — human-readable explanation
 */
/**
 * @typedef {Object} UserOutcomesParseResult
 * @property {boolean} header_present
 * @property {('absent'|'empty'|'empty-with-note'|'outcomes')} body_kind
 * @property {string} [epic]
 * @property {UserOutcome[]} outcomes
 * @property {ValidationWarning[]} [validation_warnings]
 */
/**
 * Parse the `## User Outcomes` section from a spec.md string.
 *
 * @param {string} specContent
 * @returns {UserOutcomesParseResult}
 */
export function parseUserOutcomes(specContent) {
    if (typeof specContent !== 'string' || specContent.length === 0) {
        return { header_present: false, body_kind: 'absent', outcomes: [] };
    }
    const lines = specContent.split('\n');
    // Find the `## User Outcomes` heading + scope to its body (until the next
    // heading of equal or lower depth).
    let headingLine = -1;
    let headingLevel = 0;
    for (let i = 0; i < lines.length; i++) {
        const m = lines[i].trim().match(HEADING_RE);
        if (m && USER_OUTCOMES_HEADING_RE.test(m[2])) {
            headingLine = i;
            headingLevel = m[1].length;
            break;
        }
    }
    if (headingLine === -1) {
        return { header_present: false, body_kind: 'absent', outcomes: [] };
    }
    // Collect body lines from heading+1 until the next heading of ANY
    // depth. The User Outcomes section is intentionally flat — a
    // downstream sub-heading (e.g. `### Acceptance Criteria` following
    // directly without a `## ` separator) ends the body. This matches
    // how authors write the section and avoids accidentally pulling
    // stray prose from later sections into the outcomes pool.
    const bodyLines = [];
    for (let i = headingLine + 1; i < lines.length; i++) {
        const trimmed = lines[i].trim();
        if (HEADING_RE.test(trimmed))
            break;
        bodyLines.push(lines[i]);
    }
    // Classify body shape.
    const nonBlank = bodyLines.map((l) => l.trim()).filter((l) => l.length > 0);
    if (nonBlank.length === 0) {
        return { header_present: true, body_kind: 'empty', outcomes: [] };
    }
    // Empty-with-note escape — body MUST contain only the verbatim
    // italic-underscore-em-dash-period sentinel (other lines may exist
    // only if they are the Epic header or blank — but the spec says the
    // sentinel REPLACES the outcome list, so we require it to be the only
    // non-blank line OR the only non-Epic non-blank line).
    let epic;
    const sentinelLines = [];
    const outcomeLines = [];
    for (const line of nonBlank) {
        const epicMatch = line.match(EPIC_RE);
        if (epicMatch) {
            epic = epicMatch[1].trim();
            continue;
        }
        if (EMPTY_WITH_NOTE_RE.test(line)) {
            sentinelLines.push(line);
            continue;
        }
        outcomeLines.push(line);
    }
    // If the sentinel is present and there are NO outcome lines, this is
    // the empty-with-note branch. The sentinel may co-exist with an Epic
    // header — that's not required but not forbidden either.
    if (sentinelLines.length > 0 && outcomeLines.length === 0) {
        /** @type {UserOutcomesParseResult} */
        const result = {
            header_present: true,
            body_kind: 'empty-with-note',
            outcomes: [],
        };
        if (epic)
            result.epic = epic;
        return result;
    }
    // If neither the sentinel nor any outcome rows are present, the body
    // is effectively empty (header present, no parseable content).
    if (outcomeLines.length === 0) {
        /** @type {UserOutcomesParseResult} */
        const result = {
            header_present: true,
            body_kind: 'empty',
            outcomes: [],
        };
        if (epic)
            result.epic = epic;
        return result;
    }
    // Parse outcome rows in document order. Skip non-matching lines (e.g.
    // stray prose between outcomes) — they don't count as outcomes.
    /** @type {UserOutcome[]} */
    const outcomes = [];
    const rawRequires = [];
    for (const line of outcomeLines) {
        const m = line.match(OUTCOME_RE);
        if (!m)
            continue;
        const surface = /** @type {UserOutcome['surface']} */ (m[1]);
        if (!VALID_SURFACES.has(surface))
            continue;
        const text = m[2].trim();
        const id = 'O' + (outcomes.length + 1);
        const outcome = /** @type {UserOutcome} */ ({ id, surface, text });
        if (m[4]) {
            const n = Number.parseInt(m[4], 10);
            rawRequires.push({ outcomeIndex: outcomes.length, requires: n });
        }
        outcomes.push(outcome);
    }
    if (outcomes.length === 0) {
        // Header present, prose present, but no parseable `**ON**` rows —
        // effectively empty.
        /** @type {UserOutcomesParseResult} */
        const result = {
            header_present: true,
            body_kind: 'empty',
            outcomes: [],
        };
        if (epic)
            result.epic = epic;
        return result;
    }
    // Validate `requires` cross-refs against the set of synthesized IDs.
    /** @type {ValidationWarning[]} */
    const warnings = [];
    for (const r of rawRequires) {
        if (r.requires >= 1 && r.requires <= outcomes.length) {
            outcomes[r.outcomeIndex].requires = r.requires;
        }
        else {
            warnings.push({
                kind: 'unknown_requires_target',
                outcome_id: outcomes[r.outcomeIndex].id,
                requires: r.requires,
                reason: `requires ON ${r.requires} but only ${outcomes.length} outcome(s) parsed`,
            });
        }
    }
    /** @type {UserOutcomesParseResult} */
    const result = {
        header_present: true,
        body_kind: 'outcomes',
        outcomes,
    };
    if (epic)
        result.epic = epic;
    if (warnings.length > 0)
        result.validation_warnings = warnings;
    return result;
}
/**
 * Compute the coverage-check gate result for a parsed user-outcomes
 * envelope. Per §ID-07 table — warn-not-block in v0.8.x, hard-block in
 * v0.9.0.
 *
 *  | header_present | body_kind         | v0.8.x | v0.9.0 |
 *  |----------------|-------------------|--------|--------|
 *  | false          | —                 | warn   | block  |
 *  | true           | empty-with-note   | pass   | pass   |
 *  | true           | outcomes (≥1)     | pass   | pass   |
 *  | true           | empty             | block  | block  |
 *
 * @param {UserOutcomesParseResult} parsed
 * @param {string} frameworkVersion — semver string from the kernel's package.json (e.g. "0.8.6")
 * @returns {('pass'|'warn'|'block')}
 */
export function userOutcomesGateResult(parsed, frameworkVersion) {
    if (parsed.body_kind === 'empty-with-note' || parsed.body_kind === 'outcomes') {
        return 'pass';
    }
    if (parsed.body_kind === 'empty') {
        // Header present, body empty — author started the section and forgot
        // to fill it in. Block at every version.
        return 'block';
    }
    // Missing header (body_kind: 'absent'). Warn in 0.8.x, block in 0.9.0+.
    const major = parseFrameworkMajorMinor(frameworkVersion);
    if (major === null)
        return 'warn';
    const { major: maj, minor: min } = major;
    if (maj > 0 || (maj === 0 && min >= 9))
        return 'block';
    return 'warn';
}
/**
 * Parse the leading `major.minor` of a semver string. Returns null on
 * malformed input (caller treats as "warn" — fail-soft).
 *
 * @param {string} version
 * @returns {{major: number, minor: number} | null}
 */
function parseFrameworkMajorMinor(version) {
    if (typeof version !== 'string')
        return null;
    const m = version.match(/^(\d+)\.(\d+)/);
    if (!m)
        return null;
    return { major: Number.parseInt(m[1], 10), minor: Number.parseInt(m[2], 10) };
}
//# sourceMappingURL=user-outcomes.mjs.map