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

"""
Warden — governance layer for ClawTex.

Every tool call passes through Warden.check() before execution.
Decisions: ALLOW | DENY | REVIEW

Policy is loaded from a YAML file (default: ./clawtex/governance/policies/default.yaml).
The policy file path is resolved from the WARDEN_POLICY env var or the constructor argument.

WARDEN_MODE:
  enforce  — DENY blocks execution, REVIEW pauses for approval callback (default)
  audit    — log decisions but always allow (useful for testing)
  strict   — treat REVIEW the same as DENY (no human-in-the-loop)
"""

from __future__ import annotations

import fnmatch
import logging
import os
from pathlib import Path
from typing import Any, Callable, Coroutine, Literal

import yaml

logger = logging.getLogger(__name__)

Decision = Literal["ALLOW", "DENY", "REVIEW"]
Mode = Literal["enforce", "audit", "strict"]

# Type alias for an optional async approval callback
ApprovalCallback = Callable[[str, dict[str, Any]], Coroutine[Any, Any, bool]]


class PolicyRule:
    """A single action→decision rule, supporting glob patterns."""

    def __init__(self, action: str, decision: Decision) -> None:
        self.action = action
        self.decision = decision

    def matches(self, action_type: str) -> bool:
        return fnmatch.fnmatch(action_type, self.action)


class Policy:
    """Parsed YAML policy — a list of rules plus a default decision."""

    def __init__(self, path: str | Path) -> None:
        path = Path(path)
        if not path.exists():
            raise FileNotFoundError(f"Warden policy file not found: {path}")
        with path.open() as fh:
            raw = yaml.safe_load(fh)

        self.version: int = raw.get("version", 1)
        self.default: Decision = raw.get("default", "ALLOW").upper()  # type: ignore[assignment]
        self.rules: list[PolicyRule] = [
            PolicyRule(r["action"], r["decision"].upper())  # type: ignore[arg-type]
            for r in raw.get("rules", [])
        ]

    def evaluate(self, action_type: str) -> Decision:
        """Return the first matching rule's decision, or the default."""
        for rule in self.rules:
            if rule.matches(action_type):
                return rule.decision  # type: ignore[return-value]
        return self.default  # type: ignore[return-value]


class Warden:
    """
    Governance engine — loads a policy and enforces it on tool calls.

    Usage:
        warden = Warden()
        decision = warden.check("file.delete", {"path": "/etc/passwd"})
        # → "DENY"
    """

    def __init__(
        self,
        policy_path: str | Path | None = None,
        mode: Mode | None = None,
        approval_callback: ApprovalCallback | None = None,
    ) -> None:
        resolved_path = policy_path or os.environ.get(
            "WARDEN_POLICY",
            Path(__file__).parent / "policies" / "default.yaml",
        )
        self.policy = Policy(resolved_path)
        self.mode: Mode = (
            mode or os.environ.get("WARDEN_MODE", "enforce")
        ).lower()  # type: ignore[assignment]
        self._approval_callback = approval_callback
        self._log: list[dict[str, Any]] = []

        logger.info(
            "Warden initialised — policy v%d, mode=%s, default=%s, rules=%d",
            self.policy.version,
            self.mode,
            self.policy.default,
            len(self.policy.rules),
        )

    # ------------------------------------------------------------------
    # Core check — synchronous path
    # ------------------------------------------------------------------

    def check(self, action_type: str, context: dict[str, Any] | None = None) -> Decision:
        """
        Evaluate *action_type* against the loaded policy.

        Returns "ALLOW", "DENY", or "REVIEW".
        DENY and REVIEW decisions are always logged.

        In *audit* mode all decisions return as-evaluated but execution is never
        blocked — the caller is responsible for honouring the decision.
        """
        context = context or {}
        decision = self.policy.evaluate(action_type)

        if self.mode == "audit":
            # Log but never block in audit mode
            if decision != "ALLOW":
                self._record(action_type, decision, context)
                logger.info(
                    "[WARDEN/audit] %s → %s (would block in enforce mode)",
                    action_type,
                    decision,
                )
            return decision

        if self.mode == "strict":
            # Treat REVIEW the same as DENY
            if decision == "REVIEW":
                decision = "DENY"

        if decision in ("DENY", "REVIEW"):
            self._record(action_type, decision, context)
            if decision == "DENY":
                logger.warning(
                    "[WARDEN/DENY] action=%s context=%s", action_type, context
                )
            else:
                logger.warning(
                    "[WARDEN/REVIEW] action=%s context=%s (awaiting approval)",
                    action_type,
                    context,
                )

        return decision

    # ------------------------------------------------------------------
    # Async check — supports approval callbacks for REVIEW
    # ------------------------------------------------------------------

    async def check_async(
        self,
        action_type: str,
        context: dict[str, Any] | None = None,
    ) -> Decision:
        """
        Async variant of check().

        If the decision is REVIEW and an approval_callback was registered,
        the callback is awaited.  If it returns True the final decision is
        ALLOW; otherwise DENY.
        """
        decision = self.check(action_type, context)

        if decision == "REVIEW" and self._approval_callback is not None:
            approved = await self._approval_callback(action_type, context or {})
            if approved:
                logger.info(
                    "[WARDEN/REVIEW] action=%s approved by callback", action_type
                )
                return "ALLOW"
            else:
                logger.warning(
                    "[WARDEN/REVIEW] action=%s denied by callback", action_type
                )
                return "DENY"

        return decision

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

    def _record(
        self, action_type: str, decision: Decision, context: dict[str, Any]
    ) -> None:
        import datetime

        entry = {
            "timestamp": datetime.datetime.utcnow().isoformat() + "Z",
            "action": action_type,
            "decision": decision,
            "context": context,
        }
        self._log.append(entry)

    @property
    def audit_log(self) -> list[dict[str, Any]]:
        """Return all recorded DENY/REVIEW decisions."""
        return list(self._log)

    def reload_policy(self, path: str | Path | None = None) -> None:
        """Hot-reload the policy file (useful for config changes without restart)."""
        resolved = path or os.environ.get(
            "WARDEN_POLICY",
            Path(__file__).parent / "policies" / "default.yaml",
        )
        self.policy = Policy(resolved)
        logger.info("Warden policy reloaded from %s", resolved)
