"""ElevenLabs Text-to-Speech adapter (CP-8).

Single synchronous HTTP call via urllib.request. Output is bytes streamed
to a local file. Retries on 5xx + network blips; fail-fast on 401/402/422/429.

Public surface:
    synthesize_speech(...) -> SynthesisResult
    SynthesisResult dataclass
    AudioSynthesisError + subclasses (AuthError, QuotaError, PayloadError,
        RateLimitError, ServerError, NetworkError)

Test injection: pass transport=<callable>. Default transport is
urllib.request.urlopen wrapped to enforce headers/timeout.

Cost computation: per-1k-chars rate from
recoil/config/model_profiles.json[<model_id>]["cost_per_1k_chars"]; falls
back to 0.0 if model not registered (Phase 3 guarantees registration).
"""

from __future__ import annotations

import json
import logging
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional

# Phase C: canonical pattern set (re-aliased to preserve import surface).
from recoil.pipeline.core.failure_mode import TRANSIENT_HTTP_CODES as RETRYABLE_HTTP

logger = logging.getLogger(__name__)

ENDPOINT_TEMPLATE = "https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"
DEFAULT_OUTPUT_FORMAT_QS = "mp3_44100_128"
DEFAULT_AUTH_ENV_VAR = "ELEVENLABS_API_KEY"
DEFAULT_TIMEOUT_S = 60.0


class AudioSynthesisError(RuntimeError):
    """Base class for ElevenLabs synthesis failures."""


class AuthError(AudioSynthesisError):
    """401 — invalid API key. Fail-fast."""


class QuotaError(AudioSynthesisError):
    """402 — out of credits. Fail-fast."""


class PayloadError(AudioSynthesisError):
    """422 — bad voice_id or invalid request body. Fail-fast."""


class RateLimitError(AudioSynthesisError):
    """429 — rate limit hit. Fail-fast (caller may schedule a retry layer)."""


class ServerError(AudioSynthesisError):
    """5xx — retried 3x in adapter; raised when retries exhausted."""


class NetworkError(AudioSynthesisError):
    """URLError / TimeoutError / connection blip. Retried 3x."""


@dataclass
class SynthesisResult:
    output_path: Path
    duration_s: Optional[float]
    cost_usd: float
    model: str
    voice_id: str
    request_id: Optional[str]
    raw_metadata: dict = field(default_factory=dict)


def _default_transport(url: str, *, headers: dict, body: bytes, timeout: float):
    """Wraps urlopen with explicit method=POST."""
    req = urllib.request.Request(url, data=body, headers=headers, method="POST")
    return urllib.request.urlopen(req, timeout=timeout)


def _classify_http_error(code: int, body_bytes: bytes) -> AudioSynthesisError:
    msg = body_bytes.decode("utf-8", errors="replace")[:512]
    if code == 401:
        return AuthError(f"ElevenLabs 401 (invalid API key): {msg}")
    if code == 402:
        return QuotaError(f"ElevenLabs 402 (out of credits): {msg}")
    if code == 422:
        return PayloadError(f"ElevenLabs 422 (payload): {msg}")
    if code == 429:
        return RateLimitError(f"ElevenLabs 429 (rate-limited): {msg}")
    if code in RETRYABLE_HTTP:
        return ServerError(f"ElevenLabs {code}: {msg}")
    return AudioSynthesisError(f"ElevenLabs {code}: {msg}")


def _compute_cost(model_id: str, char_count: int) -> float:
    """Best-effort cost estimate. Reads model_profiles.json lazily."""
    try:
        from recoil.core.model_profiles import get_profile
        prof = get_profile(model_id)
        rate = prof.get("cost_per_1k_chars")
        if rate is not None:
            return float(rate) * (char_count / 1000.0)
    except Exception as e:
        logger.debug(f"cost compute fell back to 0.0 ({e})")
    return 0.0


def synthesize_speech(
    *,
    text: str,
    voice_id: str,
    model_id: str = "eleven_multilingual_v2",
    output_format: str = "mp3",
    output_dir: Path,
    file_stem: str,
    voice_settings: Optional[dict] = None,
    api_key_env_var: str = DEFAULT_AUTH_ENV_VAR,
    timeout_s: float = DEFAULT_TIMEOUT_S,
    max_retries: int = 3,
    transport: Optional[Callable] = None,
) -> SynthesisResult:
    """Synthesize speech via ElevenLabs.

    Args:
        text: The utterance to synthesize. Must be non-empty.
        voice_id: ElevenLabs voice id (e.g. "Rachel" preset id, or a custom id).
        model_id: ElevenLabs model id (default eleven_multilingual_v2).
        output_format: "mp3" | "pcm" | "wav". Maps to query string under the hood.
        output_dir: Directory for the output audio file. Created if missing.
        file_stem: File name (no extension) for the output audio.
        voice_settings: Dict — stability, similarity_boost, style, use_speaker_boost.
            Defaults to {"stability": 0.5, "similarity_boost": 0.75, "style": 0.0,
            "use_speaker_boost": True}.
        api_key_env_var: env var name (default ELEVENLABS_API_KEY).
        timeout_s: per-call timeout.
        max_retries: total retries for 5xx + network. Fail-fast for 4xx.
        transport: callable (url, *, headers, body, timeout) -> response.
            Default uses urllib.request. Tests pass a mock.

    Returns:
        SynthesisResult with output_path on disk.

    Raises:
        AuthError / QuotaError / PayloadError / RateLimitError on fail-fast 4xx.
        ServerError / NetworkError after retries exhausted.
        AudioSynthesisError on unexpected HTTP code.
    """
    if not text or not isinstance(text, str):
        raise PayloadError("text must be a non-empty string")
    if not voice_id:
        raise PayloadError("voice_id must be non-empty")

    api_key = os.environ.get(api_key_env_var)
    if not api_key:
        raise AuthError(f"{api_key_env_var} not set in environment")

    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    fmt_qs = {
        "mp3": "mp3_44100_128",
        "wav": "pcm_44100",
        "pcm": "pcm_44100",
    }.get(output_format, DEFAULT_OUTPUT_FORMAT_QS)

    ext = "wav" if output_format in ("wav", "pcm") else "mp3"
    output_path = output_dir / f"{file_stem}.{ext}"

    settings = {
        "stability": 0.5,
        "similarity_boost": 0.75,
        "style": 0.0,
        "use_speaker_boost": True,
    }
    if voice_settings:
        settings.update(voice_settings)

    body = json.dumps({
        "text": text,
        "model_id": model_id,
        "voice_settings": settings,
    }).encode("utf-8")

    headers = {
        "xi-api-key": api_key,
        "Content-Type": "application/json",
        "Accept": "audio/mpeg" if ext == "mp3" else "audio/wav",
    }
    url = ENDPOINT_TEMPLATE.format(voice_id=voice_id) + f"?output_format={fmt_qs}"

    tport = transport or _default_transport
    last_err: Optional[Exception] = None
    response_headers: dict = {}

    for attempt in range(max_retries + 1):
        try:
            with tport(url, headers=headers, body=body, timeout=timeout_s) as resp:
                code = getattr(resp, "status", 200)
                if code != 200:
                    err_body = resp.read() if hasattr(resp, "read") else b""
                    raise _classify_http_error(code, err_body)
                audio_bytes = resp.read()
                response_headers = dict(getattr(resp, "headers", {}) or {})
            break
        except urllib.error.HTTPError as e:
            err_body = e.read() if hasattr(e, "read") else b""
            err = _classify_http_error(e.code, err_body)
            if isinstance(err, (AuthError, QuotaError, PayloadError, RateLimitError)):
                raise err
            last_err = err
        except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
            last_err = NetworkError(f"network: {e}")
        except AudioSynthesisError as e:
            if isinstance(e, (AuthError, QuotaError, PayloadError, RateLimitError)):
                raise
            last_err = e
        except Exception as e:  # noqa: BLE001
            last_err = AudioSynthesisError(f"unexpected: {type(e).__name__}: {e}")
        if attempt < max_retries:
            time.sleep(2 ** attempt)
        else:
            assert last_err is not None
            raise last_err

    output_path.write_bytes(audio_bytes)
    request_id = response_headers.get("request-id") or response_headers.get("x-request-id")

    return SynthesisResult(
        output_path=output_path,
        duration_s=None,
        cost_usd=_compute_cost(model_id, len(text)),
        model=model_id,
        voice_id=voice_id,
        request_id=request_id,
        raw_metadata={
            "char_count": len(text),
            "output_format": output_format,
            "headers": {k: v for k, v in response_headers.items()
                        if k.lower() in ("request-id", "x-request-id",
                                         "character-cost", "content-length")},
        },
    )


__all__ = [
    "synthesize_speech",
    "SynthesisResult",
    "AudioSynthesisError",
    "AuthError",
    "QuotaError",
    "PayloadError",
    "RateLimitError",
    "ServerError",
    "NetworkError",
]
