<?php

/**
 * AI repair pass for main SBA generator (session-only preview; tutor must apply to draft).
 */

require_once dirname(__DIR__) . '/app/ai/form_generator_question_validation.php';

/**
 * @param array<string, mixed> $question
 * @param array<string, mixed> $aiReview
 * @param list<array<string, mixed>> $styleWarnings
 * @param array<string, string> $lockedContext
 */
function buildSbaAiRepairPrompt(
    array $question,
    array $aiReview,
    array $styleWarnings,
    array $lockedContext
): string {
    $original = formGeneratorQuestionContentSnapshot($question);
    $originalJson = json_encode($original, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    if ($originalJson === false) {
        $originalJson = '{}';
    }

    $reviewJson = json_encode($aiReview, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    if ($reviewJson === false) {
        $reviewJson = '{}';
    }

    $warningsJson = json_encode($styleWarnings, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    if ($warningsJson === false) {
        $warningsJson = '[]';
    }

    $lockedJson = json_encode($lockedContext, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    if ($lockedJson === false) {
        $lockedJson = '{}';
    }

    return <<<REPAIR_PROMPT
You are an **expert UK medical school SBA editor** applying **Reviewer 1 quality/style recommendations** to a draft MSC AKT item.

You produce a **repaired preview** only. You do **not** approve the item for use. The tutor remains the final approver.

---

## Locked context (must NOT change — educational metadata only)

{$lockedJson}

Do **not** change **skill**, **specialty**, **condition**, **presentation**, or **year level** intent. The repaired item must still assess the same skill and learning aim.

---

## Original question JSON

{$originalJson}

---

## Reviewer 1 output (quality/style recommendations)

{$reviewJson}

---

## Rule-based style warnings (clues only — verify against the draft)

{$warningsJson}

---

## Your task

1. Apply the **most important** Reviewer 1 **recommended_changes** where safe. **Do not** polish an unsafe keyed answer in place.
2. If Reviewer 1 flags **unsafe_or_inaccurate**, stem–key contradiction, wrong emergency pathway, or similar **high-risk safety** issues, return **repair_blocked**: true (see Output) — **do not** return a repaired_question.
3. **Auto-apply eligible (low-risk style/structure)** — may appear in repaired preview: spelling, grammar, punctuation, UK terminology; title/lead-in rephrase with **unchanged task/meaning**; “history of” → direct phrasing; observation order; duplicated option labels; minor option length balancing with **unchanged clinical concept**.
4. **Preview-only / tutor review required** — **tutor_review_required** true, **auto_apply_eligible** false, **repair_risk** at least **medium**: distractor diagnosis/drug/investigation/monitoring/emergency changes; correct answer text/letter; scenario, contraindications, observations, risk factors; skill shift; oxygen device/flow/target; anticoagulation, antiplatelet, antibiotic, analgesia, steroid, emergency drug options.
5. **Block automatic repair** (**repair_blocked** true) when: unsafe key; stem–key or result–key contradiction; justification invents unsupported facts; emergency pathway mismatch; antibiotics without infection/sepsis cues; antiplatelet/anticoagulant with possible aortic dissection; COPD/hypercapnia oxygen risk; hypotension with beta-blocker/nitrate/vasodilator key; thrombolysis without indication; routine anti-Xa without indication; multiple plausible correct answers. Set **repair_summary** to **Automatic repair blocked: clinical safety concern**.

Preserve **one best answer** fairness. Every option A–E **non-empty**. **correct_answer** must match **correct_answer_letter** option line.

**change_type**: **style** | **structure** | **educational_content** | **clinical** | **safety**

**repair_risk** per change: **low** | **medium** | **high**. **auto_apply_eligible**: true only for low-risk style/structure with unchanged clinical meaning. **tutor_review_required**: true for educational_content, clinical, safety, or medium/high repair_risk.

**Never** label **style** when swapping diagnosis, investigation, drug, monitoring, emergency action, or pathway (e.g. lymphoedema → superficial thrombophlebitis; FBC → PT; LFTs → aPTT).

**repair_risk_level** overall: **low**, **medium**, **high**. **any_clinical_content_changed**: true when clinical meaning changes; always true if **correct_answer_letter** changes.

---

## Output

Return **valid JSON only** (no markdown fences, no commentary).

When repair is **blocked**, use this shape (omit **repaired_question**):

{
  "repair_blocked": true,
  "repair_blocked_reasons": ["<plain English reason>"],
  "repair_summary": "Automatic repair blocked: clinical safety concern",
  "change_log": [],
  "repair_risk_level": "high",
  "any_clinical_content_changed": false,
  "recommend_clinical_safety_review": true
}

When repair proceeds, use **exactly** these keys:

{
  "repair_blocked": false,
  "repaired_question": { "title": "", "stem": "", "lead_in": "", "option_a": "", "option_b": "", "option_c": "", "option_d": "", "option_e": "", "correct_answer_letter": "", "correct_answer": "", "justification": "" },
  "change_log": [
    {
      "field": "",
      "before": "",
      "after": "",
      "reason": "",
      "change_type": "style|structure|educational_content|clinical|safety",
      "repair_risk": "low|medium|high",
      "auto_apply_eligible": true,
      "tutor_review_required": false
    }
  ],
  "repair_risk_level": "low|medium|high",
  "any_clinical_content_changed": true,
  "repair_summary": ""
}

Return JSON only. No extra text.
REPAIR_PROMPT;
}

/**
 * @param list<mixed> $raw
 * @return list<array<string, mixed>>
 */
function sbaAiRepairNormalizeChangeLog($raw): array
{
    if (!is_array($raw)) {
        return [];
    }
    $types = ['style', 'structure', 'educational_content', 'clinical', 'safety'];
    $risks = ['low', 'medium', 'high'];
    $fields = [
        'title', 'stem', 'lead_in', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e',
        'options', 'correct_answer', 'correct_answer_letter', 'justification', 'general',
    ];
    $out = [];
    foreach ($raw as $entry) {
        if (!is_array($entry)) {
            continue;
        }
        $field = trim((string) ($entry['field'] ?? 'general'));
        if (!in_array($field, $fields, true)) {
            $field = 'general';
        }
        $changeType = strtolower(trim((string) ($entry['change_type'] ?? 'style')));
        if (!in_array($changeType, $types, true)) {
            $changeType = 'style';
        }
        $repairRisk = strtolower(trim((string) ($entry['repair_risk'] ?? $entry['risk'] ?? 'low')));
        if (!in_array($repairRisk, $risks, true)) {
            $repairRisk = 'low';
        }
        $before = trim((string) ($entry['before'] ?? ''));
        $after = trim((string) ($entry['after'] ?? ''));
        $reason = trim((string) ($entry['reason'] ?? ''));
        if ($before === '' && $after === '' && $reason === '') {
            continue;
        }
        $out[] = [
            'field' => $field,
            'before' => $before,
            'after' => $after,
            'reason' => $reason,
            'change_type' => $changeType,
            'repair_risk' => $repairRisk,
            'auto_apply_eligible' => !empty($entry['auto_apply_eligible']),
            'tutor_review_required' => !empty($entry['tutor_review_required']),
        ];
    }

    return $out;
}

/**
 * @param list<array<string, mixed>> $changeLog
 * @param array<string, mixed> $repairedSnapshot
 */
function sbaAiRepairEnrichChangeLogPermissions(array &$changeLog, array $repairedSnapshot): void
{
    foreach ($changeLog as &$entry) {
        if (!is_array($entry)) {
            continue;
        }
        $changeType = (string) ($entry['change_type'] ?? 'style');
        $repairRisk = (string) ($entry['repair_risk'] ?? 'low');
        $field = (string) ($entry['field'] ?? 'general');
        $before = (string) ($entry['before'] ?? '');
        $after = (string) ($entry['after'] ?? '');

        if (sbaAiRepairTextsAreStyleOnlyRephrase($before, $after, $field)) {
            $entry['change_type'] = in_array($field, ['stem', 'lead_in'], true) ? 'structure' : 'style';
            $entry['repair_risk'] = 'low';
            $entry['auto_apply_eligible'] = true;
            $entry['tutor_review_required'] = false;
            continue;
        }

        if (in_array($changeType, ['clinical', 'safety', 'educational_content'], true)) {
            $entry['tutor_review_required'] = true;
            $entry['auto_apply_eligible'] = false;
            if ($repairRisk === 'low') {
                $entry['repair_risk'] = $changeType === 'educational_content' ? 'medium' : 'high';
            }
            continue;
        }

        if (sbaAiRepairFieldIsKeyedAnswer($field, $repairedSnapshot)
            || ($field === 'correct_answer_letter')
            || sbaAiRepairEducationalContentChanged($before, $after)) {
            $entry['change_type'] = sbaAiRepairFieldIsKeyedAnswer($field, $repairedSnapshot)
                ? 'clinical'
                : 'educational_content';
            $entry['tutor_review_required'] = true;
            $entry['auto_apply_eligible'] = false;
            $entry['repair_risk'] = sbaAiRepairFieldIsKeyedAnswer($field, $repairedSnapshot) ? 'high' : 'medium';
            continue;
        }

        if (in_array($changeType, ['style', 'structure'], true) && $repairRisk === 'low') {
            $entry['auto_apply_eligible'] = true;
            $entry['tutor_review_required'] = false;
        } else {
            $entry['auto_apply_eligible'] = false;
            $entry['tutor_review_required'] = true;
        }
    }
    unset($entry);
}

/**
 * @param array<string, mixed> $aiReview
 * @return list<string>
 */
function sbaAiRepairCollectReviewerConcernTexts(array $aiReview): array
{
    $parts = [];
    foreach (['safety_concerns', 'independent_concerns', 'style_concerns'] as $key) {
        if (!is_array($aiReview[$key] ?? null)) {
            continue;
        }
        foreach ($aiReview[$key] as $item) {
            $t = trim((string) $item);
            if ($t !== '') {
                $parts[] = $t;
            }
        }
    }
    foreach ($aiReview['recommended_changes'] ?? [] as $chg) {
        if (!is_array($chg)) {
            continue;
        }
        if (($chg['priority'] ?? '') === 'high') {
            $issue = trim((string) ($chg['issue'] ?? ''));
            if ($issue !== '') {
                $parts[] = $issue;
            }
        }
    }

    return $parts;
}

/**
 * @param array<string, mixed> $snapshot
 * @param array<string, mixed> $metadata
 * @return list<string>
 */
function sbaAiRepairDetectBlockPatternsOnQuestion(array $snapshot, array $metadata = []): array
{
    $reasons = [];
    $stem = (string) ($snapshot['stem'] ?? '');
    $leadIn = (string) ($snapshot['lead_in'] ?? '');
    $justification = (string) ($snapshot['justification'] ?? '');
    $contextText = $stem . "\n" . $leadIn . "\n"
        . (string) ($metadata['condition_name'] ?? '') . "\n"
        . (string) ($metadata['presentation'] ?? '') . "\n"
        . (string) ($metadata['title'] ?? '');
    $letter = strtoupper(trim((string) ($snapshot['correct_answer_letter'] ?? '')));
    $keyText = trim((string) ($snapshot['correct_answer'] ?? ''));
    if ($letter >= 'A' && $letter <= 'E') {
        $optKey = 'option_' . strtolower($letter);
        $optLine = trim((string) ($snapshot[$optKey] ?? ''));
        if ($optLine !== '') {
            $keyText = $optLine;
        }
    }
    $combined = $stem . "\n" . $leadIn . "\n" . $keyText . "\n" . $justification;
    $stemLower = mb_strtolower($contextText);
    $keyLower = mb_strtolower($keyText);
    $comboLower = mb_strtolower($combined);

    $blockPhrases = [
        'stem-key contradiction' => '/\b(?:stem[\s-]?key|key(?:ed)?\s+answer).{0,40}contradict/i',
        'result-key contradiction' => '/\b(?:result|investigation|imaging|ctpa|ultrasound).{0,40}contradict/i',
        'justification invents' => '/\bjustification.{0,30}(?:invent|unsupported|not\s+in\s+stem)/i',
        'emergency pathway mismatch' => '/\b(?:wrong|mismatched)\s+emergency\s+pathway|sepsis\s+bundle.{0,30}(?:acs|angina|mi\b)/i',
        'multiple plausible correct' => '/\bmultiple\s+plausible|more\s+than\s+one\s+(?:correct|best)\s+answer/i',
        'unsafe key' => '/\bunsafe\s+(?:key|keyed|answer)/i',
    ];
    foreach ($blockPhrases as $label => $pattern) {
        if (preg_match($pattern, $comboLower)) {
            $reasons[] = ucfirst($label) . ' flagged in draft or review text.';
        }
    }

    $hasInfectionCue = (bool) preg_match(
        '/\b(?:sepsis|septic|infection|infective|pneumonia|uti|pyelonephritis|cellulitis|meningitis|febrile|fever|purulent|pus|positive\s+culture)\b/i',
        $stemLower
    );
    if (!$hasInfectionCue && preg_match('/\b(?:iv|intravenous)\s+antibiotics?\b|\bbroad[\s-]spectrum\s+antibiotics?\b/i', $keyLower)) {
        $reasons[] = 'Antibiotics keyed without clear infection or sepsis cues in the stem.';
    }

    if (preg_match('/\b(?:tearing\s+chest\s+pain|aortic\s+dissection|marfan|unequal\s+blood\s+pressures|widened\s+mediastinum)\b/i', $stemLower)
        && preg_match('/\b(?:aspirin|clopidogrel|ticagrelor|prasugrel|heparin|anticoagul|rivaroxaban|apixaban|lmwh|enoxaparin)\b/i', $keyLower)) {
        $reasons[] = 'Antiplatelet or anticoagulant keyed when aortic dissection may be possible.';
    }

    $copdRisk = (bool) preg_match('/\b(?:copd|co2\s+retainer|hypercapnic|type\s+2\s+respiratory\s+failure)\b/i', $stemLower);
    if ($copdRisk && preg_match('/\b(?:non[\s-]rebreather|15\s*l\/?\s*min|high[\s-]flow\s+oxygen)\b/i', $keyLower)
        && !preg_match('/\b(?:life[\s-]threatening|critical|imminent|cardiac\s+arrest|peri[\s-]arrest)\b/i', $stemLower)) {
        $reasons[] = 'Oxygen delivery/target risk in COPD or hypercapnic respiratory failure.';
    }

    $hypotension = (bool) preg_match(
        '/\b(?:hypotension|hypotensive|shock|systolic\s+(?:blood\s+pressure|bp)\s*(?:is|of|was|:)?\s*(?:[0-9]|[1-9][0-9])\b|bp\s+[0-9]{2,3}\s*\/\s*[0-5][0-9]\b)/i',
        $stemLower
    );
    if ($hypotension && preg_match(
        '/\b(?:beta[\s-]blocker|metoprolol|bisoprolol|atenolol|propranolol|carvedilol|glyceryl\s+trinitrate|gtn|nitrate|isosorbide|amlodipine|vasodilator|morphine)\b/i',
        $keyLower
    )) {
        $reasons[] = 'Hypotension or shock conflicts with beta-blocker, nitrate, vasodilator or other BP-lowering keyed answer.';
    }

    if (preg_match('/\b(?:thrombolysis|thrombolyse|alteplase)\b/i', $keyLower)
        && !preg_match('/\b(?:stemi|st[\s-]elevation|massive\s+pe|high[\s-]risk\s+pe|cardiac\s+arrest|rosc)\b/i', $stemLower)) {
        $reasons[] = 'Thrombolysis keyed without clear indication in the stem.';
    }

    if (preg_match('/\banti[\s-]xa\b/i', $keyLower)
        && !preg_match('/\b(?:renal\s+impairment|pregnancy|obesity|bleeding|weight[\s-]adjust|special\s+indication)\b/i', $stemLower)) {
        $reasons[] = 'Anti-Xa or specialist monitoring test keyed as routine monitoring without special indication.';
    }

    $invFp = sbaAiRepairInvestigationInterpretationFingerprint($stem);
    $keyInvFp = sbaAiRepairInvestigationInterpretationFingerprint($keyText . ' ' . $justification);
    if ($invFp !== [] && $keyInvFp !== []
        && !sbaAiRepairFingerprintListsEqual($invFp, $keyInvFp)
        && (in_array('no_dvt_on_us', $invFp, true) || in_array('normal_venous_ultrasound', $invFp, true))
        && (in_array('acute_dvt_on_us', $keyInvFp, true) || preg_match('/\b(?:anticoagulation|lmwh|enoxaparin|rivaroxaban)\b/i', $keyLower))) {
        $reasons[] = 'Investigation or result contradicts the keyed management answer.';
    }

    return array_values(array_unique($reasons));
}

/**
 * @param array<string, mixed> $question
 * @param array<string, mixed> $aiReview
 * @return array{block: bool, reasons: list<string>}
 */
function sbaAiRepairShouldBlockAutomaticRepair(array $question, array $aiReview): array
{
    $metadata = [
        'condition_name' => (string) ($question['condition_name'] ?? ''),
        'presentation' => (string) ($question['presentation'] ?? ''),
        'title' => (string) ($question['title'] ?? ''),
    ];
    $reasons = sbaAiRepairDetectBlockPatternsOnQuestion(
        formGeneratorQuestionContentSnapshot($question),
        $metadata
    );

    $concernBlob = mb_strtolower(implode("\n", sbaAiRepairCollectReviewerConcernTexts($aiReview)));
    $concernPatterns = [
        'stem-key contradiction' => '/stem[\s-]?key|key.{0,20}contradict/',
        'unsafe key' => '/unsafe\s+(?:key|keyed|answer)|wrong\s+key/',
        'result-key contradiction' => '/result[\s-]?key|investigation.{0,25}contradict/',
        'justification invents' => '/justification.{0,25}(?:invent|unsupported)/',
        'emergency pathway' => '/emergency\s+pathway|wrong\s+pathway|sepsis\s+bundle/',
        'multiple plausible' => '/multiple\s+plausible|more\s+than\s+one\s+correct/',
        'antibiotics without infection' => '/antibiotics?.{0,30}(?:without|no)\s+(?:infection|sepsis)/',
        'aortic dissection anticoagulation' => '/aortic\s+dissection.{0,40}(?:anticoag|antiplatelet|aspirin)/',
        'copd oxygen' => '/copd.{0,40}(?:oxygen|non[\s-]rebreather|high[\s-]flow)/',
        'hypotension beta-blocker' => '/hypotension.{0,40}(?:beta[\s-]blocker|nitrate|vasodilator)/',
    ];
    foreach ($concernPatterns as $label => $pattern) {
        if ($concernBlob !== '' && preg_match($pattern, $concernBlob)) {
            $reasons[] = 'Reviewer 1: ' . $label . '.';
        }
    }

    if (($aiReview['clinical_accuracy_rating'] ?? '') === 'unsafe_or_inaccurate') {
        $reasons[] = 'Reviewer 1 rated clinical accuracy as unsafe or inaccurate.';
    }
    if (!empty($aiReview['do_not_use_without_faculty_review'])
        && (($aiReview['one_best_answer_rating'] ?? '') === 'poor'
            || ($aiReview['clinical_accuracy_rating'] ?? '') === 'borderline')) {
        $safety = is_array($aiReview['safety_concerns'] ?? null) ? $aiReview['safety_concerns'] : [];
        if ($safety !== []) {
            $reasons[] = 'Reviewer 1 safety concerns require faculty review before automatic repair.';
        }
    }

    $reasons = array_values(array_unique(array_filter($reasons, static fn ($r) => trim((string) $r) !== '')));

    return ['block' => $reasons !== [], 'reasons' => $reasons];
}

/**
 * @param list<string> $reasons
 * @param array<string, mixed> $originalQuestion
 * @return array<string, mixed>
 */
function sbaAiRepairBuildBlockedRepairData(array $originalQuestion, array $reasons): array
{
    $snapshot = formGeneratorQuestionContentSnapshot($originalQuestion);
    $summary = 'Automatic repair blocked: clinical safety concern';
    if ($reasons !== []) {
        $summary .= ' — ' . $reasons[0];
    }

    return [
        'repair_blocked' => true,
        'repair_blocked_message' => 'Automatic repair blocked: clinical safety concern',
        'repair_blocked_reasons' => $reasons,
        'recommend_clinical_safety_review' => true,
        'repaired_question' => null,
        'change_log' => [],
        'repair_risk_level' => 'high',
        'any_clinical_content_changed' => false,
        'educational_content_changed' => false,
        'tutor_review_required' => true,
        'repair_summary' => $summary,
        'locked_context' => formGeneratorLockedQuestionContext($originalQuestion),
        'original_snapshot' => $snapshot,
    ];
}

/**
 * @param array<string, mixed> $repaired
 * @return array{ok: bool, data: ?array<string, mixed>, error: string}
 */
/**
 * @param list<array<string, mixed>> $changeLog
 * @param array<string, mixed> $repairedSnapshot
 * @return list<array<string, mixed>>
 */
function sbaAiRepairSanitizeChangeLogEntries(array $changeLog, array $repairedSnapshot): array
{
    $letter = strtoupper(trim((string) ($repairedSnapshot['correct_answer_letter'] ?? '')));
    $options = [
        'A' => trim((string) ($repairedSnapshot['option_a'] ?? '')),
        'B' => trim((string) ($repairedSnapshot['option_b'] ?? '')),
        'C' => trim((string) ($repairedSnapshot['option_c'] ?? '')),
        'D' => trim((string) ($repairedSnapshot['option_d'] ?? '')),
        'E' => trim((string) ($repairedSnapshot['option_e'] ?? '')),
    ];
    $keyedText = preg_match('/^[A-E]$/', $letter) && isset($options[$letter]) ? $options[$letter] : '';
    $keyedAnswer = trim((string) ($repairedSnapshot['correct_answer'] ?? ''));

    foreach ($changeLog as $idx => $entry) {
        if (!is_array($entry)) {
            continue;
        }
        $field = (string) ($entry['field'] ?? '');
        if ($field === 'correct_answer' || $field === 'correct_answer_letter') {
            if (formGeneratorIsPlaceholderAnswerText((string) ($entry['after'] ?? ''))) {
                $changeLog[$idx]['after'] = $keyedAnswer !== '' ? $keyedAnswer : $keyedText;
            }
            if (formGeneratorIsPlaceholderAnswerText((string) ($entry['before'] ?? ''))) {
                $changeLog[$idx]['before'] = '';
            }
        }
        if (preg_match('/^option_[a-e]$/', $field)) {
            $optLetter = strtoupper(substr($field, -1));
            if (
                $optLetter === $letter
                && formGeneratorIsPlaceholderAnswerText((string) ($entry['after'] ?? ''))
                && $keyedText !== ''
            ) {
                $changeLog[$idx]['after'] = $keyedText;
            }
        }
    }

    return $changeLog;
}

function sbaAiRepairNormalizeRepairedQuestion(array $repaired): array
{
    $required = [
        'title', 'stem', 'lead_in', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e',
        'correct_answer_letter', 'correct_answer', 'justification',
    ];
    foreach ($required as $key) {
        if (!array_key_exists($key, $repaired)) {
            return ['ok' => false, 'data' => null, 'error' => 'Repaired question missing field: ' . $key];
        }
    }

    $letter = strtoupper(trim((string) $repaired['correct_answer_letter']));
    if (!preg_match('/^[A-E]$/', $letter)) {
        return ['ok' => false, 'data' => null, 'error' => 'Repaired question has invalid correct_answer_letter.'];
    }

    $options = [
        'A' => trim((string) $repaired['option_a']),
        'B' => trim((string) $repaired['option_b']),
        'C' => trim((string) $repaired['option_c']),
        'D' => trim((string) $repaired['option_d']),
        'E' => trim((string) $repaired['option_e']),
    ];
    foreach ($options as $optLetter => $text) {
        if ($text === '') {
            return ['ok' => false, 'data' => null, 'error' => 'Repaired question option ' . $optLetter . ' is empty.'];
        }
    }

    $correctAnswer = trim((string) $repaired['correct_answer']);
    if (formGeneratorIsPlaceholderAnswerText($correctAnswer)) {
        $correctAnswer = '';
    }
    if ($correctAnswer === '') {
        $correctAnswer = $options[$letter];
    }
    if ($correctAnswer === '' || formGeneratorIsPlaceholderAnswerText($correctAnswer)) {
        return ['ok' => false, 'data' => null, 'error' => 'Repaired question has invalid correct_answer.'];
    }
    if (!formGeneratorAnswerTextsMatch($correctAnswer, $options[$letter])) {
        return ['ok' => false, 'data' => null, 'error' => 'Repaired question correct_answer does not match correct_answer_letter option.'];
    }

    return [
        'ok' => true,
        'data' => [
            'title' => trim((string) $repaired['title']),
            'stem' => trim((string) $repaired['stem']),
            'lead_in' => trim((string) $repaired['lead_in']),
            'option_a' => $options['A'],
            'option_b' => $options['B'],
            'option_c' => $options['C'],
            'option_d' => $options['D'],
            'option_e' => $options['E'],
            'correct_answer_letter' => $letter,
            'correct_answer' => $correctAnswer,
            'justification' => trim((string) $repaired['justification']),
        ],
        'error' => '',
    ];
}

/**
 * Canonical clinical-action / investigation tokens detected in option or answer text (order-independent).
 *
 * @return list<string>
 */
function sbaAiRepairClinicalConceptFingerprint(string $text): array
{
    $t = ' ' . mb_strtolower(trim($text)) . ' ';
    if ($t === '  ') {
        return [];
    }

    $rules = [
        'oxygen_therapy' => '/\b(?:give\s+)?oxygen(?:\s+therapy)?|supplemental\s+oxygen|high[\s-]flow\s+oxygen|provide\s+oxygen\b/',
        'iv_fluids' => '/\b(?:iv|intravenous)\s+fluids?|\bfluid\s+resuscitation\b|\bcrystalloid\b|\bfluid\s+bolus\b/',
        'broad_spectrum_iv_antibiotics' => '/\bbroad[\s-]spectrum\s+(?:iv\s+)?antibiotics?\b/',
        'iv_antibiotics' => '/\b(?:iv|intravenous)\s+antibiotics?\b/',
        'oral_antibiotics' => '/\b(?:oral\s+antibiotics?|oral\s+antimicrobial)\b/',
        'vasopressor' => '/\b(?:vasopressor|noradrenaline|norepinephrine|epinephrine\s+infusion)\b/',
        'thrombolysis' => '/\b(?:thrombolysis|thrombolyse|alteplase|reperfusion)\b/',
        'anticoagulation' => '/\b(?:anticoagulation|anticoagulant|heparin|lmwh|rivaroxaban|apixaban|warfarin)\b/',
        'source_control' => '/\b(?:source\s+control|surgical\s+review|drainage|debridement)\b/',
        'chest_xray' => '/\b(?:chest\s+x[\s-]?ray|cxr)\b/',
        'ct_scan' => '/\b(?:ct\s+(?:scan|chest|pulmonary)|computed\s+tomography)\b/',
        'blood_cultures' => '/\b(?:blood\s+cultures?)\b/',
        'ecg' => '/\b(?:12[\s-]lead\s+ecg|electrocardiogram|ecg)\b/',
        'troponin' => '/\btroponin\b/',
        'bronchodilator' => '/\b(?:salbutamol|nebulised\s+salbutamol|bronchodilator|beta[\s-]agonist)\b/',
        'corticosteroid' => '/\b(?:prednisolone|hydrocortisone|methylprednisolone|oral\s+corticosteroid|systemic\s+corticosteroid)\b/',
        'insulin' => '/\b(?:insulin|fixed[\s-]rate\s+insulin)\b/',
        'fluids_alone' => '/\b(?:iv\s+fluids?\s+only|fluids?\s+alone)\b/',
        'monitor_only' => '/\b(?:monitor\s+only|observe\s+only|ward\s+monitoring\s+only)\b/',
    ];

    $found = [];
    foreach ($rules as $key => $pattern) {
        if (preg_match($pattern, $t)) {
            $found[$key] = true;
        }
    }

    $keys = array_keys($found);
    sort($keys);

    return $keys;
}

/**
 * @param list<string> $a
 * @param list<string> $b
 */
function sbaAiRepairClinicalConceptSetsEqual(array $a, array $b): bool
{
    $sa = $a;
    $sb = $b;
    sort($sa);
    sort($sb);

    return $sa === $sb;
}

/**
 * Severity / observation cues that change clinical meaning when altered in stem or lead-in.
 */
function sbaAiRepairSeverityCueSignature(string $text): string
{
    $parts = [];
    if (preg_match_all('/(?:spo2|oxygen\s+saturation|saturation\s+levels?)[^\d]{0,24}(\d{1,3})\s*%/i', $text, $m)) {
        foreach ($m[1] as $v) {
            $n = (int) $v;
            if ($n >= 50 && $n <= 100) {
                $parts[] = 'spo2:' . $n;
            }
        }
    }
    if (preg_match_all('/(?:respiratory\s+rate|rr)\s*(?:is|of|was|:)?\s*(\d{1,3})/i', $text, $m)) {
        foreach ($m[1] as $v) {
            $parts[] = 'rr:' . (int) $v;
        }
    }
    if (preg_match_all('/(?:blood\s+pressure|bp)\s+(\d{2,3})\s*\/\s*(\d{2,3})/i', $text, $m)) {
        foreach ($m[1] as $i => $sys) {
            $parts[] = 'bp:' . (int) $sys . '/' . (int) ($m[2][$i] ?? 0);
        }
    }
    if (preg_match('/\b(?:hypotension|shock|septic\s+shock)\b/i', $text)) {
        $parts[] = 'flag:hypotension_or_shock';
    }
    if (preg_match('/\b(?:curb[\s-]?65|crb[\s-]?65)\s*(?:score\s*)?(?:is|of|was|:)?\s*([0-5])/i', $text, $m)) {
        $parts[] = 'curb:' . $m[1];
    }

    sort($parts);

    return implode('|', array_unique($parts));
}

/**
 * Diagnosis / syndrome labels (substantive change if set differs).
 *
 * @return list<string>
 */
function sbaAiRepairDiagnosisLabelFingerprint(string $text): array
{
    $t = ' ' . mb_strtolower(trim($text)) . ' ';
    $labels = [];
    $rules = [
        'pulmonary_embolism' => '/\bpulmonary\s+embolism\b|\bpe\b/',
        'pneumonia' => '/\bpneumonia\b|\bcap\b/',
        'sepsis' => '/\bsepsis\b|\bseptic\s+shock\b/',
        'stemi' => '/\bstemi\b|\bst[\s-]elevation\s+myocardial\b/',
        'nstemi' => '/\bnstemi\b/',
        'asthma' => '/\basthma\b/',
        'anaphylaxis' => '/\banaphylaxis\b/',
        'copd' => '/\bcopd\b|\bchronic\s+obstructive\b/',
        'pulmonary_fibrosis' => '/\bpulmonary\s+fibrosis\b|\bipf\b|\bidiopathic\s+pulmonary\s+fibrosis\b/',
        'interstitial_lung_disease' => '/\binterstitial\s+lung\s+disease\b|\bild\b/',
        'proximal_dvt' => '/\bproximal\s+(?:deep\s+)?vein\s+thrombosis\b|\bproximal\s+dvt\b/',
        'dvt' => '/\b(?:deep\s+vein\s+thrombosis|dvt)\b/',
        'cellulitis' => '/\bcellulitis\b/',
        'baker_cyst' => '/\bbaker(?:\'s)?\s+cyst(?:\s+rupture)?\b/',
        'superficial_thrombophlebitis' => '/\bsuperficial\s+thrombophlebitis\b/',
        'musculoskeletal_calf' => '/\b(?:musculoskeletal|calf\s+strain|muscle\s+tear)\b/',
        'lymphadenopathy' => '/\blymphadenopathy\b/',
        'lymphoedema' => '/\b(?:lymphoedema|lymphedema)\b/',
        'osteomyelitis' => '/\bosteomyelitis\b/',
        'chronic_venous_insufficiency' => '/\bchronic\s+venous\s+insufficiency\b/',
        'peripheral_arterial_disease' => '/\b(?:peripheral\s+arterial\s+(?:occlusion|disease)|pad\b)/',
        'acute_limb_ischaemia' => '/\b(?:acute\s+limb\s+ischaemia|acute\s+limb\s+ischemia)\b/',
        'post_thrombotic_syndrome' => '/\bpost[\s-]thrombotic\s+syndrome\b/',
        'ruptured_aneurysm' => '/\b(?:popliteal\s+aneurysm|ruptured\s+aneurysm)\b/',
        'chronic_thrombus' => '/\bchronic\s+thrombus\b/',
    ];
    foreach ($rules as $key => $pattern) {
        if (preg_match($pattern, $t)) {
            $labels[$key] = true;
        }
    }
    $keys = array_keys($labels);
    sort($keys);

    return $keys;
}

/**
 * Ultrasound / imaging interpretation labels (DVT and similar investigation SBAs).
 *
 * @return list<string>
 */
function sbaAiRepairInvestigationInterpretationFingerprint(string $text): array
{
    $t = ' ' . mb_strtolower(trim($text)) . ' ';
    $rules = [
        'acute_dvt_on_us' => '/\b(?:acute\s+dvt|acute\s+deep\s+vein\s+thrombosis|non[\s-]compressible\s+vein)\b/',
        'no_dvt_on_us' => '/\b(?:no\s+dvt|dvt\s+excluded|compressible\s+vein|normal\s+compression)\b/',
        'normal_venous_ultrasound' => '/\bnormal\s+(?:venous\s+)?ultrasound\b|\bnormal\s+compression\s+ultrasound\b/',
        'post_thrombotic_syndrome_us' => '/\bpost[\s-]thrombotic\s+syndrome\b/',
        'chronic_thrombus_us' => '/\bchronic\s+(?:non[\s-])?compressible|chronic\s+thrombus\b/',
        'baker_cyst_us' => '/\bbaker(?:\'s)?\s+cyst\b/',
        'duplex_doppler' => '/\b(?:duplex|doppler)\s+ultrasound\b/',
        'venous_ultrasound' => '/\b(?:venous\s+ultrasound|leg\s+ultrasound|compression\s+ultrasound)\b/',
    ];
    $found = [];
    foreach ($rules as $key => $pattern) {
        if (preg_match($pattern, $t)) {
            $found[$key] = true;
        }
    }
    $keys = array_keys($found);
    sort($keys);

    return $keys;
}

/**
 * Named drugs / drug classes that define what an option is testing (distractor educational content).
 *
 * @return list<string>
 */
function sbaAiRepairMedicationFingerprint(string $text): array
{
    $t = ' ' . mb_strtolower(trim($text)) . ' ';
    $rules = [
        'salbutamol' => '/\bsalbutamol\b/',
        'ipratropium' => '/\bipratropium\b/',
        'tiotropium' => '/\btiotropium\b/',
        'salmeterol' => '/\bsalmeterol\b/',
        'formoterol' => '/\bformoterol\b/',
        'budesonide' => '/\bbudesonide\b/',
        'beclometasone' => '/\bbeclometasone\b/',
        'fluticasone' => '/\bfluticasone\b/',
        'prednisolone' => '/\bprednisolone\b/',
        'hydrocortisone' => '/\bhydrocortisone\b/',
        'theophylline' => '/\btheophylline\b/',
        'aminophylline' => '/\baminophylline\b/',
        'amoxicillin' => '/\bamoxicillin\b/',
        'doxycycline' => '/\bdoxycycline\b/',
        'azithromycin' => '/\bazithromycin\b/',
        'rivaroxaban' => '/\brivaroxaban\b/',
        'apixaban' => '/\bapixaban\b/',
        'warfarin' => '/\bwarfarin\b/',
        'heparin' => '/\bheparin\b/',
        'lmwh' => '/\blmwh\b|\benoxaparin\b|\bdalteparin\b/',
    ];
    $found = [];
    foreach ($rules as $key => $pattern) {
        if (preg_match($pattern, $t)) {
            $found[$key] = true;
        }
    }
    $keys = array_keys($found);
    sort($keys);

    return $keys;
}

/**
 * Monitoring / lab targets that define misconception-testing distractors (e.g. DOAC vs warfarin).
 *
 * @return list<string>
 */
function sbaAiRepairMonitoringTestFingerprint(string $text): array
{
    $t = ' ' . mb_strtolower(trim($text)) . ' ';
    $rules = [
        'inr' => '/\binr\b|\binternational\s+normalised\s+ratio\b/',
        'pt' => '/\b(?:prothrombin\s+time|pt)\b/',
        'aptt' => '/\b(?:aptt|activated\s+partial\s+thromboplastin)\b/',
        'anti_xa' => '/\banti[\s-]xa\b/',
        'renal_function' => '/\b(?:egfr|creatinine|renal\s+function)\b/',
        'full_blood_count' => '/\b(?:full\s+blood\s+count|fbc|complete\s+blood\s+count|cbc)\b/',
        'liver_function_tests' => '/\b(?:liver\s+function\s+tests?|lfts?)\b/',
        'd_dimer' => '/\bd[\s-]?dimer\b/',
        'urea_electrolytes' => '/\b(?:urea\s+and\s+electrolytes|u&e|renal\s+profile)\b/',
    ];
    $found = [];
    foreach ($rules as $key => $pattern) {
        if (preg_match($pattern, $t)) {
            $found[$key] = true;
        }
    }
    $keys = array_keys($found);
    sort($keys);

    return $keys;
}

/**
 * Management pathway / emergency action tokens (anticoagulation stage, escalation).
 *
 * @return list<string>
 */
function sbaAiRepairManagementPathwayFingerprint(string $text): array
{
    $t = ' ' . mb_strtolower(trim($text)) . ' ';
    $rules = [
        'interim_anticoagulation' => '/\b(?:interim|empirical)\s+(?:therapeutic\s+)?anticoagulation\b|\b(?:lmwh|enoxaparin)\s+pending\s+imaging\b/',
        'doac_pathway' => '/\b(?:apixaban|rivaroxaban|doac|direct[\s-]oral\s+anticoagulant)\b/',
        'warfarin_pathway' => '/\bwarfarin\b/',
        'thrombolysis_pathway' => '/\b(?:thrombolysis|thrombolyse)\b/',
        'ivc_filter' => '/\b(?:ivc|inferior\s+vena\s+cava)\s+filter\b/',
        'compression_stockings' => '/\bcompression\s+(?:stockings|bandaging)\b/',
    ];
    $found = [];
    foreach ($rules as $key => $pattern) {
        if (preg_match($pattern, $t)) {
            $found[$key] = true;
        }
    }
    $keys = array_keys($found);
    sort($keys);

    return $keys;
}

/**
 * @return list<string>
 */
function sbaAiRepairAggregatedEducationalFingerprint(string $text): array
{
    $parts = [];
    $groups = [
        sbaAiRepairDiagnosisLabelFingerprint($text),
        sbaAiRepairInvestigationInterpretationFingerprint($text),
        sbaAiRepairMedicationFingerprint($text),
        sbaAiRepairTherapeuticClassFingerprint($text),
        sbaAiRepairMonitoringTestFingerprint($text),
        sbaAiRepairOxygenDeliveryFingerprint($text),
        sbaAiRepairClinicalConceptFingerprint($text),
        sbaAiRepairManagementPathwayFingerprint($text),
    ];
    $prefixes = ['dx', 'inv', 'med', 'class', 'mon', 'oxy', 'action', 'path'];
    foreach ($groups as $i => $keys) {
        $prefix = $prefixes[$i] ?? 'x';
        foreach ($keys as $key) {
            $parts[] = $prefix . ':' . $key;
        }
    }
    $severity = sbaAiRepairSeverityCueSignature($text);
    if ($severity !== '') {
        $parts[] = 'severity:' . $severity;
    }
    sort($parts);

    return array_values(array_unique($parts));
}

/**
 * True when two option texts share enough wording to plausibly be a cosmetic rephrase only.
 */
function sbaAiRepairSignificantTokenOverlap(string $before, string $after): bool
{
    $stop = ['the', 'and', 'for', 'with', 'via', 'per', 'oral', 'start', 'give'];
    $tokenize = static function (string $text) use ($stop): array {
        $words = preg_split('/\W+/u', mb_strtolower($text)) ?: [];
        $out = [];
        foreach ($words as $w) {
            if (strlen($w) > 2 && !in_array($w, $stop, true)) {
                $out[$w] = true;
            }
        }

        return array_keys($out);
    };
    $wa = $tokenize($before);
    $wb = $tokenize($after);
    if ($wa === [] || $wb === []) {
        return false;
    }
    $intersect = count(array_intersect($wa, $wb));
    $union = count(array_unique(array_merge($wa, $wb)));

    return $union > 0 && ($intersect / $union) >= 0.45;
}

/**
 * Oxygen flow, delivery device, and target-saturation prescribing content.
 *
 * @return list<string>
 */
function sbaAiRepairOxygenDeliveryFingerprint(string $text): array
{
    $t = ' ' . mb_strtolower(trim($text)) . ' ';
    $found = [];
    if (preg_match_all('/\b(\d{1,2})\s*(?:l\/min|l\s*\/\s*min|litres?\s+per\s+minute|lpm)\b/', $t, $m)) {
        foreach ($m[1] as $flow) {
            $found['flow_' . (int) $flow . '_l_min'] = true;
        }
    }
    $rules = [
        'nasal_cannula' => '/\bnasal\s+cannulae?\b/',
        'simple_face_mask' => '/\bsimple\s+face\s+mask\b/',
        'non_rebreather' => '/\b(?:non[\s-]rebreather|nrb)\b/',
        'venturi_mask' => '/\bventuri\b/',
        'humidified_oxygen' => '/\bhumidified\s+oxygen\b/',
        'high_flow_oxygen' => '/\bhigh[\s-]flow\s+(?:nasal\s+)?oxygen\b|\bhfno\b/',
        'target_sat_copd_range' => '/\b(?:88[\s–-]92|90[\s–-]92|target\s+(?:spo2|oxygen\s+saturation)[^\d]{0,20}88)\b/',
        'target_sat_standard_range' => '/\b(?:94[\s–-]98|92[\s–-]96|target\s+(?:spo2|oxygen\s+saturation)[^\d]{0,20}94)\b/',
        'abg' => '/\b(?:abg|arterial\s+blood\s+gas)\b/',
        'niv' => '/\b(?:niv|non[\s-]invasive\s+ventilation|bipap|bilevel)\b/',
        'icu_escalation' => '/\b(?:icu|critical\s+care|intensive\s+care|anaesthetic\s+review|senior\s+review)\b/',
        'urgent_reassessment' => '/\b(?:urgent\s+reassessment|repeat\s+abg|serial\s+abg)\b/',
        'respiratory_failure' => '/\b(?:respiratory\s+failure|type\s+2\s+respiratory\s+failure|hypercapnic)\b/',
    ];
    foreach ($rules as $key => $pattern) {
        if (preg_match($pattern, $t)) {
            $found[$key] = true;
        }
    }
    $keys = array_keys($found);
    sort($keys);

    return $keys;
}

/**
 * Drug / device classes that define COPD and similar prescribing distractors.
 *
 * @return list<string>
 */
function sbaAiRepairTherapeuticClassFingerprint(string $text): array
{
    $t = ' ' . mb_strtolower(trim($text)) . ' ';
    $rules = [
        'inhaled_corticosteroid' => '/\b(?:budesonide|beclometasone|fluticasone|ciclesonide|inhaled\s+corticosteroid)\b/',
        'systemic_corticosteroid' => '/\b(?:oral\s+corticosteroid|systemic\s+corticosteroid|prednisolone|hydrocortisone|methylprednisolone)\b/',
        'saba' => '/\b(?:salbutamol|short[\s-]acting\s+beta[\s-]agonist)\b/',
        'sama' => '/\b(?:ipratropium|short[\s-]acting\s+muscarinic)\b/',
        'lama' => '/\b(?:tiotropium|glycopyrronium|umeclidinium|aclidinium|long[\s-]acting\s+muscarinic)\b/',
        'laba' => '/\b(?:salmeterol|formoterol|indacaterol|olodaterol|long[\s-]acting\s+beta[\s-]agonist)\b/',
        'methylxanthine' => '/\b(?:theophylline|aminophylline|methylxanthine)\b/',
    ];
    $found = [];
    foreach ($rules as $key => $pattern) {
        if (preg_match($pattern, $t)) {
            $found[$key] = true;
        }
    }
    $keys = array_keys($found);
    sort($keys);

    return $keys;
}

/**
 * @param list<string> $a
 * @param list<string> $b
 */
function sbaAiRepairFingerprintListsEqual(array $a, array $b): bool
{
    $sa = $a;
    $sb = $b;
    sort($sa);
    sort($sb);

    return $sa === $sb;
}

function sbaAiRepairKeyedOptionField(array $snapshot): string
{
    $letter = strtoupper(trim((string) ($snapshot['correct_answer_letter'] ?? '')));
    if ($letter >= 'A' && $letter <= 'E') {
        return 'option_' . strtolower($letter);
    }

    return '';
}

function sbaAiRepairFieldIsKeyedAnswer(string $field, array $snapshot): bool
{
    if ($field === 'correct_answer' || $field === 'correct_answer_letter') {
        return true;
    }
    $keyed = sbaAiRepairKeyedOptionField($snapshot);

    return $keyed !== '' && $field === $keyed;
}

/**
 * True when before/after alter clinical educational content (never style-only).
 */
function sbaAiRepairEducationalContentChanged(string $before, string $after): bool
{
    if (sbaAiRepairNormalizeComparableText($before) === sbaAiRepairNormalizeComparableText($after)) {
        return false;
    }

    if (!sbaAiRepairFingerprintListsEqual(
        sbaAiRepairAggregatedEducationalFingerprint($before),
        sbaAiRepairAggregatedEducationalFingerprint($after)
    )) {
        return true;
    }

    if (sbaAiRepairSeverityCueSignature($before) !== sbaAiRepairSeverityCueSignature($after)) {
        return true;
    }

    if (!sbaAiRepairSignificantTokenOverlap($before, $after)) {
        return true;
    }

    return false;
}

function sbaAiRepairNormalizeComparableText(string $text): string
{
    $t = mb_strtolower(trim($text));
    $t = preg_replace('/\s+/u', ' ', $t) ?? $t;

    return trim($t);
}

/**
 * True when before/after differ only by wording of the same clinical concepts (not substantive clinical change).
 */
function sbaAiRepairTextsAreStyleOnlyRephrase(string $before, string $after, string $field): bool
{
    $before = sbaAiRepairNormalizeComparableText($before);
    $after = sbaAiRepairNormalizeComparableText($after);
    if ($before === $after) {
        return true;
    }

    if ($field === 'title') {
        return true;
    }

    if (in_array($field, ['stem', 'lead_in'], true)) {
        $conceptsEqual = sbaAiRepairClinicalConceptSetsEqual(
            sbaAiRepairClinicalConceptFingerprint($before),
            sbaAiRepairClinicalConceptFingerprint($after)
        );
        $severityEqual = sbaAiRepairSeverityCueSignature($before) === sbaAiRepairSeverityCueSignature($after);
        $dxEqual = sbaAiRepairDiagnosisLabelFingerprint($before) === sbaAiRepairDiagnosisLabelFingerprint($after);

        return $conceptsEqual && $severityEqual && $dxEqual;
    }

    if (preg_match('/^option_[a-e]$/', $field) || $field === 'correct_answer' || $field === 'options') {
        return !sbaAiRepairEducationalContentChanged($before, $after);
    }

    if ($field === 'justification') {
        $conceptsEqual = sbaAiRepairClinicalConceptSetsEqual(
            sbaAiRepairClinicalConceptFingerprint($before),
            sbaAiRepairClinicalConceptFingerprint($after)
        );
        $dxEqual = sbaAiRepairDiagnosisLabelFingerprint($before) === sbaAiRepairDiagnosisLabelFingerprint($after);

        return $conceptsEqual && $dxEqual;
    }

    return false;
}

/**
 * @param list<array<string, mixed>> $changeLog
 * @param array<string, mixed> $repairedSnapshot
 */
function sbaAiRepairReclassifyChangeLogEntries(array &$changeLog, array $repairedSnapshot): void
{
    foreach ($changeLog as &$entry) {
        if (!is_array($entry)) {
            continue;
        }
        $field = (string) ($entry['field'] ?? 'general');
        $before = (string) ($entry['before'] ?? '');
        $after = (string) ($entry['after'] ?? '');
        if ($before === '' && $after === '') {
            continue;
        }
        $type = (string) ($entry['change_type'] ?? '');
        if (in_array($type, ['style', 'structure'], true) && sbaAiRepairEducationalContentChanged($before, $after)) {
            $entry['change_type'] = sbaAiRepairFieldIsKeyedAnswer($field, $repairedSnapshot)
                ? 'clinical'
                : 'educational_content';
            if (($entry['repair_risk'] ?? 'low') === 'low') {
                $entry['repair_risk'] = sbaAiRepairFieldIsKeyedAnswer($field, $repairedSnapshot) ? 'high' : 'medium';
            } elseif (sbaAiRepairFieldIsKeyedAnswer($field, $repairedSnapshot) && ($entry['repair_risk'] ?? '') !== 'high') {
                $entry['repair_risk'] = 'high';
            }
            continue;
        }
        if (sbaAiRepairTextsAreStyleOnlyRephrase($before, $after, $field)) {
            $entry['change_type'] = in_array($field, ['stem', 'lead_in'], true) ? 'structure' : 'style';
            if (($entry['repair_risk'] ?? '') === 'high') {
                $entry['repair_risk'] = 'low';
            }
        }
    }
    unset($entry);
}

/**
 * @param array<string, mixed> $originalSnapshot
 * @param array<string, mixed> $repairedSnapshot
 * @param list<array<string, mixed>> $changeLog
 * @return array{
 *   repair_risk_level: string,
 *   any_clinical_content_changed: bool,
 *   educational_content_changed: bool,
 *   change_log: list<array<string, mixed>>
 * }
 */
function sbaAiRepairInferRiskFromDiff(
    array $originalSnapshot,
    array $repairedSnapshot,
    array $changeLog
): array
{
    sbaAiRepairReclassifyChangeLogEntries($changeLog, $repairedSnapshot);

    $anyClinical = false;
    $educationalOnly = false;
    $overall = 'low';
    $keyedField = sbaAiRepairKeyedOptionField($repairedSnapshot);
    if ($keyedField === '') {
        $keyedField = sbaAiRepairKeyedOptionField($originalSnapshot);
    }

    if (($originalSnapshot['correct_answer_letter'] ?? '') !== ($repairedSnapshot['correct_answer_letter'] ?? '')) {
        return [
            'repair_risk_level' => 'high',
            'any_clinical_content_changed' => true,
            'educational_content_changed' => false,
            'change_log' => $changeLog,
        ];
    }

    $hasEducationalLog = false;
    foreach ($changeLog as $entry) {
        if (is_array($entry) && ($entry['change_type'] ?? '') === 'educational_content') {
            $hasEducationalLog = true;
            break;
        }
    }

    $clinicalFields = [
        'stem', 'lead_in', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e',
        'correct_answer', 'justification',
    ];

    foreach ($clinicalFields as $field) {
        $before = (string) ($originalSnapshot[$field] ?? '');
        $after = (string) ($repairedSnapshot[$field] ?? '');
        if ($before === $after) {
            continue;
        }
        if (sbaAiRepairTextsAreStyleOnlyRephrase($before, $after, $field)) {
            continue;
        }
        $anyClinical = true;
        $isKeyed = $field === 'correct_answer' || $field === $keyedField;
        if ($isKeyed) {
            $overall = 'high';
        } elseif (sbaAiRepairEducationalContentChanged($before, $after)) {
            $educationalOnly = true;
            if ($overall === 'low') {
                $overall = 'medium';
            }
        } elseif ($overall === 'low') {
            $overall = 'medium';
        }
    }

    foreach ($changeLog as $entry) {
        $type = (string) ($entry['change_type'] ?? '');
        if (!in_array($type, ['clinical', 'safety', 'educational_content'], true)) {
            continue;
        }
        $anyClinical = true;
        if ($type === 'educational_content') {
            $educationalOnly = true;
        }
        if (($entry['repair_risk'] ?? '') === 'high') {
            $overall = 'high';
        } elseif (($entry['repair_risk'] ?? '') === 'medium' && $overall !== 'high') {
            $overall = 'medium';
        }
    }

    if (!$anyClinical) {
        $overall = 'low';
        $educationalOnly = false;
    }

    $educationalChanged = $hasEducationalLog
        || ($anyClinical && $educationalOnly && $overall === 'medium');

    return [
        'repair_risk_level' => $overall,
        'any_clinical_content_changed' => $anyClinical,
        'educational_content_changed' => $educationalChanged,
        'change_log' => $changeLog,
    ];
}

/**
 * @param array<string, mixed> $raw
 * @param array<string, mixed> $originalQuestion
 * @return array{ok: bool, data: ?array<string, mixed>, error: string}
 */
function normalizeSbaAiRepairResponse(array $raw, array $originalQuestion): array
{
    if (!empty($raw['repair_blocked'])) {
        $reasons = [];
        if (is_array($raw['repair_blocked_reasons'] ?? null)) {
            foreach ($raw['repair_blocked_reasons'] as $r) {
                $t = trim((string) $r);
                if ($t !== '') {
                    $reasons[] = $t;
                }
            }
        }
        if ($reasons === []) {
            $reasons = ['Clinical safety concern — automatic repair not applied.'];
        }

        return ['ok' => true, 'data' => sbaAiRepairBuildBlockedRepairData($originalQuestion, $reasons), 'error' => ''];
    }

    if (!isset($raw['repaired_question']) || !is_array($raw['repaired_question'])) {
        return ['ok' => false, 'data' => null, 'error' => 'Repair JSON missing repaired_question.'];
    }

    $rawRepaired = is_array($raw['repaired_question'] ?? null) ? $raw['repaired_question'] : [];
    $rawAnswerLetter = strtoupper(trim((string) ($rawRepaired['correct_answer_letter'] ?? '')));
    $rawAnswerText = trim((string) ($rawRepaired['correct_answer'] ?? ''));

    $normRepaired = sbaAiRepairNormalizeRepairedQuestion($rawRepaired);
    if (!$normRepaired['ok'] || !is_array($normRepaired['data'])) {
        $err = $normRepaired['error'] ?? '';
        $incompleteMessage =
            'AI suggestions could not be applied because the suggested correct answer was incomplete. Original draft unchanged.';
        $incompleteRepairErrors = [
            'Repaired question has invalid correct_answer.',
            'Repaired question correct_answer does not match correct_answer_letter option.',
        ];
        $incompleteAnswerFailure = false;
        foreach ($incompleteRepairErrors as $needle) {
            if ($err === $needle) {
                $incompleteAnswerFailure = true;
                break;
            }
        }
        if (
            !$incompleteAnswerFailure
            && $err === 'Repaired question has invalid correct_answer_letter.'
            && ($rawAnswerLetter === '' || formGeneratorIsPlaceholderAnswerText($rawAnswerText))
        ) {
            $incompleteAnswerFailure = true;
        }
        if ($incompleteAnswerFailure) {
            return [
                'ok' => true,
                'data' => sbaAiRepairBuildBlockedRepairData($originalQuestion, [$incompleteMessage]),
                'error' => '',
            ];
        }

        return ['ok' => false, 'data' => null, 'error' => $err];
    }

    $contentCheck = formGeneratorValidateQuestionContentSnapshot($normRepaired['data']);
    if (!$contentCheck['ok']) {
        return [
            'ok' => true,
            'data' => sbaAiRepairBuildBlockedRepairData($originalQuestion, [
                'AI suggestions could not be applied because the suggested correct answer was incomplete. Original draft unchanged.',
            ]),
            'error' => '',
        ];
    }
    $normRepaired['data']['correct_answer'] = $contentCheck['correct_answer'];
    $normRepaired['data']['correct_answer_letter'] = $contentCheck['letter'];

    $originalSnapshot = formGeneratorQuestionContentSnapshot($originalQuestion);
    $changeLog = sbaAiRepairNormalizeChangeLog($raw['change_log'] ?? []);
    $inferred = sbaAiRepairInferRiskFromDiff($originalSnapshot, $normRepaired['data'], $changeLog);
    $changeLog = $inferred['change_log'];
    sbaAiRepairEnrichChangeLogPermissions($changeLog, $normRepaired['data']);

    $repairMetadata = [
        'condition_name' => (string) ($originalQuestion['condition_name'] ?? ''),
        'presentation' => (string) ($originalQuestion['presentation'] ?? ''),
        'title' => (string) ($originalQuestion['title'] ?? ''),
    ];
    $postBlock = sbaAiRepairDetectBlockPatternsOnQuestion($normRepaired['data'], $repairMetadata);
    if ($postBlock !== []) {
        return ['ok' => true, 'data' => sbaAiRepairBuildBlockedRepairData($originalQuestion, $postBlock), 'error' => ''];
    }

    $riskLevels = ['low', 'medium', 'high'];
    $repairRisk = strtolower(trim((string) ($raw['repair_risk_level'] ?? '')));
    if (!in_array($repairRisk, $riskLevels, true)) {
        $repairRisk = $inferred['repair_risk_level'];
    }
    if ($inferred['any_clinical_content_changed']) {
        if ($inferred['repair_risk_level'] === 'high') {
            $repairRisk = 'high';
        } elseif ($inferred['repair_risk_level'] === 'medium' && $repairRisk === 'low') {
            $repairRisk = 'medium';
        }
    } else {
        $repairRisk = 'low';
    }

    $anyClinical = $inferred['any_clinical_content_changed'];
    $educationalChanged = $inferred['educational_content_changed'];
    $summary = trim((string) ($raw['repair_summary'] ?? ''));
    if ($summary === '' && $changeLog !== []) {
        $summary = count($changeLog) . ' field(s) updated in repaired preview.';
    }
    if ($summary === '') {
        return ['ok' => false, 'data' => null, 'error' => 'Repair JSON missing repair_summary.'];
    }

    $tutorReviewRequired = false;
    foreach ($changeLog as $entry) {
        if (!empty($entry['tutor_review_required'])) {
            $tutorReviewRequired = true;
            break;
        }
    }
    if ($repairRisk !== 'low') {
        $tutorReviewRequired = true;
    }

    $changeLog = sbaAiRepairSanitizeChangeLogEntries($changeLog, $normRepaired['data']);

    return [
        'ok' => true,
        'data' => [
            'repair_blocked' => false,
            'repaired_question' => $normRepaired['data'],
            'change_log' => $changeLog,
            'repair_risk_level' => $repairRisk,
            'any_clinical_content_changed' => (bool) $anyClinical,
            'educational_content_changed' => (bool) $educationalChanged,
            'tutor_review_required' => $tutorReviewRequired,
            'repair_summary' => $summary,
            'locked_context' => formGeneratorLockedQuestionContext($originalQuestion),
            'original_snapshot' => $originalSnapshot,
        ],
        'error' => '',
    ];
}
