"""Eyetracking Calibration(s) class constructor."""

# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.

from copy import deepcopy

import numpy as np

from ...io.eyelink._utils import _parse_calibration
from ...utils import _check_fname, _validate_type, fill_doc, logger
from ...viz.utils import plt_show


@fill_doc
class Calibration(dict):
    """Eye-tracking calibration info.

    This data structure behaves like a dictionary. It contains information regarding a
    calibration that was conducted during an eye-tracking recording.

    .. note::
        When possible, a Calibration instance should be created with a helper function,
        such as :func:`~mne.preprocessing.eyetracking.read_eyelink_calibration`.

    Parameters
    ----------
    onset : float
        The onset of the calibration in seconds. If the calibration was
        performed before the recording started, the onset can be
        negative.
    model : str
        A string, which is the model of the eye-tracking calibration that was applied.
        For example ``'H3'`` for a horizontal only 3-point calibration, or ``'HV3'``
        for a horizontal and vertical 3-point calibration.
    eye : str
        The eye that was calibrated. For example, ``'left'``, or ``'right'``.
    avg_error : float
        The average error in degrees between the calibration positions and the
        actual gaze position.
    max_error : float
        The maximum error in degrees that occurred between the calibration
        positions and the actual gaze position.
    positions : array-like of float, shape ``(n_calibration_points, 2)``
        The x and y coordinates of the calibration points.
    offsets : array-like of float, shape ``(n_calibration_points,)``
        The error in degrees between the calibration position and the actual
        gaze position for each calibration point.
    gaze : array-like of float, shape ``(n_calibration_points, 2)``
        The x and y coordinates of the actual gaze position for each calibration point.
    screen_size : array-like of shape ``(2,)``
        The width and height (in meters) of the screen that the eyetracking
        data was collected with. For example ``(.531, .298)`` for a monitor with
        a display area of 531 x 298 mm.
    screen_distance : float
        The distance (in meters) from the participant's eyes to the screen.
    screen_resolution : array-like of shape ``(2,)``
        The resolution (in pixels) of the screen that the eyetracking data
        was collected with. For example, ``(1920, 1080)`` for a 1920x1080
        resolution display.
    """

    def __init__(
        self,
        *,
        onset,
        model,
        eye,
        avg_error,
        max_error,
        positions,
        offsets,
        gaze,
        screen_size=None,
        screen_distance=None,
        screen_resolution=None,
    ):
        super().__init__(
            onset=onset,
            model=model,
            eye=eye,
            avg_error=avg_error,
            max_error=max_error,
            screen_size=screen_size,
            screen_distance=screen_distance,
            screen_resolution=screen_resolution,
            positions=positions,
            offsets=offsets,
            gaze=gaze,
        )

    def __repr__(self):
        """Return a summary of the Calibration object."""
        return (
            f"Calibration |\n"
            f"  onset: {self['onset']} seconds\n"
            f"  model: {self['model']}\n"
            f"  eye: {self['eye']}\n"
            f"  average error: {self['avg_error']} degrees\n"
            f"  max error: {self['max_error']} degrees\n"
            f"  screen size: {self['screen_size']} meters\n"
            f"  screen distance: {self['screen_distance']} meters\n"
            f"  screen resolution: {self['screen_resolution']} pixels\n"
        )

    def copy(self):
        """Copy the instance.

        Returns
        -------
        cal : instance of Calibration
            The copied Calibration.
        """
        return deepcopy(self)

    def plot(self, show_offsets=True, axes=None, show=True):
        """Visualize calibration.

        Parameters
        ----------
        show_offsets : bool
            Whether to display the offset (in visual degrees) of each calibration
            point or not. Defaults to ``True``.
        axes : instance of matplotlib.axes.Axes | None
            Axes to draw the calibration positions to. If ``None`` (default), a new axes
            will be created.
        show : bool
            Whether to show the figure or not. Defaults to ``True``.

        Returns
        -------
        fig : instance of matplotlib.figure.Figure
            The resulting figure object for the calibration plot.
        """
        import matplotlib.pyplot as plt

        msg = "positions and gaze keys must both be 2D numpy arrays."
        assert isinstance(self["positions"], np.ndarray), msg
        assert isinstance(self["gaze"], np.ndarray), msg

        if axes is not None:
            from matplotlib.axes import Axes

            _validate_type(axes, Axes, "axes")
            ax = axes
            fig = ax.get_figure()
        else:  # create new figure and axes
            fig, ax = plt.subplots(layout="constrained")
        px, py = self["positions"].T
        gaze_x, gaze_y = self["gaze"].T

        ax.set_title(f"Calibration ({self['eye']} eye)")
        ax.set_xlabel("x (pixels)")
        ax.set_ylabel("y (pixels)")

        # Display avg_error and max_error in the top left corner
        text = (
            f"avg_error: {self['avg_error']} deg.\nmax_error: {self['max_error']} deg."
        )
        ax.text(
            0,
            1.01,
            text,
            transform=ax.transAxes,
            verticalalignment="baseline",
            fontsize=8,
        )

        # Invert y-axis because the origin is in the top left corner
        ax.invert_yaxis()
        if self["screen_resolution"] is not None:
            w, h = self["screen_resolution"]
            ax.set_xlim(0, w)
            ax.set_ylim(h, 0)
        ax.scatter(px, py, color="gray")
        ax.scatter(gaze_x, gaze_y, color="red", alpha=0.5)

        if show_offsets:
            for i in range(len(px)):
                x_offset = 0.01 * gaze_x[i]  # 1% to the right of the gazepoint
                text = ax.text(
                    x=gaze_x[i] + x_offset,
                    y=gaze_y[i],
                    s=self["offsets"][i],
                    fontsize=8,
                    ha="left",
                    va="center",
                )

        plt_show(show)
        return fig


@fill_doc
def read_eyelink_calibration(
    fname, screen_size=None, screen_distance=None, screen_resolution=None
):
    """Return info on calibrations collected in an eyelink file.

    Parameters
    ----------
    fname : path-like
        Path to the eyelink file (.asc).
    screen_size : array-like of shape ``(2,)``
        The width and height (in meters) of the screen that the eyetracking
        data was collected with. For example ``(.531, .298)`` for a monitor with
        a display area of 531 x 298 mm. Defaults to ``None``.
    screen_distance : float
        The distance (in meters) from the participant's eyes to the screen.
        Defaults to ``None``.
    screen_resolution : array-like of shape ``(2,)``
        The resolution (in pixels) of the screen that the eyetracking data
        was collected with. For example, ``(1920, 1080)`` for a 1920x1080
        resolution display. Defaults to ``None``.

    Returns
    -------
    calibrations : list
        A list of :class:`~mne.preprocessing.eyetracking.Calibration` instances, one for
        each eye of every calibration that was performed during the recording session.
    """
    fname = _check_fname(fname, overwrite="read", must_exist=True, name="fname")
    logger.info(f"Reading calibration data from {fname}")
    lines = fname.read_text(encoding="ASCII").splitlines()
    return _parse_calibration(lines, screen_size, screen_distance, screen_resolution)
