# ClawTex — Governed Agent with Genetic Memory
# Built by DeVere Cooley / AN2B LLC · an2b.com
# BB4C — Breath Before Code

"""
ClawTex Agent — the core runtime.

Loop (per message):
  1. receive message
  2. recall memories from MandelDB   (memory before action)
  3. build prompt (SOUL.md + memories + skills context + message)
  4. call LLM  (Anthropic or OpenAI, based on LLM_PROVIDER)
  5. for each tool call:
       breathe()  →  Warden checks policy  →  ALLOW / DENY / REVIEW
       execute or block accordingly
  6. ingest exchange to MandelDB     (memory after action)
  7. return final response

The method is named breathe() because governance isn't a gate —
it's a pause, a breath, a moment of intentionality before every action.
BB4C: Breath Before Code.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
from pathlib import Path
from typing import Any

from .channels.base import InboundMessage
from .governance.warden import Decision, Warden
from .memory.context import MemoryContext
from .memory.mandeldb import MandelDBClient
from .memory.startup import on_start as brain_on_start
from .skills.loader import SkillLoader
from .tools.registry import ToolDefinition, registry as tool_registry

logger = logging.getLogger(__name__)

_SOUL_PATH = os.environ.get("CLAWTEX_SOUL", "./SOUL.md")
_LLM_PROVIDER = os.environ.get("LLM_PROVIDER", "anthropic").lower()
_CLAWTEX_NAME = os.environ.get("CLAWTEX_NAME", "ClawTex")


def _load_soul() -> str:
    path = Path(_SOUL_PATH)
    if path.exists():
        return path.read_text(encoding="utf-8").strip()
    return (
        f"You are {_CLAWTEX_NAME}, a governed AI agent with persistent memory. "
        "You act with intention, governed by policy, and remember what matters."
    )


class BlockedByWarden(Exception):
    """Raised when the Warden denies a tool call."""
    def __init__(self, action_type: str, tool_name: str) -> None:
        self.action_type = action_type
        self.tool_name = tool_name
        super().__init__(
            f"Warden DENIED tool '{tool_name}' (action_type={action_type})"
        )


class ClawTexAgent:
    """
    The ClawTex agent runtime.

    All tool executions pass through breathe() — the Warden governance check.
    Memory is recalled before every response and ingested after every exchange.
    """

    def __init__(
        self,
        name: str | None = None,
        warden: Warden | None = None,
        memory: MemoryContext | None = None,
        skills_dir: str | None = None,
    ) -> None:
        self.name = name or _CLAWTEX_NAME
        self.warden = warden or Warden()
        self._memory_client = MandelDBClient()
        self.memory = memory or MemoryContext(
            client=self._memory_client, namespace=self.name.lower().replace(" ", "_")
        )
        self._skill_loader = SkillLoader(skills_dir or os.environ.get("CLAWTEX_SKILLS_DIR", "./skills"))
        self._skills = self._skill_loader.load_all()
        self._soul = _load_soul()
        self._startup_context: str = ""  # populated by start()
        self._dream_task: asyncio.Task | None = None  # type: ignore[type-arg]

        logger.info(
            "ClawTex agent '%s' initialised — provider=%s, skills=%d, tools=%d",
            self.name,
            _LLM_PROVIDER,
            len(self._skills),
            len(tool_registry.all()),
        )

    # ------------------------------------------------------------------
    # 🧠 start() — Brain reconnect on session startup
    # ------------------------------------------------------------------

    async def start(self) -> None:
        """
        Initialize the agent's brain — pull recent memories from MandelDB.

        Call this once after construction (or after a blackout) to restore
        context from the last session. The recalled memories are injected
        into every subsequent system prompt automatically.

        Fail-open: if MandelDB is unreachable, the agent continues without
        startup context (degraded but functional).

        BB4C: memory before action. Always.
        """
        logger.info("Brain startup — recalling recent context for '%s'…", self.name)
        try:
            self._startup_context = await brain_on_start(
                client=self._memory_client,
                namespace=self.name.lower().replace(" ", "_"),
            )
            if self._startup_context:
                logger.info("Brain reconnect successful — context injected into system prompt.")
            else:
                logger.info("Brain reconnect: no prior memories found (clean slate).")
        except Exception as exc:  # noqa: BLE001
            logger.warning("Brain startup failed (fail-open, continuing): %s", exc)
            self._startup_context = ""

    # ------------------------------------------------------------------
    # 🫁 breathe() — Warden governance gate
    # ------------------------------------------------------------------

    async def breathe(
        self,
        tool: ToolDefinition,
        tool_input: dict[str, Any],
    ) -> Decision:
        """
        Pause and consult the Warden before executing any tool call.

        This is the philosophical core of ClawTex: every action is preceded
        by a breath — a moment of intentional governance.  BB4C.

        Returns the Warden's decision: "ALLOW", "DENY", or "REVIEW".
        Raises BlockedByWarden if the decision is DENY.
        """
        context = {
            "tool": tool.name,
            "input": tool_input,
            "agent": self.name,
        }
        decision = await self.warden.check_async(tool.action_type, context)

        if decision == "DENY":
            logger.warning(
                "[breathe] DENIED — tool=%s action_type=%s", tool.name, tool.action_type
            )
            raise BlockedByWarden(tool.action_type, tool.name)

        if decision == "REVIEW":
            # No callback registered — treat REVIEW as a soft block.
            # In enforce mode without a callback, REVIEW blocks execution.
            mode = self.warden.mode
            if mode == "enforce":
                logger.warning(
                    "[breathe] REVIEW (no callback, enforce mode) — blocking tool=%s",
                    tool.name,
                )
                raise BlockedByWarden(tool.action_type, tool.name)
            # In audit mode, log and continue
            logger.info(
                "[breathe] REVIEW (audit mode) — allowing tool=%s", tool.name
            )

        return decision

    # ------------------------------------------------------------------
    # Main handle loop
    # ------------------------------------------------------------------

    async def handle_message(self, message: InboundMessage) -> str:
        """
        Full agent loop for a single inbound message.

        1. recall  2. build prompt  3. LLM  4. breathe+execute tools
        5. ingest  6. return reply
        """
        user_text = message.text

        # Step 1 — recall relevant memories
        memory_context = await self.memory.build(user_text)

        # Step 2 — build system prompt
        system_prompt = self._build_system_prompt(memory_context)

        # Step 3 — call LLM (with tool-use loop)
        try:
            reply = await self._llm_loop(system_prompt, user_text)
        except Exception as exc:  # noqa: BLE001
            logger.error("LLM error: %s", exc)
            reply = f"I encountered an error processing your message: {exc}"

        # Step 4 — ingest exchange to memory
        await self.memory.record_exchange(
            user_message=user_text,
            assistant_reply=reply,
            extra_metadata={
                "channel": message.channel,
                "user_id": message.user_id,
            },
        )

        return reply

    # ------------------------------------------------------------------
    # LLM dispatch — Anthropic or OpenAI
    # ------------------------------------------------------------------

    async def _llm_loop(self, system_prompt: str, user_text: str) -> str:
        """Dispatch to the configured LLM provider and handle tool-use loops."""
        if _LLM_PROVIDER == "anthropic":
            return await self._anthropic_loop(system_prompt, user_text)
        elif _LLM_PROVIDER in ("openai", "openai-compatible"):
            return await self._openai_loop(system_prompt, user_text)
        else:
            raise ValueError(f"Unknown LLM_PROVIDER: {_LLM_PROVIDER!r}")

    async def _anthropic_loop(self, system_prompt: str, user_text: str) -> str:
        try:
            import anthropic
        except ImportError as exc:
            raise ImportError("anthropic package not installed. pip install anthropic") from exc

        client = anthropic.AsyncAnthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
        model = os.environ.get("ANTHROPIC_MODEL", "claude-3-5-sonnet-20241022")
        tools_schema = tool_registry.to_anthropic_schema()
        messages: list[dict[str, Any]] = [{"role": "user", "content": user_text}]

        while True:
            response = await client.messages.create(
                model=model,
                max_tokens=4096,
                system=system_prompt,
                tools=tools_schema if tools_schema else anthropic.NOT_GIVEN,
                messages=messages,
            )

            # Collect text from the response
            text_parts = [
                block.text for block in response.content if hasattr(block, "text")
            ]

            if response.stop_reason == "tool_use":
                # Append assistant message with the full content
                messages.append({"role": "assistant", "content": response.content})

                # Execute tool calls (each gated by breathe())
                tool_results = []
                for block in response.content:
                    if block.type != "tool_use":
                        continue
                    result = await self._execute_tool(block.name, block.input)
                    tool_results.append(
                        {
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": json.dumps(result),
                        }
                    )

                messages.append({"role": "user", "content": tool_results})
                continue  # loop for follow-up response

            # stop_reason == "end_turn" or similar
            return "\n".join(text_parts).strip() or "(no response)"

    async def _openai_loop(self, system_prompt: str, user_text: str) -> str:
        try:
            from openai import AsyncOpenAI
        except ImportError as exc:
            raise ImportError("openai package not installed. pip install openai") from exc

        client = AsyncOpenAI(
            api_key=os.environ.get("OPENAI_API_KEY", ""),
            base_url=os.environ.get("OPENAI_BASE_URL"),  # supports compatible APIs
        )
        model = os.environ.get("OPENAI_MODEL", "gpt-4o")
        tools_schema = tool_registry.to_openai_schema()
        messages: list[dict[str, Any]] = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_text},
        ]

        while True:
            kwargs: dict[str, Any] = {
                "model": model,
                "messages": messages,
            }
            if tools_schema:
                kwargs["tools"] = tools_schema

            response = await client.chat.completions.create(**kwargs)
            msg = response.choices[0].message

            if msg.tool_calls:
                messages.append(msg)  # assistant message with tool_calls
                for tc in msg.tool_calls:
                    tool_input = json.loads(tc.function.arguments)
                    result = await self._execute_tool(tc.function.name, tool_input)
                    messages.append(
                        {
                            "role": "tool",
                            "tool_call_id": tc.id,
                            "content": json.dumps(result),
                        }
                    )
                continue  # loop for follow-up response

            return (msg.content or "").strip() or "(no response)"

    # ------------------------------------------------------------------
    # Tool execution — always gated by breathe()
    # ------------------------------------------------------------------

    async def _execute_tool(
        self, tool_name: str, tool_input: dict[str, Any]
    ) -> dict[str, Any]:
        """
        Look up the tool, call breathe() for governance, then execute.

        Returns the tool's result dict, or an error dict if blocked/failed.
        """
        tool = tool_registry.get(tool_name)
        if tool is None:
            logger.warning("Unknown tool requested: %s", tool_name)
            return {"error": f"Unknown tool: {tool_name}"}

        try:
            decision = await self.breathe(tool, tool_input)
        except BlockedByWarden as exc:
            logger.info("Tool blocked by Warden: %s", exc)
            return {
                "error": f"Action blocked by governance policy (action_type={exc.action_type})",
                "blocked": True,
            }

        logger.debug("breathe() → %s for tool=%s", decision, tool_name)

        try:
            result = await tool.fn(**tool_input)
            return result if isinstance(result, dict) else {"result": result}
        except Exception as exc:  # noqa: BLE001
            logger.error("Tool execution error (%s): %s", tool_name, exc)
            return {"error": str(exc)}

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _build_system_prompt(self, memory_context: str) -> str:
        parts = [self._soul]
        # Inject startup context (blackout recovery) first — broadest context
        if self._startup_context:
            parts.append(self._startup_context)
        # Then per-message recall — most relevant to current query
        if memory_context:
            parts.append(memory_context)
        skills_block = self._skill_loader.to_context_block()
        if skills_block:
            parts.append(skills_block)
        parts.append(
            "## Governance\n"
            "Every tool call you make is reviewed by the Warden governance layer. "
            "Destructive or outbound actions may be blocked or queued for human approval. "
            "Act with intention. BB4C — Breath Before Code."
        )
        return "\n\n".join(parts)

    # ------------------------------------------------------------------
    # Dream scheduler — periodic memory consolidation
    # ------------------------------------------------------------------

    async def start_dream_scheduler(self) -> None:
        """
        Periodically trigger MandelDB memory consolidation (dreaming).
        Interval is controlled by DREAM_INTERVAL_HOURS (default 6h).
        """
        interval_hours = float(os.environ.get("DREAM_INTERVAL_HOURS", "6"))
        interval_secs = interval_hours * 3600

        async def _dream_loop() -> None:
            while True:
                await asyncio.sleep(interval_secs)
                try:
                    logger.info("Triggering MandelDB dream for namespace=%s…", self.memory.namespace)
                    result = await self.memory.trigger_dream()
                    logger.info("Dream complete: %s", result)
                except Exception as exc:  # noqa: BLE001
                    logger.warning("Dream failed (non-fatal): %s", exc)

        self._dream_task = asyncio.create_task(_dream_loop())
        logger.info("Dream scheduler started (interval=%.1fh)", interval_hours)

    async def close(self) -> None:
        if self._dream_task:
            self._dream_task.cancel()
        await self._memory_client.close()
