"""
Collection of sampling schemes for the sphere.
"""
import os
from urllib3.exceptions import InsecureRequestWarning
import numpy as np
from pyfar import Coordinates
import spharpy
import warnings
import scipy.io as sio
import requests
from multiprocessing.pool import ThreadPool

from ._eqsp import point_set as eq_point_set
from ._eqsp import lebedev_sphere


def equidistant_cuboid(n_points, flatten_output=True):
    """
    Create a cubic sampling with equidistant spacings in x, y, and z.

    The cuboid spans from -1 m to 1 m along each axis and is centered at
    the origin.

    Parameters
    ----------
    n_points : int, tuple
        Number of points in the sampling. If a single value is given, the
        number of sampling positions will be the same in every axis. If a
        tuple is given, the number of points will be set as
        ``(n_x, n_y, n_z)``.
    flatten_output : bool, optional
        Whether to flatten the output Coordinates object or not.
        The default is ``False``.

    Returns
    -------
    sampling : :py:class:`pyfar.Coordinates`
        Sampling positions as Coordinate object with cshape
        ``(n_x, n_y, n_z)`` if `flatten_output` is ``False``, otherwise
        with cshape ``(n_x*n_y*n_z, )``.
        Does not contain sampling weights.

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.equidistant_cuboid(3)
        >>> spharpy.plot.scatter(coords)
    """
    if not isinstance(flatten_output, bool):
        raise ValueError("flatten_output must be a boolean.")
    try:
        n_points = np.asarray(n_points)
        assert (n_points > 0).all()
        assert (n_points % 1 == 0).all()
        n_points = n_points.astype(int)
    except Exception as e:
        raise ValueError(
            "The number of points needs to be either an integer "
            "or a tuple with 3 elements.") from e
    if np.size(n_points) == 1:
        n_x = n_points
        n_y = n_points
        n_z = n_points
    elif np.size(n_points) == 3:
        n_x = n_points[0]
        n_y = n_points[1]
        n_z = n_points[2]
    else:
        raise ValueError("The number of points needs to be either an integer \
                or a tuple with 3 elements.")

    x = np.linspace(-1, 1, n_x)
    y = np.linspace(-1, 1, n_y)
    z = np.linspace(-1, 1, n_z)

    x_grid, y_grid, z_grid = np.meshgrid(x, y, z, indexing='ij')

    if flatten_output:
        x_grid = x_grid.flatten()
        y_grid = y_grid.flatten()
        z_grid = z_grid.flatten()

    return Coordinates(x_grid, y_grid, z_grid)


def hyperinterpolation(n_max, radius=1.):
    r"""
    Return a Hyperinterpolation sampling grid.

    After Sloan and Womersley [#]_. The samplings are available for
    spherical harmonics orders :math:`1 \leq n_\text{max} \leq 200`. The number
    of points in the sampling grid equals :math:`(n_\text{max} + 1)^2`.

    Parameters
    ----------
    n_max : int
        Maximum applicable spherical harmonic order. It must be
        between ``1`` and ``200``.
    radius : number, optional
        Radius of the sampling grid in meters. The default is ``1``.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        Sampling positions including sampling weights, with
        ``(n_max + 1)**2`` points.

    Notes
    -----
    This implementation uses precalculated sets of points from [#]_. The data
    up to ``n_max = 20`` are loaded the first time this function is called.
    The remaining data is loaded upon request.

    References
    ----------
    .. [#]  I. H. Sloan and R. S. Womersley, “Extremal Systems of Points and
            Numerical Integration on the Sphere,” Advances in Computational
            Mathematics, vol. 21, no. 1/2, pp. 107-125, 2004.
    .. [#]  https://web.maths.unsw.edu.au/~rsw/Sphere/MaxDet/

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.hyperinterpolation(n_max=3)
        >>> spharpy.plot.scatter(coords)

    """
    # check inputs
    if not isinstance(n_max, int) or n_max < 1 or n_max > 200:
        raise ValueError('n_max must be an integer between 1 and 200')
    if not isinstance(
            radius, (int, float)) or np.size(radius) > 1 or (radius <= 0):
        raise ValueError('radius must be a single positive value')

    # calculate number of points
    n_points = (n_max + 1)**2

    # download data if necessary
    filename = "samplings_extremal_md%03d.%05d" % (n_max, n_points)
    filename = os.path.join(os.path.dirname(__file__), "_eqsp",  filename)
    if not os.path.exists(filename):
        if n_max < 21:
            _sph_extremal_load_data(np.arange(1, 21))
        else:
            _sph_extremal_load_data(n_max)

    # open data
    with open(filename, 'rb') as f:
        file_data = f.read()

    # format data
    file_data = file_data.decode()
    file_data = np.fromstring(
        file_data,
        dtype=float, count=int(n_points)*4,
        sep=' ').reshape((int(n_points), 4))

    # normalize weights
    weights = file_data[:, 3]

    # generate Coordinates object
    sampling = spharpy.SamplingSphere(
        file_data[:, 0] * radius,
        file_data[:, 1] * radius,
        file_data[:, 2] * radius,
        n_max=n_max, weights=weights,
        comment='extremal spherical sampling grid')

    return sampling


def t_design(n_max, criterion='const_energy', radius=1.):
    r"""
    Return spherical t-design sampling grid.

    For detailed information, see [#]_.
    For a spherical harmonic order :math:`n_{sh}`, a t-Design of degree
    :math:`t=2n_{sh}` for constant energy or :math:`t=2n_{sh}+1` additionally
    ensuring a constant angular spread of energy is required [#]_. For a given
    degree t

    .. math::

        L = \lceil \frac{(t+1)^2}{2} \rceil+1,

    points will be generated, except for t = 3, 5, 7, 9, 11, 13, and 15.
    T-designs allow for an inverse spherical harmonic transform matrix
    calculated as :math:`D = \frac{4\pi}{L} \mathbf{Y}^\mathrm{H}` with
    :math:`\mathbf{Y}^\mathrm{H}` being the hermitian transpose of the
    spherical harmonics matrix.

    Parameters
    ----------
    n_max : int
        Maximum applicable spherical harmonic order. Related to the degree
        by ``degree = 2 * n_max`` (``const_energy``) and
        ``degree = 2 * n_max + 1`` (``const_angular_spread``).
        Must be between ``1`` and ``90`` for ``const_energy`` and between
        ``1`` and ``89`` for ``const_angular_spread``.
    criterion : ``const_energy``, ``const_angular_spread``
        Design criterion ensuring only a constant energy or additionally
        constant angular spread of energy. The default is ``const_energy``.
    radius : number, optional
        Radius of the sampling grid in meters. The default is ``1``.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        Sampling positions. Sampling weights can be obtained from
        :py:func:`calculate_sampling_weights`.

    Notes
    -----
    This function downloads a pre-calculated set of points from [#]_ . The data
    up to ``degree = 20`` are loaded the first time this function is called.
    The remaining data is loaded upon request.

    References
    ----------

    .. [#]  C. An, X. Chen, I. H. Sloan, and R. S. Womersley, “Well Conditioned
            Spherical Designs for Integration and Interpolation on the
            Two-Sphere,” SIAM Journal on Numerical Analysis, vol. 48, no. 6,
            pp. 2135-2157, Jan. 2010.
    .. [#]  F. Zotter, M. Frank, and A. Sontacchi, “The Virtual T-Design
            Ambisonics-Rig Using VBAP,” in Proceedings on the Congress on
            Sound and Vibration, 2010.
    .. [#]  http://web.maths.unsw.edu.au/~rsw/Sphere/EffSphDes/sf.html

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.t_design(n_max=3)
        >>> spharpy.plot.scatter(coords)

    """
    # check inputs
    if criterion not in ['const_energy', 'const_angular_spread']:
        raise ValueError("Invalid design criterion. Must be 'const_energy' \
                         or 'const_angular_spread'.")
    if not isinstance(n_max, (int)):
        raise ValueError("n_max must be an integer.")
    if not isinstance(radius, (int, float)) or radius <= 0:
        raise ValueError("radius must be a positive number.")

    # get the degree
    if criterion == 'const_energy':
        if n_max < 1 or n_max > 90:
            raise ValueError(
                'n_max must be between 1 and 90 for const_energy criterion.')
        degree = 2 * n_max
    else:
        if n_max < 1 or n_max > 89:
            raise ValueError(
                'n_max must be between 1 and 89 for const_angular_spread '
                'criterion.')
        degree = 2 * n_max + 1

    if degree < 1 or degree > 180:
        raise ValueError('degree must be between 1 and 180.')

    # get the number of points
    n_points = int(np.ceil((degree + 1)**2 / 2) + 1)
    n_points_exceptions = {3: 8, 5: 18, 7: 32, 9: 50, 11: 72, 13: 98, 15: 128}
    if degree in n_points_exceptions:
        n_points = n_points_exceptions[degree]

    # download data if necessary
    filename = "samplings_t_design_sf%03d.%05d" % (degree, n_points)
    filename = os.path.join(os.path.dirname(__file__), "_eqsp",  filename)
    if not os.path.exists(filename):
        if degree < 21:
            _sph_t_design_load_data(np.arange(1, 21))
        else:
            _sph_t_design_load_data(degree)

    # open data
    with open(filename, 'rb') as f:
        file_data = f.read()

    # format data
    file_data = file_data.decode()
    points = np.fromstring(
        file_data,
        dtype=np.double,
        sep=' ').reshape((n_points, 3))

    # generate Coordinates object
    sampling = spharpy.SamplingSphere(
        points[..., 0] * radius,
        points[..., 1] * radius,
        points[..., 2] * radius,
        n_max=n_max)

    return sampling


def dodecahedron(radius=1.):
    """
    Generate a sampling based on the center points of the twelve
    dodecahedron faces.

    Parameters
    ----------
    radius : number, optional
        Radius of the sampling grid. The default is ``1``.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        Sampling positions. Sampling weights can be obtained from
        :py:func:`calculate_sampling_weights`.

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.dodecahedron()
        >>> spharpy.plot.scatter(coords)
    """

    dihedral = 2 * np.arcsin(np.cos(np.pi / 3) / np.sin(np.pi / 5))
    R = np.tan(np.pi / 3) * np.tan(dihedral / 2)
    rho = np.cos(np.pi / 5) / np.sin(np.pi / 10)

    theta1 = np.arccos((np.cos(np.pi / 5)
                       / np.sin(np.pi / 5))
                       / np.tan(np.pi / 3))

    a2 = 2 * np.arccos(rho / R)

    theta2 = theta1 + a2
    theta3 = np.pi - theta2
    theta4 = np.pi - theta1

    phi1 = 0
    phi2 = 2 * np.pi / 3
    phi3 = 4 * np.pi / 3

    theta = np.concatenate((
        np.tile(theta1, 3),
        np.tile(theta2, 3),
        np.tile(theta3, 3),
        np.tile(theta4, 3)))
    phi = np.tile(np.array([
        phi1,
        phi2,
        phi3,
        phi1 + np.pi / 3,
        phi2 + np.pi / 3,
        phi3 + np.pi / 3]), 2)
    rad = radius * np.ones(np.size(theta))

    return spharpy.SamplingSphere.from_spherical_colatitude(
        phi, theta, rad)


def icosahedron(radius=1.):
    """
    Generate a sampling from the center points of the twenty icosahedron faces.

    Parameters
    ----------
    radius : number, optional
        Radius of the sampling grid. The default is ``1``.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        Sampling positions. Sampling weights can be obtained from
        :py:func:`calculate_sampling_weights`.

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.icosahedron()
        >>> spharpy.plot.scatter(coords)

    """
    gamma_R_r = np.arccos(np.cos(np.pi/3) / np.sin(np.pi/5))
    gamma_R_rho = np.arccos(1/(np.tan(np.pi/5) * np.tan(np.pi/3)))

    theta = np.tile(np.array([np.pi - gamma_R_rho,
                              np.pi - gamma_R_rho - 2 * gamma_R_r,
                              2 * gamma_R_r + gamma_R_rho,
                              gamma_R_rho]), 5)
    theta = np.sort(theta)
    phi = np.arange(0, 2 * np.pi, 2 * np.pi / 5)
    phi = np.concatenate((np.tile(phi, 2), np.tile(phi + np.pi / 5, 2)))

    return spharpy.SamplingSphere.from_spherical_colatitude(
        phi, theta, radius)


def equiangular(n_max=None, n_points=None, radius=1.):
    r"""
    Generate an equiangular sampling of the sphere.

    For detailed information, see [#]_, Chapter 3.2. This is a quadrature
    sampling with the sum of the sampling weights in `sampling.weights`
    being :math:`4\pi` if the number of rings is even and the number of
    points in azimuth and elevation are equal. This condition is always
    fulfilled if the number of points is chosen through ``n_max``.
    This sampling does not contain points at the North and South Pole and is
    typically used for spherical harmonics processing. See
    :py:func:`equal_angle` and :py:func:`great_circle` for samplings
    containing points at the poles.

    Parameters
    ----------
    n_max : int
        Maximum applicable spherical harmonic order. If this is provided,
        'n_points' is set to ``2 * n_max + 1``. Either `n_points` or
        `n_max` must be provided. The default is ``None``.
    n_points : int, tuple of two ints
        Number of sampling points in azimuth and elevation. Either `n_points`
        or `n_max` must be provided. The default is ``None``.
    radius : number, optional
        Radius of the sampling grid. The default is ``1``.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        Sampling positions including sampling weights.

    References
    ----------
    .. [#] B. Rafaely, Fundamentals of spherical array processing, 1st ed.
           Berlin, Heidelberg, Germany: Springer, 2015.

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.equiangular(n_max=3)
        >>> spharpy.plot.scatter(coords)

    """
    if (n_points is None) and (n_max is None):
        raise ValueError(
            "Either the n_points or n_max needs to be specified.")

    # get number of points from required spherical harmonic order
    # ([#], equation 3.4)
    if n_max is not None:
        n_points = 2 * (int(n_max) + 1)

    # get the angles
    n_points = np.asarray(n_points)
    if n_points.size == 2:
        n_phi = n_points[0]
        n_theta = n_points[1]
    else:
        n_phi = n_points
        n_theta = n_points

    theta_angles = np.arange(np.pi / (n_theta * 2), np.pi, np.pi / n_theta)
    phi_angles = np.arange(0, 2 * np.pi, 2 * np.pi / n_phi)

    # construct the sampling grid
    theta, phi = np.meshgrid(theta_angles, phi_angles)
    rad = radius * np.ones(theta.size)

    # compute maximum applicable spherical harmonic order
    if n_max is None:
        n_max = int(np.min([n_phi / 2 - 1, n_theta / 2 - 1]))
    else:
        n_max = int(n_max)

    # compute sampling weights ([1], equation 3.11)
    # Note that this is only valid if the number of points in ring is even
    # and the number of points in azimuth and elevation are equal
    if (n_theta != n_phi) or np.any(np.mod(n_points, 2) > 0):
        weights = None
    else:
        q = 2 * np.arange(0, n_max + 1) + 1
        weights_theta = np.sin(theta_angles) * (
            1/q @ np.sin(q[np.newaxis].T @ theta_angles[np.newaxis]))

        weights = np.tile(weights_theta*2*np.pi / (n_max+1)**2, n_phi)

    # make Coordinates object
    sampling = spharpy.SamplingSphere.from_spherical_colatitude(
        phi.reshape(-1), theta.reshape(-1), rad,
        weights=weights, n_max=n_max)

    return sampling


def gaussian(n_max, radius=1.):
    r"""
    Generate sampling of the sphere based on the Gaussian quadrature.

    For detailed information, see [#]_ (Section 3.3). This is a quadrature
    sampling with the sum of the sampling weights in `sampling.weights`
    being :math:`4\pi`.
    This sampling does not contain points at the North and South Pole and is
    typically used for spherical harmonics processing. See
    :py:func:`equal_angle` and :py:func:`great_circle` for samplings
    containing points at the poles.

    Parameters
    ----------
    n_max : int
        Maximum applicable spherical harmonic order. Results in
        ``2 * (n_max + 1)`` in azimuth and ``n_max + 1`` points in elevation.
    radius : number, optional
        Radius of the sampling grid in meters. The default is ``1``.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        Sampling positions including sampling weights.

    References
    ----------
    .. [#] B. Rafaely, Fundamentals of spherical array processing, 1st ed.
           Berlin, Heidelberg, Germany: Springer, 2015.

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.gaussian(n_max=3)
        >>> spharpy.plot.scatter(coords)

    """

    # get number of points from required spherical harmonic order
    # ([1], chapter 3.3)
    n_max = int(n_max)
    n_phi = (n_max+1)*2

    # construct the sampling grid
    legendre, weights = np.polynomial.legendre.leggauss(n_max+1)
    theta_angles = np.arccos(legendre)

    phi_angles = np.linspace(0, 2 * np.pi - (2 * np.pi / n_phi), n_phi)
    theta, phi = np.meshgrid(theta_angles, phi_angles)

    # compute the sampling weights
    weights = np.tile(weights*np.pi/(n_max+1), 2*(n_max+1))

    # make Coordinates object
    sampling = spharpy.SamplingSphere.from_spherical_colatitude(
        phi.reshape(-1), theta.reshape(-1), radius,
        weights=weights, n_max=n_max)

    return sampling


def eigenmike_em32():
    """Microphone positions of the Eigenmike em32 by mhacoustics.

    The data are according to the Eigenstudio user manual on the homepage [#]_.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        SamplingSphere object containing all sampling points

    References
    ----------
    .. [#]  Eigenstudio User Manual, https://eigenmike.com/sites/default/files/documentation-2023-10/EigenStudio%20User%20Manual%20R02D.pdf

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.eigenmike_em32()
        >>> spharpy.plot.scatter(coords)
    """  # noqa: E501
    rad = np.ones(32)
    theta = np.array([69.0, 90.0, 111.0, 90.0, 32.0, 55.0,
                      90.0, 125.0, 148.0, 125.0, 90.0, 55.0,
                      21.0, 58.0, 121.0, 159.0, 69.0, 90.0,
                      111.0, 90.0, 32.0, 55.0, 90.0, 125.0,
                      148.0, 125.0, 90.0, 55.0, 21.0, 58.0,
                      122.0, 159.0]) * np.pi / 180
    phi = np.array([0.0, 32.0, 0.0, 328.0, 0.0, 45.0,
                    69.0, 45.0, 0, 315.0, 291.0, 315.0,
                    91.0, 90.0, 90.0, 89.0, 180.0, 212.0,
                    180.0, 148.0, 180.0, 225.0, 249.0, 225.0,
                    180.0, 135.0, 111.0, 135.0, 269.0, 270.0,
                    270.0, 271.0]) * np.pi / 180

    return spharpy.SamplingSphere.from_spherical_colatitude(phi, theta, rad)


def eigenmike_em64():
    """Microphone positions of the Eigenmike em64 by mhacoustics.

    The data are according to the Eigenmike user manual on the homepage [#]_.

    Returns
    -------
    sampling : SamplingSphere
        SamplingSphere object containing all sampling points

    References
    ----------
    .. [#]  Eigenmike em64 Getting Started Guide, https://eigenmike.com/sites/default/files/documentation-2024-09/getting%20started%20Guide%20to%20em64%20and%20ES3%20R01H.pdf

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.eigenmike_em64()
        >>> spharpy.plot.scatter(coords)
    """  # noqa: E501

    rad = np.ones(64)*0.042

    theta = np.deg2rad(np.array([
        16.7656, 21.9677, 42.3941, 13.2817, 22.6728, 52.6925, 37.806, 43.3944,
        43.9386, 70.3132, 33.2231, 60.0257, 56.4763, 67.4936, 93.2735, 48.423,
        78.0793, 62.0685, 38.7171, 63.8004, 70.1946, 96.246, 81.0992, 106.094,
        67.7533, 91.7061, 39.9985, 68.7726, 60.8869, 82.2833, 63.0247, 89.794,
        137.5166, 139.7604, 135.2133, 160.3628, 162.577, 142.0685, 161.1987,
        162.577, 115.536, 86.2594, 116.0164, 95.3313, 90.0637, 111.4549,
        85.8671, 130.8398, 102.5775, 142.6375, 117.032, 117.5631, 115.8884,
        89.69, 118.4478, 93.9338, 106.3875, 81.0511, 135.9764, 142.6771,
        120.6556, 133.8834, 116.3591, 107.464,
    ]))

    phi = np.deg2rad(np.array([
        197.4561, 115.734, 81.911, 313.3592, 43.1785, 46.7324, 335.9958,
        14.5398, 204.4547, 206.542, 247.3219, 233.817, 264.5437, 99.6669,
        104.6842, 120.9227, 126.513, 148.2368, 162.6381, 178.5498, 21.2715,
        25.7834, 47.8607, 55.9075, 71.4285, 78.4921, 293.221, 290.5683,
        318.1354, 334.0042, 352.0227, 0, 174.0335, 212.7205, 251.9179,
        150.6471, 240.8266, 293.0625, 331.0098, 60.8266, 226.9135, 233.9255,
        193.6382, 209.6696, 183.169, 163.7105, 156.9524, 139.4318, 135.9729,
        102.3273, 112.5511, 83.1464, 307.7078, 309.1392, 278.2519, 282.9735,
        253.147, 260.0688, 59.7394, 14.2241, 32.4901, 334.0753, 2.0842,
        335.0677,
    ]))

    weights = np.array([
        0.954, 0.9738, 1.0029, 1.0426, 1.0426, 1.0024, 0.9738, 0.954, 1.009,
        0.9932, 1.0024, 1.0324, 0.954, 1.0024, 1.0079, 1.0268, 1.0151, 0.9463,
        1.012, 1.0253, 1.009, 0.9932, 1.0324, 1.0151, 0.954, 1.0079, 1.0029,
        1.0024, 1.0268, 0.9463, 1.012, 1.0253, 0.954, 0.9738, 1.0029, 1.0426,
        1.0426, 1.0024, 0.954, 0.9738, 1.0268, 1.0151, 1.012, 0.9463, 1.0253,
        1.009, 0.9932, 1.0024, 1.0324, 1.0029, 0.954, 1.0024, 1.0324, 1.0151,
        0.954, 1.0079, 1.0024, 1.0079, 1.0268, 1.012, 0.9463, 1.009, 1.0253,
        0.9932,
    ])
    # weights from the Eigenmike em64 user manual are not sufficiently
    # precise, so we need to re-normalize here
    weights = weights / np.sum(weights) * 4 * np.pi

    sampling = spharpy.SamplingSphere.from_spherical_colatitude(
        phi, theta, rad, n_max=6)
    sampling.weights = weights

    return sampling


def icosahedron_ke4():
    """Microphone positions of IHTA's KE4 spherical microphone array.
    The microphone marked as "1" defines the positive x-axis.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        SamplingSphere object containing all sampling points

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.icosahedron_ke4()
        >>> spharpy.plot.scatter(coords)
    """

    theta = np.array([1.565269801525254, 2.294997457752220, 1.226592351686568,
                      1.226592351686568, 1.696605844149543, 2.409088979442091,
                      2.409088979442090, 1.696605844149543, 0.506378251408914,
                      0.506378251408914, 2.635214402180879, 1.444986809440250,
                      0.732503674147703, 0.732503674147703, 1.444986809440250,
                      2.635214402180879, 1.915000301903226, 0.846595195837573,
                      1.915000301903225, 1.576322852064539])

    phi = np.array([0.000000000000000, 6.283185307179586, 0.660263824203969,
                    5.622921482975618, 5.055791576545843, 5.241315660913181,
                    1.041869646266405, 1.227393730633743, 0.826693168093822,
                    5.456492139085764, 3.968285821683615, 4.368986384223536,
                    4.183462299856198, 2.099723007323388, 1.914198922956050,
                    2.314899485495971, 2.481328829385824, 3.141592653589793,
                    3.801856477793762, 3.141592653589793])

    rad = np.ones(20) * 0.065

    return spharpy.SamplingSphere.from_spherical_colatitude(
        phi, theta, rad)


def equal_area(n_max, condition_num=2.5, n_points=None):
    """Sampling based on partitioning into faces with equal area.

    The implementation is based on [#]_ and Leopardi's MATLAB implementation.

    Parameters
    ----------
    n_max : int
        Spherical harmonic order
    condition_num : double
        Desired maximum condition number of the spherical harmonic basis
        matrix. Default is ``2.5``.
    n_points : int, optional
        Number of points to start the condition number optimization. If set to
        ``None`` the value ``(n_max+1)**2`` will be used as start.
        Default is ``None``.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        SamplingSphere object containing all sampling points

    References
    ----------
    .. [#]  P. Leopardi, “A partition of the unit sphere into regions of equal
            area and small diameter,” Electronic Transactions on Numerical
            Analysis, vol. 25, no. 12, pp. 309-327, 2006.

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.equal_area(n_max=3)
        >>> spharpy.plot.scatter(coords)

    """
    if not n_points:
        n_points = (n_max+1)**2

    while True:
        point_data = eq_point_set(2, n_points)
        sampling = spharpy.SamplingSphere(
            point_data[0], point_data[1], point_data[2])

        if condition_num == np.inf:
            break
        Y = spharpy.spherical.spherical_harmonic_basis(n_max, sampling)
        cond = np.linalg.cond(Y)
        if cond < condition_num:
            break
        n_points += 1

    sampling.n_max = n_max
    return sampling


def spiral_points(n_max, condition_num=2.5, n_points=None):
    """Sampling based on a spiral distribution of points on a sphere.

    The implementation is based on [#]_.

    Parameters
    ----------
    n_max : int
        Spherical harmonic order
    condition_num : double
        Desired maximum condition number of the spherical harmonic basis
        matrix. Default is ``2.5``.
    n_points : int, optional
        Number of points to start the condition number optimization. If set to
        ``None`` the value ``(n_max+1)**2`` will be used as start.
        Default is ``None``.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        SamplingSphere object containing all sampling points

    References
    ----------

    .. [#]  E. a. Rakhmanov, E. B. Saff, and Y. M. Zhou, “Minimal Discrete
            Energy on the Sphere,” Mathematical Research Letters, vol. 1,
            no. 6, pp. 647-662, 1994.

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.spiral_points(n_max=3)
        >>> spharpy.plot.scatter(coords)

    """
    if n_points is None:
        n_points = (n_max+1)**2

    def _spiral_points(n_points):
        """Helper function doing the actual calculation of the points."""
        r = np.zeros(n_points)
        h = np.zeros(n_points)
        theta = np.zeros(n_points)
        phi = np.zeros(n_points)

        p = 1/2
        a = 1 - 2*p/(n_points-3)
        b = p*(n_points+1)/(n_points-3)
        r[0] = 0
        theta[0] = np.pi
        phi[0] = 0
        # Then for k stepping by 1 from 2 to n-1:
        for k in range(1, n_points-1):
            kStrich = a*k + b
            h[k] = -1 + 2*(kStrich-1)/(n_points-1)
            r[k] = np.sqrt(1-h[k]**2)
            theta[k] = np.arccos(h[k])
            phi[k] = np.mod(
                (phi[k-1]) + 3.6/np.sqrt(n_points)*2/(r[k-1]+r[k]), 2*np.pi)
        # Finally:
        theta[n_points-1] = 0
        phi[n_points-1] = 0

        return theta, phi

    while True:
        theta, phi = _spiral_points(n_points)
        sampling = spharpy.SamplingSphere.from_spherical_colatitude(
            phi, theta, np.ones(n_points))
        if condition_num == np.inf:
            break
        Y = spharpy.spherical.spherical_harmonic_basis(n_max, sampling)
        cond = np.linalg.cond(Y)
        if cond < condition_num:
            break
        n_points += 1

    sampling.n_max = n_max

    return sampling


def equal_angle(delta_angles, radius=1.):
    """
    Generate sampling of the sphere with equally spaced angles.

    This sampling contain points at the North and South Pole. See
    :py:func:`equiangular`, :py:func:`gaussian`, and
    :py:func:`great_circle` for samplings that do not contain points at the
    poles.


    Parameters
    ----------
    delta_angles : tuple, number
        Tuple that gives the angular spacing in azimuth and colatitude in
        degrees. If a number is provided, the same spacing is applied in both
        dimensions.
    radius : number, optional
        Radius of the sampling grid. The default is ``1``.

    Returns
    -------
    sampling : :py:class:`pyfar.Coordinates`
        Sampling positions. Sampling weights can be obtained from
        :py:func:`calculate_sampling_weights`.

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.equal_angle(delta_angles=45)
        >>> spharpy.plot.scatter(coords)

    """

    # get the angles
    delta_angles = np.asarray(delta_angles)
    if delta_angles.size == 2:
        delta_phi = delta_angles[0]
        delta_theta = delta_angles[1]
    else:
        delta_phi = delta_angles
        delta_theta = delta_angles

    # check if the angles can be distributed
    eps = np.finfo('float').eps
    if not (np.abs(360 % delta_phi) < 2*eps):
        raise ValueError("delta_phi must be an integer divisor of 360")
    if not (np.abs(180 % delta_theta) < 2*eps):
        raise ValueError("delta_theta must be an integer divisor of 180")

    # get the angles
    phi_angles = np.arange(0, 360, delta_phi)
    theta_angles = np.arange(delta_theta, 180, delta_theta)

    # stack the angles
    phi = np.tile(phi_angles, theta_angles.size)
    theta = np.repeat(theta_angles, phi_angles.size)

    # add North and South Pole
    phi = np.concatenate(([0], phi, [0]))
    theta = np.concatenate(([0], theta, [180]))

    # make Coordinates object
    sampling = spharpy.SamplingSphere.from_spherical_colatitude(
        phi/180*np.pi, theta/180*np.pi, radius,
        comment='equal angle spherical sampling grid')

    return sampling


def great_circle(
        elevation=np.linspace(-90, 90, 19), gcd=10, radius=1,
        azimuth_res=1, match=360):
    r"""
    Spherical sampling grid according to the great circle distance criterion.

    Sampling grid where neighboring points of the same elevation have approx.
    the same great circle distance across elevations [#]_.

    Parameters
    ----------
    elevation : array like, optional
        Contains the elevation from wich the sampling grid is generated, with
        :math:`-90^\circ\leq elevation \leq 90^\circ` (:math:`90^\circ`:
        North Pole, :math:`-90^\circ`: South Pole). The default is
        ``np.linspace(-90, 90, 19)``.
    gcd : number, optional
        Desired great circle distance (GCD). Note that the actual GCD of the
        sampling grid is equal or smaller then the desired GCD and that the GCD
        may vary across elevations. The default is ``10``.
    radius : number, optional
        Radius of the sampling grid in meters. The default is ``1``.
    azimuth_res : number, optional
        Minimum resolution of the azimuth angle in degree. The default is
        ``1``.
    match : number, optional
        Forces azimuth entries to appear with a period of match degrees. E.g.,
        if ``match=90``, the grid includes the azimuth angles 0, 90, 180, and
        270 degrees. The default is ``360``.

    Returns
    -------
    sampling : :py:class:`pyfar.Coordinates`
        Sampling positions. Sampling weights can be obtained from
        :py:func:`calculate_sampling_weights`.

    References
    ----------
    .. [#]  B. P. Bovbjerg, F. Christensen, P. Minnaar, and X. Chen, “Measuring
            the head-related transfer functions of an artificial head with a
            high directional resolution,” 109th AES Convention, Los Angeles,
            USA, Sep. 2000.

    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.great_circle()
        >>> spharpy.plot.scatter(coords)

    """

    # check input
    assert 1 / azimuth_res % 1 == 0, "1/azimuth_res must be an integer."
    assert not 360 % match, "360/match must be an integer."
    assert match / azimuth_res % 1 == 0, "match/azimuth_res must be an \
                                         integer."

    elevation = np.atleast_1d(np.asarray(elevation))

    # calculate delta azimuth to meet the desired great circle distance.
    # (according to Bovbjerg et al. 2000: Measuring the head related transfer
    # functions of an artificial head with a high directional azimuth_res)
    d_az = 2 * np.arcsin(np.clip(
        np.sin(gcd / 360 * np.pi) / np.cos(elevation / 180 * np.pi), -1, 1))
    d_az = d_az / np.pi * 180
    # correct values at the poles
    d_az[np.abs(elevation) == 90] = 360
    # next smallest value in desired angular azimuth_res
    d_az = d_az // azimuth_res * azimuth_res

    # adjust phi to make sure that: match // d_az == 0
    for nn in range(d_az.size):
        if abs(elevation[nn]) != 90:
            while match % d_az[nn] > 1e-15:
                # round to precision of azimuth_res to avoid numerical errors
                d_az[nn] = np.round((d_az[nn] - azimuth_res)/azimuth_res) \
                           * azimuth_res

    # construct the full sampling grid
    azim = np.empty(0)
    elev = np.empty(0)
    for nn in range(elevation.size):
        azim = np.append(azim, np.arange(0, 360, d_az[nn]))
        elev = np.append(elev, np.full(int(360 / d_az[nn]), elevation[nn]))

    # round to precision of azimuth_res to avoid numerical errors
    azim = np.round(azim/azimuth_res) * azimuth_res

    # make Coordinates object
    sampling = spharpy.SamplingSphere.from_spherical_elevation(
        azim/180*np.pi, elev/180*np.pi, radius,
        comment='spherical great circle sampling grid')

    return sampling


def lebedev(n_max=None, radius=1.):
    r"""
    Return Lebedev spherical sampling grid.

    For the Lebedev grid [#]_, the number of points :math:`Q` can be
    approximated by the spherical harmonic order :math:`N` (parameter `n_max`)

    :math:`Q \approx 1.3 \, (N+1)^2`

    For a list of available orders `n_max` and the exact number of points call
    :py:func:`lebedev` without any arguments. This will print available
    orders and number of points to the console.

    .. note::
        This is intended to be a quadrature sampling with positive sampling
        weights summing to :math:`4\pi`. For unknown reasons, this is not the
        case in the original sources [#]_ and the Matlab code [#]_ on which
        this Python version is based for orders 6, 12, and 13. For this reason,
        the respective weights are not returned regardless of the order.

    Parameters
    ----------
    n_max : int, optional
        Maximum applicable spherical harmonic order between 1 and 65. Because
        not all orders are available, the default ``None`` prints possible
        orders to the console.
    radius : number, optional
        Radius of the sampling grid in meters. The default is ``1``.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        Sampling positions without sampling weights (see note above).

    References
    ----------
    .. [#] V.I. Lebedev, and D.N. Laikov
           "A quadrature formula for the sphere of the 131st
           algebraic order of accuracy"
           Doklady Mathematics, Vol. 59, No. 3, 1999, pp. 477-481.
    .. [#] https://people.sc.fsu.edu/~jburkardt/datasets/sphere_lebedev_rule/sphere_lebedev_rule.html
    .. [#] https://de.mathworks.com/matlabcentral/fileexchange/27097-getlebedevsphere


    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.lebedev(n_max=3)
        >>> spharpy.plot.scatter(coords)

    """  # noqa: E501

    # possible degrees
    degrees = np.array([6, 14, 26, 38, 50, 74, 86, 110, 146, 170, 194, 230,
                        266, 302, 350, 434, 590, 770, 974, 1202, 1454, 1730,
                        2030, 2354, 2702, 3074, 3470, 3890, 4334, 4802, 5294,
                        5810], dtype=int)

    # corresponding spherical harmonic orders
    orders = np.array((np.floor(np.sqrt(degrees / 1.3) - 1)), dtype=int)

    # list possible sh orders and degrees
    if n_max is None:
        print('Possible input values:')
        for o, d in zip(orders, degrees, strict=True):
            print(f"SH order {o}, number of points {d}")

        return None

    # check if the order is available
    if n_max not in orders:
        str_orders = [f"{o}" for o in orders]
        raise ValueError("Invalid spherical harmonic order 'n_max'. \
                         Valid orders are: {}.".format(', '.join(str_orders)))

    # get the samlpling
    leb = lebedev_sphere(degrees[n_max == orders])

    # generate Coordinates object
    sampling = spharpy.SamplingSphere(
        leb["x"] * radius,
        leb["y"] * radius,
        leb["z"] * radius,
        n_max=n_max,
        comment='spherical Lebedev sampling grid')

    return sampling


def fliege(n_max, radius=1.):
    r"""
    Return Fliege-Maier spherical sampling grid.

    For detailed information, see [#]_.

    .. note::
        This is intended to be a quadrature sampling with positive sampling
        weights summing to :math:`4\pi`. For unknown reasons, this is not the
        case for orders 10, 12, 18, 20, 24, 26, 27, and 29. For this reason,
        the respective weights are not returned regardless of the order.

    Parameters
    ----------
    n_max : int
        Maximum applicable spherical harmonic order.
        It must be between 1 and 29.
    radius : number, optional
        Radius of the sampling grid in meters. The default is ``1``.

    Returns
    -------
    sampling : :py:class:`spharpy.SamplingSphere`
        ``(n_max + 1)**2`` sampling positions without sampling weights
        (see note above).

    Notes
    -----
    This implementation uses pre-calculated points from the SOFiA
    toolbox [#]_.

    References
    ----------
    .. [#] J. Fliege and U. Maier, "The distribution of points on the sphere
           and corresponding cubature formulae,” IMA J. Numerical Analysis,
           Vol. 19, pp. 317-334, Apr. 1999, doi: 10.1093/imanum/19.2.317.
    .. [#] https://audiogroup.web.th-koeln.de/SOFiA_wiki/DOWNLOAD.html


    Examples
    --------

    .. plot::

        >>> import spharpy
        >>> coords = spharpy.samplings.fliege(n_max=3)
        >>> spharpy.plot.scatter(coords)

    """
    if not isinstance(n_max, int):
        raise ValueError("n_max must be an integer.")
    if n_max < 1 or n_max > 29:
        raise ValueError("n_max must be between 1 and 29.")
    if not isinstance(radius, (int, float)) or radius <= 0:
        raise ValueError("radius must be a positive number.")

    n_points = (n_max + 1)**2

    # get the sampling points
    fliege = sio.loadmat(os.path.join(
        os.path.dirname(__file__), "_eqsp", "samplings_fliege.mat"),
        variable_names=f"Fliege_{int(n_points)}")
    fliege = fliege[f"Fliege_{int(n_points)}"]

    # generate Coordinates object
    sampling = spharpy.SamplingSphere.from_spherical_colatitude(
        fliege[:, 0],
        fliege[:, 1],
        radius,
        n_max=n_max)

    # switch and invert Coordinates in Cartesian representation to be
    # consistent with [1]
    sampling.z = -sampling.z

    return sampling


def _sph_t_design_load_data(degrees='all'):
    """Download t-design sampling grids.

    Note: Samplings are downloaded form
    https://web.maths.unsw.edu.au/~rsw/Sphere/EffSphDes/sf.html

    Parameters
    ----------
    degrees : str or list of int, optional
        list of int load sampling of specified degree, `all` load all
        samplings up to degree 180, by default 'all'.
    """

    # set the degrees to be read
    if isinstance(degrees, int):
        degrees = [degrees]
    elif isinstance(degrees, str):
        degrees = range(1, 181)

    prefix = 'samplings_t_design_'

    n_points_exceptions = {3: 8, 5: 18, 7: 32, 9: 50, 11: 72, 13: 98, 15: 128}

    entries = []
    for degree in degrees:
        # number of sampling points
        n_points = int(np.ceil((degree + 1)**2 / 2) + 1)
        if degree in n_points_exceptions:
            n_points = n_points_exceptions[degree]

        # load the data
        filename = "sf%03d.%05d" % (degree, n_points)
        url = "http://web.maths.unsw.edu.au/~rsw/Sphere/Points/SF/"\
              "SF29-Nov-2012/"
        fileurl = url + filename
        path_save = os.path.join(
            os.path.dirname(__file__), "_eqsp", prefix + filename)

        entries.append((path_save, fileurl))

    pool = ThreadPool(50)
    pool.imap_unordered(_fetch_url, entries)
    pool.close()
    pool.join()


def _sph_extremal_load_data(orders='all'):
    """Download extremal sampling grids.

    Note: Samplings are downloaded form
    https://web.maths.unsw.edu.au/~rsw/Sphere/MaxDet/MaxDet1.html

    Parameters
    ----------
    orders : str or list of int, optional
        list of int load sampling of specified orders, `all` load all
        samplings up to orders 200, by default 'all'.
    """

    # set the SH orders to be read
    if isinstance(orders, int):
        orders = [orders]
    elif isinstance(orders, str):
        orders = range(1, 201)

    prefix = 'samplings_extremal_'

    entries = []
    for n_max in orders:
        # number of sampling points
        n_points = (n_max + 1)**2

        # load the data
        filename = "md%03d.%05d" % (n_max, n_points)
        url = "https://web.maths.unsw.edu.au/~rsw/Sphere/S2Pts/MD/"
        fileurl = url + filename
        path_save = os.path.join(
            os.path.dirname(__file__), "_eqsp", prefix + filename)

        entries.append((path_save, fileurl))

    # download on parallel
    pool = ThreadPool(50)
    pool.imap_unordered(_fetch_url, entries)
    pool.close()
    pool.join()


def _fetch_url(entry):
    path, uri = entry
    if not os.path.exists(path):
        # Kontrolle ist gut, Vertrauen ist besser
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", InsecureRequestWarning)
            r = requests.get(uri, stream=True, verify=False)
        if r.status_code == 200:
            with open(path, 'wb') as f:
                for chunk in r:
                    f.write(chunk)
    return path
