"""Tool lifecycle runtime for CleverAgents v3.

Implements the four-stage tool lifecycle:

```
discover -> activate -> execute -> deactivate
```

## Architecture

```
ToolRuntime
  |-- ToolLifecycleCache     (per-plan activation reuse)
  |-- ToolInstance (Protocol) (concrete tool implementations)
  |-- ToolExecutionContext    (plan metadata + resources + cancellation)
  +-- schema_validator        (JSON Schema input/output validation)
```

## Key Classes

- ``ToolDescriptor`` -- lightweight metadata returned by ``discover()``
- ``ToolInstance`` -- Protocol for concrete tool implementations
- ``ToolRuntime`` -- orchestrates lifecycle, caching, capability enforcement,
  JSON Schema validation, tracing, and cancellation propagation
- ``ToolLifecycleCache`` -- per-plan activation cache (thread-safe via ``RLock``)
- ``ToolResult`` -- structured execution result

## Capability Enforcement

| Constraint | Condition | Error |
|------------|-----------|-------|
| Read-only plan | ``writes=True`` | ``ToolAccessDeniedError`` |
| Checkpoint required | ``checkpointable=False`` | ``ToolCheckpointRequiredError`` |
| Unsafe tool blocked | ``unsafe=True`` + forbids | ``ToolSafetyViolationError`` |
| Skill category denied | category not in allow-list | ``ToolSafetyViolationError`` |
| Sandbox required | ``writes=True`` + no sandbox | ``ToolSandboxRequiredError`` |
| Human approval req. | not approved | ``ToolHumanApprovalRequiredError`` |
| Cost limit exceeded | accumulated cost >= limit | ``ToolCostLimitExceededError`` |
| Retry limit exceeded | retries > max | ``ToolRetryLimitExceededError`` |

## Error Hierarchy

```
ToolRuntimeError
  |-- ToolAccessDeniedError
  |-- ToolCheckpointRequiredError
  |-- ToolSafetyViolationError
  |-- ToolSandboxRequiredError
  |-- ToolHumanApprovalRequiredError
  |-- ToolCostLimitExceededError
  |-- ToolRetryLimitExceededError
  |-- ToolNotActivatedError
  |-- ToolActivationError
  |-- ToolExecutionError
  +-- ToolDeactivationError

ToolCancelledError        (standalone, not ToolRuntimeError)
ToolSchemaValidationError (standalone)
```

The runtime maintains a per-plan activation cache so that tools are
activated at most once per plan and guaranteed to be deactivated when
the plan completes or is cancelled.

Based on ``docs/specification.md`` Tool Interface and Architecture section.
"""

from __future__ import annotations

import logging
import threading
import time
from collections import OrderedDict
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

from pydantic import BaseModel, ConfigDict, Field

from cleveragents.domain.models.core.tool import Tool, ToolCapability
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType
from cleveragents.tool.context import (
    Change,
    ToolCancelledError,
    ToolExecutionContext,
    ToolExecutionTrace,
)
from cleveragents.tool.schema_validator import (
    ToolSchemaValidationError,
    validate_tool_input,
    validate_tool_output,
)

if TYPE_CHECKING:
    from cleveragents.infrastructure.events.protocol import EventBus

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------


class ToolRuntimeError(Exception):
    """Base error for tool runtime failures."""


class ToolAccessDeniedError(ToolRuntimeError):
    """Raised when a tool is invoked in violation of access constraints.

    For example, a tool with ``writes=True`` invoked from a read-only plan.
    """


class ToolCheckpointRequiredError(ToolRuntimeError):
    """Raised when a plan requires checkpoints but the tool is not checkpointable."""


class ToolNotActivatedError(ToolRuntimeError):
    """Raised when execute is called on a tool that has not been activated."""


class ToolActivationError(ToolRuntimeError):
    """Raised when tool activation fails."""


class ToolExecutionError(ToolRuntimeError):
    """Raised when tool execution fails."""


class ToolSafetyViolationError(ToolRuntimeError):
    """Raised when a tool is invoked in violation of safety profile constraints.

    For example, an unsafe tool invoked when the safety profile has
    ``allow_unsafe_tools=False``, or a tool whose skill category is not
    in the profile's ``allowed_skill_categories``.
    """


class ToolSandboxRequiredError(ToolRuntimeError):
    """Raised when sandbox isolation is required but not available.

    When the safety profile has ``require_sandbox=True`` and a tool with
    ``writes=True`` is invoked without a ``sandbox_id`` on the execution
    context, this error is raised to prevent unprotected writes.
    """


class ToolHumanApprovalRequiredError(ToolRuntimeError):
    """Raised when human approval is required but not granted.

    When the safety profile has ``require_human_approval=True`` and the
    execution context does not carry ``human_approved=True`` in its
    metadata, this error is raised to block execution until approval.
    """


class ToolCostLimitExceededError(ToolRuntimeError):
    """Raised when a cost limit from the safety profile is exceeded.

    Covers both ``max_cost_per_plan`` (per-plan accumulated cost) and
    ``max_total_cost`` (cross-plan accumulated cost).
    """


class ToolRetryLimitExceededError(ToolRuntimeError):
    """Raised when the retry limit per step is exceeded.

    When the safety profile's ``max_retries_per_step`` is reached for a
    given action step, further tool executions for that step are blocked.
    """


class ToolDeactivationError(ToolRuntimeError):
    """Raised when tool deactivation fails."""


# ---------------------------------------------------------------------------
# Tool descriptor
# ---------------------------------------------------------------------------


class ToolDescriptor(BaseModel):
    """Lightweight metadata returned by ``discover()``.

    Describes a tool's identity, capabilities, and schema without
    carrying the full implementation.
    """

    name: str = Field(..., description="Namespaced tool name")
    description: str = Field(..., description="Human-readable description")
    capability: ToolCapability = Field(
        default_factory=ToolCapability,
        description="Capability metadata",
    )
    input_schema: dict[str, Any] | None = Field(
        default=None, description="JSON Schema for inputs"
    )
    output_schema: dict[str, Any] | None = Field(
        default=None, description="JSON Schema for outputs"
    )
    source: str = Field("builtin", description="Tool source identifier")

    model_config = ConfigDict(str_strip_whitespace=True)


# ---------------------------------------------------------------------------
# Tool result
# ---------------------------------------------------------------------------


class ToolResult(BaseModel):
    """Structured result from a tool execution.

    Wraps the output data, any changes produced, and metadata about the
    execution.
    """

    success: bool = Field(True, description="Whether execution succeeded")
    data: Any = Field(default=None, description="Tool output data")
    changes: list[Change] = Field(default_factory=list, description="Changes produced")
    error: str | None = Field(default=None, description="Error message if failed")

    model_config = ConfigDict(str_strip_whitespace=True)


# ---------------------------------------------------------------------------
# Tool instance protocol
# ---------------------------------------------------------------------------


@runtime_checkable
class ToolInstance(Protocol):
    """Protocol for concrete tool implementations.

    Each tool source (MCP, Agent Skill, built-in, custom) provides an
    implementation of this protocol.  The lifecycle methods are called
    by ``ToolRuntime`` in strict order.
    """

    def discover(self) -> ToolDescriptor:
        """Return lightweight metadata about the tool.

        Called during tool registration to enumerate available tools.
        Must be cheap (~50-100 tokens for Agent Skills).
        """
        ...

    def activate(self, ctx: ToolExecutionContext) -> None:
        """Prepare the tool for execution within a plan context.

        Called at most once per plan.  May start external processes,
        verify connectivity, load instructions into agent context, etc.

        Raises ``ToolActivationError`` on failure.
        """
        ...

    def execute(self, params: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult:
        """Execute the tool with given parameters.

        Must only be called after ``activate()``.  The ``ctx`` carries
        resource bindings, sandbox state, and the cancellation token.

        Tools should check ``ctx.cancellation_token.is_cancelled``
        periodically for long-running operations.

        Returns a ``ToolResult`` with output data and any changes.
        """
        ...

    def deactivate(self, ctx: ToolExecutionContext) -> None:
        """Clean up after all executions within a plan are done.

        Called once per plan when the plan completes, fails, or is
        cancelled.  Must be idempotent and must not raise.
        """
        ...


# ---------------------------------------------------------------------------
# Lifecycle cache
# ---------------------------------------------------------------------------


class _ActivationEntry:
    """Internal cache entry for an activated tool instance."""

    __slots__ = ("activated_at", "execution_count", "instance")

    def __init__(self, instance: ToolInstance, activated_at: str) -> None:
        self.instance = instance
        self.activated_at = activated_at
        self.execution_count = 0


class ToolLifecycleCache:
    """Thread-safe per-plan activation cache.

    Ensures each tool is activated at most once per plan and guarantees
    ``deactivate()`` is called for every activated tool when the plan
    ends.
    """

    def __init__(self) -> None:
        self._lock = threading.RLock()
        # plan_id -> {tool_name -> _ActivationEntry}
        self._cache: dict[str, dict[str, _ActivationEntry]] = {}

    def get(self, plan_id: str, tool_name: str) -> ToolInstance | None:
        """Get an activated instance, or None if not cached."""
        with self._lock:
            plan_cache = self._cache.get(plan_id)
            if plan_cache is None:
                return None
            entry = plan_cache.get(tool_name)
            return entry.instance if entry else None

    def put(self, plan_id: str, tool_name: str, instance: ToolInstance) -> None:
        """Store an activated instance in the cache."""
        with self._lock:
            if plan_id not in self._cache:
                self._cache[plan_id] = {}
            self._cache[plan_id][tool_name] = _ActivationEntry(
                instance=instance,
                activated_at=datetime.now(UTC).isoformat(),
            )

    def increment_execution(self, plan_id: str, tool_name: str) -> None:
        """Increment the execution count for a cached tool."""
        with self._lock:
            plan_cache = self._cache.get(plan_id)
            if plan_cache and tool_name in plan_cache:
                plan_cache[tool_name].execution_count += 1

    def get_plan_tools(self, plan_id: str) -> list[str]:
        """Return the names of all activated tools for a plan."""
        with self._lock:
            plan_cache = self._cache.get(plan_id)
            return list(plan_cache.keys()) if plan_cache else []

    def remove(self, plan_id: str, tool_name: str) -> ToolInstance | None:
        """Remove and return an activated instance from the cache."""
        with self._lock:
            plan_cache = self._cache.get(plan_id)
            if plan_cache and tool_name in plan_cache:
                entry = plan_cache.pop(tool_name)
                if not plan_cache:
                    del self._cache[plan_id]
                return entry.instance
            return None

    def remove_plan(self, plan_id: str) -> dict[str, ToolInstance]:
        """Remove all activated instances for a plan.

        Returns a mapping of tool_name -> instance for deactivation.
        """
        with self._lock:
            plan_cache = self._cache.pop(plan_id, {})
            return {name: entry.instance for name, entry in plan_cache.items()}

    def get_stats(self, plan_id: str) -> OrderedDict[str, Any]:
        """Return cache statistics for a plan."""
        with self._lock:
            plan_cache = self._cache.get(plan_id, {})
            stats: OrderedDict[str, Any] = OrderedDict()
            stats["plan_id"] = plan_id
            stats["active_tools"] = len(plan_cache)
            stats["tools"] = {
                name: {
                    "activated_at": entry.activated_at,
                    "execution_count": entry.execution_count,
                }
                for name, entry in plan_cache.items()
            }
            return stats

    @property
    def plan_count(self) -> int:
        """Return the number of plans with active tools."""
        with self._lock:
            return len(self._cache)


# ---------------------------------------------------------------------------
# Tool runtime
# ---------------------------------------------------------------------------


class ToolRuntime:
    """Orchestrates the four-stage tool lifecycle.

    Responsibilities:
    - Capability enforcement (read-only plans, checkpoint requirements)
    - JSON Schema validation of inputs and outputs
    - Per-plan activation caching with guaranteed deactivation
    - Execution tracing (timestamps, duration, result size)
    - Cancellation propagation to long-running tools

    Parameters
    ----------
    tools:
        Mapping of tool name -> Tool domain model for registered tools.
    instances:
        Mapping of tool name -> ToolInstance for concrete implementations.
    """

    def __init__(
        self,
        *,
        tools: dict[str, Tool] | None = None,
        instances: dict[str, ToolInstance] | None = None,
        event_bus: EventBus | None = None,
    ) -> None:
        self._tools: dict[str, Tool] = tools or {}
        self._instances: dict[str, ToolInstance] = instances or {}
        self._cache = ToolLifecycleCache()
        self._lock = threading.RLock()
        self._event_bus = event_bus

    def _try_emit(
        self, event_type: EventType, plan_id: str, details: dict[str, Any]
    ) -> None:
        """Best-effort event emission — never raises."""
        if self._event_bus is None:
            return
        try:
            self._event_bus.emit(
                DomainEvent(event_type=event_type, plan_id=plan_id, details=details)
            )
        except Exception:
            logger.warning(
                "event_bus_emit_failed", extra={"event_type": event_type.value}
            )

    # -- Registration --------------------------------------------------------

    def register_tool(self, tool: Tool, instance: ToolInstance) -> None:
        """Register a tool and its implementation.

        Parameters
        ----------
        tool:
            The Tool domain model describing the tool.
        instance:
            The concrete ToolInstance implementation.
        """
        with self._lock:
            self._tools[tool.name] = tool
            self._instances[tool.name] = instance

    def unregister_tool(self, tool_name: str) -> None:
        """Remove a tool registration.

        Does not deactivate existing plan-level activations; those are
        handled by ``deactivate_plan()``.
        """
        with self._lock:
            self._tools.pop(tool_name, None)
            self._instances.pop(tool_name, None)

    def get_tool(self, tool_name: str) -> Tool | None:
        """Get a registered Tool domain model by name."""
        return self._tools.get(tool_name)

    def list_tools(self) -> list[str]:
        """Return names of all registered tools."""
        return sorted(self._tools.keys())

    # -- Discovery -----------------------------------------------------------

    def discover(self, tool_name: str) -> ToolDescriptor:
        """Invoke the ``discover()`` lifecycle hook.

        Returns a ``ToolDescriptor`` with the tool's metadata.
        """
        instance = self._get_instance(tool_name)
        return instance.discover()

    # -- Activation ----------------------------------------------------------

    def activate(self, tool_name: str, ctx: ToolExecutionContext) -> None:
        """Activate a tool for a plan context.

        Uses the lifecycle cache to ensure each tool is activated at most
        once per plan.  Performs capability pre-checks before activation.

        Raises
        ------
        ToolAccessDeniedError:
            If the plan is read-only and the tool writes.
        ToolCheckpointRequiredError:
            If the plan requires checkpoints and the tool is not checkpointable.
        ToolActivationError:
            If the tool's ``activate()`` hook fails.
        """
        tool = self._get_tool(tool_name)
        self._enforce_capabilities(tool, ctx)

        # Check cache - already activated for this plan?
        cached = self._cache.get(ctx.plan_id, tool_name)
        if cached is not None:
            return

        instance = self._get_instance(tool_name)
        try:
            instance.activate(ctx)
        except ToolCancelledError:
            raise
        except Exception as exc:
            raise ToolActivationError(
                f"Failed to activate tool '{tool_name}': {exc}"
            ) from exc

        self._cache.put(ctx.plan_id, tool_name, instance)
        logger.info(
            "Tool activated",
            extra={"tool": tool_name, "plan_id": ctx.plan_id},
        )

    # -- Execution -----------------------------------------------------------

    def execute(
        self,
        tool_name: str,
        params: dict[str, Any],
        ctx: ToolExecutionContext,
    ) -> ToolResult:
        """Execute a tool with given parameters.

        Performs the full execution pipeline:
        1. Check cancellation
        2. Auto-activate if not yet activated for this plan
        3. Enforce capability flags
        4. Validate inputs against JSON Schema
        5. Execute the tool
        6. Validate outputs against JSON Schema
        7. Record trace

        Parameters
        ----------
        tool_name:
            Namespaced name of the tool to execute.
        params:
            Input parameters for the tool.
        ctx:
            Execution context with plan metadata, resources, cancellation.

        Returns
        -------
        ToolResult:
            The structured result from the tool execution.
        """
        # 1. Check cancellation before starting
        ctx.cancellation_token.check()

        tool = self._get_tool(tool_name)

        # Emit TOOL_INVOKED event
        self._try_emit(
            EventType.TOOL_INVOKED,
            ctx.plan_id,
            {"tool_name": tool_name, "plan_id": ctx.plan_id},
        )
        if ctx.step_retry_count > 0:
            self._try_emit(
                EventType.TOOL_RETRIED,
                ctx.plan_id,
                {"tool_name": tool_name, "retry_count": ctx.step_retry_count},
            )

        # 2. Auto-activate if needed (activate already enforces capabilities)
        self.activate(tool_name, ctx)

        # 3. Validate inputs
        if tool.input_schema:
            try:
                validate_tool_input(params, tool.input_schema)
            except ToolSchemaValidationError as exc:
                return ToolResult(
                    success=False,
                    error=f"Input validation failed: {exc}",
                )

        # 4. Execute with tracing
        started_at = datetime.now(UTC)
        trace = ToolExecutionTrace(
            tool_name=tool_name,
            started_at=started_at.isoformat(),
        )

        try:
            # Check cancellation again right before execution
            ctx.cancellation_token.check()

            instance = self._cache.get(ctx.plan_id, tool_name)
            if instance is None:
                raise ToolNotActivatedError(
                    f"Tool '{tool_name}' is not activated for plan '{ctx.plan_id}'"
                )

            start_time = time.monotonic()
            result = instance.execute(params, ctx)
            elapsed_ms = (time.monotonic() - start_time) * 1000

            # Record trace
            ended_at = datetime.now(UTC)
            trace.ended_at = ended_at.isoformat()
            trace.duration_ms = round(elapsed_ms, 2)
            trace.success = result.success

            if result.data is not None:
                trace.result_size_bytes = len(str(result.data).encode())

            if result.error:
                trace.error = result.error

            self._cache.increment_execution(ctx.plan_id, tool_name)

        except ToolCancelledError:
            ended_at = datetime.now(UTC)
            trace.ended_at = ended_at.isoformat()
            trace.duration_ms = round((ended_at - started_at).total_seconds() * 1000, 2)
            trace.success = False
            trace.error = "Cancelled"
            ctx.add_trace(trace)
            self._try_emit(
                EventType.TOOL_ERRORED,
                ctx.plan_id,
                {"tool_name": tool_name, "error": "Cancelled"},
            )
            raise

        except Exception as exc:
            ended_at = datetime.now(UTC)
            trace.ended_at = ended_at.isoformat()
            trace.duration_ms = round((ended_at - started_at).total_seconds() * 1000, 2)
            trace.success = False
            trace.error = str(exc)
            ctx.add_trace(trace)
            self._try_emit(
                EventType.TOOL_ERRORED,
                ctx.plan_id,
                {"tool_name": tool_name, "error": str(exc)},
            )
            raise ToolExecutionError(
                f"Tool '{tool_name}' execution failed: {exc}"
            ) from exc

        ctx.add_trace(trace)

        # Emit TOOL_COMPLETED event on success
        self._try_emit(
            EventType.TOOL_COMPLETED,
            ctx.plan_id,
            {
                "tool_name": tool_name,
                "success": result.success,
                "duration_ms": trace.duration_ms,
            },
        )

        # 5. Validate outputs
        if tool.output_schema and result.data is not None:
            try:
                validate_tool_output(result.data, tool.output_schema)
            except ToolSchemaValidationError as exc:
                logger.warning(
                    "Tool output validation failed",
                    extra={"tool": tool_name, "error": str(exc)},
                )
                return ToolResult(
                    success=False,
                    data=result.data,
                    changes=result.changes,
                    error=f"Output validation failed: {exc}",
                )

        # Record changes from result into context
        for change in result.changes:
            ctx.record_change(change)

        return result

    # -- Deactivation --------------------------------------------------------

    def deactivate(self, tool_name: str, ctx: ToolExecutionContext) -> None:
        """Deactivate a single tool for a plan.

        Removes the tool from the activation cache and calls the tool's
        ``deactivate()`` hook.  Idempotent -- does nothing if the tool
        is not activated for the plan.
        """
        instance = self._cache.remove(ctx.plan_id, tool_name)
        if instance is None:
            return

        try:
            instance.deactivate(ctx)
        except Exception as exc:
            logger.warning(
                "Tool deactivation failed (non-fatal)",
                extra={"tool": tool_name, "plan_id": ctx.plan_id, "error": str(exc)},
            )

        logger.info(
            "Tool deactivated",
            extra={"tool": tool_name, "plan_id": ctx.plan_id},
        )

    def deactivate_plan(self, ctx: ToolExecutionContext) -> None:
        """Deactivate all tools for a plan.

        Called when a plan completes, fails, or is cancelled.  Guarantees
        every activated tool receives a ``deactivate()`` call.
        """
        instances = self._cache.remove_plan(ctx.plan_id)
        for tool_name, instance in instances.items():
            try:
                instance.deactivate(ctx)
            except Exception as exc:
                logger.warning(
                    "Tool deactivation failed during plan cleanup (non-fatal)",
                    extra={
                        "tool": tool_name,
                        "plan_id": ctx.plan_id,
                        "error": str(exc),
                    },
                )

        if instances:
            logger.info(
                "All plan tools deactivated",
                extra={
                    "plan_id": ctx.plan_id,
                    "tool_count": len(instances),
                },
            )

    # -- Introspection -------------------------------------------------------

    def get_cache_stats(self, plan_id: str) -> OrderedDict[str, Any]:
        """Return activation cache statistics for a plan."""
        return self._cache.get_stats(plan_id)

    @property
    def cache_plan_count(self) -> int:
        """Return the number of plans with active tool caches."""
        return self._cache.plan_count

    # -- Internal helpers ----------------------------------------------------

    def _get_tool(self, tool_name: str) -> Tool:
        """Get a registered Tool or raise."""
        tool = self._tools.get(tool_name)
        if tool is None:
            raise ToolRuntimeError(f"Tool '{tool_name}' is not registered")
        return tool

    def _get_instance(self, tool_name: str) -> ToolInstance:
        """Get a registered ToolInstance or raise."""
        instance = self._instances.get(tool_name)
        if instance is None:
            raise ToolRuntimeError(
                f"No implementation registered for tool '{tool_name}'"
            )
        return instance

    @staticmethod
    def _enforce_capabilities(tool: Tool, ctx: ToolExecutionContext) -> None:
        """Enforce capability constraints against the execution context.

        Checks are evaluated in this order:

        1. **Read-only plan** -- tools with ``writes=True`` are blocked.
        2. **Checkpoint requirement** -- non-checkpointable tools blocked
           when the plan or safety profile requires checkpoints.
        3. **Unsafe tool gating** -- tools with ``unsafe=True`` are blocked
           when the safety profile has ``allow_unsafe_tools=False``.
        4. **Skill category allow-list** -- tools whose skill category
           (carried in ``ctx.metadata["tool_skill_category"]``) is not in
           the profile's ``allowed_skill_categories`` are blocked.  An
           empty allow-list means all categories are permitted.
        5. **Sandbox requirement** -- tools with ``writes=True`` are blocked
           when the safety profile has ``require_sandbox=True`` and no
           ``sandbox_id`` is set on the context.
        6. **Human approval** -- all tools are blocked when the safety
           profile has ``require_human_approval=True`` and the context
           metadata does not carry ``human_approved=True``.
        7. **Cost limits** -- tools are blocked when the accumulated cost
           on the context exceeds the safety profile's
           ``max_cost_per_plan`` or ``max_total_cost``.
        8. **Retry limit** -- tools are blocked when the step retry count
           on the context exceeds ``max_retries_per_step``.

        Raises
        ------
        ToolAccessDeniedError:
            If the plan is read-only and the tool has ``writes=True``.
        ToolCheckpointRequiredError:
            If the plan requires checkpoints and the tool is not
            ``checkpointable``.
        ToolSafetyViolationError:
            If the tool violates the safety profile (unsafe tool gating
            or skill category restriction).
        ToolSandboxRequiredError:
            If sandbox is required but no sandbox_id is set and the tool
            writes.
        ToolHumanApprovalRequiredError:
            If human approval is required but not granted.
        ToolCostLimitExceededError:
            If accumulated cost exceeds the plan or total cost limit.
        ToolRetryLimitExceededError:
            If the step retry count exceeds max_retries_per_step.
        """
        cap = tool.capability

        # 1. Read-only plan cannot use tools that write.
        if ctx.plan_read_only and cap.writes:
            raise ToolAccessDeniedError(
                f"Tool '{tool.name}' has writes=True but plan "
                f"'{ctx.plan_id}' is read-only"
            )

        # 2. Checkpoint-required plan cannot use non-checkpointable tools.
        require_cp = ctx.require_checkpoints
        if ctx.safety_profile is not None:
            require_cp = require_cp or ctx.safety_profile.require_checkpoints
        if require_cp and not cap.checkpointable:
            raise ToolCheckpointRequiredError(
                f"Tool '{tool.name}' is not checkpointable but plan "
                f"'{ctx.plan_id}' requires checkpoints"
            )

        # 3. Unsafe tool gating via safety profile.
        if (
            ctx.safety_profile is not None
            and cap.unsafe
            and not ctx.safety_profile.allow_unsafe_tools
        ):
            raise ToolSafetyViolationError(
                f"Tool '{tool.name}' is marked unsafe but the safety "
                f"profile forbids unsafe tools "
                f"(allow_unsafe_tools=False)"
            )

        # 4. Skill category allow-list via safety profile.
        if ctx.safety_profile is not None:
            allowed = ctx.safety_profile.allowed_skill_categories
            if allowed:
                tool_category = ctx.metadata.get("tool_skill_category")
                if tool_category is None:
                    raise ToolSafetyViolationError(
                        f"Tool '{tool.name}' has no skill category "
                        f"metadata ('tool_skill_category') but the "
                        f"safety profile restricts categories "
                        f"to: {allowed}"
                    )
                if tool_category not in allowed:
                    raise ToolSafetyViolationError(
                        f"Tool '{tool.name}' belongs to skill category "
                        f"'{tool_category}' which is not in the allowed "
                        f"categories: {allowed}"
                    )

        # 5. Sandbox requirement via safety profile.
        if (
            ctx.safety_profile is not None
            and ctx.safety_profile.require_sandbox
            and cap.writes
            and ctx.sandbox_id is None
        ):
            raise ToolSandboxRequiredError(
                f"Tool '{tool.name}' has writes=True but the safety "
                f"profile requires sandbox isolation "
                f"(require_sandbox=True) and no sandbox_id is set "
                f"on the execution context"
            )

        # 6. Human approval requirement via safety profile.
        if (
            ctx.safety_profile is not None
            and ctx.safety_profile.require_human_approval
            and not ctx.metadata.get("human_approved", False)
        ):
            raise ToolHumanApprovalRequiredError(
                f"Tool '{tool.name}' requires human approval before "
                f"execution (require_human_approval=True) but no "
                f"approval has been recorded in the context metadata"
            )

        # 7. Cost limits via safety profile.
        if ctx.safety_profile is not None:
            if (
                ctx.safety_profile.max_cost_per_plan is not None
                and ctx.accumulated_cost >= ctx.safety_profile.max_cost_per_plan
            ):
                raise ToolCostLimitExceededError(
                    f"Plan '{ctx.plan_id}' accumulated cost "
                    f"({ctx.accumulated_cost}) has reached the "
                    f"max_cost_per_plan limit "
                    f"({ctx.safety_profile.max_cost_per_plan})"
                )
            if (
                ctx.safety_profile.max_total_cost is not None
                and ctx.total_accumulated_cost >= ctx.safety_profile.max_total_cost
            ):
                raise ToolCostLimitExceededError(
                    f"Total accumulated cost "
                    f"({ctx.total_accumulated_cost}) has reached the "
                    f"max_total_cost limit "
                    f"({ctx.safety_profile.max_total_cost})"
                )

        # 8. Retry limit via safety profile.
        if (
            ctx.safety_profile is not None
            and ctx.step_retry_count > ctx.safety_profile.max_retries_per_step
        ):
            raise ToolRetryLimitExceededError(
                f"Step retry count ({ctx.step_retry_count}) exceeds "
                f"max_retries_per_step "
                f"({ctx.safety_profile.max_retries_per_step}) for plan "
                f"'{ctx.plan_id}'"
            )
