# Copyright (C) 2023 - 2026 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Provides functions for performing common checks."""

from collections.abc import Iterable
import functools
from typing import TYPE_CHECKING, Type
import warnings

import numpy as np
from pint import Unit
import semver

if TYPE_CHECKING:  # pragma: no cover
    from ansys.geometry.core.designer.design import Design
    from ansys.geometry.core.shapes.curves.trimmed_curve import TrimmedCurve
    from ansys.geometry.core.shapes.surfaces.trimmed_surface import TrimmedSurface
    from ansys.geometry.core.sketch.sketch import Sketch


def ensure_design_is_active(method):
    """Make sure that the design is active before executing a method.

    This function is necessary to be called whenever we do any operation
    on the design. If we are just accessing information of the class, it
    is not necessary to call this.
    """

    @functools.wraps(method)
    def wrapper(self, *args, **kwargs):
        import ansys.geometry.core as pyansys_geometry
        from ansys.geometry.core.errors import GeometryRuntimeError

        if pyansys_geometry.DISABLE_ACTIVE_DESIGN_CHECK:
            # If the user has disabled the check, then we can skip it
            return method(self, *args, **kwargs)

        # Check if the current design is active... otherwise activate it
        def get_design_ref(obj) -> "Design":
            if hasattr(obj, "_modeler"):  # In case of a Design object
                return obj
            elif hasattr(
                obj, "_parent_component"
            ):  # In case of a Body, Component, DesignPoint, Beam
                # Recursive call
                return get_design_ref(obj._parent_component)
            elif hasattr(obj, "_body"):  # In case of a Face, Edge
                # Recursive call
                return get_design_ref(obj._body._parent_component)
            else:
                raise ValueError("Unable to find the design reference.")

        # Get the design reference
        design = get_design_ref(self)

        # Verify whether the Design has been closed on the backend
        if design.is_closed:
            raise GeometryRuntimeError(
                "The design has been closed on the backend. Cannot perform any operations on it."
            )

        # Finally, call method
        return method(self, *args, **kwargs)

    return wrapper


def check_is_float_int(param: object, param_name: str | None = None) -> None:
    """Check if a parameter has a float or integer value.

    Parameters
    ----------
    param : object
        Object instance to check.
    param_name : str, default: None
        Parameter name (if any).

    Raises
    ------
    TypeError
        If the parameter does not have a float or integer value.
    """
    if not isinstance(param, (int, float)):
        raise TypeError(
            "The parameter should have a float or integer value."
            if param_name is None
            else f"The parameter '{param_name}' should have a float or integer value."
        )


def check_ndarray_is_float_int(param: np.ndarray, param_name: str | None = None) -> None:
    """Check if a :class:`numpy.ndarray <numpy.ndarray>` has float/integer types.

    Parameters
    ----------
    param : ~numpy.ndarray
        :class:`numpy.ndarray <numpy.ndarray>` instance to check.
    param_name : str, default: None
        :class:`numpy.ndarray <numpy.ndarray>` instance name (if any).

    Raises
    ------
    TypeError
        If the :class:`numpy.ndarray <numpy.ndarray>` instance does not
        have float or integer values.
    """
    param_data = np.ravel(param)

    if not np.issubdtype(param.dtype, np.number) or not all(
        isinstance(data, (int, float)) for data in param_data.data
    ):
        raise TypeError(
            "The numpy.ndarray should contain float or integer values."
            if param_name is None
            else f"The numpy.ndarray '{param_name}' should contain float or integer values."
        )


def check_ndarray_is_not_none(param: np.ndarray, param_name: str | None = None) -> None:
    """Check if a :class:`numpy.ndarray <numpy.ndarray>` is all ``None``.

    Parameters
    ----------
    param : ~numpy.ndarray
        :class:`numpy.ndarray <numpy.ndarray>` instance to check.
    param_name : str, default: None
        :class:`numpy.ndarray <numpy.ndarray>` instance name (if any).

    Raises
    ------
    ValueError
        If the :class:`numpy.ndarray <numpy.ndarray>` instance has a value
        of ``None`` for all parameters.
    """
    param_data = np.ravel(param)
    if all(value is None for value in param_data):
        raise ValueError(
            "The numpy.ndarray should not have 'None' for all parameter values."
            if param_name is None
            else f"The numpy.ndarray '{param_name}' should not have 'None' for all parameter values."  # noqa: E501
        )


def check_ndarray_is_all_nan(param: np.ndarray, param_name: str | None = None) -> None:
    """Check if a :class:`numpy.ndarray <numpy.ndarray>` is all nan-valued.

    Parameters
    ----------
    param : ~numpy.ndarray
        :class:`numpy.ndarray <numpy.ndarray>` instance to check.
    param_name : str or None, default: None
        :class:`numpy.ndarray <numpy.ndarray>` instance name (if any).

    Raises
    ------
    ValueError
        If the :class:`numpy.ndarray <numpy.ndarray>` instance is all nan-valued.
    """
    if np.isnan(param).all():
        raise ValueError(
            "The numpy.ndarray should not be a nan numpy.ndarray."
            if param_name is None
            else f"The numpy.ndarray '{param_name}' should not be a nan numpy.ndarray."
        )


def check_ndarray_is_non_zero(param: np.ndarray, param_name: str | None = None) -> None:
    """Check if a :class:`numpy.ndarray <numpy.ndarray>` is zero-valued.

    Parameters
    ----------
    param : ~numpy.ndarray
        :class:`numpy.ndarray <numpy.ndarray>` instance to check.
    param_name : str, default: None
        :class:`numpy.ndarray <numpy.ndarray>` instance name (if any).

    Raises
    ------
    ValueError
        If the :class:`numpy.ndarray <numpy.ndarray>` instance is zero-valued.
    """
    param_data = np.ravel(param)
    if all(value == 0 for value in param_data):
        raise ValueError(
            "The numpy.ndarray should not be a numpy.ndarray of zeros."
            if param_name is None
            else f"The numpy.ndarray '{param_name}' should not be a numpy.ndarray of zeros."
        )


def check_pint_unit_compatibility(input: Unit, expected: Unit) -> None:
    """Check if input :class:`pint.Unit` is compatible with the expected input.

    Parameters
    ----------
    input : ~pint.Unit
        :class:`pint.Unit` input.
    expected : ~pint.Unit
        :class:`pint.Unit` expected dimensionality.

    Raises
    ------
    TypeError
        If the input is not compatible with the :class:`pint.Unit` class.
    """
    if not input.is_compatible_with(expected):
        raise TypeError(
            f"The pint.Unit provided as an input should be a {expected.dimensionality} quantity."
        )


def check_type_equivalence(input: object, expected: object) -> None:
    """Check if an input object is of the same class as an expected object.

    Parameters
    ----------
    input : object
        Input object.
    expected : object
        Expected object.

    Raises
    ------
    TypeError
        If the objects are not of the same class.
    """
    if not isinstance(input, type(expected)):
        raise TypeError(
            f"Provided type {type(input)} is invalid. Type {type(expected)} is expected."
        )


def check_type(input: object, expected_type: Type | tuple[Type, ...]) -> None:
    """Check if an input object is of the same type as expected types.

    Parameters
    ----------
    input : object
        Input object.
    expected_type : Type | tuple[Type, ...]
        One or more types to compare the input object against.

    Raises
    ------
    TypeError
        If the object does not match the one or more expected types.
    """
    if not isinstance(input, expected_type):
        raise TypeError(
            f"Provided type {type(input)} is invalid. Type {expected_type} is expected."
        )


def check_type_all_elements_in_iterable(
    input: Iterable, expected_type: Type | tuple[Type, ...]
) -> None:
    """Check if all elements in an iterable are of the same type as expected.

    Parameters
    ----------
    input : Iterable
        Input iterable.
    expected_type : Type | tuple[Type, ...]
        One or more types to compare the input object against.

    Raises
    ------
    TypeError
        If one of the elements in the iterable does not match the one or more expected types.
    """
    for elem in input:
        check_type(elem, expected_type)


def check_nurbs_compatibility(
    backend_version: semver.Version,
    sketch: "Sketch" = None,
    curves: list["TrimmedCurve"] = None,
    surfaces: list["TrimmedSurface"] = None,
) -> None:
    """Check if the inputs require NURBS functionality and it is available.

    Parameters
    ----------
    backend_version : semver.Version
        Backend version to check against.
    sketch : Sketch, default: None
        Sketch to check for NURBS geometry.
    curves : list[TrimmedCurve], default: None
        List of TrimmedCurve to check for NURBS geometry.
    surfaces : list[TrimmedSurface], default: None
        List of TrimmedSurface to check for NURBS geometry.

    Raises
    ------
    GeometryRuntimeError
        If inputs contain NURBS functionality but the backend is older than 26R1.
    """
    from ansys.geometry.core.errors import GeometryRuntimeError
    from ansys.geometry.core.shapes.curves.nurbs import NURBSCurve
    from ansys.geometry.core.shapes.surfaces.nurbs import NURBSSurface
    from ansys.geometry.core.sketch.nurbs import SketchNurbs

    requires_nurbs = False

    if sketch is not None:
        if any(isinstance(edge, SketchNurbs) for edge in sketch.edges):
            requires_nurbs = True

    if curves is not None:
        if any(isinstance(curve.geometry, NURBSCurve) for curve in curves):
            requires_nurbs = True

    if surfaces is not None:
        if any(isinstance(surface.geometry, NURBSSurface) for surface in surfaces):
            requires_nurbs = True

    if requires_nurbs:
        min_version = semver.Version(26, 1, 0)
        comp = min_version.compare(backend_version)
        if comp == 1:
            raise GeometryRuntimeError(
                "NURBS functionality requires a minimum Ansys release version of 26R1, "
                + f"but the current version used is {backend_version}."
            )


def min_backend_version(major: int, minor: int, service_pack: int):
    """Compare a minimum required version to the current backend version.

    Parameters
    ----------
    major : int
        Minimum major version required by the method.
    minor : int
        Minimum minor version required by the method.
    service_pack : int
        Minimum service pack version required by the method.

    Raises
    ------
    GeometryRuntimeError
        If the method version is higher than the backend version.
    GeometryRuntimeError
        If the client is not available.
    """
    # Lazy import to avoid circular imports
    from ansys.geometry.core.errors import GeometryRuntimeError
    from ansys.geometry.core.logger import LOG

    def backend_version_decorator(method):
        @functools.wraps(method)
        def wrapper(self, *args, **kwargs):
            method_version = semver.Version(major, minor, service_pack)
            if hasattr(self, "_grpc_client"):
                if self._grpc_client is None:
                    raise GeometryRuntimeError(
                        "The client is not available. You must initialize the client first."
                    )
                elif self._grpc_client.backend_version is not None:
                    comp = method_version.compare(self._grpc_client.backend_version)
                    # if comp is 1, method version is higher than backend version.
                    if comp == 1:
                        # Check if the version is "0.0.0" (i.e., the version is not available)
                        if str(self._grpc_client.backend_version) == "0.0.0":
                            raise GeometryRuntimeError(
                                f"The method '{method.__name__}' requires a minimum Ansys release version of "  # noqa: E501
                                + f"{method_version}, but the current version used is 24.1.0 or lower."  # noqa: E501
                            )
                        else:
                            raise GeometryRuntimeError(
                                f"The method '{method.__name__}' requires a minimum Ansys release version of "  # noqa: E501
                                + f"{method_version}, but the current version used is "
                                + f"{self._grpc_client.backend_version}."
                            )
                    else:
                        return method(self, *args, **kwargs)
            else:
                LOG.warning("This object does not have a connection with the backend.")

        return wrapper

    return backend_version_decorator


def deprecated_method(
    alternative: str | None = None,
    info: str | None = None,
    version: str | None = None,
    remove: str | None = None,
):
    """Decorate a method as deprecated.

    Parameters
    ----------
    alternative : str, default: None
        Alternative method to use. If provided, the warning message will
        include the alternative method.
    info : str, default: None
        Additional information to include in the warning message.
    version : str, default: None
        Version where the method was deprecated.
    remove : str, default: None
        Version where the method will be removed.
    """

    def deprecated_decorator(method):
        @functools.wraps(method)
        def wrapper(*args, **kwargs):
            msg = f"The method '{method.__name__}' is deprecated."
            if alternative:
                msg += f" Use '{alternative}' instead."
            if info:
                msg += f" {info}"
            if version:
                msg += f" This method was deprecated in version {version}."
            if remove:
                msg += f" This method will be removed in version {remove}."
            warnings.warn(msg, DeprecationWarning)
            return method(*args, **kwargs)

        return wrapper

    return deprecated_decorator


def deprecated_argument(
    arg: str,
    alternative: str | None = None,
    info: str | None = None,
    version: str | None = None,
    remove: str | None = None,
):
    """Decorate a method argument as deprecated.

    Parameters
    ----------
    arg : str
        Argument to mark as deprecated.
    alternative : str, default: None
        Alternative argument to use. If provided, the warning message will
        include the alternative argument.
    info : str, default: None
        Additional information to include in the warning message.
    version : str, default: None
        Version where the method was deprecated.
    remove : str, default: None
        Version where the method will be removed.
    """

    def deprecated_decorator(method):
        @functools.wraps(method)
        def wrapper(*args, **kwargs):
            if arg in kwargs and kwargs[arg] is not None:
                msg = f"The argument '{arg}' in '{method.__name__}' is deprecated."
                if alternative:
                    msg += f" Use '{alternative}' instead."
                if info:
                    msg += f" {info}"
                if version:
                    msg += f" This argument was deprecated in version {version}."
                if remove:
                    msg += f" This argument will be removed in version {remove}."
                warnings.warn(msg, DeprecationWarning)

            return method(*args, **kwargs)

        return wrapper

    return deprecated_decorator


ERROR_GRAPHICS_REQUIRED = (
    "Graphics are required for this method. Please install the ``graphics`` target "
    " to use this method. You can install it by running `pip install ansys-geometry-core[graphics]`"
    " or `pip install ansys-geometry-core[all]`."
)
"""Message to display when graphics are required for a method."""

__GRAPHICS_AVAILABLE = None
"""Global variable to store the result of the graphics imports."""


def run_if_graphics_required():
    """Check if graphics are available."""
    global __GRAPHICS_AVAILABLE
    if __GRAPHICS_AVAILABLE is None:
        try:
            # Attempt to perform the imports
            import ansys.tools.visualization_interface  # noqa: F401
            import pygltflib  # noqa: F401
            import pyvista  # noqa: F401
            import trame  # noqa: F401
            import vtk  # noqa: F401

            __GRAPHICS_AVAILABLE = True
        except (ModuleNotFoundError, ImportError):
            __GRAPHICS_AVAILABLE = False

    if __GRAPHICS_AVAILABLE is False:
        raise ImportError(ERROR_GRAPHICS_REQUIRED)


def graphics_required(method):
    """Decorate a method as requiring graphics.

    Parameters
    ----------
    method : callable
        Method to decorate.

    Returns
    -------
    callable
        Decorated method.
    """

    @functools.wraps(method)
    def wrapper(*args, **kwargs):
        run_if_graphics_required()
        return method(*args, **kwargs)

    return wrapper


def kwargs_passed_not_accepted(method):
    """Check that no unexpected kwargs are passed to the method.

    This decorator will raise a TypeError if any keyword arguments are passed
    to the decorated method that don't correspond to the method's parameters.
    If the method has ``**kwargs`` in its signature, this decorator will raise an
    error for any kwargs passed to it (that are not explicitly accepted).

    Parameters
    ----------
    method : callable
        The method to decorate.

    Returns
    -------
    callable
        Decorated method that raises TypeError if unexpected kwargs are passed.

    Raises
    ------
    TypeError
        If unexpected keyword arguments are passed to the decorated method.

    Examples
    --------
    >>> @kwargs_passed_not_accepted
    ... def my_method(arg1, arg2):
    ...     return arg1 + arg2
    >>> my_method(1, 2)  # Works fine
    3
    >>> my_method(arg1=1, arg2=2)  # Works fine
    3
    >>> my_method(1, 2, invalid_arg=3)  # Raises TypeError
    TypeError: The following keyword arguments are not accepted
    in the method 'my_method': invalid_arg.
    >>> @kwargs_passed_not_accepted
    ... def my_method_with_kwargs(arg1, arg2, **kwargs):
    ...     return arg1 + arg2
    >>> my_method_with_kwargs(1, 2, invalid_arg=3)  # Raises TypeError
    TypeError: The following keyword arguments are not accepted
    in the method 'my_method_with_kwargs': invalid_arg.
    """
    import inspect

    @functools.wraps(method)
    def wrapper(*args, **kwargs):
        # Get the method signature
        sig = inspect.signature(method)

        # Check if method has **kwargs parameter
        has_var_keyword = any(
            param.kind == inspect.Parameter.VAR_KEYWORD for param in sig.parameters.values()
        )

        # If method has **kwargs and kwargs are passed...
        if has_var_keyword and len(kwargs) > 0:
            # Retrieve a list of all parameter names excluding **kwargs
            param_names = {
                name
                for name, param in sig.parameters.items()
                if param.kind != inspect.Parameter.VAR_KEYWORD
            }

            # Identify unexpected kwargs
            unexpected_kwargs = [key for key in kwargs.keys() if key not in param_names]

            if unexpected_kwargs:
                raise TypeError(
                    "The following keyword arguments are not accepted in the"
                    + f" method '{method.__name__}': {', '.join(unexpected_kwargs)}."
                )

        return method(*args, **kwargs)

    return wrapper
