"""Messages: one long-running conversation per person.

Signal-shaped rather than chat-shaped. There is exactly one of these per
account, it is never titled, never filed and never deleted, and it is meant to
run for years — which is the whole difficulty, because a conversation that never
ends cannot all be sent to a model.

**What is stored and what is used are different things, and only the second is
bounded.** Every turn is kept, for ever, and scrolling up shows all of them
exactly as they were written. What reaches the model is the most recent
`LIVE_CHUNK` turns and nothing before them.

**Nothing is folded into text and nothing is deleted**, and that is a
deliberate reading of "compressed and history only". The visible conversation
would be identical either way, so the only thing destroying the older turns
would buy is disk — against which it is irreversible, it loses every attachment
and tool call in the folded range, and it contradicts the rule this codebase
already holds for compaction: *hiding turns is not deleting them*. Bounding the
request achieves the whole of what the feature needs. If the rows ever do need
folding, it is one function against this same boundary and the pages above it do
not change.

The consequence is worth stating plainly rather than discovering: **a Messages
conversation is infinite on screen and finite in the request.** Past the live
chunk the model genuinely does not see what was said, and it is told so.
"""

from __future__ import annotations

import logging

from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession

from lembas.db.models import KIND_MESSAGES, Chat, Message, User

log = logging.getLogger(__name__)

# How many turns reach the model. The "latest chunk", and deliberately larger
# than a page of history: it is the part that has to be enough to hold a
# conversation in, while the rest only has to be readable.
LIVE_CHUNK = 40

# How many older turns one scroll-up fetches. Bigger than the live chunk because
# reading back is cheap -- no tokens, no request, just rows.
HISTORY_PAGE = 100


def for_user(db: DBSession, user: User) -> Chat:
    """This person's Messages conversation, made if it is not there yet.

    The second deliberate exception to "chats are created lazily", and for a
    different reason than a task chat's: a schedule can post in here before
    anybody has ever opened the page, and `wake_chat` needs a row to write to.
    Get-or-create rather than a startup sweep, so an account that never opens
    Messages never grows one.
    """
    from lembas.services import chat as chat_service

    existing = db.scalars(
        select(Chat)
        .where(Chat.user_id == user.id, Chat.kind == KIND_MESSAGES)
        .order_by(Chat.created_at)
    ).first()
    if existing is not None:
        return existing

    # `default_model` answers with the *pair* -- the model id and the connection
    # it was reached through -- because a chat stores both and resolving the
    # second later would pick whichever connection happens to offer the id.
    # Unpacked rather than assigned, which is the mistake this comment exists to
    # stop being made again: assigning the tuple straight to `model_id` writes a
    # tuple into a String column and SQLite refuses the insert.
    chosen = chat_service.default_model(db, user)
    model_id, connection_id = chosen if chosen else ("", None)

    conversation = Chat(
        user_id=user.id,
        kind=KIND_MESSAGES,
        title="Messages",
        # Titling never runs on this one: there is no first exchange to name and
        # the name is fixed. Set so nothing downstream has to special-case it.
        title_generated=True,
        model_id=model_id,
        connection_id=connection_id,
    )
    db.add(conversation)
    db.commit()
    return conversation


def count(db: DBSession, chat: Chat) -> int:
    return int(
        db.scalar(select(func.count()).select_from(Message).where(Message.chat_id == chat.id))
        or 0
    )


def live_messages(db: DBSession, chat: Chat, *, limit: int = LIVE_CHUNK) -> list[Message]:
    """The most recent turns, oldest first.

    Fetched newest-first and reversed rather than offset from the start: an
    offset would have to be recomputed from a count on every request, and would
    be wrong the moment a turn arrived between the two queries.
    """
    newest = db.scalars(
        select(Message)
        .where(Message.chat_id == chat.id)
        .order_by(Message.created_at.desc(), Message.id.desc())
        .limit(limit)
    ).all()
    return list(reversed(newest))


def older_than(
    db: DBSession, chat: Chat, cursor: Message, *, limit: int = HISTORY_PAGE
) -> list[Message]:
    """The page of turns immediately before `cursor`, oldest first.

    The comparison is done in SQL with an `id` tie-breaker, exactly as
    `thread_tail` does going the other way. That is not decoration: 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.
    """
    rows = db.scalars(
        select(Message)
        .where(
            Message.chat_id == chat.id,
            (Message.created_at < cursor.created_at)
            | ((Message.created_at == cursor.created_at) & (Message.id < cursor.id)),
        )
        .order_by(Message.created_at.desc(), Message.id.desc())
        .limit(limit)
    ).all()
    return list(reversed(rows))


def has_more_before(db: DBSession, chat: Chat, cursor: Message) -> bool:
    """Whether the sentinel should be rendered again above a page.

    Asked separately rather than by fetching one extra row, because the answer
    is needed *after* the page has been reversed and the extra row would have to
    be trimmed off the wrong end.
    """
    return (
        db.scalar(
            select(func.count())
            .select_from(Message)
            .where(
                Message.chat_id == chat.id,
                (Message.created_at < cursor.created_at)
                | ((Message.created_at == cursor.created_at) & (Message.id < cursor.id)),
            )
        )
        or 0
    ) > 0
