<?php

/**
 * Style validation for generated Single Best Answer (SBA) items.
 *
 * Returns warnings only — intended for review feedback. Does not block generation.
 * Expected question keys (when present): title, stem, lead_in, option_a … option_e,
 * correct_answer_letter, correct_answer, justification.
 */

/**
 * Apply safe, text-only MS AKT–style fixes to selected fields. Does not alter clinical meaning
 * beyond wording conventions; does not change correct_answer_letter.
 *
 * @param array<string, mixed> $question
 * @return array<string, mixed>
 */
function repairSbaStyle(array $question): array
{
    try {
        $out = $question;
        $fields = [
            'title',
            'stem',
            'lead_in',
            'option_a',
            'option_b',
            'option_c',
            'option_d',
            'option_e',
            'correct_answer',
            'justification',
        ];

        foreach ($fields as $field) {
            if (!array_key_exists($field, $out)) {
                continue;
            }
            if (!is_string($out[$field])) {
                continue;
            }
            $out[$field] = repairSbaStyleText($out[$field]);
            if ($field === 'stem' || $field === 'justification' || $field === 'correct_answer') {
                $out[$field] = repairSbaStyleStripAmylaseLipaseLevelsPhrase($out[$field]);
            }
            if (preg_match('/^option_[a-e]$/', $field)) {
                $out[$field] = repairSbaStyleOptionStripLeadingLetterLabel(
                    repairSbaStyleOptionDuplicateLetterLabel($out[$field])
                );
                $out[$field] = repairSbaStyleOptionRedundantLevels($out[$field]);
            }
        }

        if (array_key_exists('correct_answer', $out) && is_string($out['correct_answer'])) {
            $out['correct_answer'] = repairSbaStyleOptionStripLeadingLetterLabel($out['correct_answer']);
        }

        $out = repairSbaStyleSyncCorrectAnswerFromOptions($out);

        if (array_key_exists('title', $out) && is_string($out['title'])) {
            $out['title'] = repairSbaStyleTitleSafePhrases($out['title']);
        }

        if (array_key_exists('stem', $out) && is_string($out['stem'])) {
            $out['stem'] = repairSbaStyleStemMedicationAndChainSort($out['stem']);
        }

        foreach (['stem', 'lead_in', 'justification'] as $field) {
            if (!array_key_exists($field, $out)) {
                continue;
            }
            if (!is_string($out[$field])) {
                continue;
            }
            if ($field === 'stem' || $field === 'justification') {
                $out[$field] = repairSbaStyleSentenceCaseFreeT4InProse($out[$field]);
            }
            if ($field === 'lead_in' && isset($out['stem']) && is_string($out['stem'])) {
                $out['lead_in'] = repairSbaStyleAmenorrhoeaLeadInitialToNextInvestigation($out['stem'], $out['lead_in']);
            }
            $out[$field] = repairSbaStyleNormalizeSentenceStarts($out[$field]);
        }

        return $out;
    } catch (Throwable $e) {
        return $question;
    }
}

/**
 * Single-field repair pipeline (order matters for patterns).
 */
/**
 * Nouns that may follow an adjectival age phrase (e.g. 65-year-old man).
 */
function sbaStyleAdjectivalAgeNounPattern(): string
{
    return 'man|woman|person|patient|child|boy|girl|infant|baby|neonate|adolescent';
}

/**
 * Time units in adjectival age compounds (singular: year/month/week old).
 */
function sbaStyleAdjectivalAgeUnitPattern(): string
{
    return 'year|month|week';
}

/**
 * MS AKT: de-hyphenate adjectival age before a noun ("65-year-old man" → "65 year old man").
 * Does not change predicative ages ("is 65 years old").
 */
function repairSbaStyleDehyphenateAdjectivalAge(string $text): string
{
    if ($text === '') {
        return $text;
    }

    $nouns = sbaStyleAdjectivalAgeNounPattern();
    $units = sbaStyleAdjectivalAgeUnitPattern();
    $pattern = '/\b(\d{1,3})(?:\s*-\s*|\s+)(' . $units . ')(?:\s*-\s*|\s*)old\s+(' . $nouns . ')\b/iu';

    return (string) preg_replace_callback(
        $pattern,
        static function (array $m): string {
            return $m[1] . ' ' . strtolower($m[2]) . ' old ' . $m[3];
        },
        $text
    );
}

/**
 * True when adjectival age before a noun is still hyphenated (validator fallback after repair).
 */
function sbaStyleHasHyphenatedAdjectivalAge(string $text): bool
{
    if ($text === '') {
        return false;
    }

    $nouns = sbaStyleAdjectivalAgeNounPattern();
    $units = sbaStyleAdjectivalAgeUnitPattern();

    return (bool) preg_match(
        '/\b\d{1,3}-(?:' . $units . ')-old\s+(?:' . $nouns . ')\b|\b\d{1,3}-(?:' . $units . ')\s+old\s+(?:' . $nouns . ')\b|\b\d{1,3}\s+(?:' . $units . ')-old\s+(?:' . $nouns . ')\b/iu',
        $text
    );
}

/**
 * Conservative oxygen-saturation phrasing (MS AKT: "breathing air").
 */
function repairSbaStyleOxygenSaturationPhrasing(string $text): string
{
    if ($text === '' || !preg_match('/\b(?:oxygen\s+saturation|saturating|room\s+air|breathing\s+air)\b/i', $text)) {
        return $text;
    }

    $s = $text;
    if (preg_match('/\b(?:despite|via|with|receiving|on)\s+\d+\s*(?:L\/min|l\/min|%)/i', $s)) {
        return $text;
    }

    $s = preg_replace('/\boxygen\s+saturation\s+is\s+(\d{1,3})\s*%\s+on\s+room\s+air\b/i', 'oxygen saturation is $1% breathing air', $s);
    $s = preg_replace('/\boxygen\s+saturation\s+is\s+(\d{1,3})\s*%\s+on\s+air\b/i', 'oxygen saturation is $1% breathing air', $s);
    $s = preg_replace(
        '/\boxygen\s+saturation\s+of\s+(\d{1,3})\s*%\s+breathing\s+air\b/i',
        'oxygen saturation is $1% breathing air',
        $s
    );
    $s = preg_replace('/\boxygen\s+saturation\s+(\d{1,3})\s*%\s+breathing\s+air\b/i', 'oxygen saturation is $1% breathing air', $s);
    $s = preg_replace('/\bsaturating\s+(\d{1,3})\s*%\s+on\s+room\s+air\b/i', 'oxygen saturation is $1% breathing air', $s);
    $s = preg_replace('/\bsaturating\s+(\d{1,3})\s*%\s+on\s+air\b/i', 'oxygen saturation is $1% breathing air', $s);
    $s = preg_replace('/\bsaturating\s+(\d{1,3})\s*%\s+breathing\s+air\b/i', 'oxygen saturation is $1% breathing air', $s);
    $s = preg_replace(
        '/\boxygen\s+saturation\s+is\s+(\d{1,3})\s*%\s+on\s+breathing\s+air\b/i',
        'oxygen saturation is $1% breathing air',
        $s
    );
    $s = preg_replace('/\bon\s+room\s+air\b/i', 'breathing air', $s);

    return $s;
}

/**
 * MS AKT blood-gas notation (mechanical symbol names only).
 */
function repairSbaStyleBloodGasNotation(string $text): string
{
    if ($text === '' || !preg_match('/\b(?:PaO2|PaCO2|PaO₂|PaCO₂|HCO\s*3|HCO3|bicarbonate|PO2|PCO2|pH)\b/i', $text)) {
        return $text;
    }

    $s = $text;
    $s = preg_replace('/\bPaO2\b/i', 'PO2', $s);
    $s = preg_replace('/\bPaCO2\b/i', 'PCO2', $s);
    $s = preg_replace('/\bPaO\s*2\b/i', 'PO2', $s);
    $s = preg_replace('/\bPaCO\s*2\b/i', 'PCO2', $s);
    $s = preg_replace('/\bPaO₂\b/u', 'PO2', $s);
    $s = preg_replace('/\bPaCO₂\b/u', 'PCO2', $s);
    $s = preg_replace('/\bHCO\s*3\s*[-−]?\b/i', 'bicarbonate ', $s);
    $s = preg_replace('/\bbicarbonate\s*[-−:]\s*(\d)/i', 'bicarbonate $1', $s);

    return $s;
}

/**
 * UK GI / haematology spellings (mechanical; does not change clinical meaning).
 */
function repairSbaStyleUkGiAndHaematologySpelling(string $text): string
{
    if ($text === '') {
        return $text;
    }

    $s = $text;
    $s = preg_replace('/\bmalaena\b/i', 'melaena', $s);
    $s = preg_replace('/\bmelena\b/i', 'melaena', $s);
    $s = preg_replace('/\bhematemesis\b/i', 'haematemesis', $s);
    $s = preg_replace('/\bhaematemessis\b/i', 'haematemesis', $s);
    $s = preg_replace('/\bhematemessis\b/i', 'haematemesis', $s);
    $s = preg_replace('/\besophageal\b/i', 'oesophageal', $s);
    $s = preg_replace('/\b([Gg])astro-?esophageal\b/u', '$1astro-oesophageal', $s);
    $s = preg_replace('/\banemia\b/i', 'anaemia', $s);
    $s = preg_replace('/\bedema\b/i', 'oedema', $s);

    return $s;
}

/**
 * Fix duplicated "has … has …" clause joins when the second clause is clearly symptomatic.
 */
function repairSbaStyleDoubleHasClause(string $text): string
{
    if ($text === '' || !preg_match('/\bhas\s+.+\s+has\s+/i', $text)) {
        return $text;
    }

    return (string) preg_replace(
        '/\bhas\s+((?:[a-z0-9][^,.]{3,100}?)(?:\s*\([^)]+\))?)\s+has\s+'
        . '(increasing|decreasing|worsening|progressive|new-onset|new|recent|persistent)\b/i',
        'has $1 and $2',
        $text
    );
}

/**
 * Remove formulaic non-discriminating affect labels (conservative patterns only).
 */
function repairSbaStyleAffectLabels(string $text): string
{
    if ($text === '' || !preg_match('/\b(?:appears?|looks?|seems?)\s+(?:mildly\s+)?(?:anxious|distressed|agitated)\b/i', $text)) {
        return $text;
    }

    $s = $text;
    $s = preg_replace('/,\s+and\s+(?:appears?|looks?|seems?)\s+(?:mildly\s+|very\s+)?(?:anxious|distressed|agitated)\b/i', '', $s);
    $s = preg_replace('/,\s+who\s+(?:appears?|looks?)\s+(?:mildly\s+)?anxious\b/i', '', $s);
    $s = preg_replace(
        '/\s+(?:He|She|They)\s+(?:appears?|looks?)\s+(?:mildly\s+)?anxious\.?\s*(?=\.|$)/iu',
        '',
        $s
    );

    return $s;
}

/**
 * Strip leading A–E option labels from stored option text (UI renders letters separately).
 */
function repairSbaStyleOptionStripLeadingLetterLabel(string $text): string
{
    $t = trim($text);
    if ($t === '') {
        return $text;
    }

    $fixed = preg_replace('/^[A-E][.)]\s+/i', '', $t);
    if (!is_string($fixed) || $fixed === '') {
        return $t;
    }

    return trim($fixed);
}

/**
 * Named current conditions for safe “with known / history of” → “has” repairs.
 */
function sbaStyleCurrentConditionsPattern(): string
{
    return 'asthma|hypertension|type\s+2\s+diabetes\s+mellitus|diabetes\s+mellitus|'
        . 'chronic\s+obstructive\s+pulmonary\s+disease|COPD|heart\s+failure|atrial\s+fibrillation|'
        . 'chronic\s+kidney\s+disease|CKD|ischaemic\s+heart\s+disease';
}

/**
 * Conservative PMH / current-condition phrasing (skips smoking, family/drug history, time-stamped events).
 */
function repairSbaStylePastMedicalHistoryPhrasing(string $text): string
{
    if ($text === '') {
        return $text;
    }

    $s = $text;
    $s = preg_replace('/\bhas\s+no\s+significant\s+past\s+medical\s+history\b/i', 'has no other medical conditions', $s);
    $s = preg_replace('/\bhas\s+no\s+significant\s+PMH\b/i', 'has no other medical conditions', $s);
    $s = preg_replace('/\bknown\s+to\s+have\b/i', 'has', $s);
    $s = preg_replace('/\bis\s+known\s+to\s+have\b/i', 'has', $s);

    if (!preg_match('/\bhistory\s+of\s+smoking\b/i', $s) && !preg_match('/\b(?:family|drug)\s+history\b/i', $s)) {
        $conditions = sbaStyleCurrentConditionsPattern();
        $s = preg_replace('/\bwith\s+known\s+(' . $conditions . ')\b/i', 'has $1', $s);
        $s = preg_replace('/\bwith\s+a\s+history\s+of\s+(' . $conditions . ')\b/i', 'has $1', $s);
        $s = preg_replace('/\bhas\s+a\s+history\s+of\s+(' . $conditions . ')\b/i', 'has $1', $s);
        if (!preg_match('/\b(?:ago|years?\s+ago|months?\s+ago|previously|recent|previous)\b/i', $s)) {
            $s = preg_replace(
                '/\b(he|she|they)\s+was\s+diagnosed\s+with\s+(chronic\s+obstructive\s+pulmonary\s+disease|COPD|type\s+2\s+diabetes\s+mellitus|asthma|hypertension|heart\s+failure)\b/i',
                '$1 has $2',
                $s
            );
        }
        $s = preg_replace(
            '/\bhas\s+(' . $conditions . ')\s+has\s+(?!'
            . '(?:increasing|decreasing|worsening|progressive|new-onset|new|recent|persistent)\b)/i',
            'has $1 and has',
            $s
        );
    }

    return $s;
}

/**
 * Mechanical imaging / punctuation normalisation (no clinical meaning change).
 */
function repairSbaStyleImagingTerminology(string $text): string
{
    if ($text === '') {
        return $text;
    }

    $s = $text;
    $s = preg_replace('/\bx\s*ray\b/i', 'X-ray', $s);
    $s = preg_replace('/\bX-Ray\b/', 'X-ray', $s);
    $s = preg_replace('/\bX\s+ray\b/i', 'X-ray', $s);

    return $s;
}

/**
 * Align correct_answer text with repaired option line when letter is set.
 *
 * @param array<string, mixed> $question
 * @return array<string, mixed>
 */
function repairSbaStyleSyncCorrectAnswerFromOptions(array $question): array
{
    $letter = strtoupper(trim((string) ($question['correct_answer_letter'] ?? '')));
    if (!preg_match('/^[A-E]$/', $letter)) {
        return $question;
    }

    $key = 'option_' . strtolower($letter);
    if (!isset($question[$key]) || !is_string($question[$key])) {
        return $question;
    }

    $optionText = trim($question[$key]);
    if ($optionText === '') {
        return $question;
    }

    $correct = trim((string) ($question['correct_answer'] ?? ''));
    if ($correct === '' || preg_match('/^[A-E][.)]\s+/i', $correct)) {
        $question['correct_answer'] = $optionText;
    }

    return $question;
}

/**
 * Remove duplicated option letter prefixes ("A. A. Salbutamol" → "Salbutamol").
 */
function repairSbaStyleOptionDuplicateLetterLabel(string $text): string
{
    $t = trim($text);
    if ($t === '') {
        return $text;
    }

    $fixed = preg_replace('/^[A-E]\.\s+[A-E]\.\s+/i', '', $t);

    return is_string($fixed) && $fixed !== '' ? trim($fixed) : $t;
}

/**
 * @param array{pulse: ?string, bp: ?string, rr: ?string, spo2: ?string, temp: ?string, afebrile: bool} $vitals
 */
function sbaStyleFormatMsaKtObservationLine(string $possessive, array $vitals): string
{
    $parts = [];
    if ($vitals['temp'] !== null && $vitals['temp'] !== '') {
        $parts[] = 'temperature is ' . $vitals['temp'] . '°C';
    } elseif ($vitals['afebrile']) {
        $parts[] = 'temperature is 37.0°C';
    }
    if ($vitals['pulse'] !== null && $vitals['pulse'] !== '') {
        $parts[] = 'pulse ' . $vitals['pulse'] . ' bpm';
    }
    if ($vitals['bp'] !== null && $vitals['bp'] !== '') {
        $parts[] = 'BP ' . $vitals['bp'] . ' mmHg';
    }
    if ($vitals['rr'] !== null && $vitals['rr'] !== '') {
        $parts[] = 'respiratory rate ' . $vitals['rr'] . ' breaths per minute';
    }
    if ($vitals['spo2'] !== null && $vitals['spo2'] !== '') {
        $parts[] = 'oxygen saturation is ' . $vitals['spo2'] . '% breathing air';
    }

    if ($parts === []) {
        return $possessive . 'observations';
    }

    if (count($parts) === 1) {
        return $possessive . $parts[0];
    }

    $last = array_pop($parts);
    $body = implode(', ', $parts) . ' and ' . $last;

    return $possessive . $body;
}

/**
 * @return array{pulse: ?string, bp: ?string, rr: ?string, spo2: ?string, temp: ?string, afebrile: bool}
 */
function sbaStyleExtractVitalsFromObservationSegment(string $segment): array
{
    $vitals = [
        'pulse' => null,
        'bp' => null,
        'rr' => null,
        'spo2' => null,
        'temp' => null,
        'afebrile' => false,
    ];

    if (preg_match('/\bpulse\s+(?:(?:is|of)\s+)?(\d+)/i', $segment, $m)) {
        $vitals['pulse'] = $m[1];
    }
    if ($vitals['pulse'] === null && preg_match('/\bpulse\s+(\d+)\s*bpm\b/i', $segment, $m)) {
        $vitals['pulse'] = $m[1];
    }
    if (preg_match('/\b(?:blood\s+pressure|BP)\s+(?:is\s+|of\s+)?(\d+\s*\/\s*\d+)/i', $segment, $m)) {
        $vitals['bp'] = preg_replace('/\s+/', '', $m[1]);
    }
    if ($vitals['bp'] === null && preg_match('/\bBP\s+(\d+\s*\/\s*\d+)/i', $segment, $m)) {
        $vitals['bp'] = preg_replace('/\s+/', '', $m[1]);
    }
    if (preg_match('/\brespiratory\s+rate\s+(?:is\s+|of\s+)?(\d+)/i', $segment, $m)) {
        $vitals['rr'] = $m[1];
    }
    if (preg_match('/\boxygen\s+saturation\s+(?:is\s+|of\s+)?(\d+)\s*%?/i', $segment, $m)) {
        $vitals['spo2'] = $m[1];
    }
    if (preg_match('/\btemperature\s+(?:is\s+)?([\d.]+)\s*°?\s*C/i', $segment, $m)) {
        $vitals['temp'] = $m[1];
    }
    if (preg_match('/\bafebrile\b/i', $segment)) {
        $vitals['afebrile'] = true;
    }

    return $vitals;
}

/**
 * @return string Unchanged wrapper sentence, or MS AKT direct observation line ending with “.”
 */
function sbaStyleRewriteObservationWrapperToMsaKtLine(string $possessive, string $segment, string $fullText, string $matchedSentence): string
{
    $vitals = sbaStyleExtractVitalsFromObservationSegment($segment);
    if (preg_match(
        '/\b' . preg_quote($matchedSentence, '/') . '\s+(?:He|She)\s+is\s+afebrile\b/i',
        $fullText
    )) {
        $vitals['afebrile'] = true;
    }
    $count = 0;
    foreach (['pulse', 'bp', 'rr', 'spo2', 'temp'] as $k) {
        if ($vitals[$k] !== null && $vitals[$k] !== '') {
            $count++;
        }
    }
    if ($count < 2 && !$vitals['afebrile']) {
        return $matchedSentence;
    }

    return sbaStyleFormatMsaKtObservationLine($possessive . ' ', $vitals) . '.';
}

/**
 * Rewrite “observations show/include/reveal/are …” blocks into direct MS AKT observation lines when parseable.
 */
function repairSbaStyleObservationsShowBlock(string $text): string
{
    if ($text === '') {
        return $text;
    }
    if (!preg_match('/\bobservations?\s+(?:show|include|reveal|are)\b/i', $text)) {
        return $text;
    }

    // Allow decimal temperatures (e.g. 36.5°C) without ending the segment at the decimal point.
    $observationSegment = '((?:(?:[^.]|(?<=\d)\.(?=\d))+)+)';
    $wrapperPatterns = [
        '/\b(His|Her|Their)\s+observations\s+are\s*:?\s*' . $observationSegment . '\./iu',
        '/\b(His|Her|Their)\s+observations\s+(?:show|include|reveal)s?\s+' . $observationSegment . '\./iu',
    ];

    $s = $text;
    foreach ($wrapperPatterns as $pattern) {
        $replaced = preg_replace_callback(
            $pattern,
            static function (array $m) use ($text): string {
                return sbaStyleRewriteObservationWrapperToMsaKtLine($m[1], $m[2], $text, $m[0]);
            },
            $s
        );
        if (is_string($replaced)) {
            $s = $replaced;
        }
    }

    if (!is_string($s)) {
        return $text;
    }

    if (preg_match('/\btemperature\s+is\s+37\.0°C\b/i', $s)) {
        $s = preg_replace('/\s+(?:He|She)\s+is\s+afebrile\./i', '.', $s);
    }

    if (!preg_match('/\bobservations?\s+(?:show|include|reveal|are)\b/i', $s)) {
        return $s;
    }

    $s = preg_replace('/\bvital\s+signs\s+show\s+/i', '', $s);
    $s = preg_replace('/\bobservations\s+are\s*:?\s*/i', '', $s);
    $s = preg_replace('/\bobservations\s+(?:show|include|reveal)s?\s+(?:a|an)\s+/i', '', $s);
    $s = preg_replace('/\bobservations\s+(?:show|include|reveal)s?\s+/i', '', $s);
    $s = repairSbaStyleObservationPhrasingCore($s);

    return $s;
}

/**
 * Apply compact pulse/BP/RR phrasing when multiple vital tokens appear in one sentence (grouped observations).
 */
function repairSbaStyleGroupedObservationInlineRepairs(string $text): string
{
    if ($text === '') {
        return $text;
    }
    $vitalPattern = '/\b(?:temperature|pulse|(?:blood\s+pressure|BP)|respiratory\s+rate|oxygen\s+saturation)\b/i';
    if (preg_match_all($vitalPattern, $text) < 2) {
        return $text;
    }

    return repairSbaStyleObservationPhrasingCore($text);
}

/**
 * Classify a comma-list clause as an MS AKT observation fragment (or null if not safe).
 */
function sbaStyleClassifyObservationClause(string $clause): ?string
{
    $c = trim($clause);
    if ($c === '') {
        return null;
    }
    $c = preg_replace('/^and\s+/i', '', $c) ?? $c;

    if (preg_match('/\btemperature\b/i', $c) || preg_match('/\bafebrile\b/i', $c)) {
        return 'temp';
    }
    if (preg_match('/\bpulse\b/i', $c)) {
        return 'pulse';
    }
    if (preg_match('/\b(?:blood\s+pressure|BP)\b/i', $c)) {
        return 'bp';
    }
    if (preg_match('/\brespiratory\s+rate\b/i', $c)) {
        return 'rr';
    }
    if (preg_match('/\boxygen\s+saturation\b/i', $c)) {
        return 'spo2';
    }

    return null;
}

/**
 * Reorder comma-separated observation clauses (temp → pulse → BP → RR → SpO2) when every clause is a vital.
 */
function repairSbaStyleObservationClauseOrder(string $text): string
{
    if ($text === '' || !preg_match('/,\s*(?:pulse|BP|blood\s+pressure|respiratory\s+rate|oxygen\s+saturation|temperature)/i', $text)) {
        return $text;
    }

    $reordered = preg_replace_callback(
        '/\b(His|Her|Their)\s+((?:(?:[^.]|(?:\d+\.\d+)\s*°?\s*C))+)\./iu',
        static function (array $m): string {
            $listBody = trim($m[2]);
            if ($listBody === '') {
                return $m[0];
            }

            if (substr_count($listBody, ',') < 1 && !preg_match('/\s+and\s+/i', $listBody)) {
                return $m[0];
            }

            if (!preg_match('/\b(?:temperature|pulse|(?:blood\s+pressure|BP)|respiratory\s+rate|oxygen\s+saturation|afebrile)\b/i', $listBody)) {
                return $m[0];
            }

            $parts = preg_split('/\s*,\s*(?:and\s+)?|\s+and\s+/i', $listBody);
            if (!is_array($parts) || count($parts) < 2) {
                return $m[0];
            }

            $classified = [];
            foreach ($parts as $part) {
                $part = trim((string) $part);
                if ($part === '') {
                    return $m[0];
                }
                $type = sbaStyleClassifyObservationClause($part);
                if ($type === null) {
                    return $m[0];
                }
                $classified[] = ['type' => $type, 'text' => $part];
            }

            $types = array_column($classified, 'type');
            if (count($types) !== count(array_unique($types))) {
                return $m[0];
            }

            $canonical = ['temp', 'pulse', 'bp', 'rr', 'spo2'];
            $sorted = $classified;
            usort(
                $sorted,
                static function (array $a, array $b) use ($canonical): int {
                    $posA = array_search($a['type'], $canonical, true);
                    $posB = array_search($b['type'], $canonical, true);

                    return ($posA === false ? 99 : $posA) <=> ($posB === false ? 99 : $posB);
                }
            );

            $originalOrder = array_column($classified, 'type');
            $newOrder = array_column($sorted, 'type');
            if ($originalOrder === $newOrder) {
                return $m[0];
            }

            $formatted = [];
            foreach ($sorted as $idx => $item) {
                $clause = $item['text'];
                if ($idx === count($sorted) - 1 && count($sorted) > 1) {
                    $clause = 'and ' . $clause;
                }
                $formatted[] = $clause;
            }

            return $m[1] . ' ' . implode(', ', $formatted) . '.';
        },
        $text
    );

    return is_string($reordered) ? $reordered : $text;
}

/**
 * Core pulse / BP / RR phrasing (numeric observation context only).
 */
function repairSbaStyleObservationPhrasingCore(string $text): string
{
    if ($text === '') {
        return $text;
    }

    $s = $text;

    // Pulse (require a numeric rate — avoids unrelated “pulse …” phrases)
    $s = preg_replace('/\bpulse\s+rate\s+is\s+(\d+)\s*bpm\b/i', 'pulse $1 bpm', $s);
    $s = preg_replace('/\bpulse\s+rate\s+(\d+)\s*bpm\b/i', 'pulse $1 bpm', $s);
    $s = preg_replace('/\bpulse\s+is\s+(\d+)\s*bpm\b/i', 'pulse $1 bpm', $s);
    $s = preg_replace('/\bpulse\s+is\s+(\d+)\s+beats\s+per\s+minute\b/i', 'pulse $1 bpm', $s);
    $s = preg_replace('/\bpulse\s+of\s+(\d+)\s+beats\s+per\s+minute\b/i', 'pulse $1 bpm', $s);
    $s = preg_replace('/\bpulse\s+of\s+(\d+)\s*bpm\b/i', 'pulse $1 bpm', $s);
    $s = preg_replace('/\bpulse\s+(\d+)\s+beats\s+per\s+minute\b/i', 'pulse $1 bpm', $s);

    // Blood pressure
    $s = preg_replace('/\bblood\s+pressure\s+is\s+(\d+\s*\/\s*\d+)\s*mmHg\b/i', 'BP $1 mmHg', $s);
    $s = preg_replace('/\bblood\s+pressure\s+of\s+(\d+\s*\/\s*\d+)\s*mmHg\b/i', 'BP $1 mmHg', $s);
    $s = preg_replace('/\bBP\s+is\s+(\d+\s*\/\s*\d+)\s*mmHg\b/i', 'BP $1 mmHg', $s);
    $s = preg_replace('/\bblood\s+pressure\s+(\d+\s*\/\s*\d+)\s*mmHg\b/i', 'BP $1 mmHg', $s);

    // Respiratory rate
    $s = preg_replace(
        '/\brespiratory\s+rate\s+is\s+(\d+)\s+breaths\s+per\s+minute\b/i',
        'respiratory rate $1 breaths per minute',
        $s
    );
    $s = preg_replace(
        '/\brespiratory\s+rate\s+of\s+(\d+)\s+breaths\s+per\s+minute\b/i',
        'respiratory rate $1 breaths per minute',
        $s
    );
    $s = preg_replace(
        '/\brespiratory\s+rate\s+(\d+)\s+breaths\s+per\s+minute\b/i',
        'respiratory rate $1 breaths per minute',
        $s
    );

    // Legacy wrappers → direct MS AKT fragments
    $s = preg_replace('/\bvital\s+signs\s+show\s+a\s+pulse\s+of\s+(\d+)\s*(?:beats\s+per\s+minute|bpm)?/i', 'pulse $1 bpm', $s);
    $s = preg_replace(
        '/\bobservations\s+show\s+a\s+blood\s+pressure\s+of\s+(\d+\s*\/\s*\d+)\s*mmHg\b/i',
        'BP $1 mmHg',
        $s
    );

    return $s;
}

/**
 * Observation phrasing normalisation where meaning is unchanged.
 */
function repairSbaStyleObservationPhrasing(string $text): string
{
    if ($text === '') {
        return $text;
    }

    $s = repairSbaStyleObservationsShowBlock($text);
    $s = repairSbaStyleObservationPhrasingCore($s);
    $s = preg_replace('/\bpulse\s+rate\s+is\b/i', 'pulse', $s);
    $s = preg_replace('/\bpulse\s+rate\b/i', 'pulse', $s);
    $s = repairSbaStyleGroupedObservationInlineRepairs($s);
    $s = repairSbaStyleObservationClauseOrder($s);

    return $s;
}

function repairSbaStyleText(string $text): string
{
    $s = $text;

    // "A 40 year old male/female …" → man/woman when that phrase pattern matches
    $s = preg_replace_callback(
        '/\b(a|an)\s+(\d{1,3})\s+(?:-\s*)?year\s*(?:-\s*)?old\s+(male|female)\b/i',
        static function (array $m): string {
            $role = strtolower($m[3]) === 'male' ? 'man' : 'woman';

            return $m[1] . ' ' . $m[2] . ' year old ' . $role;
        },
        $s
    );

    $s = repairSbaStyleDehyphenateAdjectivalAge($s);

    $s = preg_replace('/\bcomplains\s+of\b/i', 'has', $s);
    $s = preg_replace('/presents\s+with\b/i', 'has', $s);
    $s = preg_replace('/presents\s+to\b/i', 'attends', $s);
    $s = preg_replace('/\bcomplete\s+blood\s+count\b/i', 'full blood count', $s);
    $s = preg_replace('/\bheart\s+rate\b(?!\s+variability\b)(?!\s+zones?\b)(?!\s+monitor(?:ing)?\b)/i', 'pulse', $s);
    $s = repairSbaStyleObservationPhrasing($s);
    $s = repairSbaStyleOxygenSaturationPhrasing($s);
    $s = repairSbaStylePastMedicalHistoryPhrasing($s);
    $s = repairSbaStyleImagingTerminology($s);
    $s = repairSbaStyleUkGiAndHaematologySpelling($s);
    $s = repairSbaStyleBloodGasNotation($s);
    $s = repairSbaStyleDoubleHasClause($s);
    $s = repairSbaStyleAffectLabels($s);
    $s = preg_replace('/\bprioritized\b/i', 'prioritised', $s);
    $s = preg_replace('/\bhospitalized\b/i', 'hospitalised', $s);
    $s = preg_replace('/\brecognized\b/i', 'recognised', $s);
    $s = preg_replace('/\bhypovolemic\b/i', 'hypovolaemic', $s);
    $s = preg_replace('/\bis\s+currently\s+taking\b/i', 'takes', $s);
    $s = preg_replace('/\bhas\s+a\s+history\s+of\s+hypertension\b/i', 'has hypertension', $s);
    $s = preg_replace('/\bhas\s+a\s+history\s+of\s+asthma\b/i', 'has asthma', $s);
    $s = preg_replace('/\bwith\s+a\s+history\s+of\s+hypertension\b/i', 'with hypertension', $s);
    $s = preg_replace('/\bwith\s+a\s+history\s+of\s+asthma\b/i', 'with asthma', $s);
    $s = preg_replace('/\bwith\s+a\s+history\s+of\s+type\s+2\s+diabetes\s+mellitus\b/i', 'with type 2 diabetes mellitus', $s);
    $s = preg_replace('/\bhas\s+a\s+history\s+of\s+alcohol\s+use\s+disorder\b/i', 'has alcohol use disorder', $s);
    $s = preg_replace('/\bhas been diagnosed with primary hypothyroidism\b/i', 'has primary hypothyroidism', $s);
    $s = preg_replace('/\bhas been started on levothyroxine therapy\b/i', 'started levothyroxine', $s);
    $s = preg_replace('/\bno other significant medical history\b/i', 'no other medical conditions', $s);
    $s = preg_replace('/\badmits\s+to\b/i', 'reports', $s);
    $s = preg_replace("/\bMurphy(['\x{2019}])s\s+sign\b/iu", 'Murphy sign', $s);
    $s = preg_replace('/\bbreaths\s*\/\s*min\b/i', 'breaths per minute', $s);
    $s = preg_replace('/\bischemic\b/i', 'ischaemic', $s);
    $s = preg_replace('/\bischemia\b/i', 'ischaemia', $s);
    $s = preg_replace('/\bis\s+not\s+on\s+any\s+medications?\b/i', 'takes no regular medication', $s);

    // Remove formulaic "On examination," / "On examination"
    $s = preg_replace('/On examination,\s*/i', '', $s);
    $s = preg_replace('/On examination\b\s*/i', '', $s);

    $s = repairSbaStyleRemoveNormalRangeBeforeParen($s);
    $s = repairSbaStyleNumericLabReferenceHyphensToEnDash($s);
    $s = repairSbaStyleDoseUnitSpacing($s);
    $s = repairSbaStyleSpacesBeforePunctuation($s);

    $s = preg_replace('/\s+/u', ' ', $s);

    return trim($s);
}

/**
 * Remove stray spaces immediately before common sentence punctuation (e.g. “sign .” → “sign.”).
 */
function repairSbaStyleSpacesBeforePunctuation(string $text): string
{
    if ($text === '' || !preg_match('/\s+[.,;:!?]/u', $text)) {
        return $text;
    }

    // Do not collapse when a digit immediately precedes the space (e.g. spaced decimals).
    return (string) preg_replace('/(?<!\d)\s+([.,;:!?]+)/u', '$1', $text);
}

/**
 * Sentence-internal UK lab prose: "Free T4" → "free T4" after lowercase / punctuation
 * or after "and|or|with|plus". Applied to stem and justification only (not options).
 */
function repairSbaStyleSentenceCaseFreeT4InProse(string $text): string
{
    if ($text === '' || stripos($text, 'Free T4') === false) {
        return $text;
    }
    $s = (string) preg_replace('/(?<=[a-z,;:])\s+Free T4\b/u', ' free T4', $text);
    $s = (string) preg_replace('/\b(and|or|with|plus)\s+Free T4\b/iu', '$1 free T4', $s);

    return $s;
}

/**
 * Drop redundant “normal range” before parenthetical reference intervals (safe wording-only).
 */
function repairSbaStyleRemoveNormalRangeBeforeParen(string $text): string
{
    if ($text === '' || stripos($text, 'normal range') === false) {
        return $text;
    }

    $s = (string) preg_replace('/\bnormal\s+range\s*:?\s*(?=\()/iu', '', $text);
    $s = preg_replace('/\s+/u', ' ', $s);

    return trim($s);
}

/**
 * Prefer en dash (–) between numeric lab reference bounds when followed by a unit (conservative).
 */
function repairSbaStyleNumericLabReferenceHyphensToEnDash(string $text): string
{
    if ($text === '' || !preg_match('/\d\s*-\s*\d/', $text)) {
        return $text;
    }

    $s = $text;
    $unit = '(?:U\/\s*L|IU\/\s*L|u\/\s*l|mmol\/\s*l|mmol\/L|µmol\/\s*L|μmol\/\s*L|g\/\s*L|mg\/\s*L|ng\/\s*L|mEq\/\s*L|×\s*10\^?9\/L|x\s*10\^?9\/L)';
    $s = (string) preg_replace(
        '/\(\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*\)\s*(' . $unit . ')\b/iu',
        '($1–$2) $3',
        $s
    );
    $s = (string) preg_replace(
        '/\b(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*(' . $unit . ')\b/iu',
        '$1–$2 $3',
        $s
    );

    return $s;
}

/**
 * Space between dose number and common mass/volume/molar units when concatenated (e.g. “15g” → “15 g”).
 */
function repairSbaStyleDoseUnitSpacing(string $text): string
{
    if ($text === '') {
        return $text;
    }

    return (string) preg_replace(
        '/\b(\d+(?:\.\d+)?)(mmol|mL|ml|mg|g|L)\b/iu',
        '$1 $2',
        $text
    );
}

/**
 * Title-only safe phrase fixes (no broad rewriting).
 */
function repairSbaStyleTitleSafePhrases(string $title): string
{
    if ($title === '') {
        return $title;
    }

    $s = $title;
    $s = preg_replace('/\bin\s+Emergency\s+Department\b/', 'in the emergency department', $s);
    $s = preg_replace('/\bin\s+emergency\s+department\b/i', 'in the emergency department', $s);
    $s = preg_replace('/\s+/u', ' ', $s);

    return trim($s);
}

/**
 * Alphabetise a simple two-drug “X and Y” span in the stem when both tokens are known generic names (conservative).
 */
function repairSbaStyleStemMedicationAndChainSort(string $stem): string
{
    if ($stem === '' || stripos($stem, ' and ') === false) {
        return $stem;
    }

    $known = [
        'metformin', 'aspirin', 'bisoprolol', 'ramipril', 'atorvastatin', 'simvastatin', 'amlodipine',
        'gliclazide', 'omeprazole', 'salbutamol', 'prednisolone', 'furosemide', 'spironolactone', 'warfarin',
        'levothyroxine', 'apixaban', 'rivaroxaban', 'clopidogrel', 'digoxin', 'atenolol', 'diltiazem',
    ];
    $quoted = array_map(static function (string $w): string {
        return preg_quote($w, '/');
    }, $known);
    $alt = implode('|', $quoted);
    $pattern = '/\b(' . $alt . ')\s+and\s+(' . $alt . ')\b/i';

    return (string) preg_replace_callback(
        $pattern,
        static function (array $m): string {
            $a = mb_strtolower($m[1]);
            $b = mb_strtolower($m[2]);
            if (strcmp($a, $b) <= 0) {
                return $m[0];
            }

            return $m[2] . ' and ' . $m[1];
        },
        $stem
    );
}

/**
 * Strip redundant “levels” from short investigation-style option lines (conservative).
 */
function repairSbaStyleOptionRedundantLevels(string $text): string
{
    if ($text === '' || !preg_match('/\slevels?\b/i', $text)) {
        return $text;
    }
    if (!sbaStyleOptionTextQualifiesForInvestigationLevelsHeuristic($text)) {
        return $text;
    }

    $s = preg_replace('/\s+levels?\b/i', '', $text);
    $s = preg_replace('/\s+/u', ' ', $s);

    return trim($s);
}

/**
 * Short option text that names a common blood/urine investigation and includes “levels”.
 */
function sbaStyleOptionTextQualifiesForInvestigationLevelsHeuristic(string $text): bool
{
    if (sbaStyleWordCount($text) > 14) {
        return false;
    }

    return (bool) preg_match(sbaStyleUkInvestigationLabishRegex(), $text);
}

/**
 * Biomarker / investigation token alternation for UK “levels” heuristic (repair + warnings).
 */
function sbaStyleUkInvestigationMarkerAlternation(): string
{
    return '(?:cardiac\s+)?troponin|high[\s-]sensitivity\s+troponin|hs[\s-]?troponin|ferritin|'
        . 'nt[\s\-]*pro[\s\-]*bnp|\bbnp\b|d[\s\-]?dimer|\bcrp\b|c[\s-]?reactive\s+protein|'
        . 'hba1c|hba\s*1c|haemoglobin\s+a1c|glycated\s+haemoglobin|procalcitonin|natriuretic|'
        . 'creatinine|(?:serum\s+)?urea|\begfr\b|lactate|amylase|lipase|bilirubin|'
        . 'alkaline\s+phosphatase|\balt\b|\balp\b|\bggt\b|\binr\b|\bpt\b|\baptt\b|'
        . 'potassium|sodium|calcium|magnesium|phosphate|urate|triglyceride|cholesterol|\bldl\b|\bhdl\b|'
        . 'psa|\bcea\b|vitamin\s+b12|folate|iron|transferrin|tsh|\bft3\b|\bft4\b|cortisol|\bhcg\b|'
        . 'c[\s-]?peptide|beta[\s-]?hcg';
}

/**
 * Biomarker / investigation tokens for UK “levels” heuristic (repair + warnings).
 */
function sbaStyleUkInvestigationLabishRegex(): string
{
    return '/\b(?:' . sbaStyleUkInvestigationMarkerAlternation() . ')\b/i';
}

/**
 * Strip redundant “levels” after amylase / lipase in narrative fields (conservative).
 */
function repairSbaStyleStripAmylaseLipaseLevelsPhrase(string $text): string
{
    if ($text === '' || !preg_match('/\b(amylase|lipase)\b/i', $text)) {
        return $text;
    }

    $s = preg_replace('/\b(serum|plasma)\s+(amylase|lipase)\s+levels?\b/i', '$1 $2', $text);
    $s = preg_replace('/\b(amylase|lipase)\s+levels?\b/i', '$1', $s);
    $s = preg_replace('/\s+/u', ' ', $s);

    return trim($s);
}

/**
 * First-match byte offsets for MS AKT observation ordering checks (pulse includes heart rate).
 *
 * @return array<string, int>
 */
function sbaStyleStemObservationFirstOffsets(string $stem): array
{
    $patterns = [
        'temp' => '/\b(temperature|\btemp\b|\d+\.?\d*\s*°\s*c)\b/i',
        'pulse' => '/\b(?:pulse|heart\s+rate)\b/i',
        'bp' => '/\b(blood pressure|\d{2,3}\s*\/\s*\d{2,3}\s*mm\s*hg)\b/i',
        'rr' => '/\b(respiratory rate|\brr\b)\b/i',
        'spo2' => '/\b(oxygen saturation|spo2|spo\s*2)\b/i',
    ];

    $found = [];
    foreach ($patterns as $key => $regex) {
        if (preg_match($regex, $stem, $m, PREG_OFFSET_CAPTURE)) {
            $found[$key] = (int) $m[0][1];
        }
    }

    return $found;
}

/**
 * Capitalise sentence starts after repairs: field opening and lowercase she/he/they after . ? !
 */
function repairSbaStyleNormalizeSentenceStarts(string $text): string
{
    $s = $text;
    if ($s === '') {
        return '';
    }

    // Capitalise first character if it is a lowercase letter (Unicode-aware).
    if (preg_match('/^(\s*)(\p{Ll})/u', $s, $m)) {
        $take = mb_strlen($m[1] . $m[2]);
        $s = $m[1] . mb_strtoupper($m[2]) . mb_substr($s, $take);
    }

    // After sentence-ending punctuation, capitalise lowercase she / he / they.
    $s = preg_replace_callback(
        '/([.!?])(\s+)(she|he|they)\b/iu',
        static function (array $m): string {
            return $m[1] . $m[2] . ucfirst(mb_strtolower($m[3]));
        },
        $s
    );

    // After . ? ! + whitespace, capitalise the next word if it starts with a lowercase letter (e.g. ". his" → ". His").
    $s = preg_replace_callback(
        '/(?<=[\p{L}\p{N}])([.!?])(\s+)(\p{Ll})([\p{Ll}\'\-]*)\b/u',
        static function (array $m): string {
            return $m[1] . $m[2] . mb_strtoupper($m[3]) . $m[4];
        },
        $s
    );

    return $s;
}

/**
 * Distractor quality and option-set heuristics (warnings only).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsDistractorQuality(array $question): array
{
    $warnings = [];
    $warnings = array_merge($warnings, sbaStyleDistractorHomogeneityAndLength($question));
    $warnings = array_merge($warnings, sbaStyleDistractorWeakEscalation($question));
    $warnings = array_merge($warnings, sbaStyleDistractorLeadInTemplates($question));
    $warnings = array_merge($warnings, sbaStyleDistractorOptionSimilarity($question));

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleDistractorHomogeneityAndLength(array $question): array
{
    $warnings = [];
    $opts = sbaStyleOptionTexts($question);
    if (count($opts) < 3) {
        return $warnings;
    }

    $relaxLength = function_exists('sbaPrescribingShouldRelaxOptionSimilarityAndLengthWarnings')
        && sbaPrescribingShouldRelaxOptionSimilarityAndLengthWarnings($question);

    $types = [];
    foreach ($opts as $key => $text) {
        $types[$key] = sbaStyleClassifyOptionLine($text);
    }

    $meaningful = array_filter($types, static function ($t) {
        return $t !== 'other';
    });
    $unique = array_unique(array_values($meaningful));
    if (count($unique) >= 2) {
        $warnings[] = [
            'code' => 'options.mixed_categories',
            'severity' => 'warning',
            'message' => 'Options may mix different task types (e.g. diagnosis vs investigation vs treatment); keep all five options homogeneous for the lead-in.',
            'field' => null,
        ];
    }

    if (!$relaxLength && count($opts) === 5) {
        $lengths = [];
        foreach ($opts as $key => $text) {
            $lengths[$key] = sbaStyleWordCount($text);
        }
        $nonZero = array_filter($lengths, static fn ($n) => $n > 0);
        if (count($nonZero) === 5) {
            $min = min($nonZero);
            $max = max($nonZero);
            if ($min > 0 && ($max / $min) >= 2.5) {
                $warnings[] = [
                    'code' => 'options.length_imbalance',
                    'severity' => 'warning',
                    'message' => 'One option is much longer than others by word count; consider balancing option length for fairness.',
                    'field' => null,
                ];
            }
        }
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleDistractorWeakEscalation(array $question): array
{
    $warnings = [];
    $blob = mb_strtolower(sbaStyleCombinedBodyText($question));
    if ($blob === '') {
        return $warnings;
    }

    if (preg_match('/\bcpap\b/i', $blob) && preg_match('/\basthma\b/i', $blob) && preg_match('/\b(mild|moderate)\b/i', $blob)) {
        $warnings[] = [
            'code' => 'distractor.escalation_cpap_asthma',
            'severity' => 'warning',
            'message' => 'Heuristic: CPAP alongside mild/moderate asthma may be an implausible escalation — verify clinical proportionality.',
            'field' => null,
        ];
    }

    if (preg_match('/\bemergency\s+surgery\b/i', $blob) && preg_match('/\bmild\b/i', $blob)) {
        $warnings[] = [
            'code' => 'distractor.escalation_emergency_surgery',
            'severity' => 'warning',
            'message' => 'Heuristic: emergency surgery combined with a mild presentation may be disproportionate — check distractor plausibility.',
            'field' => null,
        ];
    }

    if ((preg_match('/\bicu\b/i', $blob) || preg_match('/\bintensive\s+care\b/i', $blob))
        && preg_match('/\bstable\b/i', $blob)
        && preg_match('/\badmission\b/i', $blob)) {
        $warnings[] = [
            'code' => 'distractor.escalation_icu_stable',
            'severity' => 'warning',
            'message' => 'Heuristic: ICU admission in an otherwise stable picture may be an implausible default — verify the vignette supports escalation.',
            'field' => null,
        ];
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleDistractorLeadInTemplates(array $question): array
{
    $warnings = [];
    $leadIn = isset($question['lead_in']) ? mb_strtolower((string) $question['lead_in']) : '';
    if ($leadIn === '') {
        return $warnings;
    }

    $phrases = [
        'safest immediate management step',
        'most appropriate immediate management',
        'best immediate treatment',
    ];

    foreach ($phrases as $phrase) {
        if (substr_count($leadIn, $phrase) < 1) {
            continue;
        }
        $slug = preg_replace('/[^a-z0-9]+/', '_', $phrase);
        $slug = trim($slug, '_');
        $warnings[] = [
            'code' => 'lead_in.template_ai.' . $slug,
            'severity' => 'warning',
            'message' => 'Lead-in contains a stock AI-style phrase ("' . $phrase . '"); prefer more varied, examination-specific wording.',
            'field' => 'lead_in',
        ];
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleDistractorOptionSimilarity(array $question): array
{
    $warnings = [];
    $opts = sbaStyleOptionTexts($question);
    $similarityThreshold = 70.0;
    if (function_exists('sbaPrescribingInferLearningPoint')) {
        $point = sbaPrescribingInferLearningPoint($question);
        if ($point === 'drug_selection'
            && function_exists('sbaPrescribingCountOptionsWithDoseOrFrequency')
            && sbaPrescribingCountOptionsWithDoseOrFrequency($opts) >= 3) {
            return $warnings;
        }
        if (function_exists('sbaPrescribingLearningPointRequiresStructuredRegimens')
            && sbaPrescribingLearningPointRequiresStructuredRegimens($point)) {
            $similarityThreshold = 82.0;
        }
    }
    $keys = array_keys($opts);
    $n = count($keys);
    for ($i = 0; $i < $n; $i++) {
        for ($j = $i + 1; $j < $n; $j++) {
            $ki = $keys[$i];
            $kj = $keys[$j];
            $a = sbaStyleNormalizeForSimilarity($opts[$ki]);
            $b = sbaStyleNormalizeForSimilarity($opts[$kj]);
            if ($a === '' || $b === '') {
                continue;
            }
            similar_text($a, $b, $pct);
            if ($pct > $similarityThreshold) {
                $pair = strcmp($ki, $kj) < 0 ? ($ki . '_' . $kj) : ($kj . '_' . $ki);
                $warnings[] = [
                    'code' => 'options.high_similarity.' . $pair,
                    'severity' => 'warning',
                    'message' => "Options \"{$ki}\" and \"{$kj}\" are very similar (~" . round($pct, 0) . '%); risk of duplication or overlapping meaning.',
                    'field' => null,
                ];
            }
        }
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return array<string, string>
 */
function sbaStyleOptionTexts(array $question): array
{
    $out = [];
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $key) {
        if (!isset($question[$key])) {
            continue;
        }
        $t = trim((string) $question[$key]);
        if ($t !== '') {
            $out[$key] = $t;
        }
    }

    return $out;
}

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

    return trim((string) $t);
}

/**
 * Coarse line classifier for option homogeneity checks (heuristic).
 */
function sbaStyleClassifyOptionLine(string $text): string
{
    $o = mb_strtolower($text);

    $inv = 0;
    if (preg_match('/\b(x-?ray|ct\s|ct\s+scan|mri|ultrasound|echo(?:cardiogram)?|ecg|ekg|troponin|fbc|full\s+blood|blood\s+culture|lumbar\s+puncture|biopsy|endoscop|colonoscopy|sigmoidoscopy|spirometry|peak\s+flow|arterial\s+blood|abg)\b/i', $o)) {
        $inv++;
    }

    $tx = 0;
    if (preg_match('/\b(surgery|operation|resection|transplant|prescribe|prescription|start\s+\w+|give\s+\w+|intravenous|^\s*iv\s|oxygen|nasal\s+cannul|face\s+mask|non-?invasive\s+vent|niv\b|cpap|bipap|intubat|mechanical\s+ventilat|admission|discharge|refer\s+to|antibiotics|corticosteroid|insulin\s+sliding)\b/i', $o)) {
        $tx++;
    }

    $dx = 0;
    if (preg_match('/\b(diagnosis|most\s+likely|underlying\s+cause|cause\s+of|type\s+of\s+\w+\s+shock|identified\s+as)\b/i', $o)) {
        $dx++;
    }

    $scores = ['investigation' => $inv, 'treatment' => $tx, 'diagnosis' => $dx];
    $max = max($scores);
    if ($max < 1) {
        return 'other';
    }

    $tops = array_keys(array_filter($scores, static fn ($v) => $v === $max));

    return $tops[0];
}

/**
 * Lead-in / stem / option consistency for investigation ordering (warnings only; ACS heuristics).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsInvestigationSequencing(array $question): array
{
    $highPriority = array_merge(
        sbaStyleWarningsAcsInvestigationTroponinSequencingHigh($question),
        sbaStyleWarningsAcsInvestigationTroponinKeyWithoutEcGStemHigh($question),
        sbaStyleWarningsAcsEcgInitialContradictionHighPriority($question),
        sbaStyleWarningsAcsInvestigationEcgKeyStemEcGShowsOrInitialPhrasesHigh($question),
        sbaStyleWarningsAcsInvestigationInitialLeadInStemEcGButNonEcGKeyHigh($question)
    );
    $stemKeyed = sbaStyleWarningsStemResultMatchesKeyedInvestigation($question);

    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';

    if ($stem !== '' && preg_match('/\bsome\s+non[\s-]?specific\b/i', $stem)) {
        $warnings[] = [
            'code' => 'wording.some_non_specific',
            'severity' => 'warning',
            'message' => 'Stem uses informal “some non-specific …”; prefer precise wording (e.g. “non-specific ST-segment changes”).',
            'field' => 'stem',
        ];
    }

    if ($leadIn !== '' && preg_match('/\bsome\s+non[\s-]?specific\b/i', $leadIn)) {
        $warnings[] = [
            'code' => 'wording.some_non_specific',
            'severity' => 'warning',
            'message' => 'Lead-in uses informal “some non-specific …”; prefer precise clinical wording.',
            'field' => 'lead_in',
        ];
    }

    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $key) {
        if (!isset($question[$key])) {
            continue;
        }
        $t = trim((string) $question[$key]);
        if ($t !== '' && preg_match('/\bsome\s+non[\s-]?specific\b/i', $t)) {
            $warnings[] = [
                'code' => 'wording.some_non_specific',
                'severity' => 'warning',
                'message' => 'Informal “some non-specific …”; prefer precise clinical wording.',
                'field' => $key,
            ];
        }
    }

    if ($leadIn !== '' && sbaStyleLeadInAsksInitialInvestigation($leadIn)) {
        $acsEcGInitialHigh = sbaStyleInvestigationHasAcsEcGInitialSequencingHigh($question);
        if ($stem !== '' && sbaStyleStemSuggestsEcGFindingsReported($stem) && !$acsEcGInitialHigh) {
            $warnings[] = [
                'code' => 'sequencing.initial_investigation_ecg_in_stem',
                'severity' => 'warning',
                'message' => 'Lead-in asks for an initial investigation while the stem already appears to report ECG findings; omit ECG results from the stem or switch the lead-in to the next investigation (often cardiac troponin in suspected ACS).',
                'field' => 'lead_in',
            ];
        }
        if ($stem !== '' && preg_match('/\becg\s+shows\b/i', $stem) && !$acsEcGInitialHigh) {
            $warnings[] = [
                'code' => 'sequencing.ecg_shows_initial_lead_in',
                'severity' => 'warning',
                'message' => 'Stem includes “ECG shows …” but the lead-in still asks for an initial investigation; rephrase the lead-in to “next” / further investigation or remove ECG results from the stem.',
                'field' => 'lead_in',
            ];
        }
    }

    $warnings = array_merge($warnings, sbaStyleWarningsShortInvestigationOptionOrder($question));
    $warnings = array_merge($warnings, sbaStyleWarningsInvestigationOptionSentenceCase($question));
    $warnings = array_merge($warnings, sbaStyleWarningsAcsWeakScreeningDistractors($question));
    $warnings = array_merge($warnings, sbaStyleWarningsEcGOptionLabelDuplication($question));
    $warnings = array_merge($warnings, sbaStyleWarningsGeneralInvestigationSequencingGuards($question));

    return array_merge($highPriority, sbaStyleWarningsAcsEcGArrangedSequencing($question), $stemKeyed, $warnings);
}

/**
 * Pattern B requires an explicit ECG result in the stem (not pending / arranged / performed-without-result prose).
 */
function sbaStyleStemHasExplicitEcGResultForAcsInvestigationPatternB(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return sbaStyleStemContainsEcGShowsElectrogramShowsOrInitialEcG($stem)
        || sbaStyleStemSuggestsEcGFindingsReported($stem);
}

/**
 * ECG narrated as arranged, pending, performed, or completed without an interpretable result in the stem.
 */
function sbaStyleStemEcGPerformedOrPendingWithoutExplicitResult(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (sbaStyleStemHasExplicitEcGResultForAcsInvestigationPatternB($stem)) {
        return false;
    }
    if (sbaStyleStemContainsEcGIsArranged($stem)) {
        return true;
    }
    if (preg_match('/\b(?:ecg|electrocardiogram|12[\s-]?lead(?:\s+ecg)?)\s+is\s+pending\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\b(?:ecg|electrocardiogram)\s+(?:has\s+been\s+)?arranged\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\b(?:ecg|electrocardiogram)\b.{0,100}\b(?:pending|awaiting\s+report|not\s+yet\s+available|results?\s+(?:are\s+)?not\s+(?:yet\s+)?available|report\s+not\s+yet|unavailable)\b/i', $stem)) {
        return true;
    }
    if (preg_match(
        '/\b(?:an\s+)?(?:ecg|electrocardiogram)\s+(?:is\s+|was\s+)?(?:performed|done|completed|recorded|carried\s+out)\b.{0,80}\b(?:but|however|although)\b.{0,60}\b(?:results?|report|findings?)\b.{0,50}\b(?:not\s+yet|unavailable|pending|awaiting|outstanding)\b/i',
        $stem
    )) {
        return true;
    }
    if (preg_match('/\b(?:ecg|electrocardiogram)\s+(?:was\s+)?(?:performed|done|completed|recorded)\b/i', $stem)) {
        return true;
    }

    return false;
}

/**
 * @return list<array{code: string, severity: 'high', message: string, field: ?string}>
 */
function sbaStyleWarningsAcsInvestigationTroponinSequencingHigh(array $question): array
{
    if (!sbaStyleFormQuestionAcsClinicalContext($question)) {
        return [];
    }
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!sbaStyleSkillIsInvestigationUk($sk)) {
        return [];
    }
    $stem = trim((string) ($question['stem'] ?? ''));
    $leadIn = trim((string) ($question['lead_in'] ?? ''));
    if ($stem === '' || $leadIn === '') {
        return [];
    }
    $correctBlob = sbaStyleCorrectAnswerCombinedText($question);
    if ($correctBlob === '' || !preg_match('/\b(?:cardiac\s+)?troponin|hs[\s-]?cTn|high[\s-]sensitivity\s+troponin\b/i', $correctBlob)) {
        return [];
    }

    $warnings = [];

    if (sbaStyleStemEcGPerformedOrPendingWithoutExplicitResult($stem)) {
        $warnings[] = [
            'code' => 'acs.investigation_troponin_ecg_pending_or_unavailable',
            'severity' => 'high',
            'message' => '[High priority] ACS Investigation: the stem says an ECG is **arranged**, **pending**, **performed**, or **completed** but **does not give an ECG result** while the keyed answer is **troponin** — this is not a clean **next investigation** pattern. Either use **Pattern A** (no ECG in stem + **initial** investigation + **12-lead ECG** key) or **Pattern B** (explicit ECG result in stem, e.g. “ECG shows no ST-segment elevation”, + **next** investigation + troponin key). Do **not** use ambiguous wording such as “ECG performed but results not yet available”.',
            'field' => 'stem',
        ];
    }

    if (!sbaStyleStemHasExplicitEcGResultForAcsInvestigationPatternB($stem)) {
        $warnings[] = [
            'code' => 'acs.investigation_troponin_without_explicit_ecg_result',
            'severity' => 'high',
            'message' => '[High priority] ACS Investigation: the keyed answer is **troponin** but the stem does not document an **explicit ECG result** (e.g. “ECG shows …”, “non-specific ST-segment changes”, “no ST-segment elevation”). Use **Pattern A** (no ECG in stem → **initial** investigation → **12-lead ECG** key) or add a clear ECG read-out and a **next investigation** lead-in for troponin (**Pattern B**).',
            'field' => 'correct_answer',
        ];
    }

    return $warnings;
}

function sbaStyleWarningsAcsInvestigationTroponinKeyWithoutEcGStemHigh(array $question): array
{
    if (!sbaStyleFormQuestionAcsClinicalContext($question)) {
        return [];
    }
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question["_current_skill"] ?? '')));
    if (!sbaStyleSkillIsInvestigationUk($sk)) {
        return [];
    }
    $stem = trim((string) ($question['stem'] ?? ''));
    $leadIn = trim((string) ($question['lead_in'] ?? ''));
    if ($stem === '' || $leadIn === '') {
        return [];
    }
    if (!sbaStyleLeadInAsksInvestigationSelectionInitialOrNext($leadIn)) {
        return [];
    }
    if (sbaStyleStemHasExplicitEcGResultForAcsInvestigationPatternB($stem)
        || sbaStyleStemEcGPerformedOrPendingWithoutExplicitResult($stem)) {
        return [];
    }
    $correctBlob = sbaStyleCorrectAnswerCombinedText($question);
    if ($correctBlob === '' || !preg_match('/\b(?:cardiac\s+)?troponin|hs[\s-]?cTn|high[\s-]sensitivity\s+troponin\b/i', $correctBlob)) {
        return [];
    }

    return [[
        'code' => 'acs.investigation_troponin_key_without_ecg_in_stem',
        'severity' => 'high',
        'message' => '[High priority] ACS Investigation: the stem has not yet documented an ECG as performed, arranged, or interpreted, but the keyed answer is troponin. In suspected ACS the first-line investigation is usually a 12-lead ECG unless the stem already reports ECG findings and the lead-in asks for the next investigation. Do not imply ECG can be deferred until after troponin when no ECG has been done; align stem, lead-in, and key.',
        'field' => 'correct_answer',
    ]];
}

function sbaStyleLeadInAsksInitialInvestigation(string $leadIn): bool
{
    if (preg_match('/\binitial\s+investigation\b/i', $leadIn)) {
        return true;
    }
    if (preg_match('/\bfirst\s+investigation\b/i', $leadIn)) {
        return true;
    }
    if (preg_match('/\bmost\s+appropriate\s+initial\s+(investigation|test)\b/i', $leadIn)) {
        return true;
    }
    if (preg_match('/\bappropriate\s+initial\s+investigation\b/i', $leadIn)) {
        return true;
    }

    return false;
}

/** Current skill is Investigation or Investigation selection (form generator). */
function sbaStyleSkillIsInvestigationUk(string $sk): bool
{
    return (bool) preg_match('/^investigation(?:\s+selection)?$/i', trim(preg_replace('/\s+/u', ' ', $sk)));
}

function sbaStyleLeadInContainsInitialInvestigationPhrase(string $leadIn): bool
{
    return (bool) preg_match('/\binitial\s+investigation\b/i', $leadIn);
}

function sbaStyleLeadInAsksInvestigationSelectionInitialOrNext(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }
    if (sbaStyleLeadInAsksInitialInvestigation($leadIn)) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:next|further)\s+(?:investigation|test)\b|\b(?:which|what)\s+(?:investigation|test)\b/i',
        $leadIn
    );
}

function sbaStyleStemDocumentsAnyTroponinResult(string $stem): bool
{
    if ($stem === '' || !preg_match('/\b(?:cardiac\s+)?troponin|hs[\s-]?cTn|high[\s-]sensitivity\s+cTn|cTnI|cTnT\b/i', $stem)) {
        return false;
    }
    if (sbaStyleStemSuggestsRaisedOrPositiveTroponin($stem)) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:troponin|hs[\s-]?cTn|cTnI|cTnT)\b.{0,90}\b(?:normal|not\s+(?:elevated|raised)|negative|undetectable)\b|\b(?:normal|not\s+(?:elevated|raised)|negative|undetectable)\b.{0,90}\b(?:troponin|hs[\s-]?cTn|cTnI|cTnT)\b/i',
        $stem
    );
}

function sbaStyleTextLooksLikeTroponinTestOrderOrAssay(string $text): bool
{
    if ($text === '') {
        return false;
    }
    $l = mb_strtolower($text);
    if (!preg_match('/\b(?:cardiac\s+)?troponin|hs[\s-]?cTn|high[\s-]sensitivity\s+troponin\b/i', $l)) {
        return false;
    }
    if (preg_match('/\b(?:request|measure|check|send|take|obtain|arrange|book|repeat|serial|urgent)\b.{0,50}\b(?:troponin|hs[\s-]?cTn)\b|\b(?:troponin|hs[\s-]?cTn)\b.{0,40}\b(?:assay|measurement|levels?\s+(?:are|is|were|will)|testing)\b/i', $l)) {
        return true;
    }
    if (preg_match('/^(?:[a-e]\s*[\.)]\s*)?(?:serial\s+)?(?:high[\s-]?sensitivity\s+)?(?:cardiac\s+)?troponin\b/i', trim($text))) {
        return true;
    }

    return false;
}

function sbaStyleStemReportsUrinePregnancyTestNegativeForSequencing(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:urine|point[\s-]of[\s-]care)\s+pregnancy\s+test\s+(?:is|was)\s+(?:negative|not\s+positive|neg\b)|\bpregnancy\s+test\s+(?:is|was)\s+(?:negative|neg\b)/iu',
        $stem
    );
}

function sbaStyleStemMentionsAmenorrhoeaContext(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return (bool) preg_match(
        '/\bamenorrhoea\b|\bamenorrhea\b|\babsent\s+(?:periods|menses|menstruation|periods\s+for)\b|\bno\s+(?:period|periods|menses)\b|\boligomenorrhoea\b|\boligomenorrhea\b|\bsecondary\s+amenorrhoea\b|\bhas\s+not\s+had\s+a\s+period\b/iu',
        $stem
    );
}

function sbaStyleStemReportsObviousFirstLineInvestigationResultGeneral(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (preg_match('/\b(?:urine|point[\s-]of[\s-]care)\s+pregnancy\s+test\s+(?:is|was)\s+(?:positive|\+)\b/iu', $stem)) {
        return true;
    }
    if (preg_match('/\bpregnancy\s+test\s+(?:is|was)\s+(?:positive|\+)\b/iu', $stem)) {
        return true;
    }
    if (preg_match('/\becg\s+(?:shows|demonstrates|reveals)\b/iu', $stem)) {
        return true;
    }
    if (preg_match('/\b(?:the\s+)?ecg\s+(?:is|was)\s+(?:normal|sinus|abnormal|unchanged)\b/iu', $stem)) {
        return true;
    }
    if (preg_match('/\bchest\s+x-?ray\s+(?:shows|reports|demonstrates|reveals)\b/iu', $stem)) {
        return true;
    }
    if (preg_match('/\bct\s+(?:head|brain|chest)\s+(?:shows|reports|demonstrates|reveals)\b/iu', $stem)) {
        return true;
    }
    if (preg_match('/\b(?:lumbar\s+puncture|LP)\b.{0,48}\b(?:shows|opening\s+pressure|(?:csf|CSF)\b)/iu', $stem)) {
        return true;
    }
    if (preg_match('/\burinalysis\s+(?:shows|reveals|demonstrates|is)\b/iu', $stem)) {
        return true;
    }
    if (preg_match('/\burine\s+dipstick\b.{0,60}(?:positive|nitrite|leukocyte|blood|\+)/iu', $stem)) {
        return true;
    }
    if (preg_match('/\bdipstick\b.{0,40}(?:positive|nitrite|leukocyte)/iu', $stem)) {
        return true;
    }

    return false;
}

function sbaStyleLeadInAsksNextOrFurtherInvestigation(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:next|further|subsequent|second[\s-]line|additional)\s+(?:investigation|test|step)\b|\bmost\s+appropriate\s+next\s+(?:investigation|test)\b|\bappropriate\s+next\s+investigation\b/iu',
        $leadIn
    );
}

function sbaStyleStemSuggestsMigraineAuraGiveawayCluster(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    $s = mb_strtolower($stem);
    $head = (bool) (preg_match('/\b(?:unilateral|one[\s-]sided)\b/', $s) || preg_match('/\bthrobb(?:ing|ed)\b/', $s));
    $nausea = (bool) preg_match('/\b(?:nausea|vomit(?:ing|ed)?)\b/', $s);
    $photo = (bool) preg_match('/\bphotophobia\b|\bsensitive\s+to\s+light\b|\b(?:light|bright)\s+sensitivity\b/', $s);
    $aura = (bool) preg_match('/\b(?:visual\s+aura|aura|zigzag|scintillat|fortification\s+spectr|flickering\s+lights)\b/', $s);

    return $head && $nausea && $photo && $aura;
}

/**
 * General investigation sequencing (non-ACS): pregnancy test, imaging, etc.
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsGeneralInvestigationSequencingGuards(array $question): array
{
    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    if ($leadIn === '') {
        return $warnings;
    }

    if (sbaStyleLeadInAsksInitialInvestigation($leadIn)) {
        if ($stem !== '' && sbaStyleStemReportsUrinePregnancyTestNegativeForSequencing($stem)) {
            $warnings[] = [
                'code' => 'sequencing.initial_investigation_urine_pregnancy_negative_in_stem',
                'severity' => 'warning',
                'message' => 'Lead-in asks for the “initial investigation” but the stem already reports a negative urine pregnancy test — switch the lead-in to “next investigation” (or omit the result from the stem if the task is truly the first test).',
                'field' => 'lead_in',
            ];
        }
        if ($stem !== '' && sbaStyleStemReportsObviousFirstLineInvestigationResultGeneral($stem)) {
            $warnings[] = [
                'code' => 'sequencing.initial_investigation_first_line_result_already_in_stem',
                'severity' => 'warning',
                'message' => 'Lead-in asks for the “initial investigation” while the stem already appears to report a first-line investigation result — rephrase the lead-in to “next” / further investigation or remove the result from the stem.',
                'field' => 'lead_in',
            ];
        }
    }

    if ($stem !== '' && sbaStyleStemMentionsAmenorrhoeaContext($stem) && sbaStyleStemReportsUrinePregnancyTestNegativeForSequencing($stem)) {
        $asksTest = (bool) preg_match('/\b(?:which|what)\b.{0,180}\b(?:investigation|test)\b/iu', $leadIn);
        if ($asksTest && !sbaStyleLeadInAsksNextOrFurtherInvestigation($leadIn) && !sbaStyleLeadInAsksInitialInvestigation($leadIn)) {
            $warnings[] = [
                'code' => 'sequencing.amenorrhoea_negative_pregnancy_expect_next_investigation_lead_in',
                'severity' => 'warning',
                'message' => 'Amenorrhoea stem with a negative pregnancy test usually warrants a “next investigation” lead-in (pregnancy already excluded) rather than “initial investigation”.',
                'field' => 'lead_in',
            ];
        }
    }

    $leadDx = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    if ($stem !== '' && sbaStyleQuestionLeadInLooksDiagnosisTyped($leadDx)) {
        $stackScore = 0;
        foreach ([
            '/\bpathognomonic\b/i',
            '/\b(?:classic|textbook)\s+(?:triad|presentation|picture|case)\b/i',
            '/\bvirtually\s+diagnostic\b/i',
            '/\bdiagnostic\s+(?:triad|trio)\b/i',
            '/\ball\s+three\s+(?:of\s+)?(?:the\s+)?(?:classic|cardinal)\b/i',
            '/\bgive(?:s)?\s+away\s+the\s+diagnosis\b/i',
        ] as $rx) {
            if (preg_match($rx, $stem)) {
                $stackScore++;
            }
        }
        if ($stackScore >= 2) {
            $warnings[] = [
                'code' => 'diagnosis.stem_multiple_giveaway_cue_stacking',
                'severity' => 'warning',
                'message' => 'Diagnosis-style lead-in with several “classic / pathognomonic / textbook” giveaway phrases in the stem can over-determine the answer — soften or spread cues so reasoning stays fair.',
                'field' => 'stem',
            ];
        }
    }

    if ($stem !== '' && sbaStyleStemSuggestsMigraineAuraGiveawayCluster($stem)) {
        $warnings[] = [
            'code' => 'diagnosis.migraine_full_typical_cluster_in_stem',
            'severity' => 'warning',
            'message' => 'Stem combines unilateral/throbbing headache, nausea, photophobia, and aura-type cues in one cluster — unless the learning point is typical migraine with aura, omit or soften some features to avoid a textbook giveaway.',
            'field' => 'stem',
        ];
    }

    return sbaStyleDedupeWarnings($warnings);
}

/**
 * Amenorrhoea: urine pregnancy already negative + “initial investigation” lead-in → “next investigation” (wording only).
 */
function repairSbaStyleAmenorrhoeaLeadInitialToNextInvestigation(string $stem, string $leadIn): string
{
    $stemT = trim($stem);
    $leadT = trim($leadIn);
    if ($stemT === '' || $leadT === '') {
        return $leadIn;
    }
    if (!sbaStyleStemMentionsAmenorrhoeaContext($stemT) || !sbaStyleStemReportsUrinePregnancyTestNegativeForSequencing($stemT)) {
        return $leadIn;
    }
    if (!sbaStyleLeadInAsksInitialInvestigation($leadT)) {
        return $leadIn;
    }

    $out = $leadT;
    $out = (string) preg_replace('/\bmost\s+appropriate\s+initial\s+(investigation|test)\b/iu', 'most appropriate next $1', $out);
    $out = (string) preg_replace('/\bappropriate\s+initial\s+investigation\b/iu', 'appropriate next investigation', $out);
    $out = (string) preg_replace('/\bthe\s+initial\s+investigation\b/iu', 'the next investigation', $out);
    $out = (string) preg_replace('/\binitial\s+investigation\b/iu', 'next investigation', $out);
    $out = (string) preg_replace('/\bfirst\s+investigation\b/iu', 'next investigation', $out);

    return trim(preg_replace('/\s+/u', ' ', $out));
}

/**
 * Combined keyed answer text (correct_answer field plus the lettered option line when present).
 */
function sbaStyleCorrectAnswerCombinedText(array $question): string
{
    $parts = [];
    if (isset($question['correct_answer']) && is_string($question['correct_answer'])) {
        $t = trim($question['correct_answer']);
        if ($t !== '') {
            $parts[] = $t;
        }
    }
    $letter = strtoupper(trim((string) ($question['correct_answer_letter'] ?? '')));
    if (preg_match('/^[A-E]$/', $letter)) {
        $key = 'option_' . strtolower($letter);
        if (isset($question[$key]) && is_string($question[$key])) {
            $opt = trim($question[$key]);
            if ($opt !== '') {
                $parts[] = $opt;
            }
        }
    }

    return trim(implode(' ', $parts));
}

function sbaStyleStemContainsAcsEcGResultCueForValidator7(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (preg_match('/\becg\s+shows\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\binitial\s+ecg\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\belectrocardiogram\s+shows\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\bST[\s\-]segment\s+changes\b/i', $stem)) {
        return true;
    }

    return false;
}

function sbaStyleWarningsAcsFormClinicalGuards(array $question): array
{
    $warnings = [];
    if (!sbaStyleFormQuestionAcsClinicalContext($question)) {
        return $warnings;
    }

    $stem = trim((string) ($question['stem'] ?? ''));
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question["_current_skill"] ?? '')));
    $correctAns = trim((string) ($question['correct_answer'] ?? ''));
    $tutor = trim((string) ($question["_tutor_comments"] ?? ''));
    $justification = isset($question['justification']) ? trim((string) $question['justification']) : '';
    $opts = sbaStyleOptionTexts($question);
    $optLines = array_values(array_filter($opts, static function ($t) {
        return trim((string) $t) !== '';
    }));

    $tutorDistinctionOk = $tutor !== '' && preg_match('/\b(?:type\s*[12]|injury\s+vs|infarction\s+vs|distinction|learning\s+point)\b/i', $tutor);

    if ($correctAns !== '' && preg_match('/\bunstable\s+angina(?:\s+pectoris)?\b/i', $correctAns)
        && $stem !== '' && sbaStyleStemSuggestsRaisedOrPositiveTroponin($stem)) {
        $warnings[] = [
            'code' => 'acs.unstable_angina_with_raised_troponin',
            'severity' => 'high',
            'message' => 'ACS context: the keyed answer suggests unstable angina while the stem indicates a raised or positive troponin with ischaemic symptoms — troponin elevation implies myocardial injury/infarction, not unstable angina; revise the key (e.g. NSTEMI / myocardial infarction) or align troponin wording with true-negative unstable angina.',
            'field' => 'correct_answer',
        ];
    }

    if (preg_match('/^interpretation$/i', $sk) && $correctAns !== '' && $stem !== ''
        && sbaStyleStemSuggestsRaisedOrPositiveTroponin($stem)
        && !sbaStyleStemSuggestsStemiFeatures($stem)
        && preg_match('/\bmyocardial\s+infarction\b/i', $correctAns)
        && !preg_match('/\b(?:non[\s-]?st(?:[\s-]?elevation)?|NSTEMI|nstemi|non[\s-]?st[\s-]?elevation\s+myocardial\s+infarction)\b/i', $correctAns)) {
        $warnings[] = [
            'code' => 'acs.interpretation_prefer_nstemi_when_raised_troponin_no_ste',
            'severity' => 'warning',
            'message' => 'ACS Interpretation: raised troponin with no ST-segment elevation in the stem — prefer the precise label **non-ST-elevation myocardial infarction (NSTEMI)** as the keyed answer rather than vague “myocardial infarction … due to troponin” wording alone.',
            'field' => 'correct_answer',
        ];
    }

    if (preg_match('/^interpretation$/i', $sk) && $optLines !== [] && !$tutorDistinctionOk
        && sbaStyleAcsInterpretationOptionsMixMiAndMyocardialInjury($optLines)) {
        $warnings[] = [
            'code' => 'acs.interpretation_mi_vs_injury_overlap',
            'severity' => 'warning',
            'message' => 'ACS interpretation: options include both myocardial infarction–type and myocardial injury–type labels in the same item — use distinct options (e.g. NSTEMI, STEMI, unstable angina, stable angina, non-cardiac chest pain) unless Tutor comments make injury vs infarction the explicit learning point.',
            'field' => 'options',
        ];
    }

    if (preg_match('/^diagnosis$/i', $sk) && $correctAns !== '' && $stem !== ''
        && sbaStyleTextSuggestsDefiniteMyocardialInfarctionDiagnosis($correctAns)
        && !sbaStyleStemSuggestsRaisedOrPositiveTroponin($stem)
        && !sbaStyleStemSuggestsStemiFeatures($stem)) {
        $warnings[] = [
            'code' => 'acs.diagnosis_mi_without_confirmatory_data',
            'severity' => sbaStyleCoreConditionIsAcuteCoronarySyndromeNameUk($question) ? 'high' : 'warning',
            'message' => 'ACS Diagnosis: the keyed answer names definite myocardial infarction (or STEMI/NSTEMI) while the stem reports neither raised/positive troponin nor ST-segment elevation — prefer **acute coronary syndrome** when symptoms suggest ACS but investigations are not given; do not key MI from symptoms alone.',
            'field' => 'correct_answer',
        ];
    }

    if (preg_match('/^diagnosis$/i', $sk) && $stem !== '' && !sbaStyleStemDocumentsAnyTroponinResult($stem)) {
        $diagText = trim($correctAns . "\n" . $justification);
        if ($diagText !== ''
            && preg_match(
                '/\b(?:troponin|hs[\s-]?cTn|cTnI|cTnT)\b.{0,160}\b(?:is\s+)?(?:normal|not\s+(?:elevated|raised|positive|high)|negative|undetectable|within\s+(?:the\s+)?reference\s+range|non[\s-]?elevated|no\s+elevation|not\s+detectable)\b|\b(?:normal|not\s+(?:elevated|raised|positive))\b.{0,160}\b(?:troponin|hs[\s-]?cTn|cTnI|cTnT)\b/i',
                $diagText
            )
            && preg_match(
                '/\b(?:myocardial\s+infarction|\bSTEMI\b|\bNSTEMI\b|\bMI\b|infarction)\b/i',
                $diagText
            )
            && preg_match(
                '/\b(?:less\s+likely|unlikely|exclude|excludes|excluded|does\s+not\s+favour|does\s+not\s+favor|without\s+evidence|no\s+evidence|rules?\s+out|favours|favors)\b/i',
                $diagText
            )) {
            $warnings[] = [
                'code' => 'acs.diagnosis_mi_excluded_by_troponin_without_troponin_result',
                'severity' => 'warning',
                'message' => 'ACS Diagnosis: the keyed answer or justification cites troponin as normal / not elevated to downplay myocardial infarction, but the stem does not document any troponin result — do not invent troponin status; align the stem with reported biomarkers or remove troponin-based reasoning and prefer **acute coronary syndrome** (or unstable angina only if the stem clearly supports troponin-negative ischaemia).',
                'field' => 'justification',
            ];
        }
    }

    if (preg_match('/^management$/i', $sk)
        && $stem !== ''
        && sbaStyleCoreConditionIsAcuteCoronarySyndromeNameUk($question)
        && sbaStyleStemSuggestsStableExertionalAnginaWithoutAcuteAcsFeatures($stem)
        && !sbaStyleTutorAllowsStableAnginaOrAcsDifferentiation($question)) {
        $warnings[] = [
            'code' => 'acs.management_stable_exertional_angina_stem_mismatch_core_acs',
            'severity' => 'warning',
            'message' => 'ACS Management: **Core condition** is acute coronary syndrome but the stem reads as **stable exertional angina** (or chronic stable pattern) without acute ACS cues — frame as **suspected or confirmed ACS after initial assessment** (acute or recent presentation) unless Tutor comments explicitly target stable-angina vs ACS differentiation.',
            'field' => 'stem',
        ];
    }

    if (preg_match('/^management$/i', $sk) && $optLines !== []) {
        $leadInitialAssessment = $leadIn !== '' && preg_match(
            '/\b(?:initial\s+assessment|first\s+assessment|immediate\s+assessment|assessment\s+including)\b/i',
            $leadIn
        );
        if (!$leadInitialAssessment && sbaStyleAcsManagementOptionsMixEcGWithTreatments($optLines)) {
            $warnings[] = [
                'code' => 'acs.management_ecg_among_treatment_options',
                'severity' => 'warning',
                'message' => 'ACS management: at least one option reads as ECG / electrocardiogram alongside treatment-style options — keep one decision family unless the lead-in explicitly asks for initial assessment steps.',
                'field' => 'options',
            ];
        }
        foreach ($optLines as $oneOpt) {
            if (sbaStyleAcsManagementOptionReadsAsInvestigation($oneOpt)) {
                $warnings[] = [
                    'code' => 'acs.management_includes_investigation_option',
                    'severity' => 'warning',
                    'message' => 'ACS management: at least one option reads as an investigation (e.g. ECG, chest X-ray, troponin) — when Current skill is Management, every option should be a treatment/management action only.',
                    'field' => 'options',
                ];
                break;
            }
        }
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $optLines !== [] && $leadIn !== ''
        && sbaStyleLeadInAsksTreatmentWithoutInvestigationFrame($leadIn)) {
        foreach ($optLines as $oneOpt) {
            if (sbaStyleOptionLineReadsAsInvestigationForTreatmentLeadInConflict($oneOpt)) {
                $warnings[] = [
                    'code' => 'acs.emergency_treatment_lead_investigation_option',
                    'severity' => 'high',
                    'message' => '[High priority] ACS emergency management: the lead-in asks for management, treatment, or immediate/first action but at least one option reads as an investigation, imaging, or diagnostic test (e.g. ECG, troponin, X-ray, CT, scan) — use treatment-only options or reframe the lead-in to initial assessment / investigation. Do not rely on automatic option repair; rewrite the option set in the JSON output.',
                    'field' => 'options',
                ];
                break;
            }
        }
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $optLines !== [] && $leadIn !== ''
        && sbaStyleLeadInAsksTreatmentWithoutInvestigationFrame($leadIn)) {
        foreach ($optLines as $oneOpt) {
            if (sbaStyleOptionLineSuggestsChestXrayForAcsEmergency($oneOpt)) {
                $warnings[] = [
                    'code' => 'acs.emergency_treatment_lead_chest_xray_option_high',
                    'severity' => 'high',
                    'message' => '[High priority] ACS emergency management: chest X-ray (or CXR / plain X-ray) appears as an option while the lead-in asks for treatment or management — remove plain radiography from the option set or change the lead-in to an investigation task.',
                    'field' => 'options',
                ];
                break;
            }
        }
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $stem !== '' && $correctAns !== '' && $optLines !== []
        && sbaStyleTextSuggestsAspirinKeyedAction($correctAns)
        && sbaStyleStemSuggestsStableAcsForEmergencyGuards($stem)
        && sbaStyleAcsEmergencyOptionsIncludeEcGDistractorNotKeyed($optLines, $correctAns)) {
        $warnings[] = [
            'code' => 'acs.emergency_stable_aspirin_ecg_distractor',
            'severity' => 'warning',
            'message' => 'ACS emergency management: stable suspected ACS with aspirin as the keyed answer but ECG appears as a non-keyed option — use treatment-only distractors (e.g. nitrates, morphine, oxygen, beta-blocker, anticoagulation) unless the lead-in explicitly asks for investigation or initial assessment.',
            'field' => 'options',
        ];
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $stem !== '' && $correctAns !== ''
        && sbaStyleTextSuggestsAspirinKeyedAction($correctAns)
        && !sbaStyleLeadInAsksImmediateAntiplatelet($leadIn)
        && !sbaStyleStemSuggestsStableNonStElevationOrEquivocalStPattern($stem)
        && (sbaStyleStemSuggestsSystolicBpUnder100($stem)
            || sbaStyleStemSuggestsHypotension($stem)
            || sbaStyleStemSuggestsStemiFeatures($stem))
        && sbaStyleAcsEmergencyAnyOptionEscalationReperfusionCathOrIvFluids($optLines)) {
        $warnings[] = [
            'code' => 'acs.emergency_aspirin_obvious_escalation_fluid_options_high',
            'severity' => 'high',
            'message' => '[High priority] ACS emergency management: the stem suggests hypotension, systolic BP under 100 mmHg, STEMI, or ST-segment elevation while the keyed answer is aspirin and at least one option offers urgent cardiology/PCI/reperfusion/cath lab or IV fluids — this usually breaks one-best-answer discipline; use a stable ACS stem for an aspirin key, or key escalation/reperfusion and drop competing options (do not rely on auto-repair; rewrite clinically).',
            'field' => 'correct_answer',
        ];
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $correctAns !== ''
        && sbaStyleTextSuggestsIvFluidResuscitation($correctAns)
        && $stem !== '' && sbaStyleStemSuggestsHypotension($stem)
        && !sbaStyleStemSupportsIvFluidsForHypotensiveChestPain($stem)) {
        $warnings[] = [
            'code' => 'acs.emergency_iv_fluids_without_clear_indication',
            'severity' => 'warning',
            'message' => 'ACS emergency management: the keyed answer is IV fluid–based resuscitation in a hypotensive chest-pain context without a clear stem cue for hypovolaemia, right ventricular infarction, or another fluid-responsive cause — prefer defensible ACS emergency actions unless the vignette justifies fluids.',
            'field' => 'correct_answer',
        ];
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $stem !== '' && $correctAns !== ''
        && sbaStyleStemSuggestsHypotension($stem)
        && sbaStyleStemSuggestsInferiorStemi($stem)
        && sbaStyleTextSuggestsAspirinKeyedAction($correctAns)
        && !sbaStyleLeadInAsksImmediateAntiplatelet($leadIn)
        && !sbaStyleStemAddressesHypotensionOrUrgentEscalationInAcs($stem)) {
        $warnings[] = [
            'code' => 'acs.emergency_aspirin_hypotensive_inferior_stemi',
            'severity' => 'high',
            'message' => '[High priority] ACS emergency management: hypotension with inferior STEMI and aspirin as the keyed answer — urgent reperfusion/escalation (or RV/nitrate teaching) is usually more appropriate than aspirin alone; revise stem, options, and key together (warnings only; no automatic key change).',
            'field' => 'correct_answer',
        ];
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $stem !== '' && $optLines !== []
        && sbaStyleStemSuggestsHypotension($stem)
        && sbaStyleStemSuggestsInferiorStemi($stem)
        && !sbaStyleStemSupportsIvFluidsForHypotensiveChestPain($stem)
        && sbaStyleAcsEmergencyOptionsIncludeIvFluidsDistractor($optLines, $correctAns)) {
        $warnings[] = [
            'code' => 'acs.emergency_iv_fluids_distractor_hypotensive_inferior_no_rv',
            'severity' => 'warning',
            'message' => 'ACS emergency management: IV fluids appear as an option in hypotensive inferior STEMI without a clear right ventricular infarction or fluid-responsive cue in the stem — fluids can be defensible here and weaken one-best-answer discipline unless the item explicitly tests RV infarct or fluid strategy.',
            'field' => 'options',
        ];
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $stem !== '' && $correctAns !== ''
        && sbaStyleTextSuggestsAspirinKeyedAction($correctAns)
        && !sbaStyleLeadInAsksImmediateAntiplatelet($leadIn)
        && !sbaStyleStemSuggestsStableNonStElevationOrEquivocalStPattern($stem)
        && (sbaStyleStemSuggestsHypotension($stem) || sbaStyleStemSuggestsStemiFeatures($stem))
        && sbaStyleAcsEmergencyHasUrgentCardiologyReperfusionDistractor($optLines, $correctAns)) {
        $warnings[] = [
            'code' => 'acs.emergency_aspirin_with_escalation_option_present',
            'severity' => 'warning',
            'message' => 'ACS emergency management: the stem suggests hypotension or STEMI/ST elevation, the keyed answer is aspirin, and another option offers urgent cardiology review or reperfusion — escalation is often equally or more defensible unless the lead-in explicitly targets immediate antiplatelet therapy; prefer a stable ACS stem for an aspirin key, or key reperfusion/escalation and adjust options.',
            'field' => 'correct_answer',
        ];
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $stem !== '' && $optLines !== []
        && !sbaStyleStemSuggestsStableNonStElevationOrEquivocalStPattern($stem)
        && (sbaStyleStemSuggestsStemiFeatures($stem) || sbaStyleStemSuggestsHypotension($stem))
        && sbaStyleStemSuggestsUnstableForAcsEmergency($stem)
        && sbaStyleAcsEmergencyHasUrgentCardiologyReperfusionDistractor($optLines, $correctAns)) {
        $warnings[] = [
            'code' => 'acs.emergency_cardiology_distractor_unstable_stemi',
            'severity' => 'warning',
            'message' => 'ACS emergency management: unstable STEMI context with urgent cardiology review or reperfusion pathway as a non-keyed option — that escalation is often clinically defensible; do not use it as a weak distractor unless the keyed line is clearly better for the lead-in.',
            'field' => 'options',
        ];
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $stem !== '' && $optLines !== []
        && sbaStyleStemSuggestsUnstableForAcsEmergency($stem)) {
        $caN = sbaStyleNormalizeForSimilarity($correctAns);
        foreach ($optLines as $oneOpt) {
            if (!sbaStyleOptionLineSuggestsSeniorReview($oneOpt)) {
                continue;
            }
            $obN = sbaStyleNormalizeForSimilarity(sbaStyleOptionBodyWithoutLetterPrefix($oneOpt));
            if ($obN === '' || $caN === $obN || ($caN !== '' && str_contains($caN, $obN)) || ($obN !== '' && str_contains($obN, $caN))) {
                continue;
            }
            $warnings[] = [
                'code' => 'acs.emergency_senior_review_distractor_unstable',
                'severity' => 'warning',
                'message' => 'ACS emergency management: an option invites senior/registrar review while the stem suggests instability — senior escalation is often still defensible; prefer distractors that represent a clear error or lower-priority step for this lead-in.',
                'field' => 'options',
            ];
            break;
        }
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $stem !== '') {
        $correctBlob = sbaStyleCorrectAnswerCombinedText($question);
        if ($correctBlob !== ''
            && sbaStyleTextSuggestsPrimaryPciReperfusionOrCardiologyPathwayKey($correctBlob)
            && !sbaStyleStemSupportsAcsEmergencyReperfusionPciKey($stem)) {
            $warnings[] = [
                'code' => 'acs.emergency_reperfusion_pci_key_without_stemi_ecg_evidence',
                'severity' => 'high',
                'message' => '[High priority] ACS Emergency Management: the keyed answer is primary PCI, urgent PCI, reperfusion, or a cardiology pathway, but the stem does not document **ST-segment elevation on ECG** (e.g. “ECG shows ST-segment elevation in leads …”) or another clear reperfusion indication (e.g. hypotension with inferior STEMI / cardiogenic shock with STE). Add explicit ECG STEMI evidence to the stem or choose a different emergency concept.',
                'field' => 'correct_answer',
            ];
        }
    }

    $onStem = $stem !== '' && preg_match('/\bon\s+air\b/i', $stem);
    $onOpts = false;
    foreach ($optLines as $o) {
        if ($o !== '' && preg_match('/\bon\s+air\b/i', $o)) {
            $onOpts = true;
            break;
        }
    }
    if ($onStem || $onOpts) {
        $warnings[] = [
            'code' => 'acs.stem_on_air',
            'severity' => 'warning',
            'message' => 'ACS context: prefer “breathing air” rather than “on air” when giving oxygen saturations on room air.',
            'field' => $onStem ? 'stem' : 'options',
        ];
    }

    if ($stem !== '' && preg_match('/\bappears\s+anxious\b/i', $stem)) {
        $warnings[] = [
            'code' => 'acs.stem_appears_anxious',
            'severity' => 'warning',
            'message' => 'ACS context: avoid vague affect labels such as “appears anxious”; use objective observations (pain, observations, ECG, troponin) that change discrimination.',
            'field' => 'stem',
        ];
    }

    if ($stem !== '' && !preg_match('/\bno\s+(?:evidence\s+of\s+)?indications?\s+of\s+st/i', $stem)
        && preg_match('/\b(?:there\s+are\s+)?indications?\s+of\s+(?:st[\s-]?segment\s+elevation|st\s+elevation)\b/i', $stem)) {
        $warnings[] = [
            'code' => 'acs.stem_vague_st_segment_elevation_indications',
            'severity' => 'warning',
            'message' => 'ACS context: avoid vague ECG phrasing such as “indications of ST-segment elevation”; use precise territory wording (e.g. “ECG shows ST-segment elevation in leads II, III and aVF”) or omit ECG detail if not needed.',
            'field' => 'stem',
        ];
    }

    if ((preg_match('/^emergency\s+management$/i', $sk) || preg_match('/^management$/i', $sk))
        && $correctAns !== '' && $stem !== ''
        && sbaStyleStemSuggestsHypotension($stem)
        && preg_match('/\b(?:GTN|glyceryl\s+trinitrate|nitroglycerin|sublingual\s+nitrate|nitrates?)\b/i', $correctAns)) {
        $warnings[] = [
            'code' => 'acs.nitrate_with_hypotension',
            'severity' => 'warning',
            'message' => 'ACS context: the keyed answer includes nitrates while the stem suggests hypotension — nitrates are usually inappropriate when blood pressure is low; revise the key or blood pressure context.',
            'field' => 'correct_answer',
        ];
    }

    return sbaStyleDedupeWarnings($warnings);
}

/**
 * ACS / suspected cardiac ischaemia context for form-generator guards (stem, lead-in, options, condition).
 */
function sbaStyleFormQuestionAcsClinicalContext(array $question): bool
{
    if (sbaStyleBlobSuggestsSuspectedAcs($question)) {
        return true;
    }
    $cond = mb_strtolower(trim((string) ($question['_condition_name'] ?? '')));

    return (bool) preg_match(
        '/\b(?:acute\s+coronary|coronary\s+syndrome|\bacs\b|myocardial|nstemi|stemi|angina|ischaemic\s+heart|ischemic\s+heart|troponin)\b/i',
        $cond
    );
}

/** Form **Core condition** names acute coronary syndrome (ACS), not generic angina alone. */
function sbaStyleCoreConditionIsAcuteCoronarySyndromeNameUk(array $question): bool
{
    $c = mb_strtolower(trim((string) ($question['_condition_name'] ?? '')));
    if ($c === '') {
        return false;
    }
    if (preg_match('/\b(?:acute\s+coronary\s+syndrome|\bacs\b)\b/i', $c)) {
        return true;
    }

    return str_contains($c, 'acute') && str_contains($c, 'coronary') && str_contains($c, 'syndrome');
}

function sbaStyleTutorAllowsStableAnginaOrAcsDifferentiation(array $question): bool
{
    $tutor = trim((string) ($question['_tutor_comments'] ?? ''));
    if ($tutor !== '' && preg_match('/\b(?:stable\s+angina|differentiat|differential|vs\.?\s*acs|angina\s+versus|contrast(?:s|ing)?\s+with\s+acs)\b/i', $tutor)) {
        return true;
    }
    $lead = trim((string) ($question['lead_in'] ?? ''));

    return (bool) preg_match('/\b(?:most\s+likely\s+diagnosis|underlying\s+diagnosis|diagnosis\s+including\s+a\s+differential|which\s+diagnosis)\b/i', $lead);
}

function sbaStyleStemSuggestsStableExertionalAnginaWithoutAcuteAcsFeatures(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    $l = mb_strtolower($stem);
    $exertional = (bool) preg_match('/\b(?:on\s+exertion|when\s+walking|climbing\s+stairs|on\s+walking|walking\s+uphill|with\s+effort|exercise(?:-induced)?|exertional)\b/i', $l);
    $reliefRest = (bool) preg_match('/\b(?:relieved\s+by\s+rest|settles\s+with\s+rest|eases\s+when\s+resting|resolves\s+with\s+rest|rest\s+relieves)\b/i', $l);
    $stableFraming = (bool) preg_match('/\b(?:stable\s+angina|chronic\s+angina|typical\s+angina\s+for\s+(?:him|her)|(?:his|her)\s+usual\s+angina|usual\s+cardiac\s+chest\s+pain)\b/i', $l);
    if (!$stableFraming && !($exertional && $reliefRest)) {
        return false;
    }
    if (preg_match('/\b(?:rest\s+pain|pain\s+at\s+rest|ongoing\s+pain|constant\s+pain|acute\s+coronary|suspected\s+acute\s+coronary|suspected\s+acs|nstemi|stemi|raised\s+troponin|positive\s+troponin|serial\s+troponin|this\s+(?:morning|afternoon|evening)\b|attends\s+(?:the\s+)?(?:ed|emergency\s+department|a\s*&\s*e|a\s+and\s+e)|999\s+call|ambulance|cold\s+sweat|diaphoresis)\b/i', $l)) {
        return false;
    }

    return true;
}

/**
 * True when the stem documents troponin with a numeric value and an interpretable reference (suppresses false qualitative-lab warnings).
 */
function sbaStyleStemBlobHasTroponinNumericWithReferenceStyle(string $stem): bool
{
    if ($stem === '' || !preg_match('/\b(?:cardiac\s+)?troponin|hs[\s-]?cTn|high[\s-]sensitivity\s+cTn|cTnI|cTnT\b/i', $stem)) {
        return false;
    }
    $labUnit = '(?:ng(?:\s*/\s*)?/L|μg/L|µg/L|pg/mL)';
    if (!preg_match('#\d+(?:\.\d+)?\s*' . $labUnit . '\b#iu', $stem)) {
        return false;
    }

    return (bool) preg_match(
        '#\d+(?:\.\d+)?\s*' . $labUnit . '\b[^.!?;]{0,220}(?:\(\s*<?\s*[\d.]+\s*\)|\(\s*[\d.]+\s*[-–]\s*[\d.]+\s*\)|\b(?:URL|ULN|LLN|reference\s+range|normal\s+range)\b|\d{1,4}\s*[-–]\s*\d{1,4}\s*ng)#iu',
        $stem
    ) || preg_match(
        '#(?:\(\s*<?\s*[\d.]+\s*\)|\(\s*[\d.]+\s*[-–]\s*[\d.]+\s*\)|\b(?:URL|ULN)\b[^.!?;]{0,50})\d+(?:\.\d+)?\s*' . $labUnit . '#iu',
        $stem
    );
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsAcsCoreConditionStemTitleMismatch(array $question): array
{
    if (!sbaStyleCoreConditionIsAcuteCoronarySyndromeNameUk($question)) {
        return [];
    }
    $stem = trim((string) ($question['stem'] ?? ''));
    $title = trim((string) ($question['title'] ?? ''));
    $blob = mb_strtolower($stem . "\n" . $title);
    if ($blob === '') {
        return [];
    }
    if (preg_match('/\b(?:stable\s+angina|chronic\s+stable\s+angina)\b/i', $blob)
        && !preg_match('/\b(?:suspected\s+acute\s+coronary|acute\s+coronary\s+syndrome|\bacs\b|nstemi|stemi|myocardial\s+infarction|troponin|rest\s+pain|suspected\s+acs)\b/i', $blob)) {
        return [
            [
                'code' => 'acs.condition_framing_mismatch_core_acs_stable_angina_narrative',
                'severity' => 'warning',
                'message' => 'Core condition is **Acute coronary syndrome** but the stem or title reads chiefly as **stable angina** without acute ACS framing — align the vignette with suspected or confirmed ACS after initial assessment, or adjust the core condition / Tutor comments if stable-angina differentiation was intended.',
                'field' => 'stem',
            ],
        ];
    }

    return [];
}

/**
 * Stem narrative suggests troponin is raised, positive, or above reference range.
 */
function sbaStyleStemSuggestsRaisedOrPositiveTroponin(string $stem): bool
{
    if ($stem === '' || !preg_match('/\b(?:cardiac\s+)?troponin|hs[\s-]?cTn|high[\s-]sensitivity\s+cTn|cTnI|cTnT\b/i', $stem)) {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:raised|elevated|positive|high|detectable|increased|above\s+(?:the\s+)?(?:upper\s+)?(?:reference|normal\s+range|limit|url)|exceeds|is\s+\d|\d+\s*(?:ng\/l|ng\s*\/\s*l|μg\/l|pg\/ml|ng\/mL))\b/i',
        $stem
    );
}

/**
 * Interpretation options mix definite MI-type labels with myocardial injury–type labels.
 *
 * @param list<string> $optLines
 */
function sbaStyleAcsInterpretationOptionsMixMiAndMyocardialInjury(array $optLines): bool
{
    $hasMi = false;
    $hasInjury = false;
    foreach ($optLines as $o) {
        $l = mb_strtolower($o);
        if (preg_match('/\bmyocardial\s+infarction\b|\bSTEMI\b|\bNSTEMI\b|\bnon[\s-]?st\s+elevation\s+myocardial\s+infarction\b/i', $l)) {
            $hasMi = true;
        }
        if (preg_match('/\bmyocardial\s+injury\b|\bsignificant\s+myocardial\s+injury\b|\btype\s+[12]\s+myocardial\s+injury\b/i', $l)) {
            $hasInjury = true;
        }
    }

    return $hasMi && $hasInjury;
}

/**
 * Keyed diagnosis text names definite myocardial infarction (not generic ACS wording only).
 */
function sbaStyleTextSuggestsDefiniteMyocardialInfarctionDiagnosis(string $text): bool
{
    $t = mb_strtolower($text);

    if (preg_match('/\bacute\s+coronary\s+syndrome\b/i', $t) && !preg_match('/\bmyocardial\s+infarction\b|\bSTEMI\b|\bNSTEMI\b/i', $t)) {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:acute\s+)?myocardial\s+infarction\b|\bSTEMI\b|\bNSTEMI\b|\bnon[\s-]?st[\s-]?elevation\s+(?:myocardial\s+)?infarction\b|\bst\s+elevation\s+myocardial\s+infarction\b/i',
        $t
    );
}

/**
 * Stem documents ECG findings or troponin levels sufficient to support MI diagnosis labelling.
 */
function sbaStyleStemHasConfirmatoryEcgOrTroponinForMi(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (sbaStyleStemSuggestsRaisedOrPositiveTroponin($stem)) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:ECG|electrocardiogram|EKG)\b.{0,140}\b(?:st\s+elevation|st\s+segment\s+elevation|st\s+depression|stemi|nstemi|pathological\s+q|new\s+(?:left\s+)?bundle|hyperacute\s+t|tombstone)\b/i',
        $stem
    );
}

/**
 * Remove negated or non-ST-elevation phrases so STEMI/ST-elevation regexes only see positive ischaemic STE.
 */
function sbaStyleStemSanitizedForPositiveStElevationMention(string $stem): string
{
    if ($stem === '') {
        return '';
    }
    $s = $stem;
    $patterns = [
        '/\bnon[\s\-]?(?:ST[\s\-]segment[\s\-]elevation|ST[\s\-]elevation|STEMI|stemi)\b/iu',
        '/\bNSTE(?:MI|ACS)\b/i',
        '/\bNSTEMI\b/i',
        '/\bpost[\s\-]?(?:stemi|pci)\b/iu',
        '/\b(?:non[\s-]?specific\s+)?st[\s\-]?segment\s+changes\b/iu',
        '/\bst[\s\-]?segment\s+changes\b/iu',
        '/\bst\s+segment\s+changes\b/iu',
        '/\bno\s+evidence\s+of\s+(?:ST[\s\-]segment\s+elevation|ST\s+elevation)\b/iu',
        '/\bwithout\s+(?:evidence\s+of\s+)?(?:ST[\s\-]segment\s+elevation|ST\s+elevation)\b/iu',
        '/\bno\s+(?:ST[\s\-]segment\s+elevation|ST\s+elevation)\b/iu',
        '/\babsence\s+of\s+(?:ST[\s\-]segment\s+elevation|ST\s+elevation)\b/iu',
        '/\b(?:free|clear)\s+of\s+(?:ST[\s\-]segment\s+elevation|ST\s+elevation)\b/iu',
    ];
    foreach ($patterns as $p) {
        $s = (string) preg_replace($p, ' ', $s);
    }

    return $s;
}

/**
 * Unequivocal STEMI-style wording already in the stem (allows definite MI key without troponin numeric).
 */
function sbaStyleStemIsUnequivocalStemiPresentation(string $stem): bool
{
    $s = sbaStyleStemSanitizedForPositiveStElevationMention($stem);

    return (bool) preg_match(
        '/\b(?:ST\s*[- ]?segment\s+elevation|ST\s+elevation|STEMI|tombstone\s+ST|hyperacute\s+T\s+waves?)\b.{0,40}\b(?:V\d|II\s*,\s*III|inferior|anterior|lateral)\b/i',
        $s
    );
}

/**
 * At least one option is ECG and at least one is a treatment / antiplatelet / revascularisation style line.
 *
 * @param list<string> $optLines
 */
function sbaStyleAcsManagementOptionsMixEcGWithTreatments(array $optLines): bool
{
    $hasEcG = false;
    $hasTreatment = false;
    foreach ($optLines as $o) {
        $t = trim($o);
        if ($t === '') {
            continue;
        }
        $l = mb_strtolower($t);
        if (preg_match('/^(?:[a-e]\s*[.)]\s*)?(?:12[\s-]?lead\s+)?(?:ECG|electrocardiogram|EKG)\b/i', $t)
            || preg_match('/\b(?:repeat\s+)?ECG\b/i', $l)) {
            $hasEcG = true;
        }
        if (preg_match(
            '/\b(?:aspirin|clopidogrel|ticagrelor|prasugrel|heparin|enoxaparin|fondaparinux|morphine|fentanyl|GTN|glyceryl\s+trinitrate|nitroglycerin|nitrate|dual\s+antiplatelet|antiplatelet|primary\s+PCI|PPCI|PCI\b|coronary\s+angiography|percutaneous\s+coronary|beta[\s-]?block|metoprolol|bisoprolol|atorvastatin|high[\s-]?intensity\s+statin|oxygen\s+therapy)\b/i',
            $l
        )) {
            $hasTreatment = true;
        }
    }

    return $hasEcG && $hasTreatment;
}

/**
 * Keyed answer describes IV fluid bolus / crystalloid resuscitation as the main action.
 */
function sbaStyleTextSuggestsIvFluidResuscitation(string $text): bool
{
    $l = mb_strtolower($text);

    return (bool) preg_match(
        '/\b(?:IV\s+)?(?:fluid|fluids|crystalloid|0\.9\s*%?\s*saline|normal\s+saline|plasmalyte|hartmann|compound\s+sodium\s+lactate|ringer)\b.{0,40}\b(?:bolus|challenge|resuscitat|infusion|give|administer)\b|\b(?:give|administer|start)\s+(?:a\s+)?(?:fluid|crystalloid)\s+bolus\b/i',
        $l
    );
}

/**
 * Stem supports choosing IV fluids for hypotension in ACS (fluid-responsive cause).
 */
function sbaStyleStemSupportsIvFluidsForHypotensiveChestPain(string $stem): bool
{
    $s = mb_strtolower($stem);

    return (bool) preg_match(
        '/\b(?:hypovol|dehydrat|gastroenteritis|prolonged\s+vomit|diarrhoea|diarrhea|haemorrhage|hemorrhage|blood\s+loss|acute\s+blood\s+loss|right\s+ventricular|rv\s+infarct|inferior.{0,40}(?:st\s+elevation|STEMI).{0,60}(?:rv|right\s+ventric)|jugular\s+venous|raised\s+jvp|clear\s+lungs\s+with\s+raised\s+jvp)\b/i',
        $s
    );
}

/**
 * Stem suggests hypotension or low systolic BP (numeric-aware; avoids treating 130/80 as hypotension).
 */
function sbaStyleStemSuggestsHypotension(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    $s = mb_strtolower($stem);
    if (preg_match('/\bhypotens/i', $s)) {
        return true;
    }
    if (sbaStyleStemSuggestsSystolicBpUnder100($stem)) {
        return true;
    }
    if (preg_match('/\bSBP\b.{0,18}(?:7\d|8[0-9]|9[0-9])\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\b(?:blood\s+pressure|bp)\s+(?:is\s+|of\s+)?(\d{2,3})\s*\/\s*\d{2,3}\b/i', $stem, $m) && (int) $m[1] < 100) {
        return true;
    }

    return false;
}

/**
 * Stem documents systolic blood pressure strictly under 100 mmHg (numeric).
 */
function sbaStyleStemSuggestsSystolicBpUnder100(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (preg_match('/\b(?:systolic|sbp)\b.{0,45}(?<!\d)(\d{2,3})\s*mm\s*hg\b/i', $stem, $m)) {
        return (int) $m[1] < 100;
    }
    if (preg_match('/\b(?:blood\s+pressure|bp)\b.{0,40}(?<!\d)(\d{2,3})\s*\/\s*\d{2,3}\s*mm\s*hg\b/i', $stem, $m)) {
        return (int) $m[1] < 100;
    }
    if (preg_match('/\b(?:blood\s+pressure|bp)\s+(?:is\s+|of\s+)?(?<!\d)(\d{2,3})\s*\/\s*\d{2,3}\b/i', $stem, $m)) {
        return (int) $m[1] < 100;
    }

    return false;
}

/**
 * Inferior STEMI or inferior ST elevation pattern suggested in the stem.
 */
function sbaStyleStemSuggestsInferiorStemi(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    $s = mb_strtolower(sbaStyleStemSanitizedForPositiveStElevationMention($stem));

    if (preg_match('/\binferior\b.{0,100}\b(?:stemi|st\s+elevation|st\s*[- ]?segment\s+elevation|st\s+elevation\s+myocardial)\b/i', $s)) {
        return true;
    }
    if (preg_match('/\b(?:stemi|st\s+elevation|st\s*[- ]?segment\s+elevation)\b.{0,100}\binferior\b/i', $s)) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:ii|iii|avf)\b.{0,80}\b(?:st\s+elevation|stemi|hyperacute|pathological\s+q)\b|\b(?:st\s+elevation|stemi)\b.{0,80}\b(?:ii|iii|avf|inferior)\b/i',
        $s
    );
}

/**
 * After negation / NSTEMI / equivocal-ST sanitisation: positive STEMI or ST-segment elevation language
 * (not "ST-segment changes" alone).
 */
function sbaStyleStemSanitizedSuggestsStElevationOrStemi(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (sbaStyleStemIsUnequivocalStemiPresentation($stem)) {
        return true;
    }
    $s = mb_strtolower(sbaStyleStemSanitizedForPositiveStElevationMention($stem));
    if (preg_match('/\bstemi\b/', $s)) {
        return true;
    }
    if (preg_match(
        '/\b(?:st\s*[- ]?segment\s+elevation|st\s+elevation)\b(?:\s+in\s+leads?)?/i',
        $s
    )) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:st\s*[- ]?segment\s+elevation|st\s+elevation)\b.{0,120}\b(?:anterior|inferior|lateral|septal|leads?\s+v|v[1-6]|\bii\b|\biii\b|avf)\b/i',
        $s
    ) || (bool) preg_match(
        '/\b(?:anterior|inferior|lateral)\b.{0,80}\b(?:st\s*[- ]?segment\s+elevation|st\s+elevation|stemi)\b/i',
        $s
    );
}

/**
 * Stem suggests STEMI or clear ST-segment elevation infarction pattern (any territory), negation-aware.
 */
function sbaStyleStemSuggestsStemiFeatures(string $stem): bool
{
    return sbaStyleStemSanitizedSuggestsStElevationOrStemi($stem);
}

/**
 * Stable suspected ACS with non–ST-elevation / equivocal ST wording (suppresses PCI–aspirin competition heuristics).
 */
function sbaStyleStemSuggestsStableNonStElevationOrEquivocalStPattern(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (sbaStyleStemSanitizedSuggestsStElevationOrStemi($stem)) {
        return false;
    }
    if (sbaStyleStemSuggestsHypotension($stem) || sbaStyleStemSuggestsSystolicBpUnder100($stem)) {
        return false;
    }
    $s = mb_strtolower($stem);

    return (bool) preg_match(
        '/\b(?:non[\s-]?specific\s+)?st[\s-]?segment\s+changes\b|\bst[\s-]?segment\s+depression\b|\b(?:nstemi|non[\s-]?st[\s-]?elevation|non[\s-]?stemi)\b|\bno\s+(?:evidence\s+of\s+)?(?:st[\s-]?segment\s+elevation|st\s+elevation)\b|\bwithout\s+(?:evidence\s+of\s+)?(?:st[\s-]?segment\s+elevation|st\s+elevation)\b|\b(?:t\s+wave|twave)\s+inversion\b/i',
        $s
    );
}

/**
 * Lead-in explicitly targets immediate antiplatelet / aspirin (allows aspirin key in STEMI context).
 */
function sbaStyleLeadInAsksImmediateAntiplatelet(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:immediate|first[\s-]line|initial)\s+(?:antiplatelet|aspirin)|\b(?:antiplatelet|aspirin)\s+(?:therapy|treatment)\b|\bgive\s+aspirin\b|\baspirin\s+(?:first|now|immediately)\b/i',
        $leadIn
    );
}

/**
 * Option line suggests urgent cardiology review or reperfusion / PPCI pathway activation.
 */
function sbaStyleOptionLineSuggestsUrgentCardiologyOrReperfusion(string $line): bool
{
    if ($line === '') {
        return false;
    }
    $l = mb_strtolower($line);

    return (bool) preg_match(
        '/\b(?:urgent|emergency|immediate)\s+(?:cardiology|coronary|pci|angiography|reperfusion)\b|\burgent\s+cardiology\s+review\b|\b(?:primary\s+pci|PPCI)\s+pathway\b|\bactivat(?:e|ion)\s+(?:of\s+)?(?:the\s+)?(?:primary\s+)?pci\b|\b(?:transfer|refer)\s+(?:for|to)\s+(?:primary\s+)?(?:pci|PPCI)\b|\burgent\s+reperfusion\b|\burgent\s+pci\b|\bemergency\s+(?:pci|coronary\s+angiography)\b|\bcath(?:eter)?\s+lab\b.{0,30}\b(?:urgent|emergency|primary|ppci)\b/i',
        $l
    );
}

/**
 * Keyed answer is primary PCI / urgent PCI / reperfusion / cardiology pathway (ACS emergency management).
 */
function sbaStyleTextSuggestsPrimaryPciReperfusionOrCardiologyPathwayKey(string $text): bool
{
    if ($text === '') {
        return false;
    }
    if (sbaStyleOptionLineSuggestsUrgentCardiologyOrReperfusion($text)) {
        return true;
    }
    $l = mb_strtolower($text);

    return (bool) preg_match(
        '/\b(?:primary\s+pci|ppci|PPCI)\b|\bpci\s+pathway\b|\burgent\s+pci\b|\burgent\s+reperfusion\b|\breperfusion\s+(?:therapy|strategy|pathway)\b|\bcardiology\s+pathway\b|\burgent\s+cardiology\b|\bactivation\s+of\s+(?:the\s+)?(?:primary\s+)?pci\b/i',
        $l
    );
}

/**
 * Stem documents ST-segment elevation (or STEMI) on a stated ECG read-out.
 */
function sbaStyleStemDocumentsStElevationOnEcG(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (!preg_match('/\b(?:ecg|electrocardiogram|12[\s-]?lead(?:\s+ecg)?)\b/i', $stem)) {
        return false;
    }

    return sbaStyleStemSuggestsStemiFeatures($stem);
}

/**
 * Non-ECG-STE cues that still justify an emergency reperfusion / PCI keyed answer.
 */
function sbaStyleStemHasOtherClearAcsReperfusionIndication(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (sbaStyleStemSuggestsInferiorStemi($stem) && sbaStyleStemSuggestsHypotension($stem)) {
        return true;
    }
    if (sbaStyleStemSuggestsUnstableForAcsEmergency($stem)
        && preg_match('/\b(?:cardiogenic\s+shock|shocked)\b/i', $stem)
        && sbaStyleStemSuggestsStemiFeatures($stem)) {
        return true;
    }

    return false;
}

function sbaStyleStemSupportsAcsEmergencyReperfusionPciKey(string $stem): bool
{
    return sbaStyleStemDocumentsStElevationOnEcG($stem)
        || sbaStyleStemHasOtherClearAcsReperfusionIndication($stem);
}

/**
 * Any option line suggests urgent escalation/reperfusion, cath lab, or IV fluid bolus (used for aspirin one-best-answer checks).
 *
 * @param list<string> $optLines
 */
function sbaStyleAcsEmergencyAnyOptionEscalationReperfusionCathOrIvFluids(array $optLines): bool
{
    foreach ($optLines as $line) {
        if ($line === '') {
            continue;
        }
        if (sbaStyleOptionLineSuggestsUrgentCardiologyOrReperfusion($line)) {
            return true;
        }
        $l = mb_strtolower($line);
        if (preg_match('/\b(?:primary\s+pci|PPCI|pci\s+pathway|reperfusion\s+(?:therapy|pathway)|urgent\s+coronary)\b/i', $l)) {
            return true;
        }
        if (preg_match('/\bcath(?:eter)?\s+lab\b/i', $l)) {
            return true;
        }
        if (sbaStyleTextSuggestsIvFluidResuscitation($line)) {
            return true;
        }
    }

    return false;
}

/**
 * IV fluid bolus / resuscitation appears as a non-keyed option (distractor).
 *
 * @param list<string> $optLines
 */
function sbaStyleAcsEmergencyOptionsIncludeIvFluidsDistractor(array $optLines, string $correctAns): bool
{
    if ($optLines === []) {
        return false;
    }
    $caN = sbaStyleNormalizeForSimilarity($correctAns);
    foreach ($optLines as $oneOpt) {
        if (!sbaStyleTextSuggestsIvFluidResuscitation($oneOpt)) {
            continue;
        }
        $obN = sbaStyleNormalizeForSimilarity(sbaStyleOptionBodyWithoutLetterPrefix($oneOpt));
        if ($obN === '') {
            continue;
        }
        if ($caN === $obN || ($caN !== '' && str_contains($caN, $obN)) || ($obN !== '' && str_contains($obN, $caN))) {
            continue;
        }

        return true;
    }

    return false;
}

/**
 * At least one option reads as urgent cardiology / reperfusion and is not the keyed answer line.
 *
 * @param list<string> $optLines
 */
function sbaStyleAcsEmergencyHasUrgentCardiologyReperfusionDistractor(array $optLines, string $correctAns): bool
{
    if ($optLines === [] || $correctAns === '') {
        return false;
    }
    $caN = sbaStyleNormalizeForSimilarity($correctAns);
    foreach ($optLines as $oneOpt) {
        if (!sbaStyleOptionLineSuggestsUrgentCardiologyOrReperfusion($oneOpt)) {
            continue;
        }
        $obN = sbaStyleNormalizeForSimilarity(sbaStyleOptionBodyWithoutLetterPrefix($oneOpt));
        if ($obN === '') {
            continue;
        }
        if ($caN === $obN || ($caN !== '' && str_contains($caN, $obN)) || ($obN !== '' && str_contains($obN, $caN))) {
            continue;
        }

        return true;
    }

    return false;
}

/**
 * Keyed answer text centres on aspirin (antiplatelet) as the main immediate drug action.
 */
function sbaStyleTextSuggestsAspirinKeyedAction(string $text): bool
{
    if ($text === '') {
        return false;
    }
    $l = mb_strtolower($text);

    return (bool) preg_match('/\baspirin\b|\b300\s*mg\s*(?:of\s+)?aspirin\b|\b(?:chew|oral)\s+aspirin\b/i', $l);
}

/**
 * Stem already frames resuscitation, RV infarct caution, or urgent reperfusion / escalation so immediate priorities are not only antiplatelet.
 */
function sbaStyleStemAddressesHypotensionOrUrgentEscalationInAcs(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    $s = mb_strtolower($stem);

    return (bool) preg_match(
        '/\b(?:fluid\s+bolus|IV\s+fluid|crystalloid|volume\s+resuscitation|resuscitat|right\s+ventricular|rv\s+infarct|rv\s+strain|avoid\s+nitrate|no\s+nitrate|primary\s+pci|ppci|cath\s+lab|emergency\s+(?:pci|angiography)|urgent\s+(?:pci|angiography|cardiology|reperfusion)|reperfusion\s+therapy|noradrenaline|norepinephrine|inotrope|vasopressor|peri[\s-]?arrest|cardiac\s+arrest\s+team|bp\s+(?:improved|restored|normalised|normalized)|blood\s+pressure\s+(?:improved|restored)|after\s+fluid|following\s+fluid)\b/i',
        $s
    );
}

/**
 * Unstable presentation cues for emergency-management senior-distractor heuristic.
 */
function sbaStyleStemSuggestsUnstableForAcsEmergency(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (sbaStyleStemSuggestsHypotension($stem)) {
        return true;
    }
    $s = mb_strtolower($stem);

    return (bool) preg_match(
        '/\b(?:shock|shocked|collapsed|collapse|syncope\s+with|peri[\s-]?arrest|unresponsive|reduced\s+conscious| gcs\s*(?:is\s*)?[3-8]\b|critically\s+unwell|profoundly\s+hypotens|cardiogenic\s+shock)\b/i',
        $s
    );
}

/**
 * Option line reads as requesting senior / registrar / consultant review.
 */
function sbaStyleOptionLineSuggestsSeniorReview(string $line): bool
{
    if ($line === '') {
        return false;
    }
    $l = mb_strtolower($line);

    return (bool) preg_match(
        '/\b(?:call|phone|telephone|speak\s+to|contact|request|seek|obtain|arrange)\b.{0,50}\b(?:senior|registrar|consultant)\b|\b(?:senior|registrar|consultant)\b.{0,30}\b(?:review|help|support|advice|opinion)\b|\burgent\s+(?:senior|registrar|consultant)\b|\b(?:escalat|refer)\b.{0,40}\b(?:senior|registrar|cardiology)\b/i',
        $l
    );
}

/**
 * Strip leading "A. " / "a) " style prefix from an option line for comparison to keyed text.
 */
function sbaStyleOptionBodyWithoutLetterPrefix(string $line): string
{
    return trim((string) preg_replace('/^[a-e]\s*[\.)]\s*/iu', '', trim($line)));
}

/**
 * Management-skill option line is investigation-style (ECG, imaging, troponin), not a treatment action.
 */
function sbaStyleAcsManagementOptionReadsAsInvestigation(string $line): bool
{
    if ($line === '') {
        return false;
    }
    $t = trim($line);
    $l = mb_strtolower($t);

    if (preg_match('/^(?:[a-e]\s*[.)]\s*)?(?:12[\s-]?lead\s+)?(?:ECG|electrocardiogram|EKG)\b/i', $t)
        || preg_match('/\b(?:repeat\s+)?ECG\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:chest\s+x[\s-]?ray|chest\s+radiograph|\bcxr\b)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:cardiac\s+)?troponin|hs[\s-]?cTn|high[\s-]sensitivity\s+troponin|cTnI|cTnT|serial\s+troponin|repeat\s+troponin|troponin\s+series\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:exercise\s+(?:ECG|test)|exercise\s+tolerance|ett\b|stress\s+echo|dobutamine\s+stress)\b/i', $l)) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:request|send|organise|organize|measure|check|arrange)\s+(?:a\s+)?(?:12[\s-]?lead\s+)?(?:ECG|electrocardiogram|troponin|chest\s+x[\s-]?ray)\b/i',
        $l
    );
}

/**
 * Lead-in asks for management/treatment/immediate action without framing the task as assessment or investigation.
 */
function sbaStyleLeadInAsksTreatmentWithoutInvestigationFrame(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }
    if (!preg_match(
        '/\b(?:management|treatment|therapy|immediate\s+action|first\s+action|immediate(?:ly)?\s+(?:management|treatment)|(?:most\s+)?appropriate\s+(?:immediate\s+)?management)\b/i',
        $leadIn
    )) {
        return false;
    }
    if (preg_match('/\b(?:initial|first|immediate)\s+(?:assessment|investigation|steps?)\b|\bnext\s+investigation\b|\bfurther\s+investigation\b|\binvestigation\s+(?:first|next)\b/i', $leadIn)) {
        return false;
    }

    return true;
}

/**
 * Option reads as investigation / imaging when mixed with a treatment lead-in (ACS emergency heuristic).
 */
function sbaStyleOptionLineReadsAsInvestigationForTreatmentLeadInConflict(string $line): bool
{
    if ($line === '') {
        return false;
    }
    $t = trim($line);
    $l = mb_strtolower($t);

    if (sbaStyleAcsManagementOptionReadsAsInvestigation($line)) {
        return true;
    }

    if (preg_match('/\b(?:arrange|request|organise|organize|perform|obtain|send|book)\s+(?:for\s+)?(?:an\s+)?(?:urgent\s+)?(?:12[\s-]?lead\s+)?(?:ECG|electrocardiogram|troponin|chest\s+x[\s-]?ray|\bcxr\b|ct\s+(?:scan|head|chest)|mri|echocard|echocardiogram|ultrasound)\b/i', $t)) {
        return true;
    }
    if (preg_match('/\b(?:further|additional|urgent)\s+investigation\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:diagnostic|cardiac)\s+tests?\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:imaging|scan|radiology)\b/i', $l) && preg_match('/\b(?:chest|ct|v\d|pulmonary|coronary|cardiac)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:blood|serum|laboratory|lab)\s+tests?\b/i', $l)) {
        return true;
    }

    if (preg_match('/\b(?:d[\s\-]?dimer|computed\s+tomography|ct\s+chest|v\/q\s+scan|ventilation\s*[\s\/]+\s*perfusion)\b/i',
        $l
    )) {
        return true;
    }
    if (preg_match('/\b(?:imaging|investigation)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:\bcxr\b|chest\s+x[\s-]?ray|chest\s+radiograph|plain\s+(?:chest\s+)?x[\s-]?ray)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\bx[\s\-]?ray\b|\bxray\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:ct|c\.t\.)\b/i', $t) || preg_match('/\bcomputed\s+tomography\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:mri|pet)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:ct|mri|pet|v\/q|isotope|radionuclide)\s+scans?\b/i', $l)) {
        return true;
    }
    if (preg_match('/\bscans?\b/i', $l) && preg_match('/\b(?:ct|mri|pet|v\/q|isotope|radionuclide|diagnostic)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:stress|exercise|treadmill|cardiac|func(?:tional)?|serial|further|additional|blood|serum|laboratory|lab|diagnostic)\s+tests?\b/i', $l)) {
        return true;
    }
    if (preg_match('/\bpoint[\s-]of[\s-]care\s+tests?\b/i', $l)) {
        return true;
    }

    return false;
}

/**
 * Option reads as investigation / imaging / wait-for-results (Emergency Management — all conditions).
 */
function sbaStyleOptionLineReadsAsInvestigationForEmergencyManagement(string $line): bool
{
    if ($line === '') {
        return false;
    }
    if (sbaStyleOptionLineReadsAsInvestigationForTreatmentLeadInConflict($line)) {
        return true;
    }
    $t = trim($line);
    $l = mb_strtolower($t);

    if (preg_match('/\b(?:full\s+blood\s+count|\bfbc\b|blood\s+test)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:wait|await)\s+for\s+(?:the\s+)?(?:results?|report|laboratory|lab|blood)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:request|arrange|organise|organize|obtain|send|book|perform)\b.{0,40}\b(?:and\s+)?wait\s+for\b/i', $l)) {
        return true;
    }
    if (preg_match('/\bct\s+scan\b|\bcomputed\s+tomography\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:arrange|request|organise|organize)\s+(?:for\s+)?(?:a\s+)?(?:ct|scan|imaging|x[\s-]?ray|radiograph)\b/i', $t)) {
        return true;
    }
    if (preg_match('/\b(?:take|send|obtain)\s+(?:blood\s+)?cultures?\b/i', $l)
        && !preg_match('/\b(?:iv\s+)?(?:antibiotic|antimicrobial|fluid|resuscitat|oxygen)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:await|wait\s+for)\s+(?:blood\s+)?cultures?\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:point[\s-]of[\s-]care|diagnostic|laboratory|lab)\s+tests?\b/i', $l)
        && !preg_match('/\b(?:glucose|dextrose|insulin)\b/i', $l)) {
        return true;
    }

    return false;
}

/**
 * Option line suggests chest X-ray / CXR / plain X-ray (ACS emergency treatment-lead heuristic).
 */
function sbaStyleOptionLineSuggestsChestXrayForAcsEmergency(string $line): bool
{
    if ($line === '') {
        return false;
    }
    $l = mb_strtolower($line);

    return (bool) preg_match(
        '/\b(?:chest\s+x[\s-]?ray|chest\s+radiograph|\bcxr\b|plain\s+(?:chest\s+)?x[\s-]?ray)\b|\bx[\s\-]?ray\b|\bxray\b/i',
        $l
    );
}

/**
 * Haemodynamically stable suspected ACS for aspirin-focused emergency heuristics (no hypotension, no STEMI/ST-elevation cues).
 */
function sbaStyleStemSuggestsStableAcsForEmergencyGuards(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return !sbaStyleStemSuggestsHypotension($stem) && !sbaStyleStemSuggestsStemiFeatures($stem);
}

/**
 * Non-keyed option suggests ECG / electrocardiogram (arrange urgent ECG, etc.).
 *
 * @param list<string> $optLines
 */
function sbaStyleAcsEmergencyOptionsIncludeEcGDistractorNotKeyed(array $optLines, string $correctAns): bool
{
    if ($optLines === [] || $correctAns === '') {
        return false;
    }
    $caN = sbaStyleNormalizeForSimilarity($correctAns);
    foreach ($optLines as $oneOpt) {
        $t = trim($oneOpt);
        if ($t === '') {
            continue;
        }
        $l = mb_strtolower($t);
        $hasEcG = (bool) (preg_match('/^(?:[a-e]\s*[.)]\s*)?(?:12[\s-]?lead\s+)?(?:ECG|electrocardiogram|EKG)\b/i', $t)
            || preg_match('/\b(?:repeat\s+)?ECG\b/i', $l)
            || preg_match('/\b(?:arrange|request|book|obtain|perform)\s+(?:for\s+)?(?:an\s+)?(?:urgent\s+)?(?:12[\s-]?lead\s+)?(?:ECG|electrocardiogram)\b/i', $l));
        if (!$hasEcG) {
            continue;
        }
        $obN = sbaStyleNormalizeForSimilarity(sbaStyleOptionBodyWithoutLetterPrefix($oneOpt));
        if ($obN === '' || $caN === $obN || ($caN !== '' && str_contains($caN, $obN)) || ($obN !== '' && str_contains($obN, $caN))) {
            continue;
        }

        return true;
    }

    return false;
}

/**
 * Stem already uses explicit “ECG shows” / “electrocardiogram shows” / “initial ECG” phrasing (ACS investigation Pattern B cues).
 */
function sbaStyleStemContainsEcGShowsElectrogramShowsOrInitialEcG(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (preg_match('/\becg\s+shows\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\belectrocardiogram\s+shows\b/i', $stem)) {
        return true;
    }

    return (bool) preg_match('/\binitial\s+ecg\b/i', $stem);
}

/**
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsAcsEcgInitialContradictionHighPriority(array $question): array
{
    if (!sbaStyleFormQuestionAcsClinicalContext($question)) {
        return [];
    }
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!sbaStyleSkillIsInvestigationUk($sk)) {
        return [];
    }
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    if ($stem === '' || $leadIn === '') {
        return [];
    }
    if (!sbaStyleLeadInAsksInitialInvestigation($leadIn)) {
        return [];
    }
    if (!sbaStyleStemMentionsEcGProcessOrResultBroad($stem)) {
        return [];
    }
    $correctBlob = sbaStyleCorrectAnswerCombinedText($question);
    if ($correctBlob === '' || !preg_match('/\b(ecg|electrocardiogram|12[\s-]?\s*lead)\b/i', $correctBlob)) {
        return [];
    }

    return [
        [
            'code' => 'sequencing.acs_ecg_initial_contradiction_high',
            'severity' => 'high',
            'message' => '[High priority] ACS Investigation (Pattern clash): the stem already narrates an ECG (performed, arranged, reported, or interpreted) while the lead-in asks for the **initial investigation** and the keyed answer is still **ECG** — use **Pattern A** (no ECG in stem + initial investigation + ECG key) or **Pattern B** (ECG result in stem + **next** investigation lead-in + troponin key); remove all ECG findings from the stem if the key remains ECG.',
            'field' => 'lead_in',
        ],
    ];
}

/**
 * ECG keyed but stem already reports an ECG read-out (“ECG shows …”, etc.) — usually Pattern B with troponin key.
 *
 * @return list<array{code: string, severity: 'high', message: string, field: ?string}>
 */
function sbaStyleWarningsAcsInvestigationEcgKeyStemEcGShowsOrInitialPhrasesHigh(array $question): array
{
    if (!sbaStyleFormQuestionAcsClinicalContext($question)) {
        return [];
    }
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!sbaStyleSkillIsInvestigationUk($sk)) {
        return [];
    }
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    if ($stem === '' || !sbaStyleStemContainsEcGShowsElectrogramShowsOrInitialEcG($stem)) {
        return [];
    }
    $correctBlob = sbaStyleCorrectAnswerCombinedText($question);
    if ($correctBlob === '' || !preg_match('/\b(ecg|electrocardiogram|12[\s-]?\s*lead)\b/i', $correctBlob)) {
        return [];
    }
    if (sbaStyleLeadInAsksInitialInvestigation($leadIn) && sbaStyleStemMentionsEcGProcessOrResultBroad($stem)) {
        return [];
    }

    return [
        [
            'code' => 'acs.investigation_ecg_key_stem_ecg_shows_or_initial_ecg',
            'severity' => 'high',
            'message' => '[High priority] ACS Investigation: the keyed answer is **ECG** / **electrocardiogram** but the stem contains **ECG shows**, **electrocardiogram shows**, or **initial ECG** wording — never key ECG when the stem already reports an ECG read-out; remove ECG findings from the stem (**Pattern A**) or switch to **next investigation** and key **cardiac troponin** (**Pattern B**).',
            'field' => 'correct_answer',
        ],
    ];
}

/**
 * Initial investigation lead-in while the stem already documents ECG — fix lead-in/stem (key is not ECG).
 *
 * @return list<array{code: string, severity: 'high', message: string, field: ?string}>
 */
function sbaStyleWarningsAcsInvestigationInitialLeadInStemEcGButNonEcGKeyHigh(array $question): array
{
    if (!sbaStyleFormQuestionAcsClinicalContext($question)) {
        return [];
    }
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!sbaStyleSkillIsInvestigationUk($sk)) {
        return [];
    }
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    if ($stem === '' || $leadIn === '') {
        return [];
    }
    if (!sbaStyleLeadInAsksInitialInvestigation($leadIn)) {
        return [];
    }
    if (!sbaStyleStemMentionsEcGProcessOrResultBroad($stem)) {
        return [];
    }
    $correctBlob = sbaStyleCorrectAnswerCombinedText($question);
    if ($correctBlob !== '' && preg_match('/\b(ecg|electrocardiogram|12[\s-]?\s*lead)\b/i', $correctBlob)) {
        return [];
    }

    return [
        [
            'code' => 'acs.investigation_initial_lead_in_stem_already_has_ecg',
            'severity' => 'high',
            'message' => '[High priority] ACS Investigation: the lead-in asks for the **initial investigation** but the stem already documents an **ECG** (performed, arranged, reported, or interpreted) — never use an **initial investigation** lead-in after an ECG result; switch to **Pattern B** (**next investigation** + troponin or appropriate follow-on test) or strip ECG content from the stem for **Pattern A**.',
            'field' => 'lead_in',
        ],
    ];
}

function sbaStyleInvestigationHasAcsEcGInitialSequencingHigh(array $question): bool
{
    if (sbaStyleWarningsAcsEcgInitialContradictionHighPriority($question) !== []) {
        return true;
    }
    if (sbaStyleWarningsAcsInvestigationEcgKeyStemEcGShowsOrInitialPhrasesHigh($question) !== []) {
        return true;
    }
    if (sbaStyleWarningsAcsInvestigationInitialLeadInStemEcGButNonEcGKeyHigh($question) !== []) {
        return true;
    }

    return false;
}

function sbaStyleInvestigationAcsEcGStemKeyedEcGHighCoversStemKeyedEcGWarning(array $question): bool
{
    return sbaStyleWarningsAcsEcgInitialContradictionHighPriority($question) !== []
        || sbaStyleWarningsAcsInvestigationEcgKeyStemEcGShowsOrInitialPhrasesHigh($question) !== [];
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsStemResultMatchesKeyedInvestigation(array $question): array
{
    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $correctBlob = sbaStyleCorrectAnswerCombinedText($question);
    if ($stem === '' || $correctBlob === '') {
        return $warnings;
    }

    if (sbaStyleStemSuggestsEcGFindingsReported($stem) && preg_match('/\b(ecg|electrocardiogram|12[\s-]?\s*lead)\b/i', $correctBlob)) {
        if (!sbaStyleInvestigationAcsEcGStemKeyedEcGHighCoversStemKeyedEcGWarning($question)) {
            $warnings[] = [
                'code' => 'sequencing.stem_reports_ecg_keyed_ecg',
                'severity' => 'warning',
                'message' => 'Heuristic: stem appears to report ECG findings while the keyed answer is still an ECG / electrocardiogram — align stem, lead-in, and key so the investigation already described is not also the chosen next test.',
                'field' => 'stem',
            ];
        }
    }

    $skVal = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^(?:interpretation|diagnosis)$/i', $skVal)
        && preg_match('/\b(?:cardiac\s+troponin|troponin|high[\s-]sensitivity\s+troponin|hs[\s-]?troponin)\b.{0,90}\b(raised|elevated|positive|high|detectable|is\s+\d|\d+\s*ng)\b/i', $stem)
        && preg_match('/\btroponin\b/i', $correctBlob)) {
        $keyLooksLikeTroponinInvestigation = sbaStyleTextLooksLikeTroponinTestOrderOrAssay($correctBlob)
            || (bool) preg_match(
                '/^(?:[a-e]\s*[\.)]\s*)?(?:serial\s+)?(?:repeat\s+)?(?:urgent\s+)?(?:high[\s-]?sensitivity\s+)?(?:cardiac\s+)?troponin\b/i',
                trim($correctBlob)
            );
        if ($keyLooksLikeTroponinInvestigation) {
            $warnings[] = [
                'code' => 'sequencing.stem_reports_troponin_keyed_troponin',
                'severity' => 'warning',
                'message' => 'Heuristic: stem suggests troponin has already been reported while the keyed answer is still troponin — check for a sequencing contradiction.',
                'field' => 'stem',
            ];
        }
    }

    return $warnings;
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsEcGOptionLabelDuplication(array $question): array
{
    $opts = sbaStyleOptionTexts($question);
    if (count($opts) < 2) {
        return [];
    }

    $hasShortEcG = false;
    $hasLongEcG = false;
    foreach ($opts as $text) {
        $tl = mb_strtolower($text);
        if (preg_match('/\becg\b/', $tl) && !preg_match('/\belectrocardiogram\b/', $tl)) {
            $hasShortEcG = true;
        }
        if (preg_match('/\belectrocardiogram\b/', $tl)) {
            $hasLongEcG = true;
        }
    }

    if ($hasShortEcG && $hasLongEcG) {
        return [
            [
                'code' => 'options.ecg_and_electrocardiogram',
                'severity' => 'warning',
                'message' => 'Option set includes both “ECG” and “electrocardiogram” style labels; use one consistent UK wording for the same test.',
                'field' => null,
            ],
        ];
    }

    return [];
}

function sbaStyleStemSuggestsEcGFindingsReported(string $stem): bool
{
    if (preg_match('/\becg\s+shows\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\binitial\s+ecg\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\b(?:ecg|ekg|12[\s\-]?\s*lead(?:\s+ecg)?|electrocardiogram)\s+(?:shows|demonstrates|reveals|displays|reports|is\s+consistent\s+with)\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\b(?:ecg|ekg)\s*[:,]\s*.{0,120}\b(?:st[\s-]segment|st\s+elevation|st\s+depression|t[\s-]wave|pathological\s+q|qt[cz]?\b|prolonged\s+qt)\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\b(?:on|from)\s+the\s+(?:ecg|ekg)\b.{0,100}\b(?:st[\s-]segment|t[\s-]wave|elevation|depression|ischaemia|ischemia)\b/i', $stem)) {
        return true;
    }

    return false;
}

function sbaStyleStemContainsEcGIsArranged(string $stem): bool
{
    return (bool) preg_match('/\becg\s+is\s+arranged\b/i', $stem);
}

/**
 * ECG arranged / performed / completed / reported wording (broader than “findings reported”).
 */
function sbaStyleStemMentionsEcGProcessOrResultBroad(string $stem): bool
{
    if (sbaStyleStemContainsEcGIsArranged($stem)) {
        return true;
    }
    if (sbaStyleStemSuggestsEcGFindingsReported($stem)) {
        return true;
    }
    if (preg_match('/\becg\s+(?:was\s+)?(?:performed|done|completed|recorded)\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\belectrocardiogram\s+(?:is\s+)?(?:arranged|planned|performed|completed)\b/i', $stem)) {
        return true;
    }

    return false;
}

function sbaStyleBlobSuggestsSuspectedAcs(array $question): bool
{
    $parts = [];
    foreach (['stem', 'lead_in', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $parts[] = (string) $question[$k];
        }
    }
    $blob = mb_strtolower(implode(' ', $parts));

    return (bool) preg_match(
        '/\b(suspected\s+acute\s+coronary|acute\s+coronary\s+syndrome|\bacs\b|nstemi|stemi|non[\s-]?st\s+elevation|st\s+elevation\s+mi|chest\s+pain.{0,40}(cardiac|atypical|epigastric)|cardiac\s+troponin|serial\s+troponin)\b/',
        $blob
    );
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsAcsEcGArrangedSequencing(array $question): array
{
    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    if ($stem === '') {
        return $warnings;
    }

    $correctBlob = sbaStyleCorrectAnswerCombinedText($question);
    $correctMentionsEcG = $correctBlob !== '' && (bool) preg_match('/\b(ecg|electrocardiogram|12[\s-]?\s*lead)\b/i', $correctBlob);
    $correctMentionsTroponin = $correctBlob !== '' && (bool) preg_match('/\b(?:cardiac\s+)?troponin|high[\s-]sensitivity\s+troponin|hs[\s-]?troponin\b/i', $correctBlob);

    if (sbaStyleStemContainsEcGIsArranged($stem) && $correctBlob !== '' && !$correctMentionsEcG) {
        $warnings[] = [
            'code' => 'sequencing.ecg_arranged_keyed_not_ecg',
            'severity' => 'warning',
            'message' => 'Stem states “ECG is arranged” but the keyed answer is not ECG / electrocardiogram — align narrative with the intended next step, or remove pending-ECG wording if the key is a later investigation.',
            'field' => 'stem',
        ];
    }

    if (sbaStyleStemContainsEcGIsArranged($stem) && $leadIn !== '' && sbaStyleLeadInContainsInitialInvestigationPhrase($leadIn)) {
        $warnings[] = [
            'code' => 'sequencing.ecg_arranged_with_initial_lead_in',
            'severity' => 'warning',
            'message' => 'Stem states “ECG is arranged” while the lead-in asks for the initial investigation — usually the initial test is ECG before it is “arranged” in prose; either remove pending-ECG wording from the stem or switch to a “next investigation” lead-in.',
            'field' => 'lead_in',
        ];
    }

    if ($leadIn !== '' && sbaStyleLeadInContainsInitialInvestigationPhrase($leadIn)
        && $correctMentionsTroponin && !$correctMentionsEcG
        && sbaStyleStemMentionsEcGProcessOrResultBroad($stem)) {
        $warnings[] = [
            'code' => 'sequencing.initial_lead_in_troponin_key_with_ecg_stem',
            'severity' => 'warning',
            'message' => '[High priority] Lead-in asks for the initial investigation and the key is troponin-related while the stem already references ECG (arranged, performed, or findings) — fix sequencing: for “initial investigation” with ECG still to interpret, omit ECG from the stem and key ECG; otherwise use a “next investigation” lead-in and keep troponin as key.',
            'field' => 'lead_in',
        ];
    }

    return $warnings;
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsInvestigationOptionSentenceCase(array $question): array
{
    $warnings = [];
    $opts = sbaStyleOptionTexts($question);
    if (count($opts) < 3) {
        return $warnings;
    }

    foreach ($opts as $key => $text) {
        if (sbaStyleClassifyOptionLine($text) !== 'investigation') {
            return $warnings;
        }
        if (sbaStyleWordCount($text) > 14) {
            return $warnings;
        }
    }

    foreach ($opts as $key => $text) {
        $t = trim($text);
        if ($t === '') {
            continue;
        }
        if (preg_match('#^\p{Ll}#u', $t)) {
            $warnings[] = [
                'code' => 'options.investigation_sentence_case',
                'severity' => 'warning',
                'message' => 'Investigation option should use sentence case (e.g. “Full blood count”), not an all-lowercase line.',
                'field' => $key,
            ];
        }
    }

    return $warnings;
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsAcsWeakScreeningDistractors(array $question): array
{
    if (!sbaStyleBlobSuggestsSuspectedAcs($question)) {
        return [];
    }

    $warnings = [];
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
        if (!isset($question[$field])) {
            continue;
        }
        $t = mb_strtolower(trim((string) $question[$field]));
        if ($t === '') {
            continue;
        }
        if (preg_match('/\burinalysis\b|\burine\s+dipstick\b|\blipid\s+profile\b|\blipid\s+panel\b|\bcholesterol\s+profile\b/', $t)) {
            $warnings[] = [
                'code' => 'distractor.acs_weak_screening_test',
                'severity' => 'warning',
                'message' => 'Heuristic: suspected ACS item includes a chronic or non-urgent screening style test (e.g. urinalysis, lipid profile) — use only if the vignette clearly justifies it.',
                'field' => $field,
            ];
        }
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsShortInvestigationOptionOrder(array $question): array
{
    $warnings = [];
    $opts = sbaStyleOptionTexts($question);
    if (count($opts) !== 5) {
        return $warnings;
    }

    foreach ($opts as $text) {
        if (sbaStyleClassifyOptionLine($text) !== 'investigation') {
            return $warnings;
        }
        if (sbaStyleWordCount($text) > 10) {
            return $warnings;
        }
    }

    $order = ['option_a', 'option_b', 'option_c', 'option_d', 'option_e'];
    $vals = [];
    foreach ($order as $k) {
        $vals[] = mb_strtolower(trim($opts[$k]));
    }
    $sorted = $vals;
    sort($sorted, SORT_NATURAL | SORT_FLAG_CASE);

    if ($vals !== $sorted) {
        $warnings[] = [
            'code' => 'options.investigation_alpha_order',
            'severity' => 'warning',
            'message' => 'All five options look like short parallel investigations but are not in alphabetical order. Sort A–E alphabetically by option text, or use a clearly defendable clinical sequence (e.g. bedside tests before blood tests) that readers can infer from the stem without cueing the key.',
            'field' => null,
        ];
    }

    return $warnings;
}

/**
 * Acute management, hypoxia/oxygen coherence, and observation wording (warnings only).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsAcuteManagement(array $question): array
{
    $warnings = [];
    $stem = isset($question['stem']) ? (string) $question['stem'] : '';
    if ($stem === '') {
        return $warnings;
    }

    $warnings = array_merge($warnings, sbaStyleAcuteOxygenDistractorHypoxia($question, $stem));
    $warnings = array_merge($warnings, sbaStyleAcuteInhalerTechniqueHypoxicAsthma($question, $stem));
    $warnings = array_merge($warnings, sbaStyleAcuteObservationOrder($stem));
    $warnings = array_merge($warnings, sbaStyleAcuteHeartRateNotPulse($stem));

    return $warnings;
}

/**
 * Heuristic: acute asthma with SpO₂ under 92% and an option about inhaler technique review.
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleAcuteInhalerTechniqueHypoxicAsthma(array $question, string $stem): array
{
    $warnings = [];
    if (!preg_match('/\basthma\b/i', $stem) || !sbaStyleStemIndicatesSpO2Below92($stem)) {
        return $warnings;
    }

    foreach (['a', 'b', 'c', 'd', 'e'] as $suffix) {
        $key = 'option_' . $suffix;
        if (!isset($question[$key])) {
            continue;
        }
        $opt = trim((string) $question[$key]);
        if ($opt === '') {
            continue;
        }
        if (!preg_match('/review\s+(?:of\s+)?inhaler\s+technique|inhaler\s+technique\s+review/i', $opt)) {
            continue;
        }
        $warnings[] = [
            'code' => 'acute.inhaler_technique_hypoxic_asthma',
            'severity' => 'warning',
            'message' => 'Heuristic: option mentions inhaler technique review in a stem suggesting hypoxic acute asthma — verify this is fair (e.g. deliberately testing under-treatment) rather than an obviously insufficient distractor.',
            'field' => $key,
        ];
    }

    return $warnings;
}

function sbaStyleLeadInAsksDefinitivePeReperfusionTreatment(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:life[\s-]threatening\s+cause|definitive\s+emergency\s+treatment|addresses\s+the\s+life[\s-]threatening|reperfusion|thrombolysis|embolectomy)\b/i',
        $leadIn
    ) || (bool) preg_match(
        '/\bmassive\s+pulmonary\s+embol(?:ism|us).{0,40}\bshock\b|\bsuspected\s+massive\s+(?:pulmonary\s+)?embol/i',
        $leadIn
    );
}

function sbaStyleLeadInAsksGenericImmediateManagementNotDefinitivePe(string $leadIn): bool
{
    if ($leadIn === '' || !function_exists('sbaStyleLeadInAsksTreatmentWithoutInvestigationFrame')
        || !sbaStyleLeadInAsksTreatmentWithoutInvestigationFrame($leadIn)) {
        return false;
    }

    return !sbaStyleLeadInAsksDefinitivePeReperfusionTreatment($leadIn);
}

/**
 * @param array<string, mixed> $question
 */
function sbaStyleAcuteOxygenDistractorHypoxia(array $question, string $stem): array
{
    $warnings = [];
    if (!sbaStyleStemIndicatesSpO2Below92($stem)) {
        return $warnings;
    }

    $letter = strtoupper(trim((string) ($question['correct_answer_letter'] ?? '')));
    if (!preg_match('/^[A-E]$/', $letter)) {
        return $warnings;
    }

    $correctKey = 'option_' . strtolower($letter);
    $correctText = isset($question[$correctKey]) ? (string) $question[$correctKey] : '';
    if ($correctText !== '' && sbaStyleTextMentionsOxygen($correctText)) {
        return $warnings;
    }

    $hasOxygenDistractor = false;
    foreach (['A', 'B', 'C', 'D', 'E'] as $L) {
        if ($L === $letter) {
            continue;
        }
        $key = 'option_' . strtolower($L);
        if (!isset($question[$key])) {
            continue;
        }
        $opt = trim((string) $question[$key]);
        if ($opt !== '' && sbaStyleOptionIsOxygenFocusedDistractor($opt)) {
            $hasOxygenDistractor = true;
            break;
        }
    }
    if (!$hasOxygenDistractor) {
        return $warnings;
    }

    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $peEmerg = preg_match('/^emergency\s+management$/i', $sk)
        && sbaStyleQuestionSuggestsPulmonaryEmbolismCoreScenario($question);
    $genericLeadIn = sbaStyleLeadInAsksGenericImmediateManagementNotDefinitivePe($leadIn);

    $peInstability = sbaStyleStemSuggestsPeHaemodynamicInstabilityForReperfusion($stem);
    $thrombolysisKey = $correctText !== ''
        && sbaStyleKeyedAnswerSuggestsPeThrombolysisOrReperfusionTherapy($correctText);
    if ($peEmerg && $thrombolysisKey && sbaStyleStemIndicatesSpO2Below92($stem) && !sbaStyleTextMentionsOxygen($correctText)) {
        $oxygenPlusThrombDistractor = false;
        foreach (['A', 'B', 'C', 'D', 'E'] as $L) {
            if ($L === $letter) {
                continue;
            }
            $key = 'option_' . strtolower($L);
            $opt = isset($question[$key]) ? trim((string) $question[$key]) : '';
            if ($opt !== '' && function_exists('sbaPeOptionLineSuggestsOxygenPlusThrombolysis')
                && sbaPeOptionLineSuggestsOxygenPlusThrombolysis($opt)) {
                $oxygenPlusThrombDistractor = true;
                break;
            }
        }
        if ($oxygenPlusThrombDistractor || $hasOxygenDistractor) {
            $fieldFair = ($genericLeadIn && $peInstability) ? 'lead_in' : 'correct_answer';
            $warnings[] = [
                'code' => 'pe.emergency_oxygen_reperfusion_fairness',
                'severity' => 'high',
                'message' => '[High priority] Pulmonary embolism Emergency Management: the stem is **hypoxic** and an option offers **oxygen plus thrombolysis/reperfusion**, but the keyed answer does **not** include **oxygen** (e.g. keys **IV fluids plus thrombolysis** or thrombolysis alone). Key **oxygen plus urgent thrombolysis/reperfusion** in one line, **or** revise the lead-in to definitive reperfusion treatment and remove competing oxygen-plus-reperfusion distractors.',
                'field' => $fieldFair,
            ];

            return $warnings;
        }
    }

    if ($peEmerg && $thrombolysisKey && $genericLeadIn && $peInstability) {
        $warnings[] = [
            'code' => 'acute.oxygen_distractor_hypoxia',
            'severity' => 'warning',
            'message' => 'Pulmonary embolism Emergency Management: SpO₂ is low and **oxygen therapy** competes with a **thrombolysis / reperfusion** key under a **generic immediate-management** lead-in. Revise the **lead-in** to target definitive emergency reperfusion treatment (e.g. life-threatening cause / massive PE with shock), **or** key **oxygen plus escalation/reperfusion** in one line, **or** remove **standalone oxygen therapy** as a distractor — keep all options as emergency actions.',
            'field' => 'lead_in',
        ];

        return $warnings;
    }

    $warnings[] = [
        'code' => 'acute.oxygen_distractor_hypoxia',
        'severity' => 'warning',
        'message' => 'Stem suggests SpO₂ under 92% while a non-keyed option centres on oxygen delivery and the keyed answer does not mention oxygen; check one-best-answer fairness for hypoxic acute care.',
        'field' => 'stem',
    ];

    return $warnings;
}

function sbaStyleStemIndicatesSpO2Below92(string $stem): bool
{
    if (preg_match_all('/(?:oxygen\s+saturation|spo2|spo\s*2|saturation(?:\s+levels?)?)[^\d%]{0,50}(\d{1,3})\s*%/i', $stem, $m)) {
        foreach ($m[1] as $v) {
            $n = (int) $v;
            if ($n >= 50 && $n < 92) {
                return true;
            }
        }
    }
    if (preg_match_all('/\b(\d{1,3})%\s*(?:on|with)\s*(?:breathing\s+)?(?:room\s+)?air\b/i', $stem, $m2)) {
        foreach ($m2[1] as $v) {
            if ((int) $v < 92) {
                return true;
            }
        }
    }

    return false;
}

function sbaStyleTextMentionsOxygen(string $text): bool
{
    return (bool) preg_match('/\boxygen\b|\bo₂\b|\bo2\b/i', $text);
}

function sbaStyleOptionIsOxygenFocusedDistractor(string $opt): bool
{
    if (!sbaStyleTextMentionsOxygen($opt)) {
        return false;
    }
    if (preg_match('/\b(salbutamol|terbutaline|ipratropium|nebulis|nebules|prednisolone|hydrocortisone|dexamethasone|magnesium\s+sulphate|adrenaline|epinephrine)\b/i', $opt)) {
        return false;
    }
    if (preg_match('/\b(nasal cannul|face mask|venturi|reservoir|non-?invasive|niv\b|high[- ]flow|15\s*l|oxygen therapy|give oxygen|start oxygen)\b/i', $opt)) {
        return true;
    }

    return (bool) preg_match('/\boxygen\b/i', $opt);
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleAcuteObservationOrder(string $stem): array
{
    $warnings = [];
    $found = sbaStyleStemObservationFirstOffsets($stem);
    if (count($found) === 0) {
        return $warnings;
    }

    if (isset($found['pulse'], $found['rr']) && $found['pulse'] > $found['rr']) {
        $warnings[] = [
            'code' => 'stem.observation_pulse_after_rr',
            'severity' => 'warning',
            'message' => 'Pulse (or heart rate) appears after respiratory rate; MS AKT order is temperature, pulse, blood pressure, respiratory rate, then oxygen saturation.',
            'field' => 'stem',
        ];
    }

    if (isset($found['bp'], $found['rr']) && $found['bp'] > $found['rr']) {
        $warnings[] = [
            'code' => 'stem.observation_bp_after_rr',
            'severity' => 'warning',
            'message' => 'Blood pressure appears after respiratory rate; MS AKT order is temperature, pulse, blood pressure, respiratory rate, then oxygen saturation.',
            'field' => 'stem',
        ];
    }

    if (isset($found['spo2'], $found['pulse']) && $found['spo2'] < $found['pulse']) {
        $warnings[] = [
            'code' => 'stem.observation_spo2_before_pulse',
            'severity' => 'warning',
            'message' => 'Oxygen saturation appears before pulse; when both are stated, place oxygen saturation after pulse and blood pressure.',
            'field' => 'stem',
        ];
    }

    if (isset($found['spo2'], $found['bp']) && $found['spo2'] < $found['bp']) {
        $warnings[] = [
            'code' => 'stem.observation_spo2_before_bp',
            'severity' => 'warning',
            'message' => 'Oxygen saturation appears before blood pressure; when both are stated, place oxygen saturation after blood pressure (and pulse).',
            'field' => 'stem',
        ];
    }

    if (count($found) < 2) {
        return $warnings;
    }

    $canonical = ['temp', 'pulse', 'bp', 'rr', 'spo2'];
    $expected = [];
    foreach ($canonical as $k) {
        if (isset($found[$k])) {
            $expected[] = $k;
        }
    }

    $actual = array_keys($found);
    usort($actual, static function ($a, $b) use ($found) {
        return $found[$a] <=> $found[$b];
    });

    if ($actual !== $expected) {
        $warnings[] = [
            'code' => 'stem.observation_order',
            'severity' => 'warning',
            'message' => 'Vital-sign style observations may be out of MS AKT order; prefer temperature, then pulse, blood pressure, respiratory rate, then oxygen saturation (preserve this relative order for whichever vitals you include).',
            'field' => 'stem',
        ];
    }

    return $warnings;
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleAcuteHeartRateNotPulse(string $stem): array
{
    $warnings = [];
    if (preg_match('/\bheart rate\b/i', $stem)) {
        $warnings[] = [
            'code' => 'stem.observation_heart_rate',
            'severity' => 'warning',
            'message' => 'Stem uses “heart rate” in an observations context; MS AKT style prefers “pulse”.',
            'field' => 'stem',
        ];
    }

    return $warnings;
}

/**
 * UK undergraduate wording and non–UK/US clinical phrasing (warnings only).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsUkTerminology(array $question): array
{
    $warnings = [];
    $textFields = ['title', 'stem', 'lead_in', 'justification', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e', 'correct_answer'];

    foreach ($textFields as $field) {
        $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
        if ($t === '') {
            continue;
        }

        if (preg_match('/\bcomplete\s+blood\s+count\b/i', $t)) {
            $warnings[] = [
                'code' => 'uk.complete_blood_count',
                'severity' => 'warning',
                'message' => 'UK style prefers “full blood count” rather than “complete blood count”.',
                'field' => $field,
            ];
        }
        if (preg_match('/\bobservations?\s+(?:show|include|reveal)s?\b/i', $t)) {
            $warnings[] = [
                'code' => 'uk.observations_show',
                'severity' => 'warning',
                'message' => 'Avoid “observations show/include/reveal”; prefer direct MS AKT-style measurements in order: temperature, pulse, BP, respiratory rate, oxygen saturation.',
                'field' => $field,
            ];
        }
        if (preg_match('/\bwith\s+known\b/i', $t) && !preg_match('/\bwith\s+known\s+(?:drug\s+)?allerg/i', $t)) {
            $warnings[] = [
                'code' => 'uk.with_known_condition',
                'severity' => 'warning',
                'message' => 'Prefer “has [condition]” rather than “with known [condition]” for current comorbidities.',
                'field' => $field,
            ];
        }
        if (preg_match('/\bvital\s+signs\s+show\b/i', $t)) {
            $warnings[] = [
                'code' => 'uk.vital_signs_show',
                'severity' => 'warning',
                'message' => 'Avoid “vital signs show”; prefer direct observation phrasing in MS AKT order (e.g. “Her temperature is …, pulse …, blood pressure …, respiratory rate … and oxygen saturation …”).',
                'field' => $field,
            ];
        } elseif (preg_match('/\bvital\s+signs\b/i', $t)) {
            $warnings[] = [
                'code' => 'uk.vital_signs',
                'severity' => 'warning',
                'message' => 'Prefer UK undergraduate phrasing such as “observations” (or name specific measurements) rather than “vital signs”.',
                'field' => $field,
            ];
        }
        if (preg_match('/\bhistory\s+of\s+(?:central\s+)?chest\s+pain\b/i', $t)) {
            $warnings[] = [
                'code' => 'uk.history_of_chest_pain',
                'severity' => 'warning',
                'message' => 'Avoid “history of chest pain” / “history of central chest pain”; prefer “has chest pain”, “has had chest pain”, or neutral narrative of the current episode.',
                'field' => $field,
            ];
        }
        if (preg_match('/\bmonitor\s+(?:in|on)\s+the\s+ward\b/i', $t)) {
            $warnings[] = [
                'code' => 'uk.monitor_in_the_ward',
                'severity' => 'warning',
                'message' => 'Avoid vague “monitor on/in the ward” wording; prefer clearer sequencing or disposition language where proportionate (e.g. observe with analgesia, admit for observation).',
                'field' => $field,
            ];
        }
        if (preg_match('/\bchief\s+complaint\b/i', $t)) {
            $warnings[] = [
                'code' => 'uk.chief_complaint',
                'severity' => 'warning',
                'message' => 'Prefer “presenting complaint” or neutral narrative wording rather than “chief complaint”.',
                'field' => $field,
            ];
        }
        if (preg_match('/\bemergency\s+room\b/i', $t)) {
            $warnings[] = [
                'code' => 'uk.emergency_room',
                'severity' => 'warning',
                'message' => 'Prefer UK phrasing such as “emergency department” (or “ED” / “A&E” where appropriate) rather than “emergency room”.',
                'field' => $field,
            ];
        }
        if (preg_match('/(?<![A-Za-z])ER(?![A-Za-z])/i', $t)) {
            $warnings[] = [
                'code' => 'uk.er_token',
                'severity' => 'warning',
                'message' => 'Standalone “ER” is US usage; prefer “emergency department”, “ED”, or “A&E” in UK undergraduate items.',
                'field' => $field,
            ];
        }
        if (preg_match('/\boxygen\s+saturation\s+is\s+\d{1,3}\s*%\s+on\s+(?:room\s+)?air\b/i', $t)) {
            $warnings[] = [
                'code' => 'uk.oxygen_saturation_on_air',
                'severity' => 'warning',
                'message' => 'Prefer “oxygen saturation is …% breathing air” rather than “on air” or “on room air”.',
                'field' => $field,
            ];
        }
        if (preg_match('/\bon\s+breathing\s+air\b/i', $t)) {
            $warnings[] = [
                'code' => 'uk.on_breathing_air',
                'severity' => 'warning',
                'message' => 'Prefer “oxygen saturation is …% breathing air” rather than “on breathing air”.',
                'field' => $field,
            ];
        }

        $usSpellings = [
            ['/\bprioritized\b/i', 'uk.spelling_prioritized', 'prioritised'],
            ['/\bhospitalized\b/i', 'uk.spelling_hospitalized', 'hospitalised'],
            ['/\brecognized\b/i', 'uk.spelling_recognized', 'recognised'],
            ['/\bhypovolemic\b/i', 'uk.spelling_hypovolemic', 'hypovolaemic'],
            ['/\bischemic\b/i', 'uk.spelling_ischemic', 'ischaemic'],
            ['/\bischemia\b/i', 'uk.spelling_ischemia', 'ischaemia'],
            ['/\bmalaena\b/i', 'uk.spelling_malaena', 'melaena'],
            ['/\bmelena\b/i', 'uk.spelling_melena', 'melaena'],
            ['/\besophageal\b/i', 'uk.spelling_esophageal', 'oesophageal'],
            ['/\bhematemesis\b/i', 'uk.spelling_hematemesis', 'haematemesis'],
            ['/\bhematemessis\b/i', 'uk.spelling_hematemessis', 'haematemesis'],
            ['/\bhaematemessis\b/i', 'uk.spelling_haematemessis', 'haematemesis'],
            ['/\banemia\b/i', 'uk.spelling_anemia', 'anaemia'],
            ['/\bedema\b/i', 'uk.spelling_edema', 'oedema'],
        ];
        if (preg_match('/\bbicarbonate\s*[-−:]\s*\d/i', $t)) {
            $warnings[] = [
                'code' => 'uk.bicarbonate_separator',
                'severity' => 'warning',
                'message' => 'Prefer “bicarbonate 28 mmol/L” (value directly after the label) rather than a dash or colon after “bicarbonate”.',
                'field' => $field,
            ];
        }
        foreach ($usSpellings as [$regex, $code, $uk]) {
            if (preg_match($regex, $t)) {
                $warnings[] = [
                    'code' => $code,
                    'severity' => 'warning',
                    'message' => "US spelling detected; UK output should use “{$uk}”.",
                    'field' => $field,
                ];
            }
        }
    }

    $stemUk = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stemUk !== '' && preg_match('/\bhistory\s+of\s+asthma\b/i', $stemUk)
        && !preg_match('/\b(?:family|maternal|paternal)\s+history\s+of\s+asthma\b/i', $stemUk)) {
        $warnings[] = [
            'code' => 'uk.history_of_asthma_prefer_has_asthma',
            'severity' => 'warning',
            'message' => 'Prefer “has asthma” or “with asthma” rather than “history of asthma” where meaning is unchanged.',
            'field' => 'stem',
        ];
    }

    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
        $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
        if ($t === '' || !preg_match('/\slevels?\b/i', $t)) {
            continue;
        }
        if (!sbaStyleOptionTextQualifiesForInvestigationLevelsHeuristic($t)) {
            continue;
        }
        $warnings[] = [
            'code' => 'uk.option_investigation_levels',
            'severity' => 'warning',
            'message' => 'Investigation-style option includes unnecessary “levels”; prefer concise UK wording (e.g. “cardiac troponin I” rather than “… levels”).',
            'field' => $field,
        ];
    }

    return sbaStyleDedupeWarnings($warnings);
}

/**
 * @return list<string>
 */
function sbaStyleStemSplitRoughSentences(string $stem): array
{
    $stem = trim($stem);
    if ($stem === '') {
        return [];
    }
    $parts = preg_split('/(?<=[.!?])\s+/u', $stem);
    if (!is_array($parts)) {
        return [$stem];
    }
    $out = [];
    foreach ($parts as $p) {
        $p = trim((string) $p);
        if ($p !== '') {
            $out[] = $p;
        }
    }

    return $out !== [] ? $out : [$stem];
}

/**
 * Slightly finer splits for lab-qualitative heuristics (semicolons often separate troponin value from other clauses).
 *
 * @return list<string>
 */
function sbaStyleStemSplitRoughSentencesForLabQualitativeCheck(string $stem): array
{
    $stem = trim($stem);
    if ($stem === '') {
        return [];
    }
    $parts = preg_split('/(?<=[.!?;])\s+/u', $stem);
    if (!is_array($parts)) {
        return [$stem];
    }
    $out = [];
    foreach ($parts as $p) {
        $p = trim((string) $p);
        if ($p !== '') {
            $out[] = $p;
        }
    }

    return $out !== [] ? $out : [$stem];
}

function sbaStyleQuestionLeadInLooksDiagnosisTyped(string $leadIn): bool
{
    $leadIn = trim(mb_strtolower($leadIn));
    if ($leadIn === '') {
        return false;
    }
    if (preg_match('/\b(?:what\s+is\s+the|the\s+most\s+likely)\s+(?:diagnosis|underlying|condition|cause|explanation)\b/', $leadIn)) {
        return true;
    }
    if (preg_match('/\b(?:most\s+likely|likely)\s+(?:diagnosis|underlying\s+condition|condition)\b/', $leadIn)) {
        return true;
    }
    if (preg_match('/\bdiagnosis\b/', $leadIn)) {
        return true;
    }

    return false;
}

function sbaStyleSentenceHasLabNumericValueAndReferenceRange(string $s): bool
{
    $hasNumeric = (bool) preg_match(
        '/\d+(?:\.\d+)?\s*(?:mmol\/|µmol|μmol|U\/\s*L|IU\/\s*L|g\/L|mg\/L|ng(?:\s*\/?\s*)L|pg\/mL|×\s*10|x\s*10|109\/|cells?\/|%)/i',
        $s
    );
    $hasRef = (bool) preg_match(
        '/\(\s*<?\s*[\d.]+\s*\)|\(\s*[\d.]+\s*[-–<]\s*[\d.]+\s*\)|\breference\s+(?:range|interval|values?)\b|'
        . '|\bnormal\s+(?:range|upper|lower|value|limits?|biochemical\s+range)\b|\bURL\b|\bULN\b|\bLLN\b|'
        . '|\d{1,4}\s*[-–]\s*\d{1,4}\s*(?:mmol|µmol|μmol|U\/|IU\/|g\/)/i',
        $s
    );

    return $hasNumeric && $hasRef;
}

function sbaStyleSentenceMatchesInvestigationQualitativeLabWording(string $s): bool
{
    if (!preg_match(sbaStyleUkInvestigationLabishRegex(), $s)) {
        // “Blood tests show …” without naming a marker in the same sentence.
        if (!preg_match(
            '/\b(?:blood|serum|plasma)\s+tests?\s+(?:show|reveal|demonstrate|are)\b[^.!?]{0,160}\b(?:elevated|raised|low|high|abnormal)\b/is',
            $s
        )) {
            return false;
        }
    }

    $marker = sbaStyleUkInvestigationMarkerAlternation();
    $qual = 'elevated|raised|low|abnormal';
    $patterns = [
        '/\b(?:' . $marker . ')\b[^.!?]{0,140}\b(?:is|are|was|were|remains)\s+(?:markedly\s+|slightly\s+|very\s+)?(?:' . $qual . ')\b/is',
        '/\b(?:is|are|was|were|remains)\s+(?:markedly\s+|slightly\s+|very\s+)?(?:elevated|raised)\b[^.!?]{0,90}\b(?:' . $marker . ')\b/is',
        '/\b(?:serum|plasma)\s+(?:sodium|potassium|calcium|magnesium|phosphate|glucose|urea|creatinine|bilirubin|albumin|amylase|lipase)\s+(?:is|was|are|were)\s+(?:markedly\s+|slightly\s+|very\s+)?(?:' . $qual . '|high|low)\b/is',
        '/\b(?:' . $marker . ')\b[^.!?]{0,100}\b(?:is|was|are|were)\s+high\b/is',
        '/\b(?:blood|serum|plasma)\s+tests?\s+(?:show|reveal|demonstrate|are)\b[^.!?]{0,160}\b(?:' . $qual . '|high|low)\b/is',
    ];
    foreach ($patterns as $p) {
        if (!preg_match($p, $s)) {
            continue;
        }
        if (preg_match('/\b(?:is|was|are|were)\s+high\b/i', $s) && preg_match('/\bhigh\s+dose\b/i', $s)) {
            continue;
        }

        return true;
    }

    return false;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsStemQualitativeLabsWithoutNumericRef(array $question): array
{
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '') {
        return [];
    }
    $troponinNumericOk = sbaStyleStemBlobHasTroponinNumericWithReferenceStyle($stem);
    foreach (sbaStyleStemSplitRoughSentencesForLabQualitativeCheck($stem) as $sentence) {
        if (!sbaStyleSentenceMatchesInvestigationQualitativeLabWording($sentence)) {
            continue;
        }
        if (sbaStyleSentenceHasLabNumericValueAndReferenceRange($sentence)) {
            continue;
        }
        if ($troponinNumericOk && preg_match('/\b(?:cardiac\s+)?troponin|hs[\s-]?cTn|cTnI|cTnT\b/i', $sentence)) {
            continue;
        }

        return [
            [
                'code' => 'investigation.qualitative_labs_without_numeric_ref',
                'severity' => 'warning',
                'message' => 'Stem uses qualitative investigation wording (e.g. “elevated”, “raised”, “low”, “high”, “abnormal”) for blood-test style results without both a numerical value and reference range in the same sentence — prefer numbers with normal limits.',
                'field' => 'stem',
            ],
        ];
    }

    return [];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsDiagnosisStemClassicCase(array $question): array
{
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !sbaStyleQuestionLeadInLooksDiagnosisTyped($leadIn)) {
        return [];
    }
    if (!preg_match('/\bclassic\s+case\b/i', $stem)) {
        return [];
    }

    return [
        [
            'code' => 'diagnosis.stem_classic_case',
            'severity' => 'warning',
            'message' => 'Diagnosis-style lead-in with “classic case” phrasing in the stem can over-signal the answer — prefer neutral clinical narrative.',
            'field' => 'stem',
        ],
    ];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsJustificationVignettePresents(array $question): array
{
    return [];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsStemAdmitsTo(array $question): array
{
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !preg_match('/\badmits\s+to\b/i', $stem)) {
        return [];
    }

    return [
        [
            'code' => 'wording.stem_admits_to',
            'severity' => 'warning',
            'message' => 'Avoid judgemental phrasing “admits to”; prefer neutral wording such as “reports” or “describes”.',
            'field' => 'stem',
        ],
    ];
}

function sbaStyleStemHasEpigastricPainCue(string $stem): bool
{
    return (bool) preg_match(
        '/\bepigastr(?:ic|ium)(?:\s+region)?\s+pain\b|\bpain\s+(?:in|at)\s+(?:the\s+)?epigastr/i',
        $stem
    );
}

function sbaStyleStemHasRadiationToBackCue(string $stem): bool
{
    if (!preg_match('/\bback\b/i', $stem)) {
        return false;
    }
    if (preg_match(
        '/(?:epigastr(?:ic|ium)|upper\s+abdominal)[^.!?]{0,120}(?:radiat(?:es|ing)?|extend(?:s|ing)?|spread(?:s|ing)?|shoot(?:s|ing)?|go(?:es|ing)?|felt)\w*[^.!?]{0,50}(?:to|into|through)\s+(?:the\s+)?back|'
        . '(?:radiat(?:es|ing)?|extend(?:s|ing)?|spread(?:s|ing)?|shoot(?:s|ing)?|go(?:es|ing)?|felt)\w*[^.!?]{0,50}(?:to|into|through)\s+(?:the\s+)?back[^.!?]{0,120}(?:epigastr|upper\s+abdominal)/i',
        $stem
    )) {
        return true;
    }
    if (preg_match('/\bthrough\s+to\s+the\s+back\b/i', $stem) && preg_match('/\bepigastr/i', $stem)) {
        return true;
    }
    if (preg_match('/\bpain\b[^.!?]{0,120}(?:radiat(?:es|ing)?|extend(?:s|ing)?|spread(?:s|ing)?|shoot(?:s|ing)?)\w*[^.!?]{0,50}\bback\b/i', $stem)) {
        return true;
    }

    return false;
}

function sbaStyleStemNearestReferenceRangeUpperUPerL(string $stem, int $anchorPos): ?float
{
    $best = null;
    $bestDist = PHP_INT_MAX;
    if (!preg_match_all(
        '/(\d+(?:\.\d+)?)\s*[-–]\s*(\d+(?:\.\d+)?)\s*U\/\s*L/is',
        $stem,
        $m,
        PREG_OFFSET_CAPTURE
    )) {
        return null;
    }
    foreach ($m[0] as $i => $full) {
        $pos = (int) $full[1];
        $lo = (float) $m[1][$i][0];
        $hi = (float) $m[2][$i][0];
        $upper = max($lo, $hi);
        if ($upper >= 10000.0) {
            continue;
        }
        $dist = abs($pos - $anchorPos);
        if ($dist <= 320 && $dist < $bestDist) {
            $bestDist = $dist;
            $best = $upper;
        }
    }

    return $best;
}

function sbaStyleStemDescribesAmylaseOrLipaseAboveThreeTimesUpperLimit(string $stem): bool
{
    if (!preg_match('/\b(?:serum\s+)?(?:amylase|lipase)\b/i', $stem)) {
        return false;
    }
    if (preg_match(
        '/\b(?:>|greater\s+than|more\s+than|over|above|exceeds?|at\s+least)\s*(?:3\s*[\s×x]|[\s×x]\s*3|three\s*(?:times|fold))\b[^.!?]{0,220}\b(?:serum\s+)?(?:amylase|lipase)\b/is',
        $stem
    )) {
        return true;
    }
    if (preg_match(
        '/\b(?:serum\s+)?(?:amylase|lipase)\b[^.!?]{0,220}\b(?:>|greater\s+than|more\s+than|over|above|exceeds?|at\s+least)\s*(?:3\s*[\s×x]|[\s×x]\s*3|three\s*(?:times|fold))\b/is',
        $stem
    )) {
        return true;
    }
    if (preg_match_all(
        '/\b(?:serum\s+)?(?:amylase|lipase)\b[^.!?]{0,140}?(\d+(?:\.\d+)?)\s*U\/\s*L/is',
        $stem,
        $matches,
        PREG_OFFSET_CAPTURE
    )) {
        foreach ($matches[1] as $pair) {
            $v = (float) $pair[0];
            $pos = (int) $pair[1];
            $upper = sbaStyleStemNearestReferenceRangeUpperUPerL($stem, $pos);
            if ($upper !== null && $upper > 0.0 && $v > 3.0 * $upper) {
                return true;
            }
        }
    }

    return false;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsStemAcutePancreatitisTriadicGiveaway(array $question): array
{
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !sbaStyleQuestionLeadInLooksDiagnosisTyped($leadIn)) {
        return [];
    }
    if (!sbaStyleStemHasEpigastricPainCue($stem) || !sbaStyleStemHasRadiationToBackCue($stem)) {
        return [];
    }
    if (!sbaStyleStemDescribesAmylaseOrLipaseAboveThreeTimesUpperLimit($stem)) {
        return [];
    }

    return [
        [
            'code' => 'diagnosis.pancreatitis_epigastric_back_enzyme_threefold',
            'severity' => 'warning',
            'message' => 'Diagnosis-style item stacks epigastric pain, radiation to the back, and amylase/lipase above three times the upper reference limit — a strong acute pancreatitis giveaway unless each element is needed for the learning point.',
            'field' => 'stem',
        ],
    ];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsStemQualitativePrefixedNumericInvestigations(array $question): array
{
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '') {
        return [];
    }
    foreach (sbaStyleStemSplitRoughSentences($stem) as $sentence) {
        if (!sbaStyleSentenceHasRedundantQualitativeWithNumericLab($sentence)) {
            continue;
        }

        return [
            [
                'code' => 'investigation.qualitative_with_numeric_same_result',
                'severity' => 'warning',
                'message' => 'Stem combines qualitative labels (“elevated”, “raised”, “low”, “high”, “abnormal”) with the same numerical investigation result — prefer values with reference ranges only, and let candidates interpret against the interval.',
                'field' => 'stem',
            ],
        ];
    }

    return [];
}

function sbaStyleSentenceHasRedundantQualitativeWithNumericLab(string $sentence): bool
{
    if ($sentence === '') {
        return false;
    }
    if (!preg_match('/\d+(?:\.\d+)?\s*(?:U\/\s*L|IU\/\s*L|mmol\/|µmol|μmol|g\/\s*L|mg\/\s*L|ng\/\s*L)/i', $sentence)) {
        return false;
    }
    if (!preg_match(sbaStyleUkInvestigationLabishRegex(), $sentence)) {
        return false;
    }
    if (!preg_match('/\b(?:elevated|raised|low|high|abnormal)\b/i', $sentence)) {
        return false;
    }
    if (preg_match('/\bhigh\s+dose\b/i', $sentence) && !preg_match('/\b(?:troponin|amylase|lipase|creatinine|crp|sodium|potassium|bilirubin|inr|haemoglobin|ferritin)\b/i', $sentence)) {
        return false;
    }

    return true;
}

function sbaStyleQuestionLooksAcutePancreatitisDiagnosisScope(array $question): bool
{
    $blob = '';
    foreach (['stem', 'lead_in', 'correct_answer', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $blob .= ' ' . mb_strtolower(trim($question[$k]));
        }
    }
    if (!preg_match('/\bpancreatitis\b/i', $blob)) {
        return false;
    }

    return sbaStyleQuestionLeadInLooksDiagnosisTyped((string) ($question['lead_in'] ?? ''));
}

/**
 * @return list<array{v: float, upper: float, pos: int}>
 */
function sbaStyleStemAmylaseLipaseValueUpperPairs(string $stem): array
{
    $out = [];
    if (!preg_match_all(
        '/\b(?:serum\s+)?(?:amylase|lipase)\b[^.!?]{0,140}?(\d+(?:\.\d+)?)\s*U\/\s*L/is',
        $stem,
        $m,
        PREG_OFFSET_CAPTURE
    )) {
        return [];
    }
    foreach ($m[1] as $pair) {
        $v = (float) $pair[0];
        $pos = (int) $pair[1];
        $upper = sbaStyleStemNearestReferenceRangeUpperUPerL($stem, $pos);
        if ($upper !== null && $upper > 0.0) {
            $out[] = ['v' => $v, 'upper' => $upper, 'pos' => $pos];
        }
    }

    return $out;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsPancreatitisEnzymeBelowThreeFoldUrl(array $question): array
{
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !sbaStyleQuestionLooksAcutePancreatitisDiagnosisScope($question)) {
        return [];
    }
    foreach (sbaStyleStemAmylaseLipaseValueUpperPairs($stem) as $row) {
        $v = $row['v'];
        $upper = $row['upper'];
        if ($v > 0.0 && $v < 3.0 * $upper) {
            return [
                [
                    'code' => 'diagnosis.pancreatitis_enzyme_below_threefold_url',
                    'severity' => 'warning',
                    'message' => 'Serum amylase or lipase is below three times the stated upper reference limit — when used as a diagnostic clue for acute pancreatitis, the value should be clearly at least 3× URL unless the stem explicitly justifies a lower threshold for a specific teaching reason.',
                    'field' => 'stem',
                ],
            ];
        }
    }

    return [];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsVagueHistoryOfAlcoholUse(array $question): array
{
    $warnings = [];
    foreach (['stem', 'justification', 'correct_answer'] as $field) {
        $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
        if ($t === '' || !preg_match('/\bhistory\s+of\s+alcohol\s+use\b/i', $t)) {
            continue;
        }
        $warnings[] = [
            'code' => 'wording.vague_history_of_alcohol_use',
            'severity' => 'warning',
            'message' => 'Avoid vague “history of alcohol use”; give a specific relevant quantity (e.g. units per week) when alcohol matters, or omit the cue.',
            'field' => $field,
        ];
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsPancreatitisWeakTwentyUnitsPerWeek(array $question): array
{
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !preg_match('/\b20\s+units\s+per\s+week\b/i', $stem)) {
        return [];
    }
    if (!sbaStyleQuestionLooksAcutePancreatitisDiagnosisScope($question)) {
        return [];
    }

    return [
        [
            'code' => 'diagnosis.pancreatitis_weak_alcohol_20_units',
            'severity' => 'warning',
            'message' => '“20 units per week” is a weak alcohol cue as major support for acute pancreatitis — use a clinically meaningful intake when alcohol matters, or omit it if symptoms and amylase/lipase already carry the diagnosis.',
            'field' => 'stem',
        ],
    ];
}

function sbaStyleStemSuggestsSuddenSevereAcuteAbdominalPain(string $stem): bool
{
    $s = mb_strtolower($stem);
    if (!preg_match('/\b(?:abdominal\s+pain|epigastric\s+pain|acute\s+abdomen|peritonitis)\b/', $s)) {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:sudden|severe|worst|excruci|(?:rapid|acute)\s+onset|(?:very|extremely)\s+severe|peritonitis|(?:generalised|generalized)\s+tenderness|rigid\s+abdomen|board(?:-like)?\s+rigidity)\b/i',
        $stem
    );
}

function sbaStyleOptionMentionsPancreaticCancer(string $text): bool
{
    $t = mb_strtolower(trim($text));

    return (bool) preg_match(
        '/\b(?:pancreatic\s+(?:cancer|carcinoma|adenocarcinoma)|cancer\s+of\s+the\s+pancreas)\b/i',
        $t
    );
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsPancreaticCancerDistractorSuddenAbdominalPain(array $question): array
{
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !sbaStyleStemSuggestsSuddenSevereAcuteAbdominalPain($stem)) {
        return [];
    }
    $letter = strtoupper(trim((string) ($question['correct_answer_letter'] ?? '')));
    $map = ['A' => 'option_a', 'B' => 'option_b', 'C' => 'option_c', 'D' => 'option_d', 'E' => 'option_e'];
    $correctKey = $map[$letter] ?? '';

    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
        if ($field === $correctKey) {
            continue;
        }
        $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
        if ($t === '' || !sbaStyleOptionMentionsPancreaticCancer($t)) {
            continue;
        }

        return [
            [
                'code' => 'distractor.pancreatic_cancer_sudden_acute_abdomen',
                'severity' => 'warning',
                'message' => 'Pancreatic cancer is usually a poor distractor for sudden, severe acute abdominal pain — prefer plausible acute differentials unless the stem clearly supports a slower tempo.',
                'field' => $field,
            ],
        ];
    }

    return [];
}

function sbaStyleQuestionLooksHypoglycaemiaContext(array $question): bool
{
    $parts = [
        (string) ($question['stem'] ?? ''),
        (string) ($question['lead_in'] ?? ''),
        (string) ($question['title'] ?? ''),
    ];
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $parts[] = $question[$k];
        }
    }
    $blob = mb_strtolower(implode(' ', array_map('trim', $parts)));
    if ($blob === '') {
        return false;
    }
    if (preg_match('/\b(?:hypoglyc(?:aemia|emia)|hypoglycaemic|hypoglycemic)\b/', $blob)) {
        return true;
    }
    if (preg_match('/\blow\s+blood\s+sugar\b/', $blob)) {
        return true;
    }
    if (preg_match('/\bcapillary\s+blood\s+glucose\b|\bcbg\b/', $blob)) {
        return true;
    }
    if (preg_match('/\bglucagon\b/', $blob) && preg_match('/\b(?:mmol|glucose|dextrose|hypo)\b/', $blob)) {
        return true;
    }

    return false;
}

function sbaStyleQuestionLooksEmergencyManagementLeadIn(array $question): bool
{
    $lead = mb_strtolower(trim((string) ($question['lead_in'] ?? '')));
    if ($lead === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:what|which)\b.{0,80}\b(?:first|immediate|initial|next|appropriate|best)\b.{0,50}\b(?:treatment|management|step|therapy|intervention|drug|medication|give|administer)\b|\bnext\s+step\s+in\s+management\b|\bmost\s+appropriate\s+(?:immediate\s+)?(?:treatment|management)\b/i',
        $lead
    );
}

function sbaStyleKeyedAnswerOptionField(array $question): string
{
    $letter = strtoupper(trim((string) ($question['correct_answer_letter'] ?? '')));
    $map = [
        'A' => 'option_a',
        'B' => 'option_b',
        'C' => 'option_c',
        'D' => 'option_d',
        'E' => 'option_e',
    ];

    return $map[$letter] ?? '';
}

function sbaStyleKeyedAnswerCombinedManagementText(array $question): string
{
    $parts = [trim((string) ($question['correct_answer'] ?? ''))];
    $f = sbaStyleKeyedAnswerOptionField($question);
    if ($f !== '' && isset($question[$f]) && is_string($question[$f])) {
        $parts[] = trim($question[$f]);
    }

    return trim(implode(' ', array_filter($parts)));
}

function sbaStyleKeyedOptionIsOralGlucoseHypoStep(array $question): bool
{
    $t = sbaStyleKeyedAnswerCombinedManagementText($question);
    if ($t === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:oral\s+glucose|glucose\s+tablets?|glucose\s+powder|glucose\s+gel|glucogel|hypostop|dextrose\s+(?:tablets?|gel|powder)|chewable\s+glucose)\b/i',
        $t
    );
}

function sbaStyleOptionLooksCompetingOralCarbVersusKeyedGlucose(string $optionText): bool
{
    $t = trim($optionText);
    if ($t === '') {
        return false;
    }
    if (preg_match('/\bglucagon\b/i', $t) && preg_match('/\b(?:intravenous|\biv\b|intramuscular|\bim\b|1\s*mg)\b/i', $t)) {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:fruit\s+juice|orange\s+juice|apple\s+juice|cranberry\s+juice|sugary\s+drink|carbohydrate\s+drink|glucose\s+gel|oral\s+sugar|honey|\bjam\b|\bcola\b|lucozade|dextrose\s+gel|sugary\s+fluid)\b/i',
        $t
    );
}

function sbaStyleOptionLooksLikeOralCarbohydrateHypoTreatmentLine(string $optionText): bool
{
    $t = trim($optionText);
    if ($t === '') {
        return false;
    }
    if (preg_match('/\b(?:intravenous|\biv\b)\s+(?:dextrose|glucose)\b/i', $t)) {
        return false;
    }
    if (preg_match('/\bglucagon\b/i', $t) && preg_match('/\b(?:intravenous|\biv\b|intramuscular|\bim\b)\b/i', $t)) {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:oral\s+)?(?:glucose|dextrose)(?:\s+(?:tablets?|gel|powder|drink|solution|sachets?))?|glucogel|hypostop|fruit\s+juice|orange\s+juice|apple\s+juice|sugary\s+drink|carbohydrate\s+drink|oral\s+sugar|lucozade|\bhoney\b|\bjam\b|\bcola\b|sugary\s+fluid\b/i',
        $t
    );
}

function sbaStyleStemExtractPrimaryHypoGlucoseMmol(string $stem): ?float
{
    if (preg_match('/\b(?:CBG|capillary\s+blood\s+glucose|blood\s+glucose|BM)(?:\s+is|\s+was|\s+of)?\s*(\d(?:\.\d+)?)\s*mmol/i', $stem, $m)) {
        return (float) $m[1];
    }
    if (preg_match('/\bBM\s+(\d(?:\.\d+)?)\s*mmol/i', $stem, $m)) {
        return (float) $m[1];
    }
    if (preg_match('/\bglucose\s+(?:is|was|of)\s*(\d(?:\.\d+)?)\s*mmol/i', $stem, $m)) {
        return (float) $m[1];
    }

    return null;
}

function sbaStyleStemSuggestsCollapseOrProfoundHypoglyPresentation(string $stem): bool
{
    return (bool) preg_match(
        '/\b(?:collapse|collapsed|unconscious|unresponsive|loss\s+of\s+consciousness|\bLOC\b|found\s+(?:on\s+the\s+)?floor|slumped|unrousable|seizur(?:e|ing))\b/i',
        $stem
    );
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsHypoglyKeyedOralGlucoseWithCompetingOralCarbDistractor(array $question): array
{
    if (!sbaStyleQuestionLooksHypoglycaemiaContext($question)) {
        return [];
    }
    if (!sbaStyleKeyedOptionIsOralGlucoseHypoStep($question)) {
        return [];
    }
    $correctField = sbaStyleKeyedAnswerOptionField($question);
    if ($correctField === '') {
        return [];
    }
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
        if ($field === $correctField) {
            continue;
        }
        $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
        if ($t === '' || !sbaStyleOptionLooksCompetingOralCarbVersusKeyedGlucose($t)) {
            continue;
        }

        return [
            [
                'code' => 'emergency.hypogly_keyed_glucose_with_oral_carb_distractor',
                'severity' => 'warning',
                'message' => 'Keyed answer is oral glucose but a distractor still offers another oral carbohydrate (e.g. juice, sugary drink, glucose gel, oral sugar) — remove competing oral sugar options; use route, timing, or escalation errors instead.',
                'field' => $field,
            ],
        ];
    }

    return [];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsEmergencyOverlappingOralCarbohydrateOptions(array $question): array
{
    if (!sbaStyleQuestionLooksHypoglycaemiaContext($question) || !sbaStyleQuestionLooksEmergencyManagementLeadIn($question)) {
        return [];
    }
    $hits = [];
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
        $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
        if ($t === '' || !sbaStyleOptionLooksLikeOralCarbohydrateHypoTreatmentLine($t)) {
            continue;
        }
        $hits[] = $field;
    }
    if (count($hits) < 2) {
        return [];
    }

    return [
        [
            'code' => 'emergency.overlapping_oral_carbohydrate_options',
            'severity' => 'warning',
            'message' => 'In a hypoglycaemia emergency-management item, more than one option describes an oral carbohydrate treatment — prefer distractors that are wrong by route, timing, or escalation, not multiple equally acceptable oral sugar treatments.',
            'field' => $hits[0],
        ],
    ];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsHypoglyStemCollapseWithMildGlucoseValue(array $question): array
{
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !sbaStyleQuestionLooksHypoglycaemiaContext($question)) {
        return [];
    }
    if (!sbaStyleStemSuggestsCollapseOrProfoundHypoglyPresentation($stem)) {
        return [];
    }
    $v = sbaStyleStemExtractPrimaryHypoGlucoseMmol($stem);
    if ($v === null || $v < 3.0 || $v >= 4.0) {
        return [];
    }
    if (preg_match('/\b(?:insulin|sulfonylurea|sulphonylurea|beta[\s-]*blocker|renal\s+impairment|liver\s+failure|alcohol|sepsis|adrenal\s+insufficiency|missed\s+meal|fasting)\b/i', $stem)) {
        return [];
    }

    return [
        [
            'code' => 'emergency.hypogly_collapse_mild_numeric_glucose',
            'severity' => 'warning',
            'message' => 'Stem suggests collapse or LOC but the narrated glucose is only mildly reduced (about 3.0–3.9 mmol/L) without a clear mechanistic explanation — align severity with the glucose value or add brief context (e.g. sulphonylurea, beta blocker).',
            'field' => 'stem',
        ],
    ];
}

function sbaStyleStemMatchesExplicitSuspectedRupturedEctopicWording(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return (bool) preg_match(
        '/\bsuspected\s+(?:ruptured\s+)?ectopic\s+pregnancy\b|\bsuspected\s+to\s+have\s+(?:a\s+)?(?:ruptured\s+)?ectopic\s+pregnancy\b|\bsuspected\s+ruptured\s+ectopic\b/i',
        $stem
    );
}

function sbaStyleStemOrTitleLooksEctopicPregnancyCoreScenario(string $stem, string $title): bool
{
    $blob = mb_strtolower(trim($stem . ' ' . $title));
    if ($blob === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:ruptured\s+)?ectopic\s+pregnancy\b|\bectopic\s+gestation\b|\btubal\s+pregnancy\b|\bextrauterine\s+pregnancy\b|\bruptured\s+ectopic\b/i',
        $blob
    );
}

function sbaStyleStemHasPregnancyStatusOrDatingCue(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:urine\s+pregnancy\s+test|pregnancy\s+test|positive\s+pregnancy\s+test|positive\s+urine\s+pregnancy|\bupt\b|beta[\s-]?hcg|\bhcg\b|serum\s+hcg|confirmed\s+pregnancy|known\s+pregnancy|\bpregnant\b|last\s+menstrual\s+period|\blmp\b|amenorrhoea|amenorrhea|dates\s+by\s+ultrasound|gestation\s+by\s+dates)\b/i',
        $stem
    );
}

function sbaStyleStemSuggestsHaemodynamicInstability(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (preg_match(
        '/\b(?:hypotension|hypotensive|shock|shocked|circulatory\s+collapse|haemodynamically\s+unstable|hemodynamically\s+unstable|cardiovascular\s+instability|tachycardia|tachycardic|collapsed|unresponsive|mottled|peripherally\s+cool|cool\s+peripher|peri[\s-]?arrest|reduced\s+conscious|altered\s+conscious|gcs\s*\d|pale\s+and\s+clammy|clammy)\b/i',
        $stem
    )) {
        return true;
    }
    if (preg_match('/\b(?:BP|blood\s+pressure)\s+(?:is|was|of)\s*(\d{2,3})\s*\/\s*(\d{1,3})\b/i', $stem, $m)) {
        $sys = (int) $m[1];
        if ($sys > 0 && $sys <= 95) {
            return true;
        }
    }
    if (preg_match('/\bpulse\s+(?:is|was|of)\s*(\d{2,3})\b/i', $stem, $m)) {
        $p = (int) $m[1];
        if ($p >= 120) {
            return true;
        }
    }

    return false;
}

function sbaStyleKeyedAnswerReflectsResuscitationOrUrgentGynSurgeryPathway(string $combinedKeyedText): bool
{
    $t = trim($combinedKeyedText);
    if ($t === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:resuscitat|abcde|airway|breathing|circulation|iv\s+access|intravenous\s+access|wide[\s-]bore|two\s+large[\s-]bore|iv\s+fluid|intravenous\s+fluid|crystalloid|blood\s+product|blood\s+transfusion|give\s+oxygen|oxygen\s+therapy|high[\s-]flow\s+oxygen|gynaecolog|gynecolog|obstetric|senior\s+(?:review|opinion|help|registrar|consultant)|surgical\s+team|emergency\s+surgery|urgent\s+surgery|laparoscop|laparotomy|take\s+to\s+theatre|to\s+theatre|theatre\s+team|operating\s+room|or\s+team)\b/i',
        $t
    );
}

function sbaStyleLeadInAsksOnlyFirstResuscitationAction(string $leadIn): bool
{
    $t = trim($leadIn);
    if ($t === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:only|solely|just)\s+the\s+first\s+resuscitation\b|\bfirst\s+resuscitation\s+action\b|\bfirst\s+(?:step|action)\s+in\s+(?:the\s+)?(?:ABC|resuscitation|resus)\b|\bfirst\s+resuscitation\s+(?:step|action)\b|\binitial\s+resuscitation\s+(?:step|action)\b|\bwhat\s+is\s+the\s+first\s+(?:step|action|resuscitation\s+action)(?:\s+in\s+(?:the\s+)?(?:ABC|resuscitation|resus))?\b|\bwhich\s+(?:is|would\s+be)\s+the\s+first\s+(?:step|action)\s+in\s+(?:the\s+)?(?:ABC|resuscitation|resus)\b|\bsingle\s+most\s+appropriate\s+immediate\s+resuscitation\b/i',
        $t
    );
}

function sbaStyleKeyedMentionsIvFluidsOrFluidResuscitation(string $combinedKeyedText): bool
{
    $t = trim($combinedKeyedText);
    if ($t === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:iv\s+fluid|intravenous\s+fluid|crystalloid|fluid\s+bolus|fluid\s+resuscitation|volume\s+resuscitation|bolus\s+of\s+fluid|hartmann|hartmann\'?s|plasmalyte|normal\s+saline|0\.9\s*%\s*saline|give\s+fluids|commence\s+fluids)\b/i',
        $t
    );
}

function sbaStyleKeyedMentionsUrgentGynSurgicalTheatreOrSeniorEscalation(string $combinedKeyedText): bool
{
    $t = trim($combinedKeyedText);
    if ($t === '') {
        return false;
    }

    return (bool) preg_match(
        '/\bgynaecolog|\bgynecolog|\bobstetric\s+(?:team|review|registrar|consultant|unit)\b|\b(?:urgent|emergency)\s+(?:gynaecolog|gynecolog|surgical\s+review|surgery)\b|\b(?:senior|urgent)\s+(?:review|opinion|help)\b|\b(?:consultant|registrar)\s+(?:review|opinion)\b|\bsurgical\s+team\b|\blaparoscop|\blaparotomy|\btheatre\b|\boperating\s+room\b|\b(?:take|refer)\s+to\s+theatre\b/i',
        $t
    );
}

function sbaStyleFirstOptionFieldMatchingNasogastric(array $question): string
{
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
        $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
        if ($t === '' || !preg_match('/\bnasogastric\s+tube\b|\bng\s+tube\b|\bngt\b/i', $t)) {
            continue;
        }

        return $field;
    }

    return '';
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsAwkwardTachycardicWithPulsePhrase(array $question): array
{
    $warnings = [];
    $re = '/\btachycardic\s+with\s+a\s+pulse\b/i';
    foreach (['stem', 'lead_in', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
        $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
        if ($t === '' || !preg_match($re, $t)) {
            continue;
        }
        $warnings[] = [
            'code' => 'stem.tachycardic_with_a_pulse_phrase',
            'severity' => 'warning',
            'message' => 'Avoid vague phrasing such as “tachycardic with a pulse”; give the pulse rate in MS AKT observation order (e.g. “pulse 128/min”).',
            'field' => $field,
        ];
    }

    return $warnings;
}

function sbaStyleKeyedIndicatesPrerenalAkiOrAzotaemia(array $question): bool
{
    $t = mb_strtolower(sbaStyleKeyedAnswerCombinedManagementText($question));
    if ($t === '') {
        return false;
    }

    return (bool) preg_match(
        '/\bpre[\s-]?renal\s+(?:acute\s+)?kidney\s+injury\b|\bpre[\s-]?renal\s+aki\b|\bpre[\s-]?renal\s+azotae?mia\b|\bprerenal\s+(?:acute\s+)?(?:kidney\s+injury|failure|azotae?mia)\b/i',
        $t
    );
}

function sbaStyleStemHasPrerenalVolumeDepletionCue(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:poor\s+(?:oral\s+)?intake|reduced\s+fluid\s+intake|minimal\s+oral\s+intake|inadequate\s+oral\s+intake|diarrhoea|diarrhea|vomiting|protracted\s+vomiting|dry\s+mucous|dry\s+oral\s+mucosa|dry\s+mouth|postural\s+(?:hypotension|dizziness|lightheaded|syncope)|orthostatic\s+(?:hypotension|dizziness)|stood\s+from|stood\s+up\s+from|\bhypotension\b|\bhypotensive\b|\bdehydrat(?:ion|ed)?\b|\bhigh\s+stoma\s+output\b|\bhigh\s+gastric\s+losses\b)/i',
        $stem
    );
}

function sbaStyleJustificationInfersDehydrationPrimarilyFromUrineOutput(string $justification): bool
{
    $j = trim($justification);
    if ($j === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:dehydrat|hypovolaemic|hypovolemic|volume\s+deplet|fluid\s+deplet|pre[\s-]?renal)\w*\b.{0,100}(?:due\s+to|because\s+of|from|on\s+the\s+basis\s+of|reflecting|after)\s+(?:the\s+)?(?:oliguria|anuria|reduced\s+urine|low\s+urine\s+output|urine\s+output)\b|\b(?:oliguria|anuria|reduced\s+urine\s+output|low\s+urine\s+output)\s+(?:alone|only)?\s*(?:indicates|suggests|implies|means|explains|accounts\s+for|is\s+consistent\s+with).{0,80}\b(?:dehydrat|hypovola|volume\s+deplet|pre[\s-]?renal)/i',
        $j
    );
}

function sbaStyleOptionTextSuggestsCkdStageLabel(string $text): bool
{
    return (bool) preg_match(
        '/\bckd\s*(?:stage\s*)?[1-5]\b|\bchronic\s+kidney\s+disease(?:\s+stage\s*[1-5])?\b/i',
        mb_strtolower($text)
    );
}

function sbaStyleOptionTextSuggestsAkiMechanismLabel(string $text): bool
{
    $t = mb_strtolower($text);

    return (bool) preg_match(
        '/\bacute\s+kidney\s+injury\b|\baki\b|\bintrarenal\s+(?:aki|acute\s+kidney|failure)|\batn\b|\bacute\s+tubular\b|\bpost[\s-]?renal\b|\bpre[\s-]?renal\b|\brenal\s+failure\b|\bazotae?mia\b/i',
        $t
    );
}

/**
 * Parse ABG numeric values from narrative text (stem-style prose).
 *
 * @return array{ph: ?float, ph_low: ?float, pco2: ?float, pco2_high: ?float, hco3: ?float, hco3_high: ?float}|null
 */
function sbaStyleParseAbgFromText(string $text): ?array
{
    if ($text === '' || !preg_match('/\bpH\b/i', $text)) {
        return null;
    }
    if (!preg_match('/\b(?:PCO2|bicarbonate|HCO\s*3)\b/i', $text)) {
        return null;
    }

    $out = [
        'ph' => null,
        'ph_low' => null,
        'pco2' => null,
        'pco2_high' => null,
        'hco3' => null,
        'hco3_high' => null,
    ];

    if (preg_match('/\bpH\s*(?:is\s+)?([0-9]+\.[0-9]+)/i', $text, $m)) {
        $out['ph'] = (float) $m[1];
    }
    if (preg_match('/\bpH[^()]*\(\s*([0-9]+\.[0-9]+)\s*[–\-]\s*([0-9]+\.[0-9]+)\s*\)/i', $text, $m)) {
        $out['ph_low'] = (float) $m[1];
    }
    if (preg_match('/\bPCO2\s*(?:is\s+)?([0-9]+\.[0-9]+)/i', $text, $m)) {
        $out['pco2'] = (float) $m[1];
    }
    if (preg_match('/\bPCO2[^()]*\(\s*([0-9]+\.[0-9]+)\s*[–\-]\s*([0-9]+\.[0-9]+)\s*\)/i', $text, $m)) {
        $out['pco2_high'] = (float) $m[2];
    }
    if (preg_match('/\bbicarbonate\s*(?:is\s+)?([0-9]+(?:\.[0-9]+)?)/i', $text, $m)) {
        $out['hco3'] = (float) $m[1];
    } elseif (preg_match('/\bHCO\s*3\s*[-−]?\s*([0-9]+(?:\.[0-9]+)?)/i', $text, $m)) {
        $out['hco3'] = (float) $m[1];
    }
    if (preg_match('/\bbicarbonate[^()]*\(\s*([0-9]+(?:\.[0-9]+)?)\s*[–\-]\s*([0-9]+(?:\.[0-9]+)?)\s*\)/i', $text, $m)) {
        $out['hco3_high'] = (float) $m[2];
    }

    if ($out['ph'] === null && $out['pco2'] === null && $out['hco3'] === null) {
        return null;
    }

    return $out;
}

function sbaStyleAbgShowsAcidaemiaWithHypercapniaAndElevatedBicarbonate(?array $abg): bool
{
    if ($abg === null) {
        return false;
    }
    $ph = $abg['ph'];
    $pco2 = $abg['pco2'];
    $hco3 = $abg['hco3'];
    if ($ph === null || $pco2 === null || $hco3 === null) {
        return false;
    }

    $phLow = $abg['ph_low'] ?? 7.35;
    $pco2High = $abg['pco2_high'] ?? 6.0;
    $hco3High = $abg['hco3_high'] ?? 26.0;

    return $ph < $phLow && $pco2 > $pco2High && $hco3 > $hco3High;
}

function sbaStyleJustificationDeniesMetabolicCompensationDespiteElevatedBicarbonate(string $justification): bool
{
    if ($justification === '') {
        return false;
    }

    $patterns = [
        '/\bno\s+(?:significant\s+)?metabolic\s+compensat/i',
        '/\bwithout\s+metabolic\s+compensat/i',
        '/\bnot\s+metabolically\s+compensat/i',
        '/\bno\s+compensation\b/i',
        '/\bbicarbonate\s+(?:is\s+)?(?:within|in)\s+(?:the\s+)?normal\s+(?:range)?/i',
        '/\bbicarbonate\s+(?:remains\s+)?within\s+(?:the\s+)?(?:normal|reference)\s+range/i',
        '/\bbicarbonate\s+is\s+normal\b/i',
        '/\bnormal\s+bicarbonate\b/i',
        '/\bwithin\s+normal\s+limits\b.{0,40}\bbicarbonate\b/i',
        '/\bbicarbonate\b.{0,40}\bwithin\s+normal\s+limits\b/i',
    ];
    foreach ($patterns as $pattern) {
        if (preg_match($pattern, $justification)) {
            return true;
        }
    }

    return false;
}

/**
 * ABG interpretation: elevated bicarbonate with respiratory acidosis pattern — do not deny metabolic compensation.
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsAbgBicarbonateInterpretation(array $question): array
{
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $justification = isset($question['justification']) ? trim((string) $question['justification']) : '';
    if ($stem === '' || $justification === '') {
        return [];
    }

    $abg = sbaStyleParseAbgFromText($stem);
    if (!sbaStyleAbgShowsAcidaemiaWithHypercapniaAndElevatedBicarbonate($abg)) {
        return [];
    }
    if (!sbaStyleJustificationDeniesMetabolicCompensationDespiteElevatedBicarbonate($justification)) {
        return [];
    }

    return [[
        'code' => 'interpretation.abg_bicarbonate_compensation_denied',
        'severity' => 'warning',
        'message' => 'Arterial blood gas shows acidaemia with raised PCO2 and bicarbonate above the reference range — the justification should not state that bicarbonate is normal or that there is no metabolic compensation. Acknowledge possible metabolic compensation (raised bicarbonate) while still identifying the primary respiratory acidosis if pH remains low.',
        'field' => 'justification',
    ]];
}

function sbaStyleBlobSuggestsCopd(string $blob): bool
{
    return $blob !== '' && (bool) preg_match(
        '/\b(?:COPD|chronic\s+obstructive\s+pulmonary\s+disease)\b/i',
        $blob
    );
}

function sbaStyleInterpretationUsesType2RespiratoryFailureWording(string $text): bool
{
    return $text !== '' && (bool) preg_match(
        '/\b(?:acute[\s-]on[\s-]chronic\s+)?type\s*2\s+respiratory\s+failure\b/i',
        $text
    );
}

function sbaStyleTextUsesSimplisticCopdAbgInterpretation(string $text): bool
{
    if ($text === '' || sbaStyleInterpretationUsesType2RespiratoryFailureWording($text)) {
        return false;
    }

    return (bool) preg_match(
        '/\bacute\s+respiratory\s+acidosis\s+with\s+metabolic\s+compensation\b/i',
        $text
    ) || (bool) preg_match(
        '/\brespiratory\s+acidosis\s+with\s+metabolic\s+compensation\b/i',
        $text
    ) || ((bool) preg_match('/\bacute\s+respiratory\s+acidosis\b/i', $text)
        && !preg_match('/\bacute[\s-]on[\s-]chronic\b/i', $text));
}

/**
 * COPD + hypercapnic ABG: avoid simplistic “acute respiratory acidosis with metabolic compensation” labels.
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsAbgCopdType2Interpretation(array $question): array
{
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !sbaStyleBlobSuggestsCopd($stem)) {
        return [];
    }

    $abg = sbaStyleParseAbgFromText($stem);
    if (!sbaStyleAbgShowsAcidaemiaWithHypercapniaAndElevatedBicarbonate($abg)) {
        return [];
    }

    $blobParts = [
        trim((string) ($question['correct_answer'] ?? '')),
        sbaStyleKeyedAnswerCombinedManagementText($question),
        trim((string) ($question['justification'] ?? '')),
    ];
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $f) {
        $blobParts[] = trim((string) ($question[$f] ?? ''));
    }
    $blob = trim(implode("\n", array_filter($blobParts)));
    if ($blob === '' || !sbaStyleTextUsesSimplisticCopdAbgInterpretation($blob)) {
        return [];
    }

    $field = sbaStyleKeyedAnswerOptionField($question);
    if ($field === '' && trim((string) ($question['justification'] ?? '')) !== ''
        && sbaStyleTextUsesSimplisticCopdAbgInterpretation(trim((string) $question['justification']))) {
        $field = 'justification';
    }
    if ($field === '') {
        $field = 'correct_answer';
    }

    return [[
        'code' => 'interpretation.abg_copd_type2_respiratory_failure_wording',
        'severity' => 'warning',
        'message' => 'COPD with acidaemia, raised PCO2 and raised bicarbonate: avoid simplistic labels such as “acute respiratory acidosis with metabolic compensation”. Prefer **type 2 respiratory failure with respiratory acidosis**, or **acute-on-chronic type 2 respiratory failure** when the stem/options support a chronic hypercapnic pattern — match the option set and vignette timing.',
        'field' => $field,
    ]];
}

function sbaStyleStemSupportsGastricUlcerSite(string $stem): bool
{
    return (bool) preg_match(
        '/\b(?:gastric\s+ulcer|stomach\s+ulcer|antral\s+ulcer|gastric\s+outlet|pyloric\s+ulcer|prepyloric)\b/i',
        $stem
    );
}

function sbaStyleStemSupportsDuodenalUlcerSite(string $stem): bool
{
    return (bool) preg_match(
        '/\b(?:duodenal\s+ulcer|duodenal\s+bulb|post[\s-]?pyloric\s+ulcer)\b/i',
        $stem
    );
}

function sbaStyleTutorCommentsForbidGastricDuodenalDistinction(string $tutorComments): bool
{
    if ($tutorComments === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:do\s+not|don\'t|avoid|without)\s+(?:distinguish|specify|name|separate|differentiate).{0,60}\b(?:gastric|duodenal)\b/i',
        $tutorComments
    ) || (bool) preg_match(
        '/\b(?:gastric|duodenal)\s+ulcer\b.{0,40}\b(?:not\s+(?:required|needed)|avoid|do\s+not)\b/i',
        $tutorComments
    );
}

/**
 * Upper GI / peptic ulcer diagnosis: do not key gastric or duodenal ulcer without site cues in the stem.
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsOverspecificGiDiagnosis(array $question): array
{
    $skill = isset($question['skill']) ? trim((string) $question['skill']) : '';
    if ($skill !== '' && !preg_match('/\bDiagnosis\b/i', $skill)) {
        return [];
    }

    $keyed = sbaStyleKeyedAnswerCombinedManagementText($question);
    if ($keyed === '') {
        return [];
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $tutor = trim((string) ($question['tutor_comments'] ?? $question['_tutor_comments'] ?? ''));
    $field = sbaStyleKeyedAnswerOptionField($question);
    if ($field === '') {
        $field = 'correct_answer';
    }

    $isGastric = (bool) preg_match('/\bgastric\s+ulcer\b/i', $keyed);
    $isDuodenal = (bool) preg_match('/\bduodenal\s+ulcer\b/i', $keyed);
    if (!$isGastric && !$isDuodenal) {
        return [];
    }

    $broadGi = $stem !== '' && preg_match(
        '/\b(?:peptic\s+ulcer|upper\s+GI|upper\s+gastrointestinal|haematemesis|melaena|melena|malaena|oesophageal\s+varices?)\b/i',
        $stem
    );

    if ($isGastric && !sbaStyleStemSupportsGastricUlcerSite($stem) && $broadGi) {
        $msg = 'Keyed answer is **gastric ulcer** but the stem does not localise ulcer site (no gastric/antral/pyloric cue) — prefer **peptic ulcer bleed** or **peptic ulcer disease** when the vignette supports a broad upper GI bleed diagnosis only.';
        if (sbaStyleTutorCommentsForbidGastricDuodenalDistinction($tutor)) {
            $msg .= ' **Tutor comments** instruct not to distinguish gastric from duodenal ulcer unless the stem supports it.';
        }

        return [[
            'code' => 'diagnosis.overspecific_gastric_ulcer_unsupported_by_stem',
            'severity' => 'warning',
            'message' => $msg,
            'field' => $field,
        ]];
    }

    if ($isDuodenal && !sbaStyleStemSupportsDuodenalUlcerSite($stem) && $broadGi) {
        $msg = 'Keyed answer is **duodenal ulcer** but the stem does not localise ulcer site (no duodenal/bulb cue) — prefer **peptic ulcer bleed** or **peptic ulcer disease** when the vignette supports a broad upper GI bleed diagnosis only.';
        if (sbaStyleTutorCommentsForbidGastricDuodenalDistinction($tutor)) {
            $msg .= ' **Tutor comments** instruct not to distinguish gastric from duodenal ulcer unless the stem supports it.';
        }

        return [[
            'code' => 'diagnosis.overspecific_duodenal_ulcer_unsupported_by_stem',
            'severity' => 'warning',
            'message' => $msg,
            'field' => $field,
        ]];
    }

    return [];
}

/**
 * Interpretation items, AKI typing, and data-heavy stem style (non-blocking warnings).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsInterpretationAkiAndDataStyle(array $question): array
{
    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $justification = isset($question['justification']) ? trim((string) $question['justification']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';

    if (sbaStyleKeyedIndicatesPrerenalAkiOrAzotaemia($question) && $stem !== '' && !sbaStyleStemHasPrerenalVolumeDepletionCue($stem)) {
        $field = sbaStyleKeyedAnswerOptionField($question);
        $warnings[] = [
            'code' => 'interpretation.prerenal_aki_keyed_without_volume_depletion_cue',
            'severity' => 'warning',
            'message' => 'Keyed answer suggests pre-renal AKI / azotaemia but the stem lacks a clear volume-depletion or hypoperfusion cue (e.g. poor oral intake, diarrhoea, vomiting, dry mucous membranes, postural symptoms, hypotension) — add proportionate context.',
            'field' => $field !== '' ? $field : 'stem',
        ];
    }

    if ($justification !== '' && sbaStyleJustificationInfersDehydrationPrimarilyFromUrineOutput($justification)) {
        $warnings[] = [
            'code' => 'interpretation.justification_dehydration_from_urine_output_only',
            'severity' => 'warning',
            'message' => 'Justification links dehydration or pre-renal physiology to oliguria / reduced urine output in isolation — support fluid status with cues already in the stem (or soften the claim).',
            'field' => 'justification',
        ];
    }

    if ($leadIn === '' || !preg_match('/\bacute\s+(?:versus|vs\.?)\s+chronic|chronic\s+(?:versus|vs\.?)\s+acute|acute\s+on\s+chronic\s+kidney/i', $leadIn)) {
        $ckdHits = 0;
        $akiHits = 0;
        foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $f) {
            $t = isset($question[$f]) ? trim((string) $question[$f]) : '';
            if ($t === '') {
                continue;
            }
            if (sbaStyleOptionTextSuggestsCkdStageLabel($t)) {
                $ckdHits++;
            }
            if (sbaStyleOptionTextSuggestsAkiMechanismLabel($t)) {
                $akiHits++;
            }
        }
        if ($ckdHits > 0 && $akiHits > 0) {
            $warnings[] = [
                'code' => 'interpretation.options_ckd_labels_mixed_with_aki_mechanism_labels',
                'severity' => 'warning',
                'message' => 'Options mix chronic kidney disease / stage-style labels with acute kidney injury mechanism labels — keep option sets homogeneous unless the lead-in deliberately tests acute versus chronic discrimination.',
                'field' => 'option_a',
            ];
        }
    }

    $az = '/\bpre[\s-]?renal\s+azotae?mia\b/i';
    $azWarned = false;
    foreach (['stem', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e', 'justification', 'correct_answer'] as $field) {
        $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
        if ($t === '' || !preg_match($az, $t)) {
            continue;
        }
        if (preg_match('/\bpre[\s-]?renal\s+(?:acute\s+)?kidney\s+injury\b|\bpre[\s-]?renal\s+aki\b/i', $t)) {
            continue;
        }
        if ($azWarned) {
            continue;
        }
        $azWarned = true;
        $warnings[] = [
            'code' => 'interpretation.pre_renal_azotaemia_prefer_aki_wording',
            'severity' => 'warning',
            'message' => '“Pre-renal azotaemia” is used — for undergraduate clarity prefer “pre-renal acute kidney injury” / “pre-renal AKI” unless azotaemia is specifically the teaching target.',
            'field' => $field,
        ];
    }

    return $warnings;
}

function sbaStyleStemRenalUeAnalyteFirstOffset(string $stem, string $analyteKey): ?int
{
    $patterns = [
        'na' => '#\b(?:serum\s+)?sodium\b|\bna\+?\s*(?:is|was|:|=|,)\s*\d|\bna\s+(?:is|was|:|=)\s*\d#i',
        'k' => '#\b(?:serum\s+)?potassium\b|(?<![A-Za-z])k\+?\s*(?:is|was|:|=|,)\s*\d|(?<![A-Za-z])k\s+(?:is|was|:|=)\s*\d#i',
        'urea' => '#\b(?:plasma|serum|blood)\s+urea\b|\burea\s*(?:is|was|:|=)\s*\d|\bBUN\b#i',
        'cr' => '#\b(?:serum\s+)?creatinine\b#i',
    ];
    if (!isset($patterns[$analyteKey])) {
        return null;
    }
    if (!preg_match($patterns[$analyteKey], $stem, $m, PREG_OFFSET_CAPTURE)) {
        return null;
    }

    return (int) $m[0][1];
}

function sbaStyleStemDocumentsPriorCreatinineContext(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (preg_match('#\b(?:baseline|previous|prior|last(?:\s+(?:documented|recorded|measured))?|known)\s+[^\n]{0,90}creatinine\s*(?:is|was|of|:)?\s*\d#i', $stem)) {
        return true;
    }
    if (preg_match('#\bcreatinine\s*(?:is|was|of)\s*\d+(?:\.\d+)?(?:\s*(?:µmol|μmol|umol)\s*/\s*L|\s*mg\s*/\s*dL)?\s*(?:previously|at\s+baseline|baseline|before\s+(?:today|admission|this)|last\s+(?:clinic|visit))#i', $stem)) {
        return true;
    }
    if (preg_match('#\bcreatinine\s+(?:has\s+)?(?:risen|fallen|increased|decreased)\s+from\s*\d#i', $stem)) {
        return true;
    }

    return false;
}

function sbaStyleJustificationInvokesCreatinineBaselineWithoutStemSupport(string $just, string $stem): bool
{
    $just = trim($just);
    if ($just === '') {
        return false;
    }
    if (!preg_match('#\b(?:baseline|previous|prior|last(?:\s+(?:documented|recorded|measured))?|usual)\s+(?:serum\s+)?creatinine\b|\bcreatinine\s+(?:is|was|had\s+been|remains)\s+(?:stable|unchanged|similar\s+to\s+(?:his|her|their)\s+)?(?:baseline|usual)|\b(?:rise|rises|rising|increase|increased)\s+from\s+(?:his|her|their)\s+(?:baseline|usual)\s+creatinine\b|\bcompared\s+to\s+(?:his|her|their)\s+(?:baseline|usual|stable)\s+creatinine\b#i', $just)) {
        return false;
    }

    return !sbaStyleStemDocumentsPriorCreatinineContext($stem);
}

/**
 * Investigation layout and UK wording refinements (non-blocking warnings).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsInvestigationFormattingUk(array $question): array
{
    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem !== '') {
        $canonical = ['na', 'k', 'urea', 'cr'];
        $off = [];
        foreach ($canonical as $k) {
            $off[$k] = sbaStyleStemRenalUeAnalyteFirstOffset($stem, $k);
        }
        $present = [];
        foreach ($canonical as $k) {
            if ($off[$k] !== null) {
                $present[] = $k;
            }
        }
        if (count($present) >= 2) {
            $ordered = true;
            for ($i = 0; $i < count($present) - 1; $i++) {
                $a = $present[$i];
                $b = $present[$i + 1];
                if ($off[$a] >= $off[$b]) {
                    $ordered = false;
                    break;
                }
            }
            if (!$ordered) {
                $warnings[] = [
                    'code' => 'investigation.renal_ue_out_of_conventional_order',
                    'severity' => 'warning',
                    'message' => 'U&Es / renal profile values in the stem appear out of the usual UK order (sodium, potassium, urea, creatinine) — reorder where several are listed together.',
                    'field' => 'stem',
                ];
            }
        }

        if (preg_match('/\b(?:his|her|their)\s+observations\s+are\s*:/i', $stem)) {
            $warnings[] = [
                'code' => 'stem.observations_are_colon_wrapper',
                'severity' => 'warning',
                'message' => 'Avoid “His/Her observations are:” as a wrapper — use direct MS AKT-style wording (e.g. “His temperature is …, pulse …, blood pressure …, respiratory rate … and oxygen saturation …”).',
                'field' => 'stem',
            ];
        }
    }

    $just = isset($question['justification']) ? trim((string) $question['justification']) : '';
    if ($just !== '' && sbaStyleJustificationInvokesCreatinineBaselineWithoutStemSupport($just, $stem)) {
        $warnings[] = [
            'code' => 'justification.creatinine_baseline_without_prior_value_in_stem',
            'severity' => 'warning',
            'message' => 'Justification refers to baseline, stable, or previous creatinine without a clear prior numeric creatinine (or baseline) in the stem — add the prior value or soften the reasoning.',
            'field' => 'justification',
        ];
    }

    return $warnings;
}

function sbaStyleStemContainsMisplacedQuestionPhrasing(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    // High-specificity only: avoid matching clinical narrative ("which management plan he was on",
    // "what management he had received") or lead-in text accidentally concatenated into stem checks.
    if (preg_match(
        '/\bwhat\s+is\s+the\s+most\s+(?:appropriate|likely|best)\b|\bwhat\s+is\s+the\s+best\b|'
        . '\bwhat\s+would\s+be\s+the\s+most\s+appropriate\b|\bwhat\s+should\s+be\b|\bwhich\s+of\s+the\s+following\b|'
        . '\bwhich\s+is\s+the\s+most\s+(?:appropriate|likely)\b|'
        . '\bwhat\s+treatment\s+should\b|\bwhat\s+(?:investigation|test)\s+(?:is|would|should|do\s+you\s+order)\b|'
        . '\bwhich\s+investigation\b|\bwhich\s+test\s+(?:is|would|should|do\s+we|to\s+order|next)\b|'
        . '\bwhich\s+(?:drug|antibiotic|option)\b|\bwhat\s+would\s+you\b|'
        . '\bhow\s+should\b.{0,48}\b(?:manage|treat)\b/i',
        $stem
    )) {
        return true;
    }
    $t = trim($stem);
    if ($t !== '' && substr($t, -1) === '?' && preg_match('/^(?:what|which|how)\b/is', $t)) {
        return true;
    }

    return false;
}

function sbaStyleLeadInLooksLikeExamQuestion(string $leadIn): bool
{
    $l = trim($leadIn);
    if ($l === '' || substr($l, -1) !== '?') {
        return false;
    }

    return (bool) preg_match('/^(?:what|which)\b/i', $l);
}

/**
 * True when the lead-in reads like an exam task (question mark + typical SBA opener).
 * Used with stem-only exam phrasing checks — do not pass combined stem+lead text here.
 */
function sbaStyleLeadInContainsExamQuestionWording(string $leadIn): bool
{
    $l = trim($leadIn);
    if ($l === '' || substr($l, -1) !== '?') {
        return false;
    }
    if (preg_match('/^(?:what|which|how)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:what|which)\s+is\s+the\s+/i', $l)) {
        return true;
    }
    if (preg_match('/\bhow\s+should\b/i', $l)) {
        return true;
    }

    return false;
}

/**
 * Stem must stay vignette-only; exam question belongs in lead_in (all skills).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsStemMisplacedExamQuestionAllSkills(array $question): array
{
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !sbaStyleStemContainsMisplacedQuestionPhrasing($stem)) {
        return [];
    }
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (sbaStyleSkillIsInvestigationUk($sk)) {
        return [];
    }
    $emerg = (bool) preg_match('/^emergency\s+management$/i', $sk);

    return [
        [
            'code' => $emerg ? 'stem.question_phrase_emergency_management_high' : 'stem.question_phrase_belongs_in_lead_in',
            'severity' => $emerg ? 'high' : 'warning',
            'message' => $emerg
                ? '[High priority] The stem contains exam-style question wording (“What is the most appropriate …”, “Which …”, etc.) — **Emergency Management** stems must be **clinical narrative only**; move the entire question into **lead_in** only.'
                : 'Stem contains exam-style question wording (“What is the most appropriate …”, “Which of the following …”, etc.) — keep questions in the lead_in; the stem should stay vignette-only.',
            'field' => 'stem',
        ],
    ];
}

function sbaStyleQuestionLooksPrescribingContext(array $question): bool
{
    $parts = [];
    foreach (['title', 'stem', 'lead_in', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $parts[] = trim($question[$k]);
        }
    }
    $b = mb_strtolower(implode(' ', array_filter($parts)));
    if ($b === '') {
        return false;
    }

    return (bool) preg_match('/\b(?:prescrib|prescription|drug\s+chart|antibiotic|antimicrobial|formulary|chart\s+the|select\s+the\s+(?:most\s+appropriate\s+)?(?:drug|antibiotic|therapy|treatment)|medication\s+choice)\b/i', $b);
}

/**
 * Strip leading "A." / "B)" style labels from option lines for overlap heuristics.
 */
function sbaStyleStripOptionLeadingLetterPrefix(string $s): string
{
    $t = trim($s);

    return trim((string) preg_replace('/^[A-E][.):]\s*/iu', '', $t));
}

/**
 * True when a distractor line begins with the keyed option text then adds a compound (and / panel / tests).
 */
function sbaStyleOptionTextLooksSupersetOfKeyedSingleTest(string $keyedOptionLine, string $otherOptionLine): bool
{
    $k = sbaStyleStripOptionLeadingLetterPrefix($keyedOptionLine);
    $o = sbaStyleStripOptionLeadingLetterPrefix($otherOptionLine);
    if ($k === '' || $o === '' || mb_strlen($k) < 2) {
        return false;
    }
    if (mb_strlen($o) <= mb_strlen($k) + 2) {
        return false;
    }
    $kLower = mb_strtolower($k);
    $oLower = mb_strtolower($o);
    if (mb_stripos($oLower, $kLower) !== 0) {
        return false;
    }

    return (bool) preg_match('/\b(and|panel|tests)\b/i', $oLower);
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsVagueNormalObservationsPhrase(array $question): array
{
    $rx = '/\bobservations\s+are\s+(?:within\s+normal\s+limits|normal)\b/i';
    $warnings = [];
    foreach (['stem', 'lead_in', 'justification', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
        $t = isset($question[$field]) && is_string($question[$field]) ? trim($question[$field]) : '';
        if ($t !== '' && preg_match($rx, $t)) {
            $warnings[] = [
                'code' => 'wording.observations_vague_normal_or_within_limits',
                'severity' => 'warning',
                'message' => 'Avoid “observations are normal” / “observations are within normal limits”; give numerical observations where they matter, or omit them.',
                'field' => $field,
            ];
        }
    }

    return sbaStyleDedupeWarnings($warnings);
}

/**
 * Monitoring / follow-up test items (Current skill or lead-in/title cues).
 */
function sbaStyleQuestionLooksMonitoringContext(array $question): bool
{
    $skill = trim((string) ($question['_current_skill'] ?? ''));
    if ($skill !== '' && preg_match('/\bmonitoring\b/i', $skill)) {
        return true;
    }
    $lead = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $title = isset($question['title']) ? trim((string) $question['title']) : '';
    $blob = mb_strtolower($title . "\n" . $lead);
    if ($blob === '') {
        return false;
    }
    if (preg_match('/\bmonitoring\b|\bmonitor\b/', $blob)) {
        return true;
    }
    if (preg_match('/\b(?:follow[\s-]up|repeat|serial|surveillance|ongoing)\b/', $blob)
        && preg_match('/\b(?:blood|test|tests|investigation|tft|tsh|monitor)\b/', $blob)) {
        return true;
    }

    return false;
}

/**
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsMonitoringStyle(array $question): array
{
    if (!sbaStyleQuestionLooksMonitoringContext($question)) {
        return [];
    }

    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $lead = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';

    if ($stem !== '' && preg_match('/\b(?:which|what)\s+blood\s+test\b|\b(?:which|what)\s+investigation\b/i', $stem)) {
        $warnings[] = [
            'code' => 'monitoring.stem_contains_investigation_question_phrase',
            'severity' => 'warning',
            'message' => 'Stem echoes an exam question (“Which blood test …”, “What investigation …”, etc.) — keep the vignette in the stem and put the monitoring task only in the lead-in.',
            'field' => 'stem',
        ];
    }

    $dupTaskRx = [
        '/\bwhich\s+blood\s+test\b/i',
        '/\bwhat\s+blood\s+test\b/i',
        '/\bwhich\s+investigation\b/i',
        '/\bwhat\s+investigation\b/i',
    ];
    foreach ($dupTaskRx as $rx) {
        if ($stem !== '' && $lead !== '' && preg_match($rx, $stem) && preg_match($rx, $lead)) {
            $warnings[] = [
                'code' => 'monitoring.duplicate_task_wording_stem_and_lead_in',
                'severity' => 'warning',
                'message' => 'Stem and lead-in both use the same investigation question wording — remove the task phrasing from the stem so the lead-in alone asks the monitoring question.',
                'field' => 'stem',
            ];
            break;
        }
    }

    $secondPersonRx = '/\byou\s+are\b|\byou\s+should\b|\byour\s+patient\b/i';
    foreach (['stem', 'lead_in', 'justification', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
        $t = isset($question[$field]) && is_string($question[$field]) ? trim($question[$field]) : '';
        if ($t !== '' && preg_match($secondPersonRx, $t)) {
            $warnings[] = [
                'code' => 'monitoring.second_person_wording',
                'severity' => 'warning',
                'message' => 'Avoid second-person examinee framing (“you are …”, “you should …”, “your patient …”); use consistent third-person vignette wording.',
                'field' => $field,
            ];
        }
    }

    foreach (['stem', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
        $t = isset($question[$field]) && is_string($question[$field]) ? trim($question[$field]) : '';
        if ($t !== '' && preg_match('/\bthyroid\s+function\s+panel\b/i', $t)) {
            $warnings[] = [
                'code' => 'monitoring.thyroid_function_panel_non_uk_vague',
                'severity' => 'warning',
                'message' => 'Avoid vague “thyroid function panel” wording; name UK-standard tests explicitly (e.g. TSH, free T4) unless the learning point is the panel itself.',
                'field' => $field,
            ];
        }
    }

    $keyField = sbaStyleKeyedAnswerOptionField($question);
    if ($keyField !== '') {
        $keyLine = trim((string) ($question[$keyField] ?? ''));
        $keyExtra = trim((string) ($question['correct_answer'] ?? ''));
        $keyText = trim($keyLine . ' ' . $keyExtra);
        if ($keyText !== '') {
            foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $f) {
                if ($f === $keyField) {
                    continue;
                }
                $ot = isset($question[$f]) && is_string($question[$f]) ? trim($question[$f]) : '';
                if ($ot === '') {
                    continue;
                }
                $overlap = false;
                if (preg_match('/\bthyroid\s+function\s+tests?\b|\bthyroid\s+profile\b|\bTFTs?\b/i', $ot)
                    && preg_match('/\bTSH\b|thyroid[-\s]stimulating|free\s+T4|\bfT4\b|free\s+thyroxine/i', $keyText)) {
                    $overlap = true;
                }
                if (preg_match('/\b(?:liver function tests?|LFTs?)\b/i', $ot)
                    && preg_match('/\bALT\b|alanine\s+transaminase|alanine\s+aminotransferase/i', $keyText)) {
                    $overlap = true;
                }
                if (preg_match('/\b(?:renal function tests?|kidney function tests?)\b/i', $ot)
                    && preg_match('/\b(?:serum\s+)?creatinine\b|\beGFR\b/i', $keyText)) {
                    $overlap = true;
                }
                if ($overlap) {
                    $warnings[] = [
                        'code' => 'monitoring.broad_test_group_overlaps_specific_keyed_answer',
                        'severity' => 'warning',
                        'message' => 'A distractor uses a broad test grouping (e.g. thyroid function tests, LFTs, renal function tests) that overlaps the keyed single-test answer — tighten distractors so one analyte or named panel clearly maps to the lead-in.',
                        'field' => $f,
                    ];
                    break;
                }
            }
        }
        if ($keyLine !== '') {
            foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $f) {
                if ($f === $keyField) {
                    continue;
                }
                $ot = isset($question[$f]) && is_string($question[$f]) ? trim($question[$f]) : '';
                if ($ot === '') {
                    continue;
                }
                if (sbaStyleOptionTextLooksSupersetOfKeyedSingleTest($keyLine, $ot)) {
                    $warnings[] = [
                        'code' => 'monitoring.option_superset_of_keyed_single_test',
                        'severity' => 'warning',
                        'message' => 'A distractor repeats the keyed test then adds “and …”, “panel”, or “tests” — use distinct single-test lines when the intended best answer is one analyte.',
                        'field' => $f,
                    ];
                    break;
                }
            }
        }
    }

    $just = isset($question['justification']) ? trim((string) $question['justification']) : '';
    if ($just !== '') {
        if (preg_match('/\ba\s+decrease\s+in\s+TSH\s+indicates\s+effective\s+treatment\b/is', $just)
            || preg_match('/\bdecrease(?:s|d)?\s+in\s+TSH\b.{0,120}\b(?:indicates|means|shows|implies|confirms|demonstrates)\b.{0,80}\b(?:effective|adequately\s+treated|adequate\s+replacement)\b/is', $just)) {
            $warnings[] = [
                'code' => 'justification.tsh_decrease_implies_effective_treatment_overclaim',
                'severity' => 'warning',
                'message' => 'Avoid implying that a fall in TSH alone proves effective levothyroxine replacement — tie TSH change to target range and clinical context.',
                'field' => 'justification',
            ];
        }
    }

    return sbaStyleDedupeWarnings($warnings);
}

function sbaStyleTutorCommentsRequestDosingDetail(array $question): bool
{
    $t = trim((string) ($question['_tutor_comments'] ?? ''));
    if ($t === '') {
        return false;
    }

    return (bool) preg_match('/\b(?:dose|dosing|mg\s*\/\s*kg|calculate\s+(?:a\s+)?dose|write\s+up\s+(?:the\s+)?dose|titration|frequency\s+table)\b/i', $t);
}

function sbaStyleBlobSuggestsDetailedPrescribingDoses(string $blob): bool
{
    if ($blob === '') {
        return false;
    }

    return (bool) preg_match('/\b\d+(?:\.\d+)?\s*mg\s*\/\s*kg\b|\bloading\s+dose\b|\binfusion\s+(?:rate|at)\b|\b\d+\s*(?:mcg|µg|ug|mg)\s*\/\s*(?:kg|day|h|hour)\b|\b\d+\s*(?:mcg|µg|mg)\s+every\s+\d+\s*(?:h|hours|hour)\b|\btitrate\s+(?:to|from)\s+\d+/i', $blob);
}

/**
 * Prescribing: stem/lead-in split, PMH wording, and dosing complexity (non-blocking warnings).
 *
 * @param array<string, mixed> $question  Optional `_tutor_comments` (string) for dosing gating only; not part of stored question JSON.
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsPrescribingStemLeadAndDosing(array $question): array
{
    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $lead = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';

    $stemReadsAsQuestion = $stem !== '' && sbaStyleStemContainsMisplacedQuestionPhrasing($stem);
    $leadReadsAsQuestion = $lead !== '' && sbaStyleLeadInContainsExamQuestionWording($lead);

    if ($stemReadsAsQuestion && $leadReadsAsQuestion) {
        $warnings[] = [
            'code' => 'stem_and_lead_in.duplicate_question_wording',
            'severity' => 'warning',
            'message' => 'Both the stem and the lead-in read like exam questions — move the question into the lead_in only and keep the stem as clinical narrative.',
            'field' => 'stem',
        ];
    }

    foreach (['stem', 'lead_in', 'justification'] as $field) {
        $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
        if ($t === '' || !preg_match('/\bsignificant\s+past\s+medical\s+history\b/i', $t)) {
            continue;
        }
        $warnings[] = [
            'code' => 'wording.significant_past_medical_history',
            'severity' => 'warning',
            'message' => 'Avoid “significant past medical history”; prefer “has no other medical conditions” or omit if irrelevant.',
            'field' => $field,
        ];
    }

    if (sbaStyleQuestionLooksPrescribingContext($question) && !sbaStyleTutorCommentsRequestDosingDetail($question)) {
        $parts = [];
        foreach (['stem', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e', 'correct_answer'] as $k) {
            if (!empty($question[$k]) && is_string($question[$k])) {
                $parts[] = (string) $question[$k];
            }
        }
        $blob = implode("\n", $parts);
        if ($blob !== '' && sbaStyleBlobSuggestsDetailedPrescribingDoses($blob)) {
            $warnings[] = [
                'code' => 'prescribing.detailed_doses_without_tutor_dosing_request',
                'severity' => 'warning',
                'message' => 'Prescribing-style item includes detailed dose / weight-band / infusion specifics — simplify unless Tutor comments explicitly ask for dosing (e.g. mg/kg, loading dose, titration).',
                'field' => 'stem',
            ];
        }
    }

    return $warnings;
}

/**
 * Prescribing option format — drug-selection vs dose/route/frequency alignment.
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsPrescribingOptionFormat(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^prescribing$/i', $sk)) {
        return [];
    }

    if (!function_exists('sbaPrescribingInferLearningPoint')) {
        $path = dirname(__DIR__) . '/prompts/sba_prescribing_option_format.php';
        if (is_readable($path)) {
            require_once $path;
        }
    }
    if (!function_exists('sbaPrescribingInferLearningPoint')) {
        return [];
    }

    $warnings = [];
    $opts = sbaStyleOptionTexts($question);
    if ($opts === []) {
        return $warnings;
    }

    $point = sbaPrescribingInferLearningPoint($question);
    $doseFreqCount = sbaPrescribingCountOptionsWithDoseOrFrequency($opts);

    if ($point === 'drug_selection' && $doseFreqCount >= 3) {
        $warnings[] = [
            'code' => 'prescribing.drug_selection_options_include_dose_frequency',
            'severity' => 'warning',
            'message' => 'Prescribing **drug-selection** item: three or more options include **dose and/or frequency** (mg, orally, TDS, etc.). Use **drug names only** (e.g. Amoxicillin, Doxycycline, Clarithromycin) or **drug + route** if route matters — keep all five options in a **similar short format**.',
            'field' => 'options',
        ];
    } elseif ($point === 'drug_selection' && $doseFreqCount >= 1 && $doseFreqCount < count($opts)) {
        $warnings[] = [
            'code' => 'prescribing.drug_selection_mixed_option_formats',
            'severity' => 'warning',
            'message' => 'Prescribing **drug-selection** item mixes **short drug-name** options with **full dose/route/frequency** regimens — use one parallel format across all five options (prefer drug names only unless testing dose/route).',
            'field' => 'options',
        ];
    }

    if (sbaPrescribingLearningPointRequiresStructuredRegimens($point) && $doseFreqCount < 3) {
        $warnings[] = [
            'code' => 'prescribing.dose_frequency_options_need_parallel_regimens',
            'severity' => 'warning',
            'message' => 'Lead-in / Tutor comments suggest **dose or frequency** selection, but options do not show parallel **dose/frequency** regimens for the same drug — align all five options to that learning point.',
            'field' => 'options',
        ];
    }

    if ($point === 'route_selection') {
        $withRoute = 0;
        foreach ($opts as $t) {
            if (sbaStyleOptionLineIncludesPrescribingRoute($t)) {
                ++$withRoute;
            }
        }
        if ($withRoute < 3) {
            $warnings[] = [
                'code' => 'prescribing.route_selection_options_missing_route',
                'severity' => 'warning',
                'message' => 'Prescribing **route-selection** item: options should compare **routes** consistently (e.g. oral vs IV) across all lines.',
                'field' => 'options',
            ];
        }
    }

    return $warnings;
}

function sbaStyleTutorCommentsSpecifyStableOrCommunityTreatment(array $question): bool
{
    $t = mb_strtolower(trim((string) ($question['_tutor_comments'] ?? '')));
    if ($t === '') {
        return false;
    }

    if (preg_match(
        '/\b(?:massive|unstable|shock|emergency|thrombolysis|reperfusion|peri[\s-]?arrest|cardiac\s+arrest|hypotension|critically\s+ill|haemodynamically\s+unstable|hemodynamically\s+unstable|acute\s+deteriorat|inpatient|resuscitat)\b/i',
        $t
    )) {
        return false;
    }
    if (preg_match('/\b(?:not\s+stable|non[\s-]stable|vs\s+stable|contrast\s+with\s+stable|unlike\s+stable)\b/i', $t)) {
        return false;
    }
    if (preg_match('/\b(?:community[\s-]acquired|treated\s+in\s+the\s+communit|outpatient|primary\s+care|gp\s+review)\b/i', $t)) {
        return true;
    }
    if (preg_match('/\bstable\s+(?:cap|pneumonia|infection|patient)\b/i', $t)) {
        return true;
    }

    return (bool) (preg_match('/\bstable\b/i', $t) && preg_match('/\b(?:prescrib|antibiotic|oral\s+therapy)\b/i', $t));
}

function sbaStyleStemSuggestsHypoxaemiaBelow94OnAirOrUnspecified(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (preg_match('/\b(?:on|via)\s+\d+(?:\.\d+)?\s*L(?:\s*\/\s*min)?\b|\b(?:high[\s-]flow|non[\s-]rebreather|cpap|bipap|niv\b|fio2)\b/i', $stem)) {
        return false;
    }
    if (!preg_match('/\b(?:oxygen\s+saturation|spo2|sp\s*o2|saturations?)\s*(?:is|of|was)?\s*(\d{1,3})\s*%/i', $stem, $m)) {
        return false;
    }
    $v = (int) $m[1];
    if ($v < 1 || $v > 100) {
        return false;
    }
    if ($v >= 94) {
        return false;
    }
    if (preg_match('/\b(?:breathing|on)\s+air\b/i', $stem)) {
        return true;
    }
    if (!preg_match('/\b(?:nasal\s+cannulae|nasal\s+cannula|facemask|face\s+mask|oxygen\s+therapy|supplemental\s+oxygen)\b/i', $stem)) {
        return true;
    }

    return false;
}

function sbaStyleStemSuggestsAcuteInstabilityOrReferralSeverity(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (sbaStyleStemSuggestsHaemodynamicInstability($stem)) {
        return true;
    }
    if (sbaStyleStemSuggestsHypoxaemiaBelow94OnAirOrUnspecified($stem)) {
        return true;
    }
    if (preg_match('/\brespiratory\s+rate\s*(?:is|of|was)?\s*(?:2[89]\d|[3-6]\d)\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\b(?:new\s+)?confus(?:ion|ed)|delirium|altered\s+mental|not\s+orientated|disorientated|\bgcs\s*(?:is|of)?\s*\d\b/i', $stem)) {
        return true;
    }
    if (preg_match('/\b(?:unable\s+to\s+complete\s+sentences|speaks\s+in\s+single\s+words|severe\s+breathless|marked\s+breathless)\b/i', $stem)) {
        return true;
    }

    return false;
}

function sbaStyleJustificationClaimsAbsenceOfSignificantComorbidities(string $just): bool
{
    return (bool) preg_match('/\babsence\s+of\s+significant\s+comorbidities\b|\bno\s+significant\s+comorbidities\b|\bwithout\s+significant\s+comorbidities\b|\bno\s+major\s+comorbidities\b/i', trim($just));
}

function sbaStyleStemDeniesKnownDrugAllergies(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return (bool) preg_match(
        '/\bno\s+known\s+drug\s+allerg(?:y|ies)\b|\bno\s+known\s+allerg(?:y|ies)\b|\bdenies\s+(?:any\s+)?(?:drug\s+)?allerg|\bno\s+drug\s+allerg(?:y|ies)\b|\bnot\s+allergic\s+to\s+(?:any\s+)?(?:medication|drug|antibiotic)s?\b|\bno\s+history\s+of\s+(?:drug\s+)?allerg|\bwithout\s+(?:any\s+)?(?:known\s+)?drug\s+allerg/i',
        $stem
    );
}

function sbaStyleTextPrefixNegatesDrugAllergyClaim(string $prefix): bool
{
    $suffix = rtrim(trim($prefix));
    if ($suffix === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:without|no|not|absence\s+of|in\s+the\s+absence\s+of|denies|denying|lacks|there\s+is\s+no|because\s+there\s+is\s+no|as\s+there\s+is\s+no|no\s+known|without\s+known|in\s+patients?\s+without)\s*$/iu',
        $suffix
    ) || (bool) preg_match(
        '/\b(?:without|no|not|there\s+is\s+no|because\s+there\s+is\s+no)\s+(?:\w+\s+){0,6}$/iu',
        $suffix
    );
}

function sbaStyleBlobClaimsDrugAllergyForPrescribing(string $blob): bool
{
    if ($blob === '') {
        return false;
    }

    $patterns = [
        '/\b(?:penicillin|beta[\s-]?lactam|amoxicillin)\s+allerg(?:y|ies)\b/iu',
        '/\ballerg(?:y|ic)\s+to\s+(?:penicillin|amoxicillin|beta[\s-]?lactam)\b/iu',
        '/\bdrug\s+allerg(?:y|ies)\b.{0,60}(?:contraindicated|avoid|cannot\s+(?:use|take)|unsuitable)\b/iu',
        '/\bcontraindicated.{0,50}(?:allerg|penicillin|amoxicillin)\b/iu',
        '/\bdue\s+to\s+(?:a\s+)?(?:penicillin|drug)\s+allerg/iu',
        '/\bbecause\s+of\s+(?:a\s+)?(?:penicillin|drug)\s+allerg/iu',
        '/\bamoxicillin\s+(?:is\s+)?contraindicated\b/iu',
        '/\bcannot\s+(?:use|take|prescribe)\s+amoxicillin\b/iu',
    ];

    foreach ($patterns as $pattern) {
        if (!preg_match_all($pattern, $blob, $matches, PREG_OFFSET_CAPTURE)) {
            continue;
        }
        foreach ($matches[0] as $match) {
            $start = (int) $match[1];
            $prefix = substr($blob, max(0, $start - 80), min(80, $start));
            if (!sbaStyleTextPrefixNegatesDrugAllergyClaim($prefix)) {
                return true;
            }
        }
    }

    return false;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsPrescribingAllergyContradiction(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^prescribing$/i', $sk)) {
        return [];
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !sbaStyleStemDeniesKnownDrugAllergies($stem)) {
        return [];
    }

    $just = isset($question['justification']) ? trim((string) $question['justification']) : '';
    $keyedBlob = sbaStyleKeyedAnswerCombinedManagementText($question);
    $claimsBlob = trim($just . "\n" . $keyedBlob);
    if ($claimsBlob === '' || !sbaStyleBlobClaimsDrugAllergyForPrescribing($claimsBlob)) {
        return [];
    }

    $field = $just !== '' && sbaStyleBlobClaimsDrugAllergyForPrescribing($just) ? 'justification' : 'correct_answer';

    return [[
        'code' => 'prescribing_allergy_contradiction',
        'severity' => 'high',
        'message' => '[High priority] **Prescribing**: the **stem** states **no known drug allergies** (or equivalent), but the **justification** or keyed-answer rationale cites **penicillin allergy**, **drug allergy**, or **amoxicillin contraindicated due to allergy**. Either **remove allergy reasoning** from the justification and key a non–beta-lactam only if appropriate, **or** **add the allergy to the stem** if penicillin allergy is the intended prescribing concept.',
        'field' => $field,
    ]];
}

/**
 * After co-amoxiclav overuse retry: amoxicillin keyed again when batch already has amoxicillin.
 *
 * @param array<string, mixed> $question
 * @param list<array{code?: string, severity?: string, message?: string, field?: string|null}> $firstPassWarnings
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsCapRetryFellBackToRepeatedAmoxicillin(array $question, array $firstPassWarnings): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^prescribing$/i', $sk) || !sbaStyleQuestionSuggestsCapCoreScenario($question)) {
        return [];
    }

    $hadCoAmoxFix = false;
    foreach ($firstPassWarnings as $w) {
        if (!is_array($w)) {
            continue;
        }
        $c = (string) ($w['code'] ?? '');
        if ($c === 'cap_prescribing_coamoxiclav_overuse_or_unjustified'
            || $c === 'cap_prescribing_diabetes_alone_not_broad_cover') {
            $hadCoAmoxFix = true;
            break;
        }
    }
    if (!$hadCoAmoxFix) {
        return [];
    }

    if (!function_exists('sbaCapPrescribingBatchAmoxicillinAlreadyKeyed')) {
        $capPath = dirname(__DIR__) . '/prompts/sba_cap_prescribing_concepts.php';
        if (is_readable($capPath)) {
            require_once $capPath;
        }
    }

    $priors = $question['_batch_prior_questions'] ?? [];
    if (!is_array($priors) || $priors === []
        || !function_exists('sbaCapPrescribingBatchAmoxicillinAlreadyKeyed')) {
        return [];
    }

    $condition = trim((string) ($question['_condition_name'] ?? ''));
    $tutor = trim((string) ($question['_tutor_comments'] ?? ''));
    if (!sbaCapPrescribingBatchAmoxicillinAlreadyKeyed($priors, $condition)) {
        return [];
    }
    if (function_exists('sbaCapPrescribingTutorAllowsRepeatUncomplicatedCap')
        && sbaCapPrescribingTutorAllowsRepeatUncomplicatedCap($tutor)) {
        return [];
    }

    $correct = sbaStyleCorrectAnswerCombinedText($question);
    if ($correct === '') {
        $correct = trim((string) ($question['correct_answer'] ?? ''));
    }
    if (sbaStyleCapPrescribingKeyedAntibioticFamily($correct) !== 'amoxicillin') {
        return [];
    }

    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';

    return [[
        'code' => 'cap_retry_fell_back_to_repeated_amoxicillin',
        'severity' => 'high',
        'message' => '[High priority] CAP **Prescribing** retry: the first pass keyed **co-amoxiclav** without justification, but regeneration keyed **amoxicillin** again even though **amoxicillin** is already used in this batch. **Do not** repeat uncomplicated amoxicillin — choose a **different scenario slot** (e.g. penicillin allergy → doxycycline/clarithromycin; atypical features; aspiration with justified co-amoxiclav; treatment failure; severe/IV).',
        'field' => $fieldKey,
    ]];
}

function sbaStyleStemMentionsCommonSignificantComorbidity(string $stem): bool
{
    return (bool) preg_match(
        '/\b(?:has\s+)?asthma\b|\bCOPD\b|\bchronic\s+obstructive\s+(?:pulmonary\s+disease|airways\s+disease)\b|\bheart\s+failure\b|\bCKD\b|\bchronic\s+kidney\s+disease\b|\bdiabetes\s+mellitus\b|\btype\s+[12]\s+diabetes\b|\bimmunosuppress/i',
        $stem
    );
}

function sbaStyleBlobSuggestsSeverityOrReferralTriage(string $blob): bool
{
    if ($blob === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:hospital\s+admission|admit(?:ted)?\s+to\s+hospital|refer\s+to\s+hospital|same[\s-]day\s+(?:emergency|acute)|acute\s+medical\s+unit|ambulance\s+call|999\s+call|CRB[\s-]?65|CURB[\s-]?65|poor\s+prognostic|high\s+risk\s+of\s+mortality|NEWS\s*(?:score)?\s*(?:is|of|:)?\s*(?:[7-9]|1\d|2[0-5])\b|\bITU\b|\bICU\b|intensive\s+care|severe\s+pneumonia|septic\s+shock|palliative\s+care\s+only\b)/i',
        $blob
    );
}

function sbaStyleBlobSuggestsCommunityAcquiredPneumonia(string $blob): bool
{
    if ($blob === '') {
        return false;
    }
    $b = mb_strtolower($blob);

    return (bool) preg_match('/\bcommunity[\s-]*acquired\s+pneumonia\b|\bcommunity\s+acquired\s+pneumonia\b|\bcap\b/i', $b)
        || (preg_match('/\bpneumonia\b/', $b) && preg_match('/\bcommunity\b/', $b));
}

function sbaStyleQuestionSuggestsCapCoreScenario(array $question): bool
{
    if (!function_exists('sbaGeneratorConditionLooksCommunityAcquiredPneumonia')) {
        $capPath = dirname(__DIR__) . '/prompts/sba_cap_prescribing_concepts.php';
        if (is_readable($capPath)) {
            require_once $capPath;
        }
    }
    $condition = (string) ($question['_condition_name'] ?? '');
    if ($condition !== '' && function_exists('sbaGeneratorConditionLooksCommunityAcquiredPneumonia')
        && sbaGeneratorConditionLooksCommunityAcquiredPneumonia($condition)) {
        return true;
    }
    $parts = [];
    foreach (['title', 'stem', 'presentation'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $parts[] = trim($question[$k]);
        }
    }

    return sbaStyleBlobSuggestsCommunityAcquiredPneumonia(implode(' ', $parts));
}

function sbaStyleOptionLooksCapAntibioticDrugName(string $text): bool
{
    if (trim($text) === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:amoxicillin|co[\s-]?amoxiclav|co[\s-]?amox\b|doxycycline|clarithromycin|azithromycin|levofloxacin|ciprofloxacin|benzylpenicillin|cef(?:triaxone|otaxime)|piperacillin|flucloxacillin|erythromycin|co[\s-]?trimoxazole|trimethoprim)\b/i',
        $text
    ) && !preg_match(
        '/\b(?:admit|admission|hospital|refer|oxygen|fluid|escalation|review|monitor|switch\s+to|outpatient|oral\s+antibiotics?\s+and|iv\s+antibiotics?\s+and)\b/i',
        $text
    );
}

function sbaStyleCapStemHasPhysiologicalSeverityCues(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (function_exists('sbaStyleStemSuggestsClinicallySignificantHypoxia')
        && sbaStyleStemSuggestsClinicallySignificantHypoxia($stem)) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:respiratory\s+rate|rr)\s*(?:is|of|was|:)?\s*(?:2[4-9]|[3-9]\d)\b|\bblood\s+pressure\s+\d{2,3}\/\d{2,3}\s*mmhg\b|\bhypotension\b|\bconfusion\b|\bnew\s+confusion\b|\b(?:urea|creatinine)\s*(?:is|of|was|:)?\s*\d+\b|\bcurb[\s-]?65\b|\bcrb[\s-]?65\b|\bseptic\s+shock\b|\bshock\b|\bperi[\s-]?arrest\b|\brequires?\s+high[\s-]flow\s+oxygen\b|\btype\s+i\s+respiratory\s+failure\b/i',
        $stem
    );
}

function sbaStyleCapInterpretationKeyOverclaimsSeverity(string $keyed): bool
{
    if ($keyed === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:severe\s+(?:cap|community[\s-]acquired\s+pneumonia|infection|pneumonia)|life[\s-]threatening\s+(?:infection|pneumonia)|high[\s-]risk\s+pneumonia)\b/i',
        $keyed
    );
}

function sbaStyleCapInterpretationKeyIsMildPneumoniaLabel(string $keyed): bool
{
    if ($keyed === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:community[\s-]acquired\s+pneumonia|pneumonia|infection)\s+(?:likely|probable|consistent)\b|\b(?:likely|probable)\s+(?:community[\s-]acquired\s+pneumonia|pneumonia|infection)\b/i',
        $keyed
    ) && !sbaStyleCapInterpretationKeyOverclaimsSeverity($keyed);
}

function sbaStyleOptionLooksCapDiagnosticWorkupNotMonitoring(string $text): bool
{
    if (trim($text) === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:ct\s+chest|computed\s+tomography|bronchoscopy|blood\s+cultures?|sputum\s+culture|pleural\s+aspiration|thoracentesis|diagnostic\s+work[\s-]?up|request\s+(?:a\s+)?ct|arrange\s+(?:a\s+)?ct)\b/i',
        $text
    );
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsCapEmergencyHypoxicOxygenUnfair(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^emergency\s+management$/i', $sk) || !sbaStyleQuestionSuggestsCapCoreScenario($question)) {
        return [];
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !sbaStyleStemIndicatesSpO2Below92($stem)) {
        return [];
    }

    $letter = strtoupper(trim((string) ($question['correct_answer_letter'] ?? '')));
    $fieldKey = preg_match('/^[A-E]$/', $letter) ? 'correct_answer' : 'options';
    $correct = trim((string) ($question['correct_answer'] ?? ''));
    if ($correct === '' && preg_match('/^[A-E]$/', $letter)) {
        $optKey = 'option_' . strtolower($letter);
        $correct = trim((string) ($question[$optKey] ?? ''));
    }
    if ($correct !== '' && sbaStyleTextMentionsOxygen($correct)) {
        return [];
    }

    $oxygenDistractor = false;
    $oxygenOnlyDistractor = false;
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $optField) {
        $opt = isset($question[$optField]) ? trim((string) $question[$optField]) : '';
        if ($opt === '') {
            continue;
        }
        if (sbaStyleOptionIsOxygenFocusedDistractor($opt) || sbaStyleOptionIsOxygenTherapyOnlyLine($opt)) {
            $oxygenDistractor = true;
            if (sbaStyleOptionIsOxygenTherapyOnlyLine($opt)) {
                $oxygenOnlyDistractor = true;
            }
        }
        if (sbaStyleTextMentionsOxygen($opt) && preg_match('/\b(?:plus|and|,)\b/i', $opt)) {
            $oxygenDistractor = true;
        }
    }
    if (!$oxygenDistractor) {
        return [];
    }

    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $narrowLeadIn = preg_match('/\b(?:vasopressor|circulatory|noradrenaline|after\s+(?:fluids|antibiotics)|persistent\s+hypotension|non[\s-]respiratory)\b/i', $leadIn);

    return [[
        'code' => 'cap.emergency_hypoxic_oxygen_unfair',
        'severity' => 'high',
        'message' => '[High priority] CAP **Emergency Management**: the stem is **hypoxic** (SpO₂ below about 92% on air) and an option includes **oxygen therapy**, but the keyed answer omits **oxygen** (e.g. keys **IV fluids plus antibiotics alone**). Include **oxygen plus** resuscitation in the key, **or** remove standalone **oxygen** distractors, **or** narrow the lead-in to a non-respiratory step if oxygen is not required.',
        'field' => $narrowLeadIn ? 'lead_in' : $fieldKey,
    ]];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsCapInterpretationSeverity(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^interpretation$/i', $sk) || !sbaStyleQuestionSuggestsCapCoreScenario($question)) {
        return [];
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $keyed = trim((string) ($question['correct_answer'] ?? ''));
    if ($keyed === '') {
        $letter = strtoupper(trim((string) ($question['correct_answer_letter'] ?? '')));
        if (preg_match('/^[A-E]$/', $letter)) {
            $keyed = trim((string) ($question['option_' . strtolower($letter)] ?? ''));
        }
    }
    if ($stem === '' || $keyed === '') {
        return [];
    }

    $warnings = [];
    $hasPhysio = sbaStyleCapStemHasPhysiologicalSeverityCues($stem);
    $hasInflammatoryOnly = preg_match('/\b(?:crp|wbc|white\s+cell|c[\s-]reactive|infiltrate|consolidation|chest\s+x[\s-]?ray)\b/i', $stem)
        && !$hasPhysio;

    if ($hasInflammatoryOnly && sbaStyleCapInterpretationKeyOverclaimsSeverity($keyed)) {
        $warnings[] = [
            'code' => 'cap.interpretation_severity_overclaim',
            'severity' => 'high',
            'message' => '[High priority] CAP **Interpretation**: the keyed answer claims **severe** infection/pneumonia but the stem supplies mainly **CRP/WBC/imaging** without **physiological severity cues** (SpO₂, respiratory rate, blood pressure, confusion, urea, CURB-65/CRB-65). Either add severity observations to the stem or key **pneumonia/infection likely** without overclaiming severity.',
            'field' => 'correct_answer',
        ];
    }

    if ($hasPhysio && sbaStyleCapInterpretationKeyIsMildPneumoniaLabel($keyed)
        && preg_match('/\b(?:curb[\s-]?65|crb[\s-]?65)\s*(?:score\s*)?(?:is|of|was|:)?\s*[3-5]\b|\bseptic\s+shock\b|\bhypotension\b/i', $stem)) {
        $warnings[] = [
            'code' => 'cap.interpretation_severity_under_specified',
            'severity' => 'warning',
            'message' => 'CAP **Interpretation**: the stem includes **high-severity physiological features** but the keyed answer only states **pneumonia/infection likely** without reflecting severity — consider a severity-appropriate interpretation if that is the learning point.',
            'field' => 'correct_answer',
        ];
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsCapManagementPrescribingDrift(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^management$/i', $sk) || !sbaStyleQuestionSuggestsCapCoreScenario($question)) {
        return [];
    }

    $abxCount = 0;
    $filled = 0;
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $optField) {
        $opt = isset($question[$optField]) ? trim((string) $question[$optField]) : '';
        if ($opt === '') {
            continue;
        }
        ++$filled;
        if (sbaStyleOptionLooksCapAntibioticDrugName($opt)) {
            ++$abxCount;
        }
    }
    if ($filled >= 4 && $abxCount >= 4) {
        return [[
            'code' => 'cap.management_antibiotic_selection_drift',
            'severity' => 'high',
            'message' => '[High priority] CAP **Management**: the option set is essentially **antibiotic selection only** (drug names across A–E). Use broader **management strategies** (e.g. outpatient oral therapy, admission, oxygen/IV therapy, escalation) unless **Tutor comments** explicitly test antibiotic choice under Management — reserve antimicrobial choice for **Prescribing**.',
            'field' => 'options',
        ]];
    }

    return [];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsCapMonitoringStyle(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^monitoring$/i', $sk) || !sbaStyleQuestionSuggestsCapCoreScenario($question)) {
        return [];
    }

    $diagCount = 0;
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $optField) {
        $opt = isset($question[$optField]) ? trim((string) $question[$optField]) : '';
        if ($opt !== '' && sbaStyleOptionLooksCapDiagnosticWorkupNotMonitoring($opt)) {
            ++$diagCount;
        }
    }
    if ($diagCount >= 2) {
        return [[
            'code' => 'cap.monitoring_diagnostic_investigation_drift',
            'severity' => 'warning',
            'message' => 'CAP **Monitoring**: multiple options read as **one-off diagnostic investigations** (e.g. CT chest, blood cultures) rather than **follow-up monitoring** parameters. Prefer oxygen saturation, respiratory rate, temperature, symptoms, clinical stability, inflammatory markers when relevant, and treatment response.',
            'field' => 'options',
        ]];
    }

    return [];
}

function sbaStyleKeyedAnswerMentionsCoAmoxiclav(string $text): bool
{
    if ($text === '') {
        return false;
    }

    return (bool) preg_match(
        '/\bco[\s-]?amoxiclav\b|\bco[\s-]?amox\b|\bamoxicillin[\s-]?(?:clavulanate|clavulanic)\b|\bamoxiclav\b|\baugmentin\b/i',
        $text
    );
}

function sbaStyleBlobJustifiesCoAmoxiclavForCap(string $blob, string $tutorComments = ''): bool
{
    $combined = trim($blob . "\n" . $tutorComments);
    if ($combined === '') {
        return false;
    }
    if (sbaStyleBlobSuggestsSeverityOrReferralTriage($combined)) {
        return true;
    }
    if (preg_match(
        '/\b(?:severe\s+pneumonia|moderate[\s-]to[\s-]severe\s+pneumonia|curb[\s-]?65\s*(?:score\s*)?(?:is|of|was|:)?\s*[3-5]|crb[\s-]?65\s*(?:score\s*)?(?:is|of|was|:)?\s*[3-5]|high[\s-]risk\s+pneumonia|hospital[\s-]acquired|hap\b|post[\s-]discharge\s+from\s+hospital|inpatient\s+pneumonia|nursing[\s-]home\s+resident|care\s+home\s+resident)\b/i',
        $combined
    )) {
        return true;
    }
    if (preg_match(
        '/\b(?:frail(?:ty)?|nursing[\s-]home\s+resident|care\s+home\s+resident|immunosuppress|immunocompromis|severe\s+COPD|heart\s+failure|diabetes\s+with\s+complications)\b/i',
        $combined
    )) {
        return true;
    }
    if (preg_match(
        '/\b(?:aspiration\s+(?:risk|pneumonia)|aspirated|impaired\s+swallow|dysphagia|vomit(?:ing|ed)?\s+(?:into|with)|risk\s+of\s+aspiration)\b/i',
        $combined
    )) {
        return true;
    }
    if (preg_match(
        '/\b(?:recent\s+antibiotic|previous\s+(?:course\s+of\s+)?antibiotic|already\s+(?:on|taking)\s+antibiotic|treatment\s+failure|failed\s+(?:a\s+)?(?:course\s+of\s+)?amoxicillin|no\s+improvement\s+(?:on|with|after)\s+amoxicillin)\b/i',
        $combined
    )) {
        return true;
    }
    if ($tutorComments !== '' && preg_match(
        '/\b(?:co[\s-]?amoxiclav|amoxicillin[\s-]clavulanate|broader\s+cover|broad[\s-]spectrum\s+oral|augmentin)\b/i',
        $tutorComments
    )) {
        return true;
    }
    if ($tutorComments !== '' && preg_match(
        '/\b(?:local\s+guidance|hospital\s+guidance|trust\s+guideline|formulary).{0,80}(?:co[\s-]?amoxiclav|clavulanate)\b/i',
        $tutorComments
    )) {
        return true;
    }

    return false;
}

/**
 * CAP Prescribing — keyed antimicrobial family for batch diversity checks.
 *
 * @return ''|'co_amoxiclav'|'amoxicillin'|'doxycycline'|'macrolide'|'fluoroquinolone'
 */
function sbaStyleCapPrescribingKeyedAntibioticFamily(string $text): string
{
    if ($text === '') {
        return '';
    }
    if (sbaStyleKeyedAnswerMentionsCoAmoxiclav($text)) {
        return 'co_amoxiclav';
    }
    if (preg_match('/\bamoxicillin\b/i', $text) && !preg_match('/\bclavulanate\b/i', $text)) {
        return 'amoxicillin';
    }
    if (preg_match('/\bdoxycycline\b/i', $text)) {
        return 'doxycycline';
    }
    if (preg_match('/\b(?:clarithromycin|erythromycin|azithromycin|roxithromycin)\b/i', $text)) {
        return 'macrolide';
    }
    if (preg_match('/\b(?:levofloxacin|ciprofloxacin|moxifloxacin)\b/i', $text)) {
        return 'fluoroquinolone';
    }

    return '';
}

/**
 * Community-acquired pneumonia — co-amoxiclav overuse (Prescribing).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsCapPrescribingCoAmoxiclav(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^prescribing$/i', $sk) || !sbaStyleQuestionSuggestsCapCoreScenario($question)) {
        return [];
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $title = isset($question['title']) ? trim((string) $question['title']) : '';
    $lead = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $tutor = trim((string) ($question['_tutor_comments'] ?? ''));
    $keyedBlob = sbaStyleKeyedAnswerCombinedManagementText($question);
    if ($keyedBlob === '' || !sbaStyleKeyedAnswerMentionsCoAmoxiclav($keyedBlob)) {
        return [];
    }

    $stemBlob = $title . "\n" . $stem . "\n" . $lead;
    $just = isset($question['justification']) ? trim((string) $question['justification']) : '';
    if (sbaStyleBlobJustifiesCoAmoxiclavForCap($stemBlob, $tutor)
        && !($just !== ''
            && function_exists('sbaCapPrescribingJustificationClaimsDiabetesWarrantsBroaderCover')
            && sbaCapPrescribingJustificationClaimsDiabetesWarrantsBroaderCover($just)
            && function_exists('sbaCapPrescribingJustificationMentionsClearCoAmoxIndication')
            && !sbaCapPrescribingJustificationMentionsClearCoAmoxIndication($just))) {
        return [];
    }

    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';

    return [[
        'code' => 'cap_prescribing_coamoxiclav_overuse_or_unjustified',
        'severity' => 'high',
        'message' => '[High priority] Community-acquired pneumonia **Prescribing**: the keyed answer is **co-amoxiclav** but the stem does not justify **broader cover** (no **aspiration risk**, **frailty with significant concern**, **severe pneumonia**, **recent antibiotics**, **treatment failure**, **immunosuppression**, **hospital-acquired context**, or **explicit tutor/local guidance**). **Type 2 diabetes mellitus alone** does **not** justify co-amoxiclav in **stable mild CAP** — prefer **amoxicillin** if not already used in this batch; otherwise use a **different scenario slot** (allergy, atypical, aspiration, treatment failure, severe/IV) — keep all options as **prescribing choices** only.',
        'field' => $fieldKey,
    ]];
}

/**
 * CAP Prescribing — co-amoxiclav keyed when diabetes mellitus alone is the broader-cover rationale.
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsCapPrescribingDiabetesAloneNotBroadCover(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^prescribing$/i', $sk) || !sbaStyleQuestionSuggestsCapCoreScenario($question)) {
        return [];
    }

    if (!function_exists('sbaCapPrescribingDiabetesAloneIsOnlyBroaderCoverRationale')) {
        $capPath = dirname(__DIR__) . '/prompts/sba_cap_prescribing_concepts.php';
        if (is_readable($capPath)) {
            require_once $capPath;
        }
    }
    if (!function_exists('sbaCapPrescribingDiabetesAloneIsOnlyBroaderCoverRationale')) {
        return [];
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $title = isset($question['title']) ? trim((string) $question['title']) : '';
    $lead = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $tutor = trim((string) ($question['_tutor_comments'] ?? ''));
    $just = isset($question['justification']) ? trim((string) $question['justification']) : '';
    $keyedBlob = sbaStyleKeyedAnswerCombinedManagementText($question);
    if ($keyedBlob === '' || !sbaStyleKeyedAnswerMentionsCoAmoxiclav($keyedBlob)) {
        return [];
    }

    $stemBlob = $title . "\n" . $stem . "\n" . $lead;
    if (!sbaCapPrescribingDiabetesAloneIsOnlyBroaderCoverRationale($stemBlob, $tutor, $just)) {
        return [];
    }

    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';

    return [[
        'code' => 'cap_prescribing_diabetes_alone_not_broad_cover',
        'severity' => 'high',
        'message' => '[High priority] Community-acquired pneumonia **Prescribing**: **co-amoxiclav** is keyed but the only broader-cover rationale is **type 2 diabetes mellitus** (or diabetes alone in the justification). **Do not** use diabetes mellitus alone to justify broader cover in **stable mild CAP** — key **amoxicillin**, or add a clear stem indication for co-amoxiclav (aspiration risk, treatment failure, severe/frail presentation, immunosuppression, recent antibiotics, or hospital-acquired context).',
        'field' => $fieldKey,
    ]];
}

/**
 * CAP Prescribing batch — antibiotic family + clinical rationale tracking (form generator).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsCapPrescribingBatchConceptTracking(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^prescribing$/i', $sk) || !sbaStyleQuestionSuggestsCapCoreScenario($question)) {
        return [];
    }

    if (!function_exists('sbaCapPrescribingCountPrescribingSlotsInJob')) {
        $capPath = dirname(__DIR__) . '/prompts/sba_cap_prescribing_concepts.php';
        if (is_readable($capPath)) {
            require_once $capPath;
        }
    }
    if (!function_exists('sbaCapPrescribingShouldApplyBatchDiversityRules')
        || !sbaCapPrescribingShouldApplyBatchDiversityRules($question)) {
        return [];
    }

    $priors = $question['_batch_prior_questions'] ?? [];
    if (!is_array($priors) || $priors === []) {
        return [];
    }

    $condition = trim((string) ($question['_condition_name'] ?? ''));
    $tutor = trim((string) ($question['_tutor_comments'] ?? ''));
    $allowRepeatUncomplicated = function_exists('sbaCapPrescribingTutorAllowsRepeatUncomplicatedCap')
        && sbaCapPrescribingTutorAllowsRepeatUncomplicatedCap($tutor);

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $title = isset($question['title']) ? trim((string) $question['title']) : '';
    $lead = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $correct = trim((string) ($question['correct_answer'] ?? ''));
    $correctBlob = sbaStyleCorrectAnswerCombinedText($question);
    if ($correctBlob !== '') {
        $correct = $correctBlob;
    }

    $famCur = sbaStyleCapPrescribingKeyedAntibioticFamily($correct);
    $ratCur = function_exists('sbaCapPrescribingInferClinicalRationale')
        ? sbaCapPrescribingInferClinicalRationale($stem, $title, $lead, $tutor, $correct)
        : 'uncomplicated';

    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';

    foreach ($priors as $p) {
        if (!is_array($p) || !function_exists('sbaCapPrescribingRowIsCapPrescribingItem')
            || !sbaCapPrescribingRowIsCapPrescribingItem($p, $condition)) {
            continue;
        }
        $pStem = trim((string) ($p['stem'] ?? ''));
        $pTitle = trim((string) ($p['title'] ?? ''));
        $pLead = trim((string) ($p['lead_in'] ?? ''));
        $pCorrect = trim((string) ($p['correct_answer'] ?? ''));
        $famPrior = sbaStyleCapPrescribingKeyedAntibioticFamily($pCorrect);
        $ratPrior = function_exists('sbaCapPrescribingInferClinicalRationale')
            ? sbaCapPrescribingInferClinicalRationale($pStem, $pTitle, $pLead, '', $pCorrect)
            : '';

        if ($famCur !== '' && $famPrior !== '' && $famCur === $famPrior) {
            if ($ratCur !== '' && $ratPrior !== '' && $ratCur === $ratPrior) {
                return [[
                    'code' => 'cap_prescribing_same_antibiotic',
                    'severity' => 'high',
                    'message' => '[High priority] CAP **Prescribing** batch: this item repeats the same **clinical rationale** (**'
                        . (function_exists('sbaCapPrescribingRationaleDisplayLabel') ? sbaCapPrescribingRationaleDisplayLabel($ratCur) : $ratCur)
                        . '**) and the same **keyed antibiotic family** as an earlier item. Regenerate using a **different scenario slot** (e.g. penicillin allergy, atypical features, frailty/comorbidity, aspiration, severe/IV, treatment failure) and a **different** appropriate antibiotic.',
                    'field' => $fieldKey,
                ]];
            }
            if (!$allowRepeatUncomplicated && $famCur === 'amoxicillin') {
                return [[
                    'code' => 'cap_prescribing_same_antibiotic',
                    'severity' => 'high',
                    'message' => '[High priority] CAP **Prescribing** batch: **amoxicillin** is already the keyed answer on an earlier item — use a **different scenario slot** and antibiotic (not amoxicillin again) unless **Tutor comments** explicitly request repeated uncomplicated CAP.',
                    'field' => $fieldKey,
                ]];
            }
        }
    }

    return [];
}

function sbaStyleStemSuggestsIncreasedRelieverInhalerUse(string $stem): bool
{
    return (bool) preg_match(
        '/\b(?:increased|more\s+frequent|extra|worsening)\s+(?:use\s+of\s+)?(?:his|her|their)?\s*(?:reliever|salbutamol|ventolin|blue\s+inhaler)|\b(?:reliever|salbutamol|ventolin)\s+(?:use|inhaler)\s+(?:has\s+)?(?:increased|gone\s+up|worsened)|\busing\s+(?:his|her|their)\s+reliever\s+more\b/i',
        $stem
    );
}

/**
 * Stable community infection prescribing alignment (non-blocking warnings).
 *
 * @param array<string, mixed> $question  Optional `_tutor_comments` for tutor-gated checks; not stored in question JSON.
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsStableCommunityInfectionPrescribing(array $question): array
{
    $warnings = [];
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^prescribing$/i', $sk) && !sbaStyleTutorCommentsSpecifyStableOrCommunityTreatment($question)) {
        return $warnings;
    }
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $just = isset($question['justification']) ? trim((string) $question['justification']) : '';
    $title = isset($question['title']) ? trim((string) $question['title']) : '';
    $lead = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';

    if (preg_match('/^prescribing$/i', $sk)
        && $stem !== ''
        && sbaStyleTutorCommentsSpecifyStableOrCommunityTreatment($question)
        && sbaStyleStemSuggestsAcuteInstabilityOrReferralSeverity($stem)) {
        $warnings[] = [
            'code' => 'prescribing.stable_community_tutor_mismatch_acute_stem_features',
            'severity' => 'warning',
            'message' => 'Tutor comments describe stable community treatment, but the stem includes features suggesting hypoxia, hypotension, marked tachypnoea, confusion, or severe breathlessness — align severity with community-stable framing or adjust Tutor comments.',
            'field' => 'stem',
        ];
    }

    if ($just !== '' && sbaStyleJustificationClaimsAbsenceOfSignificantComorbidities($just) && $stem !== '' && sbaStyleStemMentionsCommonSignificantComorbidity($stem)) {
        $warnings[] = [
            'code' => 'justification.comorbidity_absence_mismatch_stem',
            'severity' => 'warning',
            'message' => 'Justification claims absence of significant comorbidities but the stem documents a chronic condition (e.g. asthma, COPD, heart failure, CKD, diabetes) — align wording with the vignette.',
            'field' => 'justification',
        ];
    }

    if (sbaStyleQuestionLooksPrescribingContext($question)) {
        $blob = mb_strtolower($title . "\n" . $stem . "\n" . $lead);
        if (sbaStyleBlobSuggestsSeverityOrReferralTriage($blob)) {
            $warnings[] = [
                'code' => 'prescribing.severity_or_referral_cues_may_shift_decision_point',
                'severity' => 'warning',
                'message' => 'Prescribing item includes severity / referral / admission triage cues — keep the stem focused on medicine choice unless Tutor comments explicitly ask for triage or referral decisions.',
                'field' => 'stem',
            ];
        }
    }

    $capBlob = $title . "\n" . $stem . "\n" . $lead;
    if (sbaStyleQuestionLooksPrescribingContext($question)
        && sbaStyleBlobSuggestsCommunityAcquiredPneumonia($capBlob)
        && $stem !== ''
        && sbaStyleStemSuggestsIncreasedRelieverInhalerUse($stem)
        && !preg_match('/\basthma\b/i', $stem)) {
        $warnings[] = [
            'code' => 'prescribing.cap_reliever_use_without_asthma_learning_point',
            'severity' => 'warning',
            'message' => 'Community-acquired pneumonia prescribing stem includes increased reliever inhaler use without asthma in the vignette — omit asthma-exacerbation cues unless asthma is part of the learning point.',
            'field' => 'stem',
        ];
    }

    return $warnings;
}

function sbaStyleQuestionBlobLooksSuspectedSepsisEmergency(array $question): bool
{
    $parts = [];
    foreach (['title', 'stem', 'lead_in'] as $k) {
        if (empty($question[$k]) || !is_string($question[$k])) {
            continue;
        }
        $parts[] = trim($question[$k]);
    }
    $b = mb_strtolower(implode(' ', array_filter($parts)));
    if ($b === '') {
        return false;
    }
    if (preg_match('/\b(?:suspected\s+)?sepsis\b|\bseptic\s+shock\b|\bsevere\s+sepsis\b|\bsepsis\s+six\b|\b(?:quick\s+)?qsofa\b/i', $b)) {
        return true;
    }
    if (preg_match('/\blactate\s+(?:is|of|was)?\s*\d/i', $b) && preg_match('/\b(?:infection|pneumonia|pyelonephritis|cellulitis|\buti\b|urosepsis|abdominal\s+sepsis|neutropenic\s+sepsis|suspected\s+meningitis)\b/i', $b)) {
        return true;
    }
    if (preg_match('/\bnews\s+(?:score\s+)?(?:is|of|was)?\s*\d/i', $b) && preg_match('/\b(?:suspected\s+)?(?:sepsis|serious\s+infection|infection)\b/i', $b)) {
        return true;
    }

    return false;
}

function sbaStyleStemSuggestsPastMedicalHistoryOpener(string $stem): bool
{
    return (bool) preg_match(
        '/\bpast\s+medical\s+history\b|\bmedical\s+history\s+includes\b|\bpmh\s*:/i',
        $stem
    );
}

function sbaStyleOptionSuggestsDelayAntibioticsForInvestigations(string $optionText): bool
{
    $t = trim($optionText);
    if ($t === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:await|wait(?:ing)?\s+for)\s+(?:the\s+)?(?:blood\s+cultures?|culture\s+results?|investigation\s+results?|lactate\s+result|scan\s+result|imaging\s+results?)\b.{0,100}\b(?:antibiotic|antimicrobial|treatment)|\b(?:after|once)\s+(?:the\s+)?(?:blood\s+)?cultures?\s+(?:are\s+)?(?:taken|sent|back|return(?:ed|ing)|available|positive)\b.{0,100}\b(?:antibiotic|antimicrobial|start|commence|give)|\b(?:defer|delay|withhold|hold)\s+(?:iv\s+)?(?:antibiotic|antimicrobial)|\b(?:take|obtain|send)\s+(?:blood\s+)?cultures?\s+(?:before|prior\s+to|first)\s+(?:antibiotic|antimicrobial)|\b(?:antibiotic|antimicrobial)s?\s+(?:only\s+)?(?:after|once)\s+(?:blood\s+)?cultures?|\b(?:complete|await)\s+(?:investigations?|work[\s-]?up)\s+(?:before|prior\s+to)\s+(?:antibiotic|antimicrobial)/i',
        $t
    );
}

function sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy(string $combinedKeyedText): bool
{
    $t = trim($combinedKeyedText);
    if ($t === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:iv\s+)?(?:antibiotic|antimicrobial)s?\b|\b(?:empirical|empiric)\s+(?:iv\s+)?(?:antibiotic|antimicrobial|therapy)\b|\bco[\s-]?amoxiclav\b|\bpiperacillin\b|\btazocin\b|\bmeropenem\b|\bgent(?:amicin)?\b|\bcef(?:triaxone|uroxime|otaxime)\b|\bben(?:zyl)?(?:\s*)?penicillin\b|\bpen\s+v\b|\bantibiotic\s+therapy\b/i',
        $t
    );
}

function sbaStyleKeyedUsesVagueBroadSpectrumAntibioticsWithoutIv(string $combinedKeyedText): bool
{
    $t = trim($combinedKeyedText);
    if ($t === '' || !preg_match('/\bbroad[\s-]spectrum\s+antibiotics\b/i', $t)) {
        return false;
    }

    return !preg_match('/\bbroad[\s-]spectrum\s+iv\s+antibiotics\b/i', $t);
}

function sbaStyleTextMentionsIvFluidsForResuscitation(string $text): bool
{
    if ($text === '') {
        return false;
    }
    if (sbaStyleTextSuggestsIvFluidResuscitation($text)) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:start|give|commence|administer|begin|initiate)\s+(?:iv\s+|intravenous\s+)?(?:fluid|fluids|crystalloid)\b'
        . '|\b(?:iv\s+|intravenous\s+)?fluid\s+resuscitat'
        . '|\bfluid\s+resuscitation\s+(?:and|plus|with)\b/i',
        $text
    );
}

function sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle(string $text): bool
{
    $t = trim($text);
    if ($t === '') {
        return false;
    }
    if (function_exists('sbaSepsisEmergencyKeyIncludesIvFluidsAndBroadSpectrumAntibiotics')
        && sbaSepsisEmergencyKeyIncludesIvFluidsAndBroadSpectrumAntibiotics($t)) {
        return true;
    }
    if (sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($t)
        && sbaStyleTextMentionsIvFluidsForResuscitation($t)) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:sepsis\s+(?:six|6|resuscitation)\s+bundle|resuscitation\s+bundle|hour[\s-]?1\s+bundle|sepsis\s+pathway|prompt\s+sepsis\s+(?:care|management|resuscitation)|immediate\s+sepsis\s+(?:care|management|resuscitation)|begin\s+sepsis\s+resuscitation)\b/i',
        $t
    );
}

function sbaStyleStemSuggestsClinicallySignificantHypoxia(string $stem): bool
{
    if (function_exists('sbaSepsisStemHasClinicallySignificantHypoxia')) {
        return sbaSepsisStemHasClinicallySignificantHypoxia($stem);
    }

    return sbaStyleStemIndicatesSpO2Below92($stem);
}

function sbaStyleKeyedAnswerIsAntibioticsOnlyWithoutFluidResuscitation(string $text): bool
{
    $t = trim($text);
    if ($t === '' || !sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($t)) {
        return false;
    }

    return !sbaStyleTextMentionsIvFluidsForResuscitation($t)
        && !sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($t);
}

function sbaStyleKeyedAnswerIsIvFluidsOnlyWithoutAntibiotics(string $text): bool
{
    $t = trim($text);
    if ($t === '' || !sbaStyleTextMentionsIvFluidsForResuscitation($t)) {
        return false;
    }

    return !sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($t)
        && !sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($t);
}

/**
 * Emergency answer combines two or more distinct urgent treatment components (e.g. fluids + antibiotics).
 */
function sbaStyleEmergencyAnswerHasCombinedDistinctComponents(string $text): bool
{
    if (sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($text)) {
        return true;
    }
    $components = 0;
    if (sbaStyleTextMentionsIvFluidsForResuscitation($text)) {
        ++$components;
    }
    if (sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($text)) {
        ++$components;
    }
    if (preg_match(
        '/\b(?:oxygen\s+therapy|supplemental\s+oxygen|high[\s-]?flow\s+oxygen|give\s+oxygen)\b/i',
        $text
    )) {
        ++$components;
    }
    if (preg_match(
        '/\b(?:critical\s+care|icu|intensive\s+care|senior\s+(?:review|registrar|consultant)|urgent\s+escalat|escalat(?:e|ion)\s+to)\b/i',
        $text
    )) {
        ++$components;
    }
    if (preg_match('/\b(?:vasopressor|noradrenaline|norepinephrine|metaraminol|phenylephrine)\b/i', $text)) {
        ++$components;
    }

    return $components >= 2;
}

/**
 * Whole emergency strategy (not a bare isolated fluids/antibiotics fragment of a combined key).
 */
function sbaStyleEmergencyOptionHasDistinctWholeStrategyBeyondComponent(string $option): bool
{
    $l = mb_strtolower(trim($option));
    if ($l === '') {
        return false;
    }
    if (preg_match('/\b(?:vasopressor|noradrenaline|norepinephrine|metaraminol|phenylephrine)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:antipyretic|paracetamol|acetaminophen)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\boxygen\b/i', $l) && preg_match('/\b(?:therapy|administ|give|start|provide|supplemental)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:observe|observation|monitor|reassess)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:critical\s+care|icu|intensive\s+care)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:senior|registrar|consultant).{0,40}review\b/i', $l)) {
        return true;
    }
    if (preg_match('/\bwithout\s+(?:initiating|starting|commencing)\s+(?:treatment|therapy|resuscitation|antibiotic)/i', $l)) {
        return true;
    }
    if (preg_match('/\boral\s+(?:antibiotic|antimicrobial)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:withhold|delay|defer|await).{0,50}(?:antibiotic|antimicrobial|culture|treatment)\b/i', $l)) {
        return true;
    }
    if (preg_match('/\b(?:before|prior\s+to)\s+(?:adequate\s+)?(?:iv\s+)?fluid/i', $l)) {
        return true;
    }

    return false;
}

/**
 * Option's primary action is IV fluids without antibiotics (bare fragment, not e.g. vasopressors before fluids).
 */
function sbaStyleEmergencyOptionPrimaryActionIsIvFluidsWithoutAntibiotics(string $option): bool
{
    if (sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($option)) {
        return false;
    }
    if (!sbaStyleTextMentionsIvFluidsForResuscitation($option)) {
        return false;
    }
    if (preg_match('/\b(?:before|without|until|after)\s+(?:adequate\s+)?(?:iv\s+)?fluid/i', $option)) {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:start|give|administer|commence|begin)\s+(?:iv\s+|intravenous\s+)?(?:fluid|fluids|crystalloid)\b|\b(?:start|commence|begin)\s+(?:iv\s+)?fluid\s+resuscitation\b|\badminister\s+(?:an?\s+)?(?:iv\s+)?fluid\s+bolus\b/i',
        $option
    );
}

/**
 * Option's primary action is antibiotics without IV fluid resuscitation (bare fragment).
 */
function sbaStyleEmergencyOptionPrimaryActionIsAntibioticsWithoutFluids(string $option): bool
{
    if (!sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($option)) {
        return false;
    }
    if (sbaStyleEmergencyOptionPrimaryActionIsIvFluidsWithoutAntibiotics($option)) {
        return false;
    }
    if (sbaStyleTextMentionsIvFluidsForResuscitation($option)
        && preg_match('/\b(?:start|give|administer|commence|begin)\s+(?:iv\s+)?(?:fluid|fluids)/i', $option)) {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:start|give|administer|commence|begin|initiate)\s+(?:.{0,30})?(?:broad[\s-]?spectrum\s+)?(?:iv\s+)?(?:antibiotic|antimicrobial)s?\b/i',
        $option
    );
}

/**
 * Distractor is a direct isolated fragment of a combined fluids + antibiotics emergency key.
 */
function sbaStyleEmergencyOptionIsIsolatedComponentFragment(string $option, string $combinedCorrect): bool
{
    if ($option === '' || !sbaStyleEmergencyAnswerHasCombinedDistinctComponents($combinedCorrect)) {
        return false;
    }
    $cFluids = sbaStyleTextMentionsIvFluidsForResuscitation($combinedCorrect);
    $cAbx = sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($combinedCorrect);
    if (!$cFluids || !$cAbx) {
        return false;
    }
    if (sbaStyleEmergencyOptionHasDistinctWholeStrategyBeyondComponent($option)) {
        return false;
    }

    if (preg_match('/\bonly\b/i', $option)) {
        if (sbaStyleTextMentionsIvFluidsForResuscitation($option)
            && !sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($option)) {
            return true;
        }
        if (sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($option)
            && !sbaStyleTextMentionsIvFluidsForResuscitation($option)) {
            return true;
        }
    }

    if (sbaStyleEmergencyOptionPrimaryActionIsIvFluidsWithoutAntibiotics($option)) {
        return true;
    }

    return sbaStyleEmergencyOptionPrimaryActionIsAntibioticsWithoutFluids($option);
}

/**
 * @param list<string> $optionLines Non-empty option texts
 */
function sbaStyleCountEmergencyPartialComponentFragmentDistractors(array $optionLines, string $combinedCorrect, string $correctLetter): int
{
    if ($combinedCorrect === '' || $optionLines === []) {
        return 0;
    }
    $letter = strtoupper(trim($correctLetter));
    $count = 0;
    $letters = ['A', 'B', 'C', 'D', 'E'];
    foreach ($optionLines as $i => $opt) {
        $opt = trim($opt);
        if ($opt === '') {
            continue;
        }
        $optLetter = $letters[$i] ?? '';
        if ($letter !== '' && $optLetter === $letter) {
            continue;
        }
        if (sbaStyleEmergencyOptionIsIsolatedComponentFragment($opt, $combinedCorrect)) {
            ++$count;
        }
    }

    return $count;
}

function sbaStyleStemSuggestsUnstableSuspectedSepsis(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (preg_match('/\b(?:septic\s+shock|shocked)\b/i', $stem)) {
        return true;
    }
    if (sbaStyleStemSuggestsHaemodynamicInstability($stem)) {
        return true;
    }
    if (preg_match('/\blactate\s+(?:is|of|was|measured|reported|recorded|at)\s*([\d.]+)/i', $stem, $m)) {
        return (float) $m[1] > 2.0;
    }
    if (preg_match('/\blactate\s+[\d.]+\s*(?:mmol)?/i', $stem)) {
        return true;
    }

    return false;
}

function sbaStyleLeadInAsksFirstCirculatoryResuscitationOnly(array $question): bool
{
    $lead = mb_strtolower(trim((string) ($question['lead_in'] ?? '')));
    if ($lead === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:first|initial|immediate)\s+(?:action|step|intervention|measure|treatment)\b.{0,50}\b(?:hypotension|shock|circulat|fluid|bolus|resuscitat)|\b(?:first|single)\s+(?:circulatory|haemodynamic|hemodynamic)\s+(?:step|action|measure)\b|\bfirst\s+line\s+(?:for|in)\s+(?:isolated\s+)?hypotension\b|\bmost\s+appropriate\s+first\s+(?:step|action)\b.{0,40}\b(?:hypotension|shock|fluid|circulat)/i',
        $lead
    );
}

function sbaStyleLeadInAsksImmediateSepticShockOrUnstableSepsisManagement(array $question): bool
{
    $lead = mb_strtolower(trim((string) ($question['lead_in'] ?? '')));
    if ($lead === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:immediate|urgent|prompt|safest)\s+(?:management|treatment|therapy|care|resuscitation|action)\b.{0,80}\b(?:septic\s+shock|sepsis|suspected\s+sepsis)\b|\b(?:septic\s+shock|sepsis)\b.{0,80}\b(?:immediate|urgent)\s+(?:management|treatment|care|resuscitation)\b|\bsafest\s+immediate\s+management\b/i',
        $lead
    );
}

/**
 * Suspected sepsis emergency management heuristics (non-blocking warnings).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsSuspectedSepsisEmergencyStyle(array $question): array
{
    $warnings = [];
    if (!sbaStyleQuestionBlobLooksSuspectedSepsisEmergency($question)) {
        return $warnings;
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $emergencyMgmt = sbaStyleQuestionLooksEmergencyManagementLeadIn($question);

    if ($emergencyMgmt && sbaStyleStemSuggestsPastMedicalHistoryOpener($stem)) {
        $warnings[] = [
            'code' => 'clinical.sepsis_past_medical_history_may_be_irrelevant',
            'severity' => 'warning',
            'message' => 'Suspected sepsis emergency item includes explicit past medical history phrasing — omit unrelated comorbidity unless it changes which option is best for the lead-in.',
            'field' => 'stem',
        ];
    }

    if ($emergencyMgmt) {
        foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
            $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
            if ($t === '' || !sbaStyleOptionSuggestsDelayAntibioticsForInvestigations($t)) {
                continue;
            }
            $warnings[] = [
                'code' => 'emergency.sepsis_option_delays_antibiotics_for_investigations',
                'severity' => 'warning',
                'message' => 'Suspected sepsis emergency management: this option suggests delaying antibiotics for investigation results — avoid teaching unsafe sequencing in unstable sepsis unless the item explicitly tests that error.',
                'field' => $field,
            ];
        }
    }

    if ($emergencyMgmt) {
        $keyedBlob = sbaStyleKeyedAnswerCombinedManagementText($question);
        if ($keyedBlob !== '' && sbaStyleKeyedUsesVagueBroadSpectrumAntibioticsWithoutIv($keyedBlob)) {
            $field = sbaStyleKeyedAnswerOptionField($question);
            $warnings[] = [
                'code' => 'emergency.sepsis_prefer_iv_broad_spectrum_antibiotics_wording',
                'severity' => 'warning',
                'message' => 'For sepsis emergencies prefer explicit “IV antibiotics” or “broad-spectrum IV antibiotics” rather than vague “broad-spectrum antibiotics”.',
                'field' => $field !== '' ? $field : 'correct_answer',
            ];
        }
        if ($keyedBlob !== '' && sbaStyleKeyedAnswerIsIvFluidsOnlyWithoutAntibiotics($keyedBlob)
            && !sbaStyleStemSuggestsUnstableSuspectedSepsis($stem)
            && !preg_match('/\b(?:septic\s+shock|shocked|cardiogenic\s+shock)\b/i', $stem)) {
            $field = sbaStyleKeyedAnswerOptionField($question);
            $warnings[] = [
                'code' => 'emergency.sepsis_iv_fluids_key_without_hypotension_or_shock',
                'severity' => 'high',
                'message' => '[High priority] Suspected sepsis Emergency Management: the keyed answer is IV fluid resuscitation but the stem does not clearly include **hypotension** or **shock** — add haemodynamic instability cues or choose a different emergency priority (e.g. prompt IV antibiotics, oxygen if hypoxic, urgent escalation).',
                'field' => $field !== '' ? $field : 'correct_answer',
            ];
        }
        $unstableSepsis = sbaStyleStemSuggestsUnstableSuspectedSepsis($stem);
        if ($unstableSepsis && $keyedBlob !== '') {
            $field = sbaStyleKeyedAnswerOptionField($question);
            $fieldKey = $field !== '' ? $field : 'correct_answer';
            $hasCombinedBundle = sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($keyedBlob);
            $hasAntibiotics = sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($keyedBlob);
            $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
            $postInitialScenario = function_exists('sbaSepsisEmergencyPostInitialResuscitationScenario')
                && sbaSepsisEmergencyPostInitialResuscitationScenario($stem, $leadIn, $keyedBlob);
            if (!$hasAntibiotics && !$hasCombinedBundle && !$postInitialScenario) {
                $warnings[] = [
                    'code' => 'emergency.sepsis_unstable_keyed_missing_antibiotics',
                    'severity' => 'warning',
                    'message' => 'Suspected sepsis with hypotension, shock, or raised lactate and an emergency-management lead-in: the keyed answer should usually include prompt IV antibiotics (often in a combined resuscitation line with IV fluids) unless the lead-in explicitly excludes antimicrobial therapy or tests a single first circulatory step only.',
                    'field' => $fieldKey,
                ];
            }
            if (sbaStyleKeyedAnswerIsIvFluidsOnlyWithoutAntibiotics($keyedBlob)
                && !sbaStyleLeadInAsksFirstCirculatoryResuscitationOnly($question)
                && !$postInitialScenario) {
                $warnings[] = [
                    'code' => 'emergency.sepsis_unstable_fluids_only_without_circulatory_lead_in',
                    'severity' => 'warning',
                    'message' => 'Unstable suspected sepsis with hypotension / shock / raised lactate: IV fluids alone as the keyed answer creates a false dilemma — unstable sepsis usually needs prompt IV antibiotics as well. Prefer a combined line (e.g. start IV fluids and broad-spectrum IV antibiotics / sepsis resuscitation bundle), or narrow the lead-in explicitly to the **first circulatory** step in isolated hypotension.',
                    'field' => $fieldKey,
                ];
            }
            if (sbaStyleKeyedAnswerIsAntibioticsOnlyWithoutFluidResuscitation($keyedBlob)
                && (sbaStyleStemSuggestsHaemodynamicInstability($stem) || preg_match('/\b(?:septic\s+shock|shocked)\b/i', $stem))
                && !sbaStyleLeadInAsksFirstCirculatoryResuscitationOnly($question)
                && !$postInitialScenario) {
                $warnings[] = [
                    'code' => 'emergency.sepsis_unstable_antibiotics_only_without_fluid_resuscitation',
                    'severity' => 'warning',
                    'message' => 'Unstable suspected sepsis with prominent hypotension or shock: antibiotics alone as the keyed answer may omit urgent fluid resuscitation. Prefer a combined emergency line (IV fluids plus broad-spectrum IV antibiotics / sepsis resuscitation bundle) unless the lead-in clearly targets antimicrobial therapy only in a stable patient.',
                    'field' => $fieldKey,
                ];
            }
            if (sbaStyleLeadInAsksImmediateSepticShockOrUnstableSepsisManagement($question)
                && !sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($keyedBlob)
                && !$postInitialScenario
                && (sbaStyleKeyedAnswerIsIvFluidsOnlyWithoutAntibiotics($keyedBlob)
                    || sbaStyleKeyedAnswerIsAntibioticsOnlyWithoutFluidResuscitation($keyedBlob))) {
                $warnings[] = [
                    'code' => 'emergency.sepsis_shock_lead_in_prefers_combined_resuscitation',
                    'severity' => 'warning',
                    'message' => 'Lead-in asks for immediate management of suspected septic shock / unstable sepsis — prefer a single combined resuscitation answer (e.g. start IV fluids and broad-spectrum IV antibiotics / begin sepsis resuscitation bundle) rather than fluids alone or antibiotics alone.',
                    'field' => $fieldKey,
                ];
            }
        }
    }

    return $warnings;
}

function sbaStyleLeadInAsksInitialInvestigationSelection(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:most\s+appropriate\s+)?(?:initial|first)\s+investigation\b|\b(?:initial|first)\s+investigation\s+(?:to|for)\b|\bwhat\s+is\s+the\s+(?:most\s+appropriate\s+)?(?:initial|first)\s+investigation\b/i',
        $leadIn
    );
}

function sbaStyleKeyedAnswerSuggestsLactateAsInvestigation(string $text): bool
{
    if ($text === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:serum\s+|blood\s+|arterial\s+)?lactate(?:\s+measurement|\s+level|\s+test)?\b|\bmeasure(?:ment)?\s+of\s+lactate\b|\blactate\s+measurement\b/i',
        $text
    );
}

function sbaStyleStemOrLeadInFocusesOnSepsisSeverityPerfusionOrLactateAssessment(string $stem, string $leadIn): bool
{
    $blob = mb_strtolower($stem . ' ' . $leadIn);
    if ($blob === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:severity\s+(?:assessment|marker|score)|risk\s+stratif|prognostic|prognosis|perfusion|tissue\s+hypoperfusion|shock\s+severity|assess(?:ing)?\s+(?:the\s+)?severity|how\s+severe|degree\s+of\s+(?:illness|physiological\s+derangement)|lactate\s+(?:for|to\s+assess)|raised\s+lactate\s+(?:suggests|indicates)|serial\s+lactate|repeat\s+lactate)\b/i',
        $blob
    );
}

function sbaStyleLeadInAsksLikelySourceOfInfection(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:source\s+of\s+(?:the\s+)?infection|likely\s+source|underlying\s+infection|focus\s+of\s+infection|site\s+of\s+infection|where\s+is\s+the\s+infection|cause\s+of\s+(?:the\s+)?infection|most\s+likely\s+source)\b/i',
        $leadIn
    );
}

function sbaStyleBlobMentionsLocalisedInfectionSourceDiagnosis(string $text): bool
{
    if ($text === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:community[\s-]acquired\s+pneumonia|\bpneumonia\b|\burinary\s+tract\s+infection\b|\buti\b|\bpyelonephritis\b|\burosepsis\b|\bcellulitis\b|\bcholangitis\b|\bappendicitis\b|\bmeningitis\b|\bendocarditis\b|\binfective\s+endocarditis\b|\babscess\b)\b/i',
        $text
    );
}

function sbaStyleKeyedAnswerIsSepsisSyndromeLabel(string $text): bool
{
    if ($text === '') {
        return false;
    }

    return (bool) preg_match('/\b(?:sepsis|septic\s+shock|severe\s+sepsis)\b/i', $text);
}

function sbaStyleOptionMentionsSepsisSeverityTierLabel(string $text): bool
{
    return (bool) preg_match('/\b(?:severe\s+sepsis|septic\s+shock|sirs|systemic\s+inflammatory\s+response)\b/i', $text);
}

function sbaStyleOptionMentionsSepsisOrganComplicationLabel(string $text): bool
{
    return (bool) preg_match('/\b(?:ards|acute\s+respiratory\s+distress|acute\s+kidney\s+injury|aki\b|dic\b|multi[\s-]organ)\b/i', $text);
}

function sbaStyleLeadInAsksNarrowEmergencyComponentOnly(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:circulatory\s+intervention|fluid[\s-]resuscitation\s+only|which\s+fluid|vasopressor|after\s+(?:initial\s+)?(?:fluids|antibiotics|resuscitation)|next\s+step\s+after|already\s+(?:received|had|been\s+given))\b/i',
        $leadIn
    );
}

function sbaStyleInvestigationLineMentionsBedsideUrinalysis(string $text): bool
{
    return (bool) preg_match('/\b(?:urinalysis|urine\s+dipstick|dipstick)\b/i', $text)
        && !preg_match('/\burine\s+culture\b|\bmsu\b|\bmicrobiology\s+culture\b/i', $text);
}

function sbaStyleInvestigationLineMentionsUrineCulture(string $text): bool
{
    return (bool) preg_match('/\b(?:urine\s+culture|msu\b|mid[\s-]stream\s+urine\s+culture)\b/i', $text);
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsSepsisDiagnosisSyndromeVsSource(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^diagnosis$/i', $sk)) {
        return [];
    }

    if (!function_exists('sbaGeneratorConditionLooksSepsis')) {
        $sepsisConceptPath = dirname(__DIR__) . '/prompts/sba_sepsis_batch_concepts.php';
        if (is_readable($sepsisConceptPath)) {
            require_once $sepsisConceptPath;
        }
    }
    $condition = (string) ($question['_condition_name'] ?? '');
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $title = isset($question['title']) ? trim((string) $question['title']) : '';
    if (!function_exists('sbaGeneratorConditionLooksSepsis') || !sbaGeneratorConditionLooksSepsis($condition)) {
        $blob = mb_strtolower($title . ' ' . $stem);
        if (!preg_match('/\b(?:sepsis|septic\s+shock|severe\s+sepsis)\b/i', $blob)) {
            return [];
        }
    }

    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $keyed = sbaStyleKeyedAnswerCombinedManagementText($question);
    if ($keyed === '') {
        $keyed = trim((string) ($question['correct_answer'] ?? ''));
    }
    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';

    $opts = [];
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $opts[] = trim((string) $question[$k]);
        }
    }

    if (sbaStyleLeadInAsksLikelySourceOfInfection($leadIn)) {
        if ($keyed !== '' && sbaStyleKeyedAnswerIsSepsisSyndromeLabel($keyed)
            && !sbaStyleBlobMentionsLocalisedInfectionSourceDiagnosis($keyed)) {
            return [[
                'code' => 'sepsis.diagnosis_syndrome_vs_source_mismatch',
                'severity' => 'high',
                'message' => '[High priority] Sepsis **Diagnosis**: the lead-in asks for the **source of infection** but the keyed answer is **Sepsis** / **septic shock** (syndrome label). Key a **localised infection source** (e.g. pneumonia, urinary tract infection, pyelonephritis) or change the lead-in to ask for the **syndrome diagnosis**.',
                'field' => $fieldKey,
            ]];
        }
        foreach ($opts as $opt) {
            if ($opt !== '' && sbaStyleKeyedAnswerIsSepsisSyndromeLabel($opt)
                && !sbaStyleBlobMentionsLocalisedInfectionSourceDiagnosis($opt)) {
                return [[
                    'code' => 'sepsis.diagnosis_syndrome_vs_source_mismatch',
                    'severity' => 'high',
                    'message' => '[High priority] Sepsis **Diagnosis**: the lead-in asks for **source of infection** but an option is **Sepsis** / **septic shock**. When testing **source**, **all options** must be infection sources — **do not** include **Sepsis** as an option.',
                    'field' => 'options',
                ]];
            }
        }

        return [];
    }

    if ($keyed === '' || !sbaStyleKeyedAnswerIsSepsisSyndromeLabel($keyed)) {
        return [];
    }

    $sourceOptionCount = 0;
    $severityTierCount = 0;
    $hasSevereSepsisOption = false;
    $hasOrganComplicationOption = false;
    foreach ($opts as $opt) {
        if ($opt === '') {
            continue;
        }
        if (sbaStyleBlobMentionsLocalisedInfectionSourceDiagnosis($opt)) {
            ++$sourceOptionCount;
        }
        if (sbaStyleOptionMentionsSepsisSeverityTierLabel($opt)) {
            ++$severityTierCount;
        }
        if (preg_match('/\bsevere\s+sepsis\b/i', $opt)) {
            $hasSevereSepsisOption = true;
        }
        if (sbaStyleOptionMentionsSepsisOrganComplicationLabel($opt)
            || preg_match('/\bpneumonia\b/i', $opt)) {
            $hasOrganComplicationOption = true;
        }
    }

    $warnings = [];
    if ($sourceOptionCount >= 1) {
        $warnings[] = [
            'code' => 'sepsis.diagnosis_option_homogeneity',
            'severity' => 'high',
            'message' => '[High priority] Sepsis **Diagnosis**: the keyed answer is **Sepsis** but options include **infection sources** (e.g. pneumonia, UTI, pyelonephritis). Use **competing acute diagnoses** only (e.g. anaphylaxis, cardiogenic shock, hypovolaemia, pulmonary embolism, adrenal crisis) — **not** sources mixed with syndrome labels.',
            'field' => 'options',
        ];
    }
    if ($severityTierCount >= 1 || $hasSevereSepsisOption || $hasOrganComplicationOption) {
        $warnings[] = [
            'code' => 'sepsis.diagnosis_option_homogeneity',
            'severity' => 'high',
            'message' => '[High priority] Sepsis **Diagnosis**: do **not** mix **Sepsis** with **severe sepsis**, **septic shock**, **pneumonia**, or **ARDS** in one option set. When the key is **Sepsis**, use **other plausible diagnoses** as distractors — avoid **severe sepsis** as a routine option.',
            'field' => 'options',
        ];
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsSepsisInvestigationInitialWhenStemHasResult(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^investigation(?:\s+selection)?$/i', $sk)) {
        return [];
    }

    if (!function_exists('sbaGeneratorConditionLooksSepsis')) {
        $sepsisConceptPath = dirname(__DIR__) . '/prompts/sba_sepsis_batch_concepts.php';
        if (is_readable($sepsisConceptPath)) {
            require_once $sepsisConceptPath;
        }
    }
    $condition = (string) ($question['_condition_name'] ?? '');
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $title = isset($question['title']) ? trim((string) $question['title']) : '';
    if (!function_exists('sbaGeneratorConditionLooksSepsis') || !sbaGeneratorConditionLooksSepsis($condition)) {
        if (!preg_match('/\b(?:sepsis|septic\s+shock|severe\s+sepsis)\b/i', $title . ' ' . $stem)) {
            return [];
        }
    }

    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    if ($stem === '' || !sbaStyleLeadInAsksInitialInvestigationSelection($leadIn)) {
        return [];
    }
    if (!sbaStyleStemReportsObviousFirstLineInvestigationResultGeneral($stem)) {
        return [];
    }

    return [[
        'code' => 'sepsis.initial_investigation_result_already_in_stem',
        'severity' => 'high',
        'message' => '[High priority] Sepsis **Investigation**: the lead-in asks for the **initial investigation** but the stem already reports a **first-line investigation result** (e.g. urinalysis, urine dipstick, chest X-ray). Either **remove the result from the stem** or change the lead-in to ask for the **next** / **further** investigation.',
        'field' => 'lead_in',
    ]];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsSepsisInvestigationUrinaryPurposeMismatch(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^investigation(?:\s+selection)?$/i', $sk)) {
        return [];
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    if ($stem === '' || !preg_match('/\b(?:sepsis|urosepsis|urinary|pyelonephritis)\b/i', $stem)) {
        return [];
    }
    if (!preg_match('/\burinalysis\b|\burine\s+dipstick\b|\bdipstick\b/i', $stem)) {
        return [];
    }

    $keyed = sbaStyleKeyedAnswerCombinedManagementText($question);
    if ($keyed === '') {
        $keyed = trim((string) ($question['correct_answer'] ?? ''));
    }
    if ($keyed === '') {
        return [];
    }

    $asksNext = sbaStyleLeadInAsksNextOrFurtherInvestigation($leadIn);
    if ($asksNext && sbaStyleInvestigationLineMentionsBedsideUrinalysis($keyed)) {
        return [[
            'code' => 'sepsis.investigation_urinary_purpose_mismatch',
            'severity' => 'high',
            'message' => '[High priority] Sepsis **Investigation**: the stem already reports **urinalysis / dipstick** results and the lead-in asks for a **further** investigation, but the keyed answer is **urinalysis** again. Key **blood cultures**, **urine culture (MSU)**, or **imaging** as appropriate — separate **bedside dipstick** from **culture**.',
            'field' => 'correct_answer',
        ]];
    }

    if (sbaStyleLeadInAsksInitialInvestigationSelection($leadIn) && sbaStyleStemReportsObviousFirstLineInvestigationResultGeneral($stem)) {
        return [];
    }

    return [];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleKeyedAnswerIsIvFluidsWithPassiveMonitoringWithoutAntibiotics(string $text): bool
{
    $t = trim($text);
    if ($t === '' || !sbaStyleTextMentionsIvFluidsForResuscitation($t)) {
        return false;
    }
    if (sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($t)
        || sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($t)) {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:monitor\s+closely|close\s+monitoring|observe\s+closely|observation\s+only|reassess\s+later|review\s+later|regular\s+review)\b/i',
        $t
    );
}

function sbaStyleWarningsSepsisEmergencyIncompleteKey(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^emergency\s+management$/i', $sk)) {
        return [];
    }

    if (!function_exists('sbaSepsisStemReportsInitialFluidsAndAntibioticsGiven')) {
        $sepsisConceptPath = dirname(__DIR__) . '/prompts/sba_sepsis_batch_concepts.php';
        if (is_readable($sepsisConceptPath)) {
            require_once $sepsisConceptPath;
        }
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($stem === '' || !sbaStyleStemSuggestsUnstableSuspectedSepsis($stem)) {
        if (!sbaStyleQuestionBlobLooksSuspectedSepsisEmergency($question)) {
            return [];
        }
    }

    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    if (sbaStyleLeadInAsksNarrowEmergencyComponentOnly($leadIn)) {
        return [];
    }
    if (function_exists('sbaSepsisStemReportsInitialFluidsAndAntibioticsGiven')
        && sbaSepsisStemReportsInitialFluidsAndAntibioticsGiven($stem)
        && preg_match('/\b(?:next|further)\s+(?:immediate\s+)?(?:step|management|action)\b/i', $leadIn)) {
        return [];
    }

    $keyedBlob = sbaStyleKeyedAnswerCombinedManagementText($question);
    if ($keyedBlob === '') {
        return [];
    }

    if (function_exists('sbaSepsisEmergencyKeyIncludesIvFluidsAndBroadSpectrumAntibiotics')
        && sbaSepsisEmergencyKeyIncludesIvFluidsAndBroadSpectrumAntibiotics($keyedBlob)) {
        return [];
    }

    if (function_exists('sbaSepsisEmergencyPostInitialResuscitationScenario')
        && sbaSepsisEmergencyPostInitialResuscitationScenario($stem, $leadIn, $keyedBlob)
        && (function_exists('sbaSepsisKeyedAnswerIsRefractoryCirculatorySupport')
            && sbaSepsisKeyedAnswerIsRefractoryCirculatorySupport($keyedBlob)
            || function_exists('sbaSepsisKeyedAnswerIsSourceControlManagement')
                && sbaSepsisKeyedAnswerIsSourceControlManagement($keyedBlob))) {
        return [];
    }

    if (!sbaStyleLeadInAsksImmediateSepticShockOrUnstableSepsisManagement($question)
        && !preg_match('/\b(?:immediate|most\s+appropriate)\s+(?:management|treatment)\b/i', $leadIn)) {
        return [];
    }

    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';

    if (function_exists('sbaSepsisEmergencyPostInitialResuscitationScenario')
        && sbaSepsisEmergencyPostInitialResuscitationScenario($stem, $leadIn, $keyedBlob)
        && !sbaStyleKeyedAnswerIsIvFluidsOnlyWithoutAntibiotics($keyedBlob)
        && !sbaStyleKeyedAnswerIsAntibioticsOnlyWithoutFluidResuscitation($keyedBlob)
        && !sbaStyleKeyedAnswerIsIvFluidsWithPassiveMonitoringWithoutAntibiotics($keyedBlob)) {
        return [];
    }

    if (sbaStyleKeyedAnswerIsIvFluidsWithPassiveMonitoringWithoutAntibiotics($keyedBlob)) {
        return [[
            'code' => 'sepsis.emergency_incomplete_key_without_narrow_lead_in',
            'severity' => 'high',
            'message' => '[High priority] Sepsis **Emergency Management**: the keyed answer combines **IV fluids** with **passive monitoring/review** but omits **broad-spectrum IV antibiotics** — clinically unsafe for untreated septic shock. Use **fluids plus IV antibiotics**, **or** a **different scenario category** (vasopressor after fluids given, source control, hypoxic resuscitation) with a matching stem and lead-in — **do not** weaken the key to avoid batch duplication.',
            'field' => $fieldKey,
        ]];
    }

    if (sbaStyleKeyedAnswerIsAntibioticsOnlyWithoutFluidResuscitation($keyedBlob)
        || sbaStyleKeyedAnswerIsIvFluidsOnlyWithoutAntibiotics($keyedBlob)) {
        return [[
            'code' => 'sepsis.emergency_incomplete_key_without_narrow_lead_in',
            'severity' => 'high',
            'message' => '[High priority] Sepsis **Emergency Management**: general immediate-management lead-in with **unstable sepsis** but the keyed answer omits **IV fluid resuscitation** or **broad-spectrum IV antibiotics**. Use a **combined** key (fluids plus IV antibiotics / resuscitation bundle), **or** narrow the lead-in (e.g. circulatory step only, or next step after fluids and antibiotics already given).',
            'field' => $fieldKey,
        ]];
    }

    return [];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsSepsisEmergencyCategoryMismatch(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^emergency\s+management$/i', $sk)) {
        return [];
    }

    if (!function_exists('sbaGeneratorConditionLooksSepsis')) {
        $sepsisConceptPath = dirname(__DIR__) . '/prompts/sba_sepsis_batch_concepts.php';
        if (is_readable($sepsisConceptPath)) {
            require_once $sepsisConceptPath;
        }
    }
    $condition = (string) ($question['_condition_name'] ?? '');
    if (!function_exists('sbaGeneratorConditionLooksSepsis') || !sbaGeneratorConditionLooksSepsis($condition)) {
        return [];
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $correct = sbaStyleCorrectAnswerCombinedText($question);
    if ($correct === '') {
        $correct = trim((string) ($question['correct_answer'] ?? ''));
    }
    if ($correct === '') {
        return [];
    }

    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';
    $slot = function_exists('sbaSepsisInferEmergencyScenarioCategory')
        ? sbaSepsisInferEmergencyScenarioCategory($stem, $leadIn, $correct)
        : 'initial_resuscitation';
    $isBundle = sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($correct);

    if ($isBundle && $slot !== 'initial_resuscitation') {
        $oxygenInKey = function_exists('sbaSepsisKeyedAnswerIncludesOxygenSupport')
            ? sbaSepsisKeyedAnswerIncludesOxygenSupport($correct)
            : (bool) preg_match('/\b(?:oxygen|supplemental\s+oxygen|high[\s-]flow)\b/i', $correct);
        $allowHypoxicCombined = $slot === 'hypoxic_escalation'
            && $oxygenInKey
            && sbaStyleStemSuggestsClinicallySignificantHypoxia($stem);
        if (!$allowHypoxicCombined) {
            return [[
                'code' => 'sepsis.emergency_batch_duplicate_resuscitation',
                'severity' => 'high',
                'message' => '[High priority] Sepsis **Emergency Management**: keyed **initial resuscitation bundle** (IV fluids plus IV antibiotics) but the vignette fits **'
                    . (function_exists('sbaSepsisEmergencyScenarioDisplayLabel') ? sbaSepsisEmergencyScenarioDisplayLabel($slot) : $slot)
                    . '** — use a **different** correct-answer concept (e.g. vasopressor, source control, oxygen plus resuscitation, escalation) with stem and lead-in aligned to that category.',
                'field' => $fieldKey,
            ]];
        }
    }

    if ($isBundle && function_exists('sbaSepsisStemReportsInitialFluidsAndAntibioticsGiven')
        && sbaSepsisStemReportsInitialFluidsAndAntibioticsGiven($stem)) {
        return [[
            'code' => 'sepsis.emergency_batch_duplicate_resuscitation',
            'severity' => 'high',
            'message' => '[High priority] Sepsis **Emergency Management**: stem states **IV fluids and antibiotics already given** but the keyed answer repeats **start IV fluids and broad-spectrum IV antibiotics** — key the **next** category-appropriate step (vasopressor, source control, escalation, oxygen plus ongoing resuscitation).',
            'field' => $fieldKey,
        ]];
    }

    if ($slot === 'hypoxic_escalation'
        && sbaStyleStemSuggestsClinicallySignificantHypoxia($stem)
        && sbaStyleLeadInAsksImmediateSepticShockOrUnstableSepsisManagement($question)) {
        $oxygenInKey = function_exists('sbaSepsisKeyedAnswerIncludesOxygenSupport')
            ? sbaSepsisKeyedAnswerIncludesOxygenSupport($correct)
            : (bool) preg_match('/\b(?:oxygen|supplemental\s+oxygen|high[\s-]flow)\b/i', $correct);
        $abxInStem = (function_exists('sbaSepsisStemReportsInitialFluidsAndAntibioticsGiven')
                && sbaSepsisStemReportsInitialFluidsAndAntibioticsGiven($stem))
            || preg_match('/\b(?:already|received|been\s+given|after).{0,50}(?:iv\s+)?antibiot/i', $stem);
        $fullResus = function_exists('sbaSepsisEmergencyKeyIncludesIvFluidsAndBroadSpectrumAntibiotics')
            ? sbaSepsisEmergencyKeyIncludesIvFluidsAndBroadSpectrumAntibiotics($correct)
            : $isBundle;

        if (!$abxInStem && (!$oxygenInKey || !$fullResus)) {
            return [[
                'code' => 'sepsis.emergency_incomplete_key_without_narrow_lead_in',
                'severity' => 'high',
                'message' => '[High priority] Sepsis **hypoxic emergency**: SpO₂ is **clinically low** and antibiotics are **not yet given** in the stem — keyed answer should normally include **oxygen plus IV fluids plus broad-spectrum IV antibiotics**, **or** narrow the lead-in.',
                'field' => $fieldKey,
            ]];
        }
        if ($abxInStem && !$oxygenInKey) {
            return [[
                'code' => 'sepsis.emergency_incomplete_key_without_narrow_lead_in',
                'severity' => 'high',
                'message' => '[High priority] Sepsis **hypoxic emergency**: antibiotics already given — keyed answer should include **oxygen** plus appropriate **escalation/resuscitation**, **or** narrow the lead-in.',
                'field' => $fieldKey,
            ]];
        }
    }

    return [];
}

function sbaStyleKeyedAnswerReadsAsSourceControlInvestigationRequest(string $text): bool
{
    $t = mb_strtolower(trim($text));
    if ($t === '') {
        return false;
    }
    if (function_exists('sbaSepsisKeyedAnswerIsSourceControlManagement')
        && sbaSepsisKeyedAnswerIsSourceControlManagement($text)) {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:request|arrange|perform|obtain|book)\s+(?:urgent\s+)?(?:abdominal\s+)?(?:ultrasound|ct|imaging|scan|radiology)\b/i',
        $t
    ) && !preg_match('/\b(?:source[\s-]control|drainage|nephrostomy|decompress|surgical\s+review|urology|intervention)\b/i', $t);
}

function sbaStyleOptionIsObviousUnsafeSepsisEmergencyDistractor(string $text): bool
{
    if (sbaStyleOptionSuggestsDelayAntibioticsForInvestigations($text)) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:withhold\s+(?:iv\s+)?antibiot|delay\s+(?:iv\s+)?antibiot|await\s+cultures?\s+(?:before|prior)|'
        . 'oral\s+antibiot.{0,30}(?:observe|follow[\s-]?up)|antibiot.{0,20}and\s+observe|'
        . 'oxygen\s+therapy\s+only|supplemental\s+oxygen\s+only|give\s+oxygen\s+only|'
        . 'monitor\s+saturation\s+only|repeat\s+lactate\s+only|recheck\s+lactate\s+only|'
        . 'monitor\s+(?:closely|without\s+treatment)|observation\s+only|observe\s+closely|'
        . 'wait\s+for\s+senior|await\s+senior\s+review|monitor\s+without\s+treatment)\b/i',
        $text
    );
}

function sbaStyleOptionIsOxygenTherapyOnlyLine(string $text): bool
{
    $t = mb_strtolower(trim($text));
    if ($t === '' || !preg_match('/\b(?:oxygen\s+therapy|supplemental\s+oxygen|give\s+oxygen)\b/i', $t)) {
        return false;
    }

    return !preg_match(
        '/\b(?:fluid|antibiot|vasopressor|noradrenaline|critical\s+care|escalat|source[\s-]control|resuscitat)\b/i',
        $t
    );
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsSepsisEmergencyAlignment(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^emergency\s+management$/i', $sk)) {
        return [];
    }

    if (!function_exists('sbaGeneratorConditionLooksSepsis')) {
        $sepsisConceptPath = dirname(__DIR__) . '/prompts/sba_sepsis_batch_concepts.php';
        if (is_readable($sepsisConceptPath)) {
            require_once $sepsisConceptPath;
        }
    }
    $condition = (string) ($question['_condition_name'] ?? '');
    if (!function_exists('sbaGeneratorConditionLooksSepsis') || !sbaGeneratorConditionLooksSepsis($condition)) {
        if (!sbaStyleQuestionBlobLooksSuspectedSepsisEmergency($question)) {
            return [];
        }
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $correct = sbaStyleCorrectAnswerCombinedText($question);
    if ($correct === '') {
        $correct = trim((string) ($question['correct_answer'] ?? ''));
    }
    if ($correct === '') {
        return [];
    }

    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';
    $slot = function_exists('sbaSepsisInferEmergencyScenarioCategory')
        ? sbaSepsisInferEmergencyScenarioCategory($stem, $leadIn, $correct)
        : 'initial_resuscitation';
    $warnings = [];

    if ($slot === 'refractory_hypotension'
        && function_exists('sbaSepsisLeadInIsGenericImmediateManagement')
        && sbaSepsisLeadInIsGenericImmediateManagement($leadIn)) {
        $warnings[] = [
            'code' => 'sepsis.emergency_lead_in_scenario_mismatch',
            'severity' => 'high',
            'message' => '[High priority] Sepsis **refractory hypotension** after fluids and antibiotics: avoid generic “most appropriate **immediate management**”. Prefer “most appropriate **next circulatory support** step?” or “next step for **persistent hypotension**?”.',
            'field' => 'lead_in',
        ];
    }

    if ($slot === 'source_control'
        && function_exists('sbaSepsisLeadInIsGenericImmediateManagement')
        && sbaSepsisLeadInIsGenericImmediateManagement($leadIn)
        && !sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($correct)
        && (function_exists('sbaSepsisStemReportsInitialFluidsAndAntibioticsGiven')
            ? sbaSepsisStemReportsInitialFluidsAndAntibioticsGiven($stem)
            : false)) {
        $warnings[] = [
            'code' => 'sepsis.emergency_lead_in_scenario_mismatch',
            'severity' => 'high',
            'message' => '[High priority] Sepsis **source control** scenario: use “In addition to sepsis resuscitation, what is the **most important next step**?” or “What **additional urgent management** is required?” — not generic immediate management when fluids/antibiotics are already given.',
            'field' => 'lead_in',
        ];
    }

    if (($slot === 'source_control' || $slot === 'abdominal_source_control' || $slot === 'lactate_abdominal_source')
        && sbaStyleKeyedAnswerReadsAsSourceControlInvestigationRequest($correct)) {
        $warnings[] = [
            'code' => 'sepsis.emergency_source_control_investigation_key',
            'severity' => 'high',
            'message' => '[High priority] Sepsis **Emergency Management**: keyed answer reads as an **investigation request** (e.g. urgent ultrasound/CT to assess) — use **management/escalation** wording (urgent urology/surgical review, drainage/decompression, escalate for source control). Put imaging findings in the **stem** when needed.',
            'field' => $fieldKey,
        ];
    }

    $stemHypoxic = sbaStyleStemSuggestsClinicallySignificantHypoxia($stem);
    $keyHasOxygen = function_exists('sbaSepsisKeyedAnswerIncludesOxygenSupport')
        ? sbaSepsisKeyedAnswerIncludesOxygenSupport($correct)
        : (bool) preg_match('/\b(?:oxygen|supplemental\s+oxygen|high[\s-]flow)\b/i', $correct);
    if ($stemHypoxic && !$keyHasOxygen) {
        $oxygenOnlyDistractor = false;
        foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $optField) {
            $opt = isset($question[$optField]) ? trim((string) $question[$optField]) : '';
            if ($opt !== '' && sbaStyleOptionIsOxygenTherapyOnlyLine($opt)) {
                $oxygenOnlyDistractor = true;
                break;
            }
        }
        $narrowLeadIn = preg_match('/\b(?:vasopressor|circulatory|noradrenaline|after\s+(?:fluids|antibiotics)|persistent\s+hypotension)\b/i', $leadIn);
        if ($oxygenOnlyDistractor && !$narrowLeadIn) {
            $warnings[] = [
                'code' => 'sepsis.emergency_hypoxic_oxygen_unfair',
                'severity' => 'high',
                'message' => '[High priority] Sepsis **hypoxic** emergency: SpO₂ is low and an option offers **oxygen therapy only**, but the keyed answer omits **oxygen** — include **oxygen plus** resuscitation in the key, **or** narrow the lead-in so oxygen is not competing unfairly.',
                'field' => $fieldKey,
            ];
        }
    }

    $obviousUnsafeCount = 0;
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $optField) {
        $opt = isset($question[$optField]) ? trim((string) $question[$optField]) : '';
        if ($opt !== '' && sbaStyleOptionIsObviousUnsafeSepsisEmergencyDistractor($opt)) {
            ++$obviousUnsafeCount;
        }
    }
    if ($obviousUnsafeCount >= 1) {
        $warnings[] = [
            'code' => 'sepsis.emergency_obvious_unsafe_distractors',
            'severity' => 'high',
            'message' => '[High priority] Sepsis Emergency Management: option(s) use **throwaway** unsafe lines (withhold antibiotics until cultures, oral antibiotics and observe, oxygen therapy only, monitor saturation only, repeat lactate only, monitor without treatment). Replace with **subtler sequencing errors** (e.g. further bolus without reassessing responsiveness, ward review not ICU, delay source control, vasopressor without critical care).',
            'field' => 'options',
        ];
    }

    $investigationDistractor = false;
    $urgentReview = false;
    $nonUrgentReview = false;
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $optField) {
        $opt = isset($question[$optField]) ? trim((string) $question[$optField]) : '';
        if ($opt === '') {
            continue;
        }
        if (preg_match(
            '/\b(?:full\s+blood\s+count|\bfbc\b|blood\s+cultures?\s+only|await\s+results|wait\s+for\s+results|request.{0,30}(?:ct|scan|imaging|x[\s-]?ray))\b/i',
            $opt
        ) && !preg_match('/\b(?:source[\s-]control|drainage|nephrostomy|decompress)\b/i', $opt)) {
            $investigationDistractor = true;
        }
        if (preg_match('/\b(?:surgical|urology|general\s+surgical)\s+review\b/i', $opt)) {
            if (preg_match('/\burgent\b/i', $opt)) {
                $urgentReview = true;
            } else {
                $nonUrgentReview = true;
            }
        }
    }
    if ($investigationDistractor) {
        $warnings[] = [
            'code' => 'sepsis.emergency_investigation_distractor',
            'severity' => 'high',
            'message' => '[High priority] Sepsis Emergency Management: option(s) are **investigations** or **wait-for-results** lines — use **immediate management/escalation** actions only (not FBC, CT, cultures-only, or await results) unless Current skill is Investigation.',
            'field' => 'options',
        ];
    }
    if ($urgentReview && $nonUrgentReview) {
        $warnings[] = [
            'code' => 'sepsis.emergency_overlapping_review_urgency',
            'severity' => 'warning',
            'message' => 'Sepsis Emergency Management: options include **both** urgent and non-urgent **surgical review** lines that differ mainly by urgency — use **distinct** management strategies, not near-duplicate review wording.',
            'field' => 'options',
        ];
    }

    $title = isset($question['title']) ? trim((string) $question['title']) : '';
    if ($title !== ''
        && (preg_match('/^\s*Management\s+of\b/i', $title)
            || preg_match('/\bEscalation\s+in\s+septic\s+shock\s+management\b/i', $title))) {
        $warnings[] = [
            'code' => 'sepsis.emergency_formulaic_title',
            'severity' => 'high',
            'message' => '[High priority] Sepsis Emergency Management: use a **neutral clinical title** (e.g. “Persistent hypotension in septic shock”, “Obstructed urinary sepsis”) — not “Management of …” or repeated “Escalation in septic shock management”.',
            'field' => 'title',
        ];
    }

    $justification = isset($question['justification']) ? trim((string) $question['justification']) : '';
    if ($justification !== '' && preg_match('/\bunderlying\s+issue\b/i', $justification)) {
        $warnings[] = [
            'code' => 'sepsis.emergency_justification_underlying_issue',
            'severity' => 'warning',
            'message' => 'Justification uses “underlying issue”; explain why the keyed **emergency action** is best now using stem cues.',
            'field' => 'justification',
        ];
    }

    if ($stem !== '' && preg_match('/\b(?:history\s+of|observations\s+show|vital\s+signs\s+show)\b/i', $stem)) {
        $warnings[] = [
            'code' => 'sepsis.emergency_stem_style',
            'severity' => 'warning',
            'message' => 'Sepsis Emergency stem: avoid “history of”, “observations show”, and “vital signs show”; use direct measurements in MS AKT order.',
            'field' => 'stem',
        ];
    }

    $priors = $question['_batch_prior_questions'] ?? [];
    if (is_array($priors) && $leadIn !== '' && function_exists('sbaStyleNormalizeLeadInForBatch')) {
        $normLead = sbaStyleNormalizeLeadInForBatch($leadIn);
        foreach ($priors as $p) {
            if (!is_array($p) || !preg_match('/^emergency\s+management$/i', trim((string) ($p['skill'] ?? '')))) {
                continue;
            }
            $pLead = trim((string) ($p['lead_in'] ?? ''));
            if ($pLead !== '' && $normLead === sbaStyleNormalizeLeadInForBatch($pLead)) {
                $warnings[] = [
                    'code' => 'sepsis.emergency_repeated_lead_in',
                    'severity' => 'high',
                    'message' => '[High priority] Sepsis Emergency Management batch: **lead-in repeats** an earlier emergency item — vary MS AKT-style wording (e.g. next circulatory step, additional urgent management, next escalation step).',
                    'field' => 'lead_in',
                ];
                break;
            }
        }
    }

    $opts = [];
    $letter = strtoupper(trim((string) ($question['correct_answer_letter'] ?? '')));
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $opts[$k] = trim((string) $question[$k]);
        }
    }
    if (count($opts) === 5 && $letter !== '' && preg_match('/^[A-E]$/', $letter)) {
        $keyField = 'option_' . strtolower($letter);
        $keyOpt = $opts[$keyField] ?? '';
        if ($keyOpt !== '') {
            $keyWords = sbaStyleWordCount($keyOpt);
            $otherWords = [];
            foreach ($opts as $k => $t) {
                if ($k === $keyField) {
                    continue;
                }
                $w = sbaStyleWordCount($t);
                if ($w > 0) {
                    $otherWords[] = $w;
                }
            }
            if ($keyWords > 0 && count($otherWords) === 4) {
                $minOther = min($otherWords);
                if ($minOther > 0 && ($keyWords / $minOther) >= 2.0) {
                    $warnings[] = [
                        'code' => 'sepsis.emergency_option_length_imbalance',
                        'severity' => 'high',
                        'message' => '[High priority] Sepsis Emergency Management: the **keyed option** is much longer than distractors — shorten the keyed line or lengthen distractors so all five options sit in a **similar length band**.',
                        'field' => $keyField,
                    ];
                }
            }
        }
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsSepsisEmergencyBatchRepetition(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^emergency\s+management$/i', $sk)) {
        return [];
    }

    if (!function_exists('sbaGeneratorConditionLooksSepsis')) {
        $sepsisConceptPath = dirname(__DIR__) . '/prompts/sba_sepsis_batch_concepts.php';
        if (is_readable($sepsisConceptPath)) {
            require_once $sepsisConceptPath;
        }
    }
    $condition = (string) ($question['_condition_name'] ?? '');
    if (!function_exists('sbaGeneratorConditionLooksSepsis') || !sbaGeneratorConditionLooksSepsis($condition)) {
        return [];
    }

    $priors = $question['_batch_prior_questions'] ?? [];
    if (!is_array($priors) || $priors === []) {
        return [];
    }

    $correct = sbaStyleCorrectAnswerCombinedText($question);
    if ($correct === '') {
        $correct = trim((string) ($question['correct_answer'] ?? ''));
    }
    if ($correct === '') {
        return [];
    }

    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';
    $normCorrect = sbaStyleNormalizeAnswerConceptForBatch($correct);
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $curSlot = function_exists('sbaSepsisInferEmergencyScenarioCategory')
        ? sbaSepsisInferEmergencyScenarioCategory($stem, $leadIn, $correct)
        : 'initial_resuscitation';
    $curIsBundle = sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($correct);

    foreach ($priors as $p) {
        if (!is_array($p) || !preg_match('/^emergency\s+management$/i', trim((string) ($p['skill'] ?? '')))) {
            continue;
        }
        $pStem = trim((string) ($p['stem'] ?? ''));
        $pLead = trim((string) ($p['lead_in'] ?? ''));
        $pCorrect = trim((string) ($p['correct_answer'] ?? ''));
        if ($pCorrect === '') {
            continue;
        }
        $pSlot = function_exists('sbaSepsisInferEmergencyScenarioCategory')
            ? sbaSepsisInferEmergencyScenarioCategory($pStem, $pLead, $pCorrect)
            : 'initial_resuscitation';
        $pIsBundle = sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($pCorrect);

        if ($curSlot === $pSlot) {
            $unusedLabel = function_exists('sbaSepsisPickNextEmergencyScenarioSlot')
                ? sbaSepsisPickNextEmergencyScenarioSlot([$pSlot => true])
                : '';
            $suggest = $unusedLabel !== '' && function_exists('sbaSepsisEmergencyScenarioDisplayLabel')
                ? sbaSepsisEmergencyScenarioDisplayLabel($unusedLabel)
                : 'an unused scenario category with a new clinical focus';

            return [[
                'code' => 'sepsis.emergency_batch_duplicate_scenario_category',
                'severity' => 'high',
                'message' => '[High priority] Sepsis **Emergency Management** batch: repeats scenario category **'
                    . (function_exists('sbaSepsisEmergencyScenarioDisplayLabel')
                        ? sbaSepsisEmergencyScenarioDisplayLabel($curSlot)
                        : $curSlot)
                    . '** (prior key: “' . $pCorrect . '”). Use **' . $suggest
                    . '** with a **different** stem, lead-in, keyed concept, and option set — e.g. do **not** repeat **noradrenaline** if refractory hypotension was already tested.',
                'field' => $fieldKey,
            ]];
        }

        $curBucket = function_exists('sbaSepsisEmergencyAnswerConceptBucket')
            ? sbaSepsisEmergencyAnswerConceptBucket($correct)
            : '';
        $pBucket = function_exists('sbaSepsisEmergencyAnswerConceptBucket')
            ? sbaSepsisEmergencyAnswerConceptBucket($pCorrect)
            : '';
        if ($curBucket !== '' && $curBucket === $pBucket
            && in_array($curBucket, ['initial_bundle', 'vasopressor'], true)) {
            return [[
                'code' => 'sepsis.emergency_batch_duplicate_scenario_category',
                'severity' => 'high',
                'message' => '[High priority] Sepsis **Emergency Management** batch: repeats the same **answer concept** as an earlier item (“'
                    . $pCorrect
                    . '”). Use a **distinct scenario category** and keyed action (source control, hypoxic resuscitation, lactate/escalation) — **not** a second **noradrenaline** or **fluids-plus-antibiotics** line.',
                'field' => $fieldKey,
            ]];
        }

        if ($normCorrect !== '' && $normCorrect === sbaStyleNormalizeAnswerConceptForBatch($pCorrect)) {
            return [[
                'code' => 'sepsis.emergency_batch_duplicate_scenario_category',
                'severity' => 'high',
                'message' => '[High priority] Sepsis **Emergency Management** batch: repeats the same keyed answer line as an earlier item. Use a **different scenario category** and concept.',
                'field' => $fieldKey,
            ]];
        }

        if ($curIsBundle && $pIsBundle) {
            $nextLabel = function_exists('sbaSepsisEmergencyScenarioDisplayLabel')
                && function_exists('sbaSepsisPickNextEmergencyScenarioSlot')
                ? sbaSepsisEmergencyScenarioDisplayLabel(sbaSepsisPickNextEmergencyScenarioSlot([$pSlot => true]))
                : 'a different scenario category';

            return [[
                'code' => 'sepsis.emergency_batch_duplicate_resuscitation',
                'severity' => 'high',
                'message' => '[High priority] Sepsis **Emergency Management** batch: repeats **IV fluids plus broad-spectrum IV antibiotics** / resuscitation-bundle keyed answer (“'
                    . $pCorrect
                    . '”). **At most one** initial-resuscitation item per batch. Use **'
                    . $nextLabel
                    . '** with a **new stem, lead-in, key, and five options**.',
                'field' => $fieldKey,
            ]];
        }
    }

    return [];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsSepsisInvestigationLactateDrift(array $question): array
{
    $warnings = [];
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^investigation(?:\s+selection)?$/i', $sk)) {
        return $warnings;
    }
    $condition = (string) ($question['_condition_name'] ?? '');
    if (!function_exists('sbaGeneratorConditionLooksSepsis')) {
        $sepsisConceptPath = dirname(__DIR__) . '/prompts/sba_sepsis_batch_concepts.php';
        if (is_readable($sepsisConceptPath)) {
            require_once $sepsisConceptPath;
        }
    }
    if (!function_exists('sbaGeneratorConditionLooksSepsis') || !sbaGeneratorConditionLooksSepsis($condition)) {
        $title = isset($question['title']) ? trim((string) $question['title']) : '';
        $stemBlob = mb_strtolower(trim((string) ($question['stem'] ?? '') . ' ' . $title));
        if (!preg_match('/\b(?:sepsis|septic\s+shock|severe\s+sepsis)\b/i', $stemBlob)) {
            return $warnings;
        }
    }

    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if (!sbaStyleLeadInAsksInitialInvestigationSelection($leadIn)) {
        return $warnings;
    }

    $keyed = sbaStyleKeyedAnswerCombinedManagementText($question);
    if ($keyed === '' || !sbaStyleKeyedAnswerSuggestsLactateAsInvestigation($keyed)) {
        return $warnings;
    }

    if (sbaStyleStemOrLeadInFocusesOnSepsisSeverityPerfusionOrLactateAssessment($stem, $leadIn)) {
        return $warnings;
    }

    $field = sbaStyleKeyedAnswerOptionField($question);
    $warnings[] = [
        'code' => 'sepsis.initial_investigation_lactate_drift',
        'severity' => 'warning',
        'message' => 'Suspected sepsis Investigation: the lead-in asks for the **initial investigation** but the keyed answer is **lactate** — this drifts toward **severity/risk assessment** rather than **diagnostic/source investigation**. For initial investigation, prefer **blood cultures**, **urinalysis**, **chest X-ray**, or another source-focused test fitting the stem; reserve **lactate** for severity, perfusion, shock assessment, or interpretation items unless the stem explicitly focuses on tissue hypoperfusion or severity.',
        'field' => $field !== '' ? $field : 'correct_answer',
    ];

    return $warnings;
}

/**
 * Keyed answer is primarily passive monitoring without resuscitation (unstable sepsis emergency).
 */
function sbaStyleKeyedAnswerSuggestsPassiveMonitorCloselyWithoutResuscitation(string $keyedBlob): bool
{
    if ($keyedBlob === '' || !preg_match('/\bmonitor\s+closely\b/i', $keyedBlob)) {
        return false;
    }
    if (sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($keyedBlob)) {
        return false;
    }
    if (sbaStyleTextMentionsIvFluidsForResuscitation($keyedBlob)) {
        return false;
    }
    if (preg_match(
        '/\b(?:critical\s+care|icu|escalat|senior\s+(?:review|registrar|consultant)|vasopressor|resuscitation\s+bundle)\b/i',
        $keyedBlob
    )) {
        return false;
    }

    return true;
}

/**
 * Sepsis batch Management vs Emergency Management concept-map validation (form generator).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsSepsisBatchSkillConceptMap(array $question): array
{
    if (!function_exists('sbaGeneratorQuestionUsesSepsisMgmtEmergencyConceptMap')) {
        $sepsisConceptPath = dirname(__DIR__) . '/prompts/sba_sepsis_batch_concepts.php';
        if (is_readable($sepsisConceptPath)) {
            require_once $sepsisConceptPath;
        }
    }

    $warnings = [];
    $usesMap = function_exists('sbaGeneratorQuestionUsesSepsisMgmtEmergencyConceptMap')
        ? sbaGeneratorQuestionUsesSepsisMgmtEmergencyConceptMap($question)
        : false;
    if (!$usesMap) {
        return $warnings;
    }

    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $tutor = trim((string) ($question['_tutor_comments'] ?? ''));
    $tutorShockMgmt = function_exists('sbaGeneratorTutorRequestsSepticShockManagement')
        ? sbaGeneratorTutorRequestsSepticShockManagement($tutor)
        : false;
    $keyedBlob = sbaStyleKeyedAnswerCombinedManagementText($question);
    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';
    $unstableStem = sbaStyleStemSuggestsUnstableSuspectedSepsis($stem);

    if (preg_match('/^management$/i', $sk) && $keyedBlob !== '') {
        if (!$tutorShockMgmt && sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($keyedBlob)) {
            $warnings[] = [
                'code' => 'sepsis.batch_management_combined_resuscitation_reserved_for_emergency',
                'severity' => 'high',
                'message' => '[High priority] Sepsis batch concept map: **Management** is assigned **stable suspected sepsis → broad-spectrum IV antibiotics**. Do **not** key combined **IV fluids plus antibiotics** or a **sepsis resuscitation bundle** here when **Emergency Management** is also in this job — reserve shock resuscitation for Emergency Management unless **Tutor comments** explicitly request septic shock management for Management.',
                'field' => $fieldKey,
            ];
        }
        if ($unstableStem
            && !sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($keyedBlob)
            && !sbaStyleTextMentionsIvFluidsForResuscitation($keyedBlob)) {
            $warnings[] = [
                'code' => 'sepsis.batch_management_unstable_stem_mismatch',
                'severity' => 'warning',
                'message' => 'Sepsis batch concept map: **Management** stem includes hypotension, shock, or raised lactate but the keyed answer is not full resuscitation — use a **stable** suspected sepsis vignette for Management, or move shock resuscitation to **Emergency Management**.',
                'field' => 'stem',
            ];
        }
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $keyedBlob !== '') {
        $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
        $postInitial = function_exists('sbaSepsisEmergencyPostInitialResuscitationScenario')
            && sbaSepsisEmergencyPostInitialResuscitationScenario($stem, $leadIn, $keyedBlob);
        $refractoryKey = function_exists('sbaSepsisKeyedAnswerIsRefractoryCirculatorySupport')
            && sbaSepsisKeyedAnswerIsRefractoryCirculatorySupport($keyedBlob);
        if ($unstableStem
            && sbaStyleKeyedAnswerIsAntibioticsOnlyWithoutFluidResuscitation($keyedBlob)
            && !$postInitial
            && !$refractoryKey) {
            $warnings[] = [
                'code' => 'emergency.sepsis_unstable_antibiotics_only_high',
                'severity' => 'high',
                'message' => '[High priority] Sepsis batch Emergency Management: hypotensive / shocked / raised-lactate vignette — do **not** key **antibiotics alone** (e.g. begin IV antibiotics and monitor closely). Key **IV fluids plus broad-spectrum IV antibiotics**, a **sepsis resuscitation bundle**, or **urgent escalation / critical care** if fluid-refractory shock is stated.',
                'field' => $fieldKey,
            ];
        }
        if ($unstableStem && sbaStyleKeyedAnswerSuggestsPassiveMonitorCloselyWithoutResuscitation($keyedBlob)
            && !$refractoryKey
            && !$postInitial) {
            $warnings[] = [
                'code' => 'emergency.sepsis_unstable_keyed_monitoring_too_passive',
                'severity' => 'warning',
                'message' => 'Unstable sepsis Emergency Management: “monitor closely” (or equivalent passive monitoring) is too weak as the primary keyed action unless paired with **IV fluid resuscitation**, **broad-spectrum IV antibiotics**, or **urgent escalation** in the same answer line.',
                'field' => $fieldKey,
            ];
        }
        if (!$unstableStem && sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($keyedBlob)) {
            $warnings[] = [
                'code' => 'sepsis.batch_emergency_stable_stem_mismatch',
                'severity' => 'warning',
                'message' => 'Sepsis batch concept map: **Emergency Management** should use an **unstable** vignette (hypotension, shock, or raised lactate) when keying a **resuscitation bundle** or **IV fluids plus IV antibiotics**.',
                'field' => 'stem',
            ];
        }
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsPeEmergencyBatchRepetition(array $question): array
{
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^emergency\s+management$/i', $sk)) {
        return [];
    }
    if (!sbaStyleQuestionSuggestsPulmonaryEmbolismCoreScenario($question)) {
        return [];
    }

    $priors = $question['_batch_prior_questions'] ?? [];
    if (!is_array($priors) || $priors === []) {
        return [];
    }

    if (!function_exists('sbaPeInferEmergencyScenarioCategory')) {
        $pePath = dirname(__DIR__) . '/prompts/sba_pe_emergency_batch_concepts.php';
        if (is_readable($pePath)) {
            require_once $pePath;
        }
    }

    $correct = sbaStyleCorrectAnswerCombinedText($question);
    if ($correct === '') {
        $correct = trim((string) ($question['correct_answer'] ?? ''));
    }
    if ($correct === '') {
        return [];
    }

    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $curSlot = function_exists('sbaPeAssignedEmergencyScenarioSlot')
        ? (sbaPeAssignedEmergencyScenarioSlot($question)
            ?? (function_exists('sbaPeInferEmergencyScenarioCategory')
                ? sbaPeInferEmergencyScenarioCategory($stem, $leadIn, $correct)
                : 'initial_massive_pe_shock'))
        : (function_exists('sbaPeInferEmergencyScenarioCategory')
            ? sbaPeInferEmergencyScenarioCategory($stem, $leadIn, $correct)
            : 'initial_massive_pe_shock');
    $normCorrect = sbaStyleNormalizeAnswerConceptForBatch($correct);

    foreach ($priors as $p) {
        if (!is_array($p) || !preg_match('/^emergency\s+management$/i', trim((string) ($p['skill'] ?? '')))) {
            continue;
        }
        if (!sbaStyleQuestionSuggestsPulmonaryEmbolismCoreScenario($p)) {
            continue;
        }
        $pStem = trim((string) ($p['stem'] ?? ''));
        $pLead = trim((string) ($p['lead_in'] ?? ''));
        $pCorrect = trim((string) ($p['correct_answer'] ?? ''));
        if ($pCorrect === '') {
            continue;
        }
        $pSlot = function_exists('sbaPeEmergencySlotFromPriorRow')
            ? (sbaPeEmergencySlotFromPriorRow($p)
                ?? (function_exists('sbaPeInferEmergencyScenarioCategory')
                    ? sbaPeInferEmergencyScenarioCategory($pStem, $pLead, $pCorrect)
                    : 'initial_massive_pe_shock'))
            : (function_exists('sbaPeInferEmergencyScenarioCategory')
                ? sbaPeInferEmergencyScenarioCategory($pStem, $pLead, $pCorrect)
                : 'initial_massive_pe_shock');

        if ($curSlot === $pSlot) {
            $suggest = function_exists('sbaPePickNextEmergencyScenarioSlot')
                ? sbaPeEmergencyScenarioDisplayLabel(sbaPePickNextEmergencyScenarioSlot([$pSlot => true]))
                : 'an unused PE emergency category';

            return [[
                'code' => 'pe.emergency_batch_duplicate_scenario_category',
                'severity' => 'high',
                'message' => '[High priority] PE **Emergency Management** batch: repeats scenario category **'
                    . (function_exists('sbaPeEmergencyScenarioDisplayLabel')
                        ? sbaPeEmergencyScenarioDisplayLabel($curSlot)
                        : $curSlot)
                    . '** (prior key: “' . $pCorrect . '”). Use **' . $suggest
                    . '** with a **different** stem, lead-in, keyed concept, and option set.',
                'field' => $fieldKey,
            ]];
        }

        $curBucket = function_exists('sbaPeEmergencyAnswerConceptBucket')
            ? sbaPeEmergencyAnswerConceptBucket($correct)
            : '';
        $pBucket = function_exists('sbaPeEmergencyAnswerConceptBucket')
            ? sbaPeEmergencyAnswerConceptBucket($pCorrect)
            : '';
        if ($curBucket !== '' && $curBucket === $pBucket
            && in_array($curBucket, ['thrombolysis', 'anticoagulation_alone', 'oxygen_plus_escalation'], true)) {
            return [[
                'code' => 'pe.emergency_batch_duplicate_scenario_category',
                'severity' => 'high',
                'message' => '[High priority] PE **Emergency Management** batch: repeats the same **answer concept** (“'
                    . $pCorrect
                    . '”). Use a **distinct scenario category** and emergency action.',
                'field' => $fieldKey,
            ]];
        }

        if ($normCorrect !== '' && $normCorrect === sbaStyleNormalizeAnswerConceptForBatch($pCorrect)) {
            return [[
                'code' => 'pe.emergency_batch_duplicate_scenario_category',
                'severity' => 'high',
                'message' => '[High priority] PE **Emergency Management** batch: repeats the same keyed answer line. Use a **different scenario category** and wording.',
                'field' => $fieldKey,
            ]];
        }
    }

    return [];
}

function sbaStyleQuestionSuggestsPulmonaryEmbolismCoreScenario(array $question): bool
{
    if (!function_exists('sbaGeneratorConditionLooksPulmonaryEmbolism')) {
        $pePath = dirname(__DIR__) . '/prompts/sba_pe_emergency_concepts.php';
        if (is_readable($pePath)) {
            require_once $pePath;
        }
    }
    $condition = (string) ($question['_condition_name'] ?? '');
    if ($condition !== '' && function_exists('sbaGeneratorConditionLooksPulmonaryEmbolism')
        && sbaGeneratorConditionLooksPulmonaryEmbolism($condition)) {
        return true;
    }
    $parts = [];
    foreach (['title', 'stem', 'presentation'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $parts[] = trim($question[$k]);
        }
    }
    $blob = mb_strtolower(implode(' ', $parts));

    return (bool) preg_match(
        '/\bpulmonary\s+embol(?:ism|us)\b|\bmassive\s+pe\b|\bhigh[\s-]?risk\s+pe\b|\bsaddle\s+pe\b|\bacute\s+pe\b/i',
        $blob
    );
}

function sbaStyleStemSuggestsInfectionSepsisOrPneumoniaContext(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:sepsis|septic|pneumonia|pneumonitis|infection|infected|bacterial|pyrexia|rigors|rigour|rigor|purulent|abscess|cellulitis|empyema|urosepsis|meningitis|endocarditis|fever\s+source|suspected\s+infection|broad[\s-]?spectrum\s+antibiotic)\b/i',
        $stem
    );
}

function sbaStyleKeyedAnswerMentionsIvAntibiotics(string $text): bool
{
    if ($text === '') {
        return false;
    }
    if (function_exists('sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy')
        && sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($text)) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:broad[\s-]?spectrum\s+)?(?:iv\s+|intravenous\s+)?antibiotics?\b|\bempirical\s+(?:iv\s+)?antimicrobial\b/i',
        $text
    );
}

function sbaStyleKeyedAnswerSuggestsPeEscalationReperfusionOrCriticalCare(string $text): bool
{
    if ($text === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:thrombolysis|thrombolytic|fibrinolysis|alteplase|streptokinase|tenecteplase|reperfusion|embolectomy|pulmonary\s+embolectomy|catheter[\s-]?directed|surgical\s+embolectomy|critical\s+care|icu|intensive\s+care|senior\s+(?:review|registrar|consultant)|urgent\s+escalat|escalat(?:e|ion)\s+to|percutaneous|interventional\s+(?:radiology|cardiology))\b/i',
        $text
    );
}

/**
 * Haemodynamic instability required before keying thrombolysis / reperfusion for PE.
 * Hypoxia or “massive / high-risk PE” wording alone is insufficient.
 */
function sbaStyleStemSuggestsPeHaemodynamicInstabilityForReperfusion(string $stem): bool
{
    if (!function_exists('sbaPeStemDocumentsHaemodynamicInstabilityForThrombolysis')) {
        $pePath = dirname(__DIR__) . '/prompts/sba_pe_emergency_batch_concepts.php';
        if (is_readable($pePath)) {
            require_once $pePath;
        }
    }

    return function_exists('sbaPeStemDocumentsHaemodynamicInstabilityForThrombolysis')
        ? sbaPeStemDocumentsHaemodynamicInstabilityForThrombolysis($stem)
        : false;
}

function sbaStyleStemSuggestsPeWithHypotensionOrShock(string $stem): bool
{
    return sbaStyleStemSuggestsPeHaemodynamicInstabilityForReperfusion($stem);
}

function sbaStyleKeyedAnswerSuggestsPeThrombolysisOrReperfusionTherapy(string $text): bool
{
    if ($text === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:thrombolysis|thrombolytic|fibrinolysis|alteplase|streptokinase|tenecteplase|reperfusion|embolectomy|pulmonary\s+embolectomy|catheter[\s-]?directed|surgical\s+embolectomy)\b/i',
        $text
    );
}

function sbaStyleOptionLineSuggestsCtpaOrPeInvestigation(string $line): bool
{
    if ($line === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:ct\s+pulmonary\s+angiograph|ctpa|v\/q\s+scan|ventilation[\s-]?perfusion|d[\s-]?dimer|pulmonary\s+angiograph|cta\s+pulmonary)\b/i',
        $line
    );
}

/**
 * Pulmonary embolism — Emergency Management validation (form generator).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsPulmonaryEmbolismEmergencyStyle(array $question): array
{
    $warnings = [];
    if (!sbaStyleQuestionSuggestsPulmonaryEmbolismCoreScenario($question)) {
        return $warnings;
    }
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^emergency\s+management$/i', $sk)) {
        return $warnings;
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $keyedBlob = sbaStyleKeyedAnswerCombinedManagementText($question);
    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';

    if ($keyedBlob !== ''
        && sbaStyleKeyedAnswerMentionsIvAntibiotics($keyedBlob)
        && !sbaStyleStemSuggestsInfectionSepsisOrPneumoniaContext($stem)) {
        $warnings[] = [
            'code' => 'pe.emergency_antibiotics_wrong_context',
            'severity' => 'high',
            'message' => '[High priority] Pulmonary embolism Emergency Management: the keyed answer includes **antibiotics** but the stem does not indicate **sepsis**, **pneumonia**, or another **infection** — do **not** reuse sepsis-style **broad-spectrum IV antibiotics** (or fluids plus antibiotics) for PE. Use PE-appropriate emergency actions (anticoagulation when stable; thrombolysis / reperfusion / urgent critical care escalation when shocked).',
            'field' => $fieldKey,
        ];
    }

    if (sbaStyleStemSuggestsPeWithHypotensionOrShock($stem)
        && $keyedBlob !== ''
        && !sbaStyleKeyedAnswerSuggestsPeEscalationReperfusionOrCriticalCare($keyedBlob)) {
        $warnings[] = [
            'code' => 'pe.massive_shock_needs_escalation_or_reperfusion',
            'severity' => 'high',
            'message' => '[High priority] Pulmonary embolism with **hypotension / shock / massive PE**: Emergency Management should include **urgent senior or critical care escalation** and/or **reperfusion** (e.g. **thrombolysis**, embolectomy pathway) — not **anticoagulation alone**, **oxygen alone**, or **IV fluids plus antibiotics** without infection cues.',
            'field' => $fieldKey,
        ];
    }

    if ($leadIn !== '' && function_exists('sbaStyleLeadInAsksTreatmentWithoutInvestigationFrame')
        && sbaStyleLeadInAsksTreatmentWithoutInvestigationFrame($leadIn)) {
        foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $field) {
            $t = isset($question[$field]) ? trim((string) $question[$field]) : '';
            if ($t === '' || !sbaStyleOptionLineSuggestsCtpaOrPeInvestigation($t)) {
                continue;
            }
            $warnings[] = [
                'code' => 'pe.emergency_investigation_option_on_treatment_lead_in',
                'severity' => 'warning',
                'message' => 'Pulmonary embolism Emergency Management: **CTPA**, **V/Q scan**, or similar **investigation** options should not appear when the lead-in asks for **immediate management** — use treatment/resuscitation/escalation actions only.',
                'field' => 'options',
            ];
            break;
        }
    }

    return $warnings;
}

/**
 * PE — thrombolysis / reperfusion must match stem severity (Emergency Management or Management).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsPulmonaryEmbolismThrombolysisSeverity(array $question): array
{
    if (!sbaStyleQuestionSuggestsPulmonaryEmbolismCoreScenario($question)) {
        return [];
    }
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^emergency\s+management$/i', $sk) && !preg_match('/^management$/i', $sk)) {
        return [];
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $keyedBlob = sbaStyleKeyedAnswerCombinedManagementText($question);
    if ($keyedBlob === ''
        || !sbaStyleKeyedAnswerSuggestsPeThrombolysisOrReperfusionTherapy($keyedBlob)
        || sbaStyleStemSuggestsPeHaemodynamicInstabilityForReperfusion($stem)) {
        return [];
    }

    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';

    return [[
        'code' => 'pe_thrombolysis_without_instability',
        'severity' => 'high',
        'message' => '[High priority] Pulmonary embolism: the keyed answer includes **thrombolysis / reperfusion** but the stem does not document **haemodynamic instability** (e.g. **systolic BP under 90 mmHg**, **shock**, **cardiac arrest**, **peri-arrest**, or **obstructive circulatory collapse**). **Hypoxia alone** (e.g. SpO₂ 85% without hypotension/shock) is **not** enough — either add clear **hypotension / shock** if the teaching point is **massive PE**, or key **oxygen plus anticoagulation / urgent escalation** for **hypoxic but stable** PE; **thrombolysis** may appear only as a **distractor** without instability.',
        'field' => $fieldKey,
    ]];
}

/**
 * PE Emergency — clinical alignment (contraindication, option homogeneity, oxygen fairness).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsPeEmergencyClinicalAlignment(array $question): array
{
    $warnings = [];
    if (!sbaStyleQuestionSuggestsPulmonaryEmbolismCoreScenario($question)) {
        return $warnings;
    }
    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? '')));
    if (!preg_match('/^emergency\s+management$/i', $sk)) {
        return $warnings;
    }

    $pePath = dirname(__DIR__) . '/prompts/sba_pe_emergency_batch_concepts.php';
    if (is_readable($pePath)) {
        require_once $pePath;
    }

    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $keyedBlob = sbaStyleKeyedAnswerCombinedManagementText($question);
    $fieldKey = sbaStyleKeyedAnswerOptionField($question);
    $fieldKey = $fieldKey !== '' ? $fieldKey : 'correct_answer';
    $assignedSlot = function_exists('sbaPeAssignedEmergencyScenarioSlot')
        ? sbaPeAssignedEmergencyScenarioSlot($question)
        : null;
    $instability = sbaStyleStemSuggestsPeHaemodynamicInstabilityForReperfusion($stem);
    $contraindicatedStem = function_exists('sbaPeStemSuggestsThrombolysisContraindication')
        && sbaPeStemSuggestsThrombolysisContraindication($stem);
    $contraindicatedSlot = $assignedSlot === 'thrombolysis_contraindicated';

    if ($keyedBlob !== ''
        && ($contraindicatedStem || $contraindicatedSlot)
        && function_exists('sbaPeKeyedAnswerSuggestsThrombolysisDrug')
        && sbaPeKeyedAnswerSuggestsThrombolysisDrug($keyedBlob)) {
        $warnings[] = [
            'code' => 'pe.emergency_thrombolysis_despite_contraindication',
            'severity' => 'high',
            'message' => '[High priority] Pulmonary embolism: the stem documents **contraindication to thrombolysis** but the keyed answer includes **thrombolysis/alteplase**. Key **urgent critical care/specialist escalation for alternative reperfusion** — not thrombolysis.',
            'field' => $fieldKey,
        ];
    }

    if ($keyedBlob !== ''
        && ($contraindicatedStem || $contraindicatedSlot)
        && function_exists('sbaPeKeyedAnswerSuggestsGenericAlternativeReperfusionEscalation')
        && !sbaPeKeyedAnswerSuggestsGenericAlternativeReperfusionEscalation($keyedBlob)
        && preg_match('/\b(?:surgical\s+embolectomy|catheter[\s-]directed)\b/i', $keyedBlob)
        && !preg_match('/\b(?:critical\s+care|senior|escalat|alternative\s+reperfusion|specialist)\b/i', $keyedBlob)) {
        $warnings[] = [
            'code' => 'pe.emergency_contraindicated_narrow_reperfusion_key',
            'severity' => 'warning',
            'message' => 'Pulmonary embolism with **thrombolysis contraindicated**: prefer a keyed line such as **urgent critical care escalation for alternative reperfusion** rather than forcing a narrow **surgical versus catheter** choice unless that distinction is explicitly the learning point.',
            'field' => $fieldKey,
        ];
    }

    if ($keyedBlob !== ''
        && function_exists('sbaPeKeyedAnswerSuggestsThrombolysisDrug')
        && sbaPeKeyedAnswerSuggestsThrombolysisDrug($keyedBlob)
        && !$instability
        && in_array($assignedSlot, ['initial_massive_pe_shock', 'unstable_pe_reperfusion_required', 'hypoxic_pe_oxygen_escalation'], true)) {
        $warnings[] = [
            'code' => 'pe.emergency_plan_slot_requires_instability',
            'severity' => 'high',
            'message' => '[High priority] Assigned PE emergency category **' . (string) $assignedSlot
                . '** requires **documented haemodynamic instability** in the stem before keying thrombolysis/reperfusion. Add **hypotension/shock/BP under 90 mmHg** or change the keyed answer.',
            'field' => 'stem',
        ];
    }

    if ($keyedBlob !== '' && function_exists('sbaPeKeyedAnswerSuggestsThrombolysisDrug')) {
        $thrombDistractors = 0;
        foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $optField) {
            $line = isset($question[$optField]) ? trim((string) $question[$optField]) : '';
            if ($line === '' || $line === $keyedBlob) {
                continue;
            }
            if (sbaPeKeyedAnswerSuggestsThrombolysisDrug($line)) {
                ++$thrombDistractors;
            }
        }
        if (sbaPeKeyedAnswerSuggestsThrombolysisDrug($keyedBlob) && $thrombDistractors > 0 && !$contraindicatedStem) {
            $warnings[] = [
                'code' => 'pe.emergency_thrombolysis_key_and_distractor',
                'severity' => 'warning',
                'message' => 'Pulmonary embolism Emergency Management: **thrombolysis** appears in both the keyed answer and at least one distractor. Use a **distinct** unfair distractor (e.g. anticoagulation alone, delay reperfusion) unless the stem makes the distinction explicit and fair.',
                'field' => 'options',
            ];
        }
    }

    $massiveContext = $instability
        || preg_match('/\b(?:massive|shock|unstable)\b/i', $stem);
    if ($massiveContext) {
        foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $optField) {
            $line = isset($question[$optField]) ? trim((string) $question[$optField]) : '';
            if ($line === '') {
                continue;
            }
            if (preg_match('/\b(?:rivaroxaban|apixaban|dabigatran|edoxaban|oral\s+anticoagulant|discharge\s+with\s+anticoagulation|outpatient\s+referral)\b/i', $line)) {
                $warnings[] = [
                    'code' => 'pe.emergency_oral_anticoagulant_option',
                    'severity' => 'warning',
                    'message' => 'Pulmonary embolism Emergency Management with **shock/massive PE**: avoid **oral anticoagulant** or **outpatient referral** options — use urgent emergency actions only.',
                    'field' => 'options',
                ];
                break;
            }
            if (preg_match('/\bmonitor\s+(?:closely|observations|response|in\s+(?:resus|the\s+department))\b/i', $line)
                && !preg_match('/\b(?:treat|resuscitat|thrombolysis|reperfusion|anticoagul|escalat|critical\s+care)\b/i', $line)) {
                $warnings[] = [
                    'code' => 'pe.emergency_monitor_only_distractor',
                    'severity' => 'warning',
                    'message' => 'Pulmonary embolism Emergency Management: avoid **monitor-only** distractors (e.g. monitor observations without treatment) — use plausible **under-treatment or wrong-priority** emergency actions.',
                    'field' => 'options',
                ];
                break;
            }
        }
    }

    return $warnings;
}

function sbaStyleRoughWordCount(string $text): int
{
    $t = trim(preg_replace('/\s+/u', ' ', $text));
    if ($t === '') {
        return 0;
    }
    $parts = preg_split('/\s+/u', $text);

    return is_array($parts) ? count($parts) : 0;
}

/**
 * Ectopic pregnancy / unstable early pregnancy emergency style heuristics (non-blocking warnings).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsEctopicPregnancyEmergencyStyle(array $question): array
{
    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $title = isset($question['title']) ? trim((string) $question['title']) : '';

    if (sbaStyleStemMatchesExplicitSuspectedRupturedEctopicWording($stem)) {
        $warnings[] = [
            'code' => 'emergency.ectopic_explicit_diagnosis_in_stem',
            'severity' => 'warning',
            'message' => 'Stem explicitly names suspected ruptured ectopic pregnancy — prefer enough clinical cues without spelling the diagnosis in the final sentence; let candidates infer urgency from observations, bleeding, and pregnancy context.',
            'field' => 'stem',
        ];
    }

    if (sbaStyleStemOrTitleLooksEctopicPregnancyCoreScenario($stem, $title) && !sbaStyleStemHasPregnancyStatusOrDatingCue($stem)) {
        $warnings[] = [
            'code' => 'emergency.ectopic_missing_pregnancy_context_in_stem',
            'severity' => 'warning',
            'message' => 'Ectopic pregnancy scenario detected but the stem does not mention pregnancy testing, a positive pregnancy test, confirmed pregnancy, or menstrual / dating context — add a proportionate pregnancy anchor (commonly a positive urine pregnancy test).',
            'field' => 'stem',
        ];
    }

    if (sbaStyleStemOrTitleLooksEctopicPregnancyCoreScenario($stem, $title) && preg_match('/\bknown\s+to\s+be\s+pregnant\b/i', $stem)) {
        $warnings[] = [
            'code' => 'emergency.ectopic_stem_known_to_be_pregnant',
            'severity' => 'warning',
            'message' => 'Ectopic pregnancy stem uses “known to be pregnant” — prefer “A urine pregnancy test is positive” (or equivalent) or gestational / dating detail unless “known pregnant” is clearly the clearest wording.',
            'field' => 'stem',
        ];
    }

    if (sbaStyleQuestionLooksEmergencyManagementLeadIn($question)
        && sbaStyleStemSuggestsHaemodynamicInstability($stem)
        && sbaStyleStemOrTitleLooksEctopicPregnancyCoreScenario($stem, $title)) {
        $keyedBlob = sbaStyleKeyedAnswerCombinedManagementText($question);
        if ($keyedBlob !== '' && !sbaStyleKeyedAnswerReflectsResuscitationOrUrgentGynSurgeryPathway($keyedBlob)) {
            $field = sbaStyleKeyedAnswerOptionField($question);
            $warnings[] = [
                'code' => 'emergency.unstable_ectopic_keyed_missing_resuscitation_or_urgent_pathway',
                'severity' => 'warning',
                'message' => 'Unstable observations with an ectopic-pregnancy scenario and an emergency-management lead-in: the keyed answer should usually reflect resuscitation, IV access/fluids where appropriate, and urgent senior gynaecology / surgical escalation rather than ward-level steps alone.',
                'field' => $field !== '' ? $field : 'correct_answer',
            ];
        }
        if ($keyedBlob !== ''
            && !sbaStyleLeadInAsksOnlyFirstResuscitationAction((string) ($question['lead_in'] ?? ''))
            && sbaStyleKeyedMentionsIvFluidsOrFluidResuscitation($keyedBlob)
            && !sbaStyleKeyedMentionsUrgentGynSurgicalTheatreOrSeniorEscalation($keyedBlob)) {
            $field = sbaStyleKeyedAnswerOptionField($question);
            $warnings[] = [
                'code' => 'emergency.unstable_ectopic_keyed_iv_fluids_without_urgent_gyn_surgical_escalation',
                'severity' => 'warning',
                'message' => 'Unstable suspected ectopic: the keyed answer looks like IV fluids / fluid resuscitation without urgent gynaecology, surgical management, senior review, or theatre — combine immediate resuscitation with urgent gynaecology / surgical escalation unless the lead-in asks only for the first resuscitation action.',
                'field' => $field !== '' ? $field : 'correct_answer',
            ];
        }
    }

    if (sbaStyleStemOrTitleLooksEctopicPregnancyCoreScenario($stem, $title)) {
        $ngField = sbaStyleFirstOptionFieldMatchingNasogastric($question);
        if ($ngField !== '') {
            $warnings[] = [
                'code' => 'emergency.ectopic_options_nasogastric_tube',
                'severity' => 'warning',
                'message' => 'Ectopic pregnancy item includes a nasogastric tube option — this is usually an implausible distractor unless the vignette makes it clinically relevant; prefer sequencing errors (e.g. methotrexate, delayed β-hCG, ultrasound before resuscitation, outpatient review).',
                'field' => $ngField,
            ];
        }
    }

    $correctField = sbaStyleKeyedAnswerOptionField($question);
    if ($correctField !== '') {
        $keyedLine = trim((string) ($question[$correctField] ?? ''));
        $keyedExtra = trim((string) ($question['correct_answer'] ?? ''));
        $keyedCombined = trim($keyedLine . ' ' . $keyedExtra);
        if ($keyedCombined !== '') {
            $dLens = [];
            $dWords = [];
            foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $f) {
                if ($f === $correctField) {
                    continue;
                }
                $ot = trim((string) ($question[$f] ?? ''));
                if ($ot === '') {
                    continue;
                }
                $dLens[] = mb_strlen($ot);
                $dWords[] = sbaStyleRoughWordCount($ot);
            }
            if ($dLens !== []) {
                $maxLen = max($dLens);
                $maxWords = max($dWords);
                $kLen = mb_strlen($keyedCombined);
                $kWords = sbaStyleRoughWordCount($keyedCombined);
                $substantiallyLongerChars = $kLen > $maxLen + 35 && $kLen > (int) round($maxLen * 1.48);
                $substantiallyLongerWords = $kWords > $maxWords + 14 && $kWords > (int) round($maxWords * 1.35);
                if ($substantiallyLongerChars || $substantiallyLongerWords) {
                    $warnings[] = [
                        'code' => 'options.correct_answer_much_longer_than_distractors',
                        'severity' => 'warning',
                        'message' => 'The keyed option (with correct_answer text) is substantially longer than every distractor — tighten wording or lengthen distractors so options sit in a similar length band.',
                        'field' => $correctField,
                    ];
                }
            }
        }
    }

    return $warnings;
}

/**
 * MLA / MS AKT concision heuristics for the form-based generator (warnings only).
 *
 * Uses optional underscore context: _current_skill, _condition_name (not persisted on the question row).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsMlaAktFormConcision(array $question): array
{
    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $justification = isset($question['justification']) ? trim((string) $question['justification']) : '';

    if ($stem !== '') {
        $wcStem = sbaStyleWordCount($stem);
        if ($wcStem > 100) {
            $warnings[] = [
                'code' => 'mla.stem_over_100_words',
                'severity' => 'warning',
                'message' => 'Stem exceeds 100 words (' . $wcStem . '); prefer 40–80 words where clinically possible unless extra framing is essential.',
                'field' => 'stem',
            ];
        }

        $ctx = sbaStyleMlaStemContextCategoryCount($stem);
        if ($ctx > 3) {
            $warnings[] = [
                'code' => 'mla.stem_many_context_layers',
                'severity' => 'warning',
                'message' => 'Stem appears to include more than three context categories among: setting, PMH, drug history, social history, observations, and investigations. Trim material that does not change the best answer unless clinically essential.',
                'field' => 'stem',
            ];
        }
    }

    $skill = isset($question['_current_skill']) ? trim((string) $question['_current_skill']) : '';
    $condition = isset($question['_condition_name']) ? trim((string) $question['_condition_name']) : '';
    if ($stem !== '' && $condition !== '' && preg_match('/diagnosis/i', $skill)) {
        $quoted = preg_quote($condition, '/');
        $quoted = (string) preg_replace('/\s+/u', '\\s+', $quoted);
        if ($quoted !== '' && mb_strlen($condition) >= 3 && preg_match('/\b' . $quoted . '\b/iu', $stem)) {
            $warnings[] = [
                'code' => 'mla.diagnosis_stem_names_condition',
                'severity' => 'warning',
                'message' => 'Diagnosis item: stem appears to name the core condition explicitly; avoid naming that condition in the stem.',
                'field' => 'stem',
            ];
        }
    }

    if ($justification !== '') {
        if (preg_match('/^\s*the\s+vignette\s+presents\b/i', $justification)) {
            $warnings[] = [
                'code' => 'mla.justification_opens_vignette_presents',
                'severity' => 'warning',
                'message' => 'Justification starts with “The vignette presents …”; use concise reviewer-style reasoning without tutor-like meta-framing.',
                'field' => 'justification',
            ];
        }
        if (preg_match('/^\s*option\s+[a-e]\s+is\b/i', $justification)) {
            $warnings[] = [
                'code' => 'mla.justification_opens_option_x_is',
                'severity' => 'warning',
                'message' => 'Justification starts with “Option [letter] is …”; use concise reviewer-style reasoning without option-letter meta-framing.',
                'field' => 'justification',
            ];
        }
        $wcJ = sbaStyleWordCount($justification);
        if ($wcJ > 120) {
            $warnings[] = [
                'code' => 'mla.justification_over_120_words',
                'severity' => 'warning',
                'message' => 'Justification exceeds 120 words (' . $wcJ . '); keep reviewer notes concise (often about 3–4 sentences) rather than teaching paragraphs.',
                'field' => 'justification',
            ];
        }
    }

    $phraseChecks = [
        ['mla.phrase.option_best_choice', '/\boption\s+[a-e]\s+is\s+the\s+best\s+choice\b/i', '“Option [letter] is the best choice …”'],
        ['mla.phrase.this_patient_experiencing', '/\bthis\s+patient\s+is\s+experiencing\b/i', '“This patient is experiencing …”'],
        ['mla.phrase.directly_addresses', '/\bdirectly\s+addresses\b/i', '“directly addresses”'],
        ['mla.phrase.underlying_issue', '/\bunderlying\s+issue\b/i', '“underlying issue”'],
    ];
    foreach (['stem' => $stem, 'lead_in' => $leadIn, 'justification' => $justification] as $field => $text) {
        if ($text === '') {
            continue;
        }
        foreach ($phraseChecks as [$code, $rx, $label]) {
            if (preg_match($rx, $text)) {
                $warnings[] = [
                    'code' => $code,
                    'severity' => 'warning',
                    'message' => 'Avoid formulaic MLA/meta phrasing such as ' . $label . ' in the ' . str_replace('_', ' ', $field) . '.',
                    'field' => $field,
                ];
            }
        }
    }
    foreach (['stem' => $stem, 'lead_in' => $leadIn] as $field => $text) {
        if ($text !== '' && preg_match('/\bthe\s+vignette\s+presents\b/i', $text)) {
            $warnings[] = [
                'code' => 'mla.phrase.vignette_presents',
                'severity' => 'warning',
                'message' => 'Avoid formulaic MLA/meta phrasing such as “The vignette presents …” in the ' . str_replace('_', ' ', $field) . '.',
                'field' => $field,
            ];
        }
    }

    return sbaStyleDedupeWarnings($warnings);
}

/**
 * Count context categories in the stem (faculty MLA heuristic): setting, PMH, drug history,
 * social history, observations, investigations. Warn when more than three appear together.
 */
function sbaStyleMlaStemContextCategoryCount(string $stem): int
{
    $n = 0;
    if (preg_match(
        '/\b(?:attends|attended|presents to|presented to|seen in|reviewed in|admitted to|discharged from)\s+(?:the\s+)?(?:ed|emergency\s+department|emergency\s+room|gp|general\s+practice|ward|clinic|amacu|amu|sdu)\b|\b(?:in|on)\s+(?:the\s+)?(?:ward|trolley|coronary\s+care)\b|\b(?:nursing|care)\s+home\b|\bhome\s+visit\b|\bcommunity\s+(?:nurse|pharmacy)\b/i',
        $stem
    )) {
        $n++;
    }
    if (preg_match(
        '/\b(?:past\s+medical|PMH|comorbid|comorbidity|previous\s+(?:MI|stroke|DVT|PE)|known\s+(?:heart\s+failure|COPD|CKD|IHD)|history\s+of\s+(?:hypertension|diabetes|asthma|COPD|stroke))\b/i',
        $stem
    ) || preg_match('/\bhas\s+(?:type\s+[12]\s+)?diabetes|has\s+asthma|has\s+hypertension|has\s+COPD|has\s+heart\s+failure|has\s+CKD\b/i', $stem)) {
        $n++;
    }
    if (preg_match(
        '/\b(?:regular\s+medication|medication\s+history|drug\s+history|prescription|takes\s+(?:\w+\s+){0,4}(?:mg|mcg|micrograms|daily|once\s+daily|twice\s+daily|od|bd|tds|qds)|on\s+(?:warfarin|aspirin|metformin|prednisolone|bisoprolol|ramipril|amoxicillin|salbutamol))\b/i',
        $stem
    )) {
        $n++;
    }
    if (preg_match(
        '/\b(?:occupation|works\s+as|unemployed|retired|smokes?\b|alcohol|units\s+per\s+week|lives\s+alone|recent\s+travel|social\s+history)\b/i',
        $stem
    )) {
        $n++;
    }
    if (preg_match(
        '/\b(?:temperature\s+is|pulse\s+is|blood\s+pressure\s+(?:is|of)|respiratory\s+rate|oxygen\s+saturation|SpO2|RR\s*\d|HR\s*\d|BP\s*\d{2,3}\s*\/|observations?\s+(?:are|is|show))\b/i',
        $stem
    )) {
        $n++;
    }
    if (preg_match(
        '/\b(?:mmol\/L|U\/L|µmol|μmol|troponin|creatinine|haemoglobin|platelets?|WCC|ECG\s+(?:shows|demonstrates)|plain\s+X-?ray|CT\s|MRI|ultrasound|serum\s+amylase|serum\s+lipase)\b/i',
        $stem
    ) || preg_match('/\d\s*[-–]\s*\d\s*(?:U\/L|mmol\/L)/i', $stem)) {
        $n++;
    }

    return $n;
}

function sbaStyleMlaOptionLooksInvestigationLike(string $opt): bool
{
    $o = trim($opt);
    if ($o === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:ECG|electrocardiogram|echocardiogram|troponin|TSH|free\s+T4|U&Es|U\/Es|FBC|full\s+blood|CT\s|MRI\b|ultrasound|CXR|chest\s+X-?ray|spirometry|colonoscopy|gastroscopy|endoscopy|lumbar\s+puncture|blood\s+culture|(?:high-)?sensitivity\s+cTn|D-?dimer|amylase|lipase|HbA1c|HbA1C|PET(?:\s|-)?CT)\b/i',
        $o
    );
}

function sbaStyleMlaOptionLooksManagementLike(string $opt): bool
{
    $o = trim($opt);
    if ($o === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:IV\s|intravenous|oral\s+(?:antibiotic|steroid|prednisolone|glucose|morphine)|surgery|admission|discharge|refer(?:ral)?\s+to|PCI|thrombolysis|antibiotics?|broad-?spectrum|oxygen|fluid(?:s)?|bolus|insulin|glucagon|dialysis|intubat|CPR|resuscitat)\b/i',
        $o
    );
}

/**
 * Heuristic: option reads like a parallel diagnosis label (not investigation or management wording).
 */
function sbaStyleMlaOptionLooksDiagnosisLike(string $opt): bool
{
    $o = trim($opt);
    if ($o === '' || mb_strlen($o) > 140) {
        return false;
    }
    if (sbaStyleMlaOptionLooksInvestigationLike($o) || sbaStyleMlaOptionLooksManagementLike($o)) {
        return false;
    }

    if (preg_match(
        '/\b(?:syndrome|failure|pneumonia|bronchitis|asthma|migraine|cellulitis|meningitis|gastroenteritis|pancreatitis|cholecystitis|appendicitis|sepsis|shock|angina|infarction|acidosis|deficiency|insufficiency|neuropathy|retinopathy|nephropathy)\b/i',
        $o
    )) {
        return true;
    }
    if (preg_match('/\b(?:ACS|MI|DVT|PE|COPD|UTI|AKI|CKD|CAP|HAP|TIA|CVA|DKA|HHS|GORD|GERD)\b/i', $o)) {
        return true;
    }
    if (preg_match('/^(?:acute|subacute|chronic|primary|secondary|typical|atypical)\s+\w+/i', $o)) {
        return true;
    }

    return false;
}

/**
 * Lead-in suggests an investigation / test task (not generic “next step” alone).
 */
function sbaStyleLeadInSuggestsInvestigationTask(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:investigation|which\s+(?:test|investigation)|initial\s+(?:investigation|test)|next\s+(?:investigation|test)|further\s+investigation|confirmatory|monitoring\s+(?:test|blood)|appropriate\s+investigation|appropriate\s+(?:initial\s+)?(?:investigation|test)|most\s+appropriate\s+(?:initial\s+)?(?:investigation|test)|best\s+(?:initial\s+)?(?:investigation|test))\b/i',
        $leadIn
    );
}

/**
 * Lead-in suggests management, treatment, prescribing, or immediate/next-step care tasking.
 */
function sbaStyleLeadInSuggestsManagementTask(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:management|treat(?:ment)?|therap(?:y|ies)|prescrib|prescription|which\s+drug|which\s+medication|which\s+antibiotic|initial\s+management|immediate\s+(?:management|treatment)|first\s+(?:line\s+)?(?:treatment|management|action)|next\s+step|most\s+appropriate\s+(?:management|treatment|therapy))\b/i',
        $leadIn
    );
}

/**
 * Normalise answer text for batch “same concept” comparison.
 */
function sbaStyleNormalizeAnswerConceptForBatch(string $text): string
{
    $t = mb_strtolower(trim(preg_replace('/\s+/u', ' ', $text)));
    $t = (string) preg_replace('/[^\p{L}\p{N}\s]/u', '', $t);

    return trim($t);
}

function sbaStyleBatchIsManagementEmergencySkillPair(string $currentSkillNorm, string $priorSkillNorm): bool
{
    return (preg_match('/^management$/i', $currentSkillNorm) && preg_match('/^emergency\s+management$/i', $priorSkillNorm))
        || (preg_match('/^emergency\s+management$/i', $currentSkillNorm) && preg_match('/^management$/i', $priorSkillNorm));
}

/**
 * Tutor explicitly allows repeating the same keyed concept across batch items.
 *
 * @param array<string, mixed> $question
 */
function sbaStyleTutorAllowsRepeatBatchAnswerConcept(array $question): bool
{
    $tutor = trim((string) ($question['_tutor_comments'] ?? ''));
    if ($tutor === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:repeat|both|\bsame\b|duplicate|twice).{0,100}(?:answer|concept|key|correct)|(?:management|emergency\s+management).{0,50}(?:repeat|both|\bsame\b)\b/i',
        $tutor
    );
}

/**
 * Broad treatment family for batch duplicate detection (Management vs Emergency Management).
 *
 * @return ''|'antibiotics'|'aspirin'|'iv_fluids'|'oxygen'|'reperfusion_pci'
 */
function sbaStyleBatchTreatmentConceptFamily(string $text): string
{
    if ($text === '') {
        return '';
    }
    if (sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($text)) {
        return 'sepsis_resuscitation_bundle';
    }
    if (sbaStyleTextSuggestsAspirinKeyedAction($text)) {
        return 'aspirin';
    }
    if (sbaStyleKeyedMentionsSepsisAntibioticsOrEmpiricalTherapy($text)
        || preg_match(
            '/\b(?:broad[\s-]?spectrum\s+)?(?:iv\s+|intravenous\s+)?antibiotics?\b|\bempirical\s+(?:iv\s+)?antimicrobial\b|\bantimicrobial\s+therapy\b/i',
            $text
        )) {
        return 'antibiotics';
    }
    if (sbaStyleTextSuggestsIvFluidResuscitation($text)) {
        return 'iv_fluids';
    }
    if (sbaStyleTextSuggestsPrimaryPciReperfusionOrCardiologyPathwayKey($text)) {
        return 'reperfusion_pci';
    }
    if (preg_match(
        '/\b(?:oxygen\s+therapy|supplemental\s+oxygen|high[\s-]?flow\s+(?:nasal\s+)?oxygen|humidified\s+oxygen)\b/i',
        $text
    ) || (preg_match('/\boxygen\b/i', $text) && preg_match('/\b(?:therapy|administ|give|start)\b/i', $text))) {
        return 'oxygen';
    }

    return '';
}

function sbaStyleBatchTreatmentFamilyDisplayLabel(string $family): string
{
    return match ($family) {
        'antibiotics' => 'IV / broad-spectrum antibiotics',
        'aspirin' => 'aspirin / immediate antiplatelet',
        'iv_fluids' => 'IV fluid resuscitation',
        'oxygen' => 'oxygen therapy',
        'reperfusion_pci' => 'urgent reperfusion / PCI / cardiology pathway',
        'sepsis_resuscitation_bundle' => 'combined sepsis resuscitation (IV fluids plus IV antibiotics / bundle care)',
        default => 'the same treatment priority',
    };
}

function sbaStyleBatchSepsisBundleVersusAntibioticsOnlyNotDuplicate(string $currentCorrect, string $priorCorrect): bool
{
    if (sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($currentCorrect)
        && sbaStyleKeyedAnswerIsAntibioticsOnlyWithoutFluidResuscitation($priorCorrect)) {
        return true;
    }
    if (sbaStyleKeyedAnswerSuggestsSepsisCombinedResuscitationBundle($priorCorrect)
        && sbaStyleKeyedAnswerIsAntibioticsOnlyWithoutFluidResuscitation($currentCorrect)) {
        return true;
    }

    return false;
}

function sbaStyleBatchManagementEmergencyConceptsDuplicate(string $currentCorrect, string $priorCorrect): bool
{
    if (sbaStyleBatchSepsisBundleVersusAntibioticsOnlyNotDuplicate($currentCorrect, $priorCorrect)) {
        return false;
    }
    $normCur = sbaStyleNormalizeAnswerConceptForBatch($currentCorrect);
    $normPrior = sbaStyleNormalizeAnswerConceptForBatch($priorCorrect);
    if ($normCur !== '' && $normCur === $normPrior) {
        return true;
    }
    $famCur = sbaStyleBatchTreatmentConceptFamily($currentCorrect);
    $famPrior = sbaStyleBatchTreatmentConceptFamily($priorCorrect);

    return $famCur !== '' && $famCur === $famPrior;
}

/**
 * @return non-empty-string
 */
function sbaStyleBatchManagementEmergencyDuplicateMessage(
    string $currentCorrect,
    string $priorCorrect,
    string $priorSkill,
    string $currentSkill
): string {
    $family = sbaStyleBatchTreatmentConceptFamily($currentCorrect);
    $familyLabel = $family !== '' ? sbaStyleBatchTreatmentFamilyDisplayLabel($family) : 'the same treatment concept';

    return '[High priority] **Management** and **Emergency Management** in this batch both key '
        . $familyLabel
        . ' (current: “'
        . $currentCorrect
        . '”; earlier **'
        . $priorSkill
        . '**: “'
        . $priorCorrect
        . '”). Choose a **different** correct-answer concept for **'
        . $currentSkill
        . '** unless Tutor comments explicitly allow repetition (e.g. if Management used antibiotics only, Emergency Management may use a **combined sepsis resuscitation** line with IV fluids plus IV antibiotics, oxygen if hypoxic, urgent escalation/critical care review, or source-control escalation — not fluids-only as a false alternative to antibiotics).';
}

/**
 * Normalise lead-in for batch duplicate-pattern detection.
 */
function sbaStyleNormalizeLeadInForBatch(string $leadIn): string
{
    return mb_strtolower(trim(preg_replace('/\s+/u', ' ', $leadIn)));
}

/**
 * Heuristic: asthma-style reliever + nocturnal + ICS/preventer cue cluster in prose.
 */
function sbaStyleTextHasAsthmaRelieverNightIcsCluster(string $blob): bool
{
    if ($blob === '') {
        return false;
    }
    $b = mb_strtolower($blob);
    $reliever = (bool) preg_match(
        '/\b(reliever|salbutamol|ventolin|saba|more\s+frequent\s+inhaler|using\s+(?:her|his|their)\s+inhaler\s+more|increased\s+use\s+of)\b/i',
        $b
    );
    $night = (bool) preg_match(
        '/\b(night|nocturnal|wakes?\s+(?:at|in)|sleep\s+disturbed|early\s+morning|symptoms?\s+at\s+night)\b/i',
        $b
    );
    $ics = (bool) preg_match(
        '/\b(low[\s-]?dose\s+)?(?:inhaled\s+corticosteroid|ics|beclometasone|budesonide|fluticasone|ciclesonide|mometasone|preventer)\b/i',
        $b
    );

    return $reliever && $night && $ics;
}

/**
 * Reliever / SABA use called out as a clinical cue (batch repetition heuristic).
 */
function sbaStyleTextHasRelieverInhalerKeyCue(string $text): bool
{
    if ($text === '') {
        return false;
    }
    $b = mb_strtolower($text);

    return (bool) preg_match(
        '/\b(?:reliever|salbutamol|ventolin|saba|blue\s+inhaler)\s+(?:use|usage|frequency|requirement|inhalations?|puffs?)\b|\b(?:increased|more|frequent|excessive|daily|regular)\s+(?:use\s+of\s+)?(?:the\s+)?(?:blue\s+)?(?:reliever|salbutamol|ventolin|saba)\b|\busing\s+(?:his|her|their)\s+(?:reliever|blue\s+inhaler|salbutamol|ventolin)\s+more\b|\b(?:reliever|saba)\s+(?:over)?use\b/i',
        $b
    );
}

/**
 * Investigation stem already documents objective asthma-related findings (initial investigation should not).
 */
function sbaStyleInvestigationStemContainsPrematureObjectiveAsthmaData(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    $s = mb_strtolower($stem);
    if (preg_match('/\b(?:peak\s+(?:expiratory\s+)?flow|pef)\b/i', $s)
        && preg_match('/\b(?:variability|variation|variable|diurnal|morning\s+and\s+evening|>\s*20\s*%|greater\s+than\s+20|twenty\s+percent|20\s*%)\b/i', $s)) {
        return true;
    }
    if (preg_match('/\b(?:pef|peak\s+flow)\b/i', $s) && preg_match('/\b\d{2,4}\s*(?:l\/min|l\s*min)?\b/i', $s)
        && preg_match('/\b(?:and|\/|to|-|,|versus|vs\.?)\b/i', $s) && preg_match('/\b\d{2,4}\b/', $s)) {
        return true;
    }
    if (preg_match('/\b(?:fev1|fvc|fev1\s*\/\s*fvc)\b/i', $s) && preg_match('/\d/', $s)) {
        return true;
    }
    if (preg_match('/\bspirometry\b/i', $s) && preg_match('/\b(?:obstructive|restrictive|reversib|post[\s-]?(?:bd|bronchodilator)|after\s+salbutamol)\b/i', $s)) {
        return true;
    }
    if (preg_match('/\b(?:post[\s-]?(?:bd|bronchodilator)|bronchodilator\s+reversibility|reversibility\s+(?:testing|of))\b/i', $s) && preg_match('/\d/', $s)) {
        return true;
    }

    return false;
}

/**
 * Interpretation stem narrates a test without giving a numeric or scored result to interpret.
 */
function sbaStyleInterpretationStemMentionsTestWithoutNumericResult(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    $s = $stem;
    $lower = mb_strtolower($s);
    if (!preg_match('/\b(?:peak\s+flow|pef|spirometry|spirometric|bronchodilator\s+reversibility|reversibility\s+test)\b/i', $lower)) {
        return false;
    }
    if (preg_match('/\b(?:peak\s+flow|pef)\b/i', $lower)
        && preg_match('/\b(?:is\s+)?(?:performed|undertaken|done|arranged|obtained|recorded|measured|taken)\b/i', $lower)) {
        if (preg_match('/\b\d+(?:\.\d+)?\s*(?:l\/min|l\s*min|%|points?)\b/i', $s)) {
            return false;
        }
        if (preg_match('/\b(?:readings?|values?|results?)\s+(?:of|were|was)\s+\d/i', $lower)) {
            return false;
        }
        if (preg_match('/\b(?:morning|evening|best|low)\s+(?:pef|peak)\s+(?:is|of|was)\s+\d/i', $lower)) {
            return false;
        }

        return true;
    }
    if (preg_match('/\bspirometry\b/i', $lower) && preg_match('/\b(?:performed|undertaken|arranged|obtained)\b/i', $lower)) {
        if (preg_match('/\bfev1\b[^.]{0,120}\d/i', $lower) || preg_match('/\bfvc\b[^.]{0,120}\d/i', $lower) || preg_match('/\d\s*%\s*(?:predicted|of\s+predicted|pred)/i', $lower)) {
            return false;
        }

        return true;
    }

    return false;
}

/**
 * Stem includes enough cues to grade exacerbation severity (avoid overlapping mild vs severe labels without context).
 */
function sbaStyleStemHasExacerbationSeverityCriteria(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:PEF|peak\s+flow).{0,40}(?:%|percent|predicted|best)|\bSpO2\b|\b(?:respiratory\s+rate|RR|heart\s+rate|pulse)\b.{0,20}(?:>|greater\s+than|\d{2,3}\s*(?:\/|per)\s*min)/i',
        $stem
    ) || (bool) preg_match(
        '/\b(?:unable\s+to\s+complete\s+sentences?|silent\s+chest|cyanosis|accessory\s+muscle|altered\s+conscious(?:ness)?|p\.?\s*co2|pCO2|GINA|NEWS|news\s+score|pH\s*[<≤]|blood\s+gas)\b/i',
        $stem
    );
}

/**
 * Interpretation options include both mild/moderate and severe exacerbation-style labels.
 *
 * @param list<string> $opts
 */
function sbaStyleInterpretationOptionsSpanExacerbationSeverity(array $opts): bool
{
    if ($opts === []) {
        return false;
    }
    $b = mb_strtolower(implode("\n", $opts));
    $hasMildLike = (bool) preg_match(
        '/\b(?:mild|moderate)\b.{0,50}\bexacerbat|\bexacerbat.{0,50}\b(?:mild|moderate)\b/i',
        $b
    );
    $hasSevereLike = (bool) preg_match(
        '/\b(?:severe|life[\s-]threatening|critical)\b.{0,50}\bexacerbat|\bexacerbat.{0,50}\b(?:severe|life[\s-]threatening)\b|\bacute\s+severe\b/i',
        $b
    );

    return $hasMildLike && $hasSevereLike;
}

/**
 * Monitoring lead-in names a concrete monitoring target (not a generic “what to review”).
 */
function sbaStyleMonitoringLeadInHasSpecificTarget(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:peak\s+flow|pef|variability|variable\s+airflow|objective\s+home|home\s+monitoring|inhaler\s+technique|device\s+technique|spacer|reliever|saba|symptom[\s-]?control|day[\s-]?to[\s-]?day\s+symptom|ACT\b|asthma\s+control\s+test|control\s+score|adherence|lung\s+function|spirometry\s+(?:review|monitoring)|exacerbat(?:ion)?\s+history)\b/i',
        $leadIn
    );
}

/**
 * Justification narrative may align more with a different interpretation than the keyed answer (heuristic).
 */
function sbaStyleInterpretationJustificationMayFavorDifferentKey(string $justification, string $correctAnswer): bool
{
    if ($justification === '' || $correctAnswer === '') {
        return false;
    }
    $j = mb_strtolower($justification);
    $c = mb_strtolower($correctAnswer);

    $jPoorControl = (bool) preg_match(
        '/\b(?:poor(?:ly)?\s+controlled|poor\s+asthma\s+control|not\s+well\s+controlled|inadequate\s+control|suboptimal\s+control|uncontrolled\s+asthma)\b/i',
        $j
    );
    $jVariability = (bool) preg_match(
        '/\b(?:significant|marked|substantial)\s+(?:peak\s*[-\s]?flow\s+)?variability|peak\s*[-\s]?flow\s+variability|diurnal\s+variation|variable\s+(?:obstruction|airflow)\b/i',
        $j
    );

    $cPoorControl = (bool) preg_match(
        '/\b(?:poor\s+asthma\s+control|poor\s+control|not\s+well\s+controlled|inadequately\s+controlled)\b/i',
        $c
    );
    $cVariability = (bool) preg_match(
        '/\bvariability|variable\s+(?:peak|pef|readings|airflow)|diurnal\s+variation\b/i',
        $c
    );

    if ($jPoorControl && $cVariability && !$cPoorControl) {
        return true;
    }
    if ($jVariability && $cPoorControl && !$cVariability) {
        return true;
    }

    return false;
}

/**
 * Stem/justification text includes enough numeric or rule-based support for words like "significant".
 */
function sbaStyleBlobSupportsSignificantIntensityQualifier(string $blob): bool
{
    if ($blob === '') {
        return false;
    }

    return (bool) preg_match(
        '/[>≥]\s*\d+\s*%|\b(?:greater\s+than|more\s+than|at\s+least)\s+\d+\s*%|\b(?:twenty|twelve|fifteen)\s*(?:%|percent)\b|\b\d+\s*(?:ml|mL|L\/min|l\/min)\b.{0,100}\b\d+\s*(?:ml|mL|L\/min|l\/min)\b|\bratio\s+(?:of\s+)?[\d.]|\bpost[\s-]?(?:bd|bronchodilator).{0,80}\d|\bFE[vV]1\b.{0,50}\d|\bFVC\b.{0,50}\d|predicted|percentile|\bp\s*[<≤]\s*0\.\d|\b(?:doubling|three[\s-]?fold|≥\s*200\s*ml|200\s*ml)\b/i',
        $blob
    );
}

/**
 * Two+ interpretation options duplicate poor-control wording or overlapping reversible/variable obstruction labels.
 *
 * @param list<string> $opts
 */
function sbaStyleInterpretationOptionsOverlapPoorControlOrAirflow(array $opts): bool
{
    $lines = [];
    foreach ($opts as $t) {
        $t = trim($t);
        if ($t !== '') {
            $lines[] = $t;
        }
    }
    if (count($lines) < 2) {
        return false;
    }
    $poor = 0;
    foreach ($lines as $o) {
        if (preg_match('/\b(?:poor|suboptimal)\s+(?:asthma\s+)?control|not\s+well\s+controlled|inadequate\s+control\b/i', $o)) {
            ++$poor;
        }
    }
    if ($poor >= 2) {
        return true;
    }
    $varAir = 0;
    foreach ($lines as $o) {
        $l = mb_strtolower($o);
        if (preg_match('/\b(?:variable|reversible)\s+(?:airflow\s+)?obstruction|variable\s+airflow|reversible\s+airflow\s+limitation|obstructive\s+pattern\s+with\s+revers/i', $l)) {
            ++$varAir;
        }
    }

    return $varAir >= 2;
}

/**
 * Monitoring options together cover symptom control, technique, adherence and lung-function review themes.
 *
 * @param list<string> $opts
 */
function sbaStyleMonitoringOptionsSpanFourReviewThemes(array $opts): bool
{
    $blob = mb_strtolower(implode("\n", $opts));
    if ($blob === '') {
        return false;
    }
    $sym = (bool) preg_match(
        '/\b(?:symptom[\s-]?control|asthma\s+control|ACT\b|c[\s-]?act|day[\s-]?to[\s-]?day|daily\s+symptom|reliever|rescue\s+inhaler|symptom\s+diary)\b/i',
        $blob
    );
    $tec = (bool) preg_match(
        '/\b(?:inhaler\s+technique|pmdi|device\s+technique|spacer|actuation)\b/i',
        $blob
    );
    $adh = (bool) preg_match('/\b(?:adher|compli)\w*\b/i', $blob);
    $lung = (bool) preg_match(
        '/\b(?:spirometry|lung\s+function|peak\s+flow|pef\b|fev1|fvc)\b/i',
        $blob
    );

    return $sym && $tec && $adh && $lung;
}

/**
 * Option reads as a generic monitoring / review line (weak discrimination if most options match).
 */
function sbaStyleOptionLooksGenericMonitoringReviewActivity(string $opt): bool
{
    $o = mb_strtolower(trim($opt));
    if ($o === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:annual\s+(?:review|check)|routine\s+(?:review|follow[-\s]?up)|regular\s+review|review\s+(?:clinic|appointment)|check(?:ing)?\s+inhaler\s+technique|inhaler\s+technique\s+(?:review|check|assessment)|peak\s+flow\s+(?:diary|monitoring|recording|chart|review)|(?:symptom|control)\s+(?:diary|score|assessment|review)|self[\s-]?management\s+(?:education|review)|personal(?:ised|ized)?\s+asthma\s+action\s+plan|p\.?a\.?a\.?p\.?\s*(?:review)?|act\s+questionnaire|assess\s+adherence|adherence\s+review|smoking\s+cessation\s+advice|vaccination\s+status)\b/i',
        $o
    );
}

/**
 * Stem includes a cue that should favour one monitoring answer over interchangeable generic reviews.
 */
function sbaStyleStemHasMonitoringDiscriminatingCue(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    $s = mb_strtolower($stem);

    return (bool) preg_match(
        '/\b(?:poor|incorrect|suboptimal|unsatisfactory|difficult(?:ies)?|difficulty|unable\s+to\s+coordinate)\s+(?:with\s+)?(?:the\s+)?(?:inhaler|pmdi|device|actuation|spacer)|\binhaler\s+technique\s+(?:is\s+)?(?:poor|suboptimal|incorrect)|\b(?:high|frequent|excessive|increased|daily)\s+(?:reliever|saba|salbutamol|ventolin|short[\s-]?acting)\b|\b(?:variable|marked\s+variability|day[\s-]to[\s-]day)\s+(?:symptoms|peak|readings|pef)|\b(?:act|asthma\s+control\s+test)\b\s*(?:score)?\s*(?:of|is|=|:)?\s*\d|\b(?:c[-\s]?act)\b[^.]{0,40}\d|\b(?:night|nocturnal)\s+(?:symptoms|waking|wakes|disturbance)|\b(?:poor|loss\s+of)\s+control\b|\b(?:serial|home)\s+peak\s+flow\s+(?:readings|recorded|shows)\b/i',
        $s
    );
}

/**
 * Interpretation skill: lead-in asks for follow-up, review, next step, or management instead of interpreting supplied data.
 */
function sbaStyleInterpretationLeadInSuggestsMonitoringOrManagementTask(string $leadIn): bool
{
    if ($leadIn === '') {
        return false;
    }
    $l = $leadIn;

    return (bool) preg_match(
        '/\bfollow[\s-]?up\s+measure\b|\bmost\s+useful\s+follow[\s-]?up\b|\bwhat\s+should\s+be\s+reviewed\b|\bwhat\s+to\s+review\b|\bwhich\s+.{0,80}\b(?:should\s+)?(?:be\s+)?reviewed\b|\bnext\s+step\b|\bwhat\s+(?:is\s+the\s+)?(?:appropriate\s+)?management\b|\bwhich\s+management\b|\b(?:pharmacological\s+)?management\s+(?:plan|step|option)\b|\b(?:initial\s+)?treatment\s+(?:plan|option|step)\b|\bwhich\s+treatment\b|\bwhat\s+treatment\b|\bmonitor\s+at\s+follow|\breview\s+at\s+follow[\s-]?up\b/i',
        $l
    );
}

/**
 * Option or keyed answer starts with a verb that signals an action task (wrong family for Interpretation).
 */
function sbaStyleTextStartsWithInterpretationBannedActionVerb(string $text): bool
{
    $t = trim($text);
    if ($t === '') {
        return false;
    }
    $t = preg_replace('/^(?:[a-e]\s*[.)]\s*)/i', '', $t);
    $t = preg_replace('/^(?:to\s+)/i', '', $t);

    return (bool) preg_match(
        '/^(?:assess|review|check|perform|increase|start|prescribe|refer|monitor|add)\b/i',
        $t
    );
}

/**
 * True if this job looks asthma-focused (current or prior vignettes mention asthma).
 *
 * @param array<string, mixed> $question
 * @param list<array<string, mixed>> $priors
 */
function sbaStyleBatchHasAsthmaMention(array $question, array $priors): bool
{
    $cond = mb_strtolower(trim((string) ($question['_condition_name'] ?? '')));
    if (strpos($cond, 'asthma') !== false) {
        return true;
    }
    $blob = mb_strtolower(trim((string) ($question['stem'] ?? '')) . ' ' . trim((string) ($question['lead_in'] ?? '')));
    if (preg_match('/\basthma\b/i', $blob)) {
        return true;
    }
    foreach ($priors as $p) {
        if (!is_array($p)) {
            continue;
        }
        $pb = mb_strtolower(trim((string) ($p['stem'] ?? '')) . ' ' . trim((string) ($p['lead_in'] ?? '')));
        if (preg_match('/\basthma\b/i', $pb)) {
            return true;
        }
    }

    return false;
}

/**
 * Treatment escalation wording inappropriate as Monitoring keyed content.
 */
function sbaStyleTextHasMonitoringTreatmentEscalation(string $text): bool
{
    if ($text === '') {
        return false;
    }
    $t = mb_strtolower($text);

    if (preg_match('/\badd\s+(?:a\s+)?laba\b/i', $t)) {
        return true;
    }
    if (preg_match('/\blaba\b/i', $t) && preg_match('/\b(?:add|adding|introduce|start|commence|step\s*\-?up)\b/i', $t)) {
        return true;
    }
    if (preg_match('/\b(?:increase|increasing|double)\s+(?:the\s+)?(?:dose\s+of\s+)?(?:inhaled\s+corticosteroid|ics|preventer)\b/i', $t)) {
        return true;
    }
    if (preg_match('/\bstep\s*\-?up\s+(?:to\s+)?(?:laba|combination|therapy)\b/i', $t)) {
        return true;
    }
    if (preg_match('/\b(?:start|commence)\s+(?:a\s+)?(?:course\s+of\s+)?(?:oral\s+)?(?:corticosteroids?|prednisolone)\b/i', $t)) {
        return true;
    }

    return false;
}

/**
 * Stem appears to embed an exam question (should live in lead-in only).
 */
function sbaStyleStemContainsEmbeddedExamQuestion(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (sbaStyleStemContainsMisplacedQuestionPhrasing($stem)) {
        return true;
    }

    if ((bool) preg_match(
        '/\b(?:which|what)\s+is\s+the\s+(?:most\s+appropriate\s+)?(?:initial\s+)?(?:investigation|test|next\s+step)\b/i',
        $stem
    )) {
        return true;
    }

    return (bool) preg_match(
        '/\b(?:appropriate\s+)?next\s+investigation\s+(?:is|would|should)\b/i',
        $stem
    );
}

/**
 * Richer heuristic: stem contains material candidates can interpret.
 */
function sbaStyleStemHasInterpretableDataRich(string $stem): bool
{
    if ($stem === '') {
        return false;
    }

    return (bool) preg_match(
        '/\d|\b(?:fev1|fev\s*1|fev1\/|fvc|fev|pefr|peak\s+flow|mmol|µmol|μmol|u\/l|\/l\b|spirom|predicted|percentile|control\s+score|ACT|CAT|ecg|e\.?c\.?g\.?|qt|st\s+segment|troponin|creatinine|haemoglobin|hb\b|wcc|platelet|ct\s|mri|chest\s+x-?ray|cxr|ultrasound|scorecard|diary|chart|table\s+of)\b/iu',
        $stem
    );
}

/**
 * Option reads like an interpretation label (not pure management).
 */
function sbaStyleOptionLooksInterpretationStyle(string $opt): bool
{
    $o = mb_strtolower(trim($opt));
    if ($o === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:consistent\s+with|suggests|indicates|implies|compatible\s+with|most\s+likely\s+reflects|shows|reveals|demonstrates|pattern\s+of|degree\s+of|severity\s+of|obstruction|restrictive|mixed\s+defect|normal\s+spirometry|poor\s+control|well\s+controlled)\b/i',
        $o
    );
}

/**
 * Option reads like a monitoring / follow-up workflow step rather than interpretation of data.
 */
function sbaStyleOptionLooksMonitoringWorkflow(string $opt): bool
{
    $o = mb_strtolower(trim($opt));
    if ($o === '') {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:annual\s+review|review\s+clinic|check\s+inhaler\s+technique|inhaler\s+technique|peak\s+flow\s+diary|repeat\s+spirometry\s+in\s+12|monitoring\s+visit|follow[-\s]?up\s+appointment|self[-\s]?management\s+education|paap|asthma\s+action\s+plan\s+review)\b/i',
        $o
    );
}

/**
 * Asthma diagnosis vignette: acute 1–3 day window only without recurrent/variable wording.
 */
function sbaStyleAsthmaDiagnosisStemLooksAcuteShortWindowOnly(string $stem, string $conditionName): bool
{
    if (stripos($conditionName, 'asthma') === false && !preg_match('/\basthma\b/i', $stem)) {
        return false;
    }
    $s = mb_strtolower($stem);
    $acuteWindow = (bool) preg_match(
        '/\b(?:for|over|in)\s+(?:the\s+)?(?:past|last)\s*(?:1|2|3|one|two|three)\s*days?\b/i',
        $s
    ) || (bool) preg_match('/\b(?:since\s+yesterday|since\s+last\s+night|suddenly\s+since)\b/i', $s);
    $recurrent = (bool) preg_match(
        '/\b(?:recurrent|recurring|variable|intermittent|on\s+and\s+off|several\s+episodes?|weeks|months|chronic|frequent)\b/i',
        $s
    );

    return $acuteWindow && !$recurrent;
}

/**
 * Two investigation options overlap as spirometry vs post-BD / reversibility testing.
 *
 * @param list<string> $invOpts investigation-like option texts (>=2)
 */
function sbaStyleInvestigationOptionsHaveSpirometryReversibilityOverlap(array $invOpts): bool
{
    if (count($invOpts) < 2) {
        return false;
    }
    $n = count($invOpts);
    for ($i = 0; $i < $n; $i++) {
        for ($j = $i + 1; $j < $n; $j++) {
            $a = mb_strtolower($invOpts[$i]);
            $b = mb_strtolower($invOpts[$j]);
            $spiA = (bool) preg_match('/\bspirometry\b/i', $a);
            $spiB = (bool) preg_match('/\bspirometry\b/i', $b);
            $revA = (bool) preg_match('/\b(?:post[\s-]?(?:bronchodilator)?|bronchodilator\s+reversibility|reversibility\s+testing|after\s+salbutamol)\b/i', $a);
            $revB = (bool) preg_match('/\b(?:post[\s-]?(?:bronchodilator)?|bronchodilator\s+reversibility|reversibility\s+testing|after\s+salbutamol)\b/i', $b);
            if (($spiA && $revB) || ($spiB && $revA)) {
                return true;
            }
            if ($spiA && $spiB && ($revA || $revB)) {
                return true;
            }
        }
    }

    return false;
}

function sbaStyleStemSuggestsMalePatient(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    if (preg_match('/\b(?:woman|women|female|girl)\b/i', $stem)) {
        return false;
    }

    return (bool) preg_match('/\b(?:\d+\s+year\s+old\s+)?man\b|\bmale\s+patient\b/i', $stem);
}

function sbaStyleStemMentionsFemaleOnlyRiskFactor(string $stem): bool
{
    return $stem !== '' && (bool) preg_match(
        '/\b(?:oral\s+contraceptives?|combined\s+oral\s+contraceptives?|oestrogen[\s-]containing|hormone\s+replacement|she\s+is\s+pregnant|pregnant|pregnancy)\b/i',
        $stem
    );
}

function sbaStyleStemExplainsSexRiskFactorContext(string $stem): bool
{
    return $stem !== '' && (bool) preg_match(
        '/\b(?:transgender|assigned\s+female|gender[\s-]affirming|takes\s+oestrogen|hormone\s+therapy)\b/i',
        $stem
    );
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsStemDemographicConsistency(array $question): array
{
    $stem = trim((string) ($question['stem'] ?? ''));
    if ($stem === '') {
        return [];
    }
    if (!sbaStyleStemSuggestsMalePatient($stem) || !sbaStyleStemMentionsFemaleOnlyRiskFactor($stem)) {
        return [];
    }
    if (sbaStyleStemExplainsSexRiskFactorContext($stem)) {
        return [];
    }

    return [[
        'code' => 'stem.demographic_sex_risk_factor_mismatch',
        'severity' => 'high',
        'message' => '[High priority] Stem demographic inconsistency: a **man** is described with a **female-specific risk factor** (e.g. oral contraceptives, pregnancy) without explanation. Align sex and risk factors, or add explicit context if intentional.',
        'field' => 'stem',
    ]];
}

function sbaStyleStemReportsDefinitivePeOnCtpa(string $stem): bool
{
    return $stem !== '' && (bool) preg_match(
        '/\b(?:ct\s+pulmonary\s+angiogram|ct\s+pulmonary\s+angiograph|ctpa).{0,220}\b(?:filling\s+defect|pulmonary\s+embol(?:ism|us)\s+in|embolus|occlusive)\b/i',
        $stem
    );
}

function sbaStyleKeyedAnswerSuggestsPeProbabilityOnly(string $text): bool
{
    if ($text === '') {
        return false;
    }
    if (preg_match('/\b(?:pulmonary\s+embol(?:ism|us)\s+in\s+the|acute\s+pulmonary\s+embol)\b/i', $text)) {
        return false;
    }

    return (bool) preg_match(
        '/\b(?:high|intermediate|moderate|low)\s+probability\s+of\s+pulmonary\s+embol|\blikely\s+pulmonary\s+embol|\bprobable\s+pulmonary\s+embol/i',
        $text
    );
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsInterpretationDefinitiveImagingResult(
    array $question,
    string $stem,
    string $correctAns
): array {
    if (!preg_match('/^interpretation$/i', trim(preg_replace('/\s+/u', ' ', (string) ($question['_current_skill'] ?? ''))))) {
        return [];
    }
    if (!sbaStyleQuestionSuggestsPulmonaryEmbolismCoreScenario($question)) {
        return [];
    }
    if (!sbaStyleStemReportsDefinitivePeOnCtpa($stem) || $correctAns === '') {
        return [];
    }
    if (!sbaStyleKeyedAnswerSuggestsPeProbabilityOnly($correctAns)) {
        return [];
    }

    return [[
        'code' => 'interpretation.pe_definitive_ctpa_probability_key',
        'severity' => 'high',
        'message' => '[High priority] **Interpretation:** the stem reports a **definitive CTPA finding** (e.g. filling defect) but the keyed answer is only **probability/likelihood** wording. Key a **definitive** label such as **“Pulmonary embolism”** (with location if quoted in the report).',
        'field' => 'correct_answer',
    ]];
}

/**
 * @param list<string> $opts
 */
function sbaStyleManagementOptionsHaveAnticoagulationClassOverlap(array $opts): bool
{
    $hasBroad = false;
    $hasSpecific = false;
    foreach ($opts as $t) {
        if ($t === '') {
            continue;
        }
        if (preg_match('/\b(?:lmwh|low[\s-]molecular[\s-]weight\s+heparin|enoxaparin|dalteparin|fondaparinux|heparin\s+injection)\b/i', $t)) {
            $hasSpecific = true;
        }
        if (preg_match('/\b(?:initiate|start|commence)\s+anticoagulation(?:\s+therapy)?\b/i', $t)
            && !preg_match('/\b(?:lmwh|heparin|warfarin|rivaroxaban|apixaban)\b/i', $t)) {
            $hasBroad = true;
        }
    }

    return $hasBroad && $hasSpecific;
}

/**
 * Form generator: stem discipline, interpretation/monitoring/investigation option families.
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high', message: string, field: ?string}>
 */
function sbaStyleWarningsFormSkillStemOptions(array $question): array
{
    $warnings = [];
    $skillRaw = isset($question['_current_skill']) ? trim((string) $question['_current_skill']) : '';
    if ($skillRaw === '') {
        return $warnings;
    }
    $sk = trim(preg_replace('/\s+/u', ' ', $skillRaw));
    $stem = trim((string) ($question['stem'] ?? ''));
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $condition = trim((string) ($question['_condition_name'] ?? ''));
    $tutor = trim((string) ($question["_tutor_comments"] ?? ''));

    $opts = [];
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $opts[] = trim((string) $question[$k]);
        }
    }

    $correctAns = trim((string) ($question['correct_answer'] ?? ''));

    $declSkill = isset($question['skill']) && is_string($question['skill']) ? trim(preg_replace('/\s+/u', ' ', $question['skill'])) : '';
    if ($declSkill !== '' && mb_strtolower($declSkill) !== mb_strtolower($sk)) {
        $warnings[] = [
            'code' => 'skill_alignment.generated_skill_field_mismatch',
            'severity' => 'warning',
            'message' => 'A skill value in the model output does not match the form **Current skill** (' . $skillRaw . '). Use the mandated Current skill only; do not label the item as a different skill in the title or any extra field.',
            'field' => 'title',
        ];
    }

    $title = trim((string) ($question['title'] ?? ''));
    if ($title !== '') {
        if (preg_match('/^management$/i', $sk) && preg_match('/\bprescribing\b/i', $title) && !preg_match('/\bmanagement\b/i', $title)) {
            $warnings[] = [
                'code' => 'skill_alignment.title_prescribing_label_but_skill_is_management',
                'severity' => 'warning',
                'message' => 'Current skill is **Management**, but the title reads like **Prescribing** — retitle to a management framing (e.g. “ACS initial management”) and keep options as management actions, not a prescribing-only item.',
                'field' => 'title',
            ];
        }
        if (preg_match('/^prescribing$/i', $sk) && preg_match('/\bmanagement\b/i', $title) && !preg_match('/\bprescribing\b|\bprescription\b/i', $title)) {
            $warnings[] = [
                'code' => 'skill_alignment.title_management_label_but_skill_is_prescribing',
                'severity' => 'warning',
                'message' => 'Current skill is **Prescribing**, but the title reads like general **Management** only — align the title with a prescribing task (drug choice, dose, route) so the assessed skill matches the form.',
                'field' => 'title',
            ];
        }
    }

    if (preg_match('/^diagnosis$/i', $sk)) {
        if ($stem !== '' && preg_match('/\bno\s+significant\s+past\s+medical\s+history\b/i', $stem)) {
            $warnings[] = [
                'code' => 'skill_alignment.diagnosis_pmh_wording',
                'severity' => 'warning',
                'message' => 'Diagnosis stem uses “no significant past medical history”; prefer **“has no other medical conditions”** or omit if irrelevant.',
                'field' => 'stem',
            ];
        }
        foreach ($opts as $t) {
            if ($t !== '' && (sbaStyleMlaOptionLooksManagementLike($t) || sbaStyleMlaOptionLooksInvestigationLike($t))) {
                $warnings[] = [
                    'code' => 'skill_alignment.diagnosis_non_diagnosis_option',
                    'severity' => 'high',
                    'message' => 'Skill is **Diagnosis**, but at least one option reads as **management** or **investigation** rather than a diagnosis label.',
                    'field' => 'options',
                ];
                break;
            }
        }
        if ($stem !== '' && preg_match('/\b(?:with|known)\s+asthma\b|\bhas\s+asthma\b|\bestablished\s+asthma\b/i', $stem)) {
            $warnings[] = [
                'code' => 'skill_alignment.diagnosis_stem_established_asthma',
                'severity' => 'warning',
                'message' => 'Skill is Diagnosis, but the stem appears to establish that the patient already has asthma (“with asthma”, “known asthma”, etc.). For asthma diagnosis items, keep the stem undiagnosed and cue recurrent or variable symptoms instead.',
                'field' => 'stem',
            ];
        }
        $asthmaCore = stripos($condition, 'asthma') !== false || preg_match('/\basthma\b/i', $stem);
        $tutorExacerbationOk = $tutor !== '' && preg_match(
            '/\b(?:exacerbation|acute\s+exacerbation|severe\s+asthma\s+attack|attack\s+as\s+the\s+diagnos)\b/i',
            $tutor
        );
        if ($asthmaCore && $correctAns !== '' && preg_match('/\bexacerbat/i', $correctAns) && !$tutorExacerbationOk) {
            $warnings[] = [
                'code' => 'skill_alignment.diagnosis_asthma_keyed_exacerbation',
                'severity' => 'warning',
                'message' => 'Core condition appears asthma-related and the keyed answer mentions exacerbation; for Diagnosis items prefer diagnosing asthma itself unless Tutor comments explicitly ask for exacerbation as the diagnosis.',
                'field' => 'correct_answer',
            ];
        }
    }

    if (preg_match('/^interpretation$/i', $sk)) {
        if ($leadIn !== '' && sbaStyleInterpretationLeadInSuggestsMonitoringOrManagementTask($leadIn)) {
            $warnings[] = [
                'code' => 'skill_alignment.interpretation_lead_in_action_task',
                'severity' => 'high',
                'message' => 'Skill is Interpretation, but the lead-in reads like a follow-up, review, next-step, or management question. Use an interpretation lead-in (e.g. “What is the best interpretation of these findings?”) — not “most useful follow-up measure”, “what should be reviewed?”, or “what is the next step?”.',
                'field' => 'lead_in',
            ];
        }
        foreach ($opts as $t) {
            if ($t !== '' && sbaStyleTextStartsWithInterpretationBannedActionVerb($t)) {
                $warnings[] = [
                    'code' => 'skill_alignment.interpretation_option_action_verb',
                    'severity' => 'high',
                    'message' => 'Skill is Interpretation, but at least one option starts with an action verb (e.g. assess, review, check). Options must be interpretations of the supplied data, not tasks.',
                    'field' => 'options',
                ];
                break;
            }
        }
        if ($correctAns !== '' && sbaStyleTextStartsWithInterpretationBannedActionVerb($correctAns)) {
            $warnings[] = [
                'code' => 'skill_alignment.interpretation_correct_answer_action_verb',
                'severity' => 'high',
                'message' => 'Skill is Interpretation, but the keyed correct answer starts with an action verb (e.g. assess, review, check). The best answer should be an interpretation tied to the data (e.g. variable airflow obstruction, poor asthma control), not a monitoring action.',
                'field' => 'correct_answer',
            ];
        }
        if ($stem !== '' && !sbaStyleStemHasInterpretableDataRich($stem)) {
            $warnings[] = [
                'code' => 'skill_alignment.interpretation_stem_insufficient',
                'severity' => 'warning',
                'message' => 'Skill is Interpretation, but the stem may lack clear interpretable data (results, numbers, traces, scores, or structured measurements). Add explicit findings for candidates to interpret.',
                'field' => 'stem',
            ];
        }
        if ($stem !== '' && sbaStyleInterpretationStemMentionsTestWithoutNumericResult($stem)) {
            $warnings[] = [
                'code' => 'skill_alignment.interpretation_test_named_without_result',
                'severity' => 'warning',
                'message' => 'Skill is Interpretation, but the stem names a test (e.g. peak flow or spirometry) as performed without giving the measurement or result to interpret.',
                'field' => 'stem',
            ];
        }
        $tutorAllowsMgmt = $tutor !== '' && preg_match('/\b(?:management|treatment\s+step|escalat)\b/i', $tutor);
        foreach ($opts as $t) {
            if ($t === '') {
                continue;
            }
            if (sbaStyleMlaOptionLooksManagementLike($t)) {
                $warnings[] = [
                    'code' => 'skill_alignment.interpretation_options_non_interpretation',
                    'severity' => 'warning',
                    'message' => 'Skill is Interpretation, but at least one option reads as a management or treatment action rather than an interpretation of the supplied data.',
                    'field' => 'options',
                ];
                break;
            }
            if (!$tutorAllowsMgmt && sbaStyleOptionLooksMonitoringWorkflow($t) && !sbaStyleOptionLooksInterpretationStyle($t)) {
                $warnings[] = [
                    'code' => 'skill_alignment.interpretation_options_non_interpretation',
                    'severity' => 'warning',
                    'message' => 'Skill is Interpretation, but at least one option reads like a monitoring or follow-up workflow task rather than an interpretation of the findings.',
                    'field' => 'options',
                ];
                break;
            }
        }
        $title = isset($question['title']) ? trim((string) $question['title']) : '';
        if ($title !== '' && preg_match('/\bmanagement\b/i', $title)) {
            $warnings[] = [
                'code' => 'skill_alignment.interpretation_title_management_label',
                'severity' => 'warning',
                'message' => 'Skill is Interpretation, but the title contains “management”; use a topic + skill label (e.g. “Asthma peak-flow interpretation”) rather than a management assessment hook.',
                'field' => 'title',
            ];
        }
        $tutorSeverityGrading = $tutor !== '' && preg_match(
            '/\b(?:severity|grading|GINA|stage\s*[1-5]|exacerbation\s+(?:grade|category|classification))\b/i',
            $tutor
        );
        if (!$tutorSeverityGrading && $opts !== [] && sbaStyleInterpretationOptionsSpanExacerbationSeverity($opts)
            && ($stem === '' || !sbaStyleStemHasExacerbationSeverityCriteria($stem))) {
            $warnings[] = [
                'code' => 'skill_alignment.interpretation_overlapping_exacerbation_severity',
                'severity' => 'warning',
                'message' => 'Skill is Interpretation, but options include both mild/moderate and severe exacerbation-style labels without clear formal severity criteria in the stem; use distinct non-overlapping interpretations unless severity grading is the learning point (see Tutor comments).',
                'field' => 'options',
            ];
        }
        $just = isset($question['justification']) ? trim((string) $question['justification']) : '';
        if ($just !== '' && $correctAns !== '' && sbaStyleInterpretationJustificationMayFavorDifferentKey($just, $correctAns)) {
            $warnings[] = [
                'code' => 'skill_alignment.interpretation_justification_key_mismatch',
                'severity' => 'warning',
                'message' => 'The justification may argue more strongly for a different interpretation (e.g. poor control vs peak-flow variability) than for the keyed answer; if the justification chiefly supports poor control, the keyed line should say so; otherwise align the narrative with the keyed interpretation.',
                'field' => 'justification',
            ];
        }
        if ($correctAns !== '' && preg_match('/\bsignificant\b/i', $correctAns)) {
            $supportBlob = $stem . "\n" . $just;
            if (!sbaStyleBlobSupportsSignificantIntensityQualifier($supportBlob)) {
                $warnings[] = [
                    'code' => 'skill_alignment.interpretation_significant_without_support',
                    'severity' => 'warning',
                    'message' => 'The keyed interpretation uses “significant” without clear supporting thresholds or values in the stem or justification; prefer precise wording tied to the data or omit “significant”.',
                    'field' => 'correct_answer',
                ];
            }
        }
        if ($opts !== [] && sbaStyleInterpretationOptionsOverlapPoorControlOrAirflow($opts)) {
            $warnings[] = [
                'code' => 'skill_alignment.interpretation_overlapping_option_concepts',
                'severity' => 'warning',
                'message' => 'Several interpretation options repeat the same idea (e.g. multiple “poor control” lines or overlapping variable/reversible airflow obstruction labels); make distractors distinct so only one line matches the data.',
                'field' => 'options',
            ];
        }
        $warnings = array_merge($warnings, sbaStyleWarningsInterpretationDefinitiveImagingResult($question, $stem, $correctAns));
    }

    if (preg_match('/^monitoring$/i', $sk)) {
        $tutorWantsEscalation = $tutor !== '' && preg_match(
            '/\b(?:step\s*\-?up|add\s+laba|escalat|increase\s+(?:the\s+)?(?:ics|inhaled)|management\s+step|treatment\s+escalation|referral\s+for\s+(?:therapy|treatment)\s+step[\s-]?up)\b/i',
            $tutor
        );
        if (!$tutorWantsEscalation) {
            foreach ($opts as $t) {
                if ($t !== '' && sbaStyleTextHasMonitoringTreatmentEscalation($t)) {
                    $warnings[] = [
                        'code' => 'skill_alignment.monitoring_option_escalation',
                        'severity' => 'warning',
                        'message' => 'Skill is Monitoring, but at least one option describes treatment escalation (e.g. add LABA, increase ICS, oral corticosteroids). Use monitoring or review measures unless Tutor comments explicitly request management escalation.',
                        'field' => 'options',
                    ];
                    break;
                }
            }
        }
        $nonEmptyOpts = [];
        foreach ($opts as $t) {
            if ($t !== '') {
                $nonEmptyOpts[] = $t;
            }
        }
        $nFilled = count($nonEmptyOpts);
        $genMon = 0;
        foreach ($nonEmptyOpts as $t) {
            if (sbaStyleOptionLooksGenericMonitoringReviewActivity($t)) {
                ++$genMon;
            }
        }
        $hasCue = $stem !== '' && sbaStyleStemHasMonitoringDiscriminatingCue($stem);
        if ($leadIn !== '' && !sbaStyleMonitoringLeadInHasSpecificTarget($leadIn) && $genMon >= 2) {
            $warnings[] = [
                'code' => 'skill_alignment.monitoring_lead_in_not_specific',
                'severity' => 'warning',
                'message' => 'Skill is Monitoring, but the lead-in does not name a specific monitoring target (e.g. variable airflow / peak-flow monitoring, symptom-control monitoring, inhaler technique); tie the question line to the intended measure.',
                'field' => 'lead_in',
            ];
        }
        if (!$hasCue && $nFilled >= 4 && $genMon === $nFilled) {
            $warnings[] = [
                'code' => 'skill_alignment.monitoring_all_generic_options',
                'severity' => 'warning',
                'message' => 'Skill is Monitoring, but every option reads as a generic review activity while the stem lacks a specific cue to favour the keyed answer; add a discriminating clinical detail (e.g. poor inhaler technique, variable symptoms, reliever overuse, or a control score).',
                'field' => null,
            ];
        } elseif (!$hasCue && $genMon > 3) {
            $warnings[] = [
                'code' => 'skill_alignment.monitoring_generic_options_weak_discrimination',
                'severity' => 'warning',
                'message' => 'Skill is Monitoring, but several options read as interchangeable generic review activities while the stem lacks a specific cue (e.g. poor inhaler technique, variable symptoms, reliever overuse, or a control score) to favour one measure.',
                'field' => null,
            ];
        } elseif (!$hasCue && $genMon >= 2 && $nFilled >= 3) {
            $warnings[] = [
                'code' => 'skill_alignment.monitoring_stem_weak_support_for_key',
                'severity' => 'warning',
                'message' => 'Skill is Monitoring, but multiple options read as generic review activities and the stem does not clearly favour the keyed monitoring action; add a cue that points to the best answer.',
                'field' => 'stem',
            ];
        }
        if ($leadIn !== '' && !sbaStyleMonitoringLeadInHasSpecificTarget($leadIn) && $nFilled >= 4
            && sbaStyleMonitoringOptionsSpanFourReviewThemes($nonEmptyOpts)) {
            $warnings[] = [
                'code' => 'skill_alignment.monitoring_broad_lead_in_wide_option_set',
                'severity' => 'warning',
                'message' => 'Skill is Monitoring, but the lead-in is broad while options span symptom control, inhaler technique, adherence and lung-function review together; narrow the lead-in to the intended monitoring target.',
                'field' => 'lead_in',
            ];
        }
    }

    if (preg_match('/^investigation\b/i', $sk) && $stem !== '' && sbaStyleStemContainsEmbeddedExamQuestion($stem)) {
        $warnings[] = [
            'code' => 'skill_alignment.investigation_stem_embedded_question',
            'severity' => 'warning',
            'message' => 'Skill is Investigation, but the stem appears to include the investigation question (e.g. “Which initial investigation …?”). Keep the question only in the lead-in; the stem should be clinical narrative and context only.',
            'field' => 'stem',
        ];
    }

    if (preg_match('/^investigation\b/i', $sk) && $stem !== '' && sbaStyleInvestigationStemContainsPrematureObjectiveAsthmaData($stem)) {
        $warnings[] = [
            'code' => 'skill_alignment.investigation_stem_premature_objective_data',
            'severity' => 'warning',
            'message' => 'Skill is Investigation, but the stem already documents peak-flow variability, spirometry, or bronchodilator reversibility findings — usually reserve those for an Interpretation stem when the lead-in asks for an initial investigation.',
            'field' => 'stem',
        ];
    }

    if (preg_match('/^diagnosis$/i', $sk) && $stem !== '' && sbaStyleAsthmaDiagnosisStemLooksAcuteShortWindowOnly($stem, $condition)) {
        $warnings[] = [
            'code' => 'skill_alignment.asthma_diagnosis_acute_only',
            'severity' => 'warning',
            'message' => 'Asthma-related Diagnosis item: the stem reads as a very short acute (1–3 day) illness only, without recurrent or variable symptoms; prefer a pattern consistent with episodic asthma diagnosis unless Tutor comments fix an acute presentation.',
            'field' => 'stem',
        ];
    }

    if (preg_match('/^management$/i', $sk) && $opts !== [] && sbaStyleManagementOptionsHaveAnticoagulationClassOverlap($opts)) {
        $warnings[] = [
            'code' => 'skill_alignment.management_anticoagulation_class_overlap',
            'severity' => 'high',
            'message' => '[High priority] **Management** options include both a **broad anticoagulation** line (e.g. “initiate anticoagulation therapy”) and a **specific anticoagulant** (e.g. “start low-molecular-weight heparin”) — these may both be defensible. Use **one specificity level** across all five options unless explicitly testing class versus drug.',
            'field' => 'options',
        ];
    }

    if (preg_match('/^investigation\b/i', $sk) && $opts !== []) {
        $invOpts = [];
        foreach ($opts as $t) {
            if ($t !== '' && sbaStyleMlaOptionLooksInvestigationLike($t)) {
                $invOpts[] = $t;
            }
        }
        $tutorSpecifiesContrast = $tutor !== '' && preg_match('/\b(?:reversibility|bronchodilator|post[\s-]?bd|spirometry\s+versus)\b/i', $tutor);
        if (!$tutorSpecifiesContrast && sbaStyleInvestigationOptionsHaveSpirometryReversibilityOverlap($invOpts)) {
            $warnings[] = [
                'code' => 'skill_alignment.investigation_overlapping_options',
                'severity' => 'warning',
                'message' => 'Investigation options may overlap as spirometry and bronchodilator / reversibility testing in the same pathway; separate them only when Tutor comments make that distinction the learning point.',
                'field' => 'options',
            ];
        }
    }

    return sbaStyleDedupeWarnings($warnings);
}

/**
 * Batch-level repetition vs prior questions in the same generation job (form generator only).
 *
 * Expects `_batch_prior_questions`: list of DB row-shaped arrays with stem, lead_in, correct_answer, option_a–e, skill.
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsBatchRepetition(array $question): array
{
    $warnings = [];
    $priors = $question['_batch_prior_questions'] ?? null;
    if (!is_array($priors) || $priors === []) {
        return $warnings;
    }

    $stem = trim((string) ($question['stem'] ?? ''));
    $leadIn = trim((string) ($question['lead_in'] ?? ''));
    $correct = trim((string) ($question['correct_answer'] ?? ''));
    $correctBlob = sbaStyleCorrectAnswerCombinedText($question);
    if ($correctBlob !== '') {
        $correct = $correctBlob;
    }
    $sk = trim((string) ($question['_current_skill'] ?? ''));
    $skNorm = mb_strtolower(preg_replace('/\s+/u', ' ', $sk));

    $opts = [];
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $opts[] = trim((string) $question[$k]);
        }
    }
    $blob = $stem . "\n" . implode("\n", $opts);
    $normLead = sbaStyleNormalizeLeadInForBatch($leadIn);
    $normCorrect = sbaStyleNormalizeAnswerConceptForBatch($correct);
    $qMini = [
        'stem' => $stem,
        'lead_in' => $leadIn,
        'option_a' => isset($question['option_a']) && is_string($question['option_a']) ? trim((string) $question['option_a']) : '',
        'option_b' => isset($question['option_b']) && is_string($question['option_b']) ? trim((string) $question['option_b']) : '',
        'option_c' => isset($question['option_c']) && is_string($question['option_c']) ? trim((string) $question['option_c']) : '',
        'option_d' => isset($question['option_d']) && is_string($question['option_d']) ? trim((string) $question['option_d']) : '',
        'option_e' => isset($question['option_e']) && is_string($question['option_e']) ? trim((string) $question['option_e']) : '',
    ];
    $tutorBatch = trim((string) ($question['_tutor_comments'] ?? ''));
    $tutorAllowsRepeatConcept = sbaStyleTutorAllowsRepeatBatchAnswerConcept($question);
    $tutorAllowsRepeatAspirin = $tutorAllowsRepeatConcept
        || ($tutorBatch !== '' && preg_match('/\b(?:repeat|both|\bsame\b).{0,60}aspirin|\baspirin.{0,50}(?:repeat|both|\bsame\b|\btwice\b)/i', $tutorBatch));

    foreach ($priors as $p) {
        if (!is_array($p)) {
            continue;
        }
        $pStem = trim((string) ($p['stem'] ?? ''));
        $pLead = trim((string) ($p['lead_in'] ?? ''));
        $pCorrect = trim((string) ($p['correct_answer'] ?? ''));
        $pSkill = trim((string) ($p['skill'] ?? ''));
        $pSkillNorm = mb_strtolower(preg_replace('/\s+/u', ' ', $pSkill));
        $pOpts = [];
        foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
            if (!empty($p[$k]) && is_string($p[$k])) {
                $pOpts[] = trim((string) $p[$k]);
            }
        }
        $pBlob = $pStem . "\n" . implode("\n", $pOpts);
        $pNormLead = sbaStyleNormalizeLeadInForBatch($pLead);
        $pNormCorrect = sbaStyleNormalizeAnswerConceptForBatch($pCorrect);

        if ($normLead !== '' && $pNormLead !== '' && $normLead === $pNormLead) {
            $warnings[] = [
                'code' => 'batch_repetition.lead_in_pattern',
                'severity' => 'warning',
                'message' => 'This lead-in closely matches an earlier question in the same batch; vary the lead-in wording while keeping the skill aligned.',
                'field' => 'lead_in',
            ];
        }

        if (!$tutorAllowsRepeatConcept
            && sbaStyleBatchIsManagementEmergencySkillPair($skNorm, $pSkillNorm)
            && sbaStyleBatchManagementEmergencyConceptsDuplicate($correct, $pCorrect)) {
            $dupMsg = sbaStyleBatchManagementEmergencyDuplicateMessage(
                $correct,
                $pCorrect,
                $pSkill !== '' ? $pSkill : 'earlier item',
                $sk !== '' ? $sk : 'this skill'
            );
            $warnings[] = [
                'code' => 'batch_repetition.management_emergency_same_answer',
                'severity' => 'high',
                'message' => $dupMsg,
                'field' => 'correct_answer',
            ];
            $fam = sbaStyleBatchTreatmentConceptFamily($correct);
            if ($fam !== '' && sbaStyleNormalizeAnswerConceptForBatch($correct) !== sbaStyleNormalizeAnswerConceptForBatch($pCorrect)) {
                $warnings[] = [
                    'code' => 'batch_repetition.management_emergency_same_treatment_family',
                    'severity' => 'high',
                    'message' => $dupMsg,
                    'field' => 'correct_answer',
                ];
            }
            $warnings[] = [
                'code' => 'batch_repetition.same_correct_answer_concept',
                'severity' => 'high',
                'message' => 'The keyed correct answer matches an earlier **Management** / **Emergency Management** item in this batch; choose a **different correct-answer concept** for this skill.',
                'field' => 'correct_answer',
            ];
        } elseif ($normCorrect !== '' && $pNormCorrect !== '' && $normCorrect === $pNormCorrect) {
            $warnings[] = [
                'code' => 'batch_repetition.same_correct_answer_concept',
                'severity' => 'warning',
                'message' => 'The keyed correct answer text is very similar to an earlier item in this batch; vary the discriminating concept where the learning outcome allows.',
                'field' => 'correct_answer',
            ];
        }

        if (sbaStyleTextHasAsthmaRelieverNightIcsCluster($blob) && sbaStyleTextHasAsthmaRelieverNightIcsCluster($pBlob)) {
            $warnings[] = [
                'code' => 'batch_repetition.cue_cluster',
                'severity' => 'warning',
                'message' => 'This item repeats the same asthma-style cue cluster (reliever overuse + nocturnal symptoms + low-dose ICS framing) as an earlier batch item; change the clinical recipe.',
                'field' => 'stem',
            ];
        }

        $mgmtPair = (preg_match('/^management$/i', $skNorm) && preg_match('/^monitoring$/i', $pSkillNorm))
            || (preg_match('/^monitoring$/i', $skNorm) && preg_match('/^management$/i', $pSkillNorm));
        if ($mgmtPair && $normCorrect !== '' && $pNormCorrect !== '' && $normCorrect === $pNormCorrect) {
            $warnings[] = [
                'code' => 'batch_repetition.management_monitoring_same_answer',
                'severity' => 'warning',
                'message' => 'Management and Monitoring items in the same batch share the same keyed answer concept; Monitoring should usually focus on review / control / surveillance, not the same escalation as Management.',
                'field' => 'correct_answer',
            ];
        }

        $pMini = [
            'stem' => $pStem,
            'lead_in' => $pLead,
            'option_a' => isset($p['option_a']) && is_string($p['option_a']) ? trim((string) $p['option_a']) : '',
            'option_b' => isset($p['option_b']) && is_string($p['option_b']) ? trim((string) $p['option_b']) : '',
            'option_c' => isset($p['option_c']) && is_string($p['option_c']) ? trim((string) $p['option_c']) : '',
            'option_d' => isset($p['option_d']) && is_string($p['option_d']) ? trim((string) $p['option_d']) : '',
            'option_e' => isset($p['option_e']) && is_string($p['option_e']) ? trim((string) $p['option_e']) : '',
        ];
        if (!$tutorAllowsRepeatAspirin && sbaStyleBlobSuggestsSuspectedAcs($qMini) && sbaStyleBlobSuggestsSuspectedAcs($pMini)
            && sbaStyleTextSuggestsAspirinKeyedAction($correct) && sbaStyleTextSuggestsAspirinKeyedAction($pCorrect)) {
            $warnings[] = [
                'code' => 'batch_repetition.acs_batch_duplicate_aspirin',
                'severity' => 'warning',
                'message' => 'ACS batch: more than one item in this job keys immediate aspirin (e.g. aspirin 300 mg orally) — use a different correct-answer concept on later items (e.g. antithrombotic adjunct, reperfusion/escalation, nitrate avoidance when hypotensive) especially when **Management** and **Emergency Management** both appear, unless Tutor comments explicitly allow repeating aspirin.',
                'field' => 'correct_answer',
            ];
        }

    }

    if (sbaStyleBatchHasAsthmaMention($question, $priors)) {
        $relieverCueCount = 0;
        if (sbaStyleTextHasRelieverInhalerKeyCue($stem . "\n" . $leadIn)) {
            ++$relieverCueCount;
        }
        foreach ($priors as $p) {
            if (!is_array($p)) {
                continue;
            }
            $pStemR = trim((string) ($p['stem'] ?? ''));
            $pLeadR = trim((string) ($p['lead_in'] ?? ''));
            if (sbaStyleTextHasRelieverInhalerKeyCue($pStemR . "\n" . $pLeadR)) {
                ++$relieverCueCount;
            }
        }
        if ($relieverCueCount > 2) {
            $warnings[] = [
                'code' => 'batch_repetition.reliever_cue_overuse',
                'severity' => 'warning',
                'message' => 'Reliever inhaler use is highlighted in more than two questions in this asthma-related batch; rotate other cues (night symptoms alone, control scores, technique, exercise-induced symptoms, triggers) unless Tutor comments fix this pattern.',
                'field' => 'stem',
            ];
        }
    }

    return sbaStyleDedupeWarnings($warnings);
}

/**
 * Skill vs lead-in / option family alignment (form-based generator context via _current_skill).
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsSkillAlignment(array $question): array
{
    $warnings = [];
    $skillRaw = isset($question['_current_skill']) ? trim((string) $question['_current_skill']) : '';
    if ($skillRaw === '') {
        return $warnings;
    }
    $sk = trim(preg_replace('/\s+/u', ' ', $skillRaw));
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';

    $opts = [];
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $opts[] = trim((string) $question[$k]);
        }
    }

    $inv = 0;
    $mgmt = 0;
    $dx = 0;
    foreach ($opts as $t) {
        if (sbaStyleMlaOptionLooksManagementLike($t)) {
            $mgmt++;
        } elseif (sbaStyleMlaOptionLooksInvestigationLike($t)) {
            $inv++;
        }
        if (sbaStyleMlaOptionLooksDiagnosisLike($t)) {
            $dx++;
        }
    }

    if (preg_match('/^diagnosis$/i', $sk)) {
        if ($leadIn !== '' && !sbaStyleLeadInSuggestsInvestigationTask($leadIn) && preg_match(
            '/\b(?:management|treat(?:ment)?|therap(?:y|ies)|prescrib|prescription|which\s+drug|which\s+medication|which\s+antibiotic|initial\s+management|immediate\s+treatment|first\s+action|next\s+step|which\s+investigation|which\s+test|monitoring\s+test|oxygen\s+therapy|fluid\s+bolus)\b/i',
            $leadIn
        )) {
            $warnings[] = [
                'code' => 'skill_alignment.mismatch',
                'severity' => 'warning',
                'message' => 'Skill is Diagnosis, but the lead-in suggests management, treatment, prescribing, investigation, or “next/first action” wording. Use a diagnosis-focused lead-in (e.g. most likely diagnosis) aligned with diagnosis-only options.',
                'field' => 'lead_in',
            ];
        }
        if ($mgmt >= 3 || ($mgmt >= 2 && $dx === 0 && $inv === 0)) {
            $warnings[] = [
                'code' => 'skill_alignment.mismatch',
                'severity' => 'warning',
                'message' => 'Skill is Diagnosis, but several options read as treatment or management steps rather than parallel diagnoses or disease entities.',
                'field' => 'options',
            ];
        }
    }

    if (preg_match('/^management$/i', $sk) || preg_match('/^emergency\s+management$/i', $sk)) {
        if ($dx >= 3 || ($dx >= 2 && $mgmt === 0 && $inv === 0)) {
            $warnings[] = [
                'code' => 'skill_alignment.mismatch',
                'severity' => 'warning',
                'message' => 'Skill is Management or Emergency Management, but several options read as diagnoses or condition labels rather than management steps or urgent actions.',
                'field' => 'options',
            ];
        }
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && $opts !== []
        && ($leadIn === '' || sbaStyleLeadInAsksTreatmentWithoutInvestigationFrame($leadIn))) {
        foreach ($opts as $oneOpt) {
            if (sbaStyleOptionLineReadsAsInvestigationForEmergencyManagement($oneOpt)) {
                $warnings[] = [
                    'code' => 'skill_alignment.emergency_management_investigation_option',
                    'severity' => 'high',
                    'message' => '[High priority] Emergency Management: the lead-in asks for immediate management or treatment but at least one option reads as an investigation, imaging, diagnostic test, or “wait for results” (e.g. full blood count, CT scan, arrange imaging) — every option A–E must be an immediate emergency management action (resuscitation, urgent treatment, escalation). Rewrite the option set; do not include investigations as distractors.',
                    'field' => 'options',
                ];
                break;
            }
        }
        if ($inv >= 1 && $mgmt === 0) {
            $warnings[] = [
                'code' => 'skill_alignment.emergency_management_investigation_family',
                'severity' => 'high',
                'message' => '[High priority] Emergency Management: one or more options read as investigations or tests rather than urgent management actions — use a homogeneous emergency-management option family only.',
                'field' => 'options',
            ];
        }

        $correctAns = trim((string) ($question['correct_answer'] ?? ''));
        $correctLetter = trim((string) ($question['correct_answer_letter'] ?? ''));
        if ($correctAns !== '' && sbaStyleEmergencyAnswerHasCombinedDistinctComponents($correctAns)) {
            $fragCount = sbaStyleCountEmergencyPartialComponentFragmentDistractors($opts, $correctAns, $correctLetter);
            if ($fragCount >= 2) {
                $warnings[] = [
                    'code' => 'skill_alignment.emergency_management_partial_component_distractors',
                    'severity' => 'high',
                    'message' => '[High priority] Emergency Management: the keyed answer combines urgent components (e.g. IV fluids and broad-spectrum IV antibiotics) but two or more distractors are isolated single-component fragments (e.g. fluids only, antibiotics only). Use clinically plausible whole emergency strategies instead (unsafe sequencing, incomplete resuscitation, wrong priority, delayed escalation, oral antibiotics when unstable, oxygen when not hypoxic, vasopressor before adequate fluids) — not mirror halves of the correct answer.',
                    'field' => 'options',
                ];
            } elseif ($fragCount === 1) {
                $warnings[] = [
                    'code' => 'skill_alignment.emergency_management_partial_component_distractor',
                    'severity' => 'warning',
                    'message' => 'Emergency Management: the keyed answer is a combined emergency action but at least one distractor is an isolated component (e.g. fluids only or antibiotics only alongside fluids plus antibiotics). Prefer distractors that are distinct plausible emergency strategies, not a single fragment of the keyed combination.',
                    'field' => 'options',
                ];
            }
        }
    }

    if (preg_match('/^monitoring$/i', $sk)) {
        $tutor = trim((string) ($question["_tutor_comments"] ?? ''));
        $tutorWantsEscalation = $tutor !== '' && preg_match(
            '/\b(?:step\s*\-?up|add\s+laba|escalat|increase\s+(?:the\s+)?(?:ics|inhaled)|management\s+step|treatment\s+escalation)\b/i',
            $tutor
        );
        $correctAns = trim((string) ($question['correct_answer'] ?? ''));
        $escBlob = $leadIn . "\n" . $correctAns;
        if (!$tutorWantsEscalation && sbaStyleTextHasMonitoringTreatmentEscalation($escBlob)) {
            $warnings[] = [
                'code' => 'skill_alignment.mismatch',
                'severity' => 'warning',
                'message' => 'Skill is Monitoring, but the lead-in or keyed answer includes treatment escalation (e.g. add LABA, increase ICS, start oral corticosteroids). Prefer monitoring / review / control assessment unless Tutor comments explicitly request management escalation.',
                'field' => null,
            ];
        }
    }

    return sbaStyleDedupeWarnings($warnings);
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsMlaOptionsDecisionFamilyAndLength(array $question): array
{
    $warnings = [];
    $opts = [];
    foreach (['option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $k) {
        if (!empty($question[$k]) && is_string($question[$k])) {
            $opts[$k] = trim($question[$k]);
        }
    }
    if ($opts === []) {
        return $warnings;
    }

    $inv = 0;
    $mgmt = 0;
    $other = 0;
    $dx = 0;
    foreach ($opts as $t) {
        if (sbaStyleMlaOptionLooksManagementLike($t)) {
            $mgmt++;
        } elseif (sbaStyleMlaOptionLooksInvestigationLike($t)) {
            $inv++;
        } else {
            $other++;
        }
        if (sbaStyleMlaOptionLooksDiagnosisLike($t)) {
            $dx++;
        }
    }

    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $investigationLead = sbaStyleLeadInSuggestsInvestigationTask($leadIn);
    $diagnosisLead = $leadIn !== '' && !$investigationLead && preg_match(
        '/\b(?:diagnosis|most\s+likely|underlying\s+(?:cause|diagnosis)|what\s+is\s+the\s+diagnos|which\s+diagnos|likely\s+diagnos)\b/i',
        $leadIn
    );
    $managementLikeLead = sbaStyleLeadInSuggestsManagementTask($leadIn);

    if ($inv >= 1 && $mgmt >= 1 && $dx >= 1) {
        $warnings[] = [
            'code' => 'mla.options_mixed_diagnosis_investigation_management',
            'severity' => 'warning',
            'message' => 'Answer options appear to mix decision families (diagnosis-style lines with investigation-style and management / treatment-style lines); keep all five options homogeneous and from the same decision family.',
            'field' => null,
        ];
    } elseif ($inv >= 1 && $mgmt >= 1) {
        $warnings[] = [
            'code' => 'mla.options_mixed_inv_mgmt',
            'severity' => 'warning',
            'message' => 'Answer options appear to mix investigation-type lines with management or treatment-type lines; keep all five options in the same decision family (see lead-in).',
            'field' => null,
        ];
    } elseif ($diagnosisLead && $inv >= 1 && $other >= 1) {
        $warnings[] = [
            'code' => 'mla.options_mixed_decision_families',
            'severity' => 'warning',
            'message' => 'Lead-in suggests a diagnosis task, but at least one option reads like an investigation while others read like diagnoses; keep options homogeneous.',
            'field' => null,
        ];
    } elseif ($managementLikeLead && $mgmt >= 1 && ($inv + $dx) >= 1) {
        $warnings[] = [
            'code' => 'mla.options_mixed_decision_families',
            'severity' => 'warning',
            'message' => 'Lead-in suggests a management or treatment task, but at least one option reads like an investigation or a diagnosis label; keep options in the same decision family.',
            'field' => null,
        ];
    } elseif ($investigationLead && $mgmt >= 1 && ($inv + $other) >= 1) {
        $warnings[] = [
            'code' => 'mla.options_mixed_decision_families',
            'severity' => 'warning',
            'message' => 'Lead-in suggests an investigation task, but at least one option reads like treatment or management; keep options in the same decision family.',
            'field' => null,
        ];
    }

    $wcs = [];
    foreach ($opts as $t) {
        $wc = sbaStyleWordCount($t);
        if ($wc > 0) {
            $wcs[] = $wc;
        }
    }
    $relaxLength = function_exists('sbaPrescribingShouldRelaxOptionSimilarityAndLengthWarnings')
        && sbaPrescribingShouldRelaxOptionSimilarityAndLengthWarnings($question);
    if ($wcs !== [] && !$relaxLength) {
        $minW = min($wcs);
        $maxW = max($wcs);
        if ($minW > 0 && $maxW > 2 * $minW) {
            $warnings[] = [
                'code' => 'mla.option_length_outlier',
                'severity' => 'warning',
                'message' => 'One answer option is more than twice the word length of the shortest option; tighten wording so options stay homogeneous.',
                'field' => null,
            ];
        }
    }

    return $warnings;
}

/**
 * Run all style checks and return a flat list of warning records.
 *
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'high'|'error'|'info', message: string, field: ?string}>
 */
function validateSbaStyle(array $question): array
{
    $warnings = [];
    $warnings = array_merge(sbaStyleWarningsSkillAlignment($question), $warnings);
    $warnings = array_merge(sbaStyleWarningsFormSkillStemOptions($question), $warnings);
    $warnings = array_merge($warnings, sbaStyleWarningsStemMisplacedExamQuestionAllSkills($question));
    $warnings = array_merge(sbaStyleWarningsBatchRepetition($question), $warnings);
    $warnings = array_merge($warnings, sbaStyleWarningsNonAktWording($question));
    $warnings = array_merge($warnings, sbaStyleWarningsInvestigationSequencing($question));
    $warnings = array_merge($warnings, sbaStyleWarningsAcsFormClinicalGuards($question));
    $warnings = array_merge($warnings, sbaStyleWarningsAcsCoreConditionStemTitleMismatch($question));
    $warnings = array_merge($warnings, sbaStyleWarningsClinicalSequenceInitialEcgStemTroponin($question));
    $warnings = array_merge($warnings, sbaStyleWarningsClinicalSequenceNextLeadInJustificationInitial($question));
    $warnings = array_merge($warnings, sbaStyleWarningsJustificationEcgNotInvestigationPhrase($question));
    $warnings = array_merge($warnings, sbaStyleWarningsUkTerminology($question));
    $warnings = array_merge($warnings, sbaStyleWarningsStemFormatting($question));
    $warnings = array_merge($warnings, sbaStyleWarningsAgeAdjective($question));
    $warnings = array_merge($warnings, sbaStyleWarningsVagueNormalObservationsPhrase($question));
    $warnings = array_merge($warnings, sbaStyleWarningsInvestigationFormattingUk($question));
    $warnings = array_merge($warnings, sbaStyleWarningsStemAdmitsTo($question));
    $warnings = array_merge($warnings, sbaStyleWarningsVagueHistoryOfAlcoholUse($question));
    $warnings = array_merge($warnings, sbaStyleWarningsStemQualitativeLabsWithoutNumericRef($question));
    $warnings = array_merge($warnings, sbaStyleWarningsStemQualitativePrefixedNumericInvestigations($question));
    $warnings = array_merge($warnings, sbaStyleWarningsDiagnosisStemClassicCase($question));
    $warnings = array_merge($warnings, sbaStyleWarningsStemAcutePancreatitisTriadicGiveaway($question));
    $warnings = array_merge($warnings, sbaStyleWarningsPancreatitisEnzymeBelowThreeFoldUrl($question));
    $warnings = array_merge($warnings, sbaStyleWarningsPancreatitisWeakTwentyUnitsPerWeek($question));
    $warnings = array_merge($warnings, sbaStyleWarningsTitle($question));
    $warnings = array_merge($warnings, sbaStyleWarningsAnswerStructure($question));
    $warnings = array_merge($warnings, sbaStyleWarningsLeadIn($question));
    $warnings = array_merge($warnings, sbaStyleWarningsPrescribingStemLeadAndDosing($question));
    $warnings = array_merge($warnings, sbaStyleWarningsPrescribingOptionFormat($question));
    $warnings = array_merge($warnings, sbaStyleWarningsPrescribingAllergyContradiction($question));
    $warnings = array_merge($warnings, sbaStyleWarningsStableCommunityInfectionPrescribing($question));
    $warnings = array_merge($warnings, sbaStyleWarningsCapPrescribingCoAmoxiclav($question));
    $warnings = array_merge($warnings, sbaStyleWarningsCapPrescribingDiabetesAloneNotBroadCover($question));
    $warnings = array_merge($warnings, sbaStyleWarningsCapPrescribingBatchConceptTracking($question));
    $warnings = array_merge($warnings, sbaStyleWarningsCapEmergencyHypoxicOxygenUnfair($question));
    $warnings = array_merge($warnings, sbaStyleWarningsCapInterpretationSeverity($question));
    $warnings = array_merge($warnings, sbaStyleWarningsCapManagementPrescribingDrift($question));
    $warnings = array_merge($warnings, sbaStyleWarningsCapMonitoringStyle($question));
    $warnings = array_merge($warnings, sbaStyleWarningsMonitoringStyle($question));
    $warnings = array_merge($warnings, sbaStyleWarningsBasicStyle($question));
    $warnings = array_merge($warnings, sbaStyleWarningsDistractorQuality($question));
    $warnings = array_merge($warnings, sbaStyleWarningsPancreaticCancerDistractorSuddenAbdominalPain($question));
    $warnings = array_merge($warnings, sbaStyleWarningsInterpretationAkiAndDataStyle($question));
    $warnings = array_merge($warnings, sbaStyleWarningsAbgBicarbonateInterpretation($question));
    $warnings = array_merge($warnings, sbaStyleWarningsAbgCopdType2Interpretation($question));
    $warnings = array_merge($warnings, sbaStyleWarningsOverspecificGiDiagnosis($question));
    $warnings = array_merge($warnings, sbaStyleWarningsHypoglyKeyedOralGlucoseWithCompetingOralCarbDistractor($question));
    $warnings = array_merge($warnings, sbaStyleWarningsEmergencyOverlappingOralCarbohydrateOptions($question));
    $warnings = array_merge($warnings, sbaStyleWarningsHypoglyStemCollapseWithMildGlucoseValue($question));
    $warnings = array_merge($warnings, sbaStyleWarningsSuspectedSepsisEmergencyStyle($question));
    $warnings = array_merge($warnings, sbaStyleWarningsSepsisBatchSkillConceptMap($question));
    $warnings = array_merge($warnings, sbaStyleWarningsSepsisDiagnosisSyndromeVsSource($question));
    $warnings = array_merge($warnings, sbaStyleWarningsSepsisInvestigationInitialWhenStemHasResult($question));
    $warnings = array_merge($warnings, sbaStyleWarningsSepsisInvestigationUrinaryPurposeMismatch($question));
    $warnings = array_merge($warnings, sbaStyleWarningsSepsisInvestigationLactateDrift($question));
    $warnings = array_merge($warnings, sbaStyleWarningsSepsisEmergencyIncompleteKey($question));
    $warnings = array_merge($warnings, sbaStyleWarningsSepsisEmergencyBatchRepetition($question));
    $warnings = array_merge($warnings, sbaStyleWarningsPeEmergencyBatchRepetition($question));
    $warnings = array_merge($warnings, sbaStyleWarningsSepsisEmergencyCategoryMismatch($question));
    $warnings = array_merge($warnings, sbaStyleWarningsSepsisEmergencyAlignment($question));
    $warnings = array_merge($warnings, sbaStyleWarningsPulmonaryEmbolismEmergencyStyle($question));
    $warnings = array_merge($warnings, sbaStyleWarningsPulmonaryEmbolismThrombolysisSeverity($question));
    $warnings = array_merge($warnings, sbaStyleWarningsPeEmergencyClinicalAlignment($question));
    $warnings = array_merge($warnings, sbaStyleWarningsEctopicPregnancyEmergencyStyle($question));
    $warnings = array_merge($warnings, sbaStyleWarningsAwkwardTachycardicWithPulsePhrase($question));
    $warnings = array_merge($warnings, sbaStyleWarningsAcuteManagement($question));
    $warnings = array_merge($warnings, sbaStyleWarningsMlaAktFormConcision($question));
    $warnings = array_merge($warnings, sbaStyleWarningsMlaOptionsDecisionFamilyAndLength($question));
    $warnings = array_merge($warnings, sbaStyleWarningsStemDemographicConsistency($question));

    return sbaStyleSortWarningsBySeverity($warnings);
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsNonAktWording(array $question): array
{
    $warnings = [];
    $fields = sbaStyleTextFields($question);

    $patterns = [
        'wording.presents_with' => ['/presents\s+with\b/i', '"presents with"'],
        'wording.presents_to' => ['/presents\s+to\b/i', '"presents to"'],
        'wording.room_air' => ['/room\s+air\b/i', '"room air"'],
        'wording.on_examination' => ['/on\s+examination\b/i', '"on examination"'],
    ];

    foreach ($fields as $fieldName => $text) {
        if ($text === '') {
            continue;
        }
        foreach ($patterns as $code => [$regex, $label]) {
            if (preg_match($regex, $text)) {
                $warnings[] = [
                    'code' => $code,
                    'severity' => 'warning',
                    'message' => "Non–MS AKT wording: avoid {$label}.",
                    'field' => $fieldName,
                ];
            }
        }
        if (preg_match('/\b(?:male|female)\b/i', $text)) {
            $warnings[] = [
                'code' => 'wording.male_female',
                'severity' => 'warning',
                'message' => 'Non–MS AKT wording: prefer man/woman/boy/girl instead of "male" or "female" for the patient.',
                'field' => $fieldName,
            ];
        }
    }

    return sbaStyleDedupeWarnings($warnings);
}

/**
 * Heuristic: three simple tokens joined by “and” after a medication cue may need alphabetical order.
 *
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsStemMedicationAndChainOrder(string $stem): array
{
    $warnings = [];
    if ($stem === '' || stripos($stem, ' and ') === false) {
        return $warnings;
    }

    if (!preg_match('/\b([A-Za-z][a-z0-9\-]{3,})\s+and\s+([A-Za-z][a-z0-9\-]{3,})\s+and\s+([A-Za-z][a-z0-9\-]{3,})\b/', $stem, $m, PREG_OFFSET_CAPTURE)) {
        return $warnings;
    }

    $nonDrugish = [
        'pain', 'ache', 'fever', 'cough', 'nausea', 'vomit', 'blood', 'urine', 'stool', 'rash',
        'swelling', 'stiffness', 'weakness', 'fatigue', 'dizziness', 'chills', 'malaise',
        'breathlessness', 'headache', 'diarrhoea', 'diarrhea', 'syncope', 'bleeding', 'thrush',
        'sweats', 'rigors', 'tenderness', 'tightness', 'wheeze', 'crackles', 'murmur',
    ];

    $a = strtolower($m[1][0]);
    $b = strtolower($m[2][0]);
    $c = strtolower($m[3][0]);
    foreach ([$a, $b, $c] as $w) {
        if (in_array($w, $nonDrugish, true)) {
            return $warnings;
        }
    }

    $start = (int) $m[0][1];
    $before = substr($stem, max(0, $start - 120), min(120, $start));
    if (!preg_match('/\b(taking|takes|take|on|uses|using|combined|plus|together\s+with|medications?|tablets?|prescribed|regular)\b/i', $before)) {
        return $warnings;
    }

    $list = [$a, $b, $c];
    $sorted = $list;
    sort($sorted, SORT_STRING);

    if ($list !== $sorted) {
        $warnings[] = [
            'code' => 'stem.med_and_list_not_alphabetical',
            'severity' => 'warning',
            'message' => 'Stem lists three medications joined by “and” that are not in alphabetical order; list generic drug names alphabetically where the list is simple.',
            'field' => 'stem',
        ];
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsAgeAdjective(array $question): array
{
    $warnings = [];
    $fields = [
        'title',
        'stem',
        'lead_in',
        'option_a',
        'option_b',
        'option_c',
        'option_d',
        'option_e',
        'correct_answer',
        'justification',
    ];

    foreach ($fields as $field) {
        if (!isset($question[$field]) || !is_string($question[$field])) {
            continue;
        }
        $text = trim($question[$field]);
        if ($text === '' || !sbaStyleHasHyphenatedAdjectivalAge($text)) {
            continue;
        }
        $warnings[] = [
            'code' => 'style.hyphenated_age_adjective',
            'severity' => 'warning',
            'message' => 'MS AKT style does not hyphenate adjectival ages before a noun (e.g. use “65 year old man”, not “65-year-old man”).',
            'field' => $field,
        ];
    }

    return $warnings;
}

function sbaStyleWarningsStemFormatting(array $question): array
{
    $warnings = [];
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';

    if ($stem === '') {
        return $warnings;
    }

    $wordCount = sbaStyleWordCount($stem);
    if ($wordCount > 0 && ($wordCount < 30 || $wordCount > 100)) {
        $warnings[] = [
            'code' => 'stem.word_count',
            'severity' => 'warning',
            'message' => "Stem length is {$wordCount} words; ideally aim for about 30–100 words.",
            'field' => 'stem',
        ];
    }

    if (preg_match('/\bhistory\s+of\b/i', $stem)) {
        $warnings[] = [
            'code' => 'stem.history_of_phrase',
            'severity' => 'warning',
            'message' => 'Stem uses “history of”; MS AKT style prefers stating active problems directly (e.g. “has asthma”, “has type 2 diabetes mellitus”) instead of narrative “history of” where meaning is unchanged.',
            'field' => 'stem',
        ];
    }

    $historyOfInStem = preg_match_all('/\bhistory\s+of\b/i', $stem) ?: 0;
    if ($historyOfInStem > 1) {
        $warnings[] = [
            'code' => 'stem.history_of_repeated',
            'severity' => 'warning',
            'message' => 'Stem repeats “history of” more than once; consolidate comorbidity phrasing for a cleaner stem.',
            'field' => 'stem',
        ];
    }

    if (preg_match('/\b(?:appears\s+anxious|mildly\s+anxious)\b/i', $stem)) {
        $warnings[] = [
            'code' => 'stem.appearance_descriptor',
            'severity' => 'warning',
            'message' => 'Stem uses an unnecessary affect label (“appears anxious” / “mildly anxious”); omit unless it changes discrimination for the lead-in.',
            'field' => 'stem',
        ];
    }

    if (preg_match('/\bobservations\s+include\b/i', $stem)) {
        $warnings[] = [
            'code' => 'stem.observations_include',
            'severity' => 'warning',
            'message' => 'Avoid “observations include”; state measurements directly in MS AKT order (temperature, pulse, BP, respiratory rate, oxygen saturation).',
            'field' => 'stem',
        ];
    }

    if (preg_match('/\b(?:PaO2|PaCO2|PaO₂|PaCO₂)\b/i', $stem) || preg_match('/\bHCO\s*3\s*[-−]?\b/i', $stem)) {
        $warnings[] = [
            'code' => 'stem.blood_gas_notation',
            'severity' => 'warning',
            'message' => 'MS AKT style prefers PO2, PCO2 and bicarbonate wording rather than PaO2, PaCO2 or HCO3− where blood gases are named.',
            'field' => 'stem',
        ];
    }

    $warnings = array_merge($warnings, sbaStyleWarningsStemMedicationAndChainOrder($stem));

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning'|'info', message: string, field: ?string}>
 */
function sbaStyleWarningsTitle(array $question): array
{
    $warnings = [];
    $title = isset($question['title']) ? trim((string) $question['title']) : '';
    if ($title === '') {
        return $warnings;
    }

    $wc = sbaStyleWordCount($title);
    if ($wc > 0 && ($wc < 5 || $wc > 10)) {
        $warnings[] = [
            'code' => 'title.word_count',
            'severity' => 'info',
            'message' => "Title is {$wc} words; aim for about 5–10 words, neutral and grammatically complete.",
            'field' => 'title',
        ];
    }

    if (preg_match('/^\s*Management\s+of\b/i', $title)) {
        $warnings[] = [
            'code' => 'title.formulaic_management_of',
            'severity' => 'warning',
            'message' => 'Title starts with formulaic “Management of …”; prefer short neutral labels (e.g. “Conscious adult hypoglycaemia”, “Symptomatic hypoglycaemia treatment”) rather than a management headline.',
            'field' => 'title',
        ];
    }

    if (preg_match('/^\s*Initial\s+investigation\b/i', $title)) {
        $warnings[] = [
            'code' => 'title.formulaic_initial_investigation',
            'severity' => 'warning',
            'message' => 'Title starts with formulaic “Initial investigation …”; prefer a neutral clinical topic (e.g. “Suspected acute coronary syndrome”) without templated investigation wording.',
            'field' => 'title',
        ];
    }

    if (preg_match('/\bin\s+Patient\b/', $title)) {
        $warnings[] = [
            'code' => 'title.formulaic_in_patient',
            'severity' => 'warning',
            'message' => 'Title contains awkward “in Patient”; prefer a topic-only label without dummy patient phrasing.',
            'field' => 'title',
        ];
    }

    if (preg_match('/\bin\s+a\s+patient\b/i', $title)) {
        $warnings[] = [
            'code' => 'title.formulaic_in_a_patient',
            'severity' => 'warning',
            'message' => 'Title contains formulaic “in a patient”; prefer a compact neutral clinical title.',
            'field' => 'title',
        ];
    }

    if (preg_match('/\bin\s+Emergency\s+Department\b/', $title)) {
        $warnings[] = [
            'code' => 'title.formulaic_in_emergency_department',
            'severity' => 'warning',
            'message' => 'Title uses formulaic “in Emergency Department”; prefer a neutral topic label (e.g. “Chest pain assessment”) rather than an ED-tagged template.',
            'field' => 'title',
        ];
    }

    if (preg_match('/\bin\s+(?:the\s+)?emergency\s+department\b/i', $title)) {
        $warnings[] = [
            'code' => 'title.awkward_ed_fragment',
            'severity' => 'warning',
            'message' => 'Title hinges on an emergency department place fragment (a common formulaic hook); prefer compact neutral labels such as “Chest pain assessment” or “Suspected acute coronary syndrome”.',
            'field' => 'title',
        ];
    }

    if (preg_match('/\bin\s+(?!the\s+)emergency\s+department\b/i', $title)) {
        $warnings[] = [
            'code' => 'title.emergency_department_missing_the',
            'severity' => 'warning',
            'message' => 'Title uses “in emergency department”; prefer “in the emergency department” if ED wording is needed (and avoid ED fragments as the main title hook).',
            'field' => 'title',
        ];
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsAnswerStructure(array $question): array
{
    $warnings = [];
    $letters = ['a', 'b', 'c', 'd', 'e'];
    $keysPresent = 0;
    $emptyKeys = [];

    foreach ($letters as $letter) {
        $key = 'option_' . $letter;
        if (!array_key_exists($key, $question)) {
            continue;
        }
        $keysPresent++;
        $val = trim((string) $question[$key]);
        if ($val === '') {
            $emptyKeys[] = $key;
        }
    }

    if ($keysPresent > 0 && $keysPresent !== 5) {
        $warnings[] = [
            'code' => 'options.count',
            'severity' => 'warning',
            'message' => "SBA should include exactly five options (option_a–option_e); found {$keysPresent} option field(s).",
            'field' => null,
        ];
    }

    foreach ($emptyKeys as $key) {
        $warnings[] = [
            'code' => 'options.empty',
            'severity' => 'warning',
            'message' => "Answer option \"{$key}\" is empty.",
            'field' => $key,
        ];
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsLeadIn(array $question): array
{
    $warnings = [];
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';

    if ($leadIn === '') {
        return $warnings;
    }

    $normalized = ltrim($leadIn, " \t\n\r\0\x0B\"'“”‘’");
    if ($normalized === '') {
        return $warnings;
    }

    if (!preg_match('/^(What|Which)\b/i', $normalized)) {
        $warnings[] = [
            'code' => 'lead_in.opening',
            'severity' => 'warning',
            'message' => 'Lead-in does not begin with "What" or "Which" (MS AKT style usually prefers these openings).',
            'field' => 'lead_in',
        ];
    }

    return $warnings;
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsBasicStyle(array $question): array
{
    $warnings = [];
    $blob = sbaStyleCombinedBodyText($question);

    if ($blob === '') {
        return $warnings;
    }

    $mostAppropriateCount = preg_match_all('/\bmost\s+appropriate\b/i', $blob) ?: 0;
    if ($mostAppropriateCount > 1) {
        $warnings[] = [
            'code' => 'style.repeated_most_appropriate',
            'severity' => 'warning',
            'message' => 'Phrase "most appropriate" appears more than once across stem, lead-in, and options; consider varying wording.',
            'field' => null,
        ];
    }

    $historyOfCount = preg_match_all('/\bhistory\s+of\b/i', $blob) ?: 0;
    if ($historyOfCount > 1) {
        $warnings[] = [
            'code' => 'style.repeated_history_of',
            'severity' => 'warning',
            'message' => 'Phrase “history of” appears more than once across stem, lead-in, and options; consolidate and prefer direct phrasing (“has …”) where it does not change meaning.',
            'field' => null,
        ];
    }

    return $warnings;
}

/**
 * Text segments to scan for non–MS AKT wording (title + stem + lead + options).
 *
 * @param array<string, mixed> $question
 * @return array<string, string>
 */
function sbaStyleTextFields(array $question): array
{
    $out = [];
    foreach (['title', 'stem', 'lead_in', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $key) {
        if (!array_key_exists($key, $question)) {
            continue;
        }
        $out[$key] = (string) $question[$key];
    }

    return $out;
}

/**
 * @param array<string, mixed> $question
 */
function sbaStyleCombinedBodyText(array $question): string
{
    $parts = [];
    foreach (['stem', 'lead_in', 'option_a', 'option_b', 'option_c', 'option_d', 'option_e'] as $key) {
        if (!empty($question[$key])) {
            $parts[] = (string) $question[$key];
        }
    }

    return trim(implode("\n", $parts));
}

function sbaStyleWordCount(string $text): int
{
    $text = strip_tags($text);
    $text = preg_replace('/[^\p{L}\p{N}\'\-]+/u', ' ', $text);
    $text = trim((string) $text);
    if ($text === '') {
        return 0;
    }

    if (!preg_match_all('/\S+/u', $text, $matches)) {
        return 0;
    }

    return count($matches[0]);
}

/**
 * ECG “result” wording for ACS clinical guard and related validators.
 */
function sbaStyleStemHasEcgResultWordingClinicalGuard(string $stem): bool
{
    if ($stem === '') {
        return false;
    }
    $patterns = [
        '/\becg\s+shows\b/i',
        '/\belectrocardiogram\s+shows\b/i',
        '/\becg\s+demonstrates\b/i',
        '/\bST-segment\s+changes\b/i',
        '/\bST\s+elevation\b/i',
        '/\bST-elevation\b/i',
        '/\bno\s+significant\s+ST\b/i',
    ];
    foreach ($patterns as $p) {
        if (preg_match($p, $stem)) {
            return true;
        }
    }

    return false;
}

function sbaClinicalGuardJobMatchesAcsInvestigationContext(array $job, string $currentSkill): bool
{
    $cond = mb_strtolower((string) ($job['condition_name'] ?? ''));
    $pres = mb_strtolower((string) ($job['presentation'] ?? ''));
    $skill = mb_strtolower($currentSkill);
    $acs = (strpos($cond, 'acute coronary syndrome') !== false) || (strpos($pres, 'chest pain') !== false);
    $inv = strpos($skill, 'investigation') !== false;

    return $acs && $inv;
}

/**
 * Align justification with lead-in “next investigation” wording and preferred ECG rationale.
 */
function sbaClinicalGuardRepairJustificationForAcsNextInvestigation(string $justification): string
{
    if ($justification === '') {
        return $justification;
    }

    $s = $justification;
    $s = preg_replace('/\bmost\s+appropriate\s+initial\s+investigation\b/i', 'most appropriate next investigation', $s);
    $s = preg_replace('/\binitial\s+investigation\s+in\s+suspected\s+acute\s+coronary\s+syndrome\b/i', 'next investigation in suspected acute coronary syndrome', $s);

    $preferredEcg = 'The ECG has already been performed, so repeating it is not the next investigation.';
    $ecgBad = [
        '/\ban\s+ecg\s+is\s+not\s+considered\s+(?:an\s+)?investigation\b\.?/i',
        '/\ban\s+ecg\s+is\s+not\s+(?:an\s+)?investigation\b\.?/i',
        '/\becg\s+is\s+not\s+considered\s+(?:an\s+)?investigation\b\.?/i',
        '/\becg\s+was\s+not\s+considered\s+(?:an\s+)?investigation\b\.?/i',
        '/\becg\s+is\s+not\s+(?:an\s+)?investigation\b\.?/i',
        '/\becg\s+was\s+not\s+(?:an\s+)?investigation\b\.?/i',
        '/\becg\s+does\s+not\s+count\s+as\s+(?:an\s+)?investigation\b\.?/i',
    ];
    foreach ($ecgBad as $p) {
        $s = preg_replace($p, $preferredEcg, $s);
    }

    $s = preg_replace('/\s+/u', ' ', trim($s));

    return repairSbaStyleNormalizeSentenceStarts($s);
}

/**
 * Post-repair ACS sequencing guard: adjust lead-in when job context + stem + key contradict.
 *
 * @param array<string, mixed> $question
 * @param array<string, mixed> $job
 * @return array{0: array<string, mixed>, 1: list<array{code: string, severity: string, message: string, field: ?string}>}
 */
function sbaClinicalGuardRepairAcsInitialInvestigationTroponin(array $question, array $job, string $currentSkill): array
{
    $empty = [];
    try {
        if (!sbaClinicalGuardJobMatchesAcsInvestigationContext($job, $currentSkill)) {
            return [$question, $empty];
        }
        $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
        if ($leadIn === '' || !sbaStyleLeadInContainsInitialInvestigationPhrase($leadIn)) {
            return [$question, $empty];
        }
        $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
        if ($stem === '' || !sbaStyleStemHasEcgResultWordingClinicalGuard($stem)) {
            return [$question, $empty];
        }
        $corr = sbaStyleCorrectAnswerCombinedText($question);
        if ($corr === '' || !preg_match('/\btroponin\b/i', $corr)) {
            return [$question, $empty];
        }

        $out = $question;
        $newLead = $leadIn;
        $newLead = preg_replace('/\bthe\s+initial\s+investigation\b/i', 'the next investigation', $newLead);
        $newLead = preg_replace('/\binitial\s+investigation\b/i', 'next investigation', $newLead);
        $newLead = preg_replace('/\s+/u', ' ', trim($newLead));
        if ($newLead === $leadIn) {
            return [$question, $empty];
        }
        $out['lead_in'] = repairSbaStyleNormalizeSentenceStarts($newLead);

        if (isset($out['justification']) && is_string($out['justification']) && trim($out['justification']) !== '') {
            $out['justification'] = sbaClinicalGuardRepairJustificationForAcsNextInvestigation(trim((string) $out['justification']));
        }

        $w = [
            [
                'code' => 'clinical_sequence.initial_vs_next_investigation_repaired',
                'severity' => 'warning',
                'message' => 'Clinical consistency guard: stem described ECG results while the lead-in asked for the “initial investigation” and the key was troponin — lead-in and justification updated to “next investigation” where needed; keyed troponin answer unchanged.',
                'field' => 'lead_in',
            ],
        ];

        return [$out, $w];
    } catch (Throwable $e) {
        return [$question, $empty];
    }
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsClinicalSequenceInitialEcgStemTroponin(array $question): array
{
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $stem = isset($question['stem']) ? trim((string) $question['stem']) : '';
    if ($leadIn === '' || $stem === '') {
        return [];
    }
    if (!sbaStyleLeadInContainsInitialInvestigationPhrase($leadIn)) {
        return [];
    }
    if (!sbaStyleStemHasEcgResultWordingClinicalGuard($stem)) {
        return [];
    }
    $corr = sbaStyleCorrectAnswerCombinedText($question);
    if ($corr === '' || !preg_match('/\btroponin\b/i', $corr)) {
        return [];
    }

    return [
        [
            'code' => 'clinical_sequence.initial_ecg_stem_troponin_key',
            'severity' => 'warning',
            'message' => 'Lead-in asks for the “initial investigation” while the stem already reports ECG-related findings and the keyed answer is troponin — align lead-in to “next investigation” or remove ECG results from the stem (or key ECG if the task is truly the first test).',
            'field' => 'lead_in',
        ],
    ];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsClinicalSequenceNextLeadInJustificationInitial(array $question): array
{
    $leadIn = isset($question['lead_in']) ? trim((string) $question['lead_in']) : '';
    $just = isset($question['justification']) ? trim((string) $question['justification']) : '';
    if ($leadIn === '' || $just === '') {
        return [];
    }
    if (stripos($leadIn, 'next investigation') === false) {
        return [];
    }
    if (!preg_match('/\binitial\s+investigation\b/i', $just)) {
        return [];
    }

    return [
        [
            'code' => 'clinical_sequence.justification_initial_with_next_lead_in',
            'severity' => 'warning',
            'message' => 'Lead-in refers to the “next investigation” but the justification still says “initial investigation” — align justification with the lead-in.',
            'field' => 'justification',
        ],
    ];
}

/**
 * @param array<string, mixed> $question
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleWarningsJustificationEcgNotInvestigationPhrase(array $question): array
{
    $just = isset($question['justification']) ? trim((string) $question['justification']) : '';
    if ($just === '') {
        return [];
    }
    $ecgBad = [
        '/\ban\s+ecg\s+is\s+not\s+considered\s+(?:an\s+)?investigation\b/i',
        '/\ban\s+ecg\s+is\s+not\s+(?:an\s+)?investigation\b/i',
        '/\becg\s+is\s+not\s+considered\s+(?:an\s+)?investigation\b/i',
        '/\becg\s+was\s+not\s+considered\s+(?:an\s+)?investigation\b/i',
        '/\becg\s+is\s+not\s+(?:an\s+)?investigation\b/i',
        '/\becg\s+was\s+not\s+(?:an\s+)?investigation\b/i',
        '/\becg\s+does\s+not\s+count\s+as\s+(?:an\s+)?investigation\b/i',
    ];
    $hit = false;
    foreach ($ecgBad as $p) {
        if (preg_match($p, $just)) {
            $hit = true;
            break;
        }
    }
    if (!$hit) {
        return [];
    }

    return [
        [
            'code' => 'clinical_sequence.justification_ecg_not_investigation',
            'severity' => 'warning',
            'message' => 'Justification implies the ECG is not an investigation — prefer wording that the ECG has already been done (e.g. that repeating it is not the next investigation).',
            'field' => 'justification',
        ],
    ];
}

/**
 * Order style warnings so higher-severity items appear first in lists.
 *
 * @param list<array{code: string, severity?: string, message: string, field: ?string}> $warnings
 * @return list<array{code: string, severity?: string, message: string, field: ?string}>
 */
function sbaStyleSortWarningsBySeverity(array $warnings): array
{
    $rank = static function (array $w): int {
        $s = strtolower($w['severity'] ?? 'warning');
        if ($s === 'high') {
            return 0;
        }
        if ($s === 'error') {
            return 1;
        }
        if ($s === 'warning') {
            return 2;
        }
        if ($s === 'info') {
            return 3;
        }

        return 2;
    };
    usort($warnings, static function (array $a, array $b) use ($rank): int {
        return $rank($a) <=> $rank($b);
    });

    return $warnings;
}

/**
 * @param list<array{code: string, severity: 'warning', message: string, field: ?string}> $warnings
 * @return list<array{code: string, severity: 'warning', message: string, field: ?string}>
 */
function sbaStyleDedupeWarnings(array $warnings): array
{
    $seen = [];
    $out = [];
    foreach ($warnings as $w) {
        $key = $w['code'] . '|' . ($w['field'] ?? '');
        if (isset($seen[$key])) {
            continue;
        }
        $seen[$key] = true;
        $out[] = $w;
    }

    return $out;
}
