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

import re
from collections import defaultdict
from pathlib import Path

import numpy as np
from numpy.typing import NDArray

from ..._fiff.constants import FIFF
from ..._fiff.meas_info import create_info
from ...annotations import Annotations
from ...utils import (
    _check_fname,
    _soft_import,
    _validate_type,
    copy_doc,
    fill_doc,
    logger,
    verbose,
    warn,
)
from ..base import BaseRaw

_UNITS: dict[str, float] = {"uv": 1e-6, "µv": 1e-6}


@fill_doc
class RawANT(BaseRaw):
    r"""Reader for Raw ANT files in .cnt format.

    Parameters
    ----------
    fname : file-like
        Path to the ANT raw file to load. The file should have the extension ``.cnt``.
    eog : str | None
        Regex pattern to find EOG channel labels. If None, no EOG channels are
        automatically detected.
    misc : str | None
        Regex pattern to find miscellaneous channels. If None, no miscellaneous channels
        are automatically detected. The default pattern ``"BIP\d+"`` will mark all
        bipolar channels as ``misc``.

        .. note::

            A bipolar channel might actually contain ECG, EOG or other signal types
            which might have a dedicated channel type in MNE-Python. In this case, use
            :meth:`mne.io.Raw.set_channel_types` to change the channel type of the
            channel.
    bipolars : list of str | tuple of str | None
        The list of channels to treat as bipolar EEG channels. Each element should be
        a string of the form ``'anode-cathode'`` or in ANT terminology as ``'label-
        reference'``. If None, all channels are interpreted as ``'eeg'`` channels
        referenced to the same reference electrode. Bipolar channels are treated
        as EEG channels with a special coil type in MNE-Python, see also
        :func:`mne.set_bipolar_reference`

        .. warning::

            Do not provide auxiliary channels in this argument, provide them in the
            ``eog`` and ``misc`` arguments.
    impedance_annotation : str
        The string to use for impedance annotations. Defaults to ``"impedance"``,
        however, the impedance measurement might mark the end of a segment and the
        beginning of a new segment, in which case a discontinuity similar to what
        :func:`mne.concatenate_raws` produces is present. In this case, it's better to
        include a ``BAD_xxx`` annotation to mark the discontinuity.

        .. note::

            Note that the impedance annotation will likely have a duration of ``0``.
            If the measurement marks a discontinuity, the duration should be modified to
            cover the discontinuity in its entirety.
    encoding : str
        Encoding to use for :class:`str` in the CNT file. Defaults to ``'latin-1'``.
    %(preload)s
    %(verbose)s
    """

    @verbose
    def __init__(
        self,
        fname: str | Path,
        eog: str | None,
        misc: str | None,
        bipolars: list[str] | tuple[str, ...] | None,
        impedance_annotation: str,
        *,
        encoding: str = "latin-1",
        preload: bool | NDArray,
        verbose=None,
    ) -> None:
        logger.info("Reading ANT file %s", fname)
        _soft_import("antio", "reading ANT files", min_version="0.5.0")

        from antio import read_cnt
        from antio.parser import (
            read_device_info,
            read_info,
            read_meas_date,
            read_subject_info,
            read_triggers,
        )

        fname = _check_fname(fname, overwrite="read", must_exist=True, name="fname")
        _validate_type(eog, (str, None), "eog")
        _validate_type(misc, (str, None), "misc")
        _validate_type(bipolars, (list, tuple, None), "bipolar")
        _validate_type(impedance_annotation, (str,), "impedance_annotation")
        if len(impedance_annotation) == 0:
            raise ValueError("The impedance annotation cannot be an empty string.")
        cnt = read_cnt(fname)
        # parse channels, sampling frequency, and create info
        ch_names, ch_units, ch_refs, _, _ = read_info(cnt, encoding=encoding)
        ch_types = _parse_ch_types(ch_names, eog, misc, ch_refs)
        if bipolars is not None:  # handle bipolar channels
            bipolars_idx = _handle_bipolar_channels(ch_names, ch_refs, bipolars)
            for idx, ch in zip(bipolars_idx, bipolars):
                if ch_types[idx] != "eeg":
                    warn(
                        f"Channel {ch} was not parsed as an EEG channel, changing to "
                        "EEG channel type since bipolar EEG was requested."
                    )
                ch_names[idx] = ch
                ch_types[idx] = "eeg"
        info = create_info(
            ch_names, sfreq=cnt.get_sample_frequency(), ch_types=ch_types
        )
        info.set_meas_date(read_meas_date(cnt))
        make, model, serial, site = read_device_info(cnt, encoding=encoding)
        info["device_info"] = dict(type=make, model=model, serial=serial, site=site)
        his_id, name, sex, birthday = read_subject_info(cnt, encoding=encoding)
        info["subject_info"] = dict(
            his_id=his_id,
            first_name=name,
            sex=sex,
        )
        if birthday is not None:
            info["subject_info"]["birthday"] = birthday
        if bipolars is not None:
            with info._unlock():
                for idx in bipolars_idx:
                    info["chs"][idx]["coil_type"] = FIFF.FIFFV_COIL_EEG_BIPOLAR
        first_samps = np.array((0,))
        last_samps = (cnt.get_sample_count() - 1,)
        raw_extras = {
            "orig_nchan": cnt.get_channel_count(),
            "orig_ch_units": ch_units,
            "first_samples": np.array(first_samps),
            "last_samples": np.array(last_samps),
        }
        super().__init__(
            info,
            preload=preload,
            first_samps=first_samps,
            last_samps=last_samps,
            filenames=[fname],
            verbose=verbose,
            raw_extras=[raw_extras],
        )
        # look for annotations (called trigger by ant)
        onsets, durations, descriptions, _, disconnect = read_triggers(cnt)
        onsets, durations, descriptions = _prepare_annotations(
            onsets, durations, descriptions, disconnect, impedance_annotation
        )
        onsets = np.array(onsets) / self.info["sfreq"]
        durations = np.array(durations) / self.info["sfreq"]
        annotations = Annotations(onsets, duration=durations, description=descriptions)
        self.set_annotations(annotations)

    def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
        from antio import read_cnt
        from antio.parser import read_data

        ch_units = self._raw_extras[0]["orig_ch_units"]
        first_samples = self._raw_extras[0]["first_samples"]
        n_times = self._raw_extras[0]["last_samples"] + 1
        for first_samp, this_n_times in zip(first_samples, n_times):
            i_start = max(start, first_samp)
            i_stop = min(stop, this_n_times + first_samp)
            # read and scale data array
            cnt = read_cnt(self.filenames[fi])
            one = read_data(cnt, i_start, i_stop)
            _scale_data(one, ch_units)
            data_view = data[:, i_start - start : i_stop - start]
            if isinstance(idx, slice):
                data_view[:] = one[idx]
            else:
                # faster than doing one = one[idx]
                np.take(one, idx, axis=0, out=data_view)


def _handle_bipolar_channels(
    ch_names: list[str], ch_refs: list[str], bipolars: list[str] | tuple[str, ...]
) -> list[int]:
    """Handle bipolar channels."""
    bipolars_idx = []
    for ch in bipolars:
        _validate_type(ch, (str,), "bipolar_channel")
        if "-" not in ch:
            raise ValueError(
                "Bipolar channels should be provided as 'anode-cathode' or "
                f"'label-reference'. '{ch}' is not valid."
            )
        anode, cathode = ch.split("-")
        if anode not in ch_names:
            raise ValueError(f"Anode channel {anode} not found in the channels.")
        idx = ch_names.index(anode)
        if cathode != ch_refs[idx]:
            raise ValueError(
                f"Reference electrode for {anode} is {ch_refs[idx]}, not {cathode}."
            )
        # store idx for later FIFF coil type change
        bipolars_idx.append(idx)
    return bipolars_idx


def _parse_ch_types(
    ch_names: list[str], eog: str | None, misc: str | None, ch_refs: list[str]
) -> list[str]:
    """Parse the channel types."""
    eog = re.compile(eog) if eog is not None else None
    misc = re.compile(misc) if misc is not None else None
    ch_types = []
    for ch in ch_names:
        if eog is not None and re.fullmatch(eog, ch):
            ch_types.append("eog")
        elif misc is not None and re.fullmatch(misc, ch):
            ch_types.append("misc")
        else:
            ch_types.append("eeg")
    eeg_refs = [ch_refs[k] for k, elt in enumerate(ch_types) if elt == "eeg"]
    if len(set(eeg_refs)) == 1:
        logger.info(
            "All %i EEG channels are referenced to %s.", len(eeg_refs), eeg_refs[0]
        )
    else:
        warn("All EEG channels are not referenced to the same electrode.")
    return ch_types


def _prepare_annotations(
    onsets: list[int],
    durations: list[int],
    descriptions: list[str],
    disconnect: dict[str, list[int]],
    impedance_annotation: str,
) -> tuple[list[int], list[int], list[str]]:
    """Parse the ANT triggers into better Annotations."""
    # first, let's replace the description 'impedance' with impedance_annotation
    for k, desc in enumerate(descriptions):
        if desc.lower() == "impedance":
            descriptions[k] = impedance_annotation
    # next, let's look for amplifier connection/disconnection and let's try to create
    # BAD_disconnection annotations from them.
    if (
        len(disconnect["start"]) == len(disconnect["stop"])
        and len(disconnect["start"]) != 0
        and all(
            0 <= stop - start
            for start, stop in zip(disconnect["start"], disconnect["stop"])
        )
    ):
        for start, stop in zip(disconnect["start"], disconnect["stop"]):
            onsets.append(start)
            durations.append(stop - start)
            descriptions.append("BAD_disconnection")
    else:
        for elt in disconnect["start"]:
            onsets.append(elt)
            durations.append(0)
            descriptions.append("Amplifier disconnected")
        for elt in disconnect["stop"]:
            onsets.append(elt)
            durations.append(0)
            descriptions.append("Amplifier reconnected")
    return onsets, durations, descriptions


def _scale_data(data: NDArray[np.float64], ch_units: list[str]) -> None:
    """Scale the data array based on the human-readable units reported by ANT.

    Operates in-place.
    """
    units_index = defaultdict(list)
    for idx, unit in enumerate(ch_units):
        units_index[unit].append(idx)
    for unit, value in units_index.items():
        if unit in _UNITS:
            data[np.array(value, dtype=np.int16), :] *= _UNITS[unit]
        else:
            warn(
                f"Unit {unit} not recognized, not scaling. Please report the unit on "
                "a github issue on https://github.com/mne-tools/mne-python."
            )


@copy_doc(RawANT)
def read_raw_ant(
    fname,
    eog=None,
    misc=r"BIP\d+",
    bipolars=None,
    impedance_annotation="impedance",
    *,
    encoding: str = "latin-1",
    preload=False,
    verbose=None,
) -> RawANT:
    """
    Returns
    -------
    raw : instance of RawANT
        A Raw object containing ANT data.
        See :class:`mne.io.Raw` for documentation of attributes and methods.

    Notes
    -----
    .. versionadded:: 1.9
    """
    return RawANT(
        fname,
        eog=eog,
        misc=misc,
        bipolars=bipolars,
        impedance_annotation=impedance_annotation,
        encoding=encoding,
        preload=preload,
        verbose=verbose,
    )
