r"""
The SamplingSphere class inherits from the :py:mod:`pyfar.classes.coordinates`
class, which supports various coordinate systems and the conversion between
them.
:py:class:`~spharpy.SamplingSphere` is designed to represent a set of
points on a sphere.

Therefore, all points must have the same radius within an absolute tolerance,
defined by :py:attr:`~spharpy.SamplingSphere.radius_tolerance`. If the
:py:attr:`~spharpy.SamplingSphere.weights` are not None, their sum must
equal the integral over the unit sphere, which is :math:`4\pi`.

The property :py:attr:`~spharpy.SamplingSphere.quadrature` is relevant for
spherical harmonic processing. It specifies if the points form a
quadrature rule, which requires valid weights, that the maximum
spherical harmonic order of the sampling
grid :py:attr:`~spharpy.SamplingSphere.n_max` is specified, and the inner
product of the weighted spherical harmonics matrix :math:`\mathrm{Y}`
yields the identity matrix
:math:`\mathrm{Y}^\mathrm{T} \text{diag}\{w\}\mathrm{Y}=\mathrm{I}`,
with the weights vector :math:`w`. The sampling is considered a valid
quadrature if the maximum absolute deviation of
:math:`\mathrm{Y}^\mathrm{T} \text{diag}\{w\}\mathrm{Y}` from :math:`I` is
smaller than the specified
:py:attr:`~spharpy.SamplingSphere.quadrature_tolerance`.

It also adds the additional property:

- :py:attr:`~spharpy.SamplingSphere.n_max`: the maximum spherical harmonic
  order of the sampling grid.

Note that the :py:mod:`spharpy.samplings` module provides a set of
predefined spherical sampling grids, which can be used to create a
:py:class:`spharpy.SamplingSphere` object.
"""

import numpy as np
from pyfar.classes.coordinates import sph2cart, cyl2cart
import pyfar as pf
from spharpy.spherical import spherical_harmonic_basis_real


class SamplingSphere(pf.Coordinates):
    """Class for samplings on a sphere."""

    def __init__(
            self, x=None, y=None, z=None, n_max=None, weights: np.array = None,
            comment: str = "", radius_tolerance=1e-6,
            quadrature_tolerance=1e-10):
        r"""
        Create a SamplingSphere class object from a set of points on a sphere.

        See :py:mod:`pyfar.classes.coordinates` for more information.

        Parameters
        ----------
        x : ndarray, number
            X coordinate of a right handed Cartesian coordinate system in
            meters (-\infty < x < \infty).
        y : ndarray, number
            Y coordinate of a right handed Cartesian coordinate system in
            meters (-\infty < y < \infty).
        z : ndarray, number
            Z coordinate of a right handed Cartesian coordinate system in
            meters (-\infty < z < \infty).
        n_max : int, optional
            Maximum spherical harmonic order of the sampling grid. If
            provided, it must be an integer greater or equal to 0.
            The default is ``None``.
        weights: array like, number, optional
            Weighting factors for coordinate points. Their sum must equal to
            the integral over the unit sphere, which is :math:`4\pi`.
            The `shape` of the array must match the `shape` of the individual
            coordinate arrays. The default is ``None``, which means that no
            weights are used.
        comment : str, optional
            Comment about the stored coordinate points. The default is
            ``""``, which initializes an empty string.
        radius_tolerance : float, optional
            All points that are stored in a SamplingSphere must have the same
            radius and an error is raised if the maximum deviation from the
            mean radius exceeds this tolerance. The default of ``1e-6`` meter
            is intended to allow for some numerical inaccuracy.
        quadrature_tolerance : float, optional
            Tolerance for testing whether the provided sampling grid is
            a valid quadrature. The sampling is considered a valid quadrature
            if the maximum absolute deviation of the inner product of the
            weighted spherical harmonics matrix from the identity matrix is
            smaller than the specified tolerance. The default is ``1e-10``.
        """
        self._radius_tolerance = None
        self.radius_tolerance = radius_tolerance

        pf.Coordinates.__init__(
            self, x, y, z, weights=weights, comment=comment)
        self._n_max = n_max

        self._quadrature_tolerance = None
        self.quadrature_tolerance = quadrature_tolerance

        self._quadrature = None

    @classmethod
    def from_coordinates(
            cls, coordinates, n_max=None, radius_tolerance: float = 1e-6,
            quadrature_tolerance: float = 1e-10):
        r"""
        Convert Coordinates class object to SamplingSphere class object.

        Parameters
        ----------
        coordinates : pyfar.Coordinates
            Coordinates to be converted.
        n_max : int, optional
            Maximum spherical harmonic order of the sampling grid.
            The default is ``None``.
        radius_tolerance : float, optional
            All points that are stored in a SamplingSphere must have the same
            radius and an error is raised if the maximum deviation from the
            mean radius exceeds this tolerance. The default of ``1e-6`` meter
            is intended to allow for some numerical inaccuracy.
        quadrature_tolerance : float, optional
            Tolerance for testing whether the provided sampling grid is
            a valid quadrature. The sampling is considered a valid quadrature
            if the maximum absolute deviation of the inner product of the
            weighted spherical harmonics matrix from the identity matrix is
            smaller than the specified tolerance. The default is ``1e-10``.
        """

        if type(coordinates) is not pf.Coordinates:
            raise TypeError('coordinates must be a pyfar Coordinates object')

        # make sure mutable data is copied
        if coordinates.weights is None:
            weights = None
        else:
            weights = coordinates.weights.copy()

        return cls(
            coordinates.x, coordinates.y, coordinates.z,
            weights=weights, comment=coordinates.comment,
            n_max=n_max, radius_tolerance=radius_tolerance,
            quadrature_tolerance=quadrature_tolerance)

    @classmethod
    def from_cartesian(
            cls, x, y, z, n_max=None, weights: np.array = None,
            comment: str = "", radius_tolerance: float = 1e-6,
            quadrature_tolerance: float = 1e-10):
        r"""
        Create a SamplingSphere class object from a set of points on a sphere.

        See :py:mod:`pyfar.classes.coordinates` for  more information.

        Parameters
        ----------
        x : ndarray, number
            X coordinate of a right handed Cartesian coordinate system in
            meters (-\infty < x < \infty).
        y : ndarray, number
            Y coordinate of a right handed Cartesian coordinate system in
            meters (-\infty < y < \infty).
        z : ndarray, number
            Z coordinate of a right handed Cartesian coordinate system in
            meters (-\infty < z < \infty).
        n_max : int, optional
            Maximum spherical harmonic order of the sampling grid.
            The default is ``None``.
        weights: array like, number, optional
            Weighting factors for coordinate points. Their sum must equal to
            the integral over the unit sphere, which is :math:`4\pi`.
            The `shape` of the array must match the `shape` of the individual
            coordinate arrays. The default is ``None``, which means that no
            weights are used.
        comment : str, optional
            Comment about the stored coordinate points. The default is
            ``""``, which initializes an empty string.
        radius_tolerance : float, optional
            All points that are stored in a SamplingSphere must have the same
            radius and an error is raised if the maximum deviation from the
            mean radius exceeds this tolerance. The default of ``1e-6`` meter
            is intended to allow for some numerical inaccuracy.
        quadrature_tolerance : float, optional
            Tolerance for testing whether the provided sampling grid is
            a valid quadrature. The sampling is considered a valid quadrature
            if the maximum absolute deviation of the inner product of the
            weighted spherical harmonics matrix from the identity matrix is
            smaller than the specified tolerance. The default is ``1e-10``.

        Examples
        --------
        Create a SamplingSphere object

        >>> import spharpy
        >>> sampling = spharpy.SamplingSphere.from_cartesian(0, 0, 1)

        or the same using

        >>> import spharpy
        >>> sampling = spharpy.SamplingSphere(0, 0, 1)
        """
        return cls(
            x, y, z, weights=weights, comment=comment, n_max=n_max,
            radius_tolerance=radius_tolerance,
            quadrature_tolerance=quadrature_tolerance)

    @classmethod
    def from_spherical_elevation(
            cls, azimuth, elevation, radius, n_max=None,
            weights: np.array = None, comment: str = "",
            radius_tolerance: float = 1e-6,
            quadrature_tolerance: float = 1e-10):
        r"""
        Create a SamplingSphere class object from a set of points on a sphere.

        See :py:mod:`pyfar.classes.coordinates` for  more information.

        Parameters
        ----------
        azimuth : ndarray, double
            Angle in radiant of rotation from the x-y-plane facing towards
            positive x direction. Used for spherical and cylindrical coordinate
            systems.
        elevation : ndarray, double
            Angle in radiant with respect to horizontal plane (x-z-plane).
            Used for spherical coordinate systems.
        radius : ndarray, double
            Distance to origin for each point. Used for spherical coordinate
            systems.
        n_max : int, optional
            Maximum spherical harmonic order of the sampling grid.
            The default is ``None``.
        weights: array like, float, None, optional
            Weighting factors for coordinate points. Their sum must equal to
            the integral over the unit sphere, which is :math:`4\pi`.
            The `shape` of the array must match the `shape` of the individual
            coordinate arrays. The default is ``None``, which means that no
            weights are used.
        comment : str, optional
            Comment about the stored coordinate points. The default is
            ``""``, which initializes an empty string.
        radius_tolerance : float, optional
            All points that are stored in a SamplingSphere must have the same
            radius and an error is raised if the maximum deviation from the
            mean radius exceeds this tolerance. The default of ``1e-6`` meter
            is intended to allow for some numerical inaccuracy.
        quadrature_tolerance : float, optional
            Tolerance for testing whether the provided sampling grid is
            a valid quadrature. The sampling is considered a valid quadrature
            if the maximum absolute deviation of the inner product of the
            weighted spherical harmonics matrix from the identity matrix is
            smaller than the specified tolerance. The default is ``1e-10``.

        Examples
        --------
        Create a SamplingSphere object

        >>> import spharpy
        >>> sampling = spharpy.SamplingSphere.from_spherical_elevation(0, 0, 1)
        """

        x, y, z = sph2cart(
            azimuth, np.pi / 2 - np.atleast_1d(elevation), radius)
        return cls(
            x, y, z, weights=weights, comment=comment, n_max=n_max,
            radius_tolerance=radius_tolerance,
            quadrature_tolerance=quadrature_tolerance)

    @classmethod
    def from_spherical_colatitude(
            cls, azimuth, colatitude, radius, n_max=None,
            weights: np.array = None, comment: str = "",
            radius_tolerance: float = 1e-6,
            quadrature_tolerance: float = 1e-10):
        r"""
        Create a SamplingSphere class object from a set of points on a sphere.

        See :py:mod:`pyfar.classes.coordinates` for  more information.

        Parameters
        ----------
        azimuth : ndarray, double
            Angle in radiant of rotation from the x-y-plane facing towards
            positive x direction. Used for spherical and cylindrical coordinate
            systems.
        colatitude : ndarray, double
            Angle in radiant with respect to polar axis (z-axis). Used for
            spherical coordinate systems.
        radius : ndarray, double
            Distance to origin for each point. Used for spherical coordinate
            systems.
        n_max : int, optional
            Maximum spherical harmonic order of the sampling grid.
            The default is ``None``.
        weights: array like, number, optional
            Weighting factors for coordinate points. Their sum must equal to
            the integral over the unit sphere, which is :math:`4\pi`.
            The `shape` of the array must match the `shape` of the individual
            coordinate arrays. The default is ``None``, which means that no
            weights are used.
        comment : str, optional
            Comment about the stored coordinate points. The default is
            ``""``, which initializes an empty string.
        radius_tolerance : float, optional
            All points that are stored in a SamplingSphere must have the same
            radius and an error is raised if the maximum deviation from the
            mean radius exceeds this tolerance. The default of ``1e-6`` meter
            is intended to allow for some numerical inaccuracy.
        quadrature_tolerance : float, optional
            Tolerance for testing whether the provided sampling grid is
            a valid quadrature. The sampling is considered a valid quadrature
            if the maximum absolute deviation of the inner product of the
            weighted spherical harmonics matrix from the identity matrix is
            smaller than the specified tolerance. The default is ``1e-10``.

        Examples
        --------
        Create a SamplingSphere object

        >>> import spharpy
        >>> sampling = spharpy.SamplingSphere.from_spherical_colatitude(
        ...     0, 0, 1)
        """

        x, y, z = sph2cart(azimuth, colatitude, radius)
        return cls(
            x, y, z, weights=weights, comment=comment, n_max=n_max,
            radius_tolerance=radius_tolerance,
            quadrature_tolerance=quadrature_tolerance)

    @classmethod
    def from_spherical_side(
            cls, lateral, polar, radius, n_max=None,
            weights: np.array = None, comment: str = "",
            radius_tolerance: float = 1e-6,
            quadrature_tolerance: float = 1e-10):
        r"""
        Create a SamplingSphere class object from a set of points on a sphere.

        See :py:mod:`pyfar.classes.coordinates` for  more information.

        Parameters
        ----------
        lateral : ndarray, double
            Angle in radiant with respect to horizontal plane (x-y-plane).
            Used for spherical coordinate systems.
        polar : ndarray, double
            Angle in radiant of rotation from the x-z-plane facing towards
            positive x direction. Used for spherical coordinate systems.
        radius : ndarray, double
            Distance to origin for each point. Used for spherical coordinate
            systems.
        n_max : int, optional
            Maximum spherical harmonic order of the sampling grid.
            The default is ``None``.
        weights: array like, number, optional
            Weighting factors for coordinate points. Their sum must equal to
            the integral over the unit sphere, which is :math:`4\pi`.
            The `shape` of the array must match the `shape` of the individual
            coordinate arrays. The default is ``None``, which means that no
            weights are used.
        comment : str, optional
            Comment about the stored coordinate points. The default is
            ``""``, which initializes an empty string.
        radius_tolerance : float, optional
            All points that are stored in a SamplingSphere must have the same
            radius and an error is raised if the maximum deviation from the
            mean radius exceeds this tolerance. The default of ``1e-6`` meter
            is intended to allow for some numerical inaccuracy.
        quadrature_tolerance : float, optional
            Tolerance for testing whether the provided sampling grid is
            a valid quadrature. The sampling is considered a valid quadrature
            if the maximum absolute deviation of the inner product of the
            weighted spherical harmonics matrix from the identity matrix is
            smaller than the specified tolerance. The default is ``1e-10``.

        Examples
        --------
        Create a SamplingSphere object

        >>> import spharpy
        >>> sampling = spharpy.SamplingSphere.from_spherical_side(0, 0, 1)
        """

        x, z, y = sph2cart(
            polar, np.pi / 2 - np.atleast_1d(lateral), radius)
        return cls(
            x, y, z, weights=weights, comment=comment, n_max=n_max,
            radius_tolerance=radius_tolerance,
            quadrature_tolerance=quadrature_tolerance)

    @classmethod
    def from_spherical_front(
            cls, frontal, upper, radius, n_max=None, weights: np.array = None,
            comment: str = "", radius_tolerance: float = 1e-6,
            quadrature_tolerance: float = 1e-10):
        r"""
        Create a SamplingSphere class object from a set of points on a sphere.

        See :py:mod:`pyfar.classes.coordinates` for  more information.

        Parameters
        ----------
        frontal : ndarray, double
            Angle in radiant of rotation from the y-z-plane facing towards
            positive y direction. Used for spherical coordinate systems.
        upper : ndarray, double
            Angle in radiant with respect to polar axis (x-axis). Used for
            spherical coordinate systems.
        radius : ndarray, double
            Distance to origin for each point. Used for spherical coordinate
            systems.
        n_max : int, optional
            Maximum spherical harmonic order of the sampling grid.
            The default is ``None``.
        weights: array like, number, optional
            Weighting factors for coordinate points. Their sum must equal to
            the integral over the unit sphere, which is :math:`4\pi`.
            The `shape` of the array must match the `shape` of the individual
            coordinate arrays. The default is ``None``, which means that no
            weights are used.
        comment : str, optional
            Comment about the stored coordinate points. The default is
            ``""``, which initializes an empty string.
        radius_tolerance : float, optional
            All points that are stored in a SamplingSphere must have the same
            radius and an error is raised if the maximum deviation from the
            mean radius exceeds this tolerance. The default of ``1e-6`` meter
            is intended to allow for some numerical inaccuracy.
        quadrature_tolerance : float, optional
            Tolerance for testing whether the provided sampling grid is
            a valid quadrature. The sampling is considered a valid quadrature
            if the maximum absolute deviation of the inner product of the
            weighted spherical harmonics matrix from the identity matrix is
            smaller than the specified tolerance. The default is ``1e-10``.

        Examples
        --------
        Create a SamplingSphere object

        >>> import spharpy
        >>> sampling = spharpy.SamplingSphere.from_spherical_front(0, 0, 1)
        """

        y, z, x = sph2cart(frontal, upper, radius)
        return cls(
            x, y, z, weights=weights, comment=comment, n_max=n_max,
            radius_tolerance=radius_tolerance,
            quadrature_tolerance=quadrature_tolerance)

    @classmethod
    def from_cylindrical(
            cls, azimuth, z, rho, n_max=None, weights: np.array = None,
            comment: str = "", radius_tolerance: float = 1e-6,
            quadrature_tolerance: float = 1e-10):
        r"""
        Create a SamplingSphere class object from a set of points on a sphere.

        See :py:mod:`pyfar.classes.coordinates` for  more information.

        Parameters
        ----------
        azimuth : ndarray, double
            Angle in radiant of rotation from the x-y-plane facing towards
            positive x direction. Used for spherical and cylindrical coordinate
            systems.
        z : ndarray, double
            The z coordinate
        rho : ndarray, double
            Distance to origin for each point in the x-y-plane. Used for
            cylindrical coordinate systems.
        n_max : int, optional
            Maximum spherical harmonic order of the sampling grid.
            The default is ``None``.
        weights: array like, number, optional
            Weighting factors for coordinate points. Their sum must equal to
            the integral over the unit sphere, which is :math:`4\pi`.
            The `shape` of the array must match the `shape` of the individual
            coordinate arrays. The default is ``None``, which means that no
            weights are used.
        comment : str, optional
            Comment about the stored coordinate points. The default is
            ``""``, which initializes an empty string.
        radius_tolerance : float, optional
            All points that are stored in a SamplingSphere must have the same
            radius and an error is raised if the maximum deviation from the
            mean radius exceeds this tolerance. The default of ``1e-6`` meter
            is intended to allow for some numerical inaccuracy.
        quadrature_tolerance : float, optional
            Tolerance for testing whether the provided sampling grid is
            a valid quadrature. The sampling is considered a valid quadrature
            if the maximum absolute deviation of the inner product of the
            weighted spherical harmonics matrix from the identity matrix is
            smaller than the specified tolerance. The default is ``1e-10``.

        Examples
        --------
        Create a SamplingSphere object

        >>> import spharpy
        >>> sampling = spharpy.SamplingSphere.from_cylindrical(
        ...     0, 0, 1, sh_order=1)
        """

        x, y, z = cyl2cart(azimuth, z, rho)
        return cls(
            x, y, z, weights=weights, comment=comment, n_max=n_max,
            radius_tolerance=radius_tolerance,
            quadrature_tolerance=quadrature_tolerance)

    @property
    def n_max(self):
        """Get or set the spherical harmonic order."""
        return self._n_max

    @n_max.setter
    def n_max(self, value):
        """Get or set the spherical harmonic order."""
        assert value >= 0

        if value is None:
            self._n_max = None
            self._quadrature = False
        else:
            if self._n_max != value:
                self._quadrature = None
            self._n_max = int(value)

    @property
    def radius_tolerance(self):
        """Get or set the radius tolerance in meter."""
        return self._radius_tolerance

    @radius_tolerance.setter
    def radius_tolerance(self, value):
        """Get or set the radius tolerance in meter."""

        # check input
        if not isinstance(value, (int, float)) or value <= 0:
            raise ValueError(
                'The radius tolerance must be a number greater than zero')

        current_tolerance = self.radius_tolerance
        self._radius_tolerance = float(value)

        # Check if points meet new tolerance if points exist
        if hasattr(self, 'x'):
            try:
                self._check_points(self._x, self._y, self._z)
            except ValueError as e:
                # revert setting the tolerance and raise the error
                self._radius_tolerance = current_tolerance
                raise e

    @property
    def quadrature_tolerance(self):
        """Get or set the quadrature tolerance."""
        return self._quadrature_tolerance

    @quadrature_tolerance.setter
    def quadrature_tolerance(self, value):
        """Get or set the quadrature tolerance."""

        # check input
        if not isinstance(value, (int, float)) or value <= 0:
            raise ValueError(
                'The quadrature tolerance must be a number greater than zero')

        self._quadrature_tolerance = float(value)
        self._quadrature = None

    def _check_points(self, x, y, z):
        """Check input data before setting coordinates."""

        # convert to numpy arrays of the same shape
        x, y, z = super()._check_points(x, y, z)

        # check for equal radius
        radius = np.sqrt(x.flatten()**2 + y.flatten()**2 + z.flatten()**2)
        radius_delta = np.max(radius) - np.min(radius)
        if radius_delta > self.radius_tolerance:
            raise ValueError(
                'All points must have the same radius but the difference '
                f'between the minimum and maximum radius is {radius_delta:.3g}'
                ' m, which exceeds the tolerance of '
                f'{self.radius_tolerance:.3g} m. The tolerance can be changed '
                'using SamplingSphere.radius_tolerance.')

        # reset the quadrature flag to make sure it is checked again after
        # adding or changing points in the SamplingSphere
        self._quadrature = None

        return x, y, z

    def _check_weights(self, weights):
        r"""Check if the weights are valid.
        The weights must be positive and their sum must equal integration of
        the unit sphere, i.e. :math:`4\pi`.

        Parameters
        ----------
        weights : array like, number
            the weights for each point, should be of size of self.csize.

        Returns
        -------
        weights : np.ndarray[float64], None
            The weights reshaped to the cshape of the coordinates if not None.
            Otherwise None.
        """
        weights = super()._check_weights(weights)

        if weights is None:
            return weights
        if np.any(weights < 0) or np.any(np.isnan(weights)):
            raise ValueError("All weights must be positive numeric values.")

        if not np.isclose(np.sum(weights), 4*np.pi, atol=1e-6, rtol=1e-6):
            raise ValueError(
                "The sum of the weights must be equal to 4*pi. "
                f"Current sum: {np.sum(weights)}")

        return weights

    def _check_quadrature(self):
        r"""Check if the sampling is a valid quadrature.

        Returns
        -------
        check : bool
            Indicates if sampling is a valid quadrature
        """
        if self.n_max is None or self.weights is None:
            return False
        # create basis matrix
        sh_basis = spherical_harmonic_basis_real(self.n_max, self)

        # test if basis is quadrature
        quad_evaluation = sh_basis.T @ np.diag(self.weights) @ sh_basis
        identity = np.eye((self.n_max + 1)**2)

        error = np.max(np.abs(quad_evaluation-identity))
        return error < self.quadrature_tolerance

    @property
    def weights(self):
        r"""The area/quadrature weights of the sampling.
        Their sum must equal to :math:`4\pi`.
        """
        return super().weights

    @weights.setter
    def weights(self, weights):
        r"""Get or set the area/quadrature weights of the sampling.
        Their sum must equal to :math:`4\pi`.
        """
        if not np.array_equal(weights, self.weights):
            self._quadrature = None

        super(__class__, type(self)).weights.fset(self, weights)

    @property
    def quadrature(self):
        """Get the quadrature flag."""
        if self._quadrature is None:
            # recompute _quadrature flag
            self._quadrature = self._check_quadrature()

        return self._quadrature


    def __repr__(self):
        """Get info about SamplingSphere object."""
        if self.csize == 0:
            return 'SamplingSphere: empty'

        return f'SamplingSphere: n_max={self.n_max}, cshape={self.cshape}'
