from copy import deepcopy

from matplotlib import pyplot as plt
from orix.quaternion import Orientation
from orix.vector import Vector3d, Miller
from orix.crystal_map import Phase
from scipy.integrate import quad_vec
import numpy as np
from elasticipy.polefigure import add_polefigure
from elasticipy.tensors.fourth_order import FourthOrderTensor
from abc import ABC
from scipy.spatial.transform.rotation import Rotation
from scipy import __version__ as scipy_version

ANGLE_35 = 35.26438968
ANGLE_37 = 36.6992252
ANGLE_54 = 54.73561032
ANGLE_59 = 58.97991646
ANGLE_63 = 63.43494882
ANGLE_74 = 74.20683095

class CrystalTexture(ABC):
    _title = 'Abstract class for crystallographic texture'

    def __init__(self, phase=None):
        self.weight = 1.
        self._details = None
        self.phase = phase
        self.orientation = None

    def mean_tensor(self, tensor):
        """
        Perform the texture-weighted mean of a 4th-order tensor.

        Parameters
        ----------
        tensor : SymmetricFourthOrderTensor
            Reference tensor (unrotated)

        Returns
        -------
        SymmetricFourthOrderTensor
            mean value of the rotated tensor
        """
        pass

    def __mul__(self, other):
        if isinstance(other, FourthOrderTensor):
            return self.mean_tensor(other)
        else:
            t = deepcopy(self)
            t.weight = other
            return t

    def __rmul__(self, other):
        return self * other

    def __add__(self, other):
        # self + other
        if isinstance(other, CrystalTexture):
            return CompositeTexture([self, other])
        elif isinstance(other, CompositeTexture):
            t = deepcopy(other)
            t.texture_list.insert(0, self)
            return t

    def __repr__(self):
        if self._details is None:
            return self._title
        else:
            return self._title + '\n' + self._details

    def _plot_as_pf(self, orientations,
                    uvw=None, UVTW=None, hkl=None, hkil=None,
                    fig=None, projection='lambert', plot_type='plot', ax=None, symmetrise=False, **kwargs):
        miller = Miller(uvw=uvw, hkl=hkl, UVTW=UVTW, hkil=hkil, phase=self.phase)
        if symmetrise:
            miller = miller.symmetrise(unique=True)
        if fig is None:
            fig = plt.figure()
        if ax is None:
            ax = add_polefigure(fig, projection=projection)
        m = orientations.shape[0]
        n = miller.shape[0]
        phi = np.zeros((m, n))
        theta = np.zeros((m, n))
        for i in range(0, n):
            mi = miller[i]
            t = Vector3d(~orientations * mi)
            phi[:, i] = t.azimuth
            theta[:, i] = t.polar
        if plot_type == 'scatter':
            ax.scatter(phi, theta, **kwargs)
        else:
            line, = ax.plot(phi[:, 0], theta[:, 0], **kwargs)
            color = line.get_color()
            ax.plot(phi[:, 1:], theta[:, 1:], color=color)
        ax.set_ylim([0, np.pi / 2])
        return fig, ax

    def plot_as_pole_figure(self, uvw=None, hkl=None, UVTW=None, hkil=None, symmetrise=False, projection='lambert', fig=None, ax=None, **kwargs):
        """
        Plot the pole figure of the crystallographic texture

        Parameters
        ----------
        uvw : list
            Miller indices of directions to plot
        hkl : list
            Miller indices of plane normal to plot
        UVTW : list
            Miller-Bravais indices of directions to plot
        hkil : list
            Miller-Bravais indices of plane normal to plot
        symmetrise : bool, optional
            Symmetrise the direction/plane to plot the family
        projection : str, optional
            Type of projection to use, it can be either stereographic or Lambert
        fig : matplotlib.figure.Figure, optional
            Handle to existing figure, if needed
        ax : matplotlib.projections.polar.PolarAxes, optional
            Axes to plot on
        kwargs
            Keyword arguments to pass to matplotlib's scatter/plot functions

        Returns
        -------
        matplotlib.figure.Figure
            Handle to figure
        matplotlib.projections.polar.PolarAxes
            Axes where the pole figure is plotted

        Examples
        --------
        Plot the <100> pole figure of Goss texture:

        .. plot::

            from elasticipy.crystal_texture import DiscreteTexture

            goss = DiscreteTexture.Goss()
            goss.plot_as_pole_figure(uvw=[1,0,0], symmetrise=True)

        Plot the <110> pole figure of the gamma fibre texture:

        .. plot::

            from elasticipy.crystal_texture import FibreTexture

            gamma = FibreTexture.gamma()
            gamma.plot_as_pole_figure(uvw=[1,1,0], symmetrise=True)
        """
        return self._plot_as_pf(self.orientation,
                                uvw=uvw, hkl=hkl, UVTW=UVTW, hkil=hkil,
                                symmetrise=symmetrise,
                                fig=fig, projection=projection, ax=ax, plot_type='scatter', **kwargs)

    def sample(self, num=50, seed=None):
        """Generate a random sample from the texture component

        Parameters
        ----------
        num : int, optional
            Number of random orientations to generate. Default is 50.
        seed : int, optional
            Seed to use for generating random numbers. Default is None.

        Returns
        -------
        orix.quaternion.Orientation
            Random orientations from the given texture

        Examples
        --------
        Generate 50 orientations from uniform distribution:

        >>> from elasticipy.crystal_texture import UniformTexture
        >>> o = UniformTexture().sample()

        Generate 10 orientations from gamma-fibre

        >>> from elasticipy.crystal_texture import FibreTexture
        >>> o = FibreTexture.gamma().sample(num=10)
        """
        pass

class UniformTexture(CrystalTexture):
    """
    Simple class to define the uniform texture over SO(3)
    """
    _title = 'Uniform texture'

    def __init__(self):
        """
        Create a uniform texture over SO(3)
        """
        super().__init__()
        self._details = 'Uniform distribution over SO(3)'

    def mean_tensor(self, tensor):
        return tensor.infinite_random_average()

    def plot_as_pole_figure(self, miller, projection='lambert', fig=None, ax=None, **kwargs):
        if fig is None:
            fig = plt.figure()
        if ax is None:
            ax = add_polefigure(fig, projection=projection)
        phi = np.linspace(0,2*np.pi)
        theta = np.linspace(0,np.pi)
        phi, theta = np.meshgrid(phi, theta)
        r = np.ones_like(phi)
        ax.contourf(phi, theta, r, **kwargs)
        ax.set_ylim([0, np.pi / 2])
        return fig, ax

    def sample(self, num=50, seed=None):
        if scipy_version >= "1.15.0":
            rand = Rotation.random(num=num, rng=seed)
        else:
            rand = Rotation.random(num=num, random_state=seed)
        return Orientation.from_scipy_rotation(rand)

class DiscreteTexture(CrystalTexture):
    """
    Class to handle classical crystallographic texture.

    Notes
    -----
    This class implements the crystallographic textures listed by [Lohmuller]_

    References
    ----------
    .. [Lohmuller] Lohmuller, P.; Peltier, L.; Hazotte, A.; Zollinger, J.; Laheurte, P.; Fleury, E. Variations of
       the Elastic Properties of the CoCrFeMnNi High Entropy Alloy Deformed by Groove Cold Rolling.
       Materials 2018, 11, 1337. https://doi.org/10.3390/ma11081337
    """
    _title = "Crystallographic texture"

    def __init__(self, orientation, phase=None):
        """
        Create a single-orientation crystallographic texture.

        Parameters
        ----------
        orientation : orix.quaternion.orientation.Orientation
            Orientation of the crystals
        phase : orix.crystal_map.phase_list.Phase
            Phase of the crystal
        """
        super().__init__(phase=phase)
        self.orientation = orientation
        self._details = 'φ1={:.2f}°, ϕ={:.2f}°, φ2={:.2f}°'.format(*orientation.to_euler(degrees=True)[0])

    def mean_tensor(self, tensor):
        return tensor * self.orientation

    @classmethod
    def cube(cls, phase=Phase(point_group='m-3m')):
        """
        Create a Cube crystallographic texture: {100}<100>

        Parameters
        ----------
        phase : Phase, optional
            Structure of the crystal. By default, cubic (point group: m-3m) is used.

        Returns
        -------
        DiscreteTexture

        See Also
        --------
        A : Create a A single-orientation crystallographic texture
        brass : Create a Brass single-orientation crystallographic texture
        copper : Create a Copper single-orientation crystallographic texture
        CuT : Create a CuT single-orientation crystallographic texture
        Goss : Create a Goss single-orientation crystallographic texture
        GossBrass : Create a Goss-Brass single-orientation crystallographic texture
        P : Create a P single-orientation crystallographic texture
        S : Create an S single-orientation crystallographic texture
        """
        o = Orientation.from_euler([0, 0, 0], degrees=True)
        return DiscreteTexture(o, phase=phase)

    @classmethod
    def Goss(cls, phase=Phase(point_group='m-3m')):
        """
        Create a Goss crystallographic texture: {110}<100>

        Parameters
        ----------
        phase : Phase, optional
            Structure of the crystal. By default, cubic (point group: m-3m) is used.

        Returns
        -------
        DiscreteTexture

        See Also
        --------
        A : Create an A single-orientation crystallographic texture
        brass : Create a Brass single-orientation crystallographic texture
        copper : Create a Copper single-orientation crystallographic texture
        cube : Create a Cube single-orientation crystallographic texture
        CuT : Create a CuT single-orientation crystallographic texture
        GossBrass : Create a Goss-Brass single-orientation crystallographic texture
        P : Create a P single-orientation crystallographic texture
        S : Create an S single-orientation crystallographic texture
        """
        o = Orientation.from_euler([0, 45, 0], degrees=True)
        return DiscreteTexture(o, phase=phase)

    @classmethod
    def brass(cls, phase=Phase(point_group='m-3m')):
        """
        Create a Brass crystallographic texture: {110}<112>

        Parameters
        ----------
        phase : Phase, optional
            Structure of the crystal. By default, cubic (point group: m-3m) is used.

        Returns
        -------
        DiscreteTexture

        See Also
        --------
        copper : Create a Copper single-orientation crystallographic texture
        cube : Create a Cube single-orientation crystallographic texture
        GossBrass : Create a Goss-Brass single-orientation crystallographic texture
        """
        o = Orientation.from_euler([ANGLE_35, 45, 0], degrees=True)
        return DiscreteTexture(o, phase=phase)

    @classmethod
    def GossBrass(cls, phase=Phase(point_group='m-3m')):
        """
        Create a Goss/Brass crystallographic texture: {110}<115>

        Returns
        -------
        DiscreteTexture

        See Also
        --------
        brass : Create a Brass single-orientation crystallographic texture
        copper : Create a Copper single-orientation crystallographic texture
        Goss : Create a Goss single-orientation crystallographic texture
        """
        o = Orientation.from_euler([ANGLE_74, 90, 45], degrees=True)
        return DiscreteTexture(o, phase=phase)

    @classmethod
    def copper(cls, phase=Phase(point_group='m-3m')):
        """
        Create a copper crystallographic texture: {112}<111>

        Parameters
        ----------
        phase : Phase, optional
            Structure of the crystal. By default, cubic (point group: m-3m) is used.

        Returns
        -------
        DiscreteTexture

        See Also
        --------
        brass : Create a Brass single-orientation crystallographic texture
        Goss : Create a Goss single-orientation crystallographic texture
        GossBrass : Create a Goss-Brass single-orientation crystallographic texture
        """
        o = Orientation.from_euler([90, ANGLE_35, 45], degrees=True)
        return DiscreteTexture(o, phase=phase)

    @classmethod
    def A(cls, phase=Phase(point_group='m-3m')):
        """
        Create an "A" crystallographic texture: {110}<111>

        Parameters
        ----------
        phase : Phase, optional
            Structure of the crystal. By default, cubic (point group: m-3m) is used.

        Returns
        -------
        DiscreteTexture

        See Also
        --------
        cube : Create a Cube single-orientation crystallographic texture
        P : Create a P single-orientation crystallographic texture
        S : Create an S single-orientation crystallographic texture
        """
        o = Orientation.from_euler([ANGLE_35, 90, 45], degrees=True)
        return DiscreteTexture(o, phase=phase)

    @classmethod
    def P(cls, phase=Phase(point_group='m-3m')):
        """
        Create a "P"" crystallographic texture: {011}<211>

        Parameters
        ----------
        phase : Phase, optional
            Structure of the crystal. By default, cubic (point group: m-3m) is used.

        Returns
        -------
        DiscreteTexture

        See Also
        --------
        A : Create an A single-orientation crystallographic texture
        cube : Create a Cube single-orientation crystallographic texture
        S : Create an S single-orientation crystallographic texture
        """
        o = Orientation.from_euler([ANGLE_54, 90, 45], degrees=True)
        return DiscreteTexture(o, phase=phase)

    @classmethod
    def CuT(cls, phase=Phase(point_group='m-3m')):
        """
        Create a CuT crystallographic texture: {552}<115>

        Parameters
        ----------
        phase : Phase, optional
            Structure of the crystal. By default, cubic (point group: m-3m) is used.

        Returns
        -------
        DiscreteTexture

        See Also
        --------
        A : Create an A single-orientation crystallographic texture
        P : Create a P single-orientation crystallographic texture
        S : Create an S single-orientation crystallographic texture
        """
        o = Orientation.from_euler([90, ANGLE_74, 45], degrees=True)
        return DiscreteTexture(o, phase=phase)

    @classmethod
    def S(cls, phase=Phase(point_group='m-3m')):
        """
        Create an "S" crystallographic texture: {123}<634>

        Parameters
        ----------
        phase : Phase, optional
            Structure of the crystal. By default, cubic (point group: m-3m) is used.

        Returns
        -------
        DiscreteTexture

        See Also
        --------
        A : Create an A single-orientation crystallographic texture
        CuT : Create a CuT single-orientation crystallographic texture
        P : Create a P single-orientation crystallographic texture
        """
        o = Orientation.from_euler([ANGLE_59, ANGLE_37, ANGLE_63], degrees=True)
        return DiscreteTexture(o, phase=phase)

    def sample(self, num=50, seed=None):
        return Orientation(np.repeat(self.orientation.data, num, axis=0))

class FibreTexture(CrystalTexture):
    _title = 'Fibre texture'

    def __init__(self, orientation, axis, phase=None):
        """
        Create a fibre-type crystallographic texture

        Parameters
        ----------
        orientation : orix.quaternion.orientation.Orientation
            Reference orientation
        axis : list or tuple or numpy.ndarray or orix.vector.Vector3D
            Axis of rotation (in sample CS)
        point_group : orix.phase.point_group.PointGroup, optional
            Point group to use
        """
        super().__init__()
        self.orientation = orientation
        self.axis = Vector3d(axis)
        self.phase = phase

    @classmethod
    def from_Euler(cls, phi1=None, Phi=None, phi2=None, degrees=True, phase=None):
        """
        Create a fibre texture by providing two fixed Bunge-Euler values

        Parameters
        ----------
        phi1 : float
            First Euler angle
        Phi : float
            Second Euler angle
        phi2 : float
            Third Euler angle
        degrees : boolean, optional
            If true (default), the angles must be passed in degrees (in radians otherwise)

        Returns
        -------
        FibreTexture

        See Also
        --------
        from_Miller_axis : Define a fibre texture by aligning a miller direction with a given axis

        Examples
        --------
        A fibre texture corresponding to constant (e.g. zero) values for phi1 and phi2, and uniform distribution of Phi
        on [0,2π[, can be defined as follows:

        >>> from elasticipy.crystal_texture import FibreTexture
        >>> t1 = FibreTexture.from_Euler(phi1=0., phi2=0.)
        >>> t1
        Fibre texture
        φ1= 0.0°, φ2= 0.0°

        Similarly, the following returns a fibre texture for phi1=0 and Phi=0, and uniform distribution of phi2 on
        [0,2π[:

        >>> t2 = FibreTexture.from_Euler(phi1=0., Phi=0.)
        >>> t2
        Fibre texture
        φ1= 0.0°, ϕ= 0.0°
        """
        if phi1 is None:
            orient1 = Orientation.from_euler([0., Phi, phi2] , degrees=degrees)
            orient2 = Orientation.from_euler([1., Phi, phi2] , degrees=degrees)
            angle_list = {'ϕ':Phi, 'φ2':phi2}
        elif Phi is None:
            orient1 = Orientation.from_euler([phi1, 0., phi2], degrees=degrees)
            orient2 = Orientation.from_euler([phi1, 1., phi2], degrees=degrees)
            angle_list = {'φ1':phi1, 'φ2':phi2}
        elif phi2 is None:
            orient1 = Orientation.from_euler([phi1, Phi, 0.] , degrees=degrees)
            orient2 = Orientation.from_euler([phi1, Phi, 1.] , degrees=degrees)
            angle_list = {'φ1':phi1, 'ϕ':Phi}
        else:
            raise ValueError("Exactly two Euler angles are required.")
        axis = (~orient1 * orient2).axis
        a = cls(orient2, axis, phase=phase)
        (k1, v1), (k2, v2) = angle_list.items()
        if not degrees:
            v1 = v1 * 180 / np.pi
            v2 = v2 * 180 / np.pi
        a._details = f"{k1}= {v1}°, {k2}= {v2}°"
        return a

    @classmethod
    def from_Miller_axis(cls, miller, axis):
        """
        Create a perfect fibre crystallographic texture

        Parameters
        ----------
        miller : orix.vector.miller.Miller
            Crystal plane or direction to align with the axis
        axis : tuple or list
            Axis (in sample CS) to align with

        Returns
        -------
        FibreTexture

        See Also
        --------
        from_Euler : define a fibre texture from two Euler angles

        Examples
        --------
        Let's consider a cubic poly-crystal (point group: m-3m), whose orientations are defined by a perfect alignment
        of direction <100> with the Z axis of a sample (therefore a uniform distribution around the Z axis). This
        texture can be defined as:

        >>> from orix.crystal_map import Phase
        >>> from orix.vector.miller import Miller
        >>> from elasticipy.crystal_texture import FibreTexture
        >>> phase = Phase(point_group='m-3m')
        >>> m = Miller(uvw=[1,0,0], phase=phase)
        >>> t = FibreTexture.from_Miller_axis(m, [0,0,1])
        >>> t
        Fibre texture
        <1. 0. 0.> || [0, 0, 1]
        """
        ref_orient = Orientation.from_align_vectors(miller, Vector3d(axis))
        a = cls(ref_orient, axis, phase=miller.phase)
        if miller.coordinate_format == 'uvw' or miller.coordinate_format == 'UVTW':
            miller_str = str(miller.uvw[0])
            miller_str = miller_str.replace('[', '<').replace(']', '>')
        else:
            miller_str = str(miller.hkl[0])
        row_0 = "{miller} || {axis}".format(miller=miller_str, axis=axis)
        a._details = row_0
        return a

    def mean_tensor(self, tensor):
        tensor_ref_orient = tensor * ~self.orientation
        def fun(theta):
            rotation = ~Orientation.from_axes_angles(self.axis, theta)
            tensor_rotated = tensor_ref_orient * rotation
            return tensor_rotated.to_Kelvin()
        circle = 2 * np.pi
        res, *_ = quad_vec(fun, 0, circle)
        return tensor.__class__.from_Kelvin(res / circle)

    def plot_as_pole_figure(self, uvw=None, hkl=None, UVTW=None, hkil=None, projection='lambert', fig=None, ax=None,
                            n_orientations=100, symmetrise=False, **kwargs):
        theta = np.linspace(0, 2 * np.pi, n_orientations)
        orientations = self.orientation * Orientation.from_axes_angles(self.axis, theta)
        return self._plot_as_pf(orientations,
                                uvw=uvw, hkl=hkl, UVTW=UVTW, hkil=hkil,
                                symmetrise=symmetrise,
                                fig=fig, ax=ax, projection=projection, **kwargs)

    def sample(self, num=50, seed=None):
        rng = np.random.default_rng(seed)
        theta = rng.uniform(0.0, 2.0 * np.pi, size=num)
        random_rot = Orientation.from_axes_angles(self.axis, theta)
        return self.orientation * random_rot

    @classmethod
    def gamma(cls):
        """
        Create a gamma fibre-texture: <111> || ND

        Returns
        -------
        FibreTexture

        See Also
        --------
        alpha : create an alpha fibre texture
        epsilon : create an epsilon fibre texture
        """
        phase = Phase(point_group='m3m')
        m = Miller(uvw=[1,1,1], phase=phase)
        return FibreTexture.from_Miller_axis(m, [0,0,1])

    @classmethod
    def alpha(cls):
        """
        Create an alpha fibre-texture: <110> || RD

        Returns
        -------
        FibreTexture

        See Also
        --------
        gamma : create an gamma fibre texture
        epsilon : create an epsilon fibre texture
        """
        phase = Phase(point_group='m3m')
        m = Miller(uvw=[1,1,0], phase=phase)
        return FibreTexture.from_Miller_axis(m, [1,0,0])

    @classmethod
    def epsilon(cls):
        """
        Create an epsilon fibre-texture: <110> || TD

        Returns
        -------
        FibreTexture

        See Also
        --------
        gamma : create an gamma fibre texture
        alpha : create an alpha fibre texture
        """
        phase = Phase(point_group='m3m')
        m = Miller(uvw=[1,1,0], phase=phase)
        return FibreTexture.from_Miller_axis(m, [0,1,0])

class CompositeTexture:
    def __init__(self, texture_list):
        """
        Create a mix of crystal textures

        Parameters
        ----------
        texture_list : list of CrystalTexture
            List of crystal textures to mix
        """
        self.texture_list = list(texture_list)

    def __mul__(self, other):
        # self * other
        if isinstance(other, (float, int)):
            tm = deepcopy(self)
            for t in tm.texture_list:
                t.weight *= other
            return tm
        elif isinstance(other, FourthOrderTensor):
            return self.mean_tensor(other)

    def __rmul__(self, other):
        # other * self
        return self * other

    def __add__(self, other):
        # self + other
        if isinstance(other, CrystalTexture):
            t = deepcopy(self)
            t.texture_list.append(other)
            return t
        elif isinstance(other, CompositeTexture):
            return CompositeTexture(self.texture_list + other.texture_list)

    def __len__(self):
        return len(self.texture_list)

    def __repr__(self):
        title = 'Mixture of crystallographic textures'
        heading = ' Wgt.  Type      Component'
        sep =     ' ------------------------------------------------------------'
        table = []
        for t in self.texture_list:
            if isinstance(t, DiscreteTexture):
                kind = 'discrete'
            elif isinstance(t, UniformTexture):
                kind = 'uniform '
            else:
                kind = 'fibre   '
            table.append(' {:.2f}  {}  {}'.format(t.weight, kind, t._details))
        return '\n'.join([title, heading, sep] + table)

    def mean_tensor(self, tensor):
        """
        Compute the weighted average of a tensor, considering each texture component separately.

        Parameters
        ----------
        tensor : FourthOrderTensor
            Reference tensor (unrotated)

        Returns
        -------
        FourthOrderTensor

        Examples
        --------
        Let consider a mixture of Goss and fibre tensor (with phi1=0 and phi2=0):

        >>> from elasticipy.crystal_texture import DiscreteTexture, FibreTexture
        >>> from elasticipy.tensors.elasticity import StiffnessTensor
        >>> t = DiscreteTexture.Goss() + FibreTexture.from_Euler(phi1=0.0, phi2=0.0)
        >>> t
        Mixture of crystallographic textures
         Wgt.  Type      Component
         ------------------------------------------------------------
         1.00  discrete  φ1=0.00°, ϕ=45.00°, φ2=0.00°
         1.00  fibre     φ1= 0.0°, φ2= 0.0°

        Then, assume that the stiffness tensor is defined as follows:

        >>> C = StiffnessTensor.cubic(C11=186, C12=134, C44=77) # mp-30

        The ODF-weighted Voigt average can be computed as follows:

        >>> Cvoigt = t.mean_tensor(C)
        >>> Cvoigt
        Stiffness tensor (in Voigt mapping):
        [[186.   134.   134.     0.     0.     0.  ]
         [134.   224.25  95.75   0.     0.     0.  ]
         [134.    95.75 224.25  -0.     0.     0.  ]
         [  0.     0.     0.    25.88   0.     0.  ]
         [  0.     0.     0.     0.    57.75  -0.  ]
         [  0.     0.     0.     0.     0.    57.75]]

        Alternatively, on can directly use the following syntax:

        >>> Cvoigt = C * t
        """
        n = len(self)
        t = tensor.__class__.eye(shape=(n,))
        wgt = []
        for i in range(0, n):
            ti = self.texture_list[i]
            wgt.append(ti.weight)
            t[i] = ti.mean_tensor(tensor)
        return t.tensor_average(weights=wgt)

    def plot_as_pole_figure(self,
                            uvw=None, hkl=None, UVTW=None, hkil=None,
                            fig=None, symmetrise=False, projection='lambert',
                            labels=None, **kwargs):
        """
        Plot the pole figure of the composite texture, given a set of Miller indices

        Parameters
        ----------
        miller : orix.vector.miller.Miller
            Miller indices of directions/planes to plot
        projection : str, optional
            Type of projection to use, it can be either stereographic or Lambert
        fig : matplotlib.figure.Figure, optional
            Handle to existing figure, if needed
        symmetrise : bool
            Whether the symmetrise the miller indices
        labels : list of str or tuple of str, optional
            List of labels to use in the legend for each texture component. If not provided, they are automatically
            inferred from each texture component.
        kwargs
            Keyword arguments to pass to matplotlib's scatter/plot functions

        Returns
        -------
        matplotlib.figure.Figure
            Handle to figure
        matplotlib.projections.polar.PolarAxes
            Axes where the pole figure is plotted

        Examples
        --------
        Create a mixture of gamma and alpha fibre texture, and plot the corresponding [1,0,0] pole figure:

        .. plot::

            from elasticipy.crystal_texture import FibreTexture
            from orix.vector import Miller
            from orix.crystal_map import Phase

            texture = FibreTexture.alpha() + FibreTexture.gamma()
            texture.plot_as_pole_figure(uvw=[1,1,0], symmetrise=True)
        """
        if fig is None:
            fig = plt.figure(tight_layout=True)
        ax = add_polefigure(fig, projection=projection)
        for i, t in enumerate(self.texture_list):
            if labels is None:
                label = t._details
            else:
                label = labels[i]
            t.plot_as_pole_figure(uvw=uvw, hkl=hkl, UVTW=UVTW, hkil=hkil,
                                  fig=fig, projection=projection, ax=ax, symmetrise=symmetrise,
                                  label=label, **kwargs)
        return fig, ax

    def sample(self, num=50, seed=None):
        """
        Generate a random sample of orientations from the composite texture.

        Parameters
        ----------
        num : int, optional
            Number of orientations to generate
        seed : int, optional
            Seed for random number generator

        Returns
        -------
        orix.quaternion.Orientation
            Random orientations from the given texture

        Examples
        --------
        Create a balanced mixture of Goss and Brass texture:

        >>> from elasticipy.crystal_texture import DiscreteTexture
        >>> goss = DiscreteTexture.Goss()
        >>> brass = DiscreteTexture.brass()
        >>> texture = goss + brass
        >>> print(texture)
        Mixture of crystallographic textures
         Wgt.  Type      Component
         ------------------------------------------------------------
         1.00  discrete  φ1=0.00°, ϕ=45.00°, φ2=0.00°
         1.00  discrete  φ1=35.26°, ϕ=45.00°, φ2=0.00°

        Now generate a set of 1000 orientations from this texture:

        >>> o = texture.sample(num=1000, seed=123) # Use seed to ensure reproducibility

        One can check that around 50% of these orientations correspond to that of Goss:

        >>> import numpy as np
        >>> np.count_nonzero((o * ~goss.orientation).angle == 0.)
        530

        whereas all the other orientations correspond to brass:

        >>> np.count_nonzero((o * ~brass.orientation).angle == 0.)
        470
        """
        weights = np.array([w.weight for w in self.texture_list])
        weights = weights / weights.sum()
        rng = np.random.default_rng(seed)
        counts = rng.multinomial(num, weights)
        quat = np.zeros((num,4))
        start_index = 0
        for tex, ni in zip(self.texture_list, counts):
            sub_sample = tex.sample(ni, seed=seed)
            quat[start_index:start_index+ni] = sub_sample.data
            start_index += ni
        return Orientation(quat)
