"""
Functions for finding and calling Cloud Functions.
"""

import importlib
import inspect
import os
import pkgutil
import sys
import warnings
from functools import lru_cache
from typing import Any

from pymongo.errors import OperationFailure

from ixoncdkingress.function.context import FunctionContext
from ixoncdkingress.types import (
    AppDescription,
    AppDescriptionData,
    AppDescriptionResponse,
    FunctionArguments,
    FunctionLocation,
    ListToolsIssuesResponse,
    ListToolsResponse,
    ModuleLoadIssue,
    Tool,
)
from ixoncdkingress.utils import handle_exception
from ixoncdkingress.webserver.config import Config
from ixoncdkingress.webserver.response import Response


def call_function(
    config: Config,
    context: FunctionContext,
    function_location: FunctionLocation,
    function_kwargs: FunctionArguments,
    response: Response,
) -> Any:
    """
    Finds, loads and calls the function specified in the body. The content_type specifies the
    format of the body.
    """

    # Get the specified module
    if function_location[1] == "list-agent-tools":
        return list_agent_tools(config.cbc_path)

    if function_location[1] == "list-agent-tools-issues":
        return list_agent_tools_issues(config.cbc_path)

    if function_location[1] == "app-description":
        return get_app_description(config.cbc_path)

    sys.path.insert(0, config.cbc_path)
    module = importlib.import_module(function_location[0])
    if config.production_mode is False:
        try:
            module = importlib.reload(module)
        except ImportError as error:
            handle_exception(error, response)
            return None
    del sys.path[0]

    # Get the specified function
    if hasattr(module, function_location[1]):
        function = getattr(module, function_location[1])

        # Check if it's exposed
        if getattr(function, "exposed", False):
            try:
                return function(context, **get_function_args(function, function_kwargs))
            except OperationFailure as ex:
                if ex.details and ex.details.get("codeName"):
                    return f"Document DB operation failure: {ex.details['codeName']}"

                return "Document DB operation failure"

    return f"Not found, function '{'.'.join(function_location)}' does not exist or is not exposed"


def get_function_args(function: Any, function_kwargs: dict[Any, Any]) -> dict[Any, Any]:
    """
    Get the arguments of the function.

    Discards arguments that the function does not take.
    """
    params = inspect.signature(function).parameters

    # Check if the function takes a variable number of keyword arguments
    if any(key.kind is inspect.Parameter.VAR_KEYWORD for key in params.values()):
        return function_kwargs

    input_model = getattr(function, "input_model", None)

    if input_model is not None:
        # Create pydantic models for the function arguments
        args = dict(vars(input_model(**function_kwargs)))
    else:
        # Discard all arguments that the function can not take
        args = {key: value for key, value in function_kwargs.items() if key in params}

    return args


def _get_tools(cbc_path: str) -> tuple[list[Tool], list[ModuleLoadIssue]]:
    sys.path.insert(0, cbc_path)
    tools: list[Tool] = []
    issues: list[ModuleLoadIssue] = []

    # Discover all .py files under the cbc_path (package and submodules)
    for module_name in _walk_modules(cbc_path):
        try:
            module = importlib.import_module(module_name)
        except Exception as e:  # noqa: BLE001
            warnings.warn(f"Failed to import module {module_name}, reason: {e!r}", stacklevel=2)
            issues.append(
                ModuleLoadIssue(
                    type="import_error",
                    module_name=module_name,
                    error=str(e),
                )
            )
            continue

        for obj in module.__dict__.values():
            # Only consider functions or callables
            if (
                callable(obj)
                and getattr(obj, "exposed_to_agent", False)
                and hasattr(obj, "tool_definition")
            ):
                tool_definition = obj.tool_definition
                tool_definition.module_name = module_name
                tools.append(tool_definition)

    del sys.path[0]

    return tools, issues


@lru_cache(maxsize=1)
def list_agent_tools(cbc_path: str) -> ListToolsResponse:
    """
    List the tools available to the agent.
    """

    tools, _ = _get_tools(cbc_path)

    return ListToolsResponse(
        result="success",
        data=[tool.to_dict() for tool in sorted(tools, key=lambda x: x.name) if tool.is_ready],
    )


@lru_cache(maxsize=1)
def list_agent_tools_issues(cbc_path: str) -> ListToolsIssuesResponse:
    """
    List the the tools that are not available to the agent, and the reasons why
    they are not available.
    """

    tools, issues = _get_tools(cbc_path)

    return ListToolsIssuesResponse(
        result="success",
        data=issues + [tool.to_dict() for tool in tools if not tool.is_ready],
    )


def _walk_modules(package_path: str, prefix: str = "") -> list[str]:
    """Recursively discover all modules and submodules under package_path."""
    modules = []
    for _, modname, ispkg in pkgutil.iter_modules([package_path]):
        full_name = prefix + modname if prefix == "" else prefix + "." + modname
        modules.append(full_name)
        if ispkg:
            sub_path = os.path.join(package_path, modname)
            modules.extend(_walk_modules(sub_path, full_name))
    return modules


@lru_cache(maxsize=1)
def get_app_description(cbc_path: str) -> AppDescriptionResponse:
    _ = _get_tools(cbc_path)  # load all modules to set the app description

    try:
        app_description = AppDescription.get()
    except ValueError:
        app_description = None

    return AppDescriptionResponse(
        result="success",
        data=AppDescriptionData(
            description=app_description.description if app_description else None,
            shortName=app_description.short_name if app_description else None,
        ),
    )
