"""Speech synthesizer for Tea Party — Polly TTS with SSML preprocessing.

Converts Script turns into MP3 audio segments via Amazon Polly, with
configurable engine selection, text chunking at 3,000 characters, and
SSML structural markup for natural pacing.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any

from alice_cli.errors import AliceCLIError
from alice_cli.teaparty.models import GenerationMode, Script

# Maximum characters per Polly synthesis request.
_CHUNK_LIMIT = 3000

# Sentence-ending patterns for chunking.
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")

# Coordinating conjunctions for micro-pause heuristic.
_CONJUNCTIONS = {"and", "but", "or", "nor", "for", "yet", "so"}

# Comma followed by a coordinating conjunction or 5+ words (independent clause heuristic).
_COMMA_PAUSE_RE = re.compile(r",\s+(?:(?:and|but|or|nor|for|yet|so)\b|(?:\S+\s+){4,}\S+)")

# Semicolon pause pattern.
_SEMICOLON_RE = re.compile(r";\s*")


@dataclass
class SynthesizedTurn:
    """A single synthesized audio segment."""

    audio_data: bytes  # MP3 audio bytes
    turn_index: int


# ---------------------------------------------------------------------------
# Text chunking
# ---------------------------------------------------------------------------


def chunk_text(text: str, limit: int = _CHUNK_LIMIT) -> list[str]:
    """Split *text* into chunks of at most *limit* characters.

    Splitting strategy:
    1. If text ≤ limit → single chunk.
    2. Otherwise split at sentence boundaries (`. `, `? `, `! `).
    3. If a single sentence > limit → split at nearest word boundary.

    Concatenating all returned chunks reproduces the original text exactly.
    """
    if len(text) <= limit:
        return [text]

    chunks: list[str] = []
    remaining = text

    while remaining:
        if len(remaining) <= limit:
            chunks.append(remaining)
            break

        # Try to find a sentence boundary within the limit.
        best_split = _find_sentence_boundary(remaining, limit)
        if best_split > 0:
            chunks.append(remaining[:best_split])
            remaining = remaining[best_split:]
        else:
            # No sentence boundary found — split at word boundary.
            best_split = _find_word_boundary(remaining, limit)
            chunks.append(remaining[:best_split])
            remaining = remaining[best_split:]

    return chunks


def _find_sentence_boundary(text: str, limit: int) -> int:
    """Find the last sentence boundary within *limit* characters.

    Returns the index just after the sentence-ending punctuation + space,
    or 0 if no boundary is found.
    """
    best = 0
    for m in _SENTENCE_SPLIT_RE.finditer(text):
        end = m.end()
        if end <= limit:
            best = end
        else:
            break
    return best


def _find_word_boundary(text: str, limit: int) -> int:
    """Find the last word boundary (space) within *limit* characters.

    Returns *limit* if no space is found (forced split).
    """
    idx = text.rfind(" ", 0, limit)
    if idx <= 0:
        return limit
    return idx + 1  # include the space in the first chunk


# ---------------------------------------------------------------------------
# SSML preprocessing
# ---------------------------------------------------------------------------


def prepare_ssml(
    text: str,
    engine: str = "generative",
    mode: GenerationMode = GenerationMode.CONVERSATION,
    neural_style: bool = False,
) -> str:
    """Apply the SSML preprocessing pipeline to *text*.

    This is a pure function — no side effects.

    Pipeline:
    1. Paragraph detection → ``<p>`` tags
    2. Sentence detection → ``<s>`` tags
    3. Micro-pause insertion (``<break>`` tags)
    4. Domain wrapping (neural_style only)
    5. Prosody wrapping (long-form narrator only)
    6. Outer ``<speak>`` wrapping
    """
    # 1 & 2: Split into paragraphs, then sentences, wrapping in <p>/<s>.
    paragraphs = _split_paragraphs(text)
    body_parts: list[str] = []
    for para in paragraphs:
        sentences = _split_sentences(para)
        wrapped_sentences = [f"<s>{_insert_breaks(s)}</s>" for s in sentences]
        body_parts.append(f"<p>{''.join(wrapped_sentences)}</p>")

    body = "".join(body_parts)

    # 4. Domain wrapping (neural_style + neural engine only).
    if neural_style and engine == "neural":
        domain = _domain_for_mode(mode)
        if domain is not None:
            body = f'<amazon:domain name="{domain}">{body}</amazon:domain>'

    # 5. Prosody wrapping (long-form narrator only).
    if engine == "long-form" and mode == GenerationMode.NARRATOR:
        body = f'<prosody rate="medium">{body}</prosody>'

    # 6. Outer <speak> wrapping.
    return f"<speak>{body}</speak>"


def _split_paragraphs(text: str) -> list[str]:
    """Split text on double-newlines into paragraph segments."""
    parts = re.split(r"\n\n+", text)
    return [p.strip() for p in parts if p.strip()]


def _split_sentences(text: str) -> list[str]:
    """Split a paragraph into sentences.

    Keeps the sentence-ending punctuation attached to each sentence.
    """
    # Split after sentence-ending punctuation followed by whitespace.
    raw = _SENTENCE_SPLIT_RE.split(text)
    return [s.strip() for s in raw if s.strip()]


def _insert_breaks(sentence: str) -> str:
    """Insert ``<break>`` micro-pauses at natural pause points.

    - After commas preceding independent clauses: 200ms
    - After semicolons: 250ms
    """
    # Semicolons first (so comma regex doesn't interfere).
    result = _SEMICOLON_RE.sub(
        ';<break time="250ms"/> ',
        sentence,
    )
    # Commas before independent clauses.
    result = _COMMA_PAUSE_RE.sub(
        lambda m: m.group(0)[:1] + '<break time="200ms"/> ' + m.group(0)[2:],
        result,
    )
    return result


def _domain_for_mode(mode: GenerationMode) -> str | None:
    """Return the ``amazon:domain`` name for the given mode, or None."""
    if mode in (GenerationMode.CONVERSATION, GenerationMode.INTERVIEW):
        return "conversational"
    if mode == GenerationMode.BRIEFING:
        return "news"
    return None  # narrator — uses long-form engine, no domain


# ---------------------------------------------------------------------------
# Engine selection
# ---------------------------------------------------------------------------


def select_engine(
    mode: GenerationMode,
    voice_id: str,
    supported_engines: list[str],
    neural_style: bool = False,
) -> str:
    """Select the best Polly engine for the given mode and voice.

    Priority:
    - narrator mode + voice supports long-form → "long-form"
    - neural_style=True + voice supports neural → "neural"
    - default → "generative"
    - fallback if generative unavailable → "neural" → "standard"
    """
    # Narrator + long-form takes highest priority.
    if mode == GenerationMode.NARRATOR and "long-form" in supported_engines:
        return "long-form"

    # Neural style requested and available.
    if neural_style and "neural" in supported_engines:
        return "neural"

    # Default: generative if available.
    if "generative" in supported_engines:
        return "generative"

    # Fallback chain.
    if "neural" in supported_engines:
        return "neural"
    if "standard" in supported_engines:
        return "standard"

    # Last resort — return generative and let Polly error.
    return "generative"


# ---------------------------------------------------------------------------
# Synthesis orchestration
# ---------------------------------------------------------------------------


def synthesize_turns(
    script: Script,
    session: Any,  # boto3 Session
    voice_a: str,
    voice_b: str,
    engine: str = "generative",
    mode: GenerationMode = GenerationMode.CONVERSATION,
    neural_style: bool = False,
    quiet: bool = False,
) -> list[SynthesizedTurn]:
    """Synthesize all turns in *script* to MP3 audio segments via Polly.

    Each turn is synthesized with the specified engine. Turns exceeding
    3,000 characters are split at sentence boundaries into chunks, each
    synthesized separately and concatenated.
    """
    polly = session.client("polly")
    results: list[SynthesizedTurn] = []

    for idx, turn in enumerate(script.turns):
        voice_id = voice_a if turn.speaker == "Host_A" else voice_b

        # In narrator mode, always use voice_a.
        if mode == GenerationMode.NARRATOR:
            voice_id = voice_a

        chunks = chunk_text(turn.text)
        audio_parts: list[bytes] = []

        for chunk in chunks:
            ssml = prepare_ssml(chunk, engine, mode, neural_style)
            try:
                response = polly.synthesize_speech(
                    Text=ssml,
                    TextType="ssml",
                    OutputFormat="mp3",
                    VoiceId=voice_id,
                    Engine=engine,
                )
                audio_stream = response.get("AudioStream")
                if audio_stream is None:
                    raise AliceCLIError(f"Polly returned no audio stream for turn {idx}")
                audio_parts.append(audio_stream.read())
            except AliceCLIError:
                raise
            except Exception as exc:
                raise AliceCLIError(f"Polly synthesis failed for turn {idx}: {exc}") from exc

        results.append(
            SynthesizedTurn(
                audio_data=b"".join(audio_parts),
                turn_index=idx,
            )
        )

    return results
