"""
LLM client: OpenAI API or Ollama (local open-source model).
Configure via LLM_PROVIDER, OPENAI_API_KEY, OLLAMA_BASE_URL, etc.
User overrides from settings API take precedence over env vars.

Returns (content, usage, trace). usage = {provider, model, input_tokens, output_tokens}.
trace = optional dict with tool_calls, reasoning, finish_reason, messages_summary, etc.
"""
from datetime import datetime, timezone
from typing import Any

import httpx

from app.config import get_settings


def _effective_settings(overrides: dict[str, Any] | None = None) -> dict[str, Any]:
    """Merge env settings with user overrides. overrides can partially override."""
    s = get_settings()
    base = {
        "llm_provider": s.llm_provider,
        "openai_api_key": s.openai_api_key or "",
        "openai_base_url": s.openai_base_url,
        "openai_model": s.openai_model,
        "openai_audio_model": s.openai_audio_model,
        "ollama_base_url": s.ollama_base_url,
        "ollama_model": s.ollama_model,
        "litellm_base_url": s.litellm_base_url,
        "litellm_model": s.litellm_model,
        "litellm_audio_model": s.litellm_audio_model,
        "litellm_api_key": s.litellm_api_key or "",
        "google_api_key": s.google_api_key or "",
        "google_model": s.google_model,
        "anthropic_api_key": s.anthropic_api_key or "",
        "anthropic_model": s.anthropic_model,
        "claude_code_model": s.claude_code_model,
        "claude_code_oauth_token": s.claude_code_oauth_token or "",
    }
    if overrides:
        for k, v in overrides.items():
            if v is not None and v != "":
                base[k] = v
    return base


def _usage(
    provider: str,
    model: str,
    input_tokens: int = 0,
    output_tokens: int = 0,
) -> dict[str, Any]:
    return {
        "provider": provider,
        "model": model,
        "input_tokens": input_tokens,
        "output_tokens": output_tokens,
    }


def get_audio_model(llm_overrides: dict[str, Any] | None = None) -> tuple[str, str]:
    """Return (provider, audio_model) for TTS, or ('', '') if unsupported/not configured."""
    cfg = _effective_settings(llm_overrides)
    provider = cfg.get("llm_provider", "openai")
    if provider == "openai":
        return provider, (cfg.get("openai_audio_model") or "").strip()
    if provider == "litellm":
        return provider, (cfg.get("litellm_audio_model") or "").strip()
    return provider, ""


_MAX_TRACE_CONTENT = 30000  # chars per field to avoid huge DB payloads


def _truncate(s: str, max_len: int = _MAX_TRACE_CONTENT) -> str:
    s = str(s) if s is not None else ""
    return s if len(s) <= max_len else s[:max_len] + "\n...[truncated]"


def _build_trace(
    messages: list[dict[str, Any]],
    message: Any,
    content: str = "",
    finish_reason: str | None = None,
    raw: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Build trace dict from LLM response for logging. Includes full input/output for debugging."""
    trace: dict[str, Any] = {
        "messages_count": len(messages),
        "roles": [m.get("role", "?") for m in messages],
        "input_messages": [{"role": m.get("role", "?"), "content": _truncate(m.get("content", ""))} for m in messages],
        "output_content": _truncate(content),
    }
    if messages:
        first_content = messages[0].get("content", "") or ""
        trace["prompt_preview"] = (first_content[:500] + "…") if len(str(first_content)) > 500 else first_content
    if finish_reason:
        trace["finish_reason"] = finish_reason
    if message:
        tc_list = getattr(message, "tool_calls", None)
        if tc_list:
            trace["tool_calls"] = []
            for tc in tc_list:
                fn = getattr(tc, "function", None)
                trace["tool_calls"].append({
                    "id": getattr(tc, "id", ""),
                    "name": getattr(fn, "name", "") if fn else "",
                    "args": getattr(fn, "arguments", "") if fn else "",
                })
        if getattr(message, "reasoning_content", None):
            trace["reasoning"] = message.reasoning_content
    if raw:
        trace["raw"] = raw
    return trace


async def chat_completion(
    messages: list[dict[str, str]],
    max_tokens: int = 4096,
    llm_overrides: dict[str, Any] | None = None,
) -> tuple[str, dict[str, Any], dict[str, Any]]:
    """
    Returns (content, usage, trace). content is the assistant reply text.
    usage = {provider, model, input_tokens, output_tokens}.
    trace = dict with tool_calls, reasoning, messages_summary, etc.
    llm_overrides override env/user settings.
    """
    cfg = _effective_settings(llm_overrides)
    provider = cfg["llm_provider"]
    if provider == "ollama":
        return await _ollama_chat(messages, max_tokens, cfg)
    if provider == "litellm":
        return await _litellm_chat(messages, max_tokens, cfg)
    if provider == "google":
        return await _google_chat(messages, max_tokens, cfg)
    if provider == "anthropic":
        return await _anthropic_chat(messages, max_tokens, cfg)
    if provider == "claude-code":
        return await _claude_code_chat(messages, max_tokens, cfg)
    return await _openai_chat(messages, max_tokens, cfg)


async def _openai_chat(messages: list[dict[str, str]], max_tokens: int, cfg: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
    from openai import AsyncOpenAI

    api_key = (cfg.get("openai_api_key") or "").strip() or None
    base_url = (cfg.get("openai_base_url") or "https://api.openai.com/v1").rstrip("/")
    model = cfg.get("openai_model", "gpt-4o-mini")
    if not api_key:
        raise ValueError(
            "OpenAI API key is not configured. "
            "Set it in Account > LLM / AI settings or via the OPENAI_API_KEY environment variable."
        )
    client = AsyncOpenAI(api_key=api_key, base_url=base_url)
    resp = await client.chat.completions.create(
        model=model,
        messages=messages,
        max_tokens=max_tokens,
    )
    msg = resp.choices[0].message
    content = (msg.content or "").strip()
    usage_data = resp.usage
    input_t = getattr(usage_data, "prompt_tokens", None) or getattr(usage_data, "input_tokens", 0) if usage_data else 0
    output_t = getattr(usage_data, "completion_tokens", None) or getattr(usage_data, "output_tokens", 0) if usage_data else 0
    finish = getattr(resp.choices[0], "finish_reason", None)
    trace = _build_trace(messages, msg, content=content, finish_reason=finish)
    return content, _usage("openai", model, input_t or 0, output_t or 0), trace


async def _ollama_chat(messages: list[dict[str, str]], max_tokens: int, cfg: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
    """Use Ollama's OpenAI-compatible API (/v1/chat/completions)."""
    from openai import AsyncOpenAI

    model = cfg.get("ollama_model", "llama3.2")
    base_url = (cfg.get("ollama_base_url") or "http://localhost:11434").rstrip("/")
    if not base_url.endswith("/v1"):
        base_url = f"{base_url}/v1"
    client = AsyncOpenAI(api_key="ollama", base_url=base_url)
    resp = await client.chat.completions.create(
        model=model,
        messages=messages,
        max_tokens=max_tokens,
    )
    msg = resp.choices[0].message
    content = (msg.content or "").strip()
    usage_data = resp.usage
    input_t = getattr(usage_data, "prompt_tokens", None) or 0 if usage_data else 0
    output_t = getattr(usage_data, "completion_tokens", None) or 0 if usage_data else 0
    finish = getattr(resp.choices[0], "finish_reason", None)
    trace = _build_trace(messages, msg, content=content, finish_reason=finish)
    return content, _usage("ollama", model, input_t or 0, output_t or 0), trace


async def _litellm_chat(messages: list[dict[str, str]], max_tokens: int, cfg: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
    """Use LiteLLM proxy (OpenAI-compatible API)."""
    from openai import AsyncOpenAI

    model = cfg.get("litellm_model", "gpt-4o-mini")
    base_url = (cfg.get("litellm_base_url") or "http://localhost:4000").rstrip("/")
    if not base_url.endswith("/v1"):
        base_url = f"{base_url}/v1"
    api_key = cfg.get("litellm_api_key") or "not-needed"
    client = AsyncOpenAI(api_key=api_key, base_url=base_url)
    resp = await client.chat.completions.create(
        model=model,
        messages=messages,
        max_tokens=max_tokens,
    )
    msg = resp.choices[0].message
    content = (msg.content or "").strip()
    usage_data = resp.usage
    input_t = getattr(usage_data, "prompt_tokens", None) or getattr(usage_data, "input_tokens", 0) if usage_data else 0
    output_t = getattr(usage_data, "completion_tokens", None) or getattr(usage_data, "output_tokens", 0) if usage_data else 0
    finish = getattr(resp.choices[0], "finish_reason", None)
    trace = _build_trace(messages, msg, content=content, finish_reason=finish)
    return content, _usage("litellm", model, input_t or 0, output_t or 0), trace


async def _google_chat(messages: list[dict[str, str]], max_tokens: int, cfg: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
    """Use Google Gemini via its OpenAI-compatible API endpoint."""
    from openai import AsyncOpenAI

    api_key = (cfg.get("google_api_key") or "").strip() or None
    model = cfg.get("google_model", "gemini-2.0-flash")
    if not api_key:
        raise ValueError(
            "Google API key is not configured. "
            "Set it in Account > LLM / AI settings or via the GOOGLE_API_KEY environment variable."
        )
    client = AsyncOpenAI(
        api_key=api_key,
        base_url="https://generativelanguage.googleapis.com/openai/",
    )
    resp = await client.chat.completions.create(
        model=model,
        messages=messages,
        max_tokens=max_tokens,
    )
    msg = resp.choices[0].message
    content = (msg.content or "").strip()
    usage_data = resp.usage
    input_t = getattr(usage_data, "prompt_tokens", None) or getattr(usage_data, "input_tokens", 0) if usage_data else 0
    output_t = getattr(usage_data, "completion_tokens", None) or getattr(usage_data, "output_tokens", 0) if usage_data else 0
    finish = getattr(resp.choices[0], "finish_reason", None)
    trace = _build_trace(messages, msg, content=content, finish_reason=finish)
    return content, _usage("google", model, input_t or 0, output_t or 0), trace


async def _anthropic_chat(messages: list[dict[str, str]], max_tokens: int, cfg: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
    """Use Anthropic Claude via the official Anthropic SDK."""
    from anthropic import AsyncAnthropic

    api_key = (cfg.get("anthropic_api_key") or "").strip() or None
    model = cfg.get("anthropic_model", "claude-sonnet-4-20250514")
    if not api_key:
        raise ValueError(
            "Anthropic API key is not configured. "
            "Set it in Account > LLM / AI settings or via the ANTHROPIC_API_KEY environment variable."
        )
    # Anthropic API requires system message as a separate parameter
    system_text = ""
    user_messages = []
    for m in messages:
        if m.get("role") == "system":
            system_text = (system_text + "\n" + m.get("content", "")).strip()
        else:
            user_messages.append({"role": m["role"], "content": m.get("content", "")})

    client = AsyncAnthropic(api_key=api_key)
    kwargs: dict[str, Any] = {
        "model": model,
        "messages": user_messages,
        "max_tokens": max_tokens,
    }
    if system_text:
        kwargs["system"] = system_text

    resp = await client.messages.create(**kwargs)
    content = ""
    for block in resp.content:
        if getattr(block, "text", None):
            content += block.text
    content = content.strip()
    input_t = getattr(resp.usage, "input_tokens", 0) if resp.usage else 0
    output_t = getattr(resp.usage, "output_tokens", 0) if resp.usage else 0
    finish = getattr(resp, "stop_reason", None)
    trace = _build_trace(messages, None, content=content, finish_reason=finish)
    return content, _usage("anthropic", model, input_t, output_t), trace


async def _claude_code_via_api(
    messages: list[dict[str, str]],
    max_tokens: int,
    cfg: dict[str, Any],
    oauth_token: str,
) -> tuple[str, dict[str, Any], dict[str, Any]]:
    """Call the Anthropic Messages API directly using a Claude Code OAuth
    bearer token.

    Anthropic's Messages API rejects user-scoped (OAuth) tokens unless
    THREE things hold:

      1. Header `anthropic-beta: oauth-2025-04-20`.
      2. The first system content block starts with the exact CLI
         identification string ("You are Claude Code, Anthropic's
         official CLI for Claude."). We send it as a structured block
         so it can't be coalesced with caller-provided system text.
      3. The request must NOT carry an `x-api-key` header. The official
         `anthropic` SDK only emits `X-Api-Key` when `api_key` is a
         truthy string — passing `api_key=None` (the default) skips it
         and only the `Authorization: Bearer <token>` header is sent,
         which is what Anthropic accepts for OAuth.

    We use `anthropic.AsyncAnthropic` here rather than raw httpx so we
    inherit the SDK's retry/timeout/typing infrastructure and so any
    future `anthropic-version` bump is picked up automatically. An
    earlier revision used httpx because we believed the SDK forced an
    empty `X-Api-Key`; reading the SDK source (`_api_key_auth` returns
    `{}` when `api_key is None`) showed that was a misdiagnosis.

    Rate-limit handling: on HTTP 429 we extract the `retry-after`
    header (in seconds) and surface it in the error so the UI can show
    a useful "try again in N seconds" message.

    Returns the same `(content, usage, trace)` shape as `_anthropic_chat`
    so the dispatch layer is provider-agnostic.
    """
    from anthropic import APIStatusError, AsyncAnthropic, RateLimitError

    model = (cfg.get("claude_code_model") or "claude-sonnet-4-20250514").strip()

    system_text = ""
    user_messages = []
    for m in messages:
        if m.get("role") == "system":
            system_text = (system_text + "\n" + m.get("content", "")).strip()
        else:
            user_messages.append({"role": m["role"], "content": m.get("content", "")})

    # Send the system prompt as structured blocks. Block 0 carries the
    # required Claude Code identity string; block 1 (if non-empty)
    # carries whatever the caller wanted. Keeping them in separate
    # blocks (rather than concatenating into one string) matches what
    # the Claude CLI itself sends and is the form Anthropic's OAuth
    # validator is friendliest to.
    REQUIRED_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
    system_blocks: list[dict[str, str]] = [{"type": "text", "text": REQUIRED_PREFIX}]
    if system_text:
        system_blocks.append({"type": "text", "text": system_text})

    # api_key=None is critical: it tells the SDK not to emit X-Api-Key,
    # so only the Authorization Bearer header is sent. The OAuth beta
    # header is required for user-scoped tokens.
    client = AsyncAnthropic(
        api_key=None,
        auth_token=oauth_token,
        default_headers={"anthropic-beta": "oauth-2025-04-20"},
        timeout=120.0,
    )

    try:
        resp = await client.messages.create(
            model=model,
            messages=user_messages,
            max_tokens=max_tokens,
            system=system_blocks,
        )
    except RateLimitError as e:
        # Anthropic returns `retry-after` in seconds (sometimes as an
        # HTTP-date, but seconds is the common case for their API). We
        # parse defensively so a malformed header doesn't blow up the
        # error path.
        retry_after_raw = ""
        try:
            retry_after_raw = (e.response.headers.get("retry-after") or "").strip()
        except Exception:  # noqa: BLE001 - defensive on any header access
            retry_after_raw = ""
        retry_after_secs: int | None = None
        if retry_after_raw.isdigit():
            retry_after_secs = int(retry_after_raw)
        suffix = (
            f" Try again in ~{retry_after_secs}s."
            if retry_after_secs
            else " Try again in a moment."
        )
        raise RuntimeError(
            "Anthropic rate limit hit on your Claude account (HTTP 429)." + suffix
        ) from e
    except APIStatusError as e:
        # Surface Anthropic's actual response body — capped — so the
        # operator sees what the API complained about (auth / model /
        # quota / etc.) without dragging the full HTML page into logs.
        try:
            body_snippet = (e.response.text or "").strip()[:600]
        except Exception:  # noqa: BLE001
            body_snippet = str(e)[:600]
        raise RuntimeError(
            f"Anthropic Messages API rejected the OAuth token "
            f"(HTTP {e.status_code}): {body_snippet}"
        ) from e

    content = ""
    for block in resp.content or []:
        if getattr(block, "type", None) == "text" and getattr(block, "text", None):
            content += block.text
    content = content.strip()

    input_t = getattr(resp.usage, "input_tokens", 0) if resp.usage else 0
    output_t = getattr(resp.usage, "output_tokens", 0) if resp.usage else 0
    finish = getattr(resp, "stop_reason", None)
    trace = _build_trace(messages, None, content=content, finish_reason=finish)
    return content, _usage("claude-code", model, input_t, output_t), trace


async def _claude_code_chat(messages: list[dict[str, str]], max_tokens: int, cfg: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
    """
    Use the Claude CLI binary as an LLM provider via async subprocess.

    The CLI is invoked as:
      claude -p "<prompt>" --output-format stream-json --model <model> --verbose

    Authentication: OAuth token passed via CLAUDE_CODE_OAUTH_TOKEN env var
    (loaded from config or ~/.claude/oauth_token).

    Cloud deploys (Railway, Docker, …) usually don't have the `claude`
    binary on the host. When the binary is missing AND the LlmConfig
    carries an OAuth token, we transparently route through
    `_claude_code_via_api` instead of failing — same result, no CLI.
    """
    import asyncio
    import json as _json
    import os
    import shutil
    from pathlib import Path as _Path

    model = cfg.get("claude_code_model", "")

    # ── Discover the claude binary ──
    claude_bin = shutil.which("claude")
    if not claude_bin:
        # macOS Claude Desktop bundle
        support_dir = _Path.home() / "Library" / "Application Support" / "Claude" / "claude-code"
        if support_dir.exists():
            for version_dir in sorted(support_dir.iterdir(), reverse=True):
                candidate = version_dir / "claude.app" / "Contents" / "MacOS" / "claude"
                if candidate.exists():
                    claude_bin = str(candidate)
                    break

    # ── If no binary, try the direct-API fallback ──
    # OAuth token resolution mirrors the env-var lookup later in this
    # function (config > env var > known on-disk locations) so the
    # fallback works for every code path that the CLI path supports.
    def _resolve_oauth_token() -> str | None:
        from pathlib import Path as _P

        t = (cfg.get("claude_code_oauth_token") or "").strip()
        if t:
            return t
        t = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
        if t:
            return t
        for tp in (
            _P.home() / ".claude" / "oauth_token",
            _P.home() / ".last-intelligence" / "claude_oauth_token",
        ):
            if tp.exists():
                t = tp.read_text().strip()
                if t:
                    return t
        return None

    # Path B: ANY OAuth token we know about → direct Messages API.
    #
    # The CLI binary maintains its OWN credentials (populated by
    # `claude login` into ~/.claude/credentials and checked at CLI start).
    # It does NOT accept the user-scoped OAuth tokens we exchange via
    # `claude_oauth.py` (or any token written to ~/.last-intelligence/...
    # by other Anthropic tooling) — passing one via CLAUDE_CODE_OAUTH_TOKEN
    # makes the CLI exit 1 with "API Error: 401 Invalid authentication
    # credentials".
    #
    # So whenever we have an OAuth token from ANY source — the LlmConfig
    # (user finished the "Login with Claude" flow), the env var (operator
    # set it for a Docker/Railway deploy), or a known on-disk path — we
    # route through `_claude_code_via_api`, which sends the token as a
    # bearer with the required `anthropic-beta: oauth-2025-04-20` header
    # and the "You are Claude Code…" system prefix.
    #
    # The CLI subprocess path is reserved for the case where we have no
    # OAuth token at all — then the CLI uses its own internal credentials
    # and we don't touch CLAUDE_CODE_OAUTH_TOKEN in the spawned env.
    discovered_oauth_token = _resolve_oauth_token()
    if discovered_oauth_token:
        return await _claude_code_via_api(messages, max_tokens, cfg, discovered_oauth_token)

    if not claude_bin:
        raise ValueError(
            "Claude CLI binary not found and no OAuth token is configured. "
            "Either install the CLI (`npm install -g @anthropic-ai/claude-code`) "
            "or log in via Settings → LLM → 'Login with Claude'."
        )

    # ── Build the prompt from messages ──
    # Combine system + user messages into a single prompt for the CLI
    system_parts = []
    user_parts = []
    for m in messages:
        role = m.get("role", "user")
        content = m.get("content", "")
        if role == "system":
            system_parts.append(content)
        else:
            user_parts.append(content)

    prompt = ""
    if system_parts:
        prompt += "\n".join(system_parts) + "\n\n"
    prompt += "\n".join(user_parts)

    # ── Build subprocess environment ──
    # We deliberately do NOT inject CLAUDE_CODE_OAUTH_TOKEN here. By the
    # time we reach this code path, `_resolve_oauth_token()` returned
    # `None` (no OAuth token anywhere), so the CLI is expected to use its
    # own internal credentials from `claude login`. Inheriting whatever
    # CLAUDE_CODE_OAUTH_TOKEN happened to be in the parent process env
    # would override those credentials with potentially-incompatible
    # OAuth tokens (the very bug that motivated this branch).
    env = {**os.environ, "CLAUDE_CODE_ENTRYPOINT": "cli"}
    env.pop("CLAUDE_CODE_OAUTH_TOKEN", None)

    # ── Build CLI args ──
    # We pipe the prompt via stdin instead of `-p "<prompt>"` because ETL/Q&A
    # prompts can easily exceed the OS argv limit (256 KB on macOS, ~2 MB on
    # Linux). With `-p ""` and stdin the prompt size is bounded only by the
    # CLI's own context window.
    args = [
        claude_bin,
        "-p", "",
        "--output-format", "stream-json",
        "--verbose",
    ]
    if model:
        args.extend(["--model", model])

    # ── Execute subprocess ──
    proc = await asyncio.create_subprocess_exec(
        *args,
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        env=env,
    )

    stdout_data, stderr_data = await proc.communicate(input=prompt.encode("utf-8"))

    # Helper: pull any error message embedded in the stream-json stdout
    # (the CLI emits auth failures, rate-limit hits, and other API errors
    # there even when the process exits non-zero with empty stderr).
    def _extract_stream_json_error(raw: bytes) -> str:
        text = raw.decode("utf-8", errors="replace")
        for line in text.splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                obj = _json.loads(line)
            except _json.JSONDecodeError:
                continue
            # Result lines flag explicit failures
            if obj.get("type") == "result" and obj.get("is_error"):
                msg = obj.get("result") or obj.get("text") or ""
                if msg:
                    return str(msg).strip()
            # Assistant lines may carry an `error` slug + message
            if obj.get("type") == "assistant" and obj.get("error"):
                inner = obj.get("message", {}).get("content") or []
                for blk in inner:
                    if blk.get("type") == "text" and blk.get("text"):
                        return str(blk["text"]).strip()
            # System/setup error
            if obj.get("type") == "error":
                return str(obj.get("message") or obj.get("error") or line).strip()
        return ""

    # Even when the CLI exits 0, a stream-json result with `is_error: true`
    # means the call failed (auth, rate limit, etc). Surface those as
    # explicit errors so the caller doesn't see an empty answer.
    embedded = _extract_stream_json_error(stdout_data)

    if proc.returncode != 0 or embedded:
        err_text = stderr_data.decode("utf-8", errors="replace").strip()
        # Prefer the structured stdout message; fall back to stderr; then to
        # a hint about authentication if both are empty (most common cause
        # of exit 1 with no output is a missing/expired OAuth token).
        if embedded:
            err_text = embedded
        elif not err_text:
            err_text = (
                "Claude CLI exited with no output. The most common cause is a "
                "missing or expired OAuth token — run `claude login` from a "
                "terminal, or set CLAUDE_CODE_OAUTH_TOKEN in backend/.env."
            )
        raise RuntimeError(f"Claude CLI exited with code {proc.returncode}: {err_text}")

    # ── Parse stream-json output ──
    # Each line is a JSON object. We look for:
    # - {"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}}
    # - {"type": "content_block_delta", "delta": {"text": "..."}}
    # - {"type": "result", "result": "...", "text": "..."}
    content_parts: list[str] = []
    result_text = ""

    for line in stdout_data.decode("utf-8", errors="replace").splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            obj = _json.loads(line)
        except _json.JSONDecodeError:
            continue

        msg_type = obj.get("type", "")

        if msg_type == "assistant":
            # Extract text blocks from message.content
            message = obj.get("message", {})
            for block in message.get("content", []):
                if block.get("type") == "text":
                    content_parts.append(block.get("text", ""))

        elif msg_type == "content_block_delta":
            delta = obj.get("delta", {})
            if delta.get("text"):
                content_parts.append(delta["text"])

        elif msg_type == "result":
            result_text = obj.get("result", "") or obj.get("text", "")

    content = result_text or "".join(content_parts)
    content = content.strip()

    # Usage: CLI doesn't report tokens, estimate from content length
    est_input = len(prompt) // 4
    est_output = len(content) // 4

    display_model = model or "claude-code"
    trace = _build_trace(messages, None, content=content, finish_reason="end_turn")
    return content, _usage("claude-code", display_model, est_input, est_output), trace


async def synthesize_speech(
    text: str,
    llm_overrides: dict[str, Any] | None = None,
) -> tuple[bytes, str, dict[str, Any]]:
    """
    Convert text to speech using the configured provider.
    Supports OpenAI and LiteLLM (OpenAI-compatible /v1/audio/speech).
    Returns (audio_bytes, mime_type, usage).
    """
    cfg = _effective_settings(llm_overrides)
    provider, audio_model = get_audio_model(llm_overrides)
    if provider not in ("openai", "litellm"):
        raise ValueError("Audio overview currently requires an OpenAI or LiteLLM configuration")
    if not audio_model:
        raise ValueError("No audio model configured. Add an audio model in Account > LLM / AI settings")

    if provider == "openai":
        api_key = (cfg.get("openai_api_key") or "").strip()
        if not api_key:
            raise ValueError("OpenAI API key is required for audio generation")
        base_url = (cfg.get("openai_base_url") or "https://api.openai.com/v1").rstrip("/")
        url = f"{base_url}/audio/speech"
        headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        }
    else:
        base_url = (cfg.get("litellm_base_url") or "http://localhost:4000").rstrip("/")
        if not base_url.endswith("/v1"):
            base_url = f"{base_url}/v1"
        url = f"{base_url}/audio/speech"
        headers = {
            "Authorization": f"Bearer {(cfg.get('litellm_api_key') or 'not-needed').strip() or 'not-needed'}",
            "Content-Type": "application/json",
        }

    payload = {
        "model": audio_model,
        "voice": "alloy",
        "input": text.strip(),
        "response_format": "mp3",
    }
    async with httpx.AsyncClient(timeout=120.0) as client:
        response = await client.post(url, headers=headers, json=payload)
        response.raise_for_status()
        mime_type = response.headers.get("content-type", "audio/mpeg").split(";")[0].strip() or "audio/mpeg"
        return response.content, mime_type, _usage(provider, audio_model, 0, 0)
