<?php

/**
 * Form-based generator: one-time regeneration when structural style validation fails.
 *
 * Caller must load `validators/style_validator.php` before
 * `formGeneratorStyleWarningsTriggerSeriousRetry()` (uses sbaStyleLeadInSuggestsManagementTask).
 */

/**
 * Warning codes that alone (or with the gates in formGeneratorStyleWarningsTriggerSeriousRetry)
 * trigger a single automatic regeneration of the form generator output.
 *
 * @return list<string>
 */
function formGeneratorSeriousRetryWarningCodesDocumented(): array
{
    return [
        'acs.management_includes_investigation_option',
        'acs.management_ecg_among_treatment_options',
        'acs.emergency_treatment_lead_investigation_option',
        'acs.emergency_treatment_lead_chest_xray_option_high',
        'stem_and_lead_in.duplicate_question_wording',
        'stem.question_phrase_belongs_in_lead_in',
        'stem.question_phrase_emergency_management_high',
        'skill_alignment.generated_skill_field_mismatch',
        'batch_repetition.same_correct_answer_concept',
        'batch_repetition.management_emergency_same_answer',
        'batch_repetition.management_emergency_same_treatment_family',
        'batch_repetition.acs_batch_duplicate_aspirin',
        'acs.investigation_troponin_ecg_pending_or_unavailable',
        'acs.investigation_troponin_without_explicit_ecg_result',
        'acs.investigation_troponin_key_without_ecg_in_stem',
        'sequencing.acs_ecg_initial_contradiction_high',
        'acs.investigation_ecg_key_stem_ecg_shows_or_initial_ecg',
        'acs.investigation_initial_lead_in_stem_already_has_ecg',
        'acs.unstable_angina_with_raised_troponin',
        'acs.diagnosis_mi_without_confirmatory_data',
        'mla.options_mixed_inv_mgmt',
        'mla.options_mixed_diagnosis_investigation_management',
        'mla.options_mixed_decision_families',
        'skill_alignment.emergency_management_investigation_option',
        'skill_alignment.emergency_management_investigation_family',
        'skill_alignment.emergency_management_partial_component_distractors',
        'sepsis.batch_management_combined_resuscitation_reserved_for_emergency',
        'emergency.sepsis_unstable_antibiotics_only_high',
        'sepsis.initial_investigation_lactate_drift',
        'sepsis.diagnosis_syndrome_vs_source_mismatch',
        'sepsis.diagnosis_option_homogeneity',
        'sepsis.initial_investigation_result_already_in_stem',
        'sepsis.investigation_urinary_purpose_mismatch',
        'sepsis.emergency_incomplete_key_without_narrow_lead_in',
        'sepsis.emergency_batch_duplicate_resuscitation',
        'sepsis.emergency_batch_duplicate_scenario_category',
        'sepsis.emergency_lead_in_scenario_mismatch',
        'sepsis.emergency_source_control_investigation_key',
        'sepsis.emergency_hypoxic_oxygen_unfair',
        'sepsis.emergency_obvious_unsafe_distractors',
        'sepsis.emergency_option_length_imbalance',
        'sepsis.emergency_repeated_lead_in',
        'sepsis.emergency_formulaic_title',
        'pe.emergency_antibiotics_wrong_context',
        'pe.emergency_batch_duplicate_scenario_category',
        'pe.massive_shock_needs_escalation_or_reperfusion',
        'pe_thrombolysis_without_instability',
        'pe.emergency_thrombolysis_despite_contraindication',
        'pe.emergency_oxygen_reperfusion_fairness',
        'pe.emergency_plan_slot_requires_instability',
        'acute.oxygen_distractor_hypoxia',
        'cap_prescribing_coamoxiclav_overuse_or_unjustified',
        'cap_prescribing_diabetes_alone_not_broad_cover',
        'cap_prescribing_same_antibiotic',
        'prescribing_allergy_contradiction',
        'cap.emergency_hypoxic_oxygen_unfair',
        'cap.interpretation_severity_overclaim',
        'cap.management_antibiotic_selection_drift',
    ];
}

function formGeneratorLeadInLooksLikeExamQuestion(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;
}

/**
 * @param list<array{code?: string, severity?: string, message?: string, field?: string|null}> $warnings
 */
function formGeneratorStyleWarningsTriggerSeriousRetry(array $warnings, array $repairedQuestion): bool
{
    if ($warnings === []) {
        return false;
    }

    $codesPresent = [];
    foreach ($warnings as $w) {
        if (!is_array($w)) {
            continue;
        }
        $c = isset($w['code']) ? (string) $w['code'] : '';
        if ($c !== '') {
            $codesPresent[$c] = true;
        }
    }

    $sk = trim(preg_replace('/\s+/u', ' ', (string) ($repairedQuestion['_current_skill'] ?? '')));
    $leadIn = isset($repairedQuestion['lead_in']) ? trim((string) $repairedQuestion['lead_in']) : '';

    if (!empty($codesPresent['stem_and_lead_in.duplicate_question_wording'])) {
        return true;
    }

    if (!empty($codesPresent['stem.question_phrase_belongs_in_lead_in'])
        || !empty($codesPresent['stem.question_phrase_emergency_management_high'])) {
        if (formGeneratorLeadInLooksLikeExamQuestion($leadIn)) {
            return true;
        }
    }

    if (!empty($codesPresent['skill_alignment.generated_skill_field_mismatch'])) {
        return true;
    }

    if (!empty($codesPresent['batch_repetition.management_emergency_same_answer'])
        || !empty($codesPresent['batch_repetition.management_emergency_same_treatment_family'])
        || !empty($codesPresent['batch_repetition.acs_batch_duplicate_aspirin'])) {
        return true;
    }

    if (!empty($codesPresent['batch_repetition.same_correct_answer_concept'])
        && function_exists('sbaStyleBatchIsManagementEmergencySkillPair')
        && formGeneratorBatchDuplicateIsManagementEmergencyPair($repairedQuestion)) {
        return true;
    }

    if (!empty($codesPresent['acs.investigation_troponin_ecg_pending_or_unavailable'])
        || !empty($codesPresent['acs.investigation_troponin_without_explicit_ecg_result'])
        || !empty($codesPresent['acs.investigation_troponin_key_without_ecg_in_stem'])) {
        return true;
    }

    if (!empty($codesPresent['sequencing.acs_ecg_initial_contradiction_high'])
        || !empty($codesPresent['acs.investigation_ecg_key_stem_ecg_shows_or_initial_ecg'])
        || !empty($codesPresent['acs.investigation_initial_lead_in_stem_already_has_ecg'])) {
        return true;
    }

    if (!empty($codesPresent['acs.unstable_angina_with_raised_troponin'])) {
        return true;
    }

    if (!empty($codesPresent['acs.diagnosis_mi_without_confirmatory_data'])
        && preg_match('/^diagnosis$/i', $sk)) {
        return true;
    }

    if (!empty($codesPresent['acs.management_includes_investigation_option'])
        || !empty($codesPresent['acs.management_ecg_among_treatment_options'])) {
        return true;
    }

    if (!empty($codesPresent['acs.emergency_treatment_lead_investigation_option'])
        || !empty($codesPresent['acs.emergency_treatment_lead_chest_xray_option_high'])) {
        return true;
    }

    if (preg_match('/^emergency\s+management$/i', $sk)
        && (!empty($codesPresent['skill_alignment.emergency_management_investigation_option'])
            || !empty($codesPresent['skill_alignment.emergency_management_investigation_family'])
            || !empty($codesPresent['skill_alignment.emergency_management_partial_component_distractors'])
            || !empty($codesPresent['emergency.sepsis_unstable_antibiotics_only_high'])
            || !empty($codesPresent['pe.emergency_antibiotics_wrong_context'])
            || !empty($codesPresent['pe.massive_shock_needs_escalation_or_reperfusion'])
            || !empty($codesPresent['pe_thrombolysis_without_instability'])
            || !empty($codesPresent['pe.emergency_thrombolysis_despite_contraindication'])
            || !empty($codesPresent['pe.emergency_oxygen_reperfusion_fairness'])
            || !empty($codesPresent['pe.emergency_plan_slot_requires_instability']))) {
        return true;
    }

    if (preg_match('/^management$/i', $sk) && !empty($codesPresent['pe_thrombolysis_without_instability'])) {
        return true;
    }

    if (preg_match('/^prescribing$/i', $sk)
        && (!empty($codesPresent['cap_prescribing_coamoxiclav_overuse_or_unjustified'])
            || !empty($codesPresent['cap_prescribing_diabetes_alone_not_broad_cover'])
            || !empty($codesPresent['cap_prescribing_same_antibiotic'])
            || !empty($codesPresent['prescribing_allergy_contradiction']))) {
        return true;
    }

    if (preg_match('/^management$/i', $sk)
        && !empty($codesPresent['sepsis.batch_management_combined_resuscitation_reserved_for_emergency'])) {
        return true;
    }

    if (preg_match('/^investigation(?:\s+selection)?$/i', $sk)
        && (!empty($codesPresent['sepsis.initial_investigation_lactate_drift'])
            || !empty($codesPresent['sepsis.initial_investigation_result_already_in_stem'])
            || !empty($codesPresent['sepsis.investigation_urinary_purpose_mismatch']))) {
        return true;
    }

    if (preg_match('/^diagnosis$/i', $sk)
        && (!empty($codesPresent['sepsis.diagnosis_syndrome_vs_source_mismatch'])
            || !empty($codesPresent['sepsis.diagnosis_option_homogeneity']))) {
        return true;
    }

    if (preg_match('/^emergency\s+management$/i', $sk)
        && (!empty($codesPresent['sepsis.emergency_batch_duplicate_resuscitation'])
            || !empty($codesPresent['sepsis.emergency_batch_duplicate_scenario_category'])
            || !empty($codesPresent['sepsis.emergency_incomplete_key_without_narrow_lead_in'])
            || !empty($codesPresent['sepsis.emergency_lead_in_scenario_mismatch'])
            || !empty($codesPresent['sepsis.emergency_source_control_investigation_key'])
            || !empty($codesPresent['sepsis.emergency_hypoxic_oxygen_unfair'])
            || !empty($codesPresent['sepsis.emergency_obvious_unsafe_distractors'])
            || !empty($codesPresent['sepsis.emergency_option_length_imbalance'])
            || !empty($codesPresent['sepsis.emergency_repeated_lead_in'])
            || !empty($codesPresent['sepsis.emergency_formulaic_title']))) {
        return true;
    }

    if (preg_match('/^emergency\s+management$/i', $sk)
        && !empty($codesPresent['pe.emergency_batch_duplicate_scenario_category'])) {
        return true;
    }

    if (preg_match('/^emergency\s+management$/i', $sk) && !empty($codesPresent['acute.oxygen_distractor_hypoxia'])) {
        if (!function_exists('sbaGeneratorConditionLooksPulmonaryEmbolism')) {
            $pePath = dirname(__DIR__) . '/../prompts/sba_pe_emergency_concepts.php';
            if (is_readable($pePath)) {
                require_once $pePath;
            }
        }
        $cond = trim((string) ($repairedQuestion['_condition_name'] ?? ''));
        if (function_exists('sbaGeneratorConditionLooksPulmonaryEmbolism')
            && sbaGeneratorConditionLooksPulmonaryEmbolism($cond)) {
            return true;
        }
    }

    if (preg_match('/^management$/i', $sk)) {
        if (!empty($codesPresent['mla.options_mixed_inv_mgmt'])
            || !empty($codesPresent['mla.options_mixed_diagnosis_investigation_management'])) {
            return true;
        }
    }

    if (!empty($codesPresent['mla.options_mixed_decision_families'])
        && (preg_match('/^management$/i', $sk) || preg_match('/^emergency\s+management$/i', $sk))) {
        if (function_exists('sbaStyleLeadInSuggestsManagementTask') && $leadIn !== ''
            && sbaStyleLeadInSuggestsManagementTask($leadIn)) {
            return true;
        }
    }

    return false;
}

/**
 * @param array<string, mixed> $question
 */
function formGeneratorResolveCorrectAnswerText(array $question): string
{
    if (function_exists('sbaStyleCorrectAnswerCombinedText')) {
        return trim(sbaStyleCorrectAnswerCombinedText($question));
    }

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

/**
 * @param array<string, mixed> $repairedQuestion
 */
function formGeneratorBatchDuplicateIsManagementEmergencyPair(array $repairedQuestion): bool
{
    if (!function_exists('sbaStyleBatchIsManagementEmergencySkillPair')
        || !function_exists('sbaStyleBatchManagementEmergencyConceptsDuplicate')) {
        return false;
    }
    $priors = $repairedQuestion['_batch_prior_questions'] ?? null;
    if (!is_array($priors) || $priors === []) {
        return false;
    }
    $skNorm = mb_strtolower(trim(preg_replace('/\s+/u', ' ', (string) ($repairedQuestion['_current_skill'] ?? ''))));
    $currentCorrect = formGeneratorResolveCorrectAnswerText($repairedQuestion);
    if ($currentCorrect === '') {
        return false;
    }
    foreach ($priors as $p) {
        if (!is_array($p)) {
            continue;
        }
        $pSkillNorm = mb_strtolower(trim(preg_replace('/\s+/u', ' ', (string) ($p['skill'] ?? ''))));
        $pCorrect = trim((string) ($p['correct_answer'] ?? ''));
        if ($pCorrect !== ''
            && sbaStyleBatchIsManagementEmergencySkillPair($skNorm, $pSkillNorm)
            && sbaStyleBatchManagementEmergencyConceptsDuplicate($currentCorrect, $pCorrect)) {
            return true;
        }
    }

    return false;
}

/**
 * Prior batch item that conflicts on correct-answer concept (Management vs Emergency Management).
 *
 * @param array<string, mixed> $repairedQuestion
 * @return array{answer: string, skill: string}|null
 */
function formGeneratorBatchPriorConflictingAnswerConcept(array $repairedQuestion): ?array
{
    if (!function_exists('sbaStyleBatchIsManagementEmergencySkillPair')
        || !function_exists('sbaStyleBatchManagementEmergencyConceptsDuplicate')) {
        return null;
    }
    $priors = $repairedQuestion['_batch_prior_questions'] ?? null;
    if (!is_array($priors) || $priors === []) {
        return null;
    }
    $skNorm = mb_strtolower(trim(preg_replace('/\s+/u', ' ', (string) ($repairedQuestion['_current_skill'] ?? ''))));
    $currentCorrect = formGeneratorResolveCorrectAnswerText($repairedQuestion);
    if ($currentCorrect === '') {
        return null;
    }
    foreach ($priors as $p) {
        if (!is_array($p)) {
            continue;
        }
        $pSkillNorm = mb_strtolower(trim(preg_replace('/\s+/u', ' ', (string) ($p['skill'] ?? ''))));
        $pCorrect = trim((string) ($p['correct_answer'] ?? ''));
        if ($pCorrect !== ''
            && sbaStyleBatchIsManagementEmergencySkillPair($skNorm, $pSkillNorm)
            && sbaStyleBatchManagementEmergencyConceptsDuplicate($currentCorrect, $pCorrect)) {
            return [
                'answer' => $pCorrect,
                'skill' => trim((string) ($p['skill'] ?? '')),
                'family' => function_exists('sbaStyleBatchTreatmentConceptFamily')
                    ? sbaStyleBatchTreatmentConceptFamily($currentCorrect)
                    : '',
            ];
        }
    }

    return null;
}

/**
 * @param array<string, mixed> $repairedQuestion
 */
function formGeneratorBatchStillDuplicatesPriorConcept(array $repairedQuestion): bool
{
    return formGeneratorBatchPriorConflictingAnswerConcept($repairedQuestion) !== null;
}

/**
 * @param array<string, mixed> $question
 */
function formGeneratorRetryQuestionIsSepsisContext(array $question): bool
{
    $cond = trim((string) ($question['_condition_name'] ?? ''));
    if ($cond !== '' && function_exists('sbaGeneratorConditionLooksSepsis')) {
        return sbaGeneratorConditionLooksSepsis($cond);
    }

    return is_array($question['_sepsis_emergency_scenario_plan'] ?? null);
}

/**
 * @param array<string, mixed> $question
 */
function formGeneratorRetryQuestionIsPeContext(array $question): bool
{
    $cond = trim((string) ($question['_condition_name'] ?? ''));
    if ($cond !== '' && function_exists('sbaGeneratorConditionLooksPulmonaryEmbolism')) {
        return sbaGeneratorConditionLooksPulmonaryEmbolism($cond);
    }

    return is_array($question['_pe_emergency_scenario_plan'] ?? null);
}

/**
 * @param array<string, mixed> $question
 */
function formGeneratorRetryWarningAppliesToQuestionContext(string $code, array $question): bool
{
    if ($code === '') {
        return true;
    }
    $isSepsis = formGeneratorRetryQuestionIsSepsisContext($question);
    $isPe = formGeneratorRetryQuestionIsPeContext($question);
    if (str_starts_with($code, 'sepsis.') || str_starts_with($code, 'emergency.sepsis_')) {
        return $isSepsis;
    }
    if (str_starts_with($code, 'pe.') || $code === 'pe_thrombolysis_without_instability') {
        return $isPe;
    }

    return true;
}

/**
 * @param list<array{code?: string, severity?: string, message?: string, field?: string|null}> $warnings
 * @param array<string, mixed> $questionContext
 */
function formGeneratorSummarizeWarningsForRetryBanner(array $warnings, array $questionContext = []): string
{
    $parts = [];
    foreach ($warnings as $w) {
        if (!is_array($w)) {
            continue;
        }
        $c = isset($w['code']) ? trim((string) $w['code']) : '';
        if ($questionContext !== [] && !formGeneratorRetryWarningAppliesToQuestionContext($c, $questionContext)) {
            continue;
        }
        $m = isset($w['message']) ? trim((string) $w['message']) : '';
        if ($c === '' && $m === '') {
            continue;
        }
        $parts[] = $c !== '' ? ($c . ($m !== '' ? ': ' . $m : '')) : $m;
    }
    $blob = implode(' | ', $parts);
    if (strlen($blob) > 900) {
        return substr($blob, 0, 897) . '...';
    }

    return $blob !== '' ? $blob : '(no message text)';
}

/**
 * @param list<array{code?: string, severity?: string, message?: string, field?: string|null}> $warnings
 */
/**
 * @param list<array{code?: string, severity?: string, message?: string, field?: string|null}> $warnings
 */
function formGeneratorRetryWasTriggeredForPartialComponentDistractors(array $warnings): bool
{
    foreach ($warnings as $w) {
        if (!is_array($w)) {
            continue;
        }
        if (($w['code'] ?? '') === 'skill_alignment.emergency_management_partial_component_distractors') {
            return true;
        }
    }

    return false;
}

/**
 * @param array<string, mixed> $question
 */
function formGeneratorCountEmergencyPartialComponentFragmentDistractors(array $question): int
{
    if (!function_exists('sbaStyleCountEmergencyPartialComponentFragmentDistractors')
        || !function_exists('sbaStyleEmergencyAnswerHasCombinedDistinctComponents')) {
        return 0;
    }
    $correct = formGeneratorResolveCorrectAnswerText($question);
    if ($correct === '' || !sbaStyleEmergencyAnswerHasCombinedDistinctComponents($correct)) {
        return 0;
    }
    $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]);
        }
    }

    return sbaStyleCountEmergencyPartialComponentFragmentDistractors(
        $opts,
        $correct,
        (string) ($question['correct_answer_letter'] ?? '')
    );
}

/**
 * @param list<array{code?: string, severity?: string, message?: string, field?: string|null}> $warnings
 * @param array<string, mixed> $repairedQuestion
 */
function formGeneratorRetryWasTriggeredForBatchDuplicate(array $warnings, array $repairedQuestion): bool
{
    $codes = [];
    foreach ($warnings as $w) {
        if (is_array($w) && isset($w['code']) && (string) $w['code'] !== '') {
            $codes[(string) $w['code']] = true;
        }
    }
    if (!empty($codes['batch_repetition.management_emergency_same_answer'])
        || !empty($codes['batch_repetition.management_emergency_same_treatment_family'])
        || !empty($codes['batch_repetition.acs_batch_duplicate_aspirin'])) {
        return true;
    }

    return !empty($codes['batch_repetition.same_correct_answer_concept'])
        && formGeneratorBatchDuplicateIsManagementEmergencyPair($repairedQuestion);
}

/**
 * @param list<array{code?: string, severity?: string, message?: string, field?: string|null}> $warnings
 * @return list<array{code?: string, severity?: string, message?: string, field?: string|null}>
 */
function formGeneratorFilterHighPriorityWarnings(array $warnings): array
{
    $out = [];
    foreach ($warnings as $w) {
        if (!is_array($w)) {
            continue;
        }
        if (($w['severity'] ?? '') === 'high') {
            $out[] = $w;
        }
    }

    return $out;
}

/**
 * @param list<array{code?: string, severity?: string, message?: string, field?: string|null}> $afterRetry
 * @param list<array{code?: string, severity?: string, message?: string, field?: string|null}> $firstPass
 * @return list<array{code?: string, severity?: string, message?: string, field?: string|null}>
 */
function formGeneratorMergePersistentHighPriorityWarningsAfterRetry(array $afterRetry, array $firstPass, array $questionContext = []): array
{
    $highAfter = formGeneratorFilterHighPriorityWarnings($afterRetry);
    if ($highAfter === []) {
        return $afterRetry;
    }

    $summary = formGeneratorSummarizeWarningsForRetryBanner($highAfter, $questionContext);

    return array_merge($afterRetry, [[
        'code' => 'form_generator.high_priority_warnings_persist_after_retry',
        'severity' => 'high',
        'message' => '[High priority] Automatic regeneration did **not** clear all structural problems. The question is **not** treated as fully improved. Remaining issues: '
            . $summary,
        'field' => null,
    ]]);
}

/**
 * @param array<string, bool> $codes
 * @param array<string, mixed> $repairedQuestion
 */
function formGeneratorAppendSepsisEmergencyPlanRetryLock(array &$lines, array $codes, array $repairedQuestion): void
{
    $plan = $repairedQuestion['_sepsis_emergency_scenario_plan'] ?? null;
    if (!is_array($plan)) {
        return;
    }

    $changeCategory = !empty($codes['sepsis.emergency_lead_in_scenario_mismatch'])
        || !empty($codes['sepsis.emergency_source_control_investigation_key']);

    $lines[] = '**LOCKED sepsis Emergency scenario plan (regeneration):**';
    $lines[] = '- **Assigned category:** `' . (string) ($plan['slot'] ?? '') . '` — ' . (string) ($plan['label'] ?? '');
    if ($changeCategory) {
        $lines[] = '- You may adjust category **only** because the first pass clearly mismatched this slot; otherwise keep the same slot.';
    } else {
        $lines[] = '- **Do NOT change scenario category** — rewrite stem, lead-in, all five options, title, and keyed answer to fit this slot.';
    }
    $lines[] = '- **Required key concept:** ' . (string) ($plan['required_key_concept'] ?? '');
    $lines[] = '- **Required lead-in focus:** ' . (string) ($plan['lead_in_focus'] ?? '');
    if (!empty($plan['forbidden_distractor_lines']) && is_array($plan['forbidden_distractor_lines'])) {
        $lines[] = '- **Do not use throwaway distractors:** ' . implode('; ', $plan['forbidden_distractor_lines']);
    }
    $lines[] = '';
}

/**
 * @param array<string, bool> $codes
 * @param array<string, mixed> $repairedQuestion
 */
function formGeneratorAppendPeEmergencyPlanRetryLock(array &$lines, array $codes, array $repairedQuestion): void
{
    $plan = $repairedQuestion['_pe_emergency_scenario_plan'] ?? null;
    if (!is_array($plan)) {
        return;
    }

    $lines[] = '**LOCKED PE Emergency scenario plan (regeneration):**';
    $lines[] = '- **Assigned category:** `' . (string) ($plan['slot'] ?? '') . '` — ' . (string) ($plan['label'] ?? '');
    $lines[] = '- **Do NOT change scenario category** — rewrite stem, lead-in, all five options, title, and keyed answer to fit this slot.';
    $lines[] = '- **Required key concept:** ' . (string) ($plan['required_key_concept'] ?? '');
    $lines[] = '- **Required lead-in focus:** ' . (string) ($plan['lead_in_focus'] ?? '');
    if (!empty($plan['forbidden_distractor_lines']) && is_array($plan['forbidden_distractor_lines'])) {
        $lines[] = '- **Do not use throwaway distractors:** ' . implode('; ', $plan['forbidden_distractor_lines']);
    }
    $lines[] = '';
}

/**
 * @param list<array{code?: string, severity?: string, message?: string, field?: string|null}> $firstPassWarnings
 * @param array<string, mixed> $repairedQuestion
 */
function formGeneratorBuildRetryUserPrompt(string $originalPrompt, array $firstPassWarnings, string $currentSkill, array $repairedQuestion = []): string
{
    $lines = [];
    $lines[] = '---';
    $lines[] = '';
    $lines[] = '**Regenerate because these serious validation problems occurred on the first JSON output.** Produce **one** corrected JSON object for the same task; output **JSON only** (no markdown fences, no commentary).';
    $lines[] = '';
    $codes = [];
    foreach ($firstPassWarnings as $w) {
        if (!is_array($w)) {
            continue;
        }
        $c = isset($w['code']) ? (string) $w['code'] : '';
        if ($c !== '') {
            $codes[$c] = true;
        }
    }

    formGeneratorAppendSepsisEmergencyPlanRetryLock($lines, $codes, $repairedQuestion);
    formGeneratorAppendPeEmergencyPlanRetryLock($lines, $codes, $repairedQuestion);
    if ($codes !== []) {
        $lines[] = '**Warning codes:** ' . implode(', ', array_keys($codes));
        $lines[] = '';
    }
    $lines[] = '**Warning details (first pass):**';
    foreach ($firstPassWarnings as $w) {
        if (!is_array($w)) {
            continue;
        }
        $c = isset($w['code']) ? (string) $w['code'] : '';
        $m = isset($w['message']) ? (string) $w['message'] : '';
        if ($c !== '' || $m !== '') {
            $lines[] = '- `' . $c . '`: ' . $m;
        }
    }
    $lines[] = '';
    $lines[] = '**Mandatory fixes for this regeneration:**';
    if (!empty($codes['acs.diagnosis_mi_without_confirmatory_data'])) {
        $lines[] = '- **ACS Diagnosis:** when the stem reports **no ECG result** and **no troponin result**, key **acute coronary syndrome** (broader working diagnosis), **not** definite **myocardial infarction**, **STEMI**, or **NSTEMI**; do **not** justify MI from symptoms alone. Use **unstable angina** only if the stem clearly supports **troponin-negative** ischaemic pain. Key definite MI labels only when the stem documents **raised/positive troponin** or a **clear ST-segment elevation / STEMI** pattern.';
    }
    if (!empty($codes['acs.investigation_troponin_ecg_pending_or_unavailable'])
        || !empty($codes['acs.investigation_troponin_without_explicit_ecg_result'])
        || !empty($codes['acs.investigation_troponin_key_without_ecg_in_stem'])
        || !empty($codes['sequencing.acs_ecg_initial_contradiction_high'])
        || !empty($codes['acs.investigation_ecg_key_stem_ecg_shows_or_initial_ecg'])
        || !empty($codes['acs.investigation_initial_lead_in_stem_already_has_ecg'])) {
        $lines[] = '- **ACS Investigation — use exactly one pattern:** **Pattern A:** no ECG in stem → **initial** investigation lead-in → **12-lead ECG** key. **Pattern B:** explicit ECG result in stem (e.g. “ECG shows no ST-segment elevation”) → **next** investigation lead-in → **cardiac troponin** key. **Forbidden** when keying troponin: “ECG performed but results not yet available”, “ECG is pending”, “ECG has been arranged”, or any ECG process wording **without** an ECG read-out.';
    }

    if (!empty($codes['batch_repetition.management_emergency_same_answer'])
        || !empty($codes['batch_repetition.management_emergency_same_treatment_family'])
        || !empty($codes['batch_repetition.same_correct_answer_concept'])
        || !empty($codes['batch_repetition.acs_batch_duplicate_aspirin'])) {
        $conflict = formGeneratorBatchPriorConflictingAnswerConcept($repairedQuestion);
        if ($conflict !== null) {
            $family = (string) ($conflict['family'] ?? '');
            $familyHint = '';
            if ($family !== '' && function_exists('sbaStyleBatchTreatmentFamilyDisplayLabel')) {
                $familyHint = ' (**forbidden repeat family:** ' . sbaStyleBatchTreatmentFamilyDisplayLabel($family) . ').';
            }
            $lines[] = '- **Batch duplicate — Management vs Emergency Management:** an earlier item (**' . $conflict['skill'] . '**) already keyed **“' . $conflict['answer'] . '”**.' . $familyHint . ' You **must NOT** use that same concept again for **' . $currentSkill . '** — pick a **different** emergency or management priority (e.g. antibiotics vs IV fluids for shock vs oxygen if hypoxic vs urgent escalation; aspirin vs reperfusion for ACS).';
        } elseif (!empty($codes['batch_repetition.acs_batch_duplicate_aspirin'])) {
            $lines[] = '- **ACS batch:** do **not** key **immediate aspirin** again if an earlier item in this job already keyed aspirin — use a **different** management or emergency concept for this skill.';
        }
    }
    if (!empty($codes['skill_alignment.emergency_management_partial_component_distractors'])
        || !empty($codes['skill_alignment.emergency_management_partial_component_distractor'])) {
        $keyed = formGeneratorResolveCorrectAnswerText($repairedQuestion);
        $isSepsisCtx = formGeneratorRetryQuestionIsSepsisContext($repairedQuestion);
        $isPeCtx = formGeneratorRetryQuestionIsPeContext($repairedQuestion);
        $lines[] = '- **Emergency Management — REPLACE THE ENTIRE OPTION SET (mandatory):** The first pass used **mirror-fragment** distractors (isolated halves of a combined correct answer). You **must** write a **completely new** set of five options A–E. **Do not** reuse the same option lines or the same pattern.';
        if ($isSepsisCtx) {
            $lines[] = '- **Explicitly forbidden distractors (sepsis):** “**IV fluids only**”, “**antibiotics only**”, “**broad-spectrum IV antibiotics only**”, or any **isolated single component** of a combined fluids-plus-antibiotics key. **Do not** use mirror halves of the correct answer.';
            $lines[] = '- **Sepsis examples:** vasopressors before fluids; oral antibiotics and observe; oxygen only when inappropriate; monitor without treatment; withhold antibiotics until cultures (unsafe sequencing only).';
        }
        if ($isPeCtx) {
            $lines[] = '- **Pulmonary embolism examples:** anticoagulation alone in unstable PE; oxygen or fluids without reperfusion escalation; vasopressor without treating PE; delay reperfusion while observing; thrombolysis despite explicit contraindication; CTPA/imaging before escalation when shock is established.';
        }
        if (!$isSepsisCtx && !$isPeCtx) {
            $lines[] = '- **Explicitly forbidden:** isolated single components of a combined keyed treatment; mirror halves of the correct answer.';
        }
        if ($keyed !== '') {
            $lines[] = '- **First-pass keyed answer to preserve (you may keep this concept):** “' . $keyed . '” — but **all five option lines must be rewritten** with new wording.';
        }
        $lines[] = '- **Required:** every distractor must be a **whole** plausible emergency-management strategy — **not** one isolated part of the keyed combination.';
        $lines[] = '- **Still forbidden:** investigations and wait-for-results distractors — no **blood cultures**, **CT/CTPA**, **imaging**, **scan**, **await results**, or **request test and wait** (unless Investigation skill).';
        $lines[] = '- Keep all options in the **same emergency-management action family** (urgent treatment / resuscitation / escalation only).';
    }
    if (!empty($codes['skill_alignment.emergency_management_investigation_option'])
        || !empty($codes['skill_alignment.emergency_management_investigation_family'])) {
        $lines[] = '- **Emergency Management — no investigations:** remove every investigation, imaging, laboratory request, and “wait for results” option; use treatment/resuscitation/escalation actions only.';
    }
    if (!empty($codes['sepsis.batch_management_combined_resuscitation_reserved_for_emergency'])) {
        $lines[] = '- **Sepsis batch — Management:** key **broad-spectrum IV antibiotics** for **stable** suspected sepsis only. **Do not** key combined **IV fluids plus antibiotics** or a **resuscitation bundle** — that concept is assigned to **Emergency Management** in this job.';
    }
    if (!empty($codes['sepsis.diagnosis_syndrome_vs_source_mismatch'])
        || !empty($codes['sepsis.diagnosis_option_homogeneity'])) {
        $lines[] = '- **Sepsis Diagnosis (mandatory):** **one option family only**. Syndrome lead-in → key **Sepsis** with **competing diagnoses** (anaphylaxis, cardiogenic shock, hypovolaemia, pulmonary embolism, adrenal crisis) — **not** pneumonia / UTI / severe sepsis / septic shock / ARDS mixed together. Source lead-in → **all options** are infection sources; **no Sepsis**. Avoid **severe sepsis** as a routine option.';
    }
    if (!empty($codes['sepsis.investigation_urinary_purpose_mismatch'])) {
        $lines[] = '- **Sepsis Investigation (sepsis.investigation_urinary_purpose_mismatch):** stem already has **dipstick/urinalysis** — key **blood cultures**, **urine culture (MSU)**, or **imaging** for a **further** investigation; do **not** key **urinalysis** again.';
    }
    if (!empty($codes['sepsis.initial_investigation_result_already_in_stem'])) {
        $lines[] = '- **Sepsis Investigation (sepsis.initial_investigation_result_already_in_stem, mandatory):** the stem already reports a **first-line test result** (e.g. urinalysis) — **do not** use an **initial investigation** lead-in. Either **remove the result from the stem** or ask for the **next** / **further** investigation (e.g. blood cultures, imaging) with a matching key.';
    }
    if (!empty($codes['sepsis.initial_investigation_lactate_drift'])) {
        $lines[] = '- **Sepsis Investigation — initial investigation selection:** when testing the **initial investigation** in suspected sepsis, **do not** key **lactate** (or lactate measurement) unless the stem explicitly focuses on **tissue hypoperfusion**, **shock severity**, or **perfusion/severity assessment**. Prefer a **diagnostic or source** key such as **blood cultures**, **urinalysis**, **chest X-ray**, or **FBC** as stem context dictates.';
    }
    $sepsisEmergencyRetry = !empty($codes['sepsis.emergency_batch_duplicate_resuscitation'])
        || !empty($codes['sepsis.emergency_batch_duplicate_scenario_category'])
        || !empty($codes['sepsis.emergency_incomplete_key_without_narrow_lead_in'])
        || !empty($codes['sepsis.emergency_lead_in_scenario_mismatch'])
        || !empty($codes['sepsis.emergency_source_control_investigation_key'])
        || !empty($codes['sepsis.emergency_hypoxic_oxygen_unfair'])
        || !empty($codes['sepsis.emergency_obvious_unsafe_distractors'])
        || !empty($codes['sepsis.emergency_option_length_imbalance'])
        || !empty($codes['sepsis.emergency_repeated_lead_in'])
        || !empty($codes['sepsis.emergency_formulaic_title']);
    if ($sepsisEmergencyRetry && formGeneratorRetryQuestionIsSepsisContext($repairedQuestion)) {
        if (!empty($codes['sepsis.emergency_batch_duplicate_scenario_category'])
            && empty($codes['sepsis.emergency_lead_in_scenario_mismatch'])
            && empty($codes['sepsis.emergency_source_control_investigation_key'])
            && is_array($repairedQuestion['_sepsis_emergency_scenario_plan'] ?? null)) {
            $lines[] = '- **Sepsis Emergency (mandatory):** keep the **LOCKED scenario category** above — the error was **wrong output**, not wrong category. Use a **new** key and option set for that slot; **do not** repeat noradrenaline or fluids-plus-antibiotics from earlier items.';
        } else {
            $lines[] = '- **Sepsis Emergency Management (mandatory):** match the **LOCKED** or reassigned scenario category; **no second noradrenaline**; **vary lead-in**; neutral title.';
        }
        $lines[] = '  - **Options:** similar length; **subtler** sequencing distractors only — **no** withhold-until-cultures, oral antibiotics and observe, oxygen only, monitor without treatment, antipyretics and reassess only, arrange review without treatment.';
        $lines[] = '  - **Justification:** no “underlying issue”.';
    }
    if (!empty($codes['pe.emergency_batch_duplicate_scenario_category'])
        && formGeneratorRetryQuestionIsPeContext($repairedQuestion)) {
        if (is_array($repairedQuestion['_pe_emergency_scenario_plan'] ?? null)) {
            $plan = $repairedQuestion['_pe_emergency_scenario_plan'];
            $lines[] = '- **PE Emergency (mandatory):** keep **LOCKED** slot `' . (string) ($plan['slot'] ?? '') . '` — `' . (string) ($plan['label'] ?? '') . '`. The error was **repeated output**, not wrong category.';
            $lines[] = '- **Required key concept:** ' . (string) ($plan['required_key_concept'] ?? '');
            $lines[] = '- **Do not** repeat oxygen-plus-thrombolysis wording from **initial massive PE** when this slot is **unstable PE / anticoagulation insufficient** or **thrombolysis contraindicated**.';
        } else {
            $lines[] = '- **PE Emergency (mandatory):** use a **distinct** PE emergency scenario category — new stem, lead-in, key, and distractors.';
        }
    }
    if (!empty($codes['pe.emergency_antibiotics_wrong_context'])
        || !empty($codes['pe.massive_shock_needs_escalation_or_reperfusion'])) {
        $lines[] = '- **Pulmonary embolism Emergency Management (mandatory):** do **not** use **sepsis** templates — **no broad-spectrum IV antibiotics** and **no fluids-plus-antibiotics bundle** unless the stem clearly shows **infection**.';
        $lines[] = '- If the stem shows **hypotension**, **shock**, **cardiac arrest**, or **systolic BP under 90 mmHg** with suspected massive / high-risk PE, the keyed answer must include **urgent senior or critical care escalation** and/or **reperfusion** (e.g. **thrombolysis**, embolectomy / catheter-directed pathway per local practice) — **not anticoagulation alone** and **not oxygen alone**.';
        $lines[] = '- **Stable non-shocked PE:** anticoagulation (e.g. LMWH) may be appropriate; still use **emergency actions only** in options — **no CTPA** when the lead-in asks for immediate management.';
    }
    if (!empty($codes['pe_thrombolysis_without_instability'])
        || !empty($codes['pe.emergency_plan_slot_requires_instability'])) {
        $lines[] = '- **Pulmonary embolism — thrombolysis requires instability (mandatory):** do **not** key **thrombolysis / reperfusion** when the stem has **hypoxia only** (e.g. SpO₂ 85%) **without** **hypotension**, **shock**, **BP under 90 mmHg**, **peri-arrest**, or **obstructive collapse**. Either **(a)** add clear **hypotension / shock** to the stem for **massive PE**, **or** **(b)** key **oxygen plus anticoagulation / urgent escalation** for **hypoxic but stable** PE. Keep the **LOCKED PE scenario category** unless the category itself was wrong.';
    }
    if (!empty($codes['pe.emergency_thrombolysis_despite_contraindication'])
        && formGeneratorRetryQuestionIsPeContext($repairedQuestion)) {
        $lines[] = '- **PE thrombolysis contraindicated (mandatory):** do **not** key **thrombolysis/alteplase**. Key **urgent critical care/specialist escalation for alternative reperfusion** (generic wording — not a forced surgical-versus-catheter choice unless explicit).';
    }
    if (!empty($codes['pe.emergency_oxygen_reperfusion_fairness'])
        && formGeneratorRetryQuestionIsPeContext($repairedQuestion)) {
        $lines[] = '- **PE oxygen plus reperfusion fairness (mandatory):** when the stem is **hypoxic** and shocked, the keyed answer must include **oxygen plus urgent thrombolysis/reperfusion** in **one line** — do **not** key **IV fluids plus thrombolysis** without oxygen while **oxygen plus thrombolysis** is a distractor under a generic immediate-management lead-in.';
    }
    if (!empty($codes['prescribing_allergy_contradiction'])) {
        $lines[] = '- **Prescribing allergy contradiction (prescribing_allergy_contradiction, mandatory):** the **stem** states **no known drug allergies** but the **justification** cites an **active** **penicillin allergy**, **drug allergy**, or **amoxicillin contraindicated due to allergy**. Phrases such as **“without penicillin allergy”**, **“no penicillin allergy”**, or **“first-line in the absence of allergy”** are **valid** — do **not** treat them as contradictions. Either **(a)** remove **affirmative** allergy reasoning when the stem denies allergies, **or** **(b)** add documented allergy to the **stem** and key **doxycycline** or **clarithromycin** if that is the intended concept.';
    }
    if (!empty($codes['cap.emergency_hypoxic_oxygen_unfair'])) {
        $lines[] = '- **CAP Emergency Management — hypoxic oxygen fairness (cap.emergency_hypoxic_oxygen_unfair, mandatory):** SpO₂ is **below about 92%** on air and an option includes **oxygen**, but the key omits oxygen. Key **oxygen plus IV fluids plus broad-spectrum IV antibiotics** (or **oxygen plus IV antibiotics plus urgent escalation**) in **one line**, **or** remove standalone **oxygen therapy** distractors, **or** narrow the lead-in to a **non-respiratory** step if appropriate.';
    }
    if (!empty($codes['cap.interpretation_severity_overclaim'])) {
        $lines[] = '- **CAP Interpretation severity (cap.interpretation_severity_overclaim, mandatory):** do **not** key **severe** infection/pneumonia when the stem has only **CRP/WBC/CXR** — add physiological severity cues if severe CAP is intended, **or** key **pneumonia/infection likely** without overclaiming severity.';
    }
    if (!empty($codes['cap.management_antibiotic_selection_drift'])) {
        $lines[] = '- **CAP Management vs Prescribing (cap.management_antibiotic_selection_drift, mandatory):** rewrite options as **broader management strategies** — **not** five antibiotic drug names only. Reserve antimicrobial choice for **Prescribing** in this batch.';
    }
    if (!empty($codes['cap_prescribing_coamoxiclav_overuse_or_unjustified'])
        || !empty($codes['cap_prescribing_diabetes_alone_not_broad_cover'])) {
        $lines[] = '- **Community-acquired pneumonia — Prescribing (cap_prescribing_coamoxiclav_overuse_or_unjustified / cap_prescribing_diabetes_alone_not_broad_cover, mandatory):**';
        $capPath = dirname(__DIR__) . '/../prompts/sba_cap_prescribing_concepts.php';
        if (is_readable($capPath)) {
            require_once $capPath;
        }
        if (function_exists('sbaCapPrescribingAppendCoAmoxOveruseRetryGuidance')) {
            $priors = isset($repairedQuestion['_batch_prior_questions']) && is_array($repairedQuestion['_batch_prior_questions'])
                ? $repairedQuestion['_batch_prior_questions']
                : [];
            $condition = trim((string) ($repairedQuestion['_condition_name'] ?? ''));
            $tutor = trim((string) ($repairedQuestion['_tutor_comments'] ?? ''));
            sbaCapPrescribingAppendCoAmoxOveruseRetryGuidance($lines, $priors, $condition, $tutor);
        } else {
            $lines[] = '- **Do not use type 2 diabetes mellitus alone as the justification for co-amoxiclav in stable mild CAP.** Key **amoxicillin** only if not already used in the batch; otherwise use a **different scenario slot** (allergy, atypical, aspiration, treatment failure, severe/IV).';
        }
    }
    if (!empty($codes['cap_prescribing_same_antibiotic'])) {
        $lines[] = '- **CAP Prescribing batch diversity (cap_prescribing_same_antibiotic, mandatory):** regenerate using a **different scenario slot** and **different keyed antibiotic**. Slots: **(1)** uncomplicated → **amoxicillin** (use **once** per batch only); **(2)** penicillin **allergy** → **doxycycline** or **clarithromycin**; **(3)** **atypical** features → **doxycycline** or **clarithromycin**; **(4)** **frailty/comorbidity** → **co-amoxiclav** if justified; **(5)** **aspiration risk** → **co-amoxiclav** if justified; **(6)** **unable to take oral / severe CAP** → **IV antibiotic**; **(7)** **recent antibiotics / treatment failure** → broader or alternative regimen.';
        $lines[] = '- **Vary** the **stem** (age, frailty, allergy, atypical cues, oral tolerance, severity), **lead-in wording**, and **all five option lines** — not only the keyed drug.';
        $lines[] = '- Keep **all options** as **prescribing / antimicrobial choices** only.';
    }
    if (!empty($codes['acute.oxygen_distractor_hypoxia'])) {
        $cond = trim((string) ($repairedQuestion['_condition_name'] ?? ''));
        $isPe = function_exists('sbaGeneratorConditionLooksPulmonaryEmbolism')
            && sbaGeneratorConditionLooksPulmonaryEmbolism($cond);
        if ($isPe && preg_match('/^emergency\s+management$/i', $currentSkill)) {
            $lines[] = '- **Pulmonary embolism — oxygen vs reperfusion fairness (acute.oxygen_distractor_hypoxia):** when testing **initial/emergency management** in suspected **massive PE with shock** and the key is **thrombolysis / reperfusion**, either: **(a)** revise the **lead-in** to ask for **definitive emergency / reperfusion treatment** (e.g. which treatment addresses the life-threatening cause; most appropriate definitive emergency treatment; massive PE with shock), **or** **(b)** revise the keyed answer to include **oxygen plus escalation/reperfusion** in one line, **or** **(c)** **remove standalone oxygen therapy** as a distractor. Keep all options as **emergency management actions**.';
        }
    }
    if (formGeneratorRetryQuestionIsSepsisContext($repairedQuestion)
        && (!empty($codes['emergency.sepsis_unstable_antibiotics_only_high'])
        || !empty($codes['emergency.sepsis_unstable_antibiotics_only_without_fluid_resuscitation'])
        || !empty($codes['emergency.sepsis_unstable_keyed_monitoring_too_passive']))) {
        $lines[] = '- **Sepsis batch — Emergency Management (mandatory):** use an **unstable** patient (hypotension, shock, or raised lactate in the stem). The keyed answer **must** include **IV fluid resuscitation plus broad-spectrum IV antibiotics**, a **sepsis resuscitation bundle**, or **urgent escalation / critical care** if **fluid-refractory shock** is stated — **do not** key **antibiotics alone** or **monitor closely** without resuscitation.';
        $lines[] = '- **Forbidden:** “begin IV antibiotics and monitor closely”, antibiotics without fluids in hypotensive sepsis, passive monitoring-only keys.';
        $lines[] = '- **Preferred key examples:** “Start IV fluids and broad-spectrum IV antibiotics”; “Begin sepsis resuscitation bundle”; “Start IV fluid resuscitation and broad-spectrum IV antibiotics with urgent critical care review” when refractory shock is described.';
    }
    $lines[] = '- Do **not** include **ECG**, **troponin**, **chest X-ray**, **X-ray**, **CT**, **scan**, **imaging**, or diagnostic **test** options when **Current skill** is **Management** or **Emergency Management** and the **lead_in** asks for **management**, **treatment**, **immediate action**, or **first action** (unless the lead_in explicitly asks for investigation / initial assessment only).';
    $lines[] = '- Keep **all options** in the **same decision family** (e.g. all treatment/management actions, or all investigations aligned with the lead-in).';
    $lines[] = '- Do **not** put exam question wording in the **stem**; the stem must be **clinical narrative only**; put the **full question** in **lead_in** only.';
    $lines[] = '- Ensure any **skill** / tag / title wording in the JSON matches the selected **Current skill**: **' . $currentSkill . '**.';
    $lines[] = '';
    $lines[] = '---';
    $lines[] = '';
    $lines[] = $originalPrompt;

    return implode("\n", $lines);
}

/**
 * Temporary debug log for main SBA generator prompts (no API keys or secrets).
 */
function formGeneratorDebugLogMainSbaPrompt(string $userPrompt, string $phase, int $jobId = 0): void
{
    if (!is_string($userPrompt) || $userPrompt === '') {
        $type = get_debug_type($userPrompt);
        $userPrompt = '(invalid prompt: ' . $type . ', coerced length ' . strlen((string) $userPrompt) . ')';
    }

    $logDir = dirname(__DIR__, 2) . '/logs';
    if (!is_dir($logDir)) {
        @mkdir($logDir, 0755, true);
    }
    $logFile = $logDir . '/sba_generator_prompt_debug.log';
    $len = strlen($userPrompt);
    $hasJsonOutput = stripos($userPrompt, 'Return valid JSON ONLY') !== false
        || stripos($userPrompt, 'Return JSON only') !== false;
    $entry = [
        'time' => gmdate('c'),
        'phase' => $phase,
        'job_id' => $jobId,
        'length' => $len,
        'sha256' => hash('sha256', $userPrompt),
        'has_json_output_instructions' => $hasJsonOutput,
        'starts_with' => substr($userPrompt, 0, 500),
        'ends_with' => $len > 500 ? substr($userPrompt, -500) : '',
        'full_prompt' => $userPrompt,
    ];
    @file_put_contents(
        $logFile,
        json_encode($entry, JSON_UNESCAPED_UNICODE) . "\n",
        FILE_APPEND | LOCK_EX
    );
}

/**
 * OpenAI chat model for main SBA question generation (not variant/RISr).
 */
function formGeneratorOpenAiSbaGenerationModel(): string
{
    if (defined('OPENAI_SBA_GENERATION_MODEL')) {
        $m = trim((string) OPENAI_SBA_GENERATION_MODEL);
        if ($m !== '') {
            return $m;
        }
    }

    return 'gpt-4o-mini';
}

/**
 * OpenAI chat model for main SBA optional AI review (ai_review_question.php).
 */
function formGeneratorOpenAiSbaReviewModel(): string
{
    if (defined('OPENAI_SBA_REVIEW_MODEL')) {
        $m = trim((string) OPENAI_SBA_REVIEW_MODEL);
        if ($m !== '') {
            return $m;
        }
    }

    return formGeneratorOpenAiSbaGenerationModel();
}

/**
 * OpenAI chat model for main SBA clinical safety review (clinical_safety_review_question.php).
 */
function formGeneratorOpenAiSbaSafetyReviewModel(): string
{
    if (defined('OPENAI_SBA_SAFETY_REVIEW_MODEL')) {
        $m = trim((string) OPENAI_SBA_SAFETY_REVIEW_MODEL);
        if ($m !== '') {
            return $m;
        }
    }

    return formGeneratorOpenAiSbaReviewModel();
}

/**
 * OpenAI chat model for main SBA AI repair pass (ai_repair_question.php).
 */
function formGeneratorOpenAiSbaRepairModel(): string
{
    if (defined('OPENAI_SBA_REPAIR_MODEL')) {
        $m = trim((string) OPENAI_SBA_REPAIR_MODEL);
        if ($m !== '') {
            return $m;
        }
    }

    return formGeneratorOpenAiSbaReviewModel();
}

/**
 * @return ?array<string, mixed>
 */
function formGeneratorNormalizeDecodedJsonToAssoc($decoded): ?array
{
    if (!is_array($decoded)) {
        return null;
    }
    if (array_is_list($decoded)) {
        if (isset($decoded[0]) && is_array($decoded[0])) {
            return $decoded[0];
        }

        return null;
    }

    return $decoded;
}

/**
 * Extract first plausible JSON object/array fragment from mixed text.
 */
function formGeneratorExtractFirstJsonFragment(string $content): string
{
    if (preg_match('/\{[\s\S]*\}/', $content, $m)) {
        return trim((string) $m[0]);
    }
    if (preg_match('/\[[\s\S]*\]/', $content, $m)) {
        return trim((string) $m[0]);
    }

    return '';
}

/**
 * @return ?array<string, mixed>
 */
function formGeneratorDecodeOpenAiJsonContent(string $content): ?array
{
    $content = trim($content);
    if ($content === '') {
        return null;
    }
    if (preg_match('/^```(?:json)?\s*(.*?)\s*```\s*$/is', $content, $m)) {
        $content = trim((string) $m[1]);
    }
    $decoded = json_decode($content, true);
    $normalized = formGeneratorNormalizeDecodedJsonToAssoc($decoded);
    if (is_array($normalized)) {
        return $normalized;
    }

    // Fallback: extract first JSON object/array from mixed prose output.
    $candidate = formGeneratorExtractFirstJsonFragment($content);
    if ($candidate !== '') {
        $decoded = json_decode($candidate, true);
        $normalized = formGeneratorNormalizeDecodedJsonToAssoc($decoded);
        if (is_array($normalized)) {
            return $normalized;
        }
    }

    return null;
}

/**
 * @return array{
 *   ok: bool,
 *   data: ?array<string, mixed>,
 *   error: string,
 *   raw: string,
 *   http_status: int,
 *   response_len: int,
 *   timed_out: bool
 * }
 */
function formGeneratorOpenAiChatJsonQuestion(
    string $userPrompt,
    string $debugPhase = '',
    int $debugJobId = 0,
    float $temperature = 0.7,
    ?string $model = null
): array
{
    if (!is_string($userPrompt)) {
        return [
            'ok' => false,
            'data' => null,
            'error' => 'Internal error: prompt must be a string (got ' . get_debug_type($userPrompt) . ').',
            'raw' => '',
            'http_status' => 0,
            'response_len' => 0,
            'timed_out' => false,
        ];
    }
    if (trim($userPrompt) === '' || $userPrompt === '1') {
        return [
            'ok' => false,
            'data' => null,
            'error' => 'Internal error: invalid or empty prompt before OpenAI call.',
            'raw' => '',
            'http_status' => 0,
            'response_len' => 0,
            'timed_out' => false,
        ];
    }

    if ($debugPhase !== '') {
        formGeneratorDebugLogMainSbaPrompt($userPrompt, $debugPhase, $debugJobId);
    }

    if (!defined('OPENAI_API_KEY')) {
        return ['ok' => false, 'data' => null, 'error' => 'OpenAI API key is not configured.', 'raw' => '', 'http_status' => 0, 'response_len' => 0, 'timed_out' => false];
    }

    $modelName = ($model !== null && trim($model) !== '')
        ? trim($model)
        : formGeneratorOpenAiSbaGenerationModel();

    $url = 'https://api.openai.com/v1/chat/completions';
    $data = [
        'model' => $modelName,
        'messages' => [
            [
                'role' => 'user',
                'content' => $userPrompt,
            ],
        ],
        'temperature' => $temperature,
    ];
    $headers = [
        'Content-Type: application/json',
        'Authorization: Bearer ' . OPENAI_API_KEY,
    ];
    $ch = curl_init($url);
    if ($ch === false) {
        return ['ok' => false, 'data' => null, 'error' => 'Could not initialise HTTP client.', 'raw' => '', 'http_status' => 0, 'response_len' => 0, 'timed_out' => false];
    }
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
    curl_setopt($ch, CURLOPT_TIMEOUT, 110);
    $response = curl_exec($ch);
    $curlErrNo = curl_errno($ch);
    $curlErr = curl_error($ch);
    $httpStatus = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $responseLen = is_string($response) ? strlen($response) : 0;
    $timedOut = $curlErrNo === 28;
    curl_close($ch);
    if ($response === false) {
        $msg = $curlErrNo === 28
            ? 'OpenAI request timed out.'
            : 'OpenAI request failed.';
        if ($curlErr !== '') {
            $msg .= ' ' . $curlErr;
        }

        return ['ok' => false, 'data' => null, 'error' => $msg, 'raw' => '', 'http_status' => $httpStatus, 'response_len' => $responseLen, 'timed_out' => $timedOut];
    }
    $response_data = json_decode($response, true);
    $content = $response_data['choices'][0]['message']['content'] ?? '';
    $decoded = formGeneratorDecodeOpenAiJsonContent($content);
    if ($decoded === null) {
        return ['ok' => false, 'data' => null, 'error' => 'AI did not return valid JSON.', 'raw' => $content, 'http_status' => $httpStatus, 'response_len' => $responseLen, 'timed_out' => false];
    }

    return ['ok' => true, 'data' => $decoded, 'error' => '', 'raw' => $content, 'http_status' => $httpStatus, 'response_len' => $responseLen, 'timed_out' => false];
}
