"""TTS script: generate MP3 audio and word-level timing JSON from a text file.

CLI:
    py -3.14 scripts/tts.py <script.txt> <out_audio.mp3> <out_timing.json>
                            [--voice zh-CN-XiaoxiaoNeural]

Timing JSON contract::

    {
        "voice": "zh-CN-XiaoxiaoNeural",
        "audio_duration": 12.34,
        "words": [
            {"text": "小明", "start": 0.05, "dur": 0.25},
            ...
        ]
    }

Units: offset/duration from edge_tts are 100-nanosecond ticks; divide by
10_000_000 to convert to seconds.
"""

from __future__ import annotations

import argparse
import asyncio
import json
import subprocess
import sys
from pathlib import Path
from typing import Any

import edge_tts

_TICKS_PER_SECOND = 10_000_000  # 100-ns ticks per second


async def _synthesise(
    text: str,
    voice: str,
    audio_path: Path,
) -> list[dict[str, Any]]:
    """Stream TTS, write audio bytes, collect WordBoundary events.

    Args:
        text: The input text to synthesize.
        voice: Edge TTS voice name.
        audio_path: Destination path for the MP3 file.

    Returns:
        List of word dicts with keys ``text``, ``start``, ``dur`` (seconds).
    """
    communicate = edge_tts.Communicate(
        text,
        voice=voice,
        boundary="WordBoundary",
    )

    words: list[dict[str, Any]] = []
    audio_chunks: list[bytes] = []

    async for chunk in communicate.stream():
        chunk_type: str = chunk["type"]
        if chunk_type == "audio":
            audio_chunks.append(chunk["data"])
        elif chunk_type == "WordBoundary":
            start_sec: float = chunk["offset"] / _TICKS_PER_SECOND
            dur_sec: float = chunk["duration"] / _TICKS_PER_SECOND
            words.append(
                {
                    "text": chunk["text"],
                    "start": round(start_sec, 6),
                    "dur": round(dur_sec, 6),
                }
            )

    audio_path.parent.mkdir(parents=True, exist_ok=True)
    audio_path.write_bytes(b"".join(audio_chunks))
    return words


def _ffprobe_duration(audio_path: Path) -> float:
    """Return the actual audio duration in seconds using ffprobe.

    Args:
        audio_path: Path to the audio file.

    Returns:
        Duration in seconds as a float.

    Raises:
        RuntimeError: If ffprobe fails or returns a non-numeric result.
    """
    result = subprocess.run(
        [
            "ffprobe",
            "-v", "error",
            "-show_entries", "format=duration",
            "-of", "csv=p=0",
            str(audio_path),
        ],
        capture_output=True,
        text=True,
        check=True,
    )
    raw = result.stdout.strip()
    if not raw:
        raise RuntimeError(f"ffprobe returned empty output for {audio_path}")
    return float(raw)


def _build_timing(
    voice: str,
    words: list[dict[str, Any]],
    audio_path: Path,
) -> dict[str, Any]:
    """Build the timing dict matching the contract.

    audio_duration = max(last_word.start + last_word.dur, ffprobe_duration).

    Args:
        voice: Voice name used for synthesis.
        words: List of word dicts from synthesis.
        audio_path: Path to the generated MP3 (for ffprobe measurement).

    Returns:
        Timing dict ready for JSON serialisation.
    """
    if words:
        last = words[-1]
        words_end: float = last["start"] + last["dur"]
    else:
        words_end = 0.0

    ffprobe_dur = _ffprobe_duration(audio_path)
    audio_duration = max(words_end, ffprobe_dur)

    return {
        "voice": voice,
        "audio_duration": round(audio_duration, 6),
        "words": words,
    }


def main() -> None:
    """Entry point for the CLI."""
    parser = argparse.ArgumentParser(
        description="Generate TTS audio and word-level timing from a script file."
    )
    parser.add_argument("script", type=Path, help="Input text file (UTF-8).")
    parser.add_argument("audio", type=Path, help="Output MP3 audio file.")
    parser.add_argument("timing", type=Path, help="Output timing JSON file.")
    parser.add_argument(
        "--voice",
        default="zh-CN-XiaoxiaoNeural",
        help="Edge TTS voice name (default: zh-CN-XiaoxiaoNeural).",
    )
    args = parser.parse_args()

    script_text = args.script.read_text(encoding="utf-8").strip()
    if not script_text:
        print(f"ERROR: script file is empty: {args.script}", file=sys.stderr)
        sys.exit(1)

    print(f"[tts] voice={args.voice}, input={args.script}", flush=True)
    words = asyncio.run(_synthesise(script_text, args.voice, args.audio))
    print(
        f"[tts] audio written: {args.audio} ({args.audio.stat().st_size} bytes)",
        flush=True,
    )
    print(f"[tts] word boundaries collected: {len(words)}", flush=True)

    timing = _build_timing(args.voice, words, args.audio)

    args.timing.parent.mkdir(parents=True, exist_ok=True)
    args.timing.write_text(
        json.dumps(timing, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    print(f"[tts] timing written: {args.timing}", flush=True)
    print(f"[tts] audio_duration={timing['audio_duration']:.3f}s", flush=True)


if __name__ == "__main__":
    main()
