"""Image generation where it meets the conversation.

Three seams, each of which fails silently if it is got wrong: how a generated
image is bound to the reply that made it, why it is never replayed on an
assistant turn, and what `/image` actually sends.
"""

from __future__ import annotations

from pathlib import Path

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select

import lembas
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Attachment, Chat, Connection, Message, Model
from lembas.services import chat as chat_service
from lembas.services import files as files_service
from lembas.services import generation as generation_service
from lembas.services.crypto import encrypt

PNG = (
    b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
    b"\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178U\x00\x00\x00"
    b"\x00IEND\xaeB`\x82"
)


@pytest.fixture
def vision_chat(db, user_id):
    connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt(""))
    db.add(connection)
    db.commit()
    db.add(
        Model(
            connection_id=connection.id,
            model_id="m",
            capabilities_json={"vision": True, "tools": True},
        )
    )
    chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
    db.add(chat)
    db.commit()
    return chat


# --- Storing what was drawn ----------------------------------------------------
def test_a_generated_image_is_kept_as_it_arrived(db, user_id, vision_chat):
    """`_process_image` transcodes to JPEG q85 and downscales to 1400px, which
    is right for a phone photo and a visible loss on the one output this feature
    exists to produce."""
    attachment = files_service.store(
        db,
        user_id=user_id,
        chat_id=vision_chat.id,
        payload=PNG,
        filename="out.png",
        keep_original=True,
    )

    assert attachment.media_type == "image/png"
    assert files_service.stored_path(attachment.stored_name).read_bytes() == PNG


def test_an_ordinary_upload_is_still_processed(db, user_id, vision_chat):
    """The flag is opt-in, and the protection it skips still applies to
    everything that arrives from outside."""
    attachment = files_service.store(
        db, user_id=user_id, chat_id=vision_chat.id, payload=PNG, filename="photo.png"
    )
    assert attachment.media_type == "image/jpeg"


def test_a_corrupt_image_is_still_refused(db, user_id, vision_chat):
    """What `keep_original` skips is the resize and the transcode, not the
    check that this is an image at all."""
    with pytest.raises(files_service.FileError):
        files_service.store(
            db,
            user_id=user_id,
            chat_id=vision_chat.id,
            payload=b"\x89PNG\r\n\x1a\n" + b"rubbish",
            filename="broken.png",
            keep_original=True,
        )


def test_the_loop_binds_the_image_to_the_reply(db, user_id, vision_chat):
    """A runner cannot write the message row -- `_persist` is the single writer
    -- so the runner makes the attachment and the loop says which turn owns it,
    exactly as it already does for a canvas tab."""
    message = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "here it is")
    attachment = files_service.store(
        db, user_id=user_id, chat_id=vision_chat.id, payload=PNG, filename="a.png"
    )

    generation_service._bind_attachments(db, vision_chat, message, [attachment.id])
    db.commit()

    db.refresh(attachment)
    assert attachment.message_id == message.id


def test_binding_refuses_a_row_from_another_chat(db, user_id, vision_chat):
    """The ids arrive on a tool event, which is a dict a runner built. Without
    the narrowing a forged one would pull somebody else's file into this
    conversation -- the reason `files.claim` is scoped the same way."""
    other = Chat(user_id=user_id, model_id="m")
    db.add(other)
    db.commit()
    elsewhere = files_service.store(
        db, user_id=user_id, chat_id=other.id, payload=PNG, filename="a.png"
    )
    message = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "x")

    generation_service._bind_attachments(db, vision_chat, message, [elsewhere.id])
    db.commit()

    db.refresh(elsewhere)
    assert elsewhere.message_id is None


def test_binding_refuses_a_row_already_bound(db, user_id, vision_chat):
    first = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "one")
    second = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "two")
    attachment = files_service.store(
        db,
        user_id=user_id,
        chat_id=vision_chat.id,
        payload=PNG,
        filename="a.png",
        message_id=first.id,
    )

    generation_service._bind_attachments(db, vision_chat, second, [attachment.id])
    db.commit()

    db.refresh(attachment)
    assert attachment.message_id == first.id


# --- What reaches the model ----------------------------------------------------
def test_an_image_on_an_assistant_turn_is_never_replayed(db, user_id, vision_chat):
    """The multimodal list form on an `assistant` turn is rejected outright by
    OpenAI and by most local runners -- and it would break not that turn but
    every later one in the chat."""
    reply = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "here it is")
    files_service.store(
        db,
        user_id=user_id,
        chat_id=vision_chat.id,
        payload=PNG,
        filename="a.png",
        message_id=reply.id,
    )
    db.refresh(reply)

    payload = chat_service.message_payload(reply, vision=True)

    assert isinstance(payload["content"], str), "no image_url parts on an assistant turn"
    assert payload["content"] == "here it is"


def test_an_image_a_person_sent_still_reaches_the_model(db, user_id, vision_chat):
    """The rule narrows assistant turns and nothing else."""
    turn = chat_service.create_message(db, vision_chat, ROLE_USER, "what is this?")
    files_service.store(
        db,
        user_id=user_id,
        chat_id=vision_chat.id,
        payload=PNG,
        filename="a.png",
        message_id=turn.id,
    )
    db.refresh(turn)

    payload = chat_service.message_payload(turn, vision=True)

    assert isinstance(payload["content"], list)
    assert any(part["type"] == "image_url" for part in payload["content"])


def test_a_generated_image_still_renders_in_the_bubble(
    client: TestClient, db, user_id, vision_chat
):
    """It is an attachment on the assistant message, and `_message.html` renders
    attachments for either role -- so the reader sees it without the template
    learning anything new."""
    reply = chat_service.create_message(db, vision_chat, ROLE_ASSISTANT, "here it is")
    attachment = files_service.store(
        db,
        user_id=user_id,
        chat_id=vision_chat.id,
        payload=PNG,
        filename="a.png",
        message_id=reply.id,
    )

    page = client.get(f"/chat/{vision_chat.id}").text
    assert f"/api/files/{attachment.id}/content" in page


# --- Forcing the tool ----------------------------------------------------------
def test_the_forced_tool_reaches_the_request(db, vision_chat):
    tools = [{"type": "function", "function": {"name": "image_generate", "parameters": {}}}]
    body = chat_service.build_request(db, vision_chat, tools=tools, force_tool="image_generate")

    assert body["tool_choice"] == {
        "type": "function",
        "function": {"name": "image_generate"},
    }


def test_nothing_is_forced_by_default(db, vision_chat):
    """A provider strict about unknown parameters must see exactly the request
    it always did until somebody types a slash command."""
    tools = [{"type": "function", "function": {"name": "image_generate", "parameters": {}}}]
    assert "tool_choice" not in chat_service.build_request(db, vision_chat, tools=tools)


def test_a_tool_that_was_not_offered_cannot_be_forced(db, vision_chat):
    """`resolve_tools` still decides what exists. Forcing something absent from
    the array is a request most endpoints reject outright."""
    tools = [{"type": "function", "function": {"name": "web_search", "parameters": {}}}]
    body = chat_service.build_request(db, vision_chat, tools=tools, force_tool="image_generate")
    assert "tool_choice" not in body


def test_forcing_needs_a_tools_array_at_all(db, vision_chat):
    assert "tool_choice" not in chat_service.build_request(
        db, vision_chat, force_tool="image_generate"
    )


def test_the_endpoint_only_accepts_names_from_the_allow_list(
    client: TestClient, db, registered, vision_chat, monkeypatch
):
    """This becomes `tool_choice`, so a name read straight off a form would let
    anyone who can send a message decide what the model must do next."""
    from lembas.api import chats as chats_api

    seen: dict = {}
    monkeypatch.setattr(
        chats_api.generation_service,
        "ensure",
        lambda chat_id, message_id, *, force_tool="": seen.update(force_tool=force_tool),
    )

    client.post(
        f"/api/chats/{vision_chat.id}/messages",
        data={"content": "hello", "force_tool": "shell_run"},
    )
    assert seen["force_tool"] == "", "not on the list, so not forced"

    # That first turn left an unfinished assistant row behind, and a second
    # message while one is in flight is *queued* rather than sent -- so it would
    # never reach `ensure` at all. Finish it first.
    db.query(Message).filter(Message.complete.is_(False)).update({"complete": True})
    db.commit()

    client.post(
        f"/api/chats/{vision_chat.id}/messages",
        data={"content": "a bicycle", "force_tool": "image_generate"},
    )
    assert seen["force_tool"] == "image_generate"


def test_the_image_command_is_in_the_table():
    """`/help` reads this list, so a command missing from it is one nobody can
    discover -- the direction this actually rots."""
    source = (Path(lembas.__file__).parent / "web/static/js/commands.js").read_text(
        encoding="utf-8"
    )
    assert 'name: "image"' in source
    assert 'body.append("force_tool", "image_generate")' in source
    assert "htmx.process" in source, "or the reply's sse-connect is inert markup"


def test_a_forced_round_does_not_force_the_next_one(db, vision_chat):
    """Leaving `tool_choice` in place would make every round call the tool
    again: draw a picture, be asked again, draw another."""
    source = (Path(lembas.__file__).parent / "services/generation.py").read_text(encoding="utf-8")
    assert 'payload.pop("tool_choice", None)' in source


def test_a_queued_turn_is_not_lost_when_it_was_forced(db, user_id, vision_chat):
    """`/image` typed while a reply is streaming queues like anything else. The
    forcing is on the generation, so a queued turn simply arrives unforced --
    which is right: by then the model has the words and the context."""
    db.add(Message(chat_id=vision_chat.id, role=ROLE_ASSISTANT, content="", complete=False))
    db.commit()

    from lembas.api import chats as chats_api

    assert chats_api._reply_in_flight(db, vision_chat) is True
    assert db.scalar(select(Attachment)) is None
