"""Skill protocol types for CleverAgents v3.

Defines the protocol-level types for skill discovery, composition,
execution results, and structured errors.  All models are frozen
Pydantic ``BaseModel`` instances following the project's immutable
domain-model conventions.

## Overview

- **SkillErrorType** -- ``StrEnum`` enumerating all skill error categories.
- **SkillError** -- Structured error payload with error type, message, and
  optional diagnostic details.
- **SkillMetadata** -- Lightweight descriptor for discovery (name, version,
  tool count, capability summary, writes/read_only flags).
- **SkillDefinition** -- Full skill definition pairing the domain ``Skill``
  with its resolved tools and pre-computed metadata.  Includes schema
  validation for inputs/outputs.
- **SkillResult** -- Outcome of a single skill-tool invocation (success
  flag, output data, timing, resource changes, optional error).

## Writes / Read-Only Gating

Every ``SkillMetadata`` exposes explicit ``writes`` and ``read_only`` flags
derived from the underlying ``SkillCapabilitySummary``.  These flags are
intended for propagation to the ToolRuntime gating layer so that a
skill marked ``read_only=True`` never triggers a write-capable tool.

## Error Mapping

The ``map_tool_error`` helper normalises arbitrary tool-execution
exceptions into a ``SkillError`` with the appropriate ``SkillErrorType``,
ensuring uniform error payloads across all skill operations.

Based on ``docs/specification.md`` and ``implementation_plan.md`` task
C3.protocol.
"""

from __future__ import annotations

from enum import StrEnum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, model_validator

from cleveragents.domain.models.core.skill import (
    ResolvedToolEntry,
    Skill,
    SkillCapabilitySummary,
    SkillResolver,
)

# ---------------------------------------------------------------------------
# SkillErrorType enum
# ---------------------------------------------------------------------------


class SkillErrorType(StrEnum):
    """Enumeration of all skill error categories.

    Used as the discriminator in ``SkillError`` to enable typed error
    handling across the skill framework.
    """

    SKILL_NOT_FOUND = "skill_not_found"
    RESOLUTION_FAILURE = "resolution_failure"
    CYCLE_DETECTED = "cycle_detected"
    TOOL_ACTIVATION_FAILURE = "tool_activation_failure"
    TOOL_EXECUTION_FAILURE = "tool_execution_failure"
    VALIDATION_ERROR = "validation_error"
    PERMISSION_DENIED = "permission_denied"


# ---------------------------------------------------------------------------
# SkillError
# ---------------------------------------------------------------------------


class SkillError(BaseModel):
    """Structured error payload for skill operations.

    Captures the error type, human-readable message, and optional
    diagnostic context.  Produced by error mapping helpers or raised
    directly when a skill operation fails.
    """

    error_type: SkillErrorType = Field(..., description="Category of the skill error")
    message: str = Field(
        ..., min_length=1, description="Human-readable error description"
    )
    details: dict[str, Any] = Field(
        default_factory=dict,
        description="Additional diagnostic information",
    )
    skill_name: str = Field(
        ..., min_length=1, description="Skill that produced the error"
    )
    tool_name: str | None = Field(
        default=None,
        description="Tool that produced the error (if applicable)",
    )

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# SkillMetadata
# ---------------------------------------------------------------------------


class SkillMetadata(BaseModel):
    """Lightweight metadata descriptor for skill discovery.

    Designed to be cheap to compute and transmit so that skill
    catalogues and CLI listing commands can display essential
    information without resolving the full tool set.

    The ``writes`` and ``read_only`` flags are intended for propagation
    to the ToolRuntime gating layer.
    """

    name: str = Field(..., min_length=1, description="Namespaced skill name")
    description: str = Field(..., min_length=1, description="Skill description")
    version: str = Field(default="0.0.0", description="Semantic version of the skill")
    tool_count: int = Field(
        default=0, ge=0, description="Number of tools in the resolved set"
    )
    capability_summary: SkillCapabilitySummary = Field(
        default_factory=lambda: SkillCapabilitySummary.model_validate({}),
        description="Aggregated capability summary",
    )
    source_types: list[str] = Field(
        default_factory=list,
        description="Distinct source type labels present in the skill",
    )
    writes: bool = Field(
        default=False,
        description="Whether any tool in the skill can write",
    )
    read_only: bool = Field(
        default=True,
        description="Whether the skill is entirely read-only",
    )

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
        validate_assignment=True,
    )

    # -- Factory ---------------------------------------------------------------

    @classmethod
    def from_skill(
        cls,
        skill: Skill,
        resolved: list[ResolvedToolEntry] | None = None,
        version: str = "0.0.0",
    ) -> SkillMetadata:
        """Create metadata from a ``Skill`` domain model.

        If *resolved* entries are not provided, the skill is resolved
        using a fresh ``SkillResolver`` with an empty lookup.

        Args:
            skill: The domain-level ``Skill`` instance.
            resolved: Pre-resolved tool entries (optional).
            version: Semantic version string for the skill.

        Returns:
            A new ``SkillMetadata`` instance.
        """
        if skill is None:
            raise ValueError("skill must not be None")

        resolver = SkillResolver()
        if resolved is None:
            resolved = resolver.resolve_tools(skill, {})

        summary = resolver.compute_capability_summary(skill, resolved)

        # Derive source_types from resolved entries
        source_types: list[str] = []
        for entry in resolved:
            if entry.is_inline:
                label = "inline"
            elif entry.name.startswith("mcp:"):
                label = "mcp"
            elif entry.name.startswith("agent_skill:"):
                label = "agent_skill"
            else:
                label = "tool_ref"
            if label not in source_types:
                source_types.append(label)

        has_writes = summary.write_tools > 0 or summary.has_side_effects
        is_read_only = summary.write_tools == 0 and not summary.has_side_effects

        return cls(
            name=skill.name,
            description=skill.description,
            version=version,
            tool_count=summary.total_tools,
            capability_summary=summary,
            source_types=source_types,
            writes=has_writes,
            read_only=is_read_only,
        )


# ---------------------------------------------------------------------------
# SkillDefinition
# ---------------------------------------------------------------------------


class SkillDefinition(BaseModel):
    """Full skill definition with resolved tools and metadata.

    Pairs the domain ``Skill`` model with the resolved (flattened) tool
    entries and pre-computed ``SkillMetadata``.  Includes validation
    that ensures schema consistency between the definition's
    input/output schemas and the Tool Registry schemas.
    """

    skill: Skill = Field(..., description="Domain-level Skill model")
    resolved_tools: list[ResolvedToolEntry] = Field(
        default_factory=list,
        description="Flattened set of resolved tool entries",
    )
    metadata: SkillMetadata = Field(..., description="Pre-computed skill metadata")
    input_schema: dict[str, Any] | None = Field(
        default=None,
        description="JSON Schema for skill-level inputs",
    )
    output_schema: dict[str, Any] | None = Field(
        default=None,
        description="JSON Schema for skill-level outputs",
    )

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
        validate_assignment=True,
    )

    # -- Schema validation -----------------------------------------------------

    @model_validator(mode="after")
    def _validate_schemas(self) -> SkillDefinition:
        """Validate that input/output schemas are well-formed JSON Schema.

        A valid JSON Schema object must have ``"type"`` declared.
        This lightweight check catches the most common authoring errors
        without pulling in a full JSON Schema meta-validator.
        """
        for label, schema in [
            ("input_schema", self.input_schema),
            ("output_schema", self.output_schema),
        ]:
            if schema is not None:
                if not isinstance(schema, dict):
                    raise ValueError(f"{label} must be a dict, got {type(schema)}")
                if "type" not in schema:
                    raise ValueError(
                        f"{label} must include a 'type' key to be a valid JSON Schema"
                    )
        return self

    # -- Writes / read_only propagation ----------------------------------------

    @model_validator(mode="after")
    def _validate_writes_consistency(self) -> SkillDefinition:
        """Ensure writes/read_only metadata is consistent.

        If metadata declares ``read_only=True`` but the resolved tools
        include write-capable inline tools, the skill is inconsistent
        and we raise a validation error.
        """
        write_inline_count = 0
        for entry in self.resolved_tools:
            if (
                entry.is_inline
                and entry.inline_tool is not None
                and entry.inline_tool.capability is not None
                and entry.inline_tool.capability.writes
            ):
                write_inline_count += 1

        if self.metadata.read_only and write_inline_count > 0:
            raise ValueError(
                "SkillDefinition metadata declares read_only=True but "
                f"{write_inline_count} resolved inline tool(s) have "
                "writes=True"
            )
        return self


# ---------------------------------------------------------------------------
# SkillResult
# ---------------------------------------------------------------------------


class SkillResult(BaseModel):
    """Result of a single skill-tool invocation.

    Captures success/failure, output data, timing, and any resource
    changes produced by the tool.  An optional ``SkillError`` provides
    structured diagnostics on failure.
    """

    skill_name: str = Field(..., min_length=1, description="Skill that was invoked")
    tool_name: str = Field(..., min_length=1, description="Tool that was invoked")
    success: bool = Field(..., description="Whether the invocation succeeded")
    output_data: Any = Field(default=None, description="Output data from the tool")
    error: SkillError | None = Field(
        default=None,
        description="Structured error on failure",
    )
    duration_ms: float = Field(
        default=0.0, ge=0.0, description="Execution duration in milliseconds"
    )
    resource_changes: list[dict[str, Any]] = Field(
        default_factory=list,
        description="List of resource change descriptors",
    )

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# Error mapping helpers
# ---------------------------------------------------------------------------


def map_tool_error(
    exc: Exception,
    skill_name: str,
    tool_name: str | None = None,
) -> SkillError:
    """Normalise an arbitrary exception into a ``SkillError``.

    The mapping heuristic inspects the exception message and type to
    select the most appropriate ``SkillErrorType``:

    - ``ValueError`` with *"cycle"* → ``CYCLE_DETECTED``
    - ``ValueError`` with *"not found"* → ``SKILL_NOT_FOUND``
    - ``PermissionError`` → ``PERMISSION_DENIED``
    - ``ValidationError`` (Pydantic) → ``VALIDATION_ERROR``
    - Everything else → ``TOOL_EXECUTION_FAILURE``

    Args:
        exc: The exception to map.
        skill_name: Name of the skill context.
        tool_name: Optional tool name context.

    Returns:
        A new ``SkillError`` instance.

    Raises:
        ValueError: If *skill_name* is empty.
    """
    if not skill_name:
        raise ValueError("skill_name must not be empty")

    msg = str(exc)
    error_type = _classify_exception(exc, msg)

    details: dict[str, Any] = {
        "exception_type": type(exc).__name__,
    }
    if tool_name:
        details["tool_name"] = tool_name

    return SkillError(
        error_type=error_type,
        message=msg or f"Unknown error: {type(exc).__name__}",
        details=details,
        skill_name=skill_name,
        tool_name=tool_name,
    )


def _classify_exception(exc: Exception, msg: str) -> SkillErrorType:
    """Classify an exception into a ``SkillErrorType``.

    Args:
        exc: The exception instance.
        msg: String representation of the exception.

    Returns:
        The determined ``SkillErrorType``.
    """
    lower_msg = msg.lower()

    if isinstance(exc, ValueError):
        if "cycle" in lower_msg:
            return SkillErrorType.CYCLE_DETECTED
        if "not found" in lower_msg:
            return SkillErrorType.SKILL_NOT_FOUND
        return SkillErrorType.RESOLUTION_FAILURE

    if isinstance(exc, PermissionError):
        return SkillErrorType.PERMISSION_DENIED

    # Pydantic ValidationError check (avoid importing pydantic here to
    # keep the mapping function lightweight)
    if type(exc).__name__ == "ValidationError":
        return SkillErrorType.VALIDATION_ERROR

    if "activation" in lower_msg or "activate" in lower_msg:
        return SkillErrorType.TOOL_ACTIVATION_FAILURE

    return SkillErrorType.TOOL_EXECUTION_FAILURE
