# retoor <retoor@molodetz.nl>

from __future__ import annotations

import asyncio
import logging
from typing import Any

import uuid_utils

from .actions import Dispatcher
from .agent import Agent, SYSTEM_PROMPT
from .agentic import AgenticController, LessonStore
from .avatar import AvatarController
from .behavior import BehaviorController
from .chunks import ChunkStore
from .client import ClientController
from .config import Settings
from .cost import CostTracker
from .cost.tracker import Pricing
from .http_client import PlatformClient
from .llm import LLMClient
from .registry import CATALOG
from .tasks import Scheduler, TaskController, TaskStore
from .virtual_tools import VirtualToolController, VirtualToolStore

logger = logging.getLogger("devii.session")

AVATAR_QUERY_TIMEOUT_SECONDS = 30.0
BROWSER_REQUEST_DEADLINE_SECONDS = 60.0
RESULT_NOTICE_CHARS = 20000
INTERNAL_PREFIXES = (
    "[memory]",
    "[reflection-trigger]",
    "[verification-gate]",
    "[stopped]",
    "Protocol violation:",
)
BUFFERED_TYPES = ("task", "error", "reply", "status")
BEHAVIOR_HEADER = "# TRUTH RULES AND BEHAVIOR"


def _repair_history(messages: list[dict[str, Any]]) -> None:
    last_idx = None
    for i in range(len(messages) - 1, -1, -1):
        message = messages[i]
        if message.get("role") == "assistant" and message.get("tool_calls"):
            last_idx = i
            break
    if last_idx is None:
        while messages and messages[-1].get("role") == "tool":
            messages.pop()
        return
    declared = {
        call.get("id")
        for call in (messages[last_idx].get("tool_calls") or [])
        if call.get("id")
    }
    answered = {
        messages[j].get("tool_call_id")
        for j in range(last_idx + 1, len(messages))
        if messages[j].get("role") == "tool"
    }
    if not declared or not declared.issubset(answered):
        del messages[last_idx:]

LOGIN_REQUEST = (
    "Welcome to Devii. Ask me anything - I can generate clients and bots, search the docs, "
    "fetch pages, and more. Sign in to DevPlace whenever you want me to work on your own account."
)


class DeviiSession:
    def __init__(
        self,
        owner_kind: str,
        owner_id: str,
        username: str,
        settings: Settings,
        llm: LLMClient,
        lessons: LessonStore,
        pricing: Pricing,
        task_store: TaskStore,
        virtual_tool_store: VirtualToolStore,
        behavior_store: Any,
        stores: dict[str, Any],
        is_admin: bool = False,
        channel: str = "main",
    ) -> None:
        self.owner_kind = owner_kind
        self.owner_id = owner_id
        self.channel = channel
        self.username = username
        self.settings = settings
        self.is_admin = is_admin
        self.persist_conversation = owner_kind == "user"
        self._llm = llm
        self._lessons = lessons
        self.client = PlatformClient(
            settings.base_url, settings.timeout_seconds, settings.platform_api_key
        )
        self.avatar = AvatarController()
        self.browser = ClientController(
            allow_eval=settings.allow_eval, allow_control=settings.browser_control
        )
        self.store = task_store
        self.task_controller = TaskController(self.store)
        self.agentic = AgenticController(lessons, settings)
        self.cost = CostTracker(pricing=pricing)
        self.chunks = ChunkStore()
        self._virtual_tool_store = virtual_tool_store
        self.virtual_tools = VirtualToolController(
            virtual_tool_store, self.agentic.run_subagent, set(CATALOG.by_name())
        )
        self._behavior_store = behavior_store
        self.behavior = BehaviorController(behavior_store)
        self.dispatcher = Dispatcher(
            CATALOG,
            self.client,
            settings,
            self.task_controller,
            self.agentic,
            avatar=self.avatar,
            browser=self.browser,
            is_admin=is_admin,
            quota_provider=self._quota_snapshot,
            owner_kind=owner_kind,
            owner_id=owner_id,
            virtual_tools=self.virtual_tools,
            behavior=self.behavior,
        )
        self.tools = self._builtin_tools()
        self._system_prompt = (
            DOCS_SYSTEM_PROMPT if channel == "docs" else _system_prompt_for(is_admin)
        )
        self.agentic.bind(
            llm=llm,
            dispatcher=self.dispatcher,
            tools=self.tools,
            on_trace=self._trace,
            cost_tracker=self.cost,
            chunk_store=self.chunks,
        )
        self.agent = Agent(
            settings,
            llm,
            self.dispatcher,
            self.tools,
            lessons=lessons,
            on_trace=self._trace,
            cost_tracker=self.cost,
            chunk_store=self.chunks,
            system_prompt=self._compose_system_prompt(),
        )
        self.scheduler = Scheduler(
            self.store,
            self._make_executor(),
            self._task_event,
            settings.scheduler_tick_seconds,
        )
        self._conv = stores["conversations"]
        self._ledger = stores["ledger"]
        self._audit = stores["turns"]
        self._conns: set[Any] = set()
        self._conn_meta: dict[Any, dict[str, Any]] = {}
        self._attach_seq = 0
        self._lock = asyncio.Lock()
        self._send_lock = asyncio.Lock()
        self._pending: dict[str, asyncio.Future] = {}
        self._query_seq = 0
        self._connected = asyncio.Event()
        self._disconnected = asyncio.Event()
        self._disconnected.set()
        self._started = False
        self._buffer: list[dict[str, Any]] = []
        self._turn_tool_calls = 0
        self._pending_session: str | None = None
        self._turns: set[asyncio.Task] = set()
        self._turn_epoch = 0

    def restore_history(self, messages: list[dict[str, Any]]) -> None:
        if messages:
            self.agent._messages = messages

    def history(self) -> list[dict[str, Any]]:
        visible: list[dict[str, Any]] = []
        for message in self.agent._messages:
            role = message.get("role")
            content = message.get("content")
            if role not in ("user", "assistant") or not content:
                continue
            if (
                role == "user"
                and isinstance(content, str)
                and content.startswith(INTERNAL_PREFIXES)
            ):
                continue
            visible.append({"role": role, "content": content})
        return visible

    def _make_executor(self) -> Any:
        async def execute(prompt: str) -> str:
            async with self._lock:
                worker = Agent(
                    self.settings,
                    self._llm,
                    self.dispatcher,
                    self.tools,
                    lessons=self._lessons,
                    on_trace=self._trace,
                    cost_tracker=self.cost,
                    chunk_store=self.chunks,
                    system_prompt=self._compose_system_prompt(),
                )
                return await worker.respond(prompt)

        return execute

    def attach(self, ws: Any) -> None:
        self._conns.add(ws)
        self._attach_seq += 1
        self._conn_meta[ws] = {
            "seq": self._attach_seq,
            "visible": True,
            "focused": False,
        }
        self._connected.set()
        self._disconnected.clear()
        self.avatar.bind(self._avatar_request)
        self.browser.bind(self._client_request)
        if not self._started and self.channel == "main":
            self.scheduler.start()
            self._started = True
        if self._buffer:
            pending = self._buffer
            self._buffer = []
            asyncio.create_task(self._flush(pending))
        logger.info(
            "Session %s/%s attached (%d conns)",
            self.owner_kind,
            self.owner_id,
            len(self._conns),
        )

    def detach(self, ws: Any) -> None:
        self._conns.discard(ws)
        self._conn_meta.pop(ws, None)
        if not self._conns:
            self._connected.clear()
            self._disconnected.set()
            self.avatar.unbind()
            self.browser.unbind()
        logger.info(
            "Session %s/%s detached (%d conns)",
            self.owner_kind,
            self.owner_id,
            len(self._conns),
        )

    def set_visibility(self, ws: Any, visible: bool, focused: bool) -> None:
        meta = self._conn_meta.get(ws)
        if meta is not None:
            meta["visible"] = visible
            meta["focused"] = focused

    def _pick_target(self) -> Any:
        best = None
        highest_rank = None
        for ws in self._conns:
            meta = self._conn_meta.get(ws)
            if meta is None:
                continue
            rank = (meta["focused"], meta["visible"], meta["seq"])
            if highest_rank is None or rank > highest_rank:
                highest_rank = rank
                best = ws
        return best

    @property
    def connection_count(self) -> int:
        return len(self._conns)

    async def aclose(self) -> None:
        await self.scheduler.stop()
        await self.client.aclose()
        await self._llm.aclose()

    async def bootstrap_greeting(self) -> str:
        if self.channel == "docs":
            return DOCS_GREETING
        if self.client.authenticated:
            return f"Signed in as {self.username}. Devii is operating your DevPlace account. How can I help?"
        return LOGIN_REQUEST

    async def send_bootstrap(self, ws: Any) -> None:
        await self._send_to(ws, {"type": "status", "text": "Devii ready"})
        history = self.history()
        if history:
            await self._send_to(ws, {"type": "history", "messages": history})
        else:
            await self._send_to(
                ws, {"type": "reply", "text": await self.bootstrap_greeting()}
            )

    async def reset_conversation(self) -> None:
        async with self._lock:
            messages = self.agent._messages
            if messages and messages[0].get("role") == "system":
                system = messages[0]
            else:
                system = {"role": "system", "content": self._system_prompt}
            self.agent._messages = [system]
            self._buffer = []
            if self.persist_conversation:
                try:
                    self._conv.clear(self.owner_kind, self.owner_id, self.channel)
                except Exception:  # noqa: BLE001 - clearing storage must not break the socket
                    logger.exception(
                        "Failed to clear conversation for %s/%s",
                        self.owner_kind,
                        self.owner_id,
                    )
        await self._emit({"type": "clear"}, buffer=False)

    def spawn_turn(self, text: str) -> None:
        epoch = self._turn_epoch
        task = asyncio.create_task(self._run_turn(text, epoch))
        self._turns.add(task)
        task.add_done_callback(self._turns.discard)

    async def cancel_turns(self) -> None:
        self._turn_epoch += 1
        turns = [task for task in self._turns if not task.done()]
        if not turns:
            return
        for task in turns:
            task.cancel()
        try:
            await asyncio.wait_for(
                asyncio.gather(*turns, return_exceptions=True), timeout=10
            )
        except asyncio.TimeoutError:
            logger.warning(
                "cancel_turns: a turn did not unwind within 10s for %s/%s (epoch-guarded)",
                self.owner_kind,
                self.owner_id,
            )

    async def reset(self) -> None:
        await self.cancel_turns()
        await self.reset_conversation()
        await self._emit(
            {"type": "status", "text": "Reset. Conversation cleared."}, buffer=False
        )

    async def stop(self) -> None:
        await self.cancel_turns()
        async with self._lock:
            _repair_history(self.agent._messages)
            if self.persist_conversation:
                try:
                    self._conv.save(
                        self.owner_kind,
                        self.owner_id,
                        self.agent._messages,
                        self.channel,
                    )
                except Exception:  # noqa: BLE001 - persistence must not break the socket
                    logger.exception(
                        "Failed to persist history after stop for %s/%s",
                        self.owner_kind,
                        self.owner_id,
                    )
        await self._emit({"type": "status", "text": "Stopped."}, buffer=False)

    async def _run_turn(self, text: str, epoch: int) -> None:
        turn_id = uuid_utils.uuid7().hex
        started_at = _now_iso()
        before = self._cost_snapshot()
        self._turn_tool_calls = 0
        reply = ""
        error = ""
        cancelled = False
        try:
            async with self._lock:
                self._refresh_tools()
                self._refresh_system_prompt()
                if self.channel == "docs":
                    await self._docs_topic_gate(text)
                reply = await self.agent.respond(text)
            if epoch == self._turn_epoch:
                await self._emit({"type": "reply", "text": reply}, buffer=True)
        except asyncio.CancelledError:
            cancelled = True
            raise
        except Exception as exc:  # noqa: BLE001 - reported to the browser, recorded for audit
            error = str(exc)
            logger.exception("Turn failed for %s/%s", self.owner_kind, self.owner_id)
            if epoch == self._turn_epoch:
                await self._emit({"type": "error", "text": error}, buffer=True)
        finally:
            if not cancelled and epoch == self._turn_epoch:
                self._record_turn(turn_id, started_at, text, reply, error, before)
                if self.owner_kind == "user" and self.channel != "docs" and not error:
                    from devplacepy.utils import track_action

                    track_action(self.owner_id, "devii")

    def _builtin_tools(self) -> list[dict[str, Any]]:
        schemas = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
        if self.channel == "docs":
            return [
                s
                for s in schemas
                if s.get("function", {}).get("name") in DOCS_TOOLS
            ]
        return schemas

    def _refresh_tools(self) -> None:
        if self.channel == "docs":
            self.tools[:] = self._builtin_tools()
            return
        virtual = self._virtual_tool_store.tool_schemas()
        self.tools[:] = self._builtin_tools() + virtual

    def _compose_system_prompt(self) -> str:
        if self.channel == "docs":
            return self._system_prompt
        body = self._behavior_store.text().strip()
        section = BEHAVIOR_HEADER if not body else f"{BEHAVIOR_HEADER}\n{body}"
        return f"{self._system_prompt}\n\n{section}"

    def _refresh_system_prompt(self) -> None:
        messages = self.agent._messages
        if messages and messages[0].get("role") == "system":
            messages[0]["content"] = self._compose_system_prompt()

    async def _docs_topic_gate(self, text: str) -> None:
        prior = [
            m
            for m in self.agent._messages
            if m.get("role") in ("user", "assistant") and m.get("content")
        ]
        if not prior:
            return
        decision, reason = await self._classify_topic(prior, text)
        if decision == "new":
            messages = self.agent._messages
            if messages and messages[0].get("role") == "system":
                system = messages[0]
            else:
                system = {"role": "system", "content": self._compose_system_prompt()}
            self.agent._messages = [system]
            self._buffer = []
            if self.persist_conversation:
                try:
                    self._conv.clear(self.owner_kind, self.owner_id, self.channel)
                except Exception:  # noqa: BLE001 - clearing storage must not break the turn
                    logger.exception(
                        "Failed to clear docs conversation for %s/%s",
                        self.owner_kind,
                        self.owner_id,
                    )
        await self._emit(
            {"type": "topic", "decision": decision, "reason": reason}, buffer=False
        )

    async def _classify_topic(
        self, prior: list[dict[str, Any]], text: str
    ) -> tuple[str, str]:
        recent = prior[-6:]
        convo = "\n".join(
            f"{m['role']}: {str(m['content'])[:300]}" for m in recent
        )
        messages = [
            {"role": "system", "content": TOPIC_CLASSIFIER_PROMPT},
            {
                "role": "user",
                "content": f"Prior conversation:\n{convo}\n\nNew message:\n{text}\n\nClassify.",
            },
        ]
        try:
            raw = await self._llm.complete_text(messages)
        except Exception:  # noqa: BLE001 - on any failure, keep context (treat as follow-up)
            return "follow_up", ""
        return _parse_topic(raw)

    def _quota_snapshot(self) -> dict[str, Any]:
        spent = self._ledger.spent_24h(self.owner_kind, self.owner_id)
        turns = self._ledger.turns_24h(self.owner_kind, self.owner_id)
        limit = self.settings.daily_limit_usd
        used_pct = round(min(100.0, spent / limit * 100), 1) if limit > 0 else 0.0
        return {
            "used_pct": used_pct,
            "turns_today": turns,
            "limit_reached": limit > 0 and spent >= limit,
        }

    def _cost_snapshot(self) -> dict[str, Any]:
        return {
            "requests": self.cost.requests,
            "prompt_tokens": self.cost.prompt_tokens,
            "completion_tokens": self.cost.completion_tokens,
            "cache_hit_tokens": self.cost.cache_hit_tokens,
            "cache_miss_tokens": self.cost.cache_miss_tokens,
            "cost_usd": self.cost.cost_usd()["total"],
        }

    def _record_turn(self, turn_id, started_at, prompt, reply, error, before) -> None:
        after = self._cost_snapshot()
        usage = {
            "prompt_tokens": after["prompt_tokens"] - before["prompt_tokens"],
            "completion_tokens": after["completion_tokens"]
            - before["completion_tokens"],
            "cache_hit_tokens": after["cache_hit_tokens"] - before["cache_hit_tokens"],
            "cache_miss_tokens": after["cache_miss_tokens"]
            - before["cache_miss_tokens"],
        }
        cost_delta = round(after["cost_usd"] - before["cost_usd"], 8)
        try:
            if self.persist_conversation:
                self._conv.save(
                    self.owner_kind,
                    self.owner_id,
                    self.agent._messages,
                    self.channel,
                )
            self._ledger.record(
                self.owner_kind,
                self.owner_id,
                turn_id,
                usage,
                cost_delta,
                self.settings.ai_model,
            )
            self._audit.record(
                {
                    "turn_id": turn_id,
                    "owner_kind": self.owner_kind,
                    "owner_id": self.owner_id,
                    "username": self.username,
                    "started_at": started_at,
                    "ended_at": _now_iso(),
                    "prompt": prompt,
                    "reply": reply,
                    "iterations": after["requests"] - before["requests"],
                    "tool_calls": self._turn_tool_calls,
                    "cost_usd": cost_delta,
                    "error": error,
                }
            )
            from devplacepy.services.audit import record as audit

            audit.record_system(
                "devii.turn",
                actor_kind="user" if self.owner_kind == "user" else self.owner_kind,
                actor_uid=self.owner_id if self.owner_kind == "user" else None,
                actor_username=self.username,
                actor_role="user" if self.owner_kind == "user" else self.owner_kind,
                origin="devii",
                via_agent=1,
                result="failure" if error else "success",
                summary=f"Devii processed a turn for {self.username or self.owner_id}",
                metadata={
                    "iterations": after["requests"] - before["requests"],
                    "tool_calls": self._turn_tool_calls,
                    "cost_usd": cost_delta,
                    "prompt_tokens": usage["prompt_tokens"],
                    "completion_tokens": usage["completion_tokens"],
                },
            )
        except Exception:  # noqa: BLE001 - persistence must never break a turn
            logger.exception(
                "Failed to persist turn for %s/%s", self.owner_kind, self.owner_id
            )

    def resolve_query(self, query_id: str, payload: Any) -> None:
        future = self._pending.pop(query_id, None)
        if future is not None and not future.done():
            future.set_result(payload)

    async def _flush(self, frames: list[dict[str, Any]]) -> None:
        for frame in frames:
            await self._emit(frame, buffer=True)

    def _trace(self, event: str, name: str = "", detail: str = "") -> None:
        if event == "call":
            self._turn_tool_calls += 1
        elif event == "ok":
            self._sync_auth(name)
        payload = {"type": "trace", "event": event, "name": name, "detail": detail}
        asyncio.create_task(self._emit(payload, buffer=False))

    def _sync_auth(self, name: str) -> None:
        # When the agent changes who is logged in, propagate it to the browser so the terminal and
        # the browser share one session. Login mints a real platform session in the client's cookie
        # jar; hand that token to the browser via /devii/adopt. Logout reuses /auth/logout.
        if name in ("login", "signup"):
            token = self.client.session_cookie()
            if token:
                self._pending_session = token
                asyncio.create_task(
                    self._emit({"type": "auth", "action": "adopt"}, buffer=False)
                )
        elif name == "logout":
            self._pending_session = None
            asyncio.create_task(
                self._emit({"type": "auth", "action": "logout"}, buffer=False)
            )

    def take_pending_session(self) -> str | None:
        token = self._pending_session
        self._pending_session = None
        return token

    async def _send_to(self, ws: Any, payload: dict[str, Any]) -> None:
        async with self._send_lock:
            await ws.send_json(payload)

    async def _emit(self, payload: dict[str, Any], buffer: bool = True) -> None:
        delivered = False
        for ws in list(self._conns):
            try:
                async with self._send_lock:
                    await ws.send_json(payload)
                delivered = True
            except Exception:  # noqa: BLE001 - drop dead sockets, keep serving the rest
                self._conns.discard(ws)
        if not delivered and buffer and payload.get("type") in BUFFERED_TYPES:
            self._buffer.append(payload)
            if len(self._buffer) > 100:
                self._buffer = self._buffer[-100:]

    async def _avatar_request(self, action: str, args: dict[str, Any]) -> Any:
        return await self._browser_request("avatar", action, args)

    async def _client_request(self, action: str, args: dict[str, Any]) -> Any:
        return await self._browser_request("client", action, args)

    async def _browser_request(
        self, channel: str, action: str, args: dict[str, Any]
    ) -> Any:
        loop = asyncio.get_event_loop()
        deadline = loop.time() + BROWSER_REQUEST_DEADLINE_SECONDS
        self._query_seq += 1
        request_id = f"{self.owner_id}:{self._query_seq}"
        future: asyncio.Future = loop.create_future()
        self._pending[request_id] = future
        frame = {"type": channel, "id": request_id, "action": action, "args": args}
        try:
            while not future.done():
                remaining = deadline - loop.time()
                if remaining <= 0:
                    raise RuntimeError(f"Browser did not respond to '{action}' in time")
                try:
                    await asyncio.wait_for(self._connected.wait(), timeout=remaining)
                except asyncio.TimeoutError as exc:
                    raise RuntimeError(
                        f"No browser connected to handle '{action}'"
                    ) from exc
                ws = self._pick_target()
                if ws is None:
                    continue
                try:
                    await self._send_to(ws, frame)
                except Exception:  # noqa: BLE001 - socket dying mid-send; retry on reconnect
                    await asyncio.sleep(0.1)
                    continue
                drop = asyncio.create_task(self._disconnected.wait())
                try:
                    await asyncio.wait(
                        {future, drop},
                        timeout=max(0.0, deadline - loop.time()),
                        return_when=asyncio.FIRST_COMPLETED,
                    )
                finally:
                    drop.cancel()
                # future done -> loop exits and returns; disconnected -> re-send after
                # reconnect; otherwise the deadline check at the top raises.
            return future.result()
        finally:
            self._pending.pop(request_id, None)

    def _task_event(self, kind: str, row: dict[str, Any], payload: str) -> None:
        label = row.get("label") or row.get("uid", "task")
        message = {
            "type": "task",
            "kind": kind,
            "label": label,
            "payload": payload[:RESULT_NOTICE_CHARS],
        }
        asyncio.create_task(self._emit(message, buffer=True))


NON_ADMIN_COST_RULE = (
    "\n\nThe current user is NOT an administrator. Never reveal any monetary cost, dollar "
    "amount, pricing, or spend figure to them under any circumstance. When asked about "
    "usage or resources, call usage_quota and report only the percentage of their 24h "
    "quota used and the turn count."
)

DOCS_TOOLS = frozenset({"search_docs"})

TOPIC_CLASSIFIER_PROMPT = (
    "You are a routing classifier for a documentation assistant. Decide whether the "
    "user's NEW message continues the PRIOR conversation (a follow-up: a clarification, "
    "a refinement, or another question about the same subject) or starts a NEW, "
    "unrelated topic that should begin from a clean slate.\n"
    "Respond with ONLY a JSON object and nothing else: "
    '{"decision": "follow_up" or "new", "reason": "<one short sentence explaining why>"}.'
)

DOCS_GREETING = (
    "Hi, I am Docii, the DevPlace documentation assistant. Ask me anything about "
    "DevPlace and I will search the documentation to find your answer."
)

DOCS_SYSTEM_PROMPT = (
    "You are Docii, the documentation assistant for DevPlace, a social network for "
    "software developers. You answer questions about DevPlace using ONLY its official "
    "documentation.\n\n"
    "YOU HAVE EXACTLY ONE TOOL: search_docs(query, max_results). It performs a keyword "
    "search over the documentation and returns the matching pages. Each result is an object "
    'with "title", "url" (e.g. /docs/authentication.html), "score" (BM25 relevance, higher '
    'means more relevant), and "content" (the page text).\n\n'
    "HARD RULES - follow every one, every turn:\n"
    "1. Ground every CLAIM ABOUT DEVPLACE (routes, endpoints, parameters, auth methods, "
    "settings, behavior, limits) in content returned by search_docs in THIS turn. Never state "
    "a platform fact from memory or assumption.\n"
    "2. For EVERY question, your FIRST action is to call search_docs with focused keywords "
    "drawn from the question (feature names, endpoint paths, nouns).\n"
    "3. READ the content in the results. If they do not fully and confidently answer the "
    "question, call search_docs AGAIN with different or more specific keywords (synonyms, "
    "related terms, the exact feature or route). Keep searching recursively - visiting the "
    "results, refining the query, searching again - until you have gathered enough grounded "
    "information to answer. Prefer several targeted searches over one broad search.\n"
    "4. Do not invent routes, endpoints, parameters, settings, or behavior that the docs did "
    "not state. The platform-specific facts in your answer must come from the docs.\n"
    "5. If, after several distinct searches, the documentation genuinely does not cover a "
    "needed FACT, say which part is undocumented - but still help as far as the docs allow "
    "(for example, write the code using the endpoints you DID find).\n"
    "6. Your only tool is search_docs and you perform no platform actions (no posting, "
    "account changes, file edits, or web browsing). Writing code and examples in your reply "
    "is allowed and encouraged.\n\n"
    "WRITING CODE - when the user asks for a script, code example, API client, bot, or "
    "snippet in ANY language, WRITE complete, runnable example code. Do NOT refuse merely "
    "because the literal source is not published in the docs - synthesize it from the "
    "documented API: take the base URL, endpoints, HTTP methods, parameters and "
    "authentication from your search_docs results, and write all the ordinary programming "
    "scaffolding (imports, language syntax, error handling, a main loop, comments) yourself. "
    "Put it in a fenced code block with a language tag. Never invent endpoints or parameters "
    "the docs do not describe; if a detail you need is missing, search for it first.\n\n"
    "LINKING - this is mandatory:\n"
    "- ACTIVE INLINE LINKING: whenever you mention a documented page, feature, endpoint, or "
    "concept in your prose, write it as a markdown link to that page's `url`, for example "
    "[authentication](/docs/authentication.html). Use the exact `url` from the search "
    "results. Never paste a bare URL - always a markdown link with descriptive text.\n"
    "- ALWAYS end every answer with a '## References' section: a markdown bullet list of the "
    "documentation pages you actually used to answer, each as a clickable markdown link "
    "followed by its relevance score, highest score first, e.g. "
    "`- [Authentication](/docs/authentication.html) - score 12.3`. Include only pages you "
    "used; do not invent links or scores.\n\n"
    "STYLE: concise, practical, technical. Use markdown."
)


def _parse_topic(raw: str) -> tuple[str, str]:
    import json
    import re

    match = re.search(r"\{.*\}", raw or "", re.S)
    if not match:
        return "follow_up", ""
    try:
        data = json.loads(match.group())
    except (ValueError, TypeError):
        return "follow_up", ""
    decision = (
        "new" if str(data.get("decision", "")).strip().lower() == "new" else "follow_up"
    )
    reason = str(data.get("reason", "")).strip()[:200]
    return decision, reason


def _system_prompt_for(is_admin: bool) -> str:
    if is_admin:
        return SYSTEM_PROMPT
    return SYSTEM_PROMPT + NON_ADMIN_COST_RULE


def _now_iso() -> str:
    from datetime import datetime, timezone

    return datetime.now(timezone.utc).isoformat()
