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

from __future__ import annotations

from zoneinfo import available_timezones

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 (
    KIND_CHAT,
    KIND_MESSAGES,
    KIND_TASK,
    KINDS,
    Chat,
    Folder,
    KnowledgeBase,
    Message,
    User,
)
from lembas.security import permissions
from lembas.services import audio as audio_service
from lembas.services import branding as branding_service
from lembas.services import canvas as canvas_service
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import reports as reports_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.schedule import clock
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,
        "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,
        # What the picker shows, and what `build_request` will send. One
        # resolver so the two cannot disagree.
        "resolved_effort": chat_service.resolved_effort(chat) if chat else "",
        **_scope_context(db, user, chat),
        **_agent_context(db, user, chat),
        **audio_service.template_flags(db, user),
    }


def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
    """What this chat may use, for the menu that narrows it.

    The families listed are the ones actually offered *right now*, so the menu
    never shows a switch for something the model, the reader's permissions or
    the instance has already ruled out -- turning that on would do nothing,
    since `resolve_tools` applies this after the gates.

    **It works before the chat exists**, and that is not a nicety. The whole
    point of narrowing is to decide what a conversation may reach, and the first
    turn is the one where it matters most: the harness puts a tool's guidance in
    front of the model the moment the tool is offered, so by the time a chat
    existed to switch anything off, the model had already been told how to keep
    notes and been given the tools to do it. Switching it off afterwards does
    not un-send that turn.

    It used to say there was no row to write to. There is not -- so the
    prospective menu writes nothing: its switches are plain checkboxes submitted
    with the first message, and `start_chat` turns them into `scope_json` on the
    row it is about to create. `scope_allow` stays empty because nothing can
    have been allowed yet.

    The stand-in `Chat` is `agent/draft.py:as_chat`'s trick again: `resolve_tools`
    reads the kind, the model and the scope off a chat and never queries or
    writes it, so a row that is constructed and never added satisfies it
    unchanged. `scope_json` is set explicitly because it is a *column* default,
    applied at flush, and this one is never flushed.
    """
    from lembas.services import tool_labels
    from lembas.services import tools as tools_service
    from lembas.services.library import skills as skills_service

    prospective = chat is None
    if prospective:
        model_id = ""
        chosen = chat_service.default_model(db, user)
        if chosen is not None:
            model_id = chosen[0]
        if not model_id:
            return {"scope_families": [], "scope_skills": [], "scope_allow": []}
        # An ordinary chat, deliberately, even though the kind can still be
        # switched on this screen: an agent chat's tools depend on a connection
        # that is not settled until the chat is created, so offering them here
        # would be a switch for something that may not be offered. Everything a
        # plain chat can reach is switchable, which is the part that matters.
        chat = Chat(user_id=user.id, kind=KIND_CHAT, model_id=model_id, scope_json={})

    off = tools_service.scoped_off(chat)
    skills_off = tools_service.scoped_skills_off(chat)

    # Gates rather than tool names: `notes` is one switch, not five, which is
    # the same reasoning the per-model capability checkboxes carry.
    seen: dict[str, str] = {}
    for tool in tools_service.resolve_tools(db, chat, user).defs:
        seen.setdefault(tools_service.gate_of(tool.family), tool.name)
    # Anything already switched off is absent from the offered set, so it has to
    # be put back or there would be no way to turn it on again.
    for gate in off:
        seen.setdefault(gate, "")

    families = [
        {
            "gate": gate,
            "label": _GATE_LABELS.get(gate) or tool_labels.label_for(example) or gate,
            "on": gate not in off,
        }
        for gate, example in sorted(seen.items())
    ]

    skills = []
    if permissions.has(db, user, "library.use"):
        skills = [
            {
                "name": skill.name,
                "description": skill.description,
                "on": skill.name not in skills_off,
            }
            for skill in skills_service.enabled_for(db, user)
        ]
        for name in sorted(skills_off):
            if name not in {s["name"] for s in skills}:
                skills.append({"name": name, "description": "", "on": False})

    # What this chat has been told to stop asking about. Shown so the list
    # cannot grow invisibly: every entry is one click of "Always allow this" on
    # a card, and a standing permission nobody can see is one nobody can revoke.
    return {
        "scope_families": families,
        "scope_skills": skills,
        "scope_allow": list(tools_service.scoped_allow(chat)),
        # Which of the two menus to draw: switches that POST at once, or
        # switches that ride along with the first message. The template asks
        # this rather than `chat is None`, so the reason is named where the
        # difference is.
        "scope_prospective": prospective,
    }


# What a gate is called in the menu. A gate covers several tools, so no single
# tool's label is the right name for it.
_GATE_LABELS = {
    "web_search": "Web search",
    "fetch": "Fetching pages",
    "knowledge": "Your knowledge library",
    "notes": "Notes",
    "memory": "Memory",
    "skills": "Skills",
    "ask": "Asking you questions",
    "scratch": "Writing in the canvas",
    "image": "Generating images",
    "report": "Filing reports",
    "schedule": "Scheduling work",
    "subagent": "Sending helpers",
    "agent": "Running commands",
    "custom": "Custom tools",
    "mcp": "MCP servers",
}


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 hosts
    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 = [
            profile
            for profile in db.scalars(
                select(SshProfile)
                .where(SshProfile.owner_id == user.id, SshProfile.enabled.is_(True))
                .order_by(SshProfile.name)
            )
            # A connection pointing at this machine that an administrator has not
            # allowed is not offered at all. `session.resolve` refuses it too and
            # is the control; this is so it never appears in a picker whose only
            # outcome is an agent chat with no tools and nothing said about why.
            if hosts.usable(db, profile)
        ]

    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
    elif chat is None and profiles:
        # The new-chat screen. Which connection is *chosen* is a decision being
        # made in the browser, so the server cannot know it -- what it can say is
        # that there is one to choose, which is all the panels need in order to
        # exist. They are pointed at a target by `lembas:agent-target`, and show
        # nothing until they are.
        #
        # This says the panels may *exist*, never that they should be *offered*.
        # The two buttons render `hidden` here and are shown by the same event,
        # because the kind toggle and the connection select are both in the
        # browser: answering with `profiles[0]` and leaving it at that offered a
        # terminal on an ordinary chat with nothing selected, and pressing it
        # opened a panel that could not work.
        current = profiles[0]

    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),
        # Whether this chat could have background jobs at all. Not whether it
        # has any -- that is what the chip's own request answers, five seconds
        # later, off the request path. A chip that can never show anything is a
        # chip that only takes room in a row this codebase has already had to
        # fight to keep on one line.
        "jobs_enabled": _jobs_enabled(db, user, chat, current),
        # Any chat that exists. Deliberately not gated the way the terminal is:
        # half the canvas's sources -- notes, skills, this chat's attachments,
        # its own scratch document -- need no machine at all, so the terminal's
        # total gate would remove a working feature because one source is
        # unavailable. Absent on the new-chat screen for the reason the scope
        # menu is: there is no row yet to hang a tab on.
        # Also before the chat exists, where it opens on the connection being
        # chosen in the composer. That reverses an earlier decision -- "there is
        # no row yet to hang a tab on" -- which was true of the *storage* and
        # was never a reason to withhold the panel: a draft holds its tabs in
        # memory and hands them over when the chat is created. See
        # services/agent/draft.py.
        "canvas_enabled": chat is not None or bool(profiles),
        # And whether it may *also* reach project files. Re-derived server-side
        # on every canvas request; this flag only decides what the panel offers.
        "canvas_agent": canvas_service.agent_ready(db, user, chat) is not None,
    }


def _jobs_enabled(db: DBSession, user: User, chat: Chat | None, profile) -> bool:
    """Whether background jobs are possible in this chat.

    The same shape as `_terminal_enabled` and for the same reason, but keyed on
    `background_enabled` rather than on `terminal_enabled` and on `tools.agent`
    rather than `agent.terminal` -- somebody who may have a model run commands
    here may see which of them are still running. It is not a second permission,
    because there is no action here the agent tools do not already grant.
    """
    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, "tools.agent"):
        return False
    values = settings_store.agents(db)
    if not values.get("enabled") or not values.get("background_enabled"):
        return False
    return ssh_service.available() == ""


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

    # `chat is None` is the new-chat screen, which may open a shell on the
    # connection being chosen there. Everything else still has to hold.
    if profile is None or (chat is not None and chat.kind != KIND_AGENT):
        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_kind(user: User) -> str:
    """Which side of the sidebar's switch this user last chose.

    One resolver, because the page, the fragment route and the switch's own
    pressed state all have to agree about it. Anything unrecognised -- an older
    release's value, a hand-edited row -- reads as ordinary chats rather than
    showing an empty sidebar nobody can explain.
    """
    chosen = (user.settings_json or {}).get("sidebar_kind")
    return chosen if chosen in KINDS else KIND_CHAT


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.

    Everything is narrowed to one `Chat.kind`. A folder the filter has emptied
    is dropped here rather than in the template, so the "Folders" heading cannot
    appear above nothing -- the same reason `visible_chats` moved off the
    template in the first place. `shown_in` is what draws that line: a folder
    that was empty to begin with is kept, on both sides.
    """
    # With the switch absent the sidebar goes back to showing everything, rather
    # than to one side of a fork nobody can move. An administrator turning agent
    # chats off would otherwise strand whoever last left the switch on Agents in
    # a sidebar that is empty with no way out of it.
    split = permissions.has(db, user, "agent.ssh") and bool(
        settings_store.agents(db).get("enabled")
    )
    kind = sidebar_kind(user) if split else ""

    folders = [
        folder
        for folder in db.scalars(
            select(Folder)
            .where(Folder.user_id == user.id, Folder.parent_id.is_(None))
            .order_by(Folder.position, Folder.name)
        )
        if folder.shown_in(kind)
    ]
    narrowed = select(Chat).where(
        Chat.user_id == user.id,
        Chat.folder_id.is_(None),
        Chat.archived.is_(False),
        Chat.temporary.is_(False),
        # `kind` empty means "both sides of the switch", never "no filter" --
        # see `Folder.visible_chats`. Task chats and the Messages conversation
        # have sections of their own and must never appear in this list, and
        # the case that reaches here with "" is precisely an instance with
        # agents disabled, where nobody would ever see the leak coming.
        Chat.kind.in_((kind,) if kind else KINDS),
    )
    unfiled = list(
        db.scalars(narrowed.order_by(Chat.pinned.desc(), Chat.updated_at.desc()))
    )
    return {
        "folders": folders,
        "unfiled_chats": unfiled,
        # The shortcuts at the top of the sidebar. Here rather than in
        # `_chat_context`, where they used to be, for two reasons: they are
        # sidebar content and the fragment route that re-renders the sidebar has
        # only this, and the library and connections pages carry the sidebar
        # without ever calling `_chat_context` -- so the shortcuts simply were
        # not there on any of them. The picker lists every model in the
        # administrator's order, pinned or not; pinning is not ordering.
        "pinned_models": [m for m in chat_service.available_models(db, user) if m.pinned],
        # Whether the Reports entry starts with its dot showing. Only the first
        # paint: from then on `/api/chats/unread` moves it out of band, the same
        # deal a chat row's dot has. Counted rather than existence-checked
        # because the same query answers both and a count is what a title would
        # want if this ever grows one.
        "unread_reports": reports_service.unread_count(db, user),
        # Read rather than created, for the reason the poll does the same: this
        # runs on every page, and `messages.for_user` would write a conversation
        # for every account that has never opened the section.
        "unread_messages": bool(
            db.scalar(
                select(Chat.unread).where(
                    Chat.user_id == user.id, Chat.kind == KIND_MESSAGES
                )
            )
        ),
        "sidebar_kind": kind,
        # Whether the switch is worth showing at all. A two-way switch with one
        # useful side is worse than no switch: it offers a view that is empty by
        # construction and cannot be made otherwise.
        "sidebar_split": split,
        "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("/healthz", include_in_schema=False)
async def healthz() -> Response:
    """Is the process up and can it reach its database.

    Unauthenticated, like the three below, and for a fourth reason: a
    healthcheck that needed a session would be a healthcheck nothing could run.
    It says nothing about *what* is here -- no version, no counts -- because it
    is reachable without signing in and a health endpoint is a common place to
    leak the first fact an attacker wants.

    The query is what makes it worth having. A process that is up with a
    database it cannot open answers every page with a 500, and a check that only
    proved the socket was listening would call that healthy.
    """
    from sqlalchemy import text

    from lembas.db.session import session_scope

    try:
        with session_scope() as db:
            db.execute(text("SELECT 1"))
    except Exception:  # noqa: BLE001 - the answer is the status code
        return JSONResponse({"status": "error"}, status_code=503)
    return JSONResponse({"status": "ok"})


@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.
    """
    brand = branding_service.for_db(db)
    icons = brand.icon_paths
    return JSONResponse(
        {
            "id": "/",
            "name": brand.name,
            "short_name": brand.name[:12],
            "description": brand.tagline or "A web UI for your language models.",
            "start_url": "/chat",
            "scope": "/",
            "display": "standalone",
            "background_color": THEME_COLOUR["moria"],
            "theme_color": THEME_COLOUR["moria"],
            # An uploaded logo's derived icons, or the shipped ones. Whole-set
            # rather than per size: a manifest listing two custom icons and one
            # shipped is a launcher tile that changes when the device picks a
            # different size, which reads as a bug in the install.
            "icons": [
                {"src": f"/branding/{icons['icon-192']}", "sizes": "192x192",
                 "type": "image/png", "purpose": "any"},
                {"src": f"/branding/{icons['icon-512']}", "sizes": "512x512",
                 "type": "image/png", "purpose": "any"},
                {"src": f"/branding/{icons['maskable']}", "sizes": "512x512",
                 "type": "image/png", "purpose": "maskable"},
            ] if icons.get("icon-192") and icons.get("icon-512") and icons.get("maskable") else [
                {"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,
    kind: str = "",
    folder: str = "",
):
    """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. `?kind=agent`
    is how the sidebar's Agent side opens a new chat already on that side --
    a preselection like the other two, not a decision: the kind is still
    chosen on the screen and still fixed only when the first message is sent.
    `?folder=` is the same again, and is what "New chat here" on a folder row
    posts: the chat is filed there, and `_new_chat` fills in whatever the
    folder seeds and the screen left empty.
    """
    context = _chat_context(db, user, None)

    # Somebody else's folder id in the URL is ignored rather than refused. It
    # would only ever get there by hand, and an error page holding a composer
    # hostage over a bad query string helps nobody.
    starting_folder = db.get(Folder, folder) if folder else None
    if starting_folder is not None and starting_folder.user_id != user.id:
        starting_folder = None
    # A folder that fixes the kind picks the fork, unless the URL already said.
    if not kind and starting_folder is not None:
        kind = starting_folder.kind

    # 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)
    # The folder's own model, ahead of the reader's default and behind an
    # explicit `?model=`. Same order `_new_chat` applies, so the picker shows
    # the model the chat is actually going to be created with -- which matters,
    # because the composer decides from it whether to warn about images.
    if preselected is None and starting_folder is not None and starting_folder.model_id:
        preselected = next(
            (m for m in context["models"] if m.model_id == starting_folder.model_id), 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,
            "starting_kind": kind if kind in KINDS else KIND_CHAT,
            "starting_folder": starting_folder,
            "suggestions": suggestions_service.visible(db),
            **sidebar_context(db, user),
        },
    )


def _candidate_parents(db: DBSession, user_id: str, folder: Folder) -> list[Folder]:
    from lembas.api.folders import candidate_parents

    return candidate_parents(db, user_id, folder)


@router.get("/folders/{folder_id}")
async def folder_settings(request: Request, db: Db, user: RequiredUser, folder_id: str):
    """What a folder hands to the chats started inside it.

    A page rather than a row that expands, following the admin convention: a
    form per row in a tree that nests eight deep would be unusable, and the
    sidebar is the one part of the application that has to stay scannable.

    Guarded by `folder.manage`, the same permission the whole folder router
    carries -- editing a folder's system prompt is managing a folder, and a page
    that renders for somebody whose save is going to 403 is a trap.
    """
    if not permissions.has(db, user, "folder.manage"):
        raise HTTPException(status.HTTP_403_FORBIDDEN, "You cannot manage folders.")

    folder = db.get(Folder, folder_id)
    if folder is None or folder.user_id != user.id:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.")

    return render(
        request,
        "folders/edit.html",
        {
            "folder": folder,
            "chat": None,
            # Imported here rather than at module scope: `api.folders` imports
            # `api.deps`, which this module is a peer of, and the pair have been
            # kept apart deliberately.
            "parents": _candidate_parents(db, user.id, folder),
            "models": chat_service.available_models(db, user),
            **_agent_context(db, user, None),
            **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)

    # Empty, and kept only so `_thread.html` and the four handlers that render a
    # bubble keep one signature between them. An assistant turn is rendered from
    # its steps now (`message_steps`, a Jinja global), which is what lets a
    # reply's prose sit either side of the tool call it surrounded rather than
    # arriving as one block at the bottom. Nothing reads this for an assistant
    # message any more; `library/note_detail.html` has its own.
    bodies: dict[str, str] = {}

    # 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.
    #
    # This mirrors `chat_service.effective_system_prompt` and has to keep
    # mirroring it, layer for layer and in the same order -- a panel naming the
    # wrong source is worse than one naming none, because it is believed.
    inherited, inherited_from = "", ""
    folder_prompt = chat_service.folder_system_prompt(db, chat)
    current = next(
        (m for m in chat_service.available_models(db, user) if m.model_id == chat.model_id), None
    )
    if folder_prompt:
        inherited, inherited_from = folder_prompt, "folder"
    elif 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,
            **_schedule_context(db, user, chat),
            **_chat_context(db, user, chat),
            **sidebar_context(db, user),
        },
    )


def _schedule_context(db: DBSession, user: User, chat: Chat) -> dict:
    """What the strip below a task chat needs.

    Empty for every other kind, so the three keys exist unconditionally and the
    template can ask about `schedule` without a `default(false)` -- the same
    reason `audio_service.template_flags` is passed by all four bubble
    renderers rather than by whichever one remembered.

    `schedule` being None on a task chat is a real state, not an error: removing
    a schedule keeps its chat by default, and the strip says so.
    """
    from lembas.services import schedules as schedules_service

    if chat is None or chat.kind != KIND_TASK:
        return {"schedule": None, "schedule_summary": "", "schedule_next": None}

    schedule = schedules_service.for_chat(db, chat)
    if schedule is None:
        return {"schedule": None, "schedule_summary": "", "schedule_next": None}

    zone = clock.zone_for(user)
    return {
        "schedule": schedule,
        "schedule_summary": schedules_service.describe(schedule, owner=user),
        "schedule_next": (
            clock.as_utc(schedule.next_fire_at).astimezone(zone)
            if schedule.next_fire_at
            else None
        ),
    }


@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,
            # Sorted rather than left in set order, because a list of six
            # hundred zones that is not alphabetical is one nobody can use.
            "timezones": sorted(available_timezones()),
            "timezone": clock.name_for(user),
            "server_timezone": str(clock.server_zone()),
            "local_now": clock.now_for(user).strftime("%H:%M on %A %-d %B"),
            **context,
            **sidebar_context(db, user),
        },
    )
