<?php

/**
 * Combined optional tutor review for main SBA generator (review + field-level suggestions).
 * Advisory only — never auto-approve or auto-apply.
 */

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

/**
 * @param array<string, mixed> $question
 * @param list<array<string, mixed>> $styleWarnings
 * @param list<array<string, mixed>> $batchSiblings
 */
function buildSbaCombinedTutorReviewerPrompt(array $question, array $styleWarnings, array $batchSiblings = []): string
{
    $specialty = trim((string) ($question['specialty'] ?? ''));
    $condition = trim((string) ($question['condition_name'] ?? ''));
    $presentation = trim((string) ($question['presentation'] ?? ''));
    $yearLevel = trim((string) ($question['year_level'] ?? ''));
    $summative = trim((string) ($question['summative_formative'] ?? ''));
    $skill = trim((string) ($question['skill'] ?? ''));
    $tutorComments = trim((string) ($question['tutor_comments'] ?? ''));
    $title = trim((string) ($question['title'] ?? ''));
    $stem = trim((string) ($question['stem'] ?? ''));
    $leadIn = trim((string) ($question['lead_in'] ?? ''));
    $letter = trim((string) ($question['correct_answer_letter'] ?? ''));
    $correctAnswer = trim((string) ($question['correct_answer'] ?? ''));
    $justification = trim((string) ($question['justification'] ?? ''));

    $styleReference = buildSbaAiReviewerMlaStyleReferenceBlock($skill, $condition, $styleWarnings, $presentation);

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

    $batchLines = [];
    foreach ($batchSiblings as $i => $sib) {
        if (!is_array($sib)) {
            continue;
        }
        $batchLines[] = '- Item ' . ($i + 1) . ' (' . trim((string) ($sib['skill'] ?? 'skill unknown')) . '): keyed “'
            . trim((string) ($sib['correct_answer'] ?? '')) . '”; lead-in: “'
            . trim((string) ($sib['lead_in'] ?? '')) . '”';
    }
    $batchBlock = $batchLines !== []
        ? implode("\n", $batchLines)
        : '(No other items in this generation batch were supplied.)';

    $options = [
        'A' => trim((string) ($question['option_a'] ?? '')),
        'B' => trim((string) ($question['option_b'] ?? '')),
        'C' => trim((string) ($question['option_c'] ?? '')),
        'D' => trim((string) ($question['option_d'] ?? '')),
        'E' => trim((string) ($question['option_e'] ?? '')),
    ];
    $optionsBlock = '';
    foreach ($options as $optLetter => $text) {
        $optionsBlock .= $optLetter . '. ' . ($text !== '' ? $text : '(empty)') . "\n";
    }

    $tutorBlock = $tutorComments !== '' ? $tutorComments : '(none provided)';

    $msAktRules = implode("\n", [
        '**Stem:** present tense; third person; start with age and gender (e.g. “A 22 year old woman”); no patient names; presenting symptoms in the first sentence; aim 30–100 words; avoid extraneous detail; simple words (e.g. “start” not “commence”); do not describe poor clinical practice as if acceptable.',
        '**Age:** do not hyphenate (“A 65 year old man”, not “65-year-old”).',
        '**Presenting complaint:** prefer “has” not “presents with” / “complains of”.',
        '**Current conditions:** avoid “known”, “known to have”, “diagnosed with”, “with a history of” for current problems; use “has type 2 diabetes mellitus”; use “has no other medical conditions” not “no significant past medical history”.',
        '**Examination:** avoid “On examination”; observation order: temperature, pulse, BP, respiratory rate, oxygen saturation; “pulse” not “pulse rate”; prefer observation wording such as “pulse 98 bpm, BP 145/90 mmHg, respiratory rate 20 breaths per minute and oxygen saturation is 95% breathing air”. **Acceptable:** “oxygen saturation is 98% breathing air” — **do not** flag this as a style problem. **Flag/correct:** “on air”, “on room air”, or “on breathing air” (prefer “…% breathing air” without “on”). **Do not** suggest clipped possessive shorthand such as “Her respiratory rate 18 breaths per minute”, “His pulse 70 bpm”, or “She has wheeze, respiratory rate 18 breaths per minute” — use complete grammar (**is** / **a respiratory rate of**) or a new sentence for vitals.',
        '**Lead-in:** one clear question; “What?” default; “Which?” only when justified; no negative phrasing; no vague “Which of the following is correct?”; avoid unnecessary “single most”; both cover tests (task inferable from stem; cannot answer without stem).',
        '**Options:** exactly five; one key + four plausible distractors; short homogeneous options; sentence case; alphabetical/numerical/logical order; if two answers equally good, flag — question should have one best answer. **No duplicate or near-duplicate options** — e.g. do **not** include both “Lewy body dementia” and “Dementia with Lewy bodies”; do not pair a broad label with a synonym as separate distractors; when replacing an option, keep it **distinct** from all other A–E lines.',
        '**Justification:** why key is best on stem cues; why major distractors fail; no vague unsupported claims; do not overclaim guidelines without a named source. **Every distractor discussed must still exist in the final A–E options** — if you change any option text, **rewrite the justification** so it does not still explain a removed or replaced distractor.',
        '**Investigations:** values with reference ranges where useful; order: labs, ECG, radiology; blood gases: PO2, PCO2, bicarbonate; “breathing air”; do not call results normal if outside stated range.',
        '**Medicines:** generic BNF names; “takes” not “is on”; “is treated with” not “receives” / “is started on”; “micrograms” not mcg/µg; “adrenaline/epinephrine”; doses only when necessary.',
        '**Spelling:** British (haemorrhage, haematemesis, melaena, anaemia, oesophageal, ischaemia, oedema, tumour); lower case emergency department, intensive care unit; “specialty” not “speciality”.',
        '**Imaging:** X-ray; CT scan of…; MR scan; procedure terms in option lists where appropriate.',
        '**Eponyms:** no possessive apostrophe; en dash in pairs (Mallory–Weiss, Cheyne–Stokes, Epstein–Barr).',
    ]);

    $mistakeBank = implode("\n", [
        '**Calibration:** These patterns check ambiguity, stem–key support, safety and one-best-answer fairness. Do **not** mark every item in these contexts **unsafe** or **do_not_approve_yet** by default. A clinically correct key with weak distractors → **needs_edits** / lower scores, not automatic unsafe.',
        '',
        '### Quick reference (existing)',
        '1. **overspecific_diagnosis_unsupported_by_stem** — e.g. gastric ulcer keyed when only peptic ulcer bleed is supported.',
        '2. **asthma_step_mismatch** — see detailed check below (reliever-only stem, missing current controller, ICS/LABA/MART step-up).',
        '3. **asthma_acute_prescribing_management_mismatch** — hypoxic acute asthma keyed salbutamol only without oxygen/nebulised/steroids when emergency bundle needed.',
        '4. **unstable_acs_antiplatelet_only_key** — hypotensive/STEMI context keyed aspirin/clopidogrel only.',
        '5. **emergency_bundle_completeness** — see detailed check below (unsafe vs incomplete-but-safe).',
        '6. **Investigation vs assessment tool mismatch** — investigation lead-in but Glasgow–Blatchford keyed; also D-dimer vs CTPA etc. without stem probability/urgency framing.',
        '7. **ABG compensation error** — raised bicarbonate called normal or “no metabolic compensation”; COPD: prefer type 2 respiratory failure / acute-on-chronic wording over simplistic “acute respiratory acidosis with metabolic compensation”.',
        '8. **Stroke facial weakness conflict** — cannot raise eyebrow (LMN) vs intended cortical stroke unless intentional.',
        '9. **Option homogeneity failure** — mixed diagnosis/investigation/management families.',
        '10. **batch_repetition_same_treatment_family** — flag if batch context shows duplicate keyed concepts across skills.',
        '11. **Justification–options mismatch** — after any suggested option change, final justification must not reference removed options (see also mandatory section below).',
        '12. **duplicate_or_near_duplicate_options** — final A–E must not contain duplicate/near-duplicate clinical concepts (e.g. Lewy body dementia + Dementia with Lewy bodies; peptic ulcer disease + gastric ulcer unless stem supports subtype only; stroke + ischaemic stroke unless subtype is tested).',
        '',
        '### Active clinical checks (from mistake bank — apply when relevant)',
        '',
        '**wrong_emergency_pathway_imported** — Do not treat non-infective emergencies as sepsis unless infection cues are in the stem. Do **not** defend broad-spectrum IV antibiotics or sepsis-style bundles for ACS, aortic dissection, pulmonary embolism, hypoglycaemia or hyperkalaemia when the stem lacks fever, infection source, sepsis physiology or similar. Flag wrong pathway in **concerns.clinical** or **concerns.safety**.',
        '',
        '**justification_invents_unsupported_diagnosis** — The justification must **not** add diagnoses, risk factors, clinical features, ECG findings, imaging findings or blood results that are **not stated or strongly implied** by the stem. If unsupported facts defend the key, flag and suggest a corrected **justification** (or **concerns.clinical**).',
        '',
        '**copd_uncontrolled_oxygen_key** / **oxygen_prescription_missing_target_range** — For COPD or hypercapnia-risk contexts, avoid uncontrolled high-flow oxygen (e.g. non-rebreather as best answer) unless the stem clearly describes immediate life-threatening hypoxia where delay is unacceptable. Prefer **controlled oxygen** with a **target saturation range** where appropriate (commonly **88–92%** in COPD). If oxygen is keyed/prescribed without a clinically important target range, flag in **concerns.clinical** or **concerns.one_best_answer**.',
        '',
        '**condition_stage_ambiguity** — Check the stem establishes the stage the key needs: stable vs acute exacerbation; suspected vs confirmed; initial presentation vs follow-up; treatment-naive vs already treated. If the key depends on an unstated stage, flag **concerns.one_best_answer** or **concerns.skill_alignment**.',
        '',
        '**result_key_contradiction** — Key and justification must match stated results: do **not** call a value **normal** if outside the stated reference range; do **not** infer a diagnosis the provided results do not support. Flag in **concerns.clinical** or **concerns.one_best_answer**.',
        '',
        '**specialist_monitoring_test_keyed_routinely** — For Monitoring, do **not** key specialist/exceptional tests (anti-Xa levels, drug levels, advanced imaging) as **routine** follow-up unless the stem explains why (renal impairment, pregnancy, bleeding, extremes of weight, etc.). Routine review, symptoms, bleeding, renal function and FBC are often more appropriate. Flag ambiguity or unfairness, not automatic unsafe.',
        '',
        '**anticoagulation_pathway_ambiguity** — For VTE, AF, valves or bleeding contexts, check the stem supports the intended decision: suspected vs confirmed VTE; initial treatment vs follow-up; renal impairment; bleeding risk; valve status; pregnancy/postpartum where relevant. If the key needs pathway details missing from the stem, flag **concerns.one_best_answer** (short note only — do **not** paste a full pathway table).',
        '',
        '**acute_followup_context_mismatch** — Lead-in must match timing: do **not** ask routine **follow-up monitoring** if the stem describes an **acute unstable** presentation needing immediate assessment/treatment, unless the stem says the acute phase has passed. Flag **concerns.skill_alignment**.',
        '',
        '**emergency_bundle_completeness** (`acute_management_missing_essential_component`, `acute_management_bundle_incompleteness_watchlist`, `safe_but_incomplete_emergency_key`) — For **Emergency Management**, distinguish:',
        '- **Unsafe key:** omits an essential immediate action and could harm the patient.',
        '- **Incomplete but safe key:** useful action but insufficient for severity/lead-in (flag fairness, not always unsafe).',
        'Examples to check: **oxygen** in hypoxia; **IV calcium** before potassium-shifting treatment in ECG hyperkalaemia; appropriate **parenteral glucose/glucagon** route in severe hypoglycaemia; **resuscitation/escalation** in shock; urgent imaging/escalation when required.',
        '',
        '**asthma_step_mismatch** (`asthma_step_mismatch_relief_only_vs_ics_laba`, `asthma_step_mismatch_missing_current_controller`) — If the key recommends **ICS/LABA**, **MART** or **adding a LABA**, the stem must clearly state **current controller therapy**. For step-up items, the stem should state **adherence and inhaler technique** were checked or why escalation is appropriate. If the stem only mentions **reliever** use, do **not** assume regular low-dose ICS.',
        '',
        '**Investigation appropriateness (PE and similar)** — For investigation choice (e.g. **D-dimer vs CTPA** in PE), check the stem/lead-in supports **pre-test probability**, urgency and whether the item tests rule-out vs definitive diagnosis. If the best investigation depends on unstated probability or staging, flag **concerns.skill_alignment** or **concerns.one_best_answer**.',
    ]);

    return <<<PROMPT
You are an expert UK medical school tutor reviewing a draft **MS AKT / MLA Single Best Answer** item for **tutor-facing advisory feedback only**.

You must **not** approve the question, **not** claim it is validated, and **not** auto-apply edits. The tutor decides what to accept.

Deterministic formatting may already have been applied to the draft. Judge the **substantive** quality.

---

## Draft and context

| Element | Content |
|--------|---------|
| Title | {$title} |
| Stem | {$stem} |
| Lead-in | {$leadIn} |
| Options | (below) |
| Correct answer | {$letter}. {$correctAnswer} |
| Justification | {$justification} |
| Specialty | {$specialty} |
| Core condition | {$condition} |
| Presentation | {$presentation} |
| Year level | {$yearLevel} |
| Assessment type | {$summative} |
| Selected skill | {$skill} |
| Tutor comments | {$tutorBlock} |

**Options (A–E)**
{$optionsBlock}

**Other items in this generation batch (avoid duplicate keyed concepts)**
{$batchBlock}

---

## MS AKT Style Guide (April 2025) — apply all

{$msAktRules}

---

## Clinical and SBA quality — mandatory checks

1. **Stem–key support** — enough evidence for the keyed answer?
2. **Over-specific diagnosis** — is the key more specific than the stem allows?
3. **Skill alignment** — Diagnosis / Investigation / Interpretation / Management / Prescribing / Emergency Management / Monitoring each require matching option families and lead-in.
4. **One-best-answer fairness** — two defensible keys? overlapping options? length cueing? throwaway distractors? **Duplicate/near-duplicate options** (same diagnosis under different wording) must not appear together in the final A–E set after your suggestions.
5. **Emergency sequencing / bundle completeness** — immediate priority correct; distinguish unsafe vs incomplete-but-safe keys (e.g. IV calcium before K-shift in ECG hyperkalaemia; oxygen in hypoxia; reperfusion in unstable STEMI; appropriate parenteral treatment in severe hypoglycaemia; resuscitation in shock)?
6. **Prescribing safety** — route, dose, allergies, contraindications, scenario support.
7. **Interpretation consistency** — numbers, ECG, imaging support the key; no “normal” if outside range.
8. **Tutor comments** — explicit tutor directions followed or violated?
9. **Justification–options consistency** — if **any** option (A–E) or **correct_answer** text changes, the **justification must be checked and updated** to match the **final** option set.

---

## Justification–options consistency (mandatory when any option changes)

When you suggest changing **any** of **option_a** … **option_e** and/or **correct_answer**:

1. Build the **final** five options **after** your suggested changes.
2. Re-read the **justification** against that **final** set only.
3. **Remove** explanations for distractors that are **no longer** in the options.
4. **Update** explanations when a distractor **text changed** (e.g. if option E is now “Lewy body dementia”, do **not** still explain why “transient global amnesia” is wrong).
5. **Do not** name diagnoses, drugs, or investigations as distractors unless they appear in the **final** A–E list (brief stem-linked background is fine; do not discuss absent option labels).
6. **Do not** introduce explanations for options that are **not present** in the final set.

**Whenever you suggest changing any option** (even one letter option), you **must** re-check the **justification** against the **final** A–E set. The suggested justification must **not** refer to removed options and should briefly address any **new** important distractor if relevant. If you cannot update the justification safely, set **`requires_tutor_review`: true** on **every** option edit and explain in **concerns**.

**If any option edit is needed**, you **must** either:
- include a matching **`"field": "justification"`** entry in **suggested_edits** with a full revised justification aligned to the final options, **or**
- if you cannot rewrite it confidently, set **`requires_tutor_review`: true** on **every** option edit and add a **concerns.one_best_answer** line naming **stale distractor references** still in the justification.

**Self-check before returning JSON:**
- Every distractor **named or clearly described** in the justification still exists in the final options (or is essential non-option background).
- Every **changed** distractor has updated justification if it was previously discussed.
- No “Option X is wrong because …” reasoning for text that is **not** the final option X text.

---

## Lead-in quality (mandatory — suggest **lead_in** edits when weak)

Check the **lead-in** against **Selected skill** and the option set:

- **Skill match** — Diagnosis / Investigation / Interpretation / Management / Prescribing / Emergency Management / Monitoring each needs a matching task (do not ask for “investigation” when options are diagnoses, medications, interpretation lines, or risk scores).
- **“What?” vs “Which?”** — prefer **“What …?”**; flag **“Which …?”** unless the stem limits the answer set or options are clearly inferable from the stem alone.
- **Cover tests** — (1) stem should let a candidate infer **what type of answer** is required before seeing options; (2) candidate should **not** answer correctly without the stem.
- **Clarity** — specific, not overly broad (“Which of the following is correct?”), not too leading, not asking one task while options test another.
- **Wording** — avoid negative phrasing (“least likely”); avoid unnecessary **“single most”**; flag repeated stock lead-ins across the batch when batch context shows duplication.

**Stem observation wording (mandatory when editing stem)**
- **suggested** must be a **complete, grammatical replacement for the whole stem field** — never a fragment and never clipped shorthand.
- Do **not** suggest **“a respiratory rate 18 breaths per minute”** — use **“a respiratory rate of 18 breaths per minute”** (noun phrase) or **“Her respiratory rate is 18 breaths per minute”** (sentence).
- Do **not** suggest **“oxygen saturation 98%”** as a bare noun phrase — use **“oxygen saturation of 98%”** or **“oxygen saturation is 98%”**.
- Do **not** mix sentence-style observations and list-style shorthand in the same clause (e.g. **“a respiratory rate 18 … and oxygen saturation is 98%”**).
- Do **not** change grammatical observation wording into clipped shorthand (**“His pulse 70 bpm”**, **“Her BP 150/85 mmHg”**, **“She has wheeze, respiratory rate 18 …”**).
- If the current stem already has **acceptable grammatical** observation wording, **leave it alone** unless there is a **clear clinical or educational improvement** — do **not** rewrite observations for minor style preference.
- For **good items**, prefer **fewer, higher-value** suggested edits; omit low-value cosmetic stem rewrites.

**Bad stem suggestion examples (never output these):**
- **“Her temperature is 36.8°C, pulse 88 bpm, BP 118/72 mmHg, a respiratory rate 22 breaths per minute and oxygen saturation is 96% breathing air.”**
- **“She has bilateral wheeze, a respiratory rate 22 breaths per minute and oxygen saturation is 96% breathing air.”**
- **“Respiratory rate 22 breaths per minute and oxygen saturation is 96% breathing air.”**
- **“His pulse 70 bpm, BP 120/80 mmHg.”**
- **“Her pulse 110 bpm, BP 85/50 mmHg, respiratory rate 22 breaths per minute.”**
- **“Pulse 92 bpm, blood pressure is 110/70 mmHg, a respiratory rate of 20 breaths per minute.”**

**Correct answer suggestions (mandatory)**
- **suggested** must be a **complete replacement**: either a single letter **A–E** that maps to a non-empty option, or the **full exact text** of one existing option.
- **Never** suggest **“Correct answer:”**, a dash, blank text, or any placeholder.
- If unsure which option should be keyed, describe the concern in **concerns** only — **omit** **correct_answer** from **suggested_edits**.

**Good stem suggestion examples:**
- **“Her temperature is 36.8°C, pulse is 88 bpm, blood pressure is 118/72 mmHg, respiratory rate is 22 breaths per minute and oxygen saturation is 96% breathing air.”**
- **“Her pulse is 110 bpm, blood pressure is 85/50 mmHg and respiratory rate is 22 breaths per minute.”**
- **“Her observations are: temperature 36.8°C, pulse 88 bpm, BP 118/72 mmHg, respiratory rate 22 breaths per minute and oxygen saturation 96% breathing air.”**
- **“She has bilateral wheeze on auscultation. Her respiratory rate is 22 breaths per minute and oxygen saturation is 96% breathing air.”**
- **“She has bilateral wheeze, a respiratory rate of 22 breaths per minute and oxygen saturation of 96% breathing air.”**
- **“His pulse is 70 bpm and blood pressure is 120/80 mmHg.”**

**If the lead-in is weak or mismatched**, you **must** include a **suggested_edits** entry with `"field": "lead_in"`, the current lead-in, a improved **suggested** line, and a short **reason**.

**Preferred replacements by skill** (use when the draft lead-in is wrong):

| Skill | Suggested lead-in pattern |
|-------|---------------------------|
| Diagnosis | What is the most likely diagnosis? |
| Investigation | What is the most appropriate initial investigation? — or urgent/next wording if the stem requires it |
| Interpretation | What is the best interpretation of these findings? / … these results? |
| Management | What is the most appropriate next step in management? |
| Prescribing | What is the most appropriate medication to start/add? / treatment to prescribe? |
| Emergency Management | What treatment should be given first? / What is the most appropriate immediate treatment? |
| Monitoring | What should be monitored to assess response to treatment? / What is the most appropriate parameter to monitor? |

**Examples requiring a lead_in edit**
- Key is a **risk score** (e.g. Glasgow–Blatchford) but lead-in asks for an **investigation** → suggest: “What assessment tool is used to estimate the need for intervention?”
- Options are **medications** but lead-in asks for **diagnosis** → suggest prescribing/treatment wording.
- Options are **diagnostic labels** but lead-in asks for **management** → suggest diagnosis wording.
- Options are **interpretation statements** but lead-in asks for **next investigation** → suggest interpretation wording.
- **Unstable** or **time-critical** stem with a bland lead-in → suggest urgent/emergency priority wording.

Put lead-in problems in **concerns.skill_alignment** and/or **concerns.style** as well as **suggested_edits**.

---

## Known mistake-bank patterns — actively look for

{$mistakeBank}

---

## Condition-specific reference (secondary)

{$styleReference}

---

## Deterministic style warnings (clues only — verify against draft text)

```json
{$warningsJson}
```

Do **not** treat warnings as automatically correct. Note disagreements in concerns only if relevant to tutors.

---

## Your task

Assess: MS AKT style, SBA construction, clinical correctness, one-best-answer fairness, skill alignment, tutor comments, clinical safety, mistake-bank patterns.

Return **valid JSON only** — no markdown fences, no prose before or after.

Required shape:

{
  "ok": true,
  "overall_judgement": "ready_for_tutor_review | needs_edits | do_not_approve_yet",
  "summary": "Short tutor-facing summary.",
  "scores": {
    "clinical_correctness": 0,
    "one_best_answer": 0,
    "ms_akt_style": 0,
    "skill_alignment": 0,
    "overall_tutor_readiness": 0
  },
  "status": {
    "clinical_safety": "no_major_concerns | needs_review | unsafe",
    "suggestions_available": true,
    "content_changes_require_tutor_review": true
  },
  "concerns": {
    "clinical": [],
    "one_best_answer": [],
    "style": [],
    "skill_alignment": [],
    "tutor_comments": [],
    "safety": []
  },
  "suggested_edits": [
    {
      "field": "title | stem | lead_in | option_a | option_b | option_c | option_d | option_e | correct_answer | justification",
      "current": "Existing text",
      "suggested": "Suggested replacement text",
      "reason": "Short explanation",
      "change_type": "style | structure | educational_content | clinical_safety",
      "risk": "low | medium | high",
      "requires_tutor_review": true
    }
  ],
  "do_not_auto_apply": true
}

Rules:
- Scores are integers 0–100 (higher = better).
- **suggested_edits**: only fields that need a real change; omit unchanged fields; **suggested** must differ from **current**.
- When the **lead-in** fails skill alignment or cover tests, include **`"field": "lead_in"`** with a concrete replacement line (do not only describe the problem in **concerns**).
- For **correct_answer**, use the **full keyed option text** or a **single letter A–E** if only the letter should change — **never** leave **correct_answer** blank, as a label only (**“Correct answer:”**), or as a placeholder.
- If you recommend a **key change** but cannot supply a **complete** letter **and** matching option text, give **advisory comments only** — do **not** include a **correct_answer** entry in **suggested_edits**.
- Serious clinical safety → **overall_judgement** = **do_not_approve_yet** and **status.clinical_safety** = **unsafe**.
- Educational/clinical meaning changes → **risk** medium or high, **requires_tutor_review** true.
- If **any option** is in **suggested_edits**, you **must** include a revised **justification** in **suggested_edits** unless the current justification already matches the final options with no stale distractor references.
- **do_not_auto_apply** must always be true.
- **Oxygen saturation wording:** **“oxygen saturation is 98% breathing air”** is **acceptable** — never list it in **concerns.style** and never suggest “improving” it. Flag or suggest correction only for **“on air”**, **“on room air”**, **“on breathing air”**, or **“saturating … on room air”** → prefer **“oxygen saturation is …% breathing air”**.
- **Option overlap:** before returning **suggested_edits** that change any **option_***, check the **final** A–E set has no duplicate/near-duplicate concepts; if overlap would result, **omit** those option edits and note in **concerns.one_best_answer** instead.
PROMPT;
}

/**
 * @param array<string, mixed> $raw
 * @param array<string, mixed> $question
 * @return array{ok: bool, data: ?array<string, mixed>, error: string}
 */
function normalizeSbaCombinedTutorReviewResponse(array $raw, array $question): array
{
    if (array_key_exists('ok', $raw) && $raw['ok'] === false) {
        return ['ok' => false, 'data' => null, 'error' => trim((string) ($raw['error'] ?? 'AI review failed.'))];
    }

    $judgements = ['ready_for_tutor_review', 'needs_edits', 'do_not_approve_yet'];
    $safetyLevels = ['no_major_concerns', 'needs_review', 'unsafe'];
    $editFields = [
        'title', 'stem', 'lead_in', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e',
        'correct_answer', 'justification',
    ];
    $changeTypes = ['style', 'structure', 'educational_content', 'clinical_safety'];
    $risks = ['low', 'medium', 'high'];
    $concernKeys = ['clinical', 'one_best_answer', 'style', 'skill_alignment', 'tutor_comments', 'safety'];

    $judgement = (string) ($raw['overall_judgement'] ?? '');
    if (!in_array($judgement, $judgements, true)) {
        $judgement = 'needs_edits';
    }

    $summary = trim((string) ($raw['summary'] ?? ''));
    if ($summary === '') {
        return ['ok' => false, 'data' => null, 'error' => 'Reviewer JSON missing summary.'];
    }

    $scoresIn = is_array($raw['scores'] ?? null) ? $raw['scores'] : [];
    $scores = [
        'clinical_correctness' => max(0, min(100, (int) ($scoresIn['clinical_correctness'] ?? 0))),
        'one_best_answer' => max(0, min(100, (int) ($scoresIn['one_best_answer'] ?? 0))),
        'ms_akt_style' => max(0, min(100, (int) ($scoresIn['ms_akt_style'] ?? 0))),
        'skill_alignment' => max(0, min(100, (int) ($scoresIn['skill_alignment'] ?? 0))),
        'overall_tutor_readiness' => max(0, min(100, (int) ($scoresIn['overall_tutor_readiness'] ?? 0))),
    ];

    $statusIn = is_array($raw['status'] ?? null) ? $raw['status'] : [];
    $clinicalSafety = (string) ($statusIn['clinical_safety'] ?? 'needs_review');
    if (!in_array($clinicalSafety, $safetyLevels, true)) {
        $clinicalSafety = 'needs_review';
    }

    $concerns = [];
    $concernsIn = is_array($raw['concerns'] ?? null) ? $raw['concerns'] : [];
    foreach ($concernKeys as $key) {
        $list = $concernsIn[$key] ?? [];
        $concerns[$key] = [];
        if (is_array($list)) {
            foreach ($list as $item) {
                $t = trim((string) $item);
                if ($t !== '') {
                    $concerns[$key][] = $t;
                }
            }
        }
    }

    $suggestedEdits = [];
    if (isset($raw['suggested_edits']) && is_array($raw['suggested_edits'])) {
        foreach ($raw['suggested_edits'] as $edit) {
            if (!is_array($edit)) {
                continue;
            }
            $field = (string) ($edit['field'] ?? '');
            if (!in_array($field, $editFields, true)) {
                continue;
            }
            $current = trim((string) ($edit['current'] ?? ''));
            $suggested = trim((string) ($edit['suggested'] ?? ''));
            $reason = trim((string) ($edit['reason'] ?? ''));
            if ($suggested === '' || $reason === '') {
                continue;
            }
            if ($field === 'correct_answer' && !sbaCombinedTutorReviewCorrectAnswerSuggestionIsAcceptable($question, $suggested)) {
                if (!isset($concerns['one_best_answer']) || !is_array($concerns['one_best_answer'])) {
                    $concerns['one_best_answer'] = [];
                }
                $concerns['one_best_answer'][] =
                    'Correct answer suggestion omitted: proposed keyed answer was incomplete. Tutor may edit manually if required.';
                continue;
            }
            if (mb_strtolower($current) === mb_strtolower($suggested)) {
                continue;
            }
            $draftCurrent = sbaCombinedTutorReviewDraftFieldValue($question, $field);
            if ($draftCurrent !== '' && mb_strtolower($draftCurrent) === mb_strtolower($suggested)) {
                continue;
            }
            if ($field === 'stem' && sbaCombinedTutorReviewStemSuggestionIsUnsafe($suggested, $draftCurrent !== '' ? $draftCurrent : $current)) {
                if (!isset($concerns['style']) || !is_array($concerns['style'])) {
                    $concerns['style'] = [];
                }
                $concerns['style'][] =
                    'Stem suggestion omitted: proposed observation wording was not grammatical. Tutor may edit manually if needed.';
                continue;
            }
            $changeType = (string) ($edit['change_type'] ?? 'structure');
            if (!in_array($changeType, $changeTypes, true)) {
                $changeType = 'structure';
            }
            $risk = (string) ($edit['risk'] ?? 'medium');
            if (!in_array($risk, $risks, true)) {
                $risk = 'medium';
            }
            $suggestedEdits[] = [
                'field' => $field,
                'current' => $current !== '' ? $current : $draftCurrent,
                'suggested' => $suggested,
                'reason' => $reason,
                'change_type' => $changeType,
                'risk' => $risk,
                'requires_tutor_review' => !empty($edit['requires_tutor_review']) || $risk !== 'low',
            ];
        }
    }

    $out = [
        'ok' => true,
        'overall_judgement' => $judgement,
        'summary' => $summary,
        'scores' => $scores,
        'status' => [
            'clinical_safety' => $clinicalSafety,
            'suggestions_available' => count($suggestedEdits) > 0,
            'content_changes_require_tutor_review' => !empty($statusIn['content_changes_require_tutor_review'])
                || count(array_filter($suggestedEdits, static fn ($e) => ($e['risk'] ?? '') !== 'low')) > 0,
        ],
        'concerns' => $concerns,
        'suggested_edits' => $suggestedEdits,
        'do_not_auto_apply' => true,
    ];

    if ($clinicalSafety === 'unsafe') {
        $out['overall_judgement'] = 'do_not_approve_yet';
    }

    sbaCombinedTutorReviewEnforceJustificationOptionConsistency($out, $question);
    sbaCombinedTutorReviewFilterBreathingAirStyleFalsePositives($out);
    sbaCombinedTutorReviewValidateSuggestedEdits($out, $question);

    return ['ok' => true, 'data' => $out, 'error' => ''];
}

/**
 * Remove style concerns that incorrectly criticise acceptable “oxygen saturation is …% breathing air”.
 *
 * @param array<string, mixed> $out normalized review (mutated)
 */
function sbaCombinedTutorReviewFilterBreathingAirStyleFalsePositives(array &$out): void
{
    if (!isset($out['concerns']) || !is_array($out['concerns'])) {
        return;
    }
    if (!isset($out['concerns']['style']) || !is_array($out['concerns']['style'])) {
        return;
    }

    $out['concerns']['style'] = array_values(array_filter(
        $out['concerns']['style'],
        static function ($item): bool {
            $t = mb_strtolower(trim((string) $item));
            if ($t === '') {
                return false;
            }
            if (!preg_match('/oxygen\s+saturation\s+is\s+\d{1,3}\s*%?\s+breathing\s+air/u', $t)) {
                return true;
            }
            if (preg_match('/\bon\s+(?:room\s+)?air\b|\bon\s+breathing\s+air\b/u', $t)) {
                return true;
            }

            return !preg_match(
                '/\b(?:prefer|avoid|flag|incorrect|problem|issue|should\s+not|rather\s+than|wording|style|phrasing|use\s+["\']?on\s)/u',
                $t
            );
        }
    ));
}

/**
 * @return list<string>
 */
function sbaCombinedTutorReviewOptionFieldNames(): array
{
    return ['option_a', 'option_b', 'option_c', 'option_d', 'option_e'];
}

/**
 * @param array<string, mixed> $question
 * @param list<array<string, mixed>> $suggestedEdits
 * @return array<string, mixed>
 */
function sbaCombinedTutorReviewQuestionAfterSuggestedEdits(array $question, array $suggestedEdits): array
{
    $q = $question;
    foreach ($suggestedEdits as $edit) {
        if (!is_array($edit)) {
            continue;
        }
        $field = (string) ($edit['field'] ?? '');
        $suggested = trim((string) ($edit['suggested'] ?? ''));
        if ($suggested === '' || formGeneratorIsPlaceholderAnswerText($suggested)) {
            continue;
        }
        if ($field === 'correct_answer') {
            if (preg_match('/^[A-E]$/i', $suggested)) {
                $q['correct_answer_letter'] = strtoupper($suggested);
            } else {
                $q['correct_answer'] = $suggested;
                foreach (sbaCombinedTutorReviewOptionFieldNames() as $optionField) {
                    $optionText = trim((string) ($q[$optionField] ?? ''));
                    if ($optionText !== '' && formGeneratorAnswerTextsMatch($suggested, $optionText)) {
                        $q['correct_answer_letter'] = strtoupper(substr($optionField, -1));
                        break;
                    }
                }
            }
            continue;
        }
        if (in_array($field, sbaCombinedTutorReviewOptionFieldNames(), true) || $field === 'justification') {
            $q[$field] = $suggested;
        }
    }

    $snapshot = formGeneratorQuestionContentSnapshot($q);
    foreach ([
        'option_a', 'option_b', 'option_c', 'option_d', 'option_e',
        'correct_answer_letter', 'correct_answer',
    ] as $key) {
        $q[$key] = $snapshot[$key];
    }

    return $q;
}

/**
 * Canonical concept label for near-duplicate option comparison (word-order insensitive).
 */
function sbaCombinedTutorReviewCanonicalOptionConcept(string $text): string
{
    $t = mb_strtolower(trim($text));
    $t = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $t) ?? $t;
    $words = preg_split('/\s+/u', $t, -1, PREG_SPLIT_NO_EMPTY);
    if (!is_array($words)) {
        return '';
    }

    $stop = ['with', 'the', 'a', 'an', 'of', 'and', 'in', 'for', 'to'];
    $map = ['bodies' => 'body', 'diseases' => 'disease', 'disorders' => 'disorder'];
    $out = [];
    foreach ($words as $w) {
        if (in_array($w, $stop, true) || mb_strlen($w) < 2) {
            continue;
        }
        $out[] = $map[$w] ?? $w;
    }
    $out = array_values(array_unique($out));
    sort($out);

    return implode(' ', $out);
}

/**
 * @param array<string, mixed> $question
 * @return list<array{letter_a: string, letter_b: string, text_a: string, text_b: string}>
 */
function sbaCombinedTutorReviewFindNearDuplicateOptions(array $question): array
{
    $texts = [];
    foreach (sbaCombinedTutorReviewOptionFieldNames() as $field) {
        $letter = strtoupper(substr($field, -1));
        $text = trim((string) ($question[$field] ?? ''));
        if ($text !== '') {
            $texts[$letter] = $text;
        }
    }

    $pairs = [];
    $letters = array_keys($texts);
    $count = count($letters);
    for ($i = 0; $i < $count; $i++) {
        for ($j = $i + 1; $j < $count; $j++) {
            $letterA = $letters[$i];
            $letterB = $letters[$j];
            $textA = $texts[$letterA];
            $textB = $texts[$letterB];

            if (formGeneratorAnswerTextsMatch($textA, $textB)) {
                $pairs[] = [
                    'letter_a' => $letterA,
                    'letter_b' => $letterB,
                    'text_a' => $textA,
                    'text_b' => $textB,
                ];
                continue;
            }

            $conceptA = sbaCombinedTutorReviewCanonicalOptionConcept($textA);
            $conceptB = sbaCombinedTutorReviewCanonicalOptionConcept($textB);
            if ($conceptA !== '' && $conceptA === $conceptB) {
                $pairs[] = [
                    'letter_a' => $letterA,
                    'letter_b' => $letterB,
                    'text_a' => $textA,
                    'text_b' => $textB,
                ];
                continue;
            }

            $tokensA = $conceptA !== '' ? explode(' ', $conceptA) : [];
            $tokensB = $conceptB !== '' ? explode(' ', $conceptB) : [];
            if ($tokensA === [] || $tokensB === []) {
                continue;
            }
            $intersect = array_intersect($tokensA, $tokensB);
            $union = array_unique(array_merge($tokensA, $tokensB));
            $jaccard = count($union) > 0 ? count($intersect) / count($union) : 0.0;
            if ($jaccard >= 0.85) {
                $pairs[] = [
                    'letter_a' => $letterA,
                    'letter_b' => $letterB,
                    'text_a' => $textA,
                    'text_b' => $textB,
                ];
            }
        }
    }

    return $pairs;
}

/**
 * Post-process suggested edits: correct-answer integrity, keyed-answer metadata, block incomplete bundles.
 *
 * @param array<string, mixed> $out normalized review (mutated)
 * @param array<string, mixed> $question draft question row
 */
function sbaCombinedTutorReviewValidateSuggestedEdits(array &$out, array $question): void
{
    $suggestedEdits = is_array($out['suggested_edits'] ?? null) ? $out['suggested_edits'] : [];
    if ($suggestedEdits === []) {
        if (!isset($out['status']) || !is_array($out['status'])) {
            $out['status'] = [];
        }
        $out['status']['suggested_edits_valid'] = true;

        return;
    }

    $touchesKeyOrOptions = false;
    foreach ($suggestedEdits as $edit) {
        if (!is_array($edit)) {
            continue;
        }
        $field = (string) ($edit['field'] ?? '');
        if (in_array($field, sbaCombinedTutorReviewOptionFieldNames(), true) || $field === 'correct_answer') {
            $touchesKeyOrOptions = true;
            break;
        }
    }

    if (!isset($out['status']) || !is_array($out['status'])) {
        $out['status'] = [];
    }

    if (!$touchesKeyOrOptions) {
        $out['status']['suggested_edits_valid'] = true;

        return;
    }

    $after = sbaCombinedTutorReviewQuestionAfterSuggestedEdits($question, $suggestedEdits);
    $snapshot = formGeneratorQuestionContentSnapshot($after);
    $validation = formGeneratorValidateQuestionContentSnapshot($snapshot);
    $blockedMessage =
        'AI suggestions could not be applied because the suggested correct answer was incomplete. Original draft unchanged.';

    if (!$validation['ok']) {
        $out['suggested_edits'] = [];
        $out['status']['suggestions_available'] = false;
        $out['status']['suggested_edits_valid'] = false;
        $out['status']['suggested_edits_blocked_reason'] = $blockedMessage;
        $out['status']['content_changes_require_tutor_review'] = true;
        if (!isset($out['concerns']) || !is_array($out['concerns'])) {
            $out['concerns'] = [];
        }
        if (!isset($out['concerns']['tutor_comments']) || !is_array($out['concerns']['tutor_comments'])) {
            $out['concerns']['tutor_comments'] = [];
        }
        $out['concerns']['tutor_comments'][] = $blockedMessage;
        if (($out['overall_judgement'] ?? '') === 'ready_for_tutor_review') {
            $out['overall_judgement'] = 'needs_edits';
        }

        return;
    }

    $duplicatePairs = sbaCombinedTutorReviewFindNearDuplicateOptions($after);
    if ($duplicatePairs !== []) {
        $dupMessage =
            'AI suggestions were not applied because two options may overlap. Original draft unchanged.';
        $example = $duplicatePairs[0];
        $detail = 'Options ' . $example['letter_a'] . ' (“' . $example['text_a'] . '”) and '
            . $example['letter_b'] . ' (“' . $example['text_b'] . '”) may overlap.';
        $out['suggested_edits'] = [];
        $out['status']['suggestions_available'] = false;
        $out['status']['suggested_edits_valid'] = false;
        $out['status']['suggested_edits_blocked_reason'] = $dupMessage;
        $out['status']['duplicate_options_detected'] = true;
        $out['status']['duplicate_options_detail'] = $detail;
        $out['status']['content_changes_require_tutor_review'] = true;
        if (!isset($out['concerns']) || !is_array($out['concerns'])) {
            $out['concerns'] = [];
        }
        if (!isset($out['concerns']['one_best_answer']) || !is_array($out['concerns']['one_best_answer'])) {
            $out['concerns']['one_best_answer'] = [];
        }
        $out['concerns']['one_best_answer'][] = $detail;
        if (($out['overall_judgement'] ?? '') === 'ready_for_tutor_review') {
            $out['overall_judgement'] = 'needs_edits';
        }

        return;
    }

    $out['status']['suggested_edits_valid'] = true;

    $originalSnapshot = formGeneratorQuestionContentSnapshot($question);
    $originalLetter = strtoupper(trim((string) ($originalSnapshot['correct_answer_letter'] ?? '')));
    $newLetter = strtoupper(trim((string) ($snapshot['correct_answer_letter'] ?? '')));
    $conceptChanged = $originalLetter !== $newLetter
        || !formGeneratorAnswerTextsMatch(
            (string) ($originalSnapshot['correct_answer'] ?? ''),
            (string) ($snapshot['correct_answer'] ?? '')
        );

    if ($conceptChanged) {
        foreach ($suggestedEdits as $idx => $edit) {
            if (!is_array($edit)) {
                continue;
            }
            if (($edit['field'] ?? '') === 'correct_answer') {
                $suggestedEdits[$idx]['change_type'] = 'clinical_safety';
                $suggestedEdits[$idx]['risk'] = 'high';
                $suggestedEdits[$idx]['requires_tutor_review'] = true;
            }
        }
        $out['status']['content_changes_require_tutor_review'] = true;
        $out['do_not_auto_apply'] = true;
    }

    $staleRefs = $out['status']['stale_distractor_references'] ?? [];
    $hasStaleJustification = is_array($staleRefs) && $staleRefs !== [];
    if ($hasStaleJustification) {
        $out['status']['content_changes_require_tutor_review'] = true;
        foreach ($suggestedEdits as $idx => $edit) {
            if (!is_array($edit)) {
                continue;
            }
            $field = (string) ($edit['field'] ?? '');
            if (in_array($field, sbaCombinedTutorReviewOptionFieldNames(), true) || $field === 'correct_answer') {
                $suggestedEdits[$idx]['requires_tutor_review'] = true;
                if (($suggestedEdits[$idx]['risk'] ?? 'low') === 'low') {
                    $suggestedEdits[$idx]['risk'] = 'medium';
                }
            }
        }
    }

    $out['suggested_edits'] = $suggestedEdits;
}

/**
 * @param array<string, mixed> $question
 */
function sbaCombinedTutorReviewCorrectAnswerSuggestionIsAcceptable(array $question, string $suggested): bool
{
    $suggested = trim($suggested);
    if ($suggested === '' || formGeneratorIsPlaceholderAnswerText($suggested)) {
        return false;
    }
    if (preg_match('/^correct\s+answer\s*:?\s*$/i', $suggested) || preg_match('/^keyed\s+answer\s*:?\s*$/i', $suggested)) {
        return false;
    }

    $snapshot = formGeneratorQuestionContentSnapshot($question);
    $options = [
        'A' => trim((string) ($snapshot['option_a'] ?? '')),
        'B' => trim((string) ($snapshot['option_b'] ?? '')),
        'C' => trim((string) ($snapshot['option_c'] ?? '')),
        'D' => trim((string) ($snapshot['option_d'] ?? '')),
        'E' => trim((string) ($snapshot['option_e'] ?? '')),
    ];

    if (preg_match('/^[A-E]$/i', $suggested)) {
        $letter = strtoupper($suggested);
        $text = $options[$letter] ?? '';

        return $text !== '' && !formGeneratorIsPlaceholderAnswerText($text);
    }

    foreach ($options as $text) {
        if ($text !== '' && !formGeneratorIsPlaceholderAnswerText($text) && formGeneratorAnswerTextsMatch($suggested, $text)) {
            return true;
        }
    }

    return false;
}

function sbaCombinedTutorReviewStemHasMalformedObservationGrammar(string $text): bool
{
    return mainStemHasMalformedObservationGrammar($text);
}

function sbaCombinedTutorReviewStemObservationIsAcceptable(string $text): bool
{
    $t = trim($text);
    if ($t === '') {
        return true;
    }
    if (
        variantStemIsListStyleObservationPhrase($t)
        && !sbaCombinedTutorReviewStemHasMalformedObservationGrammar($t)
        && !mainStemHasMixedSentenceObservationMarkers($t)
    ) {
        return true;
    }

    return !sbaCombinedTutorReviewStemHasMalformedObservationGrammar($t);
}

/**
 * @return array{stem: string, needs_review: bool}
 */
function sbaCombinedTutorReviewNormalizeStemSuggestion(string $stem): array
{
    $norm = mainNormalizeStemObservationGrammar($stem);
    if ($norm['needs_review']) {
        $norm = mainNormalizeStemObservationGrammar($norm['stem']);
    }

    return [
        'stem' => $norm['stem'],
        'needs_review' => $norm['needs_review'],
    ];
}

function sbaCombinedTutorReviewStemSuggestionRegressesGrammar(string $current, string $suggested): bool
{
    $cur = trim($current);
    $sug = trim($suggested);
    if ($cur === '' || $sug === '') {
        return false;
    }
    if (!sbaCombinedTutorReviewStemObservationIsAcceptable($cur)) {
        return false;
    }
    if (preg_match('/\b(?:His|Her|Their)\s+pulse\s+is\s+\d+/i', $cur) && variantStemHasClippedPossessivePulse($sug)) {
        return true;
    }
    if (preg_match('/\bblood\s+pressure\s+is\s+\d+/i', $cur) && variantStemHasClippedPossessiveBloodPressure($sug)) {
        return true;
    }
    if (preg_match('/\b(?:His|Her|Their)\s+(?:respiratory\s+rate|RR)\s+is\s+\d+/i', $cur)
        && preg_match('/\b(?:His|Her|Their)\s+(?:respiratory\s+rate|RR)\s+(?!is\s)\d+/i', $sug)) {
        return true;
    }

    return sbaCombinedTutorReviewStemHasMalformedObservationGrammar($sug);
}

function sbaCombinedTutorReviewStemSuggestionIsUnsafe(string $suggested, string $current): bool
{
    $norm = sbaCombinedTutorReviewNormalizeStemSuggestion($suggested);
    if ($norm['needs_review']) {
        return true;
    }
    if (sbaCombinedTutorReviewStemSuggestionRegressesGrammar($current, $norm['stem'])) {
        return true;
    }

    return false;
}

function sbaCombinedTutorReviewNormalizeComparableText(string $text): string
{
    $t = mb_strtolower(trim($text));
    $t = preg_replace('/\s+/u', ' ', $t) ?? $t;
    $t = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $t) ?? $t;

    return trim($t);
}

/**
 * @param list<string> $finalOptionTexts
 */
function sbaCombinedTutorReviewOptionTextInFinalSet(string $text, array $finalOptionTexts): bool
{
    $needle = sbaCombinedTutorReviewNormalizeComparableText($text);
    if ($needle === '') {
        return true;
    }
    foreach ($finalOptionTexts as $final) {
        $hay = sbaCombinedTutorReviewNormalizeComparableText($final);
        if ($hay === '') {
            continue;
        }
        if ($needle === $hay || str_contains($hay, $needle) || str_contains($needle, $hay)) {
            return true;
        }
    }

    return false;
}

function sbaCombinedTutorReviewClinicalTextReferencedInJustification(string $text, string $justification): bool
{
    $justification = trim($justification);
    $text = trim($text);
    if ($justification === '' || $text === '') {
        return false;
    }

    if (mb_strlen($text) >= 10 && mb_stripos($justification, $text) !== false) {
        return true;
    }

    $words = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
    if (!is_array($words)) {
        return false;
    }
    $significant = array_values(array_filter($words, static fn ($w) => mb_strlen((string) $w) >= 4));
    if (count($significant) < 2) {
        return mb_strlen($text) >= 6 && mb_stripos($justification, $text) !== false;
    }

    $hits = 0;
    foreach ($significant as $word) {
        if (mb_stripos($justification, (string) $word) !== false) {
            $hits++;
        }
    }

    return $hits >= min(2, count($significant));
}

/**
 * @param array<string, mixed> $before
 * @param array<string, mixed> $after
 * @return list<string>
 */
function sbaCombinedTutorReviewFindStaleDistractorMentions(array $before, array $after, string $justification): array
{
    $justification = trim($justification);
    if ($justification === '') {
        return [];
    }

    $finalTexts = [];
    foreach (sbaCombinedTutorReviewOptionFieldNames() as $field) {
        $t = trim((string) ($after[$field] ?? ''));
        if ($t !== '') {
            $finalTexts[] = $t;
        }
    }

    $stale = [];
    foreach (sbaCombinedTutorReviewOptionFieldNames() as $field) {
        $original = trim((string) ($before[$field] ?? ''));
        if ($original === '') {
            continue;
        }
        if (sbaCombinedTutorReviewOptionTextInFinalSet($original, $finalTexts)) {
            continue;
        }
        if (sbaCombinedTutorReviewClinicalTextReferencedInJustification($original, $justification)) {
            $stale[] = $original;
        }
    }

    return array_values(array_unique($stale));
}

/**
 * @param array<string, mixed> $out normalized review (mutated)
 * @param array<string, mixed> $question draft question row
 */
function sbaCombinedTutorReviewEnforceJustificationOptionConsistency(array &$out, array $question): void
{
    $suggestedEdits = is_array($out['suggested_edits'] ?? null) ? $out['suggested_edits'] : [];
    $after = sbaCombinedTutorReviewQuestionAfterSuggestedEdits($question, $suggestedEdits);
    $justification = trim((string) ($after['justification'] ?? ''));

    $stale = sbaCombinedTutorReviewFindStaleDistractorMentions($question, $after, $justification);

    $optionFields = sbaCombinedTutorReviewOptionFieldNames();
    $optionEditCount = 0;
    $hasJustificationEdit = false;
    foreach ($suggestedEdits as $idx => $edit) {
        if (!is_array($edit)) {
            continue;
        }
        $field = (string) ($edit['field'] ?? '');
        if ($field === 'justification') {
            $hasJustificationEdit = true;
        }
        if (in_array($field, $optionFields, true) || $field === 'correct_answer') {
            $optionEditCount++;
        }
    }

    if ($stale === [] && $optionEditCount === 0) {
        return;
    }

    if (!isset($out['status']) || !is_array($out['status'])) {
        $out['status'] = [];
    }
    $out['status']['justification_option_consistency'] = $stale === [] ? 'ok' : 'stale_distractor_references';
    if ($stale !== []) {
        $out['status']['stale_distractor_references'] = $stale;
        $out['status']['content_changes_require_tutor_review'] = true;
    }

    if ($stale !== []) {
        $staleList = implode('; ', array_slice($stale, 0, 4));
        $concern = 'Justification still refers to distractor(s) no longer in the final options after suggested edits: '
            . $staleList
            . '. Update the justification so every discussed distractor matches the final A–E set.';
        if (!isset($out['concerns']) || !is_array($out['concerns'])) {
            $out['concerns'] = [];
        }
        if (!isset($out['concerns']['one_best_answer']) || !is_array($out['concerns']['one_best_answer'])) {
            $out['concerns']['one_best_answer'] = [];
        }
        $out['concerns']['one_best_answer'][] = $concern;

        if (($out['overall_judgement'] ?? '') === 'ready_for_tutor_review') {
            $out['overall_judgement'] = 'needs_edits';
        }
    }

    foreach ($suggestedEdits as $idx => $edit) {
        if (!is_array($edit)) {
            continue;
        }
        $field = (string) ($edit['field'] ?? '');
        if (in_array($field, $optionFields, true) || $field === 'correct_answer') {
            $suggestedEdits[$idx]['requires_tutor_review'] = true;
            if (($suggestedEdits[$idx]['risk'] ?? 'low') === 'low') {
                $suggestedEdits[$idx]['risk'] = 'medium';
            }
        }
        if ($field === 'justification' && $stale !== []) {
            $suggestedEdits[$idx]['requires_tutor_review'] = true;
            $suggestedEdits[$idx]['risk'] = 'high';
            $reason = trim((string) ($suggestedEdits[$idx]['reason'] ?? ''));
            $suffix = ' Remove references to distractors not in the final options (e.g. '
                . implode(', ', array_slice($stale, 0, 3))
                . ').';
            if ($reason !== '' && !str_contains($reason, 'final options')) {
                $suggestedEdits[$idx]['reason'] = rtrim($reason, '.') . '.' . $suffix;
            }
        }
    }

    if ($stale !== [] && !$hasJustificationEdit && $optionEditCount > 0) {
        if (!isset($out['concerns']['clinical']) || !is_array($out['concerns']['clinical'])) {
            $out['concerns']['clinical'] = [];
        }
        $out['concerns']['clinical'][] =
            'Suggested option change(s) require a matching justification update — the current justification still references removed or replaced distractors.';
    }

    $out['suggested_edits'] = $suggestedEdits;
}

function sbaCombinedTutorReviewDraftFieldValue(array $question, string $field): string
{
    if ($field === 'correct_answer') {
        $letter = strtoupper(trim((string) ($question['correct_answer_letter'] ?? '')));
        $text = trim((string) ($question['correct_answer'] ?? ''));

        return $text !== '' ? $text : $letter;
    }

    return trim((string) ($question[$field] ?? ''));
}

/**
 * Map combined-review field name to edit-panel field id.
 */
function sbaCombinedTutorReviewEditPanelField(string $field): string
{
    if ($field === 'correct_answer') {
        return 'correct_answer_letter';
    }

    return $field;
}
