#!/usr/bin/env bash

function coqui-read {
    local script_file stdin_file
    script_file="$(mktemp)"
    stdin_file=""

    if [[ "$#" -eq 0 && ! -t 0 ]]; then
        stdin_file="$(mktemp)"
        cat > "$stdin_file"
        set -- --stdin-file "$stdin_file"
    fi

cat > "$script_file" <<'PY'
import argparse
import json
import re
import subprocess
import sys
import tempfile
import urllib.parse
import urllib.request
from urllib.error import HTTPError, URLError
from pathlib import Path


DEFAULT_HOST = "http://[::1]:11115"
MAX_FILE_PATH_CHARS = 255
POST_FALLBACK_STATUS_CODES = {400, 404, 405}


class FallbackToGet(Exception):
    pass


def split_sentences(text: str) -> list[str]:
    parts = re.split(r"(?<=[.!?])\s+", text.strip())
    return [part.strip() for part in parts if part.strip()]


def split_text(text: str, mode: str, max_chars: int) -> list[str]:
    normalized = re.sub(r"\r\n?", "\n", text).strip()
    if not normalized:
        return []

    if mode == "sentences":
        units = split_sentences(normalized)
    else:
        units = [chunk.strip() for chunk in re.split(r"\n\s*\n+", normalized) if chunk.strip()]

    chunks: list[str] = []
    for unit in units:
        if len(unit) <= max_chars:
            chunks.append(unit)
            continue

        sentences = split_sentences(unit)
        if len(sentences) <= 1:
            chunks.append(unit)
            continue

        current = ""
        for sentence in sentences:
            candidate = sentence if not current else f"{current} {sentence}"
            if len(candidate) <= max_chars:
                current = candidate
            else:
                if current:
                    chunks.append(current)
                current = sentence
        if current:
            chunks.append(current)

    return chunks


def build_payload(text: str, speaker: str | None, language: str | None) -> dict[str, str]:
    payload: dict[str, str] = {"text": text}
    if speaker:
        payload["speaker_id"] = speaker
        payload["speaker"] = speaker
    if language:
        payload["language_id"] = language
        payload["language"] = language
    return payload


def post_tts(base_url: str, text: str, speaker: str | None, language: str | None) -> bytes:
    body = json.dumps(build_payload(text, speaker, language), separators=(",", ":")).encode("utf-8")
    request = urllib.request.Request(
        f"{base_url.rstrip('/')}/api/tts",
        data=body,
        method="POST",
        headers={"Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(request, timeout=300) as response:
            return response.read()
    except HTTPError as error:
        detail = error.read().decode("utf-8", errors="replace")
        if error.code in POST_FALLBACK_STATUS_CODES:
            raise FallbackToGet()
        if detail.strip() and detail.strip().startswith("{"):
            try:
                detail = json.dumps(json.loads(detail), indent=2)
            except Exception:
                pass
        raise SystemExit(f"TTS request failed (HTTP {error.code}): {detail or error.reason}")
    except URLError as error:
        raise SystemExit(f"Could not connect to Coqui service at {base_url}: {error}")


def get_tts(base_url: str, text: str, speaker: str | None, language: str | None) -> bytes:
    # Keep GET fallback for servers that do not support POST.
    params = build_payload(text, speaker, language)
    query = urllib.parse.urlencode(params)
    request = urllib.request.Request(f"{base_url.rstrip('/')}/api/tts?{query}")
    try:
        with urllib.request.urlopen(request, timeout=300) as response:
            return response.read()
    except HTTPError as error:
        detail = error.read().decode("utf-8", errors="replace")
        raise SystemExit(f"TTS request failed (HTTP {error.code}): {detail or error.reason}")
    except URLError as error:
        raise SystemExit(f"Could not connect to Coqui service at {base_url}: {error}")


def synthesize_chunk(base_url: str, text: str, speaker: str | None, language: str | None, chunk_index: int) -> Path:
    try:
        wav_data = post_tts(base_url, text, speaker, language)
    except FallbackToGet:
        # Older Coqui server versions tend to require query-string inputs.
        wav_data = get_tts(base_url, text, speaker, language)

    temp_file = tempfile.NamedTemporaryFile(prefix=f"coqui-read-{chunk_index}-", suffix=".wav", delete=False)
    temp_file.write(wav_data)
    temp_file.close()
    return Path(temp_file.name)


def play_file(path: Path, player: str) -> None:
    if player == "ffplay":
        cmd = [player, "-nodisp", "-autoexit", "-loglevel", "warning", str(path)]
    else:
        cmd = [player, str(path)]
    subprocess.run(cmd, check=True)


def read_input(path: str | None) -> str:
    if path:
        if len(path) > MAX_FILE_PATH_CHARS:
            raise SystemExit("Path too long. Pass text via stdin instead of an argument.")
        if "\n" in path or "\r" in path:
            raise SystemExit("Path contains newline characters. Pass text via stdin instead of an argument.")
        if not Path(path).is_file():
            raise SystemExit(f"No such file: {path!r}")
        return Path(path).read_text(encoding="utf-8", errors="replace")
    return sys.stdin.read()


def main() -> int:
    parser = argparse.ArgumentParser(description="Read text incrementally through the local Coqui TTS service.")
    parser.add_argument("--stdin-file", default=None, help=argparse.SUPPRESS)
    parser.add_argument("--file", dest="file_path", default=None, help="Read text from a file path.")
    parser.add_argument("path", nargs="?", help="Optional file path. Text from stdin is used when omitted.")
    parser.add_argument("--host", default=DEFAULT_HOST, help=f"Coqui server base URL. Default: {DEFAULT_HOST}")
    parser.add_argument("--speaker", default=None, help="Optional speaker_id value.")
    parser.add_argument("--language", default=None, help="Optional language_id value.")
    parser.add_argument(
        "--chunk-mode",
        choices=["paragraphs", "sentences"],
        default="paragraphs",
        help="Chunking strategy before synthesis.",
    )
    parser.add_argument("--max-chars", type=int, default=700, help="Maximum characters per synthesized chunk.")
    parser.add_argument(
        "--player",
        default="ffplay",
        help="Playback command. Use 'none' to only synthesize and print wav paths.",
    )
    parser.add_argument(
        "--keep",
        action="store_true",
        help="Keep generated wav files on disk instead of deleting them after playback.",
    )
    args = parser.parse_args()

    if args.file_path and args.path:
        parser.error("Pass either --file or a positional path, not both.")

    if args.player == "none" and not args.keep:
        print("--player none implies --keep; preserving synthesized wav files.", file=sys.stderr)
        args.keep = True

    if args.stdin_file:
        text = Path(args.stdin_file).read_text(encoding="utf-8", errors="replace")
    else:
        text = read_input(args.file_path or args.path)
    chunks = split_text(text, args.chunk_mode, args.max_chars)
    if not chunks:
        print("No text to synthesize.", file=sys.stderr)
        return 1

    created_files: list[Path] = []
    try:
        for index, chunk in enumerate(chunks, start=1):
            print(f"[{index}/{len(chunks)}] Synthesizing {len(chunk)} chars...", file=sys.stderr)
            wav_path = synthesize_chunk(args.host, chunk, args.speaker, args.language, index)
            created_files.append(wav_path)
            print(wav_path)
            if args.player != "none":
                play_file(wav_path, args.player)
    finally:
        if not args.keep:
            for wav_path in created_files:
                wav_path.unlink(missing_ok=True)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
PY

    python3 "$script_file" "$@"
    local exit_code=$?
    rm -f "$script_file"
    if [[ -n "$stdin_file" ]]; then
        rm -f "$stdin_file"
    fi
    return "$exit_code"
}

coqui-read "$@"
