"""Full-page routes: the chat shell and the user's own settings."""

from __future__ import annotations

from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession

from lembas.api.deps import Db, RequiredUser
from lembas.db.models import Chat, Folder, KnowledgeBase, Message, User
from lembas.security import permissions
from lembas.services import audio as audio_service
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import settings_store
from lembas.services import suggestions as suggestions_service
from lembas.services.library import documents as documents_service
from lembas.services.markdown import render_markdown
from lembas.web.templating import STATIC_DIR, render

router = APIRouter(tags=["pages"])

# Matches --bg for each theme in tokens.css. Duplicated here because the
# manifest is JSON read by the operating system before any stylesheet exists;
# there is nowhere for a CSS variable to resolve.
THEME_COLOUR = {"moria": "#101317", "shire": "#F6F1E4"}


def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
    """Model lists and permissions every chat page needs.

    Pinned and unpinned are split here rather than in the template so the
    picker's optgroups stay a plain loop.
    """
    models = chat_service.available_models(db, user)
    current = next((m for m in models if m.model_id == chat.model_id), None) if chat else None
    return {
        "models": models,
        # For the sidebar shortcuts only. The picker lists `models` in the
        # administrator's order, pinned or not.
        "pinned_models": [m for m in models if m.pinned],
        "current_model": current,
        # Assistant bubbles show the avatar of the model that wrote them, which
        # may not be the model the chat is set to now. Keyed by model_id, the
        # denormalised value stored on each message.
        "models_by_id": {m.model_id: m for m in models},
        # Offered in the chat settings panel so a conversation can be pointed at
        # particular bases. Empty when the reader has none, and the panel then
        # shows nothing rather than an empty control.
        "knowledge_bases": (
            list(
                db.scalars(
                    documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
                )
            )
            if permissions.has(db, user, "library.use")
            else []
        ),
        "attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
        # The three a reasoning model understands. From the service so the
        # command, the control and the request builder cannot disagree about
        # what is a valid effort.
        "efforts": chat_service.EFFORTS,
        **_agent_context(db, user, chat),
        **audio_service.template_flags(db, user),
    }


def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict:
    """What the composer and the chat header need to know about agent chats.

    `agent_profiles` is empty unless every one of the conditions holds -- the
    feature is on, the reader may run commands, and they have a usable
    connection -- which is what makes the picker appear only when choosing it
    would lead anywhere.
    """
    from lembas.db.models import SshProfile
    from lembas.services.agent import policy as agent_policy

    profiles: list[SshProfile] = []
    if settings_store.agents(db).get("enabled") and permissions.has(db, user, "tools.agent"):
        profiles = list(
            db.scalars(
                select(SshProfile)
                .where(SshProfile.owner_id == user.id, SshProfile.enabled.is_(True))
                .order_by(SshProfile.name)
            )
        )

    current = None
    if chat is not None and chat.ssh_profile_id:
        current = db.get(SshProfile, chat.ssh_profile_id)
        if current is not None and current.owner_id != user.id:
            current = None

    return {
        "agent_profiles": profiles,
        "agent_profile": current,
        "agent_modes": [
            (m, agent_policy.MODE_LABELS[m], agent_policy.MODE_HINTS[m])
            for m in agent_policy.MODES
        ],
        "terminal_enabled": _terminal_enabled(db, user, chat, current),
    }


def _terminal_enabled(db: DBSession, user: User, chat: Chat | None, profile) -> bool:
    """Whether this chat can offer a shell of its own.

    Every condition, not a subset: the button loads 280KB of terminal and opens
    a socket, so one that cannot work is worse than none. `ssh.available()` is
    in here because an instance that installed LLeMbas without the `ssh` extra
    would otherwise render a button whose only outcome is an error frame.
    """
    from lembas.db.models import KIND_AGENT
    from lembas.services.agent import ssh as ssh_service

    if chat is None or chat.kind != KIND_AGENT or profile is None:
        return False
    if not permissions.has(db, user, "agent.terminal"):
        return False
    values = settings_store.agents(db)
    if not values.get("enabled") or not values.get("terminal_enabled", True):
        return False
    return ssh_service.available() == ""


def sidebar_context(db: DBSession, user: User) -> dict:
    """Folder tree plus the chats that belong to no folder.

    Public because every page carrying the chat sidebar needs it, which now
    includes the library.

    Only root folders are queried; children come through the relationship and
    render recursively in the template.
    """
    folders = list(
        db.scalars(
            select(Folder)
            .where(Folder.user_id == user.id, Folder.parent_id.is_(None))
            .order_by(Folder.position, Folder.name)
        )
    )
    unfiled = list(
        db.scalars(
            select(Chat)
            .where(
                Chat.user_id == user.id,
                Chat.folder_id.is_(None),
                Chat.archived.is_(False),
                Chat.temporary.is_(False),
            )
            .order_by(Chat.pinned.desc(), Chat.updated_at.desc())
        )
    )
    return {
        "folders": folders,
        "unfiled_chats": unfiled,
        "can": permissions.resolve(db, user),
    }


@router.get("/")
async def home(user: RequiredUser):
    return RedirectResponse("/chat", status_code=status.HTTP_303_SEE_OTHER)


# --- Installing as an app -----------------------------------------------------
# All three routes below are deliberately unauthenticated. A browser fetches a
# manifest and a service worker outside any page's session, and an offline page
# has by definition no server to ask who is looking at it.


@router.get("/manifest.webmanifest", include_in_schema=False)
async def manifest(db: Db) -> Response:
    """The web app manifest.

    A route rather than a static file because the name is an instance setting,
    and an installed app showing "LLeMbas" when the instance is called something
    else would be wrong on the one screen that is hardest to correct: the
    launcher.
    """
    name = settings_store.get(db, "instance_name") or "LLeMbas"
    return JSONResponse(
        {
            "id": "/",
            "name": name,
            "short_name": name[:12],
            "description": "A web UI for your language models.",
            "start_url": "/chat",
            "scope": "/",
            "display": "standalone",
            "background_color": THEME_COLOUR["moria"],
            "theme_color": THEME_COLOUR["moria"],
            "icons": [
                {"src": "/static/img/icon-192.png", "sizes": "192x192",
                 "type": "image/png", "purpose": "any"},
                {"src": "/static/img/icon-512.png", "sizes": "512x512",
                 "type": "image/png", "purpose": "any"},
                {"src": "/static/img/icon-maskable-512.png", "sizes": "512x512",
                 "type": "image/png", "purpose": "maskable"},
            ],
        },
        media_type="application/manifest+json",
    )


@router.get("/sw.js", include_in_schema=False)
async def service_worker() -> Response:
    """The service worker, served from the root.

    A worker may only control pages at or below the path it was served from, so
    one delivered by the /static mount would have scope /static/js/ and control
    nothing. Serving it here is simpler than the Service-Worker-Allowed header
    that would be needed otherwise.

    no-store because a stale worker is a worker that keeps serving a stale
    cache: the one file in the application that must never be held onto.
    """
    return FileResponse(
        STATIC_DIR / "js" / "sw.js",
        media_type="text/javascript",
        headers={"Cache-Control": "no-store"},
    )


@router.get("/offline", include_in_schema=False)
async def offline(request: Request) -> Response:
    return render(request, "offline.html", {})


@router.get("/chat")
async def chat_index(
    request: Request, db: Db, user: RequiredUser, model: str = "", temporary: bool = False
):
    """A composer with no chat behind it yet.

    `?model=` preselects one, which is how the pinned shortcuts work without
    creating a row for a chat that may never be sent. `?temporary=1` is the
    same idea for the temporary flag: it lives in the URL rather than in
    JavaScript, so it survives a reload and can be bookmarked.
    """
    context = _chat_context(db, user, None)

    # Fall back to the same choice a new chat would make -- the user's default,
    # then the instance default, then first in order. Using models[0] here
    # instead would show a model the chat is not going to use, which matters:
    # the composer decides from it whether to warn that images will be dropped.
    preselected = next((m for m in context["models"] if m.model_id == model), None)
    if preselected is None:
        chosen = chat_service.default_model(db, user)
        if chosen is not None:
            preselected = next(
                (m for m in context["models"] if m.model_id == chosen[0]), None
            )
    if preselected is None and context["models"]:
        preselected = context["models"][0]

    return render(
        request,
        "chat/index.html",
        {
            "chat": None,
            "messages": [],
            "bodies": {},
            **context,
            "current_model": preselected,
            "starting_temporary": temporary,
            "suggestions": suggestions_service.visible(db),
            **sidebar_context(db, user),
        },
    )


@router.get("/chat/{chat_id}")
async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str):
    chat = db.get(Chat, chat_id)
    if chat is None or chat.user_id != user.id:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")

    # Opening the chat is what "read" means.
    if chat.unread:
        chat.unread = False
        chat.unread_notified = False
        db.commit()

    everything = list(
        db.scalars(
            select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
        )
    )
    # Summarised turns are kept and still rendered, behind a divider -- they
    # have only stopped being part of the request.
    compacted, messages = compaction_service.split(db, chat, everything)

    # Markdown is rendered once here rather than in the template so the same
    # helper produces the page and the streamed final frame -- one code path,
    # no chance of the two disagreeing.
    bodies = {
        message.id: render_markdown(message.content)
        for message in everything
        if message.role == "assistant" and message.content
    }

    # What the chat would use if its own prompt were empty, so the settings
    # panel can show it as placeholder text rather than leaving the user to
    # guess what "inherited" means.
    inherited, inherited_from = "", ""
    current = next(
        (m for m in chat_service.available_models(db, user) if m.model_id == chat.model_id), None
    )
    if current is not None and (current.system_prompt or "").strip():
        inherited, inherited_from = current.system_prompt.strip(), "model"
    else:
        instance_prompt = (settings_store.get(db, "system_prompt") or "").strip()
        if instance_prompt:
            inherited, inherited_from = instance_prompt, "instance"

    return render(
        request,
        "chat/index.html",
        {
            "chat": chat,
            "messages": messages,
            "compacted": compacted,
            "bodies": bodies,
            "inherited_prompt": inherited,
            "inherited_from": inherited_from,
            **_chat_context(db, user, chat),
            **sidebar_context(db, user),
        },
    )


@router.get("/settings")
async def settings_page(
    request: Request,
    db: Db,
    user: RequiredUser,
    error: str = "",
    saved: str = "",
):
    from lembas.api.audio import available_voices
    from lembas.services.library import memories as memories_service

    context = _chat_context(db, user, None)
    # Fetched here rather than by the template so a speech server that is down
    # leaves the page renderable, with the reason beside an empty list.
    voices, voice_error = await available_voices(context["audio"])

    # error/saved arrive as query parameters because the password form redirects
    # back here: a POST that re-rendered in place would re-submit on refresh.
    return render(
        request,
        "settings.html",
        {
            "chat": None,
            "error": error,
            "saved": saved,
            "voices": voices,
            "voice_error": voice_error,
            "memories": memories_service.all_for(db, user),
            "memory_limit": memories_service.MAX_MEMORY_CHARS,
            **context,
            **sidebar_context(db, user),
        },
    )
