"""The project's own notes on how to work in it — AGENTS.md, CLAUDE.md.

A file in the root of the project directory, read once per reply and put in the
system message. Everything about the shape of this module is copied from
`index.py`, and for the same three reasons:

* **`cached()` never does work.** `harness.context_variables` is synchronous and
  runs on the request path, so an SFTP round trip from there would hold a
  request open while somebody's box thought about it. The build happens in
  `generation._warm_project`, which is async and already doing network work.
* **`ensure()` shares one build between concurrent callers**, via `_BUILDING`
  and `asyncio.shield`.
* **Each name catches its own `ExecError`.** This is the ladder lesson from
  `index.py` arriving before the bug does: an `AGENTS.md` that cannot be read --
  a permission, an SFTP-only account, a directory where a file was expected --
  must not stop `CLAUDE.md` being tried.

The contents are **untrusted**, and go into the *system* message of a chat that
can run commands. Nothing here can fix that; what does is the wording of the
`context.agent_instructions` fragment, which names where the file came from and
bounds what it is allowed to do. Two things are done here: control characters
are stripped, and backticks are neutralised so the file cannot close the fence
it is put inside and start writing what looks like our own prose.
"""

from __future__ import annotations

import asyncio
import logging
import posixpath
import re
import time
from dataclasses import dataclass

from lembas.services.agent.base import ExecError, Executor

log = logging.getLogger(__name__)

# In order. AGENTS.md first because it is the vendor-neutral convention a shared
# repository is likeliest to carry; CLAUDE.md next because it is the one most
# widely written in practice. Root only, no recursion: a per-directory
# convention is a different feature with a different cost model.
NAMES = ("AGENTS.md", "CLAUDE.md", "AGENT.md", ".agents.md")

TTL = 300.0
MAX_CACHED = 64

# The default ceiling on what reaches the prompt. The admin setting wins.
MAX_CHARS = 4000

_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")


@dataclass(frozen=True)
class Instructions:
    """What was found in the project root, and where."""

    filename: str = ""
    text: str = ""
    built_at: float = 0.0

    @property
    def ok(self) -> bool:
        return bool(self.filename and self.text.strip())


def clean(raw: str) -> str:
    """Made safe to put inside a fenced block in a system message."""
    text = _CONTROL.sub("", raw).replace("\r\n", "\n").replace("\r", "\n")
    # It must not be able to close our fence and carry on in what then reads as
    # our own voice. Replaced rather than escaped: this is a display of somebody
    # else's file, not a round trip.
    return text.replace("```", "'''")


async def build(executor: Executor, budget: int = MAX_CHARS) -> Instructions:
    """Look for each name in turn, and stop at the first one that reads."""
    for name in NAMES:
        try:
            # Four bytes a character is generous for UTF-8 prose and stops a
            # two-megabyte file being pulled across to be thrown away.
            raw = await executor.read_file(name, max_bytes=max(budget, 1) * 4)
        except ExecError:
            # Its own catch, per name. A rung that raises must not end the
            # ladder -- that bug has already been paid for once in index.py.
            continue
        except Exception:  # noqa: BLE001 - a warm-up must never kill a reply
            log.debug("could not read %s", name, exc_info=True)
            continue

        text = clean(raw)
        if text.strip():
            return Instructions(filename=name, text=text, built_at=time.monotonic())

    return Instructions(built_at=time.monotonic())


# --- The cache ---------------------------------------------------------------
# Keyed on the connection and the directory, exactly as the listing is: two
# chats on one tree are looking at the same file.
_CACHE: dict[tuple[str, str], Instructions] = {}
_BUILDING: dict[tuple[str, str], asyncio.Task] = {}


def cached(profile_id: str, project_dir: str) -> Instructions | None:
    """What is already known, or None. Never does any work.

    A miss is not "there is no file" -- it is "nobody has looked yet", and the
    fragment's `requires` turns both into the same thing: no section at all.
    """
    found = _CACHE.get((profile_id, project_dir))
    if found is None:
        return None
    if time.monotonic() - found.built_at > TTL:
        _CACHE.pop((profile_id, project_dir), None)
        return None
    return found


async def ensure(
    executor: Executor,
    profile_id: str,
    project_dir: str,
    *,
    budget: int = MAX_CHARS,
    refresh: bool = False,
) -> Instructions:
    key = (profile_id, project_dir)
    if refresh:
        _CACHE.pop(key, None)
    elif (found := cached(profile_id, project_dir)) is not None:
        return found

    if (running := _BUILDING.get(key)) is not None:
        return await asyncio.shield(running)

    task = asyncio.create_task(build(executor, budget))
    _BUILDING[key] = task
    try:
        found = await task
    finally:
        _BUILDING.pop(key, None)

    _CACHE[key] = found
    while len(_CACHE) > MAX_CACHED:
        _CACHE.pop(next(iter(_CACHE)))
    return found


def is_instruction_file(path: str, project_dir: str) -> bool:
    """Whether a written path is the file this module caches.

    Resolved against the project directory rather than matched on the basename,
    so `./AGENTS.md`, `AGENTS.md` and `/work/AGENTS.md` are all it and
    `docs/AGENTS.md` is not -- root only, the same rule `build` follows. A
    basename match would drop the cache every time any subdirectory's own
    AGENTS.md was touched, which is a fetch nobody asked for.
    """
    wanted = path.strip()
    if not wanted:
        return False
    if not posixpath.isabs(wanted) and project_dir:
        wanted = posixpath.join(project_dir, wanted)
    wanted = posixpath.normpath(wanted)
    return any(
        wanted == posixpath.normpath(posixpath.join(project_dir or "", name)) for name in NAMES
    )


def forget(profile_id: str, project_dir: str) -> None:
    """Drop it, because something just rewrote it.

    The one case the TTL cannot cover: this process changing the file it has
    just quoted. Unlike the directory listing, an *edit* counts here as much as
    a write -- the listing only cares that the file exists, this cares what is
    in it.
    """
    _CACHE.pop((profile_id, project_dir), None)


def clear() -> None:
    _CACHE.clear()


def render(found: Instructions | None, budget: int) -> str:
    """The text, within the budget, cut at a line boundary."""
    if found is None or not found.ok or budget <= 0:
        return ""
    text = found.text.strip()
    if len(text) <= budget:
        return text
    cut = text[:budget]
    at = cut.rfind("\n")
    if at > budget // 2:
        cut = cut[:at]
    return f"{cut.rstrip()}\n… (truncated)"


__all__ = [
    "MAX_CHARS",
    "NAMES",
    "TTL",
    "Instructions",
    "build",
    "cached",
    "clean",
    "clear",
    "ensure",
    "forget",
    "is_instruction_file",
    "render",
]
