"""FastMCP 3.0 server — tools, lifespan, and resource definitions."""

import asyncio
import re
import sys
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Literal

from fastmcp import Context, FastMCP
from fastmcp.server.dependencies import CurrentContext
from mcp.types import ToolAnnotations

from .audio import play_audio
from .engines.base import TTSEngine
from .engines.kokoro import KokoroEngine
from .engines.orpheus import OrpheusEngine
from .engines.piper import PiperEngine
from .media_duck import MediaDucker
from .queue import Priority, SpeechQueue
from .settings import settings
from .tones import generate_tones, resolve_tone
from .voice_identity import VoiceIdentityCache, get_project_name, resolve_voice

ENGINE_NAMES = Literal["piper", "kokoro", "orpheus"]


# ---------------------------------------------------------------------------
# Lifespan — load models once at startup, share across all requests
# ---------------------------------------------------------------------------

@asynccontextmanager
async def app_lifespan(server: FastMCP):
    loop = asyncio.get_running_loop()

    # --- Kokoro (ONNX) ---
    print("Loading Kokoro ONNX model...", file=sys.stderr)
    t0 = time.time()
    from kokoro_onnx import Kokoro

    kokoro_model = await loop.run_in_executor(
        None,
        Kokoro,
        str(settings.kokoro_model),
        str(settings.kokoro_voices),
    )
    print(f"  Kokoro ready in {time.time() - t0:.1f}s", file=sys.stderr)

    # --- Build engines ---
    engines: dict[str, TTSEngine] = {
        "piper": PiperEngine(settings.piper_host, settings.piper_port),
        "kokoro": KokoroEngine(kokoro_model),
        "orpheus": OrpheusEngine(settings.orpheus_url),
    }

    # Health check all engines at startup
    for name, eng in engines.items():
        health = await eng.check_health()
        print(f"  {name}: {health['status']}", file=sys.stderr)

    # Generate tones and resolve which ones to use
    tone_paths = generate_tones(settings.output_dir)
    entry_tone = resolve_tone(settings.entry_tone, tone_paths, "entry_tone", default="chirp")
    exit_tone = resolve_tone(settings.exit_tone, tone_paths, "exit_tone", default="roger")
    cancel_tone = resolve_tone(settings.cancel_tone, tone_paths, "cancel_tone", default="scratch")
    standby_tone = tone_paths.get("standby")

    # --- Media ducker ---
    ducker: MediaDucker | None = None
    if settings.duck_media:
        ducker = MediaDucker(
            fade_out_ms=settings.duck_fade_out_ms,
            fade_in_ms=settings.duck_fade_in_ms,
        )

    queue = SpeechQueue(
        exit_tone=exit_tone,
        standby_tone=standby_tone,
        cancel_tone=cancel_tone,
        shutdown_timeout=settings.shutdown_timeout,
        ducker=ducker,
    )
    queue.start()

    entry_label = settings.entry_tone if entry_tone else "none"
    exit_label = settings.exit_tone if exit_tone else "none"
    cancel_label = settings.cancel_tone if cancel_tone else "none"
    duck_label = "on" if ducker else "off"
    print(
        f"McSpeak ready on {settings.host}:{settings.port} "
        f"with {len(engines)} engines, tones={entry_label}/{exit_label}/{cancel_label}, "
        f"duck={duck_label}",
        file=sys.stderr,
    )

    voice_cache = VoiceIdentityCache(persist_path=settings.voice_identity_file)

    identity_label = "on" if settings.voice_identity else "off"
    announce_label = "+announce" if settings.announce_project else ""
    print(
        f"  Voice identity: {identity_label}{announce_label}",
        file=sys.stderr,
    )
    print(
        f"  Shutdown budget: 3s handler drain + "
        f"{settings.shutdown_timeout:.0f}s queue drain + 5s safety",
        file=sys.stderr,
    )

    try:
        yield {
            "engines": engines,
            "queue": queue,
            "voice_cache": voice_cache,
            "entry_tone": entry_tone,
            "ducker": ducker,
        }
    finally:
        print("McSpeak shutting down", file=sys.stderr)
        await queue.stop()
        for eng in engines.values():
            if hasattr(eng, "close"):
                await eng.close()


# ---------------------------------------------------------------------------
# FastMCP instance
# ---------------------------------------------------------------------------

mcp = FastMCP(
    "mcspeak",
    instructions=(
        "Multi-engine text-to-speech server. Use 'speak' to synthesize and play audio "
        "through the host speakers.\n\n"
        "IMPORTANT — speak() plays through physical speakers, not into your context. "
        "Always call it IN PARALLEL with other tools in the same message rather than "
        "blocking on it alone. The return value is informational (speech_id, duration) "
        "and never needed for subsequent reasoning. Progress notifications arrive every "
        "~0.5s during playback so clients can show a progress indicator.\n\n"
        "If you need to check whether speech finished, use speech_status(speech_id) "
        "after the fact. Use generate_audio to synthesize without playing.\n\n"
        "Engines: kokoro (fast ONNX, ~50 voices), piper (Wyoming/Docker), "
        "orpheus (LLM via llama-server, supports <laugh> etc.). "
        "Always pass project= with the current project directory name (last path component) "
        "so each project gets a consistent, distinct voice automatically."
    ),
    lifespan=app_lifespan,
)


def _get_state(
    ctx: Context,
) -> tuple[dict[str, TTSEngine], SpeechQueue, VoiceIdentityCache, Path | None, MediaDucker | None]:
    """Extract engines, queue, voice cache, entry tone, and ducker from lifespan context."""
    state = ctx.lifespan_context
    return (
        state["engines"],
        state["queue"],
        state["voice_cache"],
        state["entry_tone"],
        state["ducker"],
    )


async def _play_tone(tone_path: Path) -> None:
    """Fire-and-forget tone playback. Non-fatal — errors are swallowed."""
    try:
        await play_audio(tone_path, expected_seconds=0.3)
    except Exception as e:
        print(f"  Entry tone failed (non-fatal): {e}", file=sys.stderr)


_CHUNK_WORD_THRESHOLD = 20  # texts shorter than this bypass chunking


def split_text(text: str) -> list[str]:
    """Split text into sentence-sized chunks for pipelined synthesis.

    Splits on sentence-ending punctuation (.!?) followed by whitespace.
    Merges short fragments (< 5 words) with their neighbor.
    Returns [text] unchanged if under the word threshold.
    """
    words = text.split()
    if len(words) < _CHUNK_WORD_THRESHOLD:
        return [text]

    # Split on sentence boundaries — require uppercase after split to avoid
    # false positives on abbreviations (Dr.), decimals (3.0), URLs, etc.
    raw = re.split(r"(?<=[.!?])\s+(?=[A-Z])", text.strip())
    if len(raw) <= 1:
        return [text]

    # Merge short fragments (< 5 words) with next chunk.
    # First chunk is allowed to be short — minimizes time-to-first-audio.
    chunks: list[str] = [raw[0]]
    for fragment in raw[1:]:
        if len(chunks[-1].split()) < 5:
            chunks[-1] = chunks[-1] + " " + fragment
        else:
            chunks.append(fragment)

    # If the last chunk is very short, merge it into its predecessor
    if len(chunks) > 1 and len(chunks[-1].split()) < 5:
        tail = chunks.pop()
        chunks[-1] = chunks[-1] + " " + tail

    return chunks


async def _resolve_project_voice(
    ctx: Context,
    engine_name: str,
    eng: TTSEngine,
    voice: str | None,
    text: str,
    voice_cache: VoiceIdentityCache,
    project: str | None = None,
) -> tuple[str | None, str]:
    """Auto-assign voice from project hint or MCP roots, optionally prefix text."""
    if voice is not None or not settings.voice_identity:
        return voice, text

    # Explicit project param takes priority, then try MCP roots
    project_name = project or await get_project_name(ctx)
    if not project_name:
        return voice, text

    voice = await resolve_voice(engine_name, eng, project_name, voice_cache)
    if settings.announce_project and voice is not None:
        text = f"{project_name}. {text}"

    return voice, text


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------

async def _speak_single(
    eng: TTSEngine,
    engine: str,
    text: str,
    voice: str | None,
    queue: SpeechQueue,
    entry_tone: Path | None,
    priority: Priority,
    ctx: Context,
    ducker: MediaDucker | None = None,
) -> dict:
    """Single-shot speak path — synthesize full text, then enqueue.

    Used for short texts (< 20 words) and as the fallback when text has
    no sentence boundaries.
    """
    try:
        # Duck external media with crossfade into entry tone
        if ducker:
            duck_task = asyncio.create_task(ducker.duck())
            await asyncio.sleep(0.15)  # Let fade start — media dips to ~70%
            if entry_tone:
                await _play_tone(entry_tone)  # Tone crossfades over fading media
            await duck_task  # Ensure duck completes
        elif entry_tone:
            await _play_tone(entry_tone)
        await ctx.report_progress(progress=5, total=100, message="Entry tone")

        await ctx.info(f"Synthesizing with {engine}...")
        result = await eng.synthesize(text, voice)
        await ctx.report_progress(progress=30, total=100, message="Synthesizing...")

        await ctx.info(f"Synthesized {result.duration_seconds:.1f}s audio, enqueueing...")
        enqueue_result = await queue.enqueue(result, priority=priority)

        if not enqueue_result.get("queued"):
            return enqueue_result

        speech_id = enqueue_result["speech_id"]
        duration = result.duration_seconds
        await ctx.report_progress(
            progress=35, total=100,
            message="Playing audio \u2014 you can continue working",
        )

        await ctx.info("Playing...")
        # NOTE: Do NOT use asyncio.wait_for() — see CLAUDE.md Python 3.13 pitfall
        outcome = await _await_with_progress(queue, speech_id, duration, ctx, 35)

        await ctx.report_progress(progress=100, total=100, message="Playback complete")
        return outcome

    except asyncio.CancelledError:
        # speak() was cancelled (server shutdown or MCP client disconnect).
        # Do NOT cancel the consumer — let it finish the current audio.
        # Shutdown: queue.stop() in the lifespan finalizer handles graceful drain.
        # Client cancel: use cancel_speech(speech_id) for explicit mid-playback stop.
        raise


async def _speak_chunked(
    eng: TTSEngine,
    engine: str,
    chunks: list[str],
    voice: str | None,
    queue: SpeechQueue,
    entry_tone: Path | None,
    priority: Priority,
    ctx: Context,
    ducker: MediaDucker | None = None,
) -> dict:
    """Chunked speak path — pipeline synthesis with playback.

    Synthesizes sentence chunks one at a time and enqueues each immediately.
    Playback of chunk N overlaps synthesis of chunk N+1. Non-final chunks
    suppress exit/standby tones for seamless playback.
    """
    n_chunks = len(chunks)
    total_duration = 0.0
    all_speech_ids: list[str] = []
    first_enqueue_time: float | None = None

    try:
        # Duck external media with crossfade into entry tone
        if ducker:
            duck_task = asyncio.create_task(ducker.duck())
            await asyncio.sleep(0.15)  # Let fade start — media dips to ~70%
            if entry_tone:
                await _play_tone(entry_tone)  # Tone crossfades over fading media
            await duck_task  # Ensure duck completes
        elif entry_tone:
            await _play_tone(entry_tone)
        await ctx.report_progress(progress=5, total=100, message="Entry tone")

        for i, chunk in enumerate(chunks):
            is_last = i == n_chunks - 1
            pct_synth = 5 + int(25 * (i + 1) / n_chunks)  # 5-30% across all chunks

            await ctx.info(f"Synthesizing chunk {i + 1}/{n_chunks} with {engine}...")
            result = await eng.synthesize(chunk, voice)
            total_duration += result.duration_seconds
            await ctx.report_progress(
                progress=pct_synth, total=100,
                message=f"Synthesizing chunk {i + 1}/{n_chunks}...",
            )

            enqueue_result = await queue.enqueue(
                result,
                priority=priority,
                suppress_exit_tone=not is_last,
            )

            if not enqueue_result.get("queued"):
                # Queue full mid-message — already-enqueued chunks play through
                if all_speech_ids:
                    msg = f"Queue full at chunk {i + 1}/{n_chunks}, waiting for enqueued chunks..."
                    await ctx.info(msg)
                    elapsed = time.time() - first_enqueue_time if first_enqueue_time else 0
                    remaining = max(total_duration - elapsed, 0.1)
                    outcome = await _await_with_progress(
                        queue, all_speech_ids[-1], remaining, ctx, 35
                    )
                    outcome["chunks_enqueued"] = len(all_speech_ids)
                    outcome["chunks_total"] = n_chunks
                    return outcome
                return enqueue_result

            all_speech_ids.append(enqueue_result["speech_id"])
            if first_enqueue_time is None:
                first_enqueue_time = time.time()

        # All chunks enqueued — wait for final chunk to finish playing
        if not all_speech_ids:
            return {"error": "No chunks were successfully enqueued", "chunks_total": n_chunks}

        await ctx.report_progress(
            progress=35, total=100,
            message="Playing audio \u2014 you can continue working",
        )
        await ctx.info(f"Playing {n_chunks} chunks ({total_duration:.1f}s total)...")

        # Subtract time already elapsed since first chunk started playing
        elapsed = time.time() - first_enqueue_time if first_enqueue_time else 0
        remaining = max(total_duration - elapsed, 0.1)
        outcome = await _await_with_progress(queue, all_speech_ids[-1], remaining, ctx, 35)
        await ctx.report_progress(progress=100, total=100, message="Playback complete")

        # Check for partial failures in earlier chunks
        failed_chunks = []
        for idx, sid in enumerate(all_speech_ids[:-1]):
            chunk_status = queue.get_status(sid)
            if chunk_status.get("status") == "completed" and not chunk_status.get("played", True):
                failed_chunks.append(idx + 1)

        outcome["chunks"] = n_chunks
        outcome["total_duration_seconds"] = total_duration
        if failed_chunks:
            outcome["partial_failure"] = True
            outcome["failed_chunks"] = failed_chunks
        return outcome

    except asyncio.CancelledError:
        # speak() was cancelled (server shutdown or MCP client disconnect).
        # Do NOT cancel the consumer — let already-enqueued chunks play through.
        # Shutdown: queue.stop() in the lifespan finalizer handles graceful drain.
        # Client cancel: use cancel_speech(speech_id) for explicit mid-playback stop.
        raise


async def _await_with_progress(
    queue: SpeechQueue,
    speech_id: str,
    duration: float,
    ctx: Context,
    base_pct: int,
) -> dict:
    """Await playback completion with a progress ticker.

    Uses a background task for progress — NOT asyncio.wait_for polling
    (see CLAUDE.md for the Python 3.13 pitfall).
    """
    async def _progress_ticker():
        t0 = time.time()
        while True:
            await asyncio.sleep(0.5)
            elapsed = time.time() - t0
            pct = min(base_pct + int((99 - base_pct) * elapsed / max(duration, 0.1)), 99)
            await ctx.report_progress(
                progress=pct, total=100,
                message="Playing audio \u2014 you can continue working",
            )

    ticker = asyncio.create_task(_progress_ticker())
    try:
        return await queue.wait_for_completion(speech_id)
    finally:
        ticker.cancel()
        try:
            await ticker
        except asyncio.CancelledError:
            pass


@mcp.tool(annotations=ToolAnnotations(
    readOnlyHint=False,
    destructiveHint=False,
    idempotentHint=True,
    openWorldHint=True,
))
async def speak(
    text: str,
    engine: ENGINE_NAMES = "kokoro",
    voice: str | None = None,
    urgent: bool = False,
    project: str | None = None,
    ctx: Context = CurrentContext(),
) -> dict:
    """Synthesize text and play it through the host speakers.

    CALL THIS IN PARALLEL with other tools — don't block on it alone.
    Audio plays through physical speakers, not into your context. The return
    value (speech_id, duration) is informational only and never needed for
    subsequent reasoning. Call speak alongside your next tool in the same
    message and let progress notifications track playback.

    Progress notifications arrive every ~0.5s during playback:
    - 5%: Entry tone played (audible acknowledgement)
    - 30%: Synthesis complete
    - 35-99%: Playing — progress tracks elapsed time vs duration
    - 100%: Playback complete

    External media (YouTube, Spotify) is automatically ducked during speech
    and fades back in after the exit tone.

    Long texts are automatically split into sentences and pipelined — the
    first sentence plays within ~1-2s while remaining sentences synthesize
    in the background.

    Audio is queued so agents don't talk over each other. Urgent messages
    jump ahead of normal-priority items.

    Args:
        text: Text to speak. Orpheus supports emotion tags like <laugh>, <sigh>, etc.
        engine: TTS engine to use. kokoro is fastest, orpheus is most expressive.
        voice: Voice name (use list_voices to see options). None = auto-assigned by project.
        urgent: If True, this message jumps ahead of normal-priority items.
        project: Project name for voice identity (auto-detected from MCP roots if omitted).
    """
    engines, queue, voice_cache, entry_tone, ducker = _get_state(ctx)

    if engine not in engines:
        return {"error": f"Unknown engine: {engine}. Available: {list(engines.keys())}"}

    eng = engines[engine]
    voice, text = await _resolve_project_voice(
        ctx, engine, eng, voice, text, voice_cache, project,
    )

    priority = Priority.URGENT if urgent else Priority.NORMAL

    chunks = split_text(text)
    if len(chunks) <= 1:
        return await _speak_single(
            eng, engine, text, voice, queue, entry_tone, priority, ctx, ducker,
        )
    return await _speak_chunked(
        eng, engine, chunks, voice, queue, entry_tone, priority, ctx, ducker,
    )


@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
async def speech_status(
    speech_id: str,
    ctx: Context = CurrentContext(),
) -> dict:
    """Check the status of a previously enqueued speech item.

    Returns the current state: 'completed' (with playback result), 'playing',
    'queued' (with position), or 'unknown' (expired from history or invalid ID).

    Args:
        speech_id: The speech_id returned by speak().
    """
    _, queue, _, _, _ = _get_state(ctx)
    return queue.get_status(speech_id)


@mcp.tool(annotations=ToolAnnotations(
    readOnlyHint=False,
    destructiveHint=True,
    idempotentHint=True,
))
async def cancel_speech(
    speech_id: str,
    ctx: Context = CurrentContext(),
) -> dict:
    """Cancel a queued or currently-playing speech item.

    If the item is playing, stops playback immediately and plays a cancel tone.
    If the item is queued, removes it from the queue.

    Args:
        speech_id: The speech_id returned by speak().
    """
    _, queue, _, _, _ = _get_state(ctx)
    return queue.cancel(speech_id)


@mcp.tool(annotations=ToolAnnotations(
    readOnlyHint=False,
    destructiveHint=False,
    idempotentHint=True,
))
async def generate_audio(
    text: str,
    engine: ENGINE_NAMES = "kokoro",
    voice: str | None = None,
    project: str | None = None,
    ctx: Context = CurrentContext(),
) -> dict:
    """Synthesize text to a WAV file without playing it.

    Bypasses the speech queue — multiple agents can generate simultaneously.
    Returns the file path and metadata.

    Args:
        text: Text to synthesize.
        engine: TTS engine to use.
        voice: Voice name (use list_voices to see options). None = auto-assigned by project.
        project: Project name for voice identity (auto-detected from MCP roots if omitted).
    """
    engines, _, voice_cache, _, _ = _get_state(ctx)

    if engine not in engines:
        return {"error": f"Unknown engine: {engine}. Available: {list(engines.keys())}"}

    eng = engines[engine]
    voice, text = await _resolve_project_voice(
        ctx, engine, eng, voice, text, voice_cache, project,
    )

    await ctx.info(f"Generating audio with {engine}...")
    result = await eng.synthesize(text, voice)

    return {
        "file": str(result.audio_path),
        "duration_seconds": result.duration_seconds,
        "sample_rate": result.sample_rate,
        "engine": result.engine,
        "voice": result.voice,
    }


@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
async def list_voices(
    engine: ENGINE_NAMES,
    ctx: Context = CurrentContext(),
) -> list[str]:
    """List available voices for a TTS engine.

    Blacklisted voices are excluded. Use the returned names as the
    'voice' parameter in speak/generate_audio.

    Args:
        engine: Which engine to list voices for.
    """
    engines, _, _, _, _ = _get_state(ctx)

    if engine not in engines:
        return []

    return await engines[engine].list_voices()


@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True))
async def list_engines(
    ctx: Context = CurrentContext(),
) -> list[dict]:
    """Show all TTS engines and their health status.

    Returns engine name, default voice, and health check results.
    """
    engines, queue, _, _, _ = _get_state(ctx)

    results = []
    for name, eng in engines.items():
        health = await eng.check_health()
        results.append({
            "engine": name,
            "default_voice": eng.default_voice,
            **health,
        })

    # Include queue status
    results.append({"queue": queue.status()})
    return results


# ---------------------------------------------------------------------------
# Resource — recent audio files
# ---------------------------------------------------------------------------

@mcp.resource("audio://recent")
async def recent_audio() -> str:
    """List recently generated audio files."""
    out_dir = settings.output_dir
    if not out_dir.exists():
        return "No audio files yet."

    wavs = sorted(out_dir.glob("*.wav"), key=lambda p: p.stat().st_mtime, reverse=True)
    lines = []
    for w in wavs[:20]:
        size_kb = w.stat().st_size / 1024
        mtime = time.strftime("%H:%M:%S", time.localtime(w.stat().st_mtime))
        lines.append(f"{mtime}  {size_kb:6.1f}KB  {w.name}")

    return "\n".join(lines) if lines else "No audio files yet."
