"""Segmentation narration/dialogue + attribution de locuteur + casting.

Approche hybride :
1. Pre-segmentation deterministe au niveau paragraphe (regles de ponctuation
   francaise : un paragraphe commencant par un cadratin "—" est une replique).
2. Gemma attribue un locuteur a chaque replique, en un seul appel par chapitre
   (liste numerotee + contexte), et extrait le casting (personnages + attributs).

Le decoupage fin des incises ("..., dit-il") est laisse a une passe ulterieure ;
en v1 la replique entiere est portee par la voix du personnage.
"""
from __future__ import annotations

import re
from typing import Optional

from ..models import (
    Cast,
    Chapter,
    ChapterAnalysis,
    ChapterText,
    Character,
    Incise,
    Segment,
    SegmentType,
)
from ..settings import get_settings
from .llm.client import LLM

# Un paragraphe de dialogue commence par un cadratin (U+2014) ou un tiret long.
_DIALOGUE_LEAD_RE = re.compile(r"^\s*[—―]\s*")

# --- Detection des incises (inversion verbe-sujet francaise) ------------------
# Une incise est un groupe de narration insere dans une replique ("..., dit-il.").
# On exclut tu/nous/vous (imperatifs "Donne-le-moi", "Crois-tu ?") pour limiter
# les faux positifs. Voir `detect_incises` plus bas pour les deux passes
# (inversion verbe-pronom + nominale "lanca Drummer", conscience du casting).
_INCISE_PRON = r"(?:il|elle|on|ils|elles|je)"
# Verbe de parole, eventuellement reflechi ("s'ecria", "s'exclama").
_INCISE_VERB = r"(?:[A-Za-zÀ-ÿ]+['’])?[A-Za-zÀ-ÿ]{2,}"


def segment_chapter_text(ct: ChapterText) -> list[Segment]:
    """Decoupe un chapitre en segments narration/dialogue (regles seules)."""
    segments: list[Segment] = []
    for para in ct.paragraphs:
        if _DIALOGUE_LEAD_RE.match(para):
            text = _DIALOGUE_LEAD_RE.sub("", para).strip()
            segments.append(Segment(
                type=SegmentType.DIALOGUE, text=text, speaker="?"))
        else:
            segments.append(Segment(
                type=SegmentType.NARRATION, text=para, speaker="narrateur"))
    return segments


# --- Attribution des locuteurs (Gemma) --------------------------------------
# Le prompt systeme est editable dans les reglages (settings.prompt_speakers).


_UNKNOWN = {"", "?", "inconnu", "narrateur"}
_CTX_CHARS = 160          # troncature du contexte narratif avant/apres
_CHUNK_MAX_DIALOGUES = 30  # repliques par appel (fiabilite du modele)


def attribute_speakers(
    segments: list[Segment],
    gemma: LLM,
    *,
    characters: Optional[list[Character]] = None,
    pov: Optional[str] = None,
) -> dict[int, str]:
    """Renseigne `speaker` pour chaque dialogue (mutation en place).

    Fournit au modele la liste canonique enrichie des personnages (nom, genre,
    description) et, pour chaque replique, le contexte narratif AVANT et APRES
    (l'incise d'attribution est souvent placee apres : "— Bonjour. dit Marie.").

    Renvoie une map {index_de_segment: confidence} ("high"/"medium"/"low"),
    conservee en memoire (non persistee) pour piloter la 2e passe retroactive.
    Une replique dont le nom rendu sort de la liste fournie est gardee mais
    marquee "low" afin d'etre reexaminee.
    """
    dialogues = [(i, s) for i, s in enumerate(segments)
                 if s.type is SegmentType.DIALOGUE]
    if not dialogues:
        return {}

    # Repliques deja resolues (seed par incise) : montrees comme contexte fixe,
    # jamais re-demandees au modele. Si tout est resolu, rien a faire.
    locked = {i for i, s in dialogues if _is_resolved(s.speaker)}
    if len(locked) == len(dialogues):
        return {i: "high" for i, _ in dialogues}

    hint = _speakers_hint(characters, pov)
    valid = {c.name.strip().lower() for c in (characters or [])}
    confidence: dict[int, str] = {}

    for chunk in _chunk_dialogues(dialogues, segments, hint):
        prompt = (
            "Voici les repliques de dialogue d'un extrait, numerotees, avec la "
            "narration qui precede et qui suit chaque replique. Les repliques "
            "deja attribuees affichent (locuteur: X) : ne les modifie pas, "
            "sers-t'en comme contexte (alternance des tours). Pour les AUTRES, "
            "indique le personnage qui parle (recopie son nom depuis la liste "
            "fournie ; 'inconnu' si vraiment indeterminable) et ta confiance "
            "(high/medium/low)."
            f"{hint}\n\n" + "\n".join(line for _, line in chunk) +
            '\n\nReponds par un tableau JSON: '
            '[{"i": 0, "speaker": "Holden", "confidence": "high"}, ...]'
        )
        result = gemma.generate_json(prompt, system=get_settings().prompt_speakers)
        by_i: dict[int, dict] = {item["i"]: item for item in result
                                 if isinstance(item, dict) and "i" in item}
        for j, (seg_idx, _line) in enumerate(chunk):
            if seg_idx in locked:          # seed conserve
                confidence[seg_idx] = "high"
                continue
            seg = segments[seg_idx]
            item = by_i.get(j) or {}
            speaker = (str(item.get("speaker") or "inconnu").strip()
                       or "inconnu")
            conf = str(item.get("confidence") or "low").strip().lower()
            if conf not in {"high", "medium", "low"}:
                conf = "low"
            # Nom hors liste connue -> on garde le nom mais on le rejuge.
            if (valid and speaker.lower() not in _UNKNOWN
                    and speaker.lower() not in valid):
                conf = "low"
            seg.speaker = speaker
            confidence[seg_idx] = conf
    return confidence


def _speakers_hint(characters: Optional[list[Character]], pov: Optional[str]) -> str:
    hint = ""
    if characters:
        lines = []
        for c in characters:
            attrs = c.gender or ""
            desc = f" — {c.description}" if c.description else ""
            lines.append(f"- {c.name}" + (f" ({attrs})" if attrs else "") + desc)
        hint += "\nPersonnages du chapitre:\n" + "\n".join(lines)
    if pov:
        hint += f"\nLe point de vue de ce chapitre est: {pov}."
    return hint


def _is_resolved(speaker: str) -> bool:
    """Vrai si la replique a deja un locuteur sur (seed incise, etc.)."""
    return (speaker or "").strip().lower() not in _UNKNOWN


def _dialogue_line(n: int, segments: list[Segment], idx: int) -> str:
    seg = segments[idx]
    # Replique deja resolue (ex: seed par incise) -> montree comme contexte fixe.
    if _is_resolved(seg.speaker):
        return f"[{n}] (locuteur: {seg.speaker}) REPLIQUE: {seg.text!r}"
    before = _adjacent_narration(segments, idx, -1)
    after = _adjacent_narration(segments, idx, +1)
    parts = [f"[{n}]"]
    if before:
        parts.append(f"(avant: {before!r})")
    parts.append(f"REPLIQUE: {seg.text!r}")
    if after:
        parts.append(f"(apres: {after!r})")
    return " ".join(parts)


def _adjacent_narration(segments: list[Segment], idx: int, direction: int) -> str:
    """Texte de la narration immediatement adjacente (incise d'attribution)."""
    j = idx + direction
    if 0 <= j < len(segments) and segments[j].type is SegmentType.NARRATION:
        return segments[j].text[:_CTX_CHARS]
    return ""


def _chunk_dialogues(
    dialogues: list[tuple[int, Segment]],
    segments: list[Segment],
    hint: str,
) -> list[list[tuple[int, str]]]:
    """Decoupe les repliques en lots tenant sous `_MAX_PROMPT_CHARS`.

    Chaque lot est une liste de (index_segment, ligne_rendue) ; la ligne est
    numerotee localement (0..k) pour le prompt, l'index segment sert au mapping
    retour. Evite la troncature brutale sur les longs chapitres.
    """
    budget = _MAX_PROMPT_CHARS - len(hint) - 400  # marge pour les consignes
    chunks: list[list[tuple[int, str]]] = []
    current: list[tuple[int, str]] = []
    size = 0
    for idx, _seg in dialogues:
        line = _dialogue_line(len(current), segments, idx)
        if current and (size + len(line) > budget
                        or len(current) >= _CHUNK_MAX_DIALOGUES):
            chunks.append(current)
            current = []
            size = 0
            line = _dialogue_line(0, segments, idx)
        current.append((idx, line))
        size += len(line) + 1
    if current:
        chunks.append(current)
    return chunks


# --- Passe retroactive : re-resolution des repliques indeterminees ----------
# Le prompt systeme est editable (settings.prompt_speakers_refine).


def _refine_unknown_speakers(
    segments: list[Segment],
    gemma: LLM,
    *,
    characters: Optional[list[Character]] = None,
    confidence: dict[int, str],
) -> None:
    """2e passe : re-resout les repliques restees indeterminees/peu sures.

    Chaque replique douteuse est presentee avec ses voisines de dialogue DEJA
    identifiees (alternance des tours) et son contexte narratif, pour exploiter
    l'information venant des repliques *suivantes*. Mutation en place ; aucun
    appel Gemma si rien n'est douteux.
    """
    dialogues = [(i, s) for i, s in enumerate(segments)
                 if s.type is SegmentType.DIALOGUE]
    if not dialogues:
        return
    pos = {seg_idx: n for n, (seg_idx, _s) in enumerate(dialogues)}
    doubtful = [seg_idx for seg_idx, _s in dialogues
                if segments[seg_idx].speaker.strip().lower() in _UNKNOWN
                or confidence.get(seg_idx) == "low"]
    if not doubtful:
        return

    hint = _speakers_hint(characters, pov=None)
    lines = []
    for j, seg_idx in enumerate(doubtful):
        n = pos[seg_idx]
        ctx = []
        if n > 0:
            prev_idx = dialogues[n - 1][0]
            ctx.append(f"replique precedente (dite par "
                       f"{segments[prev_idx].speaker}): "
                       f"{segments[prev_idx].text[:_CTX_CHARS]!r}")
        before = _adjacent_narration(segments, seg_idx, -1)
        if before:
            ctx.append(f"narration avant: {before!r}")
        after = _adjacent_narration(segments, seg_idx, +1)
        if after:
            ctx.append(f"narration apres: {after!r}")
        if n < len(dialogues) - 1:
            next_idx = dialogues[n + 1][0]
            ctx.append(f"replique suivante (dite par "
                       f"{segments[next_idx].speaker}): "
                       f"{segments[next_idx].text[:_CTX_CHARS]!r}")
        ctx_str = (" [" + " ; ".join(ctx) + "]") if ctx else ""
        lines.append(f"[{j}]{ctx_str} REPLIQUE: {segments[seg_idx].text!r}")

    prompt = (
        "Repliques au locuteur indetermine. Pour chacune, en t'appuyant sur les "
        "repliques voisines DEJA attribuees (alternance des tours) et le "
        "contexte, indique qui parle (recopie le nom depuis la liste ; "
        "'inconnu' si toujours indeterminable)."
        f"{hint}\n\n" + "\n".join(lines) +
        '\n\nReponds par un tableau JSON: [{"i": 0, "speaker": "Holden"}, ...]'
    )
    result = gemma.generate_json(_truncate(prompt),
                                 system=get_settings().prompt_speakers_refine)
    by_i = {item["i"]: item.get("speaker") for item in result
            if isinstance(item, dict) and "i" in item}
    for j, seg_idx in enumerate(doubtful):
        new = (str(by_i.get(j) or "").strip())
        if new and new.lower() not in _UNKNOWN:
            segments[seg_idx].speaker = new


# --- Post-traitement deterministe (sans LLM) --------------------------------


# Traductions FR pour construire l'identite d'un locuteur anonyme.
_ANON_GENDER_FR = {"male": "homme", "female": "femme"}
_ANON_AGE_FR = {"child": "enfant", "young": "jeune", "adult": "adulte", "old": "vieux"}


def _anon_identity(gender: Optional[str], age: Optional[str]) -> str:
    """Identite canonique d'un locuteur anonyme, regroupe par (genre, age).

    Ex: ("male", "adult") -> "anonyme (homme, adulte)" ; ("male", None) ->
    "anonyme (homme)" ; (None, None) -> "anonyme". Tous les personnages-fonction
    d'un meme bucket partagent une voix (genre/age suffisent a la choisir)."""
    g = _ANON_GENDER_FR.get((gender or "").lower())
    a = _ANON_AGE_FR.get((age or "").lower())
    parts = [p for p in (g, a) if p]
    return f"anonyme ({', '.join(parts)})" if parts else "anonyme"


def _apply_anonymous_speakers(
    segments: list[Segment], *, names=None) -> dict[str, tuple[Optional[str], Optional[str]]]:
    """Rattache les repliques a incise de role a un locuteur ANONYME par genre/age.

    Une incise "informa le soldat" -> "anonyme (homme)" : on ne stocke pas la
    fonction (garde/marine...), seuls genre+age comptent pour la voix. Genre/age
    deduits du nom de role (`_ROLE_GENDER`/`_ROLE_AGE`). Applique APRES le LLM
    (autorite deterministe), sans modifier le prompt. Mutation en place.

    Renvoie {identite_anonyme: (genre, age)} des buckets utilises, pour que
    l'appelant cree les `Character` generiques correspondants (assignation voix)."""
    names = names or set()
    used: dict[str, tuple[Optional[str], Optional[str]]] = {}
    for seg in segments:
        if seg.type is not SegmentType.DIALOGUE:
            continue
        for inc in seg.incises:
            role = incise_role(seg.text, inc, names)
            if role:
                gender = _ROLE_GENDER.get(role)
                age = _ROLE_AGE.get(role)
                ident = _anon_identity(gender, age)
                seg.speaker = ident
                used[ident] = (gender, age)
                break
    return used


def _inversion_gender(text: str) -> Optional[str]:
    """Genre porte par le pronom d'une incise d'inversion ("demanda-t-elle" ->
    female, "dit-il" -> male). None si aucune inversion. Signal sur LE locuteur."""
    m = _INV_GENDER_RE.search(text)
    if not m:
        return None
    return "female" if m.group("p").lower().startswith("elle") else "male"


def _resolve_anonymous_figurants(
    segments: list[Segment]) -> dict[str, tuple[Optional[str], Optional[str]]]:
    """Resout les repliques restees INDETERMINEES (inconnu/?) en figurants anonymes.

    Quand une replique non resolue est entouree d'une narration decrivant un
    figurant genre ("La femme...", "La jeune marine...", "Le soldat..."), on
    l'attribue au bucket anonyme correspondant. Genre : pronom d'inversion de la
    replique ("demanda-t-elle") sinon l'article du role dans la narration
    (la/une -> femme, le/un -> homme). N'agit QUE sur l'indetermine (jamais sur
    une attribution sure) -> sans risque pour les personnages nommes. Mutation en
    place ; renvoie les buckets crees (pour creer les Character generiques)."""
    used: dict[str, tuple[Optional[str], Optional[str]]] = {}
    for idx, seg in enumerate(segments):
        if seg.type is not SegmentType.DIALOGUE or _is_resolved(seg.speaker):
            continue
        narr_gender = role_age = None
        found = False
        for j in (idx - 1, idx + 1):  # narration adjacente (avant puis apres)
            if 0 <= j < len(segments) and segments[j].type is SegmentType.NARRATION:
                m = _ANON_NARR_RE.search(segments[j].text)
                if m:
                    found = True
                    art = m.group("art").lower().rstrip("’'")
                    narr_gender = "female" if art in ("la", "une") else "male"
                    role_age = _ROLE_AGE.get(m.group("role").lower())
                    break
        if not found:
            continue
        gender = _inversion_gender(seg.text) or narr_gender
        ident = _anon_identity(gender, role_age)
        seg.speaker = ident
        used[ident] = (gender, role_age)
    return used


def _canonicalize_speakers(segments: list[Segment], chars: list[Character]) -> None:
    """Reecrit chaque locuteur variant vers le nom canonique du cast.

    Le LLM emet souvent des variantes hors liste ("Amiral Mehmet Sagale" pour
    "Sagale", "Elvi Okoye" pour "Elvi"). Non rattachees, elles cassent le rendu
    (mauvaise voix -> repli narrateur) et le score. On les recolle au canonique
    via `heuristic_match` (primitive sure du dedup) : on n'agit QUE sur un match
    certain (`Character`), on s'abstient sur ambiguite/inconnu. Pur, sans LLM,
    ne touche pas au prompt. Ordre-independant : `tokfreq` calcule globalement.
    Idempotent (un nom deja canonique matche en exact)."""
    from ..casting.dedup import heuristic_match, _token_freq

    spoken = [s.speaker for s in segments
              if s.type is SegmentType.DIALOGUE and _is_resolved(s.speaker)]
    if not spoken or not chars:
        return
    tokfreq = _token_freq(chars, spoken)
    for seg in segments:
        if seg.type is not SegmentType.DIALOGUE or not _is_resolved(seg.speaker):
            continue
        match = heuristic_match(seg.speaker, chars, tokfreq)
        if isinstance(match, Character):
            seg.speaker = match.name


# --- Passe deterministe : reparation de l'alternance des tours ---------------


def _norm_name(name: str) -> str:
    return (name or "").strip().casefold()


# Tolerance de narration intercalee entre deux repliques d'un meme run. STRICT
# (0) : seules les repliques d'indices consecutifs forment un run. Toute valeur
# >0 est DANGEREUSE : une narration peut porter une *continuation du meme
# locuteur* ("— …", "Fayez marqua une pause.", "— …") ou il reparle ; verifie
# sur ch06 (runs 66-79 et 83-90 de la reference NON alternes des GAP=1). On
# prefere ne pas reparer une replique isolee que d'inventer une fausse alternance.
_RUN_MAX_NARRATION_GAP = 0


def _dialogue_runs(segments: list[Segment]) -> list[list[int]]:
    """Suites de repliques d'indices consecutifs (aucune narration intercalee).

    Hypothese (verifiee sur les references ch05 ET ch06, 0 contre-exemple) : dans
    une telle salve ou chaque cadratin marque un changement de locuteur, les
    tours alternent strictement. Des qu'une narration s'intercale, l'alternance
    n'est plus garantie (continuation possible du meme locuteur) -> nouveau run."""
    runs: list[list[int]] = []
    cur: list[int] = []
    gap = 0
    for i, s in enumerate(segments):
        if s.type is SegmentType.DIALOGUE:
            cur.append(i)
            gap = 0
        else:
            gap += 1
            if gap > _RUN_MAX_NARRATION_GAP:
                if len(cur) >= 2:
                    runs.append(cur)
                cur = []
    if len(cur) >= 2:
        runs.append(cur)
    return runs


def _repair_alternation(segments: list[Segment], *, names=None) -> None:
    """Force l'alternance des tours dans les echanges a exactement 2 locuteurs.

    Pour chaque suite de repliques consecutives a deux locuteurs, on retient,
    parmi les deux motifs alternes possibles (A/B/A… ou B/A/B…), celui qui :
    1. ne contredit aucune ancre sure (locuteur explicite d'incise nominale) ;
    2. exige le moins de corrections au resultat de la 1re passe.
    On n'agit qu'avec un gagnant STRICT, sinon on s'abstient (on prefere laisser
    une erreur qu'en introduire une). En particulier, des qu'un 3e locuteur (meme
    minoritaire) apparait dans le run, on ne touche a rien : un echange a >=3
    n'alterne pas forcement. Pur, sans appel LLM ; comble aussi les repliques
    indeterminees du run.
    """
    names = names or set()
    for run in _dialogue_runs(segments):
        speakers = [segments[i].speaker for i in run]
        resolved = {_norm_name(s) for s in speakers if _is_resolved(s)}
        if len(resolved) != 2:
            continue
        # Noms canoniques (1re occurrence de chaque forme normalisee).
        order: list[str] = []
        for s in speakers:
            n = _norm_name(s)
            if n in resolved and n not in order:
                order.append(n)
        name_a, name_b = order[0], order[1]
        canon_of = {}
        for s in speakers:
            n = _norm_name(s)
            if n in resolved:
                canon_of.setdefault(n, s.strip())

        # Ancres sures : locuteur explicite d'une incise nominale.
        anchors: dict[int, str] = {}
        for k, idx in enumerate(run):
            seg = segments[idx]
            for inc in seg.incises:
                spk = incise_speaker(seg.text, inc, names)
                if spk:
                    anchors[k] = _norm_name(spk)
                    break
        # Une ancre nommant un tiers (hors paire) -> run suspect, on s'abstient.
        if any(a not in (name_a, name_b) for a in anchors.values()):
            continue

        def pattern(start: str) -> list[str]:
            other = name_b if start == name_a else name_a
            return [start if k % 2 == 0 else other for k in range(len(run))]

        candidates = [pattern(name_a), pattern(name_b)]
        admissible = [p for p in candidates
                      if all(p[k] == a for k, a in anchors.items())]
        if not admissible:
            continue

        def cost(p: list[str]) -> int:  # corrections sur les repliques resolues
            return sum(1 for k, idx in enumerate(run)
                       if _is_resolved(segments[idx].speaker)
                       and _norm_name(segments[idx].speaker) != p[k])

        admissible.sort(key=cost)
        if len(admissible) == 2 and cost(admissible[0]) == cost(admissible[1]):
            continue  # ex aequo sans ancre discriminante -> trop ambigu
        chosen = admissible[0]
        for k, idx in enumerate(run):
            segments[idx].speaker = canon_of[chosen[k]]


# --- Extraction du casting (Gemma) ------------------------------------------
# Le prompt systeme est editable dans les reglages (settings.prompt_characters).


def extract_characters(text: str, gemma: LLM) -> list[Character]:
    """Extrait les personnages et leurs attributs (genre, age) d'un texte."""
    prompt = (
        "A partir de l'extrait suivant, liste les personnages qui parlent ou "
        "sont nommes. Pour chacun, donne: name (nom court canonique), gender "
        "(male/female/unknown), age (child/young/adult/old/unknown), et une "
        "courte description. Ignore les figurants sans nom.\n\n"
        f"EXTRAIT:\n{_truncate(text)}\n\n"
        'Reponds par un tableau JSON: '
        '[{"name":"Holden","gender":"male","age":"adult","description":"..."}]'
    )
    result = gemma.generate_json(prompt, system=get_settings().prompt_characters)
    characters: list[Character] = []
    for item in result:
        if not isinstance(item, dict) or not item.get("name"):
            continue
        characters.append(Character(
            name=str(item["name"]).strip(),
            gender=_norm(item.get("gender")),
            age=_norm(item.get("age")),
            description=(item.get("description") or None),
        ))
    return characters


def merge_characters(existing: list[Character], new: list[Character]) -> list[Character]:
    """Fusionne deux listes de personnages par nom (insensible a la casse)."""
    by_key = {c.name.lower(): c for c in existing}
    for c in new:
        key = c.name.lower()
        if key in by_key:
            cur = by_key[key]
            cur.gender = cur.gender or c.gender
            cur.age = cur.age or c.age
            cur.description = cur.description or c.description
        else:
            by_key[key] = c
    return list(by_key.values())


def _norm(value) -> Optional[str]:
    if not value:
        return None
    v = str(value).strip().lower()
    return v if v and v != "unknown" else None


# --- Helpers -----------------------------------------------------------------

# Garde-fou de contexte (caracteres) pour rester dans une fenetre raisonnable.
_MAX_PROMPT_CHARS = 24000


def _truncate(text: str) -> str:
    return text if len(text) <= _MAX_PROMPT_CHARS else text[:_MAX_PROMPT_CHARS]


# --- Detection des incises (deterministe, conscience du casting) -------------
# Les incises sont annotees par des bornes (offsets) sur la replique persistee
# (non destructif) ; le rendu les fait porter par la voix du narrateur. Deux
# passes complementaires :
#   1. inversion verbe-pronom ("dit-il", "coupa-t-elle") ;
#   2. nominale : verbe de parole + sujet connu (nom du casting OU nom de role,
#      ex: "compatit Holden", "lanca Drummer", "informa le soldat").
# La passe nominale s'appuie sur la liste des personnages -> peu de faux positifs
# et permet d'extraire le locuteur explicite (seeding de l'attribution).

# Pronom objet eventuel devant le verbe ("lui demanda un garde").
_CLITIC = r"(?:lui|leur|nous|vous|me|te|se|y|en|[mts]['’])"

# Formes conjuguees de verbes de parole (3e pers., passe simple / present /
# imparfait). Liste curee : on prefere rater une incise que d'en inventer une.
_SPEECH_VERBS = {
    "dit", "disait", "redit", "répondit", "repondit", "répond", "repond",
    "répondait", "repondait", "demanda", "demandait", "demande", "interrogea",
    "questionna", "ecria", "écria", "exclama", "enquit", "lança", "lanca",
    "lançait", "lance", "murmura", "chuchota", "souffla", "soupira", "ajouta",
    "ajoute", "reprit", "poursuivit", "poursuit", "continua", "enchaîna",
    "enchaina", "fit", "faisait", "remarqua", "observa", "nota", "déclara",
    "declara", "affirma", "assura", "rétorqua", "retorqua", "répliqua",
    "repliqua", "riposta", "objecta", "protesta", "insista", "renchérit",
    "rencherit", "acquiesça", "acquiesca", "admit", "avoua", "convint",
    "concéda", "conceda", "rectifia", "corrigea", "précisa", "precisa",
    "expliqua", "raconta", "annonça", "annonca", "proclama", "ordonna",
    "commanda", "supplia", "implora", "gémit", "gemit", "grogna", "ronchonna",
    "maugréa", "maugrea", "marmonna", "glissa", "lâcha", "lacha", "coupa",
    "interrompit", "conclut", "compléta", "completa", "suggéra", "suggera",
    "proposa", "promit", "jura", "menaça", "menaca", "ironisa", "plaisanta",
    "railla", "cria", "hurla", "tonna", "gronda", "rugit", "susurra",
    "compatit", "salua", "appela", "héla", "hela", "interpella", "balbutia",
    "bredouilla", "bafouilla", "gloussa", "ricana", "siffla", "tempêta",
    "tempeta", "rétorque", "lâche", "informa", "renseigna", "indiqua",
    "rappela", "avertit", "prévint", "prevint", "intima", "rétorquait",
    "lançait", "questionnait", "reconnut", "constata", "répéta", "repeta",
    "intervint", "intervient", "renchérissait",
}

# Noms de role (FONCTION) pouvant etre sujet d'une incise ("informa le soldat").
# On EXCLUT volontairement les rangs/titres (amiral, capitaine, lieutenant...) :
# ils precedent presque toujours un nom propre ("dit l'amiral Sagale") -> ce
# n'est pas un figurant anonyme mais une personne nommee ; les laisser ici ferait
# capter le titre au lieu du nom. Le nom propre est alors capte normalement.
_ROLE_NOUNS = {
    "garde", "soldat", "sentinelle", "gardien", "prêtre", "pretre", "homme",
    "femme", "fille", "garçon", "garcon", "vieille", "vieillard", "voix",
    "inconnu", "inconnue", "étranger", "etranger", "enfant", "serviteur",
    "servante", "messager", "domestique", "médecin", "medecin", "marine", "marin",
}

# Genre/age probables d'un personnage-fonction, pour l'attribuer a un locuteur
# anonyme regroupe (voix par genre/age). On ne mappe QUE les cas ou le genre de
# la PERSONNE est fortement implique (roles militaires/masculins, feminins
# explicites) ; les cas ambigus (medecin, officier, voix, sentinelle...) restent
# inconnus -> bucket "anonyme" generique. Mieux vaut un genre inconnu qu'errone.
_ROLE_GENDER = {
    "soldat": "male", "garde": "male", "gardien": "male", "marine": "male",
    "marin": "male", "homme": "male", "garçon": "male", "garcon": "male",
    "vieillard": "male", "serviteur": "male", "messager": "male",
    "prêtre": "male", "pretre": "male",
    "femme": "female", "fille": "female", "servante": "female",
    "vieille": "female", "inconnue": "female",
}
# Age probable (rare : seul "enfant" le donne nettement).
_ROLE_AGE = {
    "enfant": "child", "garçon": "child", "garcon": "child",
    "fille": "child", "vieillard": "old", "vieille": "old",
}

# Genre du pronom d'une incise d'inversion ("-t-elle"/"-il"). "-" => inversion.
_INV_GENDER_RE = re.compile(r"-(?:t-)?(?P<p>ils?|elles?)\b", re.IGNORECASE)

# Figurant genre decrit dans la narration : article (genre) + nom de role proche.
# Ex: "La femme", "La jeune marine", "Le soldat". Sert a resoudre une replique
# indeterminee en anonyme (cf. `_resolve_anonymous_figurants`).
_ANON_NARR_RE = re.compile(
    r"\b(?P<art>la|le|une|un)\s+(?:[\wÀ-ÿ’'-]+\s+){0,2}?"
    r"(?P<role>" + "|".join(re.escape(r) for r in sorted(_ROLE_NOUNS, key=len, reverse=True)) + r")\b",
    re.IGNORECASE,
)

# Mots vides ignores quand on indexe les tokens d'un nom de personnage.
_NAME_STOP = {
    "le", "la", "les", "un", "une", "de", "du", "des", "monsieur", "madame",
    "mademoiselle", "m", "mme", "mlle", "mr", "dr", "docteur", "saint", "sainte",
}

# Ponctuations qui terminent la partie parlee : si l'incise les suit, tout le
# reste de la replique est de la narration (la parole est finie). Apres une
# simple virgule au contraire, le dialogue reprend apres l'incise.
_SENTENCE_FINAL = {"", ".", "!", "?", "…"}


def _incise_end(text: str, close_end: int, lead: str) -> int:
    """Fin effective de l'incise : jusqu'au bout de la replique si la parole
    etait deja close a gauche (`.`/`!`/`?`/`…` ou debut), sinon la cloture."""
    return len(text) if lead in _SENTENCE_FINAL else close_end


# Passe 1 : inversion verbe-(t-)pronom, ancree sur une ponctuation a gauche
# (virgule, point, ?, !, …) ou le debut de la replique.
_INVERSION_RE = re.compile(
    r"(?P<lead>[,.!?…]|^)\s*"
    r"(?P<inc>" + _INCISE_VERB + r"-(?:t-)?" + _INCISE_PRON +
    r"(?:\s+[^.!?…»\",;]*?)?)"          # complements eventuels ("dit-il en souriant")
    r"(?P<close>[.!?…,])",              # cloture : ponctuation forte OU virgule
    re.IGNORECASE,
)


def _inversion_spans(text: str) -> list[tuple[int, int]]:
    return [(m.start("inc"), _incise_end(text, m.end("close"), m.group("lead")))
            for m in _INVERSION_RE.finditer(text)]


def _name_token_index(names) -> dict[str, str]:
    """Index token -> nom canonique (tokens distinctifs uniquement).

    Un token partage par plusieurs personnages est ambigu et ecarte.
    """
    idx: dict[str, str] = {}
    ambiguous: set[str] = set()
    for name in names or ():
        for tok in re.split(r"[^\wÀ-ÿ]+", name):
            t = tok.lower()
            if len(t) < 2 or t in _NAME_STOP:
                continue
            if t in idx and idx[t] != name:
                ambiguous.add(t)
            else:
                idx[t] = name
    for t in ambiguous:
        idx.pop(t, None)
    return idx


# Nom propre : initiale majuscule (motif sensible a la casse).
_PROPER = r"[A-ZÀ-Ÿ][\wÀ-ÿ’'\-]+"
_REJECT = object()  # le sujet n'en est pas un -> pas une incise


def _classify_subject(subj: str, idx: dict[str, str]):
    """Locuteur NOMME porte par le sujet d'une incise nominale.

    - personnage connu -> nom canonique ;
    - nom propre (capitalise) inconnu -> nom de surface (seed quand meme : le
      texte le nomme, independamment de la fiabilite de l'extraction) ;
    - nom de role ("le soldat") -> None : pas un locuteur NOMME. L'incise reste
      detectee (narration), et le rattachement a un anonyme (par genre/age) se
      fait en post-traitement (cf. `_apply_anonymous_speakers` / `incise_role`) ;
    - mot quelconque -> _REJECT (pas une incise).
    """
    low = subj.lower()
    if low in idx:
        return idx[low]
    if low in _ROLE_NOUNS:
        return None
    if subj[:1].isupper() and len(low) >= 2 and low not in _NAME_STOP:
        return subj.strip("’'")
    return _REJECT


def _nominal_matches(text: str, names
                     ) -> list[tuple[int, int, Optional[str], str]]:
    """Passe 2 : (start, end, locuteur, sujet) pour chaque incise nominale.

    Une incise nominale = verbe de parole + sujet (nom du casting, nom propre,
    ou nom de role). Le sujet nom propre est seede meme absent du casting.
    Le 4e champ est le sujet (minuscule) : sert a reconnaitre un nom de role
    (`incise_role`) pour rattacher un locuteur anonyme par genre/age.
    """
    idx = _name_token_index(names)
    literals = sorted(set(idx) | _ROLE_NOUNS, key=len, reverse=True)
    lit_alt = "|".join(re.escape(s) for s in literals)
    # Sujet : nom connu/role (insensible casse) OU nom propre (capitalise, sensible
    # casse pour ne pas happer un determiner "un"/"le"). Pas d'IGNORECASE global.
    subj_alt = (f"(?i:{lit_alt})|{_PROPER}") if lit_alt else _PROPER
    verbs = "|".join(re.escape(v) for v in sorted(_SPEECH_VERBS, key=len, reverse=True))
    pat = re.compile(
        r"(?P<lead>[,.!?…]|^)\s*"
        r"(?P<inc>(?:(?i:" + _CLITIC + r")\s+)?"
        r"(?i:" + verbs + r")\b"
        r"[^.!?…»\",;]{0,40}?\b"
        r"(?P<subj>" + subj_alt + r")\b"
        r"[^.!?…»\",;]*?)"
        r"(?P<close>[.!?…,])",
    )
    out: list[tuple[int, int, Optional[str], str]] = []
    for m in pat.finditer(text):
        subj = m.group("subj")
        spk = _classify_subject(subj, idx)
        if spk is _REJECT:
            continue
        out.append((m.start("inc"),
                    _incise_end(text, m.end("close"), m.group("lead")),
                    spk, subj.lower()))
    return out


def _merge_spans(spans: list[tuple[int, int]]) -> list[Incise]:
    """Trie et fusionne (sans chevauchement) une liste de bornes -> Incise."""
    out: list[Incise] = []
    last_end = -1
    for s, e in sorted(set(spans)):
        if s < last_end:          # chevauchement -> on garde le premier vu
            continue
        out.append(Incise(start=s, end=e))
        last_end = e
    return out


def detect_incises(text: str, *, names=None) -> list[Incise]:
    """Bornes des incises dans une replique (inversion + nominale cast-aware)."""
    spans = _inversion_spans(text)
    spans += [(s, e) for s, e, _, _ in _nominal_matches(text, names or set())]
    return _merge_spans(spans)


def incise_speaker(text: str, incise: Incise, names) -> Optional[str]:
    """Locuteur NOMME explicite porte par une incise nominale ("compatit Holden").

    None pour une incise de role ("informa le soldat") : un role n'est pas un
    locuteur nomme (cf. `incise_role` pour le rattachement anonyme).
    """
    for s, e, spk, _ in _nominal_matches(text, names):
        if s == incise.start and e == incise.end:
            return spk
    return None


def incise_role(text: str, incise: Incise, names) -> Optional[str]:
    """Nom de role (minuscule) sujet d'une incise ("informa le soldat" -> "soldat").

    Renvoie None si l'incise n'est pas une incise de role. Sert a rattacher la
    replique a un locuteur anonyme regroupe par genre/age (cf. `_anon_identity`)."""
    for s, e, _spk, subj in _nominal_matches(text, names):
        if s == incise.start and e == incise.end and subj in _ROLE_NOUNS:
            return subj
    return None


def iter_incise_pieces(
    text: str, incises: list[Incise]
) -> list[tuple[bool, str]]:
    """Decoupe `text` en morceaux (is_incise, sous_texte) via les bornes.

    Utilise au rendu : pieces dialogue -> voix du personnage, pieces incise ->
    voix du narrateur. Texte conserve modulo espaces de bordure.
    """
    pieces: list[tuple[bool, str]] = []
    cursor = 0
    for inc in sorted(incises, key=lambda i: i.start):
        if inc.start < cursor:    # garde-fou chevauchement
            continue
        before = text[cursor:inc.start]
        if before.strip():
            pieces.append((False, before.strip()))
        body = text[inc.start:inc.end]
        if body.strip():
            pieces.append((True, body.strip()))
        cursor = inc.end
    tail = text[cursor:]
    if tail.strip():
        pieces.append((False, tail.strip()))
    return pieces


def analyze_chapter(
    chapter: Chapter,
    ct: ChapterText,
    gemma: LLM,
    *,
    book_chars: Optional[list[Character]] = None,
    dedup_gemma: Optional[LLM] = None,
) -> tuple[ChapterAnalysis, list[Character]]:
    """Analyse complete d'un chapitre.

    Sequence : segmentation -> extraction des personnages -> reconciliation
    (dedup contre le cast cumule du livre) -> annotation des incises + seeding
    du locuteur explicite -> attribution LLM des repliques restantes -> passe
    retroactive. Les repliques sont persistees entieres (incises = bornes).

    `book_chars` : cast cumule du livre (personnages canoniques deja connus).
    `dedup_gemma` : si fourni, tranche les cas de dedup ambigus.

    Renvoie (analyse, cast cumule mis a jour) ; le 2e element est l'ensemble du
    casting du livre reconcilie, pret a etre persiste tel quel.
    """
    from ..casting.dedup import reconcile_characters

    segments = segment_chapter_text(ct)
    full_text = "\n".join(ct.paragraphs)
    found = extract_characters(full_text, gemma)

    # Dedup AVANT l'attribution : le modele recevra des noms canoniques.
    chars, name_map = reconcile_characters(book_chars or [], found, dedup_gemma)

    # Liste canonique restreinte a ce chapitre (personnages detectes + POV).
    chapter_canon = {(name_map.get(c.name.strip().lower()) or c.name).strip().lower()
                     for c in found}
    chapter_chars = [c for c in chars if c.name.strip().lower() in chapter_canon]
    if chapter.pov:
        pv = chapter.pov.strip().lower()
        for c in chars:
            if (c not in chapter_chars and
                    (pv in c.name.lower()
                     or any(pv in a.lower() for a in c.aliases))):
                chapter_chars.append(c)

    # Annotation deterministe des incises (bornes, non destructif) + seeding :
    # une incise nominale qui nomme un personnage fixe le locuteur avec certitude
    # AVANT l'appel LLM (corrige les cas que le petit modele rate).
    # NB: ne PAS inclure les alias ici -> mesure : ca change trop le prompt et
    # provoque de gros effets papillon (ch06 12B: 96% -> 80%). Les epithetes sont
    # rattaches en post-traitement par la canonicalisation (sur le cast complet).
    names = {c.name for c in chars}
    for seg in segments:
        if seg.type is not SegmentType.DIALOGUE:
            continue
        seg.incises = detect_incises(seg.text, names=names)
        for inc in seg.incises:
            # PRE-LLM : seuls les noms propres seedent (les incises de role
            # renvoient None -> pas de seed, donc prompt inchange ; les roles
            # sont rattaches en anonymes en post-traitement, sans effet papillon).
            spk = incise_speaker(seg.text, inc, names)
            if spk:
                seg.speaker = spk
                break

    conf = attribute_speakers(segments, gemma, characters=chapter_chars,
                              pov=chapter.pov)
    if get_settings().retro_pass_use_gemma:
        _refine_unknown_speakers(segments, gemma, characters=chapter_chars,
                                 confidence=conf)

    # Post-traitement deterministe (sans LLM). Ordre important :
    # 1. rattache les incises de role a un locuteur anonyme par genre/age ;
    # 2. repare l'alternance des tours dans les echanges a deux ;
    # 3. recolle les variantes de noms au canonique du cast (rendu + score) ;
    # 4. resout les figurants restes indetermines via la narration adjacente.
    anon = _apply_anonymous_speakers(segments, names=names)
    _repair_alternation(segments, names=names)
    _canonicalize_speakers(segments, chars)
    anon.update(_resolve_anonymous_figurants(segments))
    # Cree les Character generiques des buckets anonymes (assignation de voix).
    known = {c.name for c in chars}
    for ident, (gender, age) in anon.items():
        if ident not in known:
            chars.append(Character(name=ident, gender=gender, age=age))
            known.add(ident)

    # Absorbe les locuteurs residuels (hors liste) en aliases (heuristique seule).
    chars, _ = reconcile_characters(
        chars, [], None, speaker_names=[s.speaker for s in segments])

    # Les repliques sont persistees entieres ; les incises restent des bornes
    # (rendu : voix narrateur). Plus de fragmentation a l'analyse.
    analysis = ChapterAnalysis(index=chapter.index, title=ct.title,
                               segments=segments)
    return analysis, chars
