"""Agent Skills discovery for CleverAgents v3.

Scans configured directories for Agent Skills Standard folders (each
containing a ``SKILL.md`` file) and produces
:class:`~cleveragents.tool.runtime.ToolSpec` instances ready for
registration in the :class:`~cleveragents.tool.registry.ToolRegistry`.

## Discovery Algorithm

1. Read the comma-separated ``skills.agent_skills_paths`` config value.
2. For each directory, find immediate subdirectories containing a
   ``SKILL.md`` file.
3. Parse the ``SKILL.md`` front-matter (YAML between ``---`` fences) to
   extract tool name, description, and optional input/output schemas.
4. Construct a ``ToolSpec`` with ``source="agent_skills"`` and
   ``source_metadata`` containing the filesystem path.

## Conflict Handling

When a discovered tool name already exists in the ``ToolRegistry`` the
discovery process records a ``DiscoveryConflict`` rather than silently
overwriting.  Callers decide whether to skip, warn, or error.

Based on ``docs/specification.md`` Agent Skills discovery section and
issue #161.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import yaml

from cleveragents.tool.runtime import ToolSpec

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class DiscoveredAgentSkill:
    """Metadata extracted from a single Agent Skills Standard folder."""

    name: str
    description: str
    path: str
    input_schema: dict[str, Any] = field(default_factory=dict)
    output_schema: dict[str, Any] = field(default_factory=dict)


@dataclass(frozen=True, slots=True)
class DiscoveryConflict:
    """Records a name collision during agent skills discovery."""

    tool_name: str
    existing_source: str
    discovered_path: str


@dataclass(slots=True)
class DiscoveryResult:
    """Aggregate result of an agent skills discovery scan."""

    discovered: list[DiscoveredAgentSkill] = field(default_factory=list)
    conflicts: list[DiscoveryConflict] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)


# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------

_FRONT_MATTER_SEP = "---"


def _parse_skill_md(skill_md_path: Path) -> dict[str, Any] | None:
    """Parse the YAML front-matter from a ``SKILL.md`` file.

    Returns the parsed dict or ``None`` if the file has no valid
    front-matter block.
    """
    if not skill_md_path.is_file():
        return None

    text = skill_md_path.read_text(encoding="utf-8")
    lines = text.split("\n")

    # Find opening and closing ``---`` fences
    fence_indices: list[int] = []
    for idx, line in enumerate(lines):
        if line.strip() == _FRONT_MATTER_SEP:
            fence_indices.append(idx)
            if len(fence_indices) == 2:
                break

    if len(fence_indices) < 2:
        return None

    yaml_block = "\n".join(lines[fence_indices[0] + 1 : fence_indices[1]])
    if not yaml_block.strip():
        return None

    try:
        data = yaml.safe_load(yaml_block)
    except yaml.YAMLError as exc:
        logger.warning("Failed to parse SKILL.md at %s: %s", skill_md_path, exc)
        return None

    if not isinstance(data, dict):
        return None

    return data


def _skill_from_front_matter(
    data: dict[str, Any],
    folder_path: Path,
) -> DiscoveredAgentSkill | None:
    """Build a ``DiscoveredAgentSkill`` from parsed front-matter.

    Returns ``None`` when required fields are missing.
    """
    name = data.get("name")
    if not name or not isinstance(name, str):
        # Fall back to folder name
        name = folder_path.name

    description = data.get("description", "")
    if not isinstance(description, str):
        description = str(description)
    if not description:
        description = f"Agent skill from {folder_path.name}"

    return DiscoveredAgentSkill(
        name=name,
        description=description,
        path=str(folder_path),
        input_schema=data.get("input_schema", {}),
        output_schema=data.get("output_schema", {}),
    )


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

_AGENT_SKILLS_NAMESPACE = "agent_skills"


def _noop_handler(**_kwargs: Any) -> dict[str, Any]:
    """Placeholder handler for discovered agent skill tools.

    Agent skill tools are executed via the Agent Skills runtime; this
    placeholder satisfies the ``ToolSpec.handler`` requirement.
    """
    return {"status": "agent_skill_placeholder"}


def parse_agent_skills_paths(raw_value: str) -> list[Path]:
    """Split a comma-separated path string into resolved ``Path`` objects.

    Empty segments and whitespace are stripped.  Tilde (``~``) is expanded.

    Args:
        raw_value: The raw config value (comma-separated directory paths).

    Returns:
        List of resolved ``Path`` objects.
    """
    if not raw_value:
        return []
    paths: list[Path] = []
    for segment in raw_value.split(","):
        segment = segment.strip()
        if segment:
            paths.append(Path(segment).expanduser().resolve())
    return paths


def scan_directory(directory: Path) -> list[DiscoveredAgentSkill]:
    """Scan a single directory for Agent Skills Standard folders.

    Each immediate subdirectory containing a ``SKILL.md`` file is treated
    as an agent skill.

    Args:
        directory: Root directory to scan.

    Returns:
        List of discovered agent skills.
    """
    if not directory.is_dir():
        logger.debug("Skipping non-directory path: %s", directory)
        return []

    results: list[DiscoveredAgentSkill] = []
    for child in sorted(directory.iterdir()):
        if not child.is_dir():
            continue
        skill_md = child / "SKILL.md"
        if not skill_md.is_file():
            continue

        data = _parse_skill_md(skill_md)
        if data is None:
            logger.debug("No valid front-matter in %s", skill_md)
            continue

        skill = _skill_from_front_matter(data, child)
        if skill is not None:
            results.append(skill)

    return results


def discover_agent_skills(
    paths: list[Path],
) -> DiscoveryResult:
    """Run a full agent skills discovery scan across all configured paths.

    Args:
        paths: Directories to scan for Agent Skills folders.

    Returns:
        ``DiscoveryResult`` containing discovered skills and any errors.
    """
    result = DiscoveryResult()

    for directory in paths:
        if not directory.exists():
            result.errors.append(f"Directory does not exist: {directory}")
            continue
        if not directory.is_dir():
            result.errors.append(f"Path is not a directory: {directory}")
            continue

        try:
            skills = scan_directory(directory)
            result.discovered.extend(skills)
        except OSError as exc:
            result.errors.append(f"Error scanning {directory}: {exc}")

    return result


def build_tool_spec(skill: DiscoveredAgentSkill) -> ToolSpec:
    """Convert a ``DiscoveredAgentSkill`` into a ``ToolSpec``.

    The tool name uses the ``agent_skills/<name>`` namespace pattern.

    Args:
        skill: Discovered agent skill metadata.

    Returns:
        A ``ToolSpec`` ready for registration.
    """
    namespaced_name = f"{_AGENT_SKILLS_NAMESPACE}/{skill.name}"
    return ToolSpec(
        name=namespaced_name,
        description=skill.description,
        input_schema=skill.input_schema,
        output_schema=skill.output_schema,
        handler=_noop_handler,
        source="agent_skills",
        source_metadata={
            "path": skill.path,
            "skill_name": skill.name,
        },
    )


def register_discovered_skills(
    discovered: list[DiscoveredAgentSkill],
    registry: Any,
    *,
    on_conflict: str = "skip",
) -> tuple[list[ToolSpec], list[DiscoveryConflict]]:
    """Register discovered agent skills into a ToolRegistry.

    Args:
        discovered: List of discovered agent skills.
        registry: A ``ToolRegistry`` instance.
        on_conflict: Conflict strategy — ``"skip"`` (default), ``"error"``,
            or ``"replace"``.

    Returns:
        Tuple of (registered_specs, conflicts).

    Raises:
        ValueError: When *on_conflict* is ``"error"`` and a collision occurs.
    """
    if not discovered:
        return [], []

    registered: list[ToolSpec] = []
    conflicts: list[DiscoveryConflict] = []

    for skill in discovered:
        spec = build_tool_spec(skill)
        existing = registry.get(spec.name)

        if existing is not None:
            conflict = DiscoveryConflict(
                tool_name=spec.name,
                existing_source=getattr(existing, "source", "unknown"),
                discovered_path=skill.path,
            )
            conflicts.append(conflict)

            if on_conflict == "error":
                raise ValueError(
                    f"Agent skill name collision: '{spec.name}' already "
                    f"registered (source: {conflict.existing_source}). "
                    f"Discovered at: {skill.path}"
                )
            if on_conflict == "replace":
                registry.remove(spec.name)
                registry.register(spec)
                registered.append(spec)
            else:
                # skip — do not register, record conflict
                logger.warning(
                    "Skipping agent skill '%s' — name collision with "
                    "existing tool (source: %s)",
                    spec.name,
                    conflict.existing_source,
                )
            continue

        registry.register(spec)
        registered.append(spec)

    return registered, conflicts
