"""Skill class with lazy loading support.

This module provides the Skill class that implements lazy loading -
skills are only loaded when their tools are actually invoked.
"""

from __future__ import annotations

import importlib
import importlib.util
import sys
from abc import ABC, abstractmethod
from pathlib import Path
from types import ModuleType
from typing import Any, Callable

from oclawma.skills.base import (
    SkillLoadError,
    SkillMetadata,
    SkillToolError,
)
from oclawma.skills.manifest import SkillManifest


class LazySkill(ABC):
    """Abstract base class for skills with lazy loading.

    A skill is a collection of related tools that can be loaded on-demand.
    The skill system uses lazy loading - skills are only loaded when
    one of their tools is invoked.

    Attributes:
        metadata: SkillMetadata with information about the skill
        _loaded: Whether the skill's tools have been loaded
        _tools: Dictionary of loaded tools (name -> tool instance)
    """

    def __init__(self, metadata: SkillMetadata) -> None:
        """Initialize the skill with metadata.

        Args:
            metadata: Metadata about the skill
        """
        self.metadata = metadata
        self._loaded = False
        self._tools: dict[str, Any] = {}

    @property
    def name(self) -> str:
        """Get the skill name."""
        return self.metadata.name

    @property
    def is_loaded(self) -> bool:
        """Check if the skill has been loaded."""
        return self._loaded

    @property
    def tools(self) -> dict[str, Any]:
        """Get the loaded tools.

        Returns:
            Dictionary mapping tool names to tool instances

        Note:
            This will trigger loading if not already loaded.
        """
        if not self._loaded:
            self._load()
        return self._tools

    def get_tool(self, tool_name: str) -> Any:
        """Get a specific tool by name.

        Args:
            tool_name: Name of the tool to get

        Returns:
            The tool instance

        Raises:
            SkillToolError: If the tool is not found
        """
        if not self._loaded:
            self._load()

        if tool_name not in self._tools:
            raise SkillToolError(
                f"Tool '{tool_name}' not found in skill '{self.name}'", skill_name=self.name
            )

        return self._tools[tool_name]

    def has_tool(self, tool_name: str) -> bool:
        """Check if the skill provides a specific tool.

        Args:
            tool_name: Name of the tool to check

        Returns:
            True if the skill provides this tool
        """
        if self._loaded:
            return tool_name in self._tools

        # For unloaded skills, we can't know without loading
        # Subclasses may override to provide manifest-based checks
        return False

    def load(self) -> None:
        """Explicitly load the skill.

        This is called automatically when tools are accessed,
        but can be called explicitly to preload the skill.
        """
        if not self._loaded:
            self._load()

    def unload(self) -> None:
        """Unload the skill to free resources.

        After unloading, the skill will be reloaded on next access.
        """
        self._tools.clear()
        self._loaded = False
        self._on_unload()

    @abstractmethod
    def _load(self) -> None:
        """Load the skill's tools.

        This is called automatically when tools are needed.
        Implementations should populate self._tools.
        """
        ...

    def _on_unload(self) -> None:
        """Called when the skill is unloaded.

        Override this to perform cleanup.
        """
        return  # Default no-op; override in subclasses

    async def execute_tool(self, tool_name: str, **params: Any) -> Any:
        """Execute a tool by name.

        Args:
            tool_name: Name of the tool to execute
            **params: Parameters to pass to the tool

        Returns:
            Tool execution result

        Raises:
            SkillToolError: If the tool doesn't exist or fails
        """
        tool = self.get_tool(tool_name)

        try:
            # Assuming tool has an async execute method
            if hasattr(tool, "execute"):
                if callable(tool.execute):
                    return await tool.execute(**params)
                else:
                    raise SkillToolError(
                        f"Tool '{tool_name}' execute is not callable", skill_name=self.name
                    )
            else:
                raise SkillToolError(
                    f"Tool '{tool_name}' does not have an execute method", skill_name=self.name
                )
        except Exception as e:
            if isinstance(e, SkillToolError):
                raise
            raise SkillToolError(
                f"Tool '{tool_name}' execution failed: {e}", skill_name=self.name, cause=e
            ) from e

    def list_tools(self) -> list[str]:
        """List the names of tools provided by this skill.

        Returns:
            List of tool names
        """
        if self._loaded:
            return list(self._tools.keys())
        # Return registered function names before loading
        return list(self._tool_functions.keys())

    def get_tool_schemas(self) -> list[dict[str, Any]]:
        """Get schemas for all tools in this skill.

        Returns:
            List of tool schema dictionaries
        """
        if not self._loaded:
            self._load()

        schemas = []
        for name, tool in self._tools.items():
            if hasattr(tool, "schema"):
                schema = tool.schema
                if hasattr(schema, "to_dict"):
                    schemas.append(schema.to_dict())
                elif isinstance(schema, dict):
                    schemas.append(schema)
                else:
                    schemas.append({"name": name})
            else:
                schemas.append({"name": name})

        return schemas

    def estimate_tokens(self) -> int:
        """Estimate the token count for this skill.

        Returns:
            Estimated token count
        """
        if self._loaded:
            # Calculate from actual tools
            total = 0
            for tool in self._tools.values():
                if hasattr(tool, "schema"):
                    # Rough estimation: 1 token per 4 characters
                    schema_str = str(tool.schema)
                    total += len(schema_str) // 4
            return total
        else:
            # Use cached estimate from metadata
            return self.metadata.estimated_tokens


class ManifestSkill(LazySkill):
    """A skill loaded from a manifest file.

    This skill type loads its implementation dynamically based on
    the entry_point specified in the manifest.
    """

    def __init__(self, manifest: SkillManifest, manifest_path: Path | None = None) -> None:
        """Initialize from a manifest.

        Args:
            manifest: The skill manifest
            manifest_path: Optional path to the manifest file
        """
        metadata = manifest.to_metadata(manifest_path)
        super().__init__(metadata)
        self._manifest = manifest
        self._manifest_path = manifest_path
        self._module: ModuleType | None = None

    @property
    def manifest(self) -> SkillManifest:
        """Get the skill manifest."""
        return self._manifest

    def has_tool(self, tool_name: str) -> bool:
        """Check if the skill provides a specific tool.

        This checks the manifest without loading the skill.
        """
        # Check manifest first without loading
        for tool in self._manifest.tools:
            if tool.name == tool_name:
                return True

        # Fall back to loaded tools
        return super().has_tool(tool_name)

    def list_tools(self) -> list[str]:
        """List the names of tools provided by this skill.

        Returns tool names from manifest without loading.
        """
        if self._loaded:
            return super().list_tools()

        # Return from manifest without loading
        return [tool.name for tool in self._manifest.tools]

    def _load(self) -> None:
        """Load the skill implementation from the entry point."""
        if self._loaded:
            return

        entry_point = self._manifest.entry_point

        if not entry_point:
            raise SkillLoadError(
                f"Skill '{self.name}' has no entry_point defined", skill_name=self.name
            )

        try:
            # Parse entry_point (format: "module.path:ClassName")
            if ":" in entry_point:
                module_path, class_name = entry_point.rsplit(":", 1)
            else:
                module_path = entry_point
                class_name = None

            # If manifest_path is set, add its directory to sys.path
            if self._manifest_path:
                skill_dir = self._manifest_path.parent
                if str(skill_dir) not in sys.path:
                    sys.path.insert(0, str(skill_dir))

            # Load the module
            self._module = importlib.import_module(module_path)

            # If a class is specified, instantiate it
            if class_name:
                skill_class = getattr(self._module, class_name)
                skill_instance = skill_class(self.metadata)

                # Copy tools from instance
                if hasattr(skill_instance, "_tools"):
                    self._tools = skill_instance._tools
                elif hasattr(skill_instance, "tools"):
                    self._tools = skill_instance.tools
                else:
                    # Try to find tools by convention
                    self._discover_tools(skill_instance)
            else:
                # Discover tools from module
                self._discover_tools_from_module(self._module)

            self._loaded = True

        except ImportError as e:
            raise SkillLoadError(
                f"Failed to import skill '{self.name}' from '{entry_point}': {e}",
                skill_name=self.name,
                cause=e,
            ) from e
        except Exception as e:
            raise SkillLoadError(
                f"Failed to load skill '{self.name}': {e}", skill_name=self.name, cause=e
            ) from e

    def _discover_tools(self, instance: Any) -> None:
        """Discover tools from a skill instance."""
        # Look for tool methods (convention: methods starting with 'tool_')
        for attr_name in dir(instance):
            if attr_name.startswith("tool_"):
                tool_name = attr_name[5:]  # Remove 'tool_' prefix
                self._tools[tool_name] = getattr(instance, attr_name)

    def _discover_tools_from_module(self, module: ModuleType) -> None:
        """Discover tools from a module."""
        # Look for exported tools
        if hasattr(module, "__all__"):
            for name in module.__all__:
                obj = getattr(module, name)
                if hasattr(obj, "is_tool") or callable(obj):
                    self._tools[name] = obj


class FunctionSkill(LazySkill):
    """A skill composed of simple functions.

    This is useful for simple skills that don't need a full module.
    """

    def __init__(
        self, metadata: SkillMetadata, tools: dict[str, Callable[..., Any]] | None = None
    ) -> None:
        """Initialize with tools.

        Args:
            metadata: Skill metadata
            tools: Dictionary of tool functions
        """
        super().__init__(metadata)
        self._tool_functions = tools or {}

    def register_tool(self, name: str, func: Callable[..., Any]) -> None:
        """Register a tool function.

        Args:
            name: Tool name
            func: Tool function
        """
        self._tool_functions[name] = func
        # If already loaded, update tools
        if self._loaded:
            self._tools[name] = func

    def list_tools(self) -> list[str]:
        """List the names of tools provided by this skill.

        Returns tool names from registered functions without loading.
        """
        return list(self._tool_functions.keys())

    def _load(self) -> None:
        """Load the tool functions."""
        if self._loaded:
            return

        self._tools = dict(self._tool_functions)
        self._loaded = True


# Re-export for backwards compatibility
Skill = LazySkill
