"""Turning text into vectors, against an OpenAI-shaped `/v1/embeddings`.

The same reasoning as the chat and audio clients: plain httpx rather than an
SDK, because the target is llama.cpp, Ollama, LM Studio, Infinity or vLLM at
least as often as it is api.openai.com. They agree about the request and
disagree politely about the response, so this is tolerant about what comes back
and strict about what it hands on.

**Batched, because the cost is the round trip.** A hundred chunks one at a time
against a local endpoint is a hundred model loads' worth of latency for work
that fits in six requests. The batch size is a setting, because "how many at
once" is a property of the far side rather than of this code.

**Dimensions are discovered, never declared.** Nobody should have to look up
that bge-m3 is 1024 and nomic-embed-text is 768, and an instance that changes
model must not silently compare vectors from two different spaces --
`services/library/indexing.py` records the width beside every vector and
refuses to score across a mismatch.

Normalisation happens here, once, on the way out. Cosine similarity between two
unit vectors is their dot product, so normalising at write time turns every
later comparison into a multiply-and-add instead of two square roots per pair.
"""

from __future__ import annotations

import logging
import math
from typing import Any

import httpx

from lembas.services.llm.openai_client import (
    Endpoint,
    LLMError,
    describe_http_error,
    wrap_transport_error,
)

log = logging.getLogger(__name__)

# Longer than a chat request's, because a batch of sixteen chunks against a
# cold local endpoint includes loading the model.
TIMEOUT = 120.0


def normalise(vector: list[float]) -> list[float]:
    """A unit vector, or the input unchanged when it has no length.

    A zero vector is what an endpoint returns for empty input, and dividing by
    its norm is the one arithmetic error this path can make. It is left as it
    is: scoring it against anything gives zero, which is the honest answer.
    """
    length = math.sqrt(sum(value * value for value in vector))
    if length <= 0:
        return vector
    return [value / length for value in vector]


def _vectors_in(payload: Any) -> list[list[float]]:
    """The embeddings out of a response, whatever shape it arrived in.

    OpenAI's own answer is `{"data": [{"embedding": [...], "index": 0}]}`, and
    the index is honoured rather than assumed: nothing in the specification
    promises the order, and a provider that sorts differently would silently
    pair every chunk with somebody else's vector — which produces a search that
    works and is wrong, the worst failure this whole feature can have.
    """
    if not isinstance(payload, dict):
        raise LLMError("The embedding endpoint returned something unreadable.")
    data = payload.get("data")
    if not isinstance(data, list) or not data:
        raise LLMError("The embedding endpoint returned no vectors.")

    ordered: list[tuple[int, list[float]]] = []
    for position, entry in enumerate(data):
        if not isinstance(entry, dict):
            raise LLMError("The embedding endpoint returned no vectors.")
        raw = entry.get("embedding")
        if not isinstance(raw, list) or not raw:
            raise LLMError("The embedding endpoint returned an empty vector.")
        index = entry.get("index")
        at = int(index) if isinstance(index, int) else position
        ordered.append((at, [float(value) for value in raw]))
    ordered.sort(key=lambda pair: pair[0])
    return [vector for _, vector in ordered]


async def embed(
    endpoint: Endpoint, model_id: str, texts: list[str], *, timeout: float = TIMEOUT
) -> list[list[float]]:
    """One request. Returns a unit vector per input, in the order given.

    Raises `LLMError` for everything -- a missing model, an endpoint that does
    not implement embeddings at all, a transport failure -- because every caller
    treats them the same way: the index is left as it was and the search falls
    back to keywords. Nothing here is worth a partial answer.
    """
    if not texts:
        return []
    body = {"model": model_id, "input": texts}
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            response = await client.post(
                endpoint.url("/embeddings"), headers=endpoint.headers(), json=body
            )
            # raise_for_status, then translate. `describe_http_error` takes the
            # exception rather than the response, which is what every other
            # client here hands it.
            response.raise_for_status()
            payload = response.json()
    except httpx.HTTPStatusError as exc:
        raise LLMError(describe_http_error(exc)) from exc
    except httpx.HTTPError as exc:
        raise wrap_transport_error(exc, endpoint) from exc
    except ValueError as exc:
        raise LLMError("The embedding endpoint did not return JSON.") from exc

    vectors = _vectors_in(payload)
    if len(vectors) != len(texts):
        # Not recoverable by guessing. A response with fewer vectors than inputs
        # would pair chunk three's text with chunk four's vector from there on,
        # for the life of the index.
        raise LLMError(
            f"Asked for {len(texts)} embeddings and got {len(vectors)}."
        )
    widths = {len(vector) for vector in vectors}
    if len(widths) != 1:
        raise LLMError("The embedding endpoint returned vectors of different widths.")
    return [normalise(vector) for vector in vectors]


async def probe(endpoint: Endpoint, model_id: str) -> int:
    """How wide this model's vectors are, by asking for one.

    Used by the admin page's Test button and by nothing on the request path.
    There is no endpoint that reports it, so the only honest way to find out is
    to embed something.
    """
    vectors = await embed(endpoint, model_id, ["lembas"], timeout=60.0)
    return len(vectors[0])


__all__ = ["TIMEOUT", "embed", "normalise", "probe"]
