"""Messages: one conversation that never ends, and a request that must.

The failure this file mostly guards against is not visible on screen at all —
a conversation whose request grows with it until the endpoint refuses, which is
what `_too_big` exists for elsewhere and what the live chunk prevents here.

The rest is the cursor, which has the same four traps as `thread_tail` going the
other way. A message that cannot be scrolled back to is a message that is gone.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta

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

from lembas.db.models import KIND_MESSAGES, Chat, Message, User
from lembas.services import messages as messages_service


@pytest.fixture(autouse=True)
def a_model_exists(db, registered):
    """Not scaffolding.

    `for_user` seeds the conversation from `default_model`, which answers with a
    *pair* — and with no model configured it answers None, so the whole of that
    path is skipped and a bug in it cannot be seen. It was: the pair was
    assigned straight to `model_id`, SQLite refused a tuple in a String column,
    and every one of these tests passed anyway.
    """
    from lembas.db.models import Connection, Model
    from lembas.services.crypto import encrypt

    connection = Connection(
        name="Test", 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="test-model"))
    db.commit()
    return None


def _user(db) -> User:
    return db.scalars(select(User).order_by(User.created_at)).first()


def _fill(db, chat: Chat, count: int, *, day: int = 1) -> list[Message]:
    """`count` alternating turns, a minute apart so the order is unambiguous."""
    start = datetime(2026, 1, day, tzinfo=UTC)
    rows = []
    for index in range(count):
        rows.append(
            Message(
                chat_id=chat.id,
                role="user" if index % 2 == 0 else "assistant",
                content=f"turn {index}",
                created_at=start + timedelta(minutes=index),
                complete=True,
            )
        )
    db.add_all(rows)
    db.commit()
    return rows


# --- The conversation itself ------------------------------------------------------
def test_there_is_exactly_one_per_person(client: TestClient, db, registered):
    """Get-or-create, so a schedule can post here before anybody has opened the
    page — the second deliberate exception to "chats are created lazily"."""
    first = messages_service.for_user(db, _user(db))
    second = messages_service.for_user(db, _user(db))

    assert first.id == second.id
    assert first.kind == KIND_MESSAGES
    assert len(db.scalars(select(Chat).where(Chat.kind == KIND_MESSAGES)).all()) == 1


def test_it_is_not_in_the_chat_tree(client: TestClient, db, registered):
    """It has a section of its own. This is the kind-leakage trap again, from
    the other side."""
    from lembas.api.pages import sidebar_context

    conversation = messages_service.for_user(db, _user(db))
    listed = {c.id for c in sidebar_context(db, _user(db))["unfiled_chats"]}

    assert conversation.id not in listed


# --- What reaches the model -------------------------------------------------------
def test_the_request_does_not_grow_with_the_conversation(
    client: TestClient, db, registered
):
    """The whole point. A conversation meant to run for years cannot all be
    sent, and a request that grows until the endpoint refuses it is the failure
    nobody sees coming — there is nothing wrong on screen right up until it
    stops working.
    """
    from lembas.services import chat as chat_service

    conversation = messages_service.for_user(db, _user(db))
    _fill(db, conversation, 10)
    short = chat_service.build_messages(db, conversation)

    _fill(db, conversation, 300)
    long = chat_service.build_messages(db, conversation)

    assert len(short) == 10
    assert len(long) == messages_service.LIVE_CHUNK
    assert len(long) < len(short) + 300


def test_it_is_the_most_recent_turns_that_are_sent(client: TestClient, db, registered):
    from lembas.services import chat as chat_service

    conversation = messages_service.for_user(db, _user(db))
    _fill(db, conversation, messages_service.LIVE_CHUNK + 20)

    payload = chat_service.build_messages(db, conversation)
    bodies = [entry["content"] for entry in payload]

    assert bodies[-1] == f"turn {messages_service.LIVE_CHUNK + 19}"
    assert "turn 0" not in bodies


def test_an_ordinary_chat_still_sends_everything(client: TestClient, db, registered):
    """The bound is one branch on one kind. A chat is not silently truncated."""
    from lembas.services import chat as chat_service

    ordinary = Chat(user_id=_user(db).id, model_id="m")
    db.add(ordinary)
    db.commit()
    _fill(db, ordinary, messages_service.LIVE_CHUNK + 20)

    assert len(chat_service.build_messages(db, ordinary)) == messages_service.LIVE_CHUNK + 20


def test_compaction_never_fires_on_it(client: TestClient, db, registered):
    """Two mechanisms narrowing one transcript is how a summary ends up
    summarising a summary — and this one would be summarising turns that are
    already outside the request."""
    from lembas.services import compaction as compaction_service

    conversation = messages_service.for_user(db, _user(db))
    _fill(db, conversation, 200)

    assert compaction_service.should_compact(db, conversation) is False


# --- Nothing is lost ---------------------------------------------------------------
def test_every_turn_is_kept_however_old(client: TestClient, db, registered):
    """Bounded in the request, unbounded on disk. Deliberately not folded into
    text: the visible conversation would be identical either way, so the only
    thing destroying the rows would buy is disk — against irreversibility."""
    conversation = messages_service.for_user(db, _user(db))
    _fill(db, conversation, 250)

    assert messages_service.count(db, conversation) == 250


# --- Reading backwards --------------------------------------------------------------
def test_the_page_opens_on_the_latest_chunk(client: TestClient, db, registered):
    conversation = messages_service.for_user(db, _user(db))
    _fill(db, conversation, 120)

    body = client.get("/messages").text

    assert "turn 119" in body
    assert "turn 0" not in body
    assert "history-sentinel" in body


def test_a_short_conversation_has_no_sentinel(client: TestClient, db, registered):
    conversation = messages_service.for_user(db, _user(db))
    _fill(db, conversation, 5)

    assert "history-sentinel" not in client.get("/messages").text


def test_scrolling_up_returns_the_page_before(client: TestClient, db, registered):
    conversation = messages_service.for_user(db, _user(db))
    rows = _fill(db, conversation, 200)
    oldest_shown = rows[-messages_service.LIVE_CHUNK]

    response = client.get(f"/api/messages/history?before={oldest_shown.id}")

    assert response.status_code == 200
    assert f"turn {200 - messages_service.LIVE_CHUNK - 1}" in response.text
    # The turn it was asked to go before is not repeated.
    assert f">turn {200 - messages_service.LIVE_CHUNK}<" not in response.text


def test_a_cursor_it_cannot_place_is_answered_with_204(
    client: TestClient, db, registered
):
    """Never with "the oldest page": that would prepend a block the reader is
    already looking at, and a duplicated transcript is something only a reload
    can reconcile."""
    conversation = messages_service.for_user(db, _user(db))
    _fill(db, conversation, 100)

    other = Chat(user_id=_user(db).id, model_id="m")
    db.add(other)
    db.commit()
    stray = Message(chat_id=other.id, role="user", content="elsewhere")
    db.add(stray)
    db.commit()

    assert client.get("/api/messages/history").status_code == 204
    assert client.get("/api/messages/history?before=nope").status_code == 204
    assert client.get(f"/api/messages/history?before={stray.id}").status_code == 204


def test_the_oldest_page_stops_rather_than_looping(client: TestClient, db, registered):
    conversation = messages_service.for_user(db, _user(db))
    rows = _fill(db, conversation, 3)

    response = client.get(f"/api/messages/history?before={rows[0].id}")
    assert response.status_code == 204


def test_two_turns_sharing_a_timestamp_are_each_returned_once(
    client: TestClient, db, registered
):
    """The `id` tie-breaker. Under a bare `<`, a row sharing the cursor's
    microsecond can never be reached — and a message that cannot be scrolled
    back to is a message that is gone."""
    conversation = messages_service.for_user(db, _user(db))
    stamp = datetime(2026, 1, 1, tzinfo=UTC)
    twins = [
        Message(chat_id=conversation.id, role="user", content=f"same {i}", created_at=stamp)
        for i in range(2)
    ]
    db.add_all(twins)
    db.commit()
    # A later day, so the cursor is unambiguously after both twins -- otherwise
    # the cursor shares their stamp and the test is about id ordering, which is
    # random, rather than about the tie-breaker.
    later = _fill(db, conversation, 2, day=2)

    page = messages_service.older_than(db, conversation, later[0])
    assert {m.content for m in page} == {"same 0", "same 1"}


def test_the_sentinel_names_its_own_target(client: TestClient, db, registered):
    """It sits on a page whose composer form carries `hx-target="#thread"`, and
    htmx resolves that by walking up the DOM. The jobs chip demonstrated once
    what an unstated target does to a transcript."""
    conversation = messages_service.for_user(db, _user(db))
    _fill(db, conversation, 120)
    body = client.get("/messages").text

    sentinel = next(chunk for chunk in body.split("<div") if "history-sentinel" in chunk)
    assert 'hx-target="this"' in sentinel
    assert 'hx-swap="outerHTML"' in sentinel
    # And it is outside the composer's form, which is the other half of the same
    # lesson: a descendant that fetches inherits the form's target.
    assert body.index("history-sentinel") < body.index("composer__form")


def test_the_scroll_anchor_is_wired_up(client: TestClient, db, registered):
    """Prepending moves everything down by the height of what arrived, so
    without this the reader is dragged up the page the instant the sentinel
    fires — which reads as a browser bug rather than a feature."""
    from pathlib import Path

    source = Path("src/lembas/web/static/js/app.js").read_text()

    assert "history-sentinel" in source
    assert "htmx:beforeSwap" in source
    assert "scrollHeight - heightBefore" in source
