"""Parse a listening-script into per-speaker segments with gender hints.

The course-plan AI emits listening scripts in a "screenplay" form::

    Host: Welcome to Travel Tales. Today we have Sarah ...
    Sarah: Hi! Yes, I spent two weeks in Italy ...
    Host: That sounds amazing. What was your favourite city?
    Sarah: Venice was incredible.

Naively feeding that whole blob to a TTS engine reads the speaker
labels aloud ("Host colon Welcome ... Sarah colon Hi") and uses one
monotone voice for both characters. That's painful for listening
practice and breaks the very pedagogical signal a dialogue is supposed
to carry — turn-taking, contrasted voices, gendered roles.

This module extracts the labels, classifies each speaker by gender, and
returns a clean ``[{speaker, gender, text}, ...]`` list so the TTS
pipeline can:

1. Strip the labels from the rendered audio (``Host:`` is never spoken).
2. Synthesize each turn with a voice matching the speaker's gender
   (Host with a male voice, Sarah with a female voice).
3. Concatenate the per-turn clips so a 4-turn dialogue plays as a real
   conversation rather than a single narration.

For monologues (no ``Name:`` prefix anywhere in the script) the parser
returns one segment with ``speaker=None``, so callers can keep the
single-voice fast-path without special-casing.
"""

from __future__ import annotations

import re
from typing import Iterable

# A "speaker tag" is a 1–3 word capitalized name followed by a colon at
# either start-of-string or right after sentence-final punctuation. We
# anchor with a positive lookbehind so we don't match colons that appear
# inside dialogue ("She said: 'No.'") — only those that actually mark a
# turn boundary.
_SPEAKER_PREFIX = re.compile(
    r'(?:^|(?<=[.!?]\s)|(?<=\n))'
    r'(?P<name>[A-Z][A-Za-z\'.-]+(?:\s[A-Z][A-Za-z\'.-]+){0,2})'
    r'\s*:\s*'
)

# Pragmatic name → gender lookup. Covers the names the LLM picks 95% of
# the time when generating English-language listening dialogues. Anything
# missing falls back to alternation by encounter order, which always
# produces alternating male/female voices for the typical 2-speaker
# classroom dialogue.
_FEMALE_NAMES = frozenset({
    # English first names
    'sarah', 'anna', 'maria', 'sophie', 'sophia', 'emma', 'lisa', 'jane',
    'clare', 'claire', 'olivia', 'mia', 'emily', 'grace', 'ava', 'isabella',
    'chloe', 'julia', 'laura', 'sofia', 'amy', 'rachel', 'nora', 'ella',
    'katie', 'kate', 'hannah', 'lucy', 'mary', 'susan', 'jessica',
    'rebecca', 'alice', 'helen', 'sandra', 'linda', 'barbara', 'nancy',
    'karen', 'betty', 'diana', 'victoria', 'natalie', 'natasha', 'irina',
    'rose', 'lily', 'ruby', 'molly', 'amelia', 'zoe', 'beth', 'eve',
    # Arabic / regional first names common in the platform's audience
    'aisha', 'fatima', 'layla', 'noor', 'zainab', 'yasmin', 'salma',
    'nadia', 'mariam', 'amal', 'hala', 'huda', 'rania', 'reem',
    # Generic role / kinship terms that imply female
    'female', 'woman', 'girl', 'mother', 'mom', 'mum', 'wife', 'sister',
    'daughter', 'aunt', 'grandmother', 'gran', 'lady', 'miss', 'mrs',
    'ms', 'queen', 'princess',
})

_MALE_NAMES = frozenset({
    # English first names
    'john', 'david', 'michael', 'james', 'robert', 'william', 'richard',
    'thomas', 'charles', 'christopher', 'daniel', 'matthew', 'andrew',
    'peter', 'mark', 'paul', 'steven', 'kevin', 'brian', 'george',
    'edward', 'joseph', 'sam', 'samuel', 'ben', 'benjamin', 'tom', 'tim',
    'bob', 'jack', 'henry', 'oliver', 'noah', 'liam', 'elias', 'elijah',
    'alex', 'alexander', 'ethan', 'lucas', 'mason', 'logan', 'caleb',
    'ryan', 'nathan', 'jacob', 'frank', 'harry', 'simon', 'steve',
    # Arabic / regional first names
    'ahmed', 'ahmad', 'mohammed', 'mohamed', 'omar', 'ali', 'khaled',
    'tarek', 'yousef', 'youssef', 'hassan', 'amir', 'rami', 'sami',
    'hamza', 'ibrahim', 'mahmoud', 'karim', 'adam', 'bilal',
    # Generic role / kinship terms
    'male', 'man', 'boy', 'father', 'dad', 'husband', 'brother', 'son',
    'uncle', 'grandfather', 'grandpa', 'sir', 'mr', 'king', 'prince',
})

# Role nouns that frequently appear as speaker labels in listening
# scripts but carry no inherent gender — we route these through the
# alternation path instead of forcing a default voice on them.
_NEUTRAL_ROLES = frozenset({
    'narrator', 'speaker', 'voice', 'person', 'student', 'teacher',
    'host', 'presenter', 'interviewer', 'guest', 'reporter', 'announcer',
    'caller', 'customer', 'staff', 'clerk', 'agent', 'driver', 'doctor',
    'patient', 'manager', 'employee', 'colleague', 'friend',
})


def _lookup_gender(name: str) -> str | None:
    """Return ``'male'`` / ``'female'`` from the static name lookup, or
    ``None`` if the first token isn't recognised.
    """
    first = (name or '').strip().lower().split()[0] if name else ''
    if first in _FEMALE_NAMES:
        return 'female'
    if first in _MALE_NAMES:
        return 'male'
    return None


def parse_dialogue(script: str) -> list[dict]:
    """Split ``script`` into per-speaker segments.

    Returns a list of ``{'speaker': str | None, 'gender': str | None,
    'text': str}``:

    * Multi-speaker dialogue → one entry per turn, with the speaker's
      name (preserved as written) and a heuristic gender label.
    * Monologue (no ``Name:`` prefix) → a single segment with
      ``speaker=None`` and ``gender=None`` so callers can short-circuit.
    * Empty / whitespace-only script → ``[]``.

    The returned ``text`` never includes the speaker label — that's the
    whole point: the TTS engine must not say "Host colon ..." aloud.

    Gender assignment is two-pass so that unknown / role-only speakers
    (Host, Interviewer, etc.) end up *contrasting* with the named
    speakers in the same dialogue. Without this a "Host + Sarah"
    interview would assign female to both (Sarah by lookup, Host by
    default), defeating the whole point of multi-voice rendering.
    """
    if not script or not script.strip():
        return []

    matches = list(_SPEAKER_PREFIX.finditer(script))
    if not matches:
        return [{'speaker': None, 'gender': None, 'text': script.strip()}]

    # Build the list of (name, text) turns first so we can do a global
    # gender assignment over the unique speaker set.
    turns: list[tuple[str, str]] = []
    head = script[: matches[0].start()].strip()
    for i, m in enumerate(matches):
        name = m.group('name').strip()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(script)
        text = script[m.end():end].strip()
        if text:
            turns.append((name, text))
    if not turns:
        # Edge case: matched some "Name:" labels but every body turned
        # out empty. Return the whole pre-match head as a single
        # narration, falling back to the script if even that's empty.
        return [{'speaker': None, 'gender': None,
                 'text': head or script.strip()}]

    # Pass 1: collect unique speakers in order of first appearance, and
    # apply the static name → gender lookup. ``None`` here means
    # "we don't know yet — fill in pass 2".
    unique_order: list[str] = []
    speaker_gender: dict[str, str | None] = {}
    for name, _ in turns:
        key = name.lower()
        if key not in speaker_gender:
            unique_order.append(key)
            speaker_gender[key] = _lookup_gender(name)

    # Pass 2: resolve unknowns. The contract we want is "contrast" —
    # any 2-speaker dialogue should produce one male and one female
    # voice. So for each unknown speaker we count how many male vs
    # female slots are *already* assigned (by lookup or by an earlier
    # iteration of this pass) and pick the under-represented gender.
    # Pure parity-by-index would re-introduce the Host/Sarah collision.
    for key in unique_order:
        if speaker_gender[key] is not None:
            continue
        male_count = sum(1 for v in speaker_gender.values() if v == 'male')
        female_count = sum(1 for v in speaker_gender.values() if v == 'female')
        if male_count < female_count:
            speaker_gender[key] = 'male'
        elif female_count < male_count:
            speaker_gender[key] = 'female'
        else:
            # Truly tied (or first unknown in a no-lookup dialogue) —
            # alternate based on how many unknowns we've already filled
            # so two unknowns in a row get different voices.
            assigned_unknowns = sum(
                1 for k in unique_order
                if speaker_gender[k] is not None and _lookup_gender(k) is None
            )
            speaker_gender[key] = (
                'male' if assigned_unknowns % 2 == 0 else 'female'
            )

    # Pass 3: emit the segments, including any unattributed pre-match
    # narration ("It was a sunny day. Anna: Hi!"). The narration takes
    # whichever gender is *under-represented* among the speakers, so it
    # contrasts with the dialogue voices instead of duplicating one.
    segments: list[dict] = []
    if head:
        female = sum(1 for v in speaker_gender.values() if v == 'female')
        male = sum(1 for v in speaker_gender.values() if v == 'male')
        narrator_gender = 'female' if female <= male else 'male'
        segments.append(
            {'speaker': None, 'gender': narrator_gender, 'text': head},
        )

    for name, text in turns:
        segments.append({
            'speaker': name,
            'gender': speaker_gender[name.lower()],
            'text': text,
        })

    return segments


def strip_speaker_labels(script: str) -> str:
    """Return ``script`` with all ``Name:`` prefixes removed.

    Used when the active TTS provider can't do multi-voice (gTTS only
    speaks one voice per call) so we still get a clean recording —
    just one narrator reading both turns instead of an awkward
    "Host colon ... Sarah colon ...".
    """
    if not script:
        return ''
    return _SPEAKER_PREFIX.sub('', script).strip()


def is_dialogue(segments: Iterable[dict]) -> bool:
    """True if ``segments`` represents a multi-speaker dialogue.

    The single-speaker fast-path skips concatenation and just calls the
    underlying TTS once with the full text, so this gate has to be
    accurate. We require *two distinct named speakers* — a single
    speaker who happens to talk in multiple turns (rare but possible
    after an intro paragraph) is still a monologue from a TTS
    perspective.
    """
    seen = set()
    for seg in segments:
        sp = seg.get('speaker')
        if sp:
            seen.add(sp.lower())
        if len(seen) >= 2:
            return True
    return False
