"""Telling the model how to use what it has been given.

A model handed a `tools` array will often ignore it. It answers from recall
because that is what it was trained to do, and nothing in the request suggests
otherwise. The harness is the part of the prompt that says otherwise: what day it
is, one line per tool about *when* to reach for it, the memories, and the list of
skills available.

The text itself is not here. Every piece of it is a fragment in
``services/prompts.py``, defaulted there and overridable by an administrator on
``/admin/prompts``; this module decides which fragments apply to a given request
and what their variables resolve to. That split is what lets a custom tool
contribute its own guidance later by registering a fragment source and nothing
else.

**On the "system prompts are precedence, not concatenation" rule.** That rule
governs the three authored layers -- instance, model, chat -- and it is untouched
here: exactly one of them still wins, and ``chat.effective_system_prompt`` still
decides which. This is a different axis. It describes the machinery rather than
the behaviour, nobody authored it, and there is nothing for it to disagree with.
So it is prepended to whichever authored prompt won, inside one system message,
under a heading that makes the seam obvious.

One system message rather than two because several endpoints reject a second one.
The authored prompt goes last, where it is closest to the conversation.

A model with no tools still gets the core fragments -- the date above all, since
it has no clock and is being asked about a present it cannot see. That is a
change from the original behaviour, where no tools meant no harness at all;
clearing those fragments in the admin page restores it exactly.
"""

from __future__ import annotations

import logging
from datetime import datetime
from typing import Any

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

from lembas.db.models import User
from lembas.services import prompts, settings_store
from lembas.services.library import memories as memories_service
from lembas.services.library import skills as skills_service

log = logging.getLogger(__name__)

# A ceiling on the whole block, so that a large library cannot quietly eat the
# context window. Memory and skills have their own caps below this one. An
# administrator can lower it; `max_harness_chars` of 0 means "use this".
MAX_HARNESS_CHARS = 8000

# How many attached filenames to name in the prompt. Enough to show what the
# tags will look like, few enough that a chat with thirty files does not spend
# the window listing them -- this is an explanation, not a manifest.
MAX_NAMED_DOCUMENTS = 5


def _families(db: DBSession, tools: list[dict[str, Any]]) -> list[str]:
    """Which families are represented in an offered tool list, in a fixed order.

    Resolved against the database rather than the import-time registry, because
    an administrator-defined tool is a row and would otherwise contribute no
    family at all -- which is to say its guidance would never be admitted.
    """
    from lembas.services import tools as tools_service

    book = tools_service.registry(db)
    offered = {
        book[name].family
        for tool in tools
        if (name := (tool.get("function") or {}).get("name")) in book
    }
    return [family for family in tools_service.families(db) if family in offered]


def _tool_names(tools: list[dict[str, Any]]) -> str:
    return ", ".join(
        name for tool in tools if (name := (tool.get("function") or {}).get("name"))
    )


def _document_names(db: DBSession, chat) -> str:
    """The names of the non-image files attached anywhere in this chat."""
    from lembas.db.models import Attachment

    rows = list(
        db.scalars(
            select(Attachment.filename)
            .where(Attachment.chat_id == chat.id, Attachment.kind != "image")
            .order_by(Attachment.created_at)
            .limit(MAX_NAMED_DOCUMENTS + 1)
        ).all()
    )
    if not rows:
        return ""
    if len(rows) > MAX_NAMED_DOCUMENTS:
        return ", ".join(rows[:MAX_NAMED_DOCUMENTS]) + " and others"
    return ", ".join(rows)


def context_variables(
    db: DBSession,
    user: User | None,
    tools: list[dict[str, Any]] | None,
    chat=None,
) -> dict[str, str]:
    """What every ``{{name}}`` in a fragment resolves to for this request.

    The expensive ones are guarded by family, exactly as the memory block always
    was: a model with no skills tool must not cause a skills query, and has no
    business being told the memories either.
    """
    from lembas.services import tools as tools_service

    offered = tools or []
    families = _families(db, offered)
    stamp = datetime.now().astimezone()

    values: dict[str, str] = {
        "today": stamp.strftime("%A %-d %B %Y"),
        "now": stamp.strftime("%A %-d %B %Y, %H:%M (UTC%z)"),
        "instance_name": str(settings_store.get(db, "instance_name") or "LLeMbas"),
        "user_name": (user.name or "") if user is not None else "",
        "model_name": "",
        "max_rounds": str(tools_service.MAX_ROUNDS),
        "memory_limit": str(memories_service.MAX_MEMORY_CHARS),
        "tool_names": _tool_names(offered),
        "memories": memories_service.block(db, user) if "memory" in families else "",
        "skills": skills_service.index_block(db, user) if "skills" in families else "",
        "knowledge_bases": "",
        "document_names": "",
        "agent_target": "",
        "agent_dir": "",
        "agent_mode": "",
        "agent_rewound": "",
        "project_files": "",
    }

    if chat is not None:
        from lembas.services import chat as chat_service

        model = chat_service.model_for(db, chat)
        values["model_name"] = model.label if model is not None else chat.model_id
        # Naming the bases a chat is scoped to matters: without it the model
        # cannot tell "there is nothing about this" from "I am only allowed to
        # see the contracts folder", and phrases a miss as the former.
        if "knowledge" in families and chat.knowledge_bases:
            values["knowledge_bases"] = ", ".join(base.name for base in chat.knowledge_bases)
        values["document_names"] = _document_names(db, chat)

        # The one thing a tool description cannot carry, because a description
        # is schema: which machine, which directory, and what this chat's mode
        # currently permits. `max_rounds` is corrected here too, or an agent
        # chat with forty rounds is told it has three.
        if "agent" in families:
            values.update(_agent_values(db, chat, user))

    return values


def _agent_values(db: DBSession, chat, user) -> dict[str, str]:
    """What an agent chat's harness needs to say about where it is."""
    from lembas.services import settings_store
    from lembas.services.agent import index as index_service
    from lembas.services.agent import policy
    from lembas.services.agent import session as agent_session

    context = agent_session.resolve(db, chat, user)
    if context is None:
        return {}

    rewound = ""
    if getattr(chat, "rewound_at", None) is not None:
        rewound = chat.rewound_at.strftime("on %-d %B at %H:%M")

    return {
        "agent_target": context.label,
        "agent_dir": context.project_dir or "the login directory",
        "agent_mode": policy.MODE_GUIDANCE.get(context.mode, ""),
        "agent_rewound": rewound,
        "max_rounds": str(context.limits.steps),
        "project_files": _project_files(db, chat, context, settings_store, index_service),
    }


def _project_files(db: DBSession, chat, context, settings_store, index_service) -> str:
    """The directory listing, *read from cache and never fetched*.

    This whole module runs synchronously on the request path, so an SFTP round
    trip here would hold a request open while somebody's box thought about it.
    The build happens in the generation setup, which is async and already doing
    network work; here we take whatever it left behind.

    A chat whose very first reply outruns its first index simply has no listing
    that turn -- the fragment's `requires` makes it vanish rather than appear as
    an empty heading, and the next turn has it.
    """
    agents = settings_store.agents(db)
    if not agents.get("index_enabled"):
        return ""
    budget = int(agents.get("index_chars") or 0)
    if budget <= 0:
        return ""

    profile_id = getattr(chat, "ssh_profile_id", "") or ""
    found = index_service.cached(profile_id, context.project_dir)
    if found is None:
        return ""
    return index_service.render(found, budget)


def limit_for(db: DBSession) -> int:
    """The ceiling on the assembled block."""
    stored = settings_store.get(db, "max_harness_chars", key=settings_store.PROMPTS)
    return int(stored or 0) or MAX_HARNESS_CHARS


def compose_from(
    db: DBSession,
    *,
    variables: dict[str, str],
    families: list[str],
    has_tools: bool,
    overrides: dict[str, str] | None = None,
) -> str:
    """Assemble the preamble from already-resolved variables.

    Separate from `compose` because the admin preview has no chat and must not
    invent one: a transient Chat whose `knowledge_bases` collection cannot be
    populated without real rows is a trap, and taking a plain dict of variables
    instead sidesteps it entirely.
    """
    return prompts.assemble(
        db,
        groups=prompts.HARNESS_GROUPS,
        variables=variables,
        families=families,
        has_tools=has_tools,
        overrides=overrides,
        limit=limit_for(db),
    )


def compose(
    db: DBSession,
    user: User | None,
    tools: list[dict[str, Any]] | None,
    chat=None,
) -> str:
    """The operational preamble for this request, or "" when there is nothing to say."""
    offered = tools or []
    return compose_from(
        db,
        variables=context_variables(db, user, offered, chat),
        families=_families(db, offered),
        has_tools=bool(offered),
    )


def join(harness: str, authored: str, *, lead: str = "") -> str:
    """Put the harness in front of whichever authored prompt won.

    Separated from `compose` so the precedence between instance, model and chat
    stays testable on its own -- this function is the only place the two axes
    meet. `lead` is the sentence that sits on the seam and says which side wins
    when they disagree; it is a fragment like everything else, and an empty one
    leaves the bare rule that was there before.
    """
    if not harness:
        return authored
    if not authored:
        return harness
    seam = f"{lead}\n\n---" if lead else "---"
    return f"{harness}\n\n{seam}\n\n{authored}"
