"""
Gemini AI Service.
Used for testing — will be replaced with Claude in production.

Two functions:
1. get_first_question()  — called when session starts
2. get_next_question()   — called after every answer submission
"""

import json
import logging
import re
import litellm
from app.core.config import settings

logger = logging.getLogger(__name__)

INTAKE_COMPLETE_TOKEN = "INTAKE_COMPLETE"

_SKIP_ANSWERED = (
    "CRITICAL — Never repeat a question: "
    "Rule 1 — HISTORY LOCK: Every Q: line in the conversation history is a question already explicitly asked. "
    "Never ask any question whose meaning matches a Q: line already in history. "
    "If the patient gave any response at all (even vague or partial), that Q: topic is closed — move on. "
    "Rule 2 — CONTENT CHECK: A standard question covering a compound topic (e.g. chest pain with sub-parts) "
    "is only fully closed when both the main YES/NO AND every sub-question detail has appeared in Q:/A: history. "
    "A patient mentioning a symptom in passing does NOT satisfy this — all sub-parts must be explicitly collected. "
    "Rule 3 — AMBIGUOUS ANSWERS: Accept any response and move on. Never re-ask for clarity. "
    "Rule 4 — IMPLICIT ANSWERS: Before asking any question, scan ALL A: lines in the conversation history "
    "against EVERY remaining standard question, not just the next one you're about to ask — an answer can "
    "resolve a question far down the list, one the doctor never asked directly, or several at once. "
    "If a patient's previous answer already contains the information a standard question is asking for "
    "(e.g. patient said 'cold for two days' — this answers both the chief complaint AND the onset/duration question), "
    "that question is resolved — skip it entirely, however many questions this applies to this turn, and move to "
    "the next genuinely unanswered one. Re-check this on every single turn using the FULL conversation so far, not "
    "just the most recent exchange — a question resolved several turns ago must stay skipped even if the "
    "conversation has since moved on to unrelated topics. "
    "Always tag your response with the [question_id] from the standard list."
)

_RECORDS_INSTRUCTION = (
    "PATIENT RECORDS — USE TO SKIP QUESTIONS: "
    "The uploaded patient records above contain pre-existing medical information. "
    "Treat each piece of information in those records as an already-known answer. "
    "Before asking any standard question, check whether the records clearly answer it. "
    "If the records provide a clear answer for a question — skip that question entirely and move to the next unanswered one. "
    "Only ask questions whose answers are NOT present in the patient records."
)

_LLM_INSTRUCTION = (
    "STEP 1 — EXTRACT KNOWN FACTS: Before choosing the next question, mentally read every A: line "
    "in the conversation and list all facts the patient has already stated — symptoms, durations, "
    "timing, severity, history, medications, etc. Treat every fact in an A: line as already known, "
    "regardless of which Q: it was given in response to. "
    "Example: if the patient said 'cold and cough from past two days', the facts 'chief complaint = cold/cough' "
    "AND 'symptom duration = 2 days' are BOTH already known — do NOT ask about onset/duration again. "
    "STEP 2 — SKIP ANSWERED QUESTIONS: Check EVERY remaining standard question against the facts from STEP 1 — "
    "the whole list, not only the next one in order. Facts can resolve questions out of order (a patient "
    "volunteering something while answering a different question, or during free-form back-and-forth the doctor "
    "steers away from the standard script) — catch and skip ALL such questions this turn, however many there are, "
    "not just one. Only ask a question if its answer is genuinely not yet known after checking the full list. "
    "STEP 2b — REPORT SKIPPED QUESTIONS: List every standard question resolved in STEP 2 — ALL of them, not just "
    "the most obvious one. On the FIRST line of your response output: "
    "SKIPPED: <comma-separated question IDs from the standard list>. "
    "Example: SKIPPED: q_ct_002, q_ct_005. Omit this line entirely if no questions are being skipped. "
    "IMPORTANT: The SKIPPED line is supplemental — you MUST still output the next unanswered question "
    "on the line immediately after it. Never output ONLY a SKIPPED line with nothing following it. "
    "STEP 3 — ASK THE NEXT UNANSWERED QUESTION following these rules: "
    "  (a) Ask ONLY the main question first (the part before any 'If yes — ask:' clause). "
    "  (b) If the patient answers NO — move to the next standard question. "
    "  (c) If the patient answers YES — ask each sub-question listed after 'If yes — ask:' "
    "      ONE AT A TIME, one per turn, in the order listed. Use the same [question_id] tag for all sub-questions. "
    "  (d) After every sub-question is answered — move to the next standard question. "
    f"{_SKIP_ANSWERED} "
    "Always tag your question with its [question_id]. "
    "Ask in English. "
    "If the intake is complete, respond with exactly: INTAKE_COMPLETE"
)


def _build_questions_text(questions: list[dict]) -> str:
    return "\n".join(f"{i}. [{q['id']}] {q['text']}" for i, q in enumerate(questions, start=1))


_TAG_PATTERN = re.compile(r"\[\s*([a-zA-Z0-9_\-]+)\s*\]")
_SKIPPED_PATTERN = re.compile(r'^SKIPPED:\s*(.+)$', re.IGNORECASE | re.MULTILINE)


def _parse_skipped_ids(response: str, questions: list[dict]) -> list[str]:
    valid_ids = {q["id"] for q in questions}
    match = _SKIPPED_PATTERN.search(response)
    if not match:
        return []
    line_content = match.group(1)
    # LLM may use [id] brackets or plain comma-separated ids
    bracketed = _TAG_PATTERN.findall(line_content)
    candidates = bracketed if bracketed else [s.strip() for s in line_content.split(',')]
    return [id_ for id_ in candidates if id_ in valid_ids]


def _parse_tagged_question(response: str, questions: list[dict]) -> tuple[str | None, str]:
    question_id_set = {q["id"] for q in questions}
    text_to_id = {q["text"].strip().lower(): q["id"] for q in questions}

    cleaned = response.strip()
    match = _TAG_PATTERN.search(cleaned)
    if match:
        tag = match.group(1)
        text = (cleaned[:match.start()] + cleaned[match.end():]).strip()
        return (tag if tag in question_id_set else None), text

    matched_id = text_to_id.get(cleaned.lower())
    return matched_id, cleaned


def _build_user_message(
    patient_info: dict,
    conversation: list,
    latest_answer: str,
    questions: list[dict],
    patient_records: list[str] | None = None,
    language: str = "ENGLISH",
) -> str:
    patient_block = (
        f"Patient Info:\n"
        f"- Name: {patient_info.get('name')}\n"
        f"- Age: {patient_info.get('age')}\n"
        f"- Gender: {patient_info.get('gender')}\n"
        f"- Patient ID: {patient_info.get('daily_id') or 'Not provided'}"
    )

    questions_block = f"Standard Questions to Cover:\n{_build_questions_text(questions)}"

    if patient_records:
        records_block = "Patient Records (uploaded documents):\n" + "\n---\n".join(patient_records)
    else:
        records_block = ""

    if conversation:
        history_lines = []
        for qa in conversation:
            history_lines.append(f"Q: {qa['question']}")
            history_lines.append(f"A: {qa['answer']}")
        history_block = "Conversation so far:\n" + "\n".join(history_lines)
    else:
        history_block = "Conversation so far:\n(This is the first answer)"

    parts = [patient_block, questions_block]
    if records_block:
        parts.append(records_block)
        parts.append(_RECORDS_INSTRUCTION)
    parts += [history_block, f"Latest patient answer: {latest_answer}", _LLM_INSTRUCTION]
    return "\n\n".join(parts)


async def _call_litellm(system_prompt: str, user_message: str) -> str:
    response = await litellm.acompletion(
        model=f"openai/{settings.LITELLM_PROVIDER_MODEL_NAME}",
        api_key=settings.LITELLM_API_KEY,
        api_base=settings.LITELLM_PROVIDER_BASE_URL,
        temperature=0,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_message},
        ],
    )
    return response.choices[0].message.content.strip()


async def get_first_question(
    patient_info: dict,
    system_prompt: str,
    questions: list[dict],
    patient_records: list[str] | None = None,
    language: str = "ENGLISH",
) -> dict:
    user_message = _build_user_message(
        patient_info, conversation=[], latest_answer="",
        questions=questions, patient_records=patient_records, language=language,
    )
    response = await _call_litellm(system_prompt, user_message)
    question_id, question_text = _parse_tagged_question(response, questions)
    if question_id is None and questions:
        question_id = questions[0]["id"]
    return {"firstQuestion": {"id": question_id, "text": question_text}}


async def get_next_question(
    patient_info: dict,
    conversation: list,
    latest_answer: str,
    system_prompt: str,
    questions: list[dict],
    patient_records: list[str] | None = None,
    language: str = "ENGLISH",
) -> dict:
    user_message = _build_user_message(
        patient_info, conversation, latest_answer,
        questions=questions, patient_records=patient_records, language=language,
    )
    response = await _call_litellm(system_prompt, user_message)
    logger.info("get_next_question LLM response: %r", response)

    if INTAKE_COMPLETE_TOKEN in response:
        return {"nextQuestion": None, "intakeComplete": True, "skippedIds": []}

    skipped_ids = _parse_skipped_ids(response, questions)
    # Strip the SKIPPED: line before extracting the question so it doesn't
    # get included in the question text shown to the user.
    response_for_parsing = _SKIPPED_PATTERN.sub('', response).strip()
    question_id, question_text = _parse_tagged_question(response_for_parsing, questions)
    return {"nextQuestion": {"id": question_id, "text": question_text}, "intakeComplete": False, "skippedIds": skipped_ids}


async def generate_summary(
    patient_info: dict,
    conversation: list,
    intake_date: str,
    summary_prompt: str,
    patient_records: list[str] | None = None,
) -> str:
    """
    Calls LiteLLM to generate a structured patient summary.
    summary_prompt comes from mstr_department.llm_summary_prompt.
    """
    patient_block = (
        f"Patient Info:\n"
        f"- Name: {patient_info.get('name')}\n"
        f"- Age: {patient_info.get('age')}\n"
        f"- Gender: {patient_info.get('gender')}\n"
        f"- Patient ID: {patient_info.get('daily_id') or 'Not provided'}\n"
        f"- Date: {intake_date}"
    )
    history_lines = []
    for qa in conversation:
        history_lines.append(f"Q: {qa['question']}")
        history_lines.append(f"A: {qa['answer']}")
    history_block = "Full Intake Conversation:\n" + "\n".join(history_lines)

    parts = [patient_block, history_block]
    if patient_records:
        parts.append("Patient Records:\n" + "\n---\n".join(patient_records))
    parts.append("Generate the patient case summary.")
    user_message = "\n\n".join(parts)

    raw = await _call_litellm(summary_prompt, user_message)
    cleaned = re.sub(r'^```(?:json)?\s*', '', raw.strip())
    cleaned = re.sub(r'\s*```$', '', cleaned)
    try:
        parsed = json.loads(cleaned)
        return json.dumps(parsed)
    except json.JSONDecodeError:
        return json.dumps({"additionalNotes": cleaned})


_TRANSLITERATE_PROMPT = {
    "TELUGU": (
        "Transliterate the following Telugu text to Roman/English script using simple, natural romanization. "
        "Do NOT translate the meaning. "
        "Use simple spellings — do not double consonants and do not mark long vowels (use 'a' not 'aa', 'u' not 'uu'). "
        "If any Telugu script word is a phonetic spelling of an English word (for example ఇయర్స్ for 'years', ఫాస్ట్ for 'past', సర్జరీ for 'surgery'), restore it to the correct English word. "
        "Return only the transliterated text, nothing else.\n\n"
    ),
    "HINDI": (
        "Transliterate the following Hindi text to Roman/English script using simple, natural romanization. "
        "Do NOT translate the meaning. "
        "Use simple spellings — do not double consonants and do not mark long vowels (use 'a' not 'aa', 'u' not 'uu'). "
        "If any Devanagari word is a phonetic spelling of an English word, restore it to the correct English word. "
        "Return only the transliterated text, nothing else.\n\n"
    ),
}


async def transliterate_to_roman(text: str, language: str = "TELUGU") -> str:
    prompt = _TRANSLITERATE_PROMPT.get(language, _TRANSLITERATE_PROMPT["TELUGU"])
    response = await litellm.acompletion(
        model=f"openai/{settings.LITELLM_PROVIDER_MODEL_NAME}",
        api_key=settings.LITELLM_API_KEY,
        api_base=settings.LITELLM_PROVIDER_BASE_URL,
        messages=[
            {
                "role": "system",
                "content": "You are a transliteration tool. Output ONLY the transliterated text. No reasoning, no explanation, no thinking process.",
            },
            {
                "role": "user",
                "content": f"{prompt}Text: {text}",
            },
        ],
    )
    return response.choices[0].message.content.strip()


async def extract_from_image(base64_image: str, mime_type: str) -> str:
    response = await litellm.acompletion(
        model=f"openai/{settings.LITELLM_PROVIDER_MODEL_NAME}",
        api_key=settings.LITELLM_API_KEY,
        api_base=settings.LITELLM_PROVIDER_BASE_URL,
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": {"url": f"data:{mime_type};base64,{base64_image}"},
                    },
                    {
                        "type": "text",
                        "text": (
                            "Extract all clinical and medical information from this patient record document. "
                            "Focus on: diagnoses, current medications with dosages, allergies, previous surgeries, "
                            "test results, vitals, doctor observations, and care plan notes. "
                            "Do NOT include patient name, age, or gender. "
                            "Return plain text only — no markdown, no bullet symbols, no asterisks. "
                            "Use clear section headings followed by the details."
                        ),
                    },
                ],
            }
        ],
    )
    return response.choices[0].message.content.strip()


_CONTINUATION_INSTRUCTION = (
    "CONTINUATION CHECK: if a possibly-incomplete previous utterance is given below, decide whether the FIRST "
    "turn/entry you produce is clearly the SAME speaker resuming and finishing that exact thought after a "
    "pause (not a new, independent statement) — e.g. previous = \"I have been\" and the new turn is \"here it "
    "is and they have gradually changed it to worse\" is one broken sentence, not two separate ones. If so, "
    "set \"continuesFromPrevious\": true on that FIRST turn only (never on later ones), and use the previous "
    "utterance's text as context so your \"mappings\" for that turn reflect the FULL, completed thought — but "
    "still return only the NEW words as its \"text\"/entry text, not the previous text repeated. Set "
    "\"continuesFromPrevious\": false whenever there is no previous utterance given, or the new turn is not a "
    "continuation of it."
)

_DOCTOR_PRIORITY_INSTRUCTION = (
    "DOCTOR PRIORITY OVERRIDE: the recent conversation context is given in chronological order (oldest first). "
    "If a DOCTOR entry/turn restates, corrects, adds detail to, or otherwise contradicts information already "
    "given earlier in this same conversation for a standard question — for example the doctor says 'actually, "
    "make that three days, not two' or repeats back and clarifies something the patient said earlier — still "
    "map it to that same question with high confidence, and extract the doctor's corrected wording as the "
    "answer. This later doctor statement is authoritative: it takes PRIORITY over the earlier answer for that "
    "question even though they conflict — never skip or lower your confidence on a doctor's own restatement "
    "just because that question already appears answered earlier in the conversation."
)

_CONVERSATION_CLASSIFY_PROMPT = (
    "You are a clinical conversation-tagging assistant. You will be shown one or more diarized speech "
    "entries from a single audio segment of a live, free-flowing conversation during a medical intake — "
    "each entry is one speaker turn, already separated by speaker-diarization, and labeled with an "
    "anonymous speaker id (e.g. 'speaker_0') that is only consistent WITHIN this segment, never across "
    "segments. You are also given the most recent prior classified utterances for context, and a bank of "
    "standard intake questions the doctor is expected to cover during this conversation.\n\n"
    "There are usually two roles in this conversation: DOCTOR and PATIENT. Sometimes a family member or "
    "other companion accompanies the patient and speaks on their behalf (e.g. answering for them, adding "
    "details) — treat ANY such non-doctor speaker as PATIENT context, exactly like the patient's own speech. "
    "Never invent a third role — the only valid roles are DOCTOR, PATIENT, and SMALL_TALK (greetings, "
    "chit-chat, or speech unrelated to the intake).\n\n"
    "SPEAKER-ID CONSISTENCY: entries sharing the SAME speaker id (e.g. two entries both labeled "
    "'speaker_0') are the SAME physical person talking — diarization has already told you who is who. "
    "Before classifying any single entry, first decide each DISTINCT speaker id's role ONCE, using "
    "everything that speaker id says across ALL their entries in this segment together (not just one "
    "entry in isolation) — then apply that SAME role to every entry with that speaker id. NEVER let two "
    "entries that share a speaker id end up with different roles just because one entry's text alone is "
    "short or ambiguous (e.g. \"Or only while walking\" from the same speaker id as a clear question a "
    "moment earlier is still that speaker's role, not a fresh guess from the fragment alone).\n\n"
    "For EACH entry, in the same order given:\n"
    "1. Assign the speaker role you already decided for that entry's speaker id above: DOCTOR (that "
    "speaker is asking questions/giving instructions), PATIENT (that speaker, or anyone accompanying "
    "them, is describing symptoms or answering), or SMALL_TALK (greetings/chit-chat unrelated to the "
    "intake — only when that speaker's role genuinely isn't DOCTOR or PATIENT, not merely because one "
    "entry sounds like small talk while the rest of that speaker's entries are clearly DOCTOR/PATIENT).\n"
    "2. A single entry very often answers MORE THAN ONE standard question at once — for example "
    "'I have high blood pressure and high cholesterol, but not diabetes' answers three separate standard "
    "questions in one sentence. Using the recent context to see what was asked, extract EVERY standard "
    "question this entry answers — do not stop after the first one you notice. Only include a mapping when "
    "reasonably confident; skip anything you are not sure about.\n"
    "3. For each mapped question, extract the direct answer to THAT specific question only, stripped of "
    "filler words and the parts of the sentence that belong to other questions.\n"
    "4. If the speaker is DOCTOR, additionally decide whether this entry is a genuinely new clinical "
    "question that is NOT covered by the standard bank — a paraphrase or rewording of a standard question "
    "does not count, and neither does small talk, an instruction, or an acknowledgement. Only when it is "
    "truly a distinct clinical question outside the bank, return it cleaned of filler words as "
    "'customQuestion'. Otherwise return null.\n\n"
    "CRITICAL — output alignment: every entry is numbered starting at 0 (\"Entry 0\", \"Entry 1\", ...). "
    "You MUST return EXACTLY ONE object per entry, and EVERY object MUST include \"entryIndex\" set to that "
    "entry's number. NEVER merge two entries into one object, NEVER split one entry into two objects, and "
    "NEVER omit or reorder an index — the caller re-aligns your response by \"entryIndex\", not by array "
    "position, so a missing or duplicated index silently drops that entry's real classification.\n\n"
    f"{_CONTINUATION_INSTRUCTION}\n\n"
    f"{_DOCTOR_PRIORITY_INSTRUCTION}\n\n"
    "Respond with ONLY a JSON array, no markdown, no explanation:\n"
    '[{"entryIndex": <int>, "speakerRole": "DOCTOR" | "PATIENT" | "SMALL_TALK", "mappings": '
    '[{"questionId": "<id>", "answer": "<string>", "confidence": <number between 0.0 and 1.0>}, ...], '
    '"customQuestion": "<string or null>", "continuesFromPrevious": <true or false>}, ...]\n'
    'Return "mappings": [] when nothing maps, "customQuestion": null when it does not apply, and '
    '"continuesFromPrevious": false on every entry unless instructed otherwise above.'
)

_DEFAULT_CLASSIFICATION = {"speakerRole": "SMALL_TALK", "mappings": [], "customQuestion": None, "continuesFromPrevious": False}


def _parse_one_classification(item: object, question_ids: set[str]) -> dict:
    if not isinstance(item, dict):
        return dict(_DEFAULT_CLASSIFICATION)

    speaker_role = item.get("speakerRole")
    if speaker_role not in ("DOCTOR", "PATIENT", "SMALL_TALK"):
        speaker_role = "SMALL_TALK"

    raw_mappings = item.get("mappings")
    if not isinstance(raw_mappings, list):
        raw_mappings = []

    mappings = []
    for mapping in raw_mappings:
        if not isinstance(mapping, dict):
            continue
        question_id = mapping.get("questionId")
        if question_id not in question_ids:
            continue
        answer = mapping.get("answer")
        if not isinstance(answer, str) or not answer.strip():
            continue
        try:
            confidence = float(mapping.get("confidence") or 0.0)
        except (TypeError, ValueError):
            confidence = 0.0
        mappings.append({"questionId": question_id, "answer": answer, "confidence": confidence})

    custom_question = item.get("customQuestion")
    if speaker_role != "DOCTOR" or not isinstance(custom_question, str) or not custom_question.strip():
        custom_question = None

    continues_from_previous = item.get("continuesFromPrevious") is True

    return {
        "speakerRole": speaker_role,
        "mappings": mappings,
        "customQuestion": custom_question,
        "continuesFromPrevious": continues_from_previous,
    }


def _build_previous_utterance_block(previous_utterance: dict | None) -> str:
    if not previous_utterance:
        return ""
    return (
        "\n\nPOSSIBLY INCOMPLETE PREVIOUS UTTERANCE (unresolved — no confident answer matched yet, likely "
        f"because the speaker paused mid-sentence before finishing): {previous_utterance['speakerRole']}: "
        f"{previous_utterance['text']}\n"
        f"{_CONTINUATION_INSTRUCTION}"
    )


async def classify_and_map_utterances(
    entries: list[dict],
    recent_utterances: list[dict],
    questions: list[dict],
    previous_utterance: dict | None = None,
) -> list[dict]:
    """Classifies every diarized entry from one audio segment in a single LLM call — one
    result per entry, in order. entries: [{"speakerId": ..., "text": ...}, ...]. previous_utterance,
    when given, is the most recent still-unmapped utterance — a candidate for the FIRST entry to
    continue rather than a fresh turn (see _CONTINUATION_INSTRUCTION)."""
    if not entries:
        return []

    questions_block = f"Standard Questions Bank:\n{_build_questions_text(questions)}"

    if recent_utterances:
        context_lines = [f"{u['speakerRole']}: {u['text']}" for u in recent_utterances]
        context_block = "Recent conversation context (oldest first):\n" + "\n".join(context_lines)
    else:
        context_block = "Recent conversation context:\n(none — this is the first utterance)"
    context_block += _build_previous_utterance_block(previous_utterance)

    entries_block = "\n".join(
        f"Entry {i} (speaker {e['speakerId']}): {e['text']}" for i, e in enumerate(entries)
    )
    user_message = (
        f"{questions_block}\n\n{context_block}\n\n"
        f"Diarized entries to classify, in order:\n{entries_block}"
    )

    raw = await _call_litellm(_CONVERSATION_CLASSIFY_PROMPT, user_message)
    cleaned = re.sub(r'^```(?:json)?\s*', '', raw.strip())
    cleaned = re.sub(r'\s*```$', '', cleaned)

    try:
        parsed = json.loads(cleaned)
    except json.JSONDecodeError:
        logger.warning("classify_and_map_utterances: could not parse LLM response as JSON: %r", raw)
        return [dict(_DEFAULT_CLASSIFICATION) for _ in entries]

    if not isinstance(parsed, list):
        logger.warning("classify_and_map_utterances: LLM response was not a JSON array: %r", raw)
        return [dict(_DEFAULT_CLASSIFICATION) for _ in entries]

    # Align by the LLM-echoed "entryIndex", never by raw array position — a model that
    # merges, splits, skips, or reorders entries in its response (which happens routinely
    # on ambiguous/messy audio) would otherwise shift every classification after the first
    # mismatch onto the wrong entry, e.g. labeling a patient's answer as the doctor's.
    question_ids = {q["id"] for q in questions}
    by_index: dict[int, dict] = {}
    for item in parsed:
        if not isinstance(item, dict):
            continue
        try:
            entry_index = int(item.get("entryIndex"))
        except (TypeError, ValueError):
            continue
        if entry_index in by_index:
            continue  # keep the first classification given for a duplicated index
        by_index[entry_index] = _parse_one_classification(item, question_ids)

    if len(by_index) != len(entries):
        logger.warning(
            "classify_and_map_utterances: entryIndex coverage mismatch — %d entries, %d indexed results: %r",
            len(entries), len(by_index), raw,
        )

    return [by_index.get(i, dict(_DEFAULT_CLASSIFICATION)) for i in range(len(entries))]


_CONVERSATION_SEGMENT_PROMPT = (
    "You are a clinical conversation-tagging assistant. You will be shown ONE continuous transcript from a "
    "single audio segment of a live, free-flowing conversation during a medical intake. Unlike diarized audio, "
    "this transcript has NOT been split by speaker — it is plain text that may contain a doctor's question and "
    "the patient's answer (or several such exchanges) run together with no separator, because they were spoken "
    "with little pause between them. You are also given the most recent prior classified utterances for "
    "context, and a bank of standard intake questions the doctor is expected to cover.\n\n"
    "YOUR JOB — in order:\n"
    "1. SPLIT the transcript into consecutive speaker turns, in the order they occur. Cut at the natural "
    "boundary between a question and its answer, or between one exchange and the next — for example "
    "\"What's your problem? I am suffering from fever. When did it start? Two days ago.\" splits into FOUR "
    "turns: \"What's your problem?\" / \"I am suffering from fever.\" / \"When did it start?\" / \"Two days "
    "ago.\" Keep each turn's wording as close to verbatim as possible — do not paraphrase, only trim leading/"
    "trailing filler if needed. If the transcript is genuinely just one turn with nothing to split, return a "
    "single segment.\n"
    "2. For EACH resulting turn, decide the speaker: DOCTOR (asking a question or giving an instruction), "
    "PATIENT (the patient or anyone accompanying them describing symptoms or answering), or SMALL_TALK "
    "(greetings, chit-chat, or speech unrelated to the intake). Treat any non-doctor speaker — including a "
    "family member or companion answering on the patient's behalf — as PATIENT context.\n"
    "3. For EACH turn, extract EVERY standard question it answers (a turn can answer more than one at once). "
    "Only include a mapping when reasonably confident. For each mapped question, extract the direct answer to "
    "THAT question only, stripped of filler words.\n"
    "4. If a turn's speaker is DOCTOR, decide whether it is a genuinely new clinical question NOT covered by "
    "the standard bank (a paraphrase of a standard question does not count). If so, return it cleaned of "
    "filler words as 'customQuestion', otherwise null.\n\n"
    f"{_CONTINUATION_INSTRUCTION}\n\n"
    f"{_DOCTOR_PRIORITY_INSTRUCTION}\n\n"
    "OUTPUT: respond with ONLY a JSON array, no markdown, no explanation. Number your turns starting at 0, in "
    "the order they occur, via \"segmentIndex\" — one object per turn:\n"
    '[{"segmentIndex": <int>, "speakerRole": "DOCTOR" | "PATIENT" | "SMALL_TALK", "text": "<verbatim turn '
    'text>", "mappings": [{"questionId": "<id>", "answer": "<string>", "confidence": <0.0-1.0>}, ...], '
    '"customQuestion": "<string or null>", "continuesFromPrevious": <true or false>}, ...]\n'
    'Return "mappings": [] when nothing maps, "customQuestion": null when it does not apply, and '
    '"continuesFromPrevious": false on every turn unless instructed otherwise above.'
)


async def segment_and_map_utterance(
    transcript: str,
    recent_utterances: list[dict],
    questions: list[dict],
    previous_utterance: dict | None = None,
) -> list[dict]:
    """Text-based alternative to audio diarization — takes ONE flat, non-diarized transcript
    and asks the LLM to both split it into speaker turns AND classify/map each turn, in a
    single call. Used when Sarvam-side diarization is disabled, so the transcript display and
    question mapping can still reflect real doctor/patient turn-taking instead of forcing one
    role onto the whole transcript. previous_utterance, when given, is the most recent still-
    unmapped utterance — a candidate for the FIRST turn to continue rather than a fresh one.
    Returns a list of {"speakerRole", "text", "mappings", "customQuestion", "continuesFromPrevious"}
    in chronological order — same shape classify_and_map_utterances produces, so the caller can
    treat both paths identically."""
    if not transcript.strip():
        return []

    questions_block = f"Standard Questions Bank:\n{_build_questions_text(questions)}"

    if recent_utterances:
        context_lines = [f"{u['speakerRole']}: {u['text']}" for u in recent_utterances]
        context_block = "Recent conversation context (oldest first):\n" + "\n".join(context_lines)
    else:
        context_block = "Recent conversation context:\n(none — this is the first utterance)"
    context_block += _build_previous_utterance_block(previous_utterance)

    user_message = f"{questions_block}\n\n{context_block}\n\nTranscript to split and classify: {transcript}"

    raw = await _call_litellm(_CONVERSATION_SEGMENT_PROMPT, user_message)
    cleaned = re.sub(r'^```(?:json)?\s*', '', raw.strip())
    cleaned = re.sub(r'\s*```$', '', cleaned)

    fallback = [{"speakerRole": "SMALL_TALK", "text": transcript, "mappings": [], "customQuestion": None, "continuesFromPrevious": False}]

    try:
        parsed = json.loads(cleaned)
    except json.JSONDecodeError:
        logger.warning("segment_and_map_utterance: could not parse LLM response as JSON: %r", raw)
        return fallback

    if not isinstance(parsed, list) or not parsed:
        logger.warning("segment_and_map_utterance: LLM response was not a non-empty JSON array: %r", raw)
        return fallback

    # There's no pre-existing entries list to align against here (unlike classify_and_map_
    # utterances) — the model invents its own segmentation, so we sort by its self-reported
    # segmentIndex and just log if the sequence looks off, rather than treating it as fatal.
    question_ids = {q["id"] for q in questions}
    indexed: list[tuple[int, dict]] = []
    for item in parsed:
        if not isinstance(item, dict):
            continue
        try:
            segment_index = int(item.get("segmentIndex"))
        except (TypeError, ValueError):
            continue
        text = item.get("text")
        if not isinstance(text, str) or not text.strip():
            continue
        parsed_classification = _parse_one_classification(item, question_ids)
        indexed.append((segment_index, {**parsed_classification, "text": text.strip()}))

    if not indexed:
        logger.warning("segment_and_map_utterance: no valid segments parsed from response: %r", raw)
        return fallback

    indexed.sort(key=lambda pair: pair[0])
    expected = list(range(len(indexed)))
    if [i for i, _ in indexed] != expected:
        logger.warning(
            "segment_and_map_utterance: segmentIndex sequence was not contiguous from 0 — using array order anyway: %r",
            raw,
        )

    return [segment for _, segment in indexed]


async def extract_patient_answer(question: str, raw_transcript: str) -> str:
    try:
        result = await _call_litellm(
            "You are a medical intake assistant.",
            f'Question asked to patient: "{question}"\n'
            f'Transcript: "{raw_transcript}"\n'
            f'Extract ONLY the patient\'s direct answer to this medical question. '
            f'Remove background conversation, side talk, and unrelated speech. '
            f'Preserve all medical details in the answer — including symptoms, duration, timing, severity, and any descriptions the patient gave. '
            f'If there is no valid patient response at all, return empty string. '
            f'Return only the extracted response, nothing else.',
        )
        return result.strip()
    except Exception:
        return raw_transcript
