"""Skill YAML configuration schema, validation, and loading.

Provides :class:`SkillConfigSchema`, a Pydantic model that:

* Loads raw YAML (string or file) and validates it against the skill schema.
* Normalizes camelCase keys to snake_case before validation.
* Interpolates ``${ENV_VAR}`` placeholders from environment variables.
* Produces clear, actionable error messages for every validation failure.

Schema definition lives in ``docs/schema/skill.schema.yaml``.
Example configs live under ``examples/skills/``.

Based on:
- docs/specification.md — Skill Configuration Files + JSON Schema
- implementation_plan.md — Task C0.skill.schema
"""

from __future__ import annotations

import logging
import os
import re
from pathlib import Path
from typing import Any

import yaml
from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    field_validator,
)

logger = logging.getLogger(__name__)

# ────────────────────────────────────────────────────────────
# Constants
# ────────────────────────────────────────────────────────────

#: Pattern for ``<namespace>/<name>`` with hyphens, underscores, lowercase alphanum.
NAMESPACED_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*/[a-z0-9][a-z0-9_-]*$")

#: Pattern for ``${VAR}`` environment variable references.
_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")

#: Supported MCP transport protocols.
_VALID_TRANSPORTS = frozenset({"stdio", "sse", "streamable-http"})

#: camelCase → snake_case mapping for top-level and nested keys.
_CAMEL_TO_SNAKE: dict[str, str] = {
    "inlineTools": "inline_tools",
    "mcpServers": "mcp_servers",
    "agentSkillFolders": "agent_skill_folders",
    "inputSchema": "input_schema",
    "sideEffects": "side_effects",
    "toolFilter": "tool_filter",
}


# ────────────────────────────────────────────────────────────
# Nested models
# ────────────────────────────────────────────────────────────


class SkillToolRefSchema(BaseModel):
    """Schema for a tool reference within a skill.

    References a named tool from the Tool Registry by its namespaced name.
    """

    name: str = Field(
        ...,
        min_length=1,
        description="Fully qualified name of a tool in the Tool Registry.",
    )
    description: str | None = Field(
        default=None,
        description="Override the tool's registered description.",
    )
    writes: bool | None = Field(
        default=None,
        description="Override the tool's writes capability flag.",
    )
    checkpointable: bool | None = Field(
        default=None,
        description="Override the tool's checkpointable capability flag.",
    )

    @field_validator("name")
    @classmethod
    def validate_namespaced_name(cls, v: str) -> str:
        """Validate that the tool name follows namespace/name pattern."""
        if not NAMESPACED_NAME_RE.match(v):
            raise ValueError(
                f"Invalid tool reference name '{v}'. "
                "Names must follow the namespace/name format "
                "(e.g. 'builtin/read_file'). "
                "Only alphanumeric, hyphens, and underscores are allowed."
            )
        return v

    model_config = ConfigDict(
        str_strip_whitespace=True,
        extra="forbid",
    )


class SkillInlineToolSchema(BaseModel):
    """Schema for an anonymous inline tool definition within a skill.

    Inline tools are not registered in the Tool Registry and exist
    only within the scope of the skill that defines them.
    """

    name: str = Field(
        ...,
        min_length=1,
        description="Tool name, unique within this skill.",
    )
    description: str | None = Field(
        default=None,
        description="Human-readable description of the tool's purpose.",
    )
    source: str = Field(
        ...,
        description="Source type. Must be 'custom' for inline tools.",
    )
    code: str = Field(
        ...,
        min_length=1,
        description="Python code defining the tool's behavior.",
    )
    input_schema: dict[str, Any] | None = Field(
        default=None,
        description="JSON Schema describing the tool's input parameters.",
    )
    writes: bool = Field(
        False,
        description="Whether this tool performs write operations.",
    )
    checkpointable: bool = Field(
        False,
        description="Whether this tool supports checkpointing.",
    )
    side_effects: list[str] = Field(
        default_factory=list,
        description="Descriptions of side effects.",
    )

    @field_validator("source")
    @classmethod
    def validate_source(cls, v: str) -> str:
        """Validate that source is 'custom'."""
        if v != "custom":
            raise ValueError(
                f"Invalid inline tool source '{v}'. "
                "Inline tools must have source: custom."
            )
        return v

    model_config = ConfigDict(
        str_strip_whitespace=True,
        extra="forbid",
    )


class SkillIncludeSchema(BaseModel):
    """Schema for an included skill reference.

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

    name: str = Field(
        ...,
        min_length=1,
        description="Fully qualified skill name to include.",
    )
    description: str | None = Field(
        default=None,
        description="Override the included skill's description.",
    )

    @field_validator("name")
    @classmethod
    def validate_namespaced_name(cls, v: str) -> str:
        """Validate that the include name follows namespace/name pattern."""
        if not NAMESPACED_NAME_RE.match(v):
            raise ValueError(
                f"Invalid include name '{v}'. "
                "Names must follow the namespace/name format "
                "(e.g. 'local/file-reader'). "
                "Only alphanumeric, hyphens, and underscores are allowed."
            )
        return v

    model_config = ConfigDict(
        str_strip_whitespace=True,
        extra="forbid",
    )


class SkillMcpToolFilterSchema(BaseModel):
    """Schema for MCP server tool filtering.

    Controls which tools from the MCP server are exposed.
    """

    include: list[str] = Field(
        default_factory=list,
        description="Whitelist of tool names to expose.",
    )
    exclude: list[str] = Field(
        default_factory=list,
        description="Blacklist of tool names to hide.",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        extra="forbid",
    )


class SkillMcpServerSchema(BaseModel):
    """Schema for an MCP server specification within a skill.

    MCP servers expose remote tools via stdio, SSE, or streamable-http
    transport protocols.
    """

    name: str = Field(
        ...,
        min_length=1,
        description="Identifier for the MCP server.",
    )
    transport: str = Field(
        ...,
        description="Transport protocol: stdio | sse | streamable-http.",
    )
    command: str | None = Field(
        default=None,
        description="Command to start the server (required for stdio).",
    )
    args: list[str] = Field(
        default_factory=list,
        description="Command-line arguments for the server command.",
    )
    env: dict[str, str] = Field(
        default_factory=dict,
        description="Environment variables for the server.",
    )
    url: str | None = Field(
        default=None,
        description="Server URL (required for sse/streamable-http).",
    )
    headers: dict[str, str] = Field(
        default_factory=dict,
        description="HTTP headers for remote server connections.",
    )
    tool_filter: SkillMcpToolFilterSchema | None = Field(
        default=None,
        description="Filter which tools to expose.",
    )

    @field_validator("transport")
    @classmethod
    def validate_transport(cls, v: str) -> str:
        """Validate transport is a supported protocol."""
        v_lower = v.lower()
        if v_lower not in _VALID_TRANSPORTS:
            valid = ", ".join(sorted(_VALID_TRANSPORTS))
            raise ValueError(
                f"Invalid MCP transport '{v}'. Allowed transports: {valid}."
            )
        return v_lower

    model_config = ConfigDict(
        str_strip_whitespace=True,
        extra="forbid",
    )


class SkillAgentFolderSchema(BaseModel):
    """Schema for an Agent Skills Standard folder reference.

    Points to a directory containing a SKILL.md file that defines
    an Agent Skills Standard tool bundle.
    """

    path: str = Field(
        ...,
        min_length=1,
        description="Path to the folder containing SKILL.md.",
    )
    name: str | None = Field(
        default=None,
        description="Override the skill bundle name.",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        extra="forbid",
    )


# ────────────────────────────────────────────────────────────
# Main schema model
# ────────────────────────────────────────────────────────────


class SkillConfigSchema(BaseModel):
    """Pydantic model for a skill YAML configuration file.

    Validates all fields described in ``docs/schema/skill.schema.yaml``.

    Create instances via the factory class methods:
    - :meth:`from_yaml` — parse a YAML string
    - :meth:`from_yaml_file` — parse a YAML file from disk
    """

    # ─── Identity ───────────────────────────────────────────
    name: str = Field(
        ...,
        description="Namespaced skill name in namespace/name format.",
    )
    description: str | None = Field(
        default=None,
        description="Human-readable description of what this skill provides.",
    )

    # ─── Tool References ────────────────────────────────────
    tools: list[SkillToolRefSchema] = Field(
        default_factory=list,
        description="References to named tools from the Tool Registry.",
    )

    # ─── Inline Tools ───────────────────────────────────────
    inline_tools: list[SkillInlineToolSchema] = Field(
        default_factory=list,
        description="Anonymous tool definitions within this skill.",
    )

    # ─── Includes ───────────────────────────────────────────
    includes: list[SkillIncludeSchema] = Field(
        default_factory=list,
        description="Other skills whose tools are merged in.",
    )

    # ─── MCP Servers ────────────────────────────────────────
    mcp_servers: list[SkillMcpServerSchema] = Field(
        default_factory=list,
        description="MCP server specifications.",
    )

    # ─── Agent Skill Folders ────────────────────────────────
    agent_skill_folders: list[SkillAgentFolderSchema] = Field(
        default_factory=list,
        description="Agent Skills Standard folder references.",
    )

    # ─── Pydantic Config ───────────────────────────────────
    model_config = ConfigDict(
        str_strip_whitespace=True,
        extra="forbid",
    )

    # ────────────────────────────────────────────────────────
    # Field validators
    # ────────────────────────────────────────────────────────

    @field_validator("name")
    @classmethod
    def validate_namespaced_name(cls, v: str) -> str:
        """Validate that name follows namespace/name pattern."""
        if not NAMESPACED_NAME_RE.match(v):
            raise ValueError(
                f"Invalid skill name '{v}'. "
                "Names must follow the namespace/name format "
                "(e.g. 'local/file-reader'). "
                "Only alphanumeric, hyphens, and underscores are allowed."
            )
        return v

    # ────────────────────────────────────────────────────────
    # Factory class methods
    # ────────────────────────────────────────────────────────

    @classmethod
    def from_yaml(cls, yaml_string: str) -> SkillConfigSchema:
        """Parse and validate a skill YAML string.

        Args:
            yaml_string: Raw YAML content.

        Returns:
            Validated ``SkillConfigSchema`` instance.

        Raises:
            ValueError: If the YAML is not a mapping or is empty.
            pydantic.ValidationError: If schema validation fails.
        """
        if yaml_string is None:
            raise ValueError("YAML string cannot be None. Provide a valid YAML string.")
        if not yaml_string.strip():
            raise ValueError(
                "YAML string is empty. Provide a valid skill YAML configuration."
            )

        raw = yaml.safe_load(yaml_string)
        if not isinstance(raw, dict):
            raise ValueError(
                f"Skill YAML must be a mapping (key: value), got {type(raw).__name__}."
            )

        normalized = _normalize_keys(raw)
        interpolated = _interpolate_env_vars(normalized)
        return cls.model_validate(interpolated)

    @classmethod
    def from_yaml_file(cls, path: str | Path) -> SkillConfigSchema:
        """Load and validate a skill YAML file from disk.

        Args:
            path: Path to the YAML file.

        Returns:
            Validated ``SkillConfigSchema`` instance.

        Raises:
            FileNotFoundError: If the file does not exist.
            ValueError: If file content is invalid.
            pydantic.ValidationError: If schema validation fails.
        """
        if path is None:
            raise ValueError(
                "File path cannot be None. Provide a valid path to a skill YAML file."
            )

        filepath = Path(path)
        if not filepath.exists():
            raise FileNotFoundError(
                f"Skill YAML file not found: {filepath}. "
                "Check the file path and try again."
            )
        if not filepath.is_file():
            raise ValueError(
                f"Path is not a file: {filepath}. "
                "Provide a path to a YAML file, not a directory."
            )

        content = filepath.read_text(encoding="utf-8")
        return cls.from_yaml(content)


# ────────────────────────────────────────────────────────────
# Internal helpers
# ────────────────────────────────────────────────────────────


def _normalize_keys(data: dict[str, Any]) -> dict[str, Any]:
    """Normalize camelCase keys to snake_case recursively.

    Logs a warning for every camelCase key that is converted.
    """
    result: dict[str, Any] = {}
    for key, value in data.items():
        snake_key = _CAMEL_TO_SNAKE.get(key, key)
        if snake_key != key:
            logger.warning(
                "Deprecated camelCase key '%s' normalized to '%s'. "
                "Please update your YAML to use snake_case.",
                key,
                snake_key,
            )

        if isinstance(value, dict):
            result[snake_key] = _normalize_keys(value)
        elif isinstance(value, list):
            result[snake_key] = [
                _normalize_keys(item) if isinstance(item, dict) else item
                for item in value
            ]
        else:
            result[snake_key] = value
    return result


def _interpolate_env_vars(data: dict[str, Any]) -> dict[str, Any]:
    """Replace ``${VAR}`` references with environment variable values.

    Only string values are interpolated. Missing environment variables
    are left as-is (no error) to allow deferred resolution.
    """
    result: dict[str, Any] = {}
    for key, value in data.items():
        if isinstance(value, str):
            result[key] = _ENV_VAR_RE.sub(_env_replacer, value)
        elif isinstance(value, dict):
            result[key] = _interpolate_env_vars(value)
        elif isinstance(value, list):
            result[key] = [
                (
                    _interpolate_env_vars(item)
                    if isinstance(item, dict)
                    else (
                        _ENV_VAR_RE.sub(_env_replacer, item)
                        if isinstance(item, str)
                        else item
                    )
                )
                for item in value
            ]
        else:
            result[key] = value
    return result


def _env_replacer(match: re.Match[str]) -> str:
    """Replace a single ``${VAR}`` match with its env value."""
    var_name = match.group(1)
    return os.environ.get(var_name, match.group(0))
