import uuid
from abc import ABC, abstractmethod
from enum import Enum
from functools import reduce
from typing import Any, Callable, Sequence, cast

import numpy as np
import numpy.typing as npt

from ...config import config
from .generated import figure_pb2
from .generated.figure_type import heatmap_pb2, histogram_pb2, line_pb2

FigureUpdate = dict[str, figure_pb2.FigureData]


class SpecificFigure(ABC):
    """
    This is a base class for all figures that can be visualised through the
    visualisation server.
    """

    figures: Sequence[figure_pb2.Figure]

    @abstractmethod
    def __init__(self) -> None:
        raise NotImplementedError

    @abstractmethod
    def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
        """
        Update the figure with new data.

        The exact shape and format of the data passed as an argument will differ
        depending on the exact figure being plotted.

        The return value should be a dictionary with the UUID of the figure as the key
        and the new data as the value.

        This allows one class to update multiple figures at once (e.g. a figure plotting
        the phase and amplitude of a signal).
        """
        raise NotImplementedError


class SimpleLineChart:
    """Helper class to create a simple line chart with one or more lines.

    This allows generalising the creation of line charts, e.g. as in PerAntennaFigure.
    """

    def __init__(self, title: str, x_label: str, y_label: str) -> None:
        self.figure = figure_pb2.Figure(
            uuid=str(uuid.uuid4()),
            title=title,
            x_label=x_label,
            y_label=y_label,
        )

    def update(
        self,
        new_data: npt.NDArray[np.complex128],
        labels: Sequence[str],
        x_values: None | npt.NDArray[np.float64] = None,
    ) -> FigureUpdate:
        lines = [
            line_pb2.LineChartData.Line(
                y=new_data[i],
                label=labels[i],
                x=x_values[i] if x_values is not None else None,
            )
            for i in range(new_data.shape[0])
        ]
        return {
            self.figure.uuid: figure_pb2.FigureData(
                uuid=self.figure.uuid,
                line=line_pb2.LineChartData(lines=lines),
            )
        }


class PerSubcarrierFigure(SpecificFigure):
    def __init__(
        self,
        figures: Sequence[SimpleLineChart],
        funcs: Sequence[Callable[[npt.NDArray[Any]], npt.NDArray[Any]]],
    ) -> None:
        self.charts = figures
        self.figures = [figure.figure for figure in figures]
        self.funcs = funcs

    def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
        """
        Update the figure with new data.

        The data is expected to be in the shape (subcarriers, data).
        """
        subcarrier_labels = [f"Subcarrier {i}" for i in range(new_data.shape[0])]
        updates = [
            figure.update(func(new_data), labels=subcarrier_labels)
            for func, figure in zip(self.funcs, self.charts, strict=True)
        ]
        return reduce((lambda a, b: a | b), updates)


class LabelledMultiLineChart(SpecificFigure):
    def __init__(
        self,
        figures: Sequence[SimpleLineChart],
        funcs: list[Callable[[npt.NDArray[Any]], npt.NDArray[Any]]],
        labels: Sequence[str],
    ) -> None:
        self.charts = figures
        self.figures = [figure.figure for figure in figures]
        self.funcs = funcs
        self.labels = labels

    def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
        data_by_antenna = new_data[:, :, 0].T
        updates = [
            figure.update(func(data_by_antenna), labels=self.labels)
            for func, figure in zip(self.funcs, self.charts, strict=True)
        ]
        return reduce((lambda a, b: a | b), updates)


class PerAntennaFigure(LabelledMultiLineChart):
    """A figure that plots data for each antenna separately.

    This allows creating multiple figures, each having one line per antenna.

    Each figure can have a different function that is used to transform the data before
    plotting. For example, can be used to generate plots for the phase and amplitude of
    a signal.
    """

    def __init__(
        self,
        figures: Sequence[SimpleLineChart],
        funcs: list[Callable[[npt.NDArray[Any]], npt.NDArray[Any]]],
    ) -> None:
        super().__init__(
            figures, funcs, [f"Antenna {i + 1}" for i in range(config.antennas.count)]
        )


class MusicEigenvalueHistogram(SpecificFigure):
    def __init__(self) -> None:
        self.figure = figure_pb2.Figure(
            uuid=str(uuid.uuid4()),
            title="AoA Eigenvalues",
            x_label="Eigenvalue",
            y_label="Frequency of occurrence",
            logx=True,
        )

        self.figures = [self.figure]

    def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
        magn = np.abs(new_data)

        # The frontend library used for plotting doesn't support logarithmic
        # binning[1], so we have to do it manually.
        # Using base 10 log for the bins to make the plots easier to comprehend.
        # [1] - https://github.com/plotly/plotly.js/issues/1844
        bins = cast(
            npt.NDArray[np.float32],
            np.logspace(np.log10(magn.min()), np.log10(magn.max()), 10),
        )
        hist, _ = np.histogram(magn, bins=bins)

        series = [histogram_pb2.HistogramSeries(data=hist)]
        histogram = histogram_pb2.HistogramData(data=series, bins=bins)
        return {self.figure.uuid: figure_pb2.FigureData(histogram=histogram)}


class HeatmapFigure(SpecificFigure):
    def __init__(self) -> None:
        self.figure = figure_pb2.Figure(
            uuid=str(uuid.uuid4()),
            title="Angle of arrival Heatmap",
            x_label="Angle of arrival",
            y_label="Time of Flight",
        )

        self.figures = [self.figure]

    def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
        heatmap = heatmap_pb2.HeatmapData(
            uuid=self.figure.uuid,
            data=new_data.flatten(),
            width=new_data.shape[1],
            height=new_data.shape[0],
            x_min=0,
            x_max=np.pi,
            y_min=0,
            y_max=config.music.heatmap.tof_max,
        )
        return {self.figure.uuid: figure_pb2.FigureData(heatmap=heatmap)}


class EmpiricalCDF(SpecificFigure):
    """Helper class to create a graph of the empirical cumulative distribution function
    and probability density functions of a set of observations.
    """

    def __init__(self, title: str, x_label: str) -> None:
        self.chart = SimpleLineChart(title, x_label, "Cumulative Probability")
        self.figures = [self.chart.figure]

    def update(self, new_data: npt.NDArray[np.float64]) -> FigureUpdate:
        new_data = np.sort(new_data, axis=-1)
        if new_data.ndim == 1:
            new_data = np.expand_dims(new_data, 0)
        y_values = np.repeat(
            np.linspace(0, 1, new_data.shape[-1])[np.newaxis, :],
            new_data.shape[0],
            axis=0,
        )
        return self.chart.update(y_values, labels=["Frequency"], x_values=new_data)


class RandomVariable(SpecificFigure):
    def get_cdf(
        self, new_data: npt.NDArray[np.float64]
    ) -> tuple[npt.NDArray[np.floating[Any]], npt.NDArray[np.floating[Any]]]:
        """Get the cumulative distribution function of the data.

        The data is expected to be in the shape (lines, data) or (data,) for single-line
        charts.
        """
        new_data = np.sort(new_data, axis=-1)
        if new_data.ndim == 1:
            new_data = np.expand_dims(new_data, 0)
            y_values = np.expand_dims(np.linspace(0, 1, new_data.size), 0)
        else:
            y_values = np.repeat(
                np.linspace(0, 1, new_data.shape[-1])[np.newaxis, :],
                new_data.shape[0],
                axis=0,
            )
        return new_data, y_values

    def __init__(self, x_label: str, line_type: str = "Subcarrier") -> None:
        self.charts = [
            # SimpleLineChart(
            #     f"{x_label} Probability Density", x_label, "Probability Density"
            # ),
            SimpleLineChart(
                f"{x_label} Cumulative Distribution", x_label, "Cumulative Probability"
            ),
        ]
        self.funcs = [
            # self.get_pdf,
            self.get_cdf
        ]
        self.figures = [figure.figure for figure in self.charts]

    def update(self, new_data: npt.NDArray[np.complex128]) -> FigureUpdate:
        """
        Update the figure with new data.

        The data is expected to be in the shape (subcarriers, data).
        """
        subcarrier_labels = [f"Subcarrier {i}" for i in range(new_data.shape[0])]
        updates: list[FigureUpdate] = []
        for func, figure in zip(self.funcs, self.charts, strict=True):
            x, y = func(new_data)
            updates.append(
                figure.update(
                    y,
                    labels=subcarrier_labels,
                    x_values=x,
                )
            )
        return reduce((lambda a, b: a | b), updates)


class Figure(Enum):
    RAW_CSI = 0
    UNWRAPPED_PHASE = 1
    PROCESSED_CSI = 2
    MUSIC_EIGENVALUES = 3
    AOA_HEATMAP = 4
    PHASE_ANALYSIS = 5
    MAGN_ANALYSIS = 6

    def figure_class(self) -> SpecificFigure:
        return all_figures[self]


FigureId = str | Figure

all_figures: dict[FigureId, SpecificFigure] = {
    Figure.RAW_CSI: PerAntennaFigure(
        [
            SimpleLineChart("Raw CSI Phase", "Subcarrier", "Phase"),
            SimpleLineChart("Raw CSI Amplitude", "Subcarrier", "Amplitude"),
        ],
        [np.angle, np.abs],
    ),
    Figure.UNWRAPPED_PHASE: PerAntennaFigure(
        [SimpleLineChart("Unwrapped CSI Phase", "Subcarrier", "Phase")], [lambda x: x]
    ),
    Figure.PROCESSED_CSI: PerAntennaFigure(
        [
            SimpleLineChart("Processed CSI Phase", "Subcarrier", "Phase"),
            SimpleLineChart("Processed CSI Amplitude", "Subcarrier", "Amplitude"),
        ],
        [np.angle, np.abs],
    ),
    Figure.MUSIC_EIGENVALUES: MusicEigenvalueHistogram(),
    Figure.AOA_HEATMAP: HeatmapFigure(),
    Figure.PHASE_ANALYSIS: RandomVariable("Phase"),
    Figure.MAGN_ANALYSIS: RandomVariable("Magnitude"),
}
