"""
Abstract Tool base class for all function-calling tools.in ai-parrot framework.
"""
from typing import Dict, Any, Union, Optional, Type
from abc import ABC, abstractmethod
from pathlib import Path
from datetime import datetime
import traceback
from urllib.parse import urlparse, urlunparse
from pydantic import BaseModel, Field
from datamodel.parsers.json import json_decoder, json_encoder, JSONContent  # noqa  pylint: disable=E0611
from navconfig.logging import logging
from ..conf import BASE_STATIC_URL, STATIC_DIR, OUTPUT_DIR


logging.getLogger(name='matplotlib').setLevel(logging.INFO)
logging.getLogger(name='h5py').setLevel(logging.INFO)
logging.getLogger(name='datasets').setLevel(logging.WARNING)
logging.getLogger(name='numexpr').setLevel(logging.WARNING)
logging.getLogger(name='pymongo').setLevel(logging.WARNING)


class AbstractToolArgsSchema(BaseModel):
    """Base schema for tool arguments."""
    pass


class ToolResult(BaseModel):
    """Standardized tool result format."""
    success: bool = Field(default=True, description="Indicates if the tool executed successfully")
    status: str = Field(default="success", description="Status of the operation")
    result: Any = Field(description="The actual result of the tool operation")
    error: Optional[str] = Field(default=None, description="Error message if any")
    metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
    timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())

    # File output fields for tools that generate files/images
    files: Optional[list] = Field(
        default_factory=list, description="List of file paths generated by the tool"
    )
    images: Optional[list] = Field(
        default_factory=list, description="List of image paths generated by the tool"
    )

    # Voice-aware fields
    voice_text: Optional[str] = Field(default=None, description="Text optimized for speech")
    display_data: Optional[Dict[str, Any]] = Field(default=None, description="Visual content to display")


    @property
    def spoken_content(self) -> str:
        """Returns content for voice synthesis."""
        if self.voice_text:
            return self.voice_text
        return str(self.result) if self.result else ""

    @property
    def has_display_content(self) -> bool:
        """Check if there's visual content to display."""
        return self.display_data is not None


class AbstractTool(ABC):
    """
    Abstract base class for all tools in the ai-parrot framework.

    This class provides a unified interface for tools that can be used by both
    conversational bots and agents. It includes common functionality like:
    - Name and description management
    - JSON schema generation
    - File path management
    - Logging and error handling
    - Async/sync execution support
    """

    # Class attributes that should be set by subclasses
    name: str = None
    description: str = None
    args_schema: Type[BaseModel] = AbstractToolArgsSchema
    return_direct: bool = False
    routing_meta: Dict = None  # Set per-instance in __init__ to avoid mutable default

    def __init__(
        self,
        name: Optional[str] = None,
        description: Optional[str] = None,
        output_dir: Optional[Union[str, Path]] = None,
        base_url: Optional[str] = None,
        static_dir: Optional[Union[str, Path]] = None,
        routing_meta: Optional[Dict] = None,
        **kwargs
    ):
        """
        Initialize the tool.

        Args:
            name: Tool name (defaults to class name)
            description: Tool description
            output_dir: Directory for output files (if tool generates files)
            base_url: Base URL for serving static files
            static_dir: Static directory path
            routing_meta: Optional routing hints dict for CapabilityRegistry.
                Supported keys: ``"description"``, ``"not_for"``.
            **kwargs: Additional configuration
        """
        # routing_meta — per-instance to avoid shared mutable default
        self.routing_meta: Dict = routing_meta if routing_meta is not None else {}

        # Store initialization parameters for cloning
        self._init_kwargs = {
            'name': name,
            'description': description,
            'output_dir': output_dir,
            'base_url': base_url,
            'static_dir': static_dir,
            **kwargs
        }

        # Set name and description
        self.name = name or self.name or self.__class__.__name__
        self.description = description or self.__class__.__doc__ or f"Tool: {self.name}"

        # Set up logging
        self.logger = logging.getLogger(
            f'{self.name}.Tool'
        )

        # JSON encoders/decoders
        self._json_encoder = json_encoder
        self._json_decoder = json_decoder
        self._json = JSONContent()

        # File and URL configuration
        self.base_url = base_url or BASE_STATIC_URL
        self.static_url = base_url or BASE_STATIC_URL
        parsed = urlparse(self.static_url)
        self._base_scheme_netloc = (parsed.scheme, parsed.netloc)

        # Set up directories
        self.static_dir = Path(static_dir or STATIC_DIR).resolve()

        self.output_dir = Path(output_dir).resolve() if output_dir else self._default_output_dir()

        # Ensure output directory exists if specified
        if self.output_dir and not self.output_dir.exists():
            self.output_dir.mkdir(parents=True, exist_ok=True)

    def _default_output_dir(self) -> Optional[Path]:
        """Get the default output directory for this tool type.

        Returns OUTPUT_DIR from parrot.conf as base directory.
        Subclasses can override to append tool-specific subdirectories.
        """
        return OUTPUT_DIR

    def _get_clone_kwargs(self) -> Dict[str, Any]:
        """
        Get the keyword arguments to use when cloning this tool.

        Subclasses can override this method to customize which parameters
        are cloned and which are not. By default, all initialization
        parameters stored in _init_kwargs are returned.

        Returns:
            Dictionary of keyword arguments for tool initialization
        """
        return self._init_kwargs.copy()

    def clone(self):
        """
        Create a new instance of this tool with the same configuration.

        This method creates a new instance of the tool class with all the
        initialization parameters that were passed to the current instance.
        Subclasses can override _get_clone_kwargs() to customize which
        parameters are cloned and which are not.

        Returns:
            New instance of the same tool class with cloned configuration

        Example:
            >>> dbtool = DatabaseTool(connection_string="postgresql://...")
            >>> new_tool = dbtool.clone()
            >>> # new_tool is a fresh instance with the same configuration
        """
        clone_kwargs = self._get_clone_kwargs()
        return self.__class__(**clone_kwargs)

    @abstractmethod
    async def _execute(self, **kwargs) -> Any:
        """
        Execute the tool with the given arguments.
        This is the main method that subclasses must implement.

        Args:
            **kwargs: Tool arguments

        Returns:
            Tool execution result
        """

    def get_schema(self) -> Dict[str, Any]:
        """
        Get the JSON schema for this tool.

        Returns:
            JSON schema dictionary compatible with LLM tool registration
        """

        def _enforce_no_extra_fields(definition: Any) -> None:
            """Recursively set ``additionalProperties`` to ``False`` for objects.

            OpenAI tools require every object schema (including nested ones) to
            explicitly disallow extra properties. Pydantic's generated schema only
            sets this flag at the top level, so we walk the entire schema tree and
            ensure every object definition is strict.
            """

            if not isinstance(definition, dict):
                return

            if definition.get("type") == "object":
                definition.setdefault("properties", {})
                definition.setdefault("additionalProperties", False)

            # Recurse into common schema containers
            for key in ("properties", "patternProperties"):
                if isinstance(definition.get(key), dict):
                    for sub in definition[key].values():
                        _enforce_no_extra_fields(sub)

            for key in ("items", "additionalItems"):
                _enforce_no_extra_fields(definition.get(key))

            for key in ("anyOf", "oneOf", "allOf"):
                if isinstance(definition.get(key), list):
                    for sub in definition[key]:
                        _enforce_no_extra_fields(sub)

            # Handle $defs/definitions used by Pydantic
            for key in ("$defs", "definitions"):
                if isinstance(definition.get(key), dict):
                    for sub in definition[key].values():
                        _enforce_no_extra_fields(sub)

        schema = {
            "name": self.name,
            "description": self.description,
            "parameters": {
                "type": "object",
                "properties": {},
                "required": [],
                "additionalProperties": False
            }
        }

        # If args_schema is defined, use it to build the parameters
        if self.args_schema and self.args_schema != AbstractToolArgsSchema:
            pydantic_schema = self.args_schema.model_json_schema()
            schema["parameters"] = {
                "type": "object",
                "properties": pydantic_schema.get("properties", {}),
                "required": pydantic_schema.get("required", []),
                "additionalProperties": False,
                "$defs": pydantic_schema.get("$defs", {}),
            }

        _enforce_no_extra_fields(schema["parameters"])
        return schema

    def get_tool_schema(self) -> Dict[str, Any]:
        """
        Get the JSON schema for the tool's arguments.
        Alias for get_schema() for backward compatibility.

        Returns:
            Dictionary containing the JSON schema
        """
        return self.get_schema()

    def _coerce_json_strings(self, kwargs: dict) -> dict:
        """Pre-parse string values that should be dict/list according to the schema.

        LLMs sometimes serialize JSON objects as strings (e.g. ``'{"k": "v"}'``
        instead of ``{"k": "v"}``).  This method detects those cases by
        inspecting the ``args_schema`` field annotations and parses them before
        Pydantic validation.
        """
        if not self.args_schema or self.args_schema == AbstractToolArgsSchema:
            return kwargs

        annotations = getattr(self.args_schema, '__annotations__', {})
        if not annotations:
            return kwargs

        coerced = dict(kwargs)
        for key, value in coerced.items():
            if not isinstance(value, str):
                continue
            ann = annotations.get(key)
            if ann is None:
                continue
            # Unwrap Optional[X] → X
            origin = getattr(ann, '__origin__', None)
            if origin is Union:
                args = [a for a in ann.__args__ if a is not type(None)]
                ann = args[0] if len(args) == 1 else ann
                origin = getattr(ann, '__origin__', None)
            # Check if the expected type is dict or list
            if ann is dict or origin is dict or ann is Dict:
                try:
                    parsed = json_decoder(value)
                    if isinstance(parsed, dict):
                        coerced[key] = parsed
                except Exception:
                    pass
            elif ann is list or origin is list:
                try:
                    parsed = json_decoder(value)
                    if isinstance(parsed, list):
                        coerced[key] = parsed
                except Exception:
                    pass
        return coerced

    def validate_args(self, **kwargs) -> BaseModel:
        """
        Validate arguments using the tool's schema.

        Args:
            **kwargs: Arguments to validate

        Returns:
            Validated arguments as Pydantic model instance
        """
        if not self.args_schema or self.args_schema == AbstractToolArgsSchema:
            # If no schema is defined, return a basic model with the kwargs
            return AbstractToolArgsSchema()
        try:
            kwargs = self._coerce_json_strings(kwargs)
            result = self.args_schema(**kwargs)
            if not result:
                self.logger.warning(
                    f"Validation failed for {self.name} with args: {kwargs}"
                )
            return result
        except Exception as e:
            self.logger.error(f"Validation error in {self.name}: {e}")
            raise ValueError(
                f"Invalid arguments for {self.name}: {e}"
            ) from e

    async def execute(self, *args, **kwargs) -> ToolResult:
        """
        Execute the tool with error handling and result standardization.

        Args:
            *args: Positional arguments for the tool.
            **kwargs: Tool arguments. Special kwargs are:
                - _permission_context: PermissionContext for Layer 2 enforcement
                - _resolver: AbstractPermissionResolver for permission checks

        Returns:
            Standardized ToolResult. Returns status='forbidden' if permission denied.

        TODO: Use the Global Registry to share data between tools.
        """
        # ── Permission check (Layer 2 safety net) ────────────────────────────
        pctx = kwargs.pop('_permission_context', None)
        resolver = kwargs.pop('_resolver', None)

        if pctx is not None and resolver is not None:
            required = getattr(self, '_required_permissions', set())
            allowed = resolver.can_execute(pctx, self.name, required)
            if not allowed:
                self.logger.warning(
                    f"Permission denied: user={pctx.user_id} "
                    f"tool={self.name} required={required}"
                )
                return ToolResult(
                    success=False,
                    status='forbidden',
                    result=None,
                    error=f"Permission denied: '{self.name}' requires {required}",
                    metadata={
                        "tool_name": self.name,
                        "user_id": pctx.user_id,
                        "required_permissions": list(required),
                    }
                )

        # ── Normal execution ─────────────────────────────────────────────────
        try:
            self.logger.info(f"Executing tool: {self.name}")

            # Validate arguments
            validated_args = self.validate_args(**kwargs)

            # Execute the tool
            if hasattr(validated_args, 'model_dump'):
                result = await self._execute(*args, **validated_args.model_dump())
            else:
                result = await self._execute(*args, **kwargs)

            # if is an toolResult, return it directly
            if isinstance(result, ToolResult):
                return result
            elif isinstance(result, dict) and 'status' in result and 'result' in result:
                try:
                    return ToolResult(**result)
                except Exception as e:
                    self.logger.error(f"Error creating ToolResult from dict: {e}")
                    return ToolResult(
                        status="done_with_errors",
                        result=result.get('result', []),
                        error=f"Error creating ToolResult: {e}",
                        metadata=result.get('metadata', {})
                    )
            if result is None:
                raise ValueError(
                    "Tool execution returned None, expected a result."
                )

            self.logger.info(
                f"Tool {self.name} executed successfully"
            )
            # print('TYPE > ', type(result), ' RESULT > ', result)

            return ToolResult(
                status="success",
                result=result,
                metadata={
                    "tool_name": self.name,
                    "execution_time": datetime.now().isoformat()
                }
            )

        except Exception as e:
            print('ERROR')
            print(f'============ {e} ============')
            error_msg = f"Error in {self.name}: {str(e)}"
            self.logger.error(error_msg)
            self.logger.error(traceback.format_exc())

            return ToolResult(
                status="error",
                result=None,
                error=error_msg,
                metadata={
                    "tool_name": self.name,
                    "error_type": type(e).__name__
                }
            )
    async def run(self, *args, **kwargs) -> Any:
        """
        Public alias for executing the tool directly without the ToolResult wrapper.
        Provides a direct way to get raw results instead of calling _execute().
        """
        return await self._execute(*args, **kwargs)

    # Utility methods for file handling (inherited from BaseAbstractTool)
    def to_static_url(self, file_path: Union[str, Path]) -> str:
        """
        Convert an absolute file path to a static URL.

        Args:
            file_path: Absolute path to the file

        Returns:
            URL-based path for serving the static file
        """
        if not self.static_dir:
            return str(file_path)

        file_path = Path(file_path)

        try:
            relative_path = file_path.relative_to(self.static_dir)
            return f"{self.static_url.rstrip('/')}/{relative_path}"
        except ValueError:
            self.logger.warning(
                f"File {file_path} is not within static directory {self.static_dir}"
            )
            return str(file_path)

    def relative_url(self, url: str) -> str:
        """
        Convert an absolute URL to a relative URL based on the base URL.

        Args:
            url: Absolute URL to convert

        Returns:
            Relative URL based on the base URL
        """
        parts = urlparse(url)
        if not parts.scheme or not parts.netloc:
            return url

        if (parts.scheme, parts.netloc) == self._base_scheme_netloc:
            return urlunparse((
                "", "", parts.path, parts.params, parts.query, parts.fragment
            ))
        return url

    def generate_filename(
        self,
        prefix: str = "output",
        extension: str = "",
        include_timestamp: bool = True
    ) -> str:
        """
        Generate a unique filename with optional timestamp.

        Args:
            prefix: File prefix
            extension: File extension (with or without dot)
            include_timestamp: Whether to include timestamp

        Returns:
            Generated filename
        """
        if include_timestamp:
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            filename = f"{prefix}_{timestamp}"
        else:
            filename = prefix

        if extension:
            if not extension.startswith('.'):
                extension = f".{extension}"
            filename += extension

        return filename

    def validate_output_path(self, file_path: Union[str, Path]) -> Path:
        """
        Validate and ensure the output path is within allowed directories.

        Args:
            file_path: Path to validate

        Returns:
            Validated Path object

        Raises:
            ValueError: If path is outside allowed directories
        """
        if not self.static_dir:
            return Path(file_path).resolve()

        file_path = Path(file_path).resolve()

        try:
            file_path.relative_to(self.static_dir.resolve())
        except ValueError as e:
            raise ValueError(
                f"Output path {file_path} must be within static directory {self.static_dir}"
            ) from e

        return file_path

    def __str__(self) -> str:
        return f"{self.name}: {self.description}"

    def __repr__(self) -> str:
        return f"<{self.__class__.__name__}(name='{self.name}')>"


