"""
ANSI/IES TM-30-18 Colour Fidelity Index
=======================================

Define the *ANSI/IES TM-30-18 Colour Fidelity Index* (CFI) computation
objects:

- :class:`colour.quality.ColourQuality_Specification_ANSIIESTM3018`
- :func:`colour.quality.colour_fidelity_index_ANSIIESTM3018`

References
----------
-   :cite:`ANSI2018` : ANSI, & IES Color Committee. (2018). ANSI/IES TM-30-18 -
    IES Method for Evaluating Light Source Color Rendition.
    ISBN:978-0-87995-379-9
-   :cite:`VincentJ2017` : Vincent J. (2017). Is there any numpy group by
    function? Retrieved June 30, 2023, from https://stackoverflow.com/a/43094244
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np

from colour.colorimetry import SpectralDistribution
from colour.hints import ArrayLike, NDArrayFloat, NDArrayInt, Tuple, cast
from colour.quality import colour_fidelity_index_CIE2017
from colour.quality.cfi2017 import (
    ColourRendering_Specification_CIE2017,
    DataColorimetry_TCS_CIE2017,
    delta_E_to_R_f,
)
from colour.utilities import as_float_array, as_float_scalar, as_int_array


@dataclass
class ColourQuality_Specification_ANSIIESTM3018:
    """
    Define the *ANSI/IES TM-30-18 Colour Fidelity Index* (CFI) colour quality
    specification.

    Parameters
    ----------
    name
        Name of the test spectral distribution.
    sd_test
        Spectral distribution of the tested illuminant.
    sd_reference
        Spectral distribution of the reference illuminant.
    R_f
        *Colour Fidelity Index* (CFI) :math:`R_f`.
    R_s
        Individual *colour fidelity indexes* data for each sample.
    CCT
        Correlated colour temperature :math:`T_{cp}`.
    D_uv
        Distance from the Planckian locus :math:`\\Delta_{uv}`.
    colorimetry_data
        Colorimetry data for the test and reference computations.
    R_g
        Gamut index :math:`R_g`.
    bins
        List of 16 lists, each containing the indexes of colour samples that
        lie in the respective hue bin.
    averages_test
        Averages of *CAM02-UCS* a', b' coordinates for each hue bin for test
        samples.
    averages_reference
        Averages for reference samples.
    average_norms
        Distance of averages for reference samples from the origin.
    R_fs
        Local colour fidelities for each hue bin.
    R_cs
        Local chromaticity shifts for each hue bin, in percents.
    R_hs
        Local hue shifts for each hue bin.
    """

    name: str
    sd_test: SpectralDistribution
    sd_reference: SpectralDistribution
    R_f: float
    R_s: NDArrayFloat
    CCT: float
    D_uv: float
    colorimetry_data: Tuple[DataColorimetry_TCS_CIE2017, DataColorimetry_TCS_CIE2017]
    R_g: float
    bins: NDArrayInt
    averages_test: NDArrayFloat
    averages_reference: NDArrayFloat
    average_norms: NDArrayFloat
    R_fs: NDArrayFloat
    R_cs: NDArrayFloat
    R_hs: NDArrayFloat


def colour_fidelity_index_ANSIIESTM3018(
    sd_test: SpectralDistribution, additional_data: bool = False
) -> (
    float
    | ColourQuality_Specification_ANSIIESTM3018
    | ColourRendering_Specification_CIE2017
):
    """
    Return the *ANSI/IES TM-30-18 Colour Fidelity Index* (CFI) :math:`R_f`
    of given spectral distribution.

    Parameters
    ----------
    sd_test
        Test spectral distribution.
    additional_data
        Whether to output additional data.

    Returns
    -------
    :class:`float` or \
:class:`colour.quality.ColourQuality_Specification_ANSIIESTM3018`
        *ANSI/IES TM-30-18 Colour Fidelity Index* (CFI).

    References
    ----------
    :cite:`ANSI2018`, :cite:`VincentJ2017`

    Examples
    --------
    >>> from colour import SDS_ILLUMINANTS
    >>> sd = SDS_ILLUMINANTS["FL2"]
    >>> colour_fidelity_index_ANSIIESTM3018(sd)  # doctest: +ELLIPSIS
    70.1208244...
    """

    if not additional_data:
        return colour_fidelity_index_CIE2017(sd_test, False)

    specification = cast(
        ColourRendering_Specification_CIE2017,
        colour_fidelity_index_CIE2017(sd_test, True),
    )

    # Setup bins based on where the reference a'b' points are located.
    bins = as_int_array(np.floor(specification.colorimetry_data[1].JMh[:, 2] / 22.5))

    bin_mask = bins == np.reshape(np.arange(16), (-1, 1))

    # "bin_mask" is used later with Numpy broadcasting and "np.nanmean"
    # to skip a list comprehension and keep all the mean calculation vectorised
    # as per :cite:`VincentJ2017`.
    bin_mask = np.choose(bin_mask, [np.nan, 1])

    # Per-bin a'b' averages.
    test_apbp = as_float_array(specification.colorimetry_data[0].Jpapbp[:, 1:])
    ref_apbp = as_float_array(specification.colorimetry_data[1].Jpapbp[:, 1:])

    # Tile the "apbp" data in the third dimension and use broadcasting to place
    # each bin mask along the third dimension. By multiplying these matrices
    # together, Numpy automatically expands the apbp data in the third
    # dimension and multiplies by the nan-filled bin mask. Finally,
    # "np.nanmean" can compute the bin mean apbp positions with the appropriate
    # axis argument.
    averages_test = np.transpose(
        np.nanmean(
            np.reshape(np.transpose(bin_mask), (99, 1, 16))
            * np.reshape(test_apbp, (*ref_apbp.shape, 1)),
            axis=0,
        )
    )
    averages_reference = np.transpose(
        np.nanmean(
            np.reshape(np.transpose(bin_mask), (99, 1, 16))
            * np.reshape(ref_apbp, (*ref_apbp.shape, 1)),
            axis=0,
        )
    )

    # Gamut Index.
    R_g = 100 * (averages_area(averages_test) / averages_area(averages_reference))

    # Local colour fidelity indexes, i.e., 16 CFIs for each bin.
    bin_delta_E_s = np.nanmean(
        np.reshape(specification.delta_E_s, (1, -1)) * bin_mask, axis=1
    )
    R_fs = as_float_array(delta_E_to_R_f(bin_delta_E_s))

    # Angles bisecting the hue bins.
    angles = (22.5 * np.arange(16) + 11.25) / 180 * np.pi
    cosines = np.cos(angles)
    sines = np.sin(angles)

    average_norms = np.linalg.norm(averages_reference, axis=1)
    a_deltas = averages_test[:, 0] - averages_reference[:, 0]
    b_deltas = averages_test[:, 1] - averages_reference[:, 1]

    # Local chromaticity shifts, multiplied by 100 to obtain percentages.
    R_cs = 100 * (a_deltas * cosines + b_deltas * sines) / average_norms

    # Local hue shifts.
    R_hs = (-a_deltas * sines + b_deltas * cosines) / average_norms

    return ColourQuality_Specification_ANSIIESTM3018(
        specification.name,
        sd_test,
        specification.sd_reference,
        specification.R_f,
        specification.R_s,
        specification.CCT,
        specification.D_uv,
        specification.colorimetry_data,
        R_g,
        bins,
        averages_test,
        averages_reference,
        average_norms,
        R_fs,
        R_cs,
        R_hs,
    )


def averages_area(averages: ArrayLike) -> float:
    """
    Compute the area of the polygon formed by the hue bin averages.

    Parameters
    ----------
    averages
        Hue bin averages.

    Returns
    -------
    :class:`float`
        Area of the polygon.
    """

    averages = as_float_array(averages)

    N = averages.shape[0]

    triangle_areas = np.empty(N)
    for i in range(N):
        u = averages[i, :]
        v = averages[(i + 1) % N, :]
        triangle_areas[i] = (u[0] * v[1] - u[1] * v[0]) / 2

    return as_float_scalar(np.sum(triangle_areas))
