"""How full the context is, what a reply cost, and how fast it arrived."""

from __future__ import annotations

from fastapi.testclient import TestClient
from sqlalchemy import select

from lembas.db.models import Connection, Model
from lembas.services import metrics, tokens
from lembas.services.crypto import encrypt
from lembas.services.llm.openai_client import chunk_usage, context_from


def _model(db, **kwargs) -> Model:
    connection = Connection(
        name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
    )
    db.add(connection)
    db.commit()
    model = Model(connection_id=connection.id, model_id="test-model", **kwargs)
    db.add(model)
    db.commit()
    return model


# --- Reading a context length off /v1/models ---------------------------------
def test_context_length_is_read_from_any_of_the_spellings():
    assert context_from({"id": "m", "context_length": 8192}) == 8192
    assert context_from({"id": "m", "max_model_len": 32768}) == 32768
    assert context_from({"id": "m", "context_window": 4096}) == 4096
    assert context_from({"id": "m", "meta": {"n_ctx": 2048}}) == 2048


def test_a_quoted_number_is_accepted_but_a_label_is_not():
    """Some servers quote it. "8192 tokens" is a label, not a measurement."""
    assert context_from({"id": "m", "context_length": "8192"}) == 8192
    assert context_from({"id": "m", "context_length": "8192 tokens"}) == 0


def test_an_absent_or_implausible_context_length_is_zero():
    assert context_from({"id": "m"}) == 0
    assert context_from({"id": "m", "context_length": 0}) == 0
    assert context_from({"id": "m", "context_length": 64}) == 0
    assert context_from({"id": "m", "context_length": 10**9}) == 0
    # True is an int in Python, and it is not a context length.
    assert context_from({"id": "m", "context_length": True}) == 0


# --- Discovery ---------------------------------------------------------------
async def test_discovery_fills_in_a_context_length(client: TestClient, db, registered, mock_http):
    import httpx

    mock_http(
        lambda _r: httpx.Response(
            200, json={"data": [{"id": "big-model", "context_length": 16384}]}
        )
    )
    client.post(
        "/admin/connections",
        data={"name": "Local", "base_url": "http://x.test", "api_key": ""},
        follow_redirects=False,
    )
    model = db.scalar(select(Model).where(Model.model_id == "big-model"))
    assert model.context_length == 16384


async def test_discovery_never_overwrites_a_number_an_admin_typed(
    client: TestClient, db, registered, mock_http
):
    """A refresh must not undo a correction. Administrators set this precisely
    because the endpoint was wrong or silent."""
    import httpx

    mock_http(
        lambda _r: httpx.Response(200, json={"data": [{"id": "m", "context_length": 4096}]})
    )
    client.post(
        "/admin/connections",
        data={"name": "Local", "base_url": "http://x.test", "api_key": ""},
        follow_redirects=False,
    )
    model = db.scalar(select(Model).where(Model.model_id == "m"))
    model.context_length = 131072
    db.commit()

    connection = db.scalar(select(Connection))
    client.post(f"/admin/connections/{connection.id}/refresh", follow_redirects=False)

    db.refresh(model)
    assert model.context_length == 131072


# --- The admin field ---------------------------------------------------------
def test_an_admin_can_set_and_clear_the_context_length(client: TestClient, db, registered):
    model = _model(db)
    client.post(
        f"/admin/models/{model.id}",
        data={"context_length": "8192", "position": ""},
        follow_redirects=False,
    )
    db.refresh(model)
    assert model.context_length == 8192

    client.post(
        f"/admin/models/{model.id}", data={"context_length": "", "position": ""},
        follow_redirects=False,
    )
    db.refresh(model)
    assert model.context_length == 0


def test_junk_in_the_context_length_field_is_ignored_not_a_500(
    client: TestClient, db, registered
):
    model = _model(db, context_length=4096)
    response = client.post(
        f"/admin/models/{model.id}",
        data={"context_length": "eight thousand", "position": ""},
        follow_redirects=False,
    )
    assert response.status_code == 303
    db.refresh(model)
    assert model.context_length == 4096


# --- Usage off the wire -------------------------------------------------------
def test_usage_is_read_from_a_usage_chunk():
    chunk = {
        "choices": [],
        "usage": {"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120},
    }
    assert chunk_usage(chunk) == {
        "prompt_tokens": 100,
        "completion_tokens": 20,
        "total_tokens": 120,
    }


def test_a_missing_total_is_worked_out():
    chunk = {"choices": [], "usage": {"prompt_tokens": 100, "completion_tokens": 20}}
    assert chunk_usage(chunk)["total_tokens"] == 120


def test_an_ordinary_chunk_carries_no_usage():
    assert chunk_usage({"choices": [{"delta": {"content": "hi"}}]}) is None
    assert chunk_usage({}) is None
    assert chunk_usage({"usage": "lots"}) is None


def test_an_all_zero_usage_object_is_not_an_answer():
    """Some servers attach zeros to every chunk and the real numbers only at the
    end. Believing the zeros freezes the count at nothing."""
    chunk = {"choices": [], "usage": {"prompt_tokens": 0, "completion_tokens": 0}}
    assert chunk_usage(chunk) is None


def test_the_other_accessors_still_ignore_a_usage_chunk():
    """They return early on `choices: []`, which is exactly the shape of one.
    That is what lets a usage chunk through the loop untouched."""
    from lembas.services.llm.openai_client import (
        delta_reasoning,
        delta_text,
        delta_tool_calls,
        finish_reason,
    )

    chunk = {"choices": [], "usage": {"prompt_tokens": 1, "completion_tokens": 1}}
    assert delta_text(chunk) == ""
    assert delta_reasoning(chunk) == ""
    assert delta_tool_calls(chunk) == []
    assert finish_reason(chunk) == ""


async def test_stream_options_is_asked_for(mock_http):
    import json as json_module

    import httpx

    from lembas.services.llm.openai_client import Endpoint, stream_chat

    seen: list[dict] = []

    def handler(request: httpx.Request) -> httpx.Response:
        seen.append(json_module.loads(request.content))
        return httpx.Response(200, text="data: [DONE]\n\n")

    mock_http(handler)
    async for _ in stream_chat(Endpoint("http://ask.test", "", {}), {"model": "m"}):
        pass

    assert seen[0]["stream_options"] == {"include_usage": True}


async def test_an_endpoint_that_rejects_stream_options_is_asked_once(mock_http):
    """A 400 for an unknown key is the same hazard as sending `tools` to an
    endpoint without support. Retry without it, then stop asking."""
    import json as json_module

    import httpx

    from lembas.services.llm.openai_client import (
        _NO_STREAM_OPTIONS,
        Endpoint,
        stream_chat,
    )

    _NO_STREAM_OPTIONS.discard("http://fussy.test")
    seen: list[dict] = []

    def handler(request: httpx.Request) -> httpx.Response:
        body = json_module.loads(request.content)
        seen.append(body)
        if "stream_options" in body:
            return httpx.Response(400, json={"error": {"message": "unknown field"}})
        reply = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n'
        return httpx.Response(200, text=reply)

    mock_http(handler)
    endpoint = Endpoint("http://fussy.test", "", {})

    text = [c async for c in stream_chat(endpoint, {"model": "m"})]
    assert text, "the retry should have produced the reply"
    assert len(seen) == 2

    # Second reply: it already knows, so one request and no stream_options.
    async for _ in stream_chat(endpoint, {"model": "m"}):
        pass
    assert len(seen) == 3
    assert "stream_options" not in seen[2]
    _NO_STREAM_OPTIONS.discard("http://fussy.test")


# --- The estimate -------------------------------------------------------------
def test_the_estimate_is_about_four_characters_a_token():
    assert tokens.estimate("") == 0
    assert tokens.estimate("x" * 400) == 100


def test_typed_content_parts_are_counted_and_images_are_not():
    """An image's cost depends on the model's tiling. A number invented here
    would be worse than the omission."""
    content = [
        {"type": "text", "text": "x" * 40},
        {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA" * 500}},
    ]
    assert tokens.estimate_content(content) == 10


def test_a_request_estimate_includes_the_tools_array():
    """Thirteen schemas is a meaningful slice of a short window; leaving them
    out would read low exactly when it matters."""
    payload = {
        "messages": [{"role": "user", "content": "x" * 40}],
        "tools": [
            {"function": {"name": "web_search", "description": "y" * 400, "parameters": {}}}
        ],
    }
    assert tokens.estimate_request(payload) > tokens.estimate_request(
        {"messages": payload["messages"]}
    )


# --- The Metrics object -------------------------------------------------------
def test_the_percentage_is_zero_when_nobody_said_how_big_the_window_is():
    """Unknown must stay tellable from small. A percentage of an unknown total
    is a made-up number in a place people trust numbers."""
    assert metrics.Metrics(context_tokens=5000, context_limit=0).percent == 0
    assert metrics.Metrics(context_tokens=5000, context_limit=10000).percent == 50


def test_pressure_marks_the_bar_only_when_it_is_worth_noticing():
    assert metrics.Metrics(context_tokens=50, context_limit=100).pressure == ""
    assert metrics.Metrics(context_tokens=85, context_limit=100).pressure == "warning"
    assert metrics.Metrics(context_tokens=96, context_limit=100).pressure == "danger"
    assert metrics.Metrics(context_tokens=0, context_limit=0).pressure == ""


def test_speed_needs_both_a_count_and_a_clock():
    assert metrics.Metrics(completion_tokens=100, elapsed_ms=2000).tokens_per_second == 50.0
    assert metrics.Metrics(completion_tokens=100, elapsed_ms=0).tokens_per_second == 0.0
    assert metrics.Metrics(completion_tokens=0, elapsed_ms=2000).tokens_per_second == 0.0


def test_metrics_survive_a_round_trip_through_the_row():
    original = metrics.Metrics(
        prompt_tokens=100,
        completion_tokens=20,
        total_tokens=120,
        context_tokens=120,
        context_limit=8192,
        estimated=True,
        elapsed_ms=1500,
        rounds=2,
    )
    assert metrics.from_message(metrics.to_json(original)) == original


def test_a_row_with_no_usage_reads_as_nothing_rather_than_failing():
    assert metrics.from_message(None).has_anything is False
    assert metrics.from_message({}).has_anything is False
    assert metrics.from_message({"prompt_tokens": "lots"}).prompt_tokens == 0


# --- Through a generation -----------------------------------------------------
def _chunks(*frames: str) -> str:
    return "".join(f"data: {frame}\n\n" for frame in frames) + "data: [DONE]\n\n"


async def test_usage_is_summed_across_tool_rounds(db, registered, make_chat, monkeypatch):
    """Prompt and completion are what the reply cost; context_tokens is what the
    window holds. A three-round reply pays for its prompt three times and only
    ever occupies the window once."""
    from lembas.services import generation as generation_service

    generation = generation_service.Generation(chat_id="c", message_id="m")
    for prompt_tokens, completion_tokens in ((100, 10), (250, 20)):
        counts = {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}
        generation.prompt_tokens += counts["prompt_tokens"]
        generation.completion_tokens += counts["completion_tokens"]
        generation.context_tokens = prompt_tokens + completion_tokens

    assert generation.prompt_tokens == 350
    assert generation.completion_tokens == 30
    assert generation.context_tokens == 270


async def test_a_reply_records_what_it_cost(client: TestClient, db, registered, mock_http):
    import httpx

    from lembas.db.models import Message

    def handler(_request: httpx.Request) -> httpx.Response:
        if _request.url.path.endswith("/models"):
            return httpx.Response(200, json={"data": [{"id": "m", "context_length": 1000}]})
        return httpx.Response(
            200,
            text=_chunks(
                '{"choices":[{"delta":{"content":"Waybread."}}]}',
                '{"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":8}}',
            ),
        )

    mock_http(handler)
    client.post(
        "/admin/connections",
        data={"name": "Local", "base_url": "http://x.test", "api_key": ""},
        follow_redirects=False,
    )
    client.post("/api/chats/start", data={"content": "what is lembas?"})
    reply = db.scalar(select(Message).where(Message.role == "assistant"))

    # Following the stream is what runs the generation to completion.
    with client.stream(
        "GET", f"/api/chats/{reply.chat_id}/messages/{reply.id}/stream"
    ) as response:
        list(response.iter_lines())

    db.refresh(reply)
    assert reply.usage_json["prompt_tokens"] == 120
    assert reply.usage_json["completion_tokens"] == 8
    assert reply.usage_json["context_limit"] == 1000
    assert reply.usage_json["estimated"] is False
    assert reply.usage_json["elapsed_ms"] >= 0


def test_the_chips_render_from_a_stored_row(client: TestClient, db, registered, make_chat):
    from lembas.db.models import Message

    _model(db)
    chat_id = make_chat()
    db.add(
        Message(
            chat_id=chat_id,
            role="assistant",
            content="Waybread.",
            usage_json={
                "prompt_tokens": 100,
                "completion_tokens": 20,
                "total_tokens": 120,
                "context_tokens": 120,
                "context_limit": 1000,
                "estimated": False,
                "elapsed_ms": 2000,
                "rounds": 1,
            },
        )
    )
    db.commit()

    page = client.get(f"/chat/{chat_id}").text
    assert "120 tokens" in page
    assert "12%" in page
    assert "10.0 tok/s" in page
    assert "~" not in page.split("msg__metrics")[1][:400]


def test_an_estimated_row_says_so(client: TestClient, db, registered, make_chat):
    from lembas.db.models import Message

    _model(db)
    chat_id = make_chat()
    db.add(
        Message(
            chat_id=chat_id,
            role="assistant",
            content="Waybread.",
            usage_json={
                "prompt_tokens": 100,
                "completion_tokens": 20,
                "total_tokens": 120,
                "context_tokens": 120,
                "context_limit": 1000,
                "estimated": True,
                "elapsed_ms": 2000,
                "rounds": 1,
            },
        )
    )
    db.commit()

    page = client.get(f"/chat/{chat_id}").text
    assert "~120 tokens" in page
    assert "reports no token counts" in page


def test_no_context_length_means_no_percentage(client: TestClient, db, registered, make_chat):
    from lembas.db.models import Message

    _model(db)
    chat_id = make_chat()
    db.add(
        Message(
            chat_id=chat_id,
            role="assistant",
            content="Waybread.",
            usage_json={
                "prompt_tokens": 100,
                "completion_tokens": 20,
                "total_tokens": 120,
                "context_tokens": 120,
                "context_limit": 0,
                "estimated": False,
                "elapsed_ms": 2000,
                "rounds": 1,
            },
        )
    )
    db.commit()

    page = client.get(f"/chat/{chat_id}").text
    assert "120 tokens" in page
    assert "metric--context" not in page


# --- Counted, estimated, and the gap between them -----------------------------
def _live(**fields):
    """A Generation with just the fields the metrics read."""
    from lembas.services import generation as generation_service

    generation = generation_service.Generation(chat_id="c", message_id="m")
    for key, value in fields.items():
        setattr(generation, key, value)
    return generation


def test_a_reported_count_is_shown_verbatim():
    """Ours is four characters to a token. Overriding a number the endpoint
    actually counted with that would be a downgrade dressed as a fix."""
    generation = _live(
        reported_usage=True,
        prompt_tokens=120,
        completion_tokens=8,
        content=["Waybread."],
        counted_chars=len("Waybread."),
    )

    got = metrics.from_generation(generation)

    assert got.prompt_tokens == 120
    assert got.completion_tokens == 8
    assert got.estimated is False


def test_the_counts_keep_moving_between_usage_chunks():
    """Usage arrives once per round, so on a long agent reply the reported
    figures used to stand still for minutes while text streamed underneath
    them -- `reported or estimate` never reaches its fallback again once the
    first chunk has landed. Only what has been written since the last count is
    estimated."""
    generation = _live(
        reported_usage=True,
        prompt_tokens=100,
        completion_tokens=10,
        context_tokens=110,
        content=["x" * 40],
        counted_chars=0,
    )

    got = metrics.from_generation(generation)

    assert got.completion_tokens == 20, "10 counted plus 40 characters of new text"
    assert got.context_tokens == 120


def test_the_interpolation_is_zero_the_moment_a_count_lands():
    """Which is what makes the figure land exactly on the reported total at the
    end of a round rather than drifting a little past it every time."""
    generation = _live(
        reported_usage=True,
        completion_tokens=10,
        content=["x" * 40],
        counted_chars=40,
    )

    assert metrics.from_generation(generation).completion_tokens == 10


def test_a_reply_nobody_counted_still_says_so_once_it_is_stored():
    """`estimated` was inferred from "are both counts non-zero?", and the
    end-of-reply fallback made that true of a reply nobody had counted. So the
    tilde showed all the way through and then vanished at the moment the numbers
    were written down, which is where it mattered most."""
    generation = _live(content=["Waybread."], prompt_estimate_total=30)

    got = metrics.from_generation(generation)

    assert got.estimated is True
    assert got.prompt_tokens == 30
    assert got.completion_tokens > 0


def test_the_prompt_does_not_jump_when_the_reply_ends():
    """It read the latest round's estimate live and the sum of every round's
    when stored, so a reply that called a tool visibly changed number at the
    `done` frame. Both are the sum now."""
    generation = _live(prompt_estimate=400, prompt_estimate_total=1000)

    assert metrics.from_generation(generation).prompt_tokens == 1000


def test_estimate_chars_does_not_invent_a_token_out_of_nothing():
    """`estimate` floors at one token for any non-empty string, which is right
    for a piece of text and wrong for a difference between two lengths."""
    assert tokens.estimate_chars(0) == 0
    assert tokens.estimate_chars(-5) == 0
    assert tokens.estimate_chars(4) == 1
