"""Skill domain model and resolver for CleverAgents v3.

A **Skill** is a namespaced, reusable collection of tools that is registered
via YAML config. Skills reference named tools (from the Tool Registry), define
anonymous inline tools, include other skills, or expose MCP/Agent Skills.

When an actor references a skill it gains access to the **flattened set of all
tools** -- resolved recursively from includes, ``tool_refs``, inline tools, and
external sources. Skills are NOT single tools -- they are containers/collections.

## Skill Name

Skills use a ``namespace/short_name`` naming pattern:

```
local/code-tools
devops/deploy-tools
```

The name is the unique identifier, validated against the pattern
``^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$``.

## Resolution Algorithm

The [SkillResolver][cleveragents.domain.models.core.skill.SkillResolver]
flattens a skill's tool set as follows:

1. **Cycle detection** -- Before descending into includes, check if the
   current skill has already been visited in this resolution path. If so,
   raise a ``ValueError`` with the full cycle path trace.
2. **Depth-first include resolution** -- Process each ``SkillInclude`` by
   recursively resolving the included skill first. Tools from deeper
   includes appear earlier in the final order.
3. **Tool ref collection** -- Add ``tool_refs`` from the current skill. If a
   tool name already exists (from an include), it is replaced (last-wins
   semantics for overrides).
4. **Inline tool collection** -- Anonymous tools are keyed as
   ``{skill_name}/_anon_{index}`` and added in definition order.
5. **MCP source collection** -- For MCP servers with explicit tool lists,
   each tool is added as ``mcp:{server}/{tool_name}``.
6. **Agent skill collection** -- Each agent skill path is added as
   ``agent_skill:{path}``.

### De-duplication

When the same tool name appears multiple times (e.g., from overlapping
includes), the **last occurrence wins**. The tool's position in the ordered
list is preserved from its first appearance, but the entry metadata is
updated.

### Override Application

Overrides defined in the skill's ``overrides`` dict are applied to matching
tool refs during resolution. The overrides dict is keyed by tool name and
contains arbitrary metadata attached to the ``ResolvedToolEntry``.

Based on ``docs/specification.md`` and ADR-004 (Pydantic Validation).
"""

from __future__ import annotations

import re
from collections import OrderedDict
from typing import Any

from pydantic import BaseModel, ConfigDict, Field, field_validator

from cleveragents.domain.models.core.tool import (
    ResourceSlot,
    ToolCapability,
    ToolSource,
)

# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------

_SKILL_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$")

# ---------------------------------------------------------------------------
# Supporting models
# ---------------------------------------------------------------------------


class SkillToolRef(BaseModel):
    """Reference to a named tool in the Tool Registry.

    The name is a namespaced tool name (``namespace/short_name``).
    """

    name: str = Field(..., min_length=1, description="Namespaced tool name reference")

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


class SkillInclude(BaseModel):
    """Reference to another skill to include (recursive composition).

    When a skill includes another skill, the included skill's tools are
    merged into the parent's flattened tool set.
    """

    name: str = Field(..., min_length=1, description="Namespaced skill name to include")
    overrides: dict[str, Any] | None = Field(
        default=None,
        description="Per-tool metadata overrides applied during inclusion",
    )

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


class SkillInlineTool(BaseModel):
    """An anonymous inline tool definition embedded in a skill.

    Inline tools do not exist in the Tool Registry -- they are defined
    directly in the skill YAML and only live within the skill's scope.
    """

    description: str = Field(
        ..., min_length=1, description="Description of the inline tool"
    )
    source: ToolSource = Field(..., description="Source type for the inline tool")
    code: str | None = Field(
        default=None, description="Inline code (required for source=custom)"
    )
    input_schema: dict[str, Any] | None = Field(
        default=None, description="JSON Schema for tool inputs"
    )
    output_schema: dict[str, Any] | None = Field(
        default=None, description="JSON Schema for tool outputs"
    )
    capability: ToolCapability | None = Field(
        default=None, description="Capability metadata for the inline tool"
    )
    resource_slots: list[ResourceSlot] = Field(
        default_factory=list, description="Resource slot declarations"
    )
    timeout: int = Field(300, ge=1, description="Execution timeout in seconds")

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


class SkillMcpSource(BaseModel):
    """MCP server source for a skill.

    When ``tools`` is None, all tools from the server are imported.
    """

    server: str = Field(..., min_length=1, description="MCP server command or URI")
    tools: list[str] | None = Field(
        default=None,
        description="Specific tools to import (None means all)",
    )
    env: dict[str, str] | None = Field(
        default=None, description="Environment variables for the MCP server"
    )

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


class SkillAgentSource(BaseModel):
    """Agent Skills Standard folder source for a skill."""

    path: str = Field(
        ..., min_length=1, description="Path to Agent Skills Standard folder"
    )

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


class SkillCapabilitySummary(BaseModel):
    """Aggregated capability summary for all tools in a resolved skill.

    Computed after flattening and used for display and safety decisions.
    """

    total_tools: int = Field(0, ge=0, description="Total number of tools")
    read_only_tools: int = Field(0, ge=0, description="Number of read-only tools")
    write_tools: int = Field(0, ge=0, description="Number of write-capable tools")
    checkpointable_tools: int = Field(
        0, ge=0, description="Number of checkpointable tools"
    )
    has_side_effects: bool = Field(
        False, description="Whether any tool has side effects"
    )
    mcp_sources: int = Field(0, ge=0, description="Number of MCP server sources")
    agent_skill_sources: int = Field(
        0, ge=0, description="Number of Agent Skill sources"
    )

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


# ---------------------------------------------------------------------------
# Skill
# ---------------------------------------------------------------------------


class Skill(BaseModel):
    """Domain model for a Skill.

    A Skill is a namespaced, reusable collection of tools.  The **namespaced
    name** (``namespace/short_name``) is the unique identifier.

    Created via ``agents skill add --config <file>``.
    """

    # Identity -- namespace/name format
    name: str = Field(
        ...,
        min_length=1,
        description="Namespaced skill name (namespace/short_name)",
    )
    description: str = Field(
        ...,
        min_length=1,
        description="Short description of what the skill provides",
    )

    # Tool references -- named tools from the Tool Registry
    tool_refs: list[str] = Field(
        default_factory=list,
        description="Named tool references (namespace/name)",
    )

    # Included skills -- recursive composition
    includes: list[SkillInclude] = Field(
        default_factory=list,
        description="Other skills to include",
    )

    # Inline tool definitions
    anonymous_tools: list[SkillInlineTool] = Field(
        default_factory=list,
        description="Anonymous inline tool definitions",
    )

    # External sources
    mcp_servers: list[SkillMcpSource] = Field(
        default_factory=list,
        description="MCP server sources",
    )
    agent_skills: list[SkillAgentSource] = Field(
        default_factory=list,
        description="Agent Skills Standard folder sources",
    )

    # Per-tool metadata overrides
    overrides: dict[str, dict[str, Any]] = Field(
        default_factory=dict,
        description="Per-tool metadata overrides keyed by tool name",
    )

    # -- Name format validation ------------------------------------------------

    @field_validator("name")
    @classmethod
    def validate_name_format(cls: type[Skill], v: str) -> str:
        """Validate skill name matches namespace/name pattern."""
        if not _SKILL_NAME_PATTERN.match(v):
            raise ValueError(
                f"Skill name must match 'namespace/name' pattern "
                f"(alphanumeric, hyphens, underscores): got '{v}'"
            )
        return v

    # -- Properties ------------------------------------------------------------

    @property
    def namespace(self) -> str:
        """Extract the namespace portion from the name."""
        return self.name.split("/", 1)[0]

    @property
    def short_name(self) -> str:
        """Extract the short name portion from the name."""
        return self.name.split("/", 1)[1]

    # -- Factories & serialization --------------------------------------------

    @classmethod
    def from_config(cls, config: dict[str, Any]) -> Skill:
        """Create a Skill from a YAML configuration dict.

        The config follows the spec schema (see ``docs/specification.md``
        Skill Configuration Files section).
        """
        if "name" not in config:
            raise ValueError("Skill config must include 'name'")
        if "description" not in config:
            raise ValueError("Skill config must include 'description'")

        # Build includes from list of dicts or strings
        includes_raw: list[dict[str, Any] | str] = config.get("includes", [])
        includes: list[SkillInclude] = []
        for item in includes_raw:
            if isinstance(item, str):
                includes.append(SkillInclude(name=item))
            else:
                includes.append(SkillInclude.model_validate(item))

        # Build anonymous tools
        anon_raw: list[dict[str, Any]] = config.get("anonymous_tools", [])
        anonymous_tools = [SkillInlineTool.model_validate(t) for t in anon_raw]

        # Build MCP sources
        mcp_raw: list[dict[str, Any]] = config.get("mcp_servers", [])
        mcp_servers = [SkillMcpSource.model_validate(m) for m in mcp_raw]

        # Build agent skill sources
        agent_raw: list[dict[str, Any] | str] = config.get("agent_skills", [])
        agent_skills: list[SkillAgentSource] = []
        for item in agent_raw:
            if isinstance(item, str):
                agent_skills.append(SkillAgentSource(path=item))
            else:
                agent_skills.append(SkillAgentSource.model_validate(item))

        return cls(
            name=config["name"],
            description=config["description"],
            tool_refs=config.get("tools", config.get("tool_refs", [])),
            includes=includes,
            anonymous_tools=anonymous_tools,
            mcp_servers=mcp_servers,
            agent_skills=agent_skills,
            overrides=config.get("overrides", {}),
        )

    def as_cli_dict(self) -> OrderedDict[str, Any]:
        """Return a stable-ordered dictionary for CLI rendering.

        Keys are ordered for consistent display across output formats.
        """
        result: OrderedDict[str, Any] = OrderedDict()
        result["name"] = self.name
        result["description"] = self.description
        result["namespace"] = self.namespace
        result["short_name"] = self.short_name

        if self.tool_refs:
            result["tool_refs"] = list(self.tool_refs)

        if self.includes:
            result["includes"] = [
                {
                    "name": inc.name,
                    **({"overrides": inc.overrides} if inc.overrides else {}),
                }
                for inc in self.includes
            ]

        if self.anonymous_tools:
            result["anonymous_tools"] = [
                {
                    "description": t.description,
                    "source": t.source.value,
                    **({"code": t.code} if t.code else {}),
                    "timeout": t.timeout,
                }
                for t in self.anonymous_tools
            ]

        if self.mcp_servers:
            result["mcp_servers"] = [
                {
                    "server": m.server,
                    **({"tools": m.tools} if m.tools is not None else {}),
                    **({"env": m.env} if m.env else {}),
                }
                for m in self.mcp_servers
            ]

        if self.agent_skills:
            result["agent_skills"] = [{"path": a.path} for a in self.agent_skills]

        if self.overrides:
            result["overrides"] = dict(self.overrides)

        return result

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
        use_enum_values=False,
    )


# ---------------------------------------------------------------------------
# Resolved tool entry (output of SkillResolver)
# ---------------------------------------------------------------------------


class ResolvedToolEntry(BaseModel):
    """A single tool entry in the resolved (flattened) tool set.

    Tracks origin for debugging and override application.
    """

    name: str = Field(..., description="Tool name (or generated key for inline)")
    source_skill: str = Field(..., description="Skill that contributed this tool")
    is_inline: bool = Field(False, description="Whether this is an inline tool")
    inline_tool: SkillInlineTool | None = Field(
        default=None, description="Inline tool definition (if is_inline)"
    )
    overrides: dict[str, Any] = Field(
        default_factory=dict, description="Applied overrides"
    )

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


# ---------------------------------------------------------------------------
# Non-overridable fields (used by SkillResolver)
# ---------------------------------------------------------------------------

_NON_OVERRIDABLE_FIELDS: frozenset[str] = frozenset(
    {"name", "source_skill", "is_inline"}
)
"""Fields that may never be overridden via ``SkillInclude.overrides``.

Attempting to override any of these raises ``ValueError`` with the
originating skill name and tool name for traceability.
"""


def _validate_override_keys(
    overrides: dict[str, Any],
    *,
    include_path: str,
    skill_name: str,
) -> None:
    """Raise ``ValueError`` if *overrides* contains non-overridable keys.

    Args:
        overrides: The override dict to validate.
        include_path: Human-readable include chain for error context.
        skill_name: The skill that declared the include.

    Raises:
        ValueError: If any key in *overrides* is in
            ``_NON_OVERRIDABLE_FIELDS``.
    """
    bad_keys = _NON_OVERRIDABLE_FIELDS & overrides.keys()
    if bad_keys:
        sorted_bad = ", ".join(sorted(bad_keys))
        raise ValueError(
            f"Non-overridable field(s) {sorted_bad} in overrides "
            f"from skill '{skill_name}' (include path: {include_path})"
        )


# ---------------------------------------------------------------------------
# SkillResolver
# ---------------------------------------------------------------------------


class SkillResolver:
    """Resolves a skill into its flattened set of tools.

    Handles recursive include resolution with cycle detection,
    tool de-duplication (last-wins for overrides), and deterministic
    ordering of the output.

    **Deterministic ordering guarantee**: The output list is ordered by
    *include-order-first, then definition-order*:

    1. Tools from depth-first traversal of includes (leftmost include
       recursed first, its includes before its own tool_refs).
    2. Tool refs from the current skill, in definition order.
    3. Inline tools from the current skill, in definition order.
    4. MCP tools, then agent skill tools, in definition order.

    When a tool name appears multiple times (from overlapping includes),
    the first occurrence determines position in the list, but the **last
    occurrence's entry metadata wins** (last-wins de-duplication).

    **Per-include overrides**: Each ``SkillInclude`` may carry an
    ``overrides`` dict that is applied (shallow-merged) to tool entries
    resolved from the included skill. Fields listed in
    ``_NON_OVERRIDABLE_FIELDS`` are rejected with a ``ValueError``.

    Example:
        ```python
        resolver = SkillResolver()

        # Resolve tools
        entries = resolver.resolve_tools(skill, skill_lookup_dict)

        # Compute summary
        summary = resolver.compute_capability_summary(skill, entries)
        ```

    Cycle detection raises ``ValueError`` with a human-readable path trace:

    ```
    Cycle detected in skill includes: local/a -> local/b -> local/a
    ```
    """

    def resolve_tools(
        self,
        skill: Skill,
        skill_lookup: dict[str, Skill],
    ) -> list[ResolvedToolEntry]:
        """Resolve the flattened tool set for a skill.

        Args:
            skill: The root skill to resolve.
            skill_lookup: Map of skill names to Skill objects for include
                resolution.

        Returns:
            Ordered list of resolved tool entries.

        Raises:
            ValueError: If a cycle is detected in skill includes.
        """
        seen_tools: dict[str, ResolvedToolEntry] = {}
        order: list[str] = []
        self._resolve_recursive(
            skill=skill,
            skill_lookup=skill_lookup,
            seen_tools=seen_tools,
            order=order,
            visited=set(),
            path=[],
        )
        return [seen_tools[name] for name in order]

    def _resolve_recursive(
        self,
        skill: Skill,
        skill_lookup: dict[str, Skill],
        seen_tools: dict[str, ResolvedToolEntry],
        order: list[str],
        visited: set[str],
        path: list[str],
    ) -> None:
        """Recursively resolve includes, collecting tools along the way.

        Per-include overrides from ``SkillInclude.overrides`` are applied
        to the tool entries contributed by the included skill after
        recursion completes.  Non-overridable fields (see
        ``_NON_OVERRIDABLE_FIELDS``) are rejected with a ``ValueError``
        that includes the include path for traceability.
        """
        if skill.name in visited:
            cycle_path = " -> ".join([*path, skill.name])
            raise ValueError(f"Cycle detected in skill includes: {cycle_path}")

        visited.add(skill.name)
        path.append(skill.name)

        # 1. Resolve includes first (depth-first)
        for include in skill.includes:
            included_skill = skill_lookup.get(include.name)
            if included_skill is None:
                raise ValueError(
                    f"Included skill '{include.name}' not found "
                    f"(referenced from '{skill.name}')"
                )
            # Snapshot keys before recursing so we can identify new entries
            keys_before = set(seen_tools.keys())

            self._resolve_recursive(
                skill=included_skill,
                skill_lookup=skill_lookup,
                seen_tools=seen_tools,
                order=order,
                visited=set(visited),
                path=list(path),
            )

            # Apply per-include overrides to entries added by this include
            if include.overrides:
                include_path = " -> ".join([*path, include.name])
                _validate_override_keys(
                    include.overrides,
                    include_path=include_path,
                    skill_name=skill.name,
                )
                new_keys = set(seen_tools.keys()) - keys_before
                for key in new_keys:
                    entry = seen_tools[key]
                    merged = {**entry.overrides, **include.overrides}
                    seen_tools[key] = entry.model_copy(update={"overrides": merged})

        # 2. Add tool_refs from this skill
        for ref in skill.tool_refs:
            entry = ResolvedToolEntry(
                name=ref,
                source_skill=skill.name,
                is_inline=False,
            )
            # Apply overrides from the include that pulled this skill
            if ref in skill.overrides:
                entry = entry.model_copy(
                    update={"overrides": dict(skill.overrides[ref])}
                )
            if ref not in seen_tools:
                order.append(ref)
            seen_tools[ref] = entry

        # 3. Add anonymous (inline) tools
        for idx, inline in enumerate(skill.anonymous_tools):
            key = f"{skill.name}/_anon_{idx}"
            entry = ResolvedToolEntry(
                name=key,
                source_skill=skill.name,
                is_inline=True,
                inline_tool=inline,
            )
            if key not in seen_tools:
                order.append(key)
            seen_tools[key] = entry

        # 4. Add MCP server tools (as references)
        for mcp in skill.mcp_servers:
            if mcp.tools:
                for tool_name in mcp.tools:
                    mcp_key = f"mcp:{mcp.server}/{tool_name}"
                    entry = ResolvedToolEntry(
                        name=mcp_key,
                        source_skill=skill.name,
                        is_inline=False,
                    )
                    if mcp_key not in seen_tools:
                        order.append(mcp_key)
                    seen_tools[mcp_key] = entry

        # 5. Add agent skill sources
        for agent in skill.agent_skills:
            agent_key = f"agent_skill:{agent.path}"
            entry = ResolvedToolEntry(
                name=agent_key,
                source_skill=skill.name,
                is_inline=False,
            )
            if agent_key not in seen_tools:
                order.append(agent_key)
            seen_tools[agent_key] = entry

        path.pop()

    def compute_capability_summary(
        self,
        skill: Skill,
        resolved: list[ResolvedToolEntry],
    ) -> SkillCapabilitySummary:
        """Compute a capability summary from resolved tool entries.

        Args:
            skill: The original skill (for MCP/agent counts).
            resolved: The resolved tool entries.

        Returns:
            Aggregated capability summary.
        """
        read_only_count = 0
        write_count = 0
        checkpoint_count = 0
        has_side_effects = False

        for entry in resolved:
            if entry.is_inline and entry.inline_tool is not None:
                cap = entry.inline_tool.capability
                if cap is not None:
                    if cap.read_only:
                        read_only_count += 1
                    if cap.writes:
                        write_count += 1
                    if cap.checkpointable:
                        checkpoint_count += 1
                    if cap.side_effects:
                        has_side_effects = True

        return SkillCapabilitySummary(
            total_tools=len(resolved),
            read_only_tools=read_only_count,
            write_tools=write_count,
            checkpointable_tools=checkpoint_count,
            has_side_effects=has_side_effects,
            mcp_sources=len(skill.mcp_servers),
            agent_skill_sources=len(skill.agent_skills),
        )
