#!/usr/bin/env python3
"""Measure streaming TTS with an auditable incremental-text client."""

from __future__ import annotations

import argparse
import base64
import csv
import json
import re
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import wave
from datetime import datetime
from pathlib import Path
from typing import Any


REPO_ROOT = Path(__file__).resolve().parents[2]
LOG_ROOT = REPO_ROOT / "logs" / "streaming_test"
DEFAULT_SERVER_BIN = REPO_ROOT / "build" / "debug" / "bin" / "audiocpp_server"
DEFAULT_SAMPLE_RATE = 48000


def timestamp_slug() -> str:
    return datetime.now().strftime("%Y%m%d_%H%M%S")


def load_json(path: Path) -> dict[str, Any]:
    with path.open("r", encoding="utf-8") as handle:
        return json.load(handle)


def write_json(path: Path, value: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def path_is_under(path: Path, root: Path) -> bool:
    try:
        path.resolve().relative_to(root.resolve())
        return True
    except ValueError:
        return False


def split_incremental_text(text: str, max_chars: int) -> list[str]:
    if max_chars <= 0:
        raise RuntimeError("--max-chars must be positive")
    sentences = [part.strip() for part in re.split(r"(?<=[.!?])\s+", text.strip()) if part.strip()]
    chunks: list[str] = []
    current = ""
    for sentence in sentences:
        if not current:
            current = sentence
        elif len(current) + 1 + len(sentence) <= max_chars:
            current = current + " " + sentence
        else:
            chunks.append(current)
            current = sentence
        while len(current) > max_chars:
            split_at = current.rfind(" ", 0, max_chars + 1)
            if split_at <= 0:
                split_at = max_chars
            chunks.append(current[:split_at].strip())
            current = current[split_at:].strip()
    if current:
        chunks.append(current)
    if not chunks:
        raise RuntimeError("input text produced no chunks")
    return chunks


def gpu_memory_mib() -> int:
    output = subprocess.check_output(
        ["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
        text=True,
        timeout=5,
    )
    values = [int(line.strip()) for line in output.splitlines() if line.strip()]
    if not values:
        raise RuntimeError("nvidia-smi returned no GPU memory values")
    return max(values)


class VramSampler:
    def __init__(self, path: Path, interval_s: float) -> None:
        self.path = path
        self.interval_s = interval_s
        self.stop_event = threading.Event()
        self.thread: threading.Thread | None = None

    def __enter__(self) -> "VramSampler":
        if self.interval_s <= 0.0:
            raise RuntimeError("--vram-sample-ms must be positive")
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.thread = threading.Thread(target=self._run, daemon=True)
        self.thread.start()
        return self

    def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
        self.stop_event.set()
        if self.thread is not None:
            self.thread.join(timeout=5)

    def _run(self) -> None:
        start = time.perf_counter()
        with self.path.open("w", newline="", encoding="utf-8") as handle:
            writer = csv.writer(handle)
            writer.writerow(["elapsed_ms", "vram_mib"])
            while not self.stop_event.is_set():
                writer.writerow([round((time.perf_counter() - start) * 1000.0, 3), gpu_memory_mib()])
                handle.flush()
                time.sleep(self.interval_s)


def read_vram_summary(path: Path) -> dict[str, Any]:
    values: list[int] = []
    with path.open("r", encoding="utf-8") as handle:
        for row in csv.DictReader(handle):
            values.append(int(row["vram_mib"]))
    if not values:
        raise RuntimeError(f"VRAM sampler wrote no samples: {path}")
    return {
        "vram_start_mib": values[0],
        "vram_peak_mib": max(values),
        "vram_end_mib": values[-1],
        "vram_samples": len(values),
    }


def wait_for_health(base_url: str, timeout_s: float) -> None:
    deadline = time.monotonic() + timeout_s
    last_error: Exception | None = None
    while time.monotonic() < deadline:
        try:
            with urllib.request.urlopen(base_url + "/health", timeout=1) as response:
                if response.status == 200:
                    return
        except Exception as exc:
            last_error = exc
        time.sleep(0.5)
    raise RuntimeError(f"server health timeout for {base_url}: {last_error}")


def stream_speech_request(base_url: str, model: str, text: str, sample_rate: int) -> dict[str, Any]:
    payload = {
        "model": model,
        "input": text,
        "response_format": "pcm",
        "stream_format": "sse",
        "options": {"retry_badcase": False},
    }
    request = urllib.request.Request(
        base_url + "/v1/audio/speech",
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json", "Accept": "text/event-stream"},
        method="POST",
    )
    start = time.perf_counter()
    pcm = bytearray()
    delta_events = 0
    first_delta_ms: float | None = None
    done_ms: float | None = None
    ttft_ms: float | None = None
    done_event: dict[str, Any] | None = None
    errors: list[str] = []
    try:
        with urllib.request.urlopen(request, timeout=1200) as response:
            for raw_line in response:
                line = raw_line.decode("utf-8", "replace").strip()
                if not line or not line.startswith("data:"):
                    continue
                data = line[5:].strip()
                if data == "[DONE]":
                    break
                event = json.loads(data)
                event_ms = (time.perf_counter() - start) * 1000.0
                event_type = event.get("type")
                if event_type == "error":
                    errors.append(event.get("error", {}).get("message", json.dumps(event)))
                elif event_type == "speech.audio.delta":
                    delta_events += 1
                    if first_delta_ms is None:
                        first_delta_ms = event_ms
                    pcm.extend(base64.b64decode(event["audio"]))
                elif event_type == "speech.audio.done":
                    done_event = event
                    done_ms = event_ms
                    ttft_ms = event.get("timing", {}).get("ttft_ms")
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", "replace")
        raise RuntimeError(f"HTTP {exc.code}: {body}") from exc
    elapsed_ms = (time.perf_counter() - start) * 1000.0
    if errors:
        raise RuntimeError("; ".join(errors))
    if ttft_ms is None:
        raise RuntimeError("streaming speech response did not include ttft_ms")
    if not pcm:
        raise RuntimeError("streaming speech response produced no audio")
    return {
        "request": payload,
        "elapsed_ms": elapsed_ms,
        "ttft_ms": ttft_ms,
        "client_first_delta_ms": first_delta_ms,
        "client_done_ms": done_ms,
        "audio_duration_s": len(pcm) / (2.0 * sample_rate),
        "delta_events": delta_events,
        "pcm_bytes": len(pcm),
        "done_event": done_event,
        "pcm": bytes(pcm),
    }


def write_wav(path: Path, pcm: bytes, sample_rate: int = DEFAULT_SAMPLE_RATE) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with wave.open(str(path), "wb") as wav:
        wav.setnchannels(1)
        wav.setsampwidth(2)
        wav.setframerate(sample_rate)
        wav.writeframes(pcm)


def percentile(values: list[float], q: float) -> float:
    if not values:
        raise RuntimeError("percentile requires values")
    ordered = sorted(values)
    index = min(len(ordered) - 1, max(0, round((len(ordered) - 1) * q)))
    return ordered[index]


def run_measurement(args: argparse.Namespace) -> dict[str, Any]:
    output_dir = args.output_dir
    if not path_is_under(output_dir, LOG_ROOT):
        raise RuntimeError(f"--output-dir must be under {LOG_ROOT}")
    if args.max_chars <= 0:
        raise RuntimeError("--max-chars must be positive")
    output_dir.mkdir(parents=True, exist_ok=True)

    config = load_json(args.server_config)
    host = args.host or config.get("host", "127.0.0.1")
    port = int(args.port or config["port"])
    models = config.get("models") or []
    if not models and not args.model:
        raise RuntimeError("server config contains no models and --model was not provided")
    model = args.model or models[0]["id"]
    base_url = f"http://{host}:{port}"
    write_json(output_dir / "server_config_snapshot.json", config)

    input_text = args.input_text.read_text(encoding="utf-8")
    chunks = split_incremental_text(input_text, args.max_chars)
    (output_dir / "input_text.txt").write_text(input_text, encoding="utf-8")
    write_json(
        output_dir / "input_chunks.json",
        {
            "source": str(args.input_text),
            "input_chars": len(input_text),
            "max_chars": args.max_chars,
            "chunks": [{"index": index, "chars": len(chunk), "text": chunk} for index, chunk in enumerate(chunks)],
        },
    )

    requests_path = output_dir / "requests.jsonl"
    if requests_path.exists():
        requests_path.unlink()

    server: subprocess.Popen[str] | None = None
    server_log_handle = None
    server_command: list[str] | None = None
    try:
        if args.start_server:
            server_command = [
                str(args.server_bin),
                "--config",
                str(args.server_config),
                "--log-file",
                str(output_dir / "framework.log"),
            ]
            server_log_handle = (output_dir / "server_stdout.log").open("w", encoding="utf-8")
            server = subprocess.Popen(
                server_command,
                cwd=args.repo_root,
                stdout=server_log_handle,
                stderr=subprocess.STDOUT,
                text=True,
            )
        wait_for_health(base_url, args.health_timeout_s)
        warmup = stream_speech_request(base_url, model, args.warmup_text, args.sample_rate)
        write_wav(output_dir / "warmup_discard.wav", warmup["pcm"], args.sample_rate)
        warmup_meta = {key: value for key, value in warmup.items() if key != "pcm"}
        write_json(output_dir / "warmup_discard.json", warmup_meta)

        measured: list[dict[str, Any]] = []
        merged_pcm = bytearray()
        sequence_start = time.perf_counter()
        with VramSampler(output_dir / "measured_vram.csv", args.vram_sample_ms / 1000.0):
            for index, chunk in enumerate(chunks):
                result = stream_speech_request(base_url, model, chunk, args.sample_rate)
                chunk_pcm = result.pop("pcm")
                merged_pcm.extend(chunk_pcm)
                write_wav(output_dir / f"chunk_{index:03d}.wav", chunk_pcm, args.sample_rate)
                record = {
                    "index": index,
                    "input_chars": len(chunk),
                    "input_text": chunk,
                    **result,
                }
                measured.append(record)
                with requests_path.open("a", encoding="utf-8") as handle:
                    handle.write(json.dumps(record, ensure_ascii=False) + "\n")
        sequence_elapsed_ms = (time.perf_counter() - sequence_start) * 1000.0
        write_wav(output_dir / "merged.wav", bytes(merged_pcm), args.sample_rate)

        ttfts = [float(item["ttft_ms"]) for item in measured]
        summary = {
            "measurement_kind": "incremental_client_text_chunks_to_streaming_sse_tts",
            "repo": str(args.repo_root),
            "server_config": str(args.server_config),
            "server_command": server_command,
            "model": model,
            "memory_saver": args.memory_saver_label,
            "host": host,
            "port": port,
            "input_text": str(output_dir / "input_text.txt"),
            "input_text_source": str(args.input_text),
            "input_chars": len(input_text),
            "chunk_max_chars": args.max_chars,
            "chunk_count": len(chunks),
            "warmup": warmup_meta,
            "measured_sequence_elapsed_ms": sequence_elapsed_ms,
            "sample_rate": args.sample_rate,
            "total_audio_duration_s": len(merged_pcm) / (2.0 * args.sample_rate),
            "ttft_first_chunk_ms": ttfts[0],
            "ttft_min_ms": min(ttfts),
            "ttft_p50_ms": percentile(ttfts, 0.50),
            "ttft_p95_ms": percentile(ttfts, 0.95),
            "ttft_max_ms": max(ttfts),
            "ttft_mean_ms": sum(ttfts) / len(ttfts),
            "client_first_delta_first_chunk_ms": measured[0]["client_first_delta_ms"],
            "chunks": measured,
            "artifacts": {
                "summary": str(output_dir / "summary.json"),
                "server_config_snapshot": str(output_dir / "server_config_snapshot.json"),
                "framework_log": str(output_dir / "framework.log") if args.start_server else None,
                "server_stdout": str(output_dir / "server_stdout.log") if args.start_server else None,
                "input_text": str(output_dir / "input_text.txt"),
                "chunks": str(output_dir / "input_chunks.json"),
                "requests": str(requests_path),
                "warmup_wav": str(output_dir / "warmup_discard.wav"),
                "warmup_json": str(output_dir / "warmup_discard.json"),
                "merged_wav": str(output_dir / "merged.wav"),
                "vram_csv": str(output_dir / "measured_vram.csv"),
            },
        }
        summary.update(read_vram_summary(output_dir / "measured_vram.csv"))
        write_json(output_dir / "summary.json", summary)
        return summary
    finally:
        if server is not None:
            server.terminate()
            try:
                server.wait(timeout=10)
            except subprocess.TimeoutExpired:
                server.kill()
                server.wait(timeout=10)
        if server_log_handle is not None:
            server_log_handle.close()


def parse_args() -> argparse.Namespace:
    default_output = LOG_ROOT / f"tts_streaming_{timestamp_slug()}"
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--repo-root", type=Path, default=REPO_ROOT)
    parser.add_argument("--server-bin", type=Path, default=DEFAULT_SERVER_BIN)
    parser.add_argument("--server-config", type=Path, required=True)
    parser.add_argument("--start-server", action=argparse.BooleanOptionalAction, default=True)
    parser.add_argument("--host", default="")
    parser.add_argument("--port", type=int, default=0)
    parser.add_argument("--model", default="")
    parser.add_argument("--input-text", type=Path, required=True)
    parser.add_argument("--output-dir", type=Path, default=default_output)
    parser.add_argument("--memory-saver-label", required=True)
    parser.add_argument("--max-chars", type=int, default=480)
    parser.add_argument("--warmup-text", default="Short warmup request for streaming timing.")
    parser.add_argument("--sample-rate", type=int, default=DEFAULT_SAMPLE_RATE)
    parser.add_argument("--health-timeout-s", type=float, default=180.0)
    parser.add_argument("--vram-sample-ms", type=float, default=200.0)
    return parser.parse_args()


def main() -> int:
    try:
        summary = run_measurement(parse_args())
    except Exception as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    keys = (
        "memory_saver",
        "chunk_count",
        "measured_sequence_elapsed_ms",
        "total_audio_duration_s",
        "ttft_first_chunk_ms",
        "ttft_p50_ms",
        "ttft_p95_ms",
        "ttft_max_ms",
        "client_first_delta_first_chunk_ms",
        "vram_peak_mib",
    )
    print(json.dumps({key: summary[key] for key in keys}, indent=2))
    return 0


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