"""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 typing import Any

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

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

log = logging.getLogger(__name__)

# A ceiling on the whole block, so that a large library cannot quietly eat the
# context window. An administrator can lower it; `max_harness_chars` of 0 means
# "use this".
#
# It has to be larger than everything the shipped defaults are already allowed
# to put in, and at 8000 it was not. The fragments alone are about 7,900
# characters for an agent chat, and on top of that `index_chars` grants a 2,000
# character project listing and `instructions_chars` a 4,000 character
# AGENTS.md -- both defaults, both on by default. The block was therefore cut at
# 8,000 on an ordinary agent chat, and `prompts.assemble` cuts the *tail*, which
# by fragment order is exactly the context worth having: the listing was severed
# mid-tree and `context.agent_instructions` was dropped in its entirety. The one
# path by which a project's own instructions reach a model did not reach it.
#
# The two big blocks already carry their own budgets, applied before assembly,
# so they are bounded whatever this is. What this bounds is the *fragments*
# growing without anybody noticing -- so it is set above the sum of what those
# budgets grant, with room for the plan and the memories beside them.
#
# 20,000 rather than 16,000, which the shipped set had grown to within 1,300
# characters of. A ceiling this close to the content is one the next fragment
# crosses, and crossing it is silent: `assemble` cuts the tail, and the tail is
# the project's own AGENTS.md. `tests/test_harness.py` pins a margin now as well
# as a fit, so the room is a fact rather than a hope.
#
# 24,000 now, because that margin did its job: adding `core.commit` and
# `tool.agent_edits` took the headroom under 20% and the test said so rather
# than the AGENTS.md quietly losing its last paragraph on somebody's install.
# Raising the ceiling costs nothing by itself -- it is a limit, not a size, and
# the assembled block is the same length either way. What it buys is that the
# margin keeps meaning what it says.
MAX_HARNESS_CHARS = 24000

# How much of the ceiling the shipped fragments may occupy at full budget. The
# rest is headroom for an administrator's own wording, which is the thing this
# limit exists to leave room for -- an override is usually longer than the
# default it replaces, not shorter.
HARNESS_MARGIN = 0.2

# 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 _image_templates(db: DBSession) -> str:
    """One line per workflow, name and description.

    The description is the load-bearing half, the same way it is for a skill:
    it is the only thing the model has to choose with, and "workflow-2" is not
    a choice. Capped, because a list of thirty costs the window on every
    request forever.
    """
    from lembas.db.models import ImageWorkflow

    rows = list(
        db.scalars(
            select(ImageWorkflow)
            .where(ImageWorkflow.enabled.is_(True))
            .order_by(ImageWorkflow.position, ImageWorkflow.slug)
            .limit(12)
        )
    )
    return "\n".join(f"- {row.slug}: {row.description or row.name}" for row in rows)


def _image_models(db: DBSession) -> str:
    """The checkpoints an administrator has listed, comma separated."""
    return ", ".join(settings_store.images(db).get("checkpoints") or [])


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)
    # The reader's zone, not the server's. Telling somebody in another country
    # that it is Tuesday when it is Wednesday where they are was survivable
    # while the answer was only ever prose; it stops being survivable the moment
    # they can say "every Monday at 3" and something has to work out when that
    # is. `zone_for` falls back to the server's, so an instance where nobody has
    # set one behaves exactly as it always did.
    stamp = clock.now_for(user)

    values: dict[str, str] = {
        "today": stamp.strftime("%A %-d %B %Y"),
        "now": stamp.strftime("%A %-d %B %Y, %H:%M (UTC%z)"),
        # Named so a model working out a schedule can say which zone it meant,
        # and so `core.today` can carry it without a second fragment.
        #
        # The fallback is load-bearing and used to be absent. `name_for` returns
        # "" for anybody who has never chosen a zone -- the default state of
        # every account -- and the comment here claimed that dropped the line
        # rather than announcing the server's zone as a decision. It did not:
        # `substitute` drops a line only when it is *blank* after expansion, and
        # this variable sits inside a sentence, so every such request shipped
        # "- Times the person gives you are in  unless they say otherwise."
        #
        # Naming the server's zone was never the thing being avoided anyway.
        # `stamp` is `clock.now_for(user)`, which already falls back to it, so
        # `{{today}}` and `{{now}}` are *already* in that zone and `{{now}}`
        # already prints its offset. Withholding the label from a value the
        # model has been given is not restraint, it is a hole. This is the
        # fallback `schedule/compile.py` has always had, for the same reason.
        "timezone": clock.name_for(user) or str(clock.server_zone()),
        "instance_name": branding.for_db(db).name,
        "user_name": (user.name or "") if user is not None else "",
        "model_name": "",
        # What this request will actually allow, so the model is not told a
        # number that is not its own. `tools_service.MAX_ROUNDS` is only the
        # fallback for callers with no session.
        "max_rounds": str(settings_store.chat_rounds(db) or 0),
        # Not rendered anywhere. It is the gate on `core.rounds`: an ordinary
        # chat has a ceiling worth planning within, an agent chat is told to
        # keep going instead, and those are different sentences rather than the
        # same sentence with a different number in it. Blank when there is no
        # ceiling at all, so the fragment vanishes rather than promising zero.
        "round_budget": str(settings_store.chat_rounds(db) or ""),
        # The complement, and the gate on `core.keep_working`. Exactly one of
        # the two is ever set: a model told it has a budget rations it and stops
        # early to report progress, and one told to keep going does the work.
        # Not rendered anywhere either.
        "unbounded": "" if settings_store.chat_rounds(db) else "yes",
        "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, exclude=tools_service.scoped_skills_off(chat))
            if "skills" in families
            else ""
        ),
        # What can be drawn, and with what. Guarded by family for the reason the
        # memory block is: an instance with no ComfyUI must not pay a settings
        # read and a table scan to tell a model about a tool it was not offered.
        # Database reads only -- `context_variables` is synchronous and on the
        # request path, so asking ComfyUI itself what it has would hold the
        # request open while somebody's box thought about it. The admin page
        # discovers; this reads what it stored.
        "image_templates": _image_templates(db) if "image" in families else "",
        "image_models": _image_models(db) if "image" in families else "",
        "image_instructions": (
            str(settings_store.images(db).get("instructions") or "") if "image" in families else ""
        ),
        "knowledge_bases": "",
        "document_names": "",
        "agent_target": "",
        "agent_dir": "",
        "agent_mode": "",
        "agent_rewound": "",
        "background": "",
        "project_files": "",
        "agent_instructions": "",
        "agent_instructions_file": "",
        "plan": "",
        "plan_editable": "",
        # Empty everywhere but a scheduled task's own chat, which is what makes
        # it the gate on `core.unattended` as well as the content of
        # `context.schedule`. Two fragments, one variable, and no way for the
        # warning to appear without the thing it warns about.
        "schedule_instruction": "",
        "schedule_summary": "",
        # Set only in a helper's own chat, and the gate on `core.subagent`.
        # Deliberately not the same variable as `schedule_instruction` even
        # though both mean "nobody is reading": the two say different things to
        # a model, and one fragment covering both would have to say neither.
        "subagent": "",
    }

    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))

        # Not gated on a family: a scheduled task has no tools of its own, and
        # the thing that must reach the model is precisely that nobody is
        # reading. One primary-key lookup, the same deal `plan` gets.
        if chat.kind == KIND_TASK:
            values.update(_schedule_values(db, chat, user))

        # Not gated on a family either, and for the same reason: what has to
        # reach a helper is that it is one. A column read, no query.
        if chat.parent_chat_id:
            values["subagent"] = "yes"

    return values


def _schedule_values(db: DBSession, chat, user) -> dict[str, str]:
    """What a scheduled task's chat is for, and how often it comes round.

    A task chat accumulates every run, so by the tenth the original instruction
    is far out of sight up the transcript. Put back in front of the model each
    turn rather than left to be inferred -- exactly what `Chat.plan_message_id`
    exists to do for a plan.
    """
    from lembas.services import schedules as schedules_service

    schedule = schedules_service.for_chat(db, chat)
    if schedule is None:
        # The schedule was removed and its chat kept. There is nothing standing
        # to say, so the fragments vanish rather than describing a timer that no
        # longer exists.
        return {}
    return {
        "schedule_instruction": schedule.instruction or schedule.request or "",
        "schedule_summary": schedules_service.describe(schedule, owner=user),
    }


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 plans as plans_service
    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,
        # Non-empty only when commands may run in the background, which is what
        # gates the fragment telling the model so.
        "background": "on" if context.background else "",
        "max_rounds": str(context.limits.steps),
        # Blanked, which is what makes `core.rounds` vanish here: `steps` is a
        # runaway backstop and telling a model it has a budget of two hundred
        # invites it to ration one. `unbounded` is its complement and is what
        # `core.keep_working` is gated on, so an agent chat always gets the
        # keep-going half whatever the instance setting says.
        "round_budget": "",
        "unbounded": "yes",
        "project_files": _project_files(db, chat, context, settings_store, index_service),
        # Already resolved on the context, from one primary-key lookup in
        # `agent_session.resolve`. A plan the model cannot see is a plan it
        # cannot keep current, which is the whole of why this is here.
        "plan": plans_service.render_block(context.plan),
        # Whether `plan_update` is actually in this request, which is not the
        # same question as whether there is a plan. `agent/tools.py` drops it in
        # Plan mode -- that mode ends with `plan_submit` instead -- so gating its
        # guidance on `plan` alone told a model in Plan mode to "keep it current
        # with plan_update as you go" about a tool that was not there, directly
        # under `core.tool_list` saying anything unnamed does not exist. The
        # fragment's own hint claimed the two coincided. They do not, and this
        # is the variable that makes them.
        "plan_editable": (
            plans_service.render_block(context.plan) if context.mode != policy.MODE_PLAN else ""
        ),
        **_project_instructions(db, chat, context, settings_store),
    }


def _project_instructions(db: DBSession, chat, context, settings_store) -> dict[str, str]:
    """The project's own AGENTS.md, from cache and never fetched.

    Written to mirror `_project_files` line for line, and under the same rule:
    `cached()` only. `generation._warm_project` is what fills it.
    """
    from lembas.services.agent import instructions as instructions_service

    agents = settings_store.agents(db)
    blank = {"agent_instructions": "", "agent_instructions_file": ""}
    if not agents.get("instructions_enabled"):
        return blank
    budget = int(agents.get("instructions_chars") or 0)
    if budget <= 0:
        return blank

    profile_id = getattr(chat, "ssh_profile_id", "") or ""
    found = instructions_service.cached(profile_id, context.project_dir)
    text = instructions_service.render(found, budget)
    if not text:
        return blank
    return {"agent_instructions": text, "agent_instructions_file": found.filename}


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}"
