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

import datetime as dt
import glob as glob
import json
import os.path as op
import re as re
from configparser import ConfigParser, RawConfigParser

import numpy as np
from scipy.io import loadmat

from ..._fiff.constants import FIFF
from ..._fiff.meas_info import _format_dig_points, create_info
from ..._fiff.utils import _mult_cal_one
from ..._freesurfer import get_mni_fiducials
from ...annotations import Annotations
from ...fixes import _reshape_view
from ...transforms import _get_trans, apply_trans
from ...utils import (
    _check_fname,
    _check_option,
    _mask_to_onsets_offsets,
    _validate_type,
    fill_doc,
    logger,
    verbose,
    warn,
)
from ..base import BaseRaw
from ._localized_abbr import _localized_abbr


@fill_doc
def read_raw_nirx(
    fname, saturated="annotate", *, preload=False, encoding="latin-1", verbose=None
) -> "RawNIRX":
    """Reader for a NIRX fNIRS recording.

    Parameters
    ----------
    fname : path-like
        Path to the NIRX data folder (directory containing NIRX files) or
        the ``.hdr`` header file within that folder. The function will
        automatically find and read all required NIRX files from the
        directory.
    %(saturated)s
    %(preload)s
    %(encoding_nirx)s
    %(verbose)s

    Returns
    -------
    raw : instance of RawNIRX
        A Raw object containing NIRX data.
        See :class:`mne.io.Raw` for documentation of attributes and methods.

    See Also
    --------
    mne.io.Raw : Documentation of attributes and methods of RawNIRX.

    Notes
    -----
    %(nirx_notes)s
    """
    return RawNIRX(
        fname, saturated, preload=preload, encoding=encoding, verbose=verbose
    )


def _open(fname):
    return open(fname, encoding="latin-1")


@fill_doc
class RawNIRX(BaseRaw):
    """Raw object from a NIRX fNIRS file.

    Parameters
    ----------
    fname : path-like
        Path to the NIRX data folder or header file.
    %(saturated)s
    %(preload)s
    %(encoding_nirx)s
    %(verbose)s

    See Also
    --------
    mne.io.Raw : Documentation of attributes and methods.

    Notes
    -----
    %(nirx_notes)s
    """

    @verbose
    def __init__(self, fname, saturated, *, preload=False, encoding=None, verbose=None):
        logger.info(f"Loading {fname}")
        _validate_type(fname, "path-like", "fname")
        _validate_type(saturated, str, "saturated")
        _check_option("saturated", saturated, ("annotate", "nan", "ignore"))
        fname = str(fname)
        if fname.endswith(".hdr"):
            fname = op.dirname(op.abspath(fname))

        fname = str(_check_fname(fname, "read", True, "fname", need_dir=True))

        json_config = glob.glob(f"{fname}/*{'config.json'}")
        is_aurora = len(json_config)

        if is_aurora:
            # NIRSport2 devices using Aurora software
            keys = (
                "hdr",
                "config.json",
                "description.json",
                "wl1",
                "wl2",
                "probeInfo.mat",
                "tri",
            )
        else:
            # NIRScout devices and NIRSport1 devices
            keys = (
                "hdr",
                "inf",
                "set",
                "tpl",
                "wl1",
                "wl2",
                "config.txt",
                "probeInfo.mat",
            )
            n_dat = len(glob.glob(f"{fname}/*{'dat'}"))
            if n_dat != 1:
                warn(
                    "A single dat file was expected in the specified path, "
                    f"but got {n_dat}. This may indicate that the file "
                    "structure has been modified since the measurement "
                    "was saved."
                )

        # Check if required files exist and store names for later use
        files = dict()
        nan_mask = dict()
        for key in keys:
            files[key] = glob.glob(f"{fname}/*{key}")
            fidx = 0
            if len(files[key]) != 1:
                if key not in ("wl1", "wl2"):
                    raise RuntimeError(f"Need one {key} file, got {len(files[key])}")
                noidx = np.where(["nosatflags_" in op.basename(x) for x in files[key]])[
                    0
                ]
                if len(noidx) != 1 or len(files[key]) != 2:
                    raise RuntimeError(
                        f"Need one nosatflags and one standard {key} file, "
                        f"got {len(files[key])}"
                    )
                # Here two files have been found, one that is called
                # no sat flags. The nosatflag file has no NaNs in it.
                noidx = noidx[0]
                if saturated == "ignore":
                    # Ignore NaN and return values
                    fidx = noidx
                elif saturated == "nan":
                    # Return NaN
                    fidx = 0 if noidx == 1 else 1
                else:
                    assert saturated == "annotate"  # guaranteed above
                    fidx = noidx
                    nan_mask[key] = files[key][0 if noidx == 1 else 1]
            files[key] = files[key][fidx]

        # Read number of rows/samples of wavelength data
        with _open(files["wl1"]) as fid:
            last_sample = fid.read().count("\n") - 1

        # Read header file
        # The header file isn't compliant with the configparser. So all the
        # text between comments must be removed before passing to parser
        with open(files["hdr"], encoding=encoding) as f:
            hdr_str_all = f.read()
        hdr_str = re.sub("#.*?#", "", hdr_str_all, flags=re.DOTALL)
        if is_aurora:
            hdr_str = re.sub("(\\[DataStructure].*)", "", hdr_str, flags=re.DOTALL)
        hdr = RawConfigParser()
        hdr.read_string(hdr_str)

        # Check that the file format version is supported
        if is_aurora:
            # We may need to ease this requirement back
            if hdr["GeneralInfo"]["Version"] not in [
                "2021.4.0-34-ge9fdbbc8",
                "2021.9.0-5-g3eb32851",
                "2021.9.0-6-g14ef4a71",
            ]:
                warn(
                    "MNE has not been tested with Aurora version "
                    f"{hdr['GeneralInfo']['Version']}"
                )
        else:
            if hdr["GeneralInfo"]["NIRStar"] not in ['"15.0"', '"15.2"', '"15.3"']:
                raise RuntimeError(
                    "MNE does not support this NIRStar version"
                    f" ({hdr['GeneralInfo']['NIRStar']})"
                )
            if (
                "NIRScout" not in hdr["GeneralInfo"]["Device"]
                and "NIRSport" not in hdr["GeneralInfo"]["Device"]
            ):
                warn(
                    "Only import of data from NIRScout devices have been "
                    f"thoroughly tested. You are using a {hdr['GeneralInfo']['Device']}"
                    " device."
                )

        # Parse required header fields

        # Extract measurement date and time
        if is_aurora:
            datetime_str = hdr["GeneralInfo"]["Date"]
        else:
            datetime_str = hdr["GeneralInfo"]["Date"] + hdr["GeneralInfo"]["Time"]

        meas_date = None
        # Several formats have been observed so we try each in turn
        for loc, translations in _localized_abbr.items():
            do_break = False
            # So far we are lucky in that all the formats below, if they
            # include %a (weekday abbr), always come first. Thus we can use
            # a .split(), replace, and rejoin.
            loc_datetime_str = datetime_str.split(" ")
            for key, val in translations["weekday"].items():
                loc_datetime_str[0] = loc_datetime_str[0].replace(key, val)
            for ii in range(1, len(loc_datetime_str)):
                for key, val in translations["month"].items():
                    loc_datetime_str[ii] = loc_datetime_str[ii].replace(key, val)
            loc_datetime_str = " ".join(loc_datetime_str)
            logger.debug(f"Trying {loc} datetime: {loc_datetime_str}")
            for dt_code in [
                '"%a, %b %d, %Y""%H:%M:%S.%f"',
                '"%a %d %b %Y""%H:%M:%S.%f"',
                '"%a, %d %b %Y""%H:%M:%S.%f"',
                "%Y-%m-%d %H:%M:%S.%f",
                '"%Y年%m月%d日""%H:%M:%S.%f"',
            ]:
                try:
                    meas_date = dt.datetime.strptime(loc_datetime_str, dt_code)
                except ValueError:
                    pass
                else:
                    meas_date = meas_date.replace(tzinfo=dt.timezone.utc)
                    do_break = True
                    logger.debug(f"Measurement date language {loc} detected: {dt_code}")
                    break
            if do_break:
                break
        if meas_date is None:
            warn(
                "Extraction of measurement date from NIRX file failed. "
                "This can be caused by files saved in certain locales "
                f"(currently only {list(_localized_abbr)} supported). "
                "Please report this as a github issue. "
                "The date is being set to January 1st, 2000, "
                f"instead of {repr(datetime_str)}."
            )
            meas_date = dt.datetime(2000, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc)

        # Extract frequencies of light used by machine
        if is_aurora:
            fnirs_wavelengths = [760, 850]
        else:
            fnirs_wavelengths = [
                int(s)
                for s in re.findall(r"(\d+)", hdr["ImagingParameters"]["Wavelengths"])
            ]

        # Extract source-detectors
        if is_aurora:
            sources = re.findall(r"(\d+)-\d+", hdr_str_all.split("\n")[-2])
            detectors = re.findall(r"\d+-(\d+)", hdr_str_all.split("\n")[-2])
            sources = [int(s) + 1 for s in sources]
            detectors = [int(d) + 1 for d in detectors]

        else:
            sources = np.asarray(
                [
                    int(s)
                    for s in re.findall(
                        r"(\d+)-\d+:\d+", hdr["DataStructure"]["S-D-Key"]
                    )
                ],
                int,
            )
            detectors = np.asarray(
                [
                    int(s)
                    for s in re.findall(
                        r"\d+-(\d+):\d+", hdr["DataStructure"]["S-D-Key"]
                    )
                ],
                int,
            )

        # Extract sampling rate
        if is_aurora:
            samplingrate = float(hdr["GeneralInfo"]["Sampling rate"])
        else:
            samplingrate = float(hdr["ImagingParameters"]["SamplingRate"])

        # Read participant information file
        if is_aurora:
            with open(files["description.json"]) as f:
                inf = json.load(f)
        else:
            inf = ConfigParser(allow_no_value=True)
            inf.read(files["inf"])
            inf = inf._sections["Subject Demographics"]

        # Store subject information from inf file in mne format
        # Note: NIRX also records "Study Type", "Experiment History",
        #       "Additional Notes", "Contact Information" and this information
        #       is currently discarded
        # NIRStar does not record an id, or handedness by default
        # The name field is used to populate the his_id variable.
        subject_info = {}
        if is_aurora:
            names = inf["subject"].split()
        else:
            names = inf["name"].replace('"', "").split()
        subject_info["his_id"] = "_".join(names)
        if len(names) > 0:
            subject_info["first_name"] = names[0].replace('"', "")
        if len(names) > 1:
            subject_info["last_name"] = names[-1].replace('"', "")
        if len(names) > 2:
            subject_info["middle_name"] = names[-2].replace('"', "")
        subject_info["sex"] = inf["gender"].replace('"', "")
        # Recode values
        if subject_info["sex"] in {"M", "Male", "1"}:
            subject_info["sex"] = FIFF.FIFFV_SUBJ_SEX_MALE
        elif subject_info["sex"] in {"F", "Female", "2"}:
            subject_info["sex"] = FIFF.FIFFV_SUBJ_SEX_FEMALE
        else:
            subject_info["sex"] = FIFF.FIFFV_SUBJ_SEX_UNKNOWN
        if inf["age"] != "":
            subject_info["birthday"] = dt.date(
                meas_date.year - int(inf["age"]),
                meas_date.month,
                meas_date.day,
            )

        # Read information about probe/montage/optodes
        # A word on terminology used here:
        #   Sources produce light
        #   Detectors measure light
        #   Sources and detectors are both called optodes
        #   Each source - detector pair produces a channel
        #   Channels are defined as the midpoint between source and detector
        mat_data = loadmat(files["probeInfo.mat"])
        probes = mat_data["probeInfo"]["probes"][0, 0]
        requested_channels = probes["index_c"][0, 0]
        src_locs = probes["coords_s3"][0, 0] / 100.0
        det_locs = probes["coords_d3"][0, 0] / 100.0
        ch_locs = probes["coords_c3"][0, 0] / 100.0

        # These are all in MNI coordinates, so let's transform them to
        # the Neuromag head coordinate frame
        src_locs, det_locs, ch_locs, mri_head_t = _convert_fnirs_to_head(
            "fsaverage", "mri", "head", src_locs, det_locs, ch_locs
        )

        # Set up digitization
        dig = get_mni_fiducials("fsaverage", verbose=False)
        for fid in dig:
            fid["r"] = apply_trans(mri_head_t, fid["r"])
            fid["coord_frame"] = FIFF.FIFFV_COORD_HEAD
        for ii, ch_loc in enumerate(ch_locs, 1):
            dig.append(
                dict(
                    kind=FIFF.FIFFV_POINT_EEG,  # misnomer but probably okay
                    r=ch_loc,
                    ident=ii,
                    coord_frame=FIFF.FIFFV_COORD_HEAD,
                )
            )
        dig = _format_dig_points(dig)
        del mri_head_t

        # Determine requested channel indices
        # The wl1 and wl2 files include all possible source - detector pairs.
        # But most of these are not relevant. We want to extract only the
        # subset requested in the probe file
        req_ind = np.array([], int)
        for req_idx in range(requested_channels.shape[0]):
            sd_idx = np.where(
                (sources == requested_channels[req_idx][0])
                & (detectors == requested_channels[req_idx][1])
            )
            req_ind = np.concatenate((req_ind, sd_idx[0]))
        req_ind = req_ind.astype(int)

        snames = [f"S{sources[idx]}" for idx in req_ind]
        dnames = [f"_D{detectors[idx]}" for idx in req_ind]
        sdnames = [m + str(n) for m, n in zip(snames, dnames)]
        sd1 = [s + " " + str(fnirs_wavelengths[0]) for s in sdnames]
        sd2 = [s + " " + str(fnirs_wavelengths[1]) for s in sdnames]
        chnames = [val for pair in zip(sd1, sd2) for val in pair]

        # Create mne structure
        info = create_info(chnames, samplingrate, ch_types="fnirs_cw_amplitude")
        with info._unlock():
            info.update(subject_info=subject_info, dig=dig)
            info["meas_date"] = meas_date

        # Store channel, source, and detector locations
        # The channel location is stored in the first 3 entries of loc.
        # The source location is stored in the second 3 entries of loc.
        # The detector location is stored in the third 3 entries of loc.
        # NIRx NIRSite uses MNI coordinates.
        # Also encode the light frequency in the structure.
        for ch_idx2 in range(requested_channels.shape[0]):
            # Find source and store location
            src = int(requested_channels[ch_idx2, 0]) - 1
            # Find detector and store location
            det = int(requested_channels[ch_idx2, 1]) - 1
            # Store channel location as midpoint between source and detector.
            midpoint = (src_locs[src, :] + det_locs[det, :]) / 2
            for ii in range(2):
                ch_idx3 = ch_idx2 * 2 + ii
                info["chs"][ch_idx3]["loc"][3:6] = src_locs[src, :]
                info["chs"][ch_idx3]["loc"][6:9] = det_locs[det, :]
                info["chs"][ch_idx3]["loc"][:3] = midpoint
                info["chs"][ch_idx3]["loc"][9] = fnirs_wavelengths[ii]
                info["chs"][ch_idx3]["coord_frame"] = FIFF.FIFFV_COORD_HEAD

        # Extract the start/stop numbers for samples in the CSV. In theory the
        # sample bounds should just be 10 * the number of channels, but some
        # files have mixed \n and \n\r endings (!) so we can't rely on it, and
        # instead make a single pass over the entire file at the beginning so
        # that we know how to seek and read later.
        bounds = dict()
        for key in ("wl1", "wl2"):
            offset = 0
            bounds[key] = [offset]
            with open(files[key], "rb") as fid:
                for line in fid:
                    offset += len(line)
                    bounds[key].append(offset)
                assert offset == fid.tell()

        # Extras required for reading data
        raw_extras = {
            "sd_index": req_ind,
            "files": files,
            "bounds": bounds,
            "nan_mask": nan_mask,
        }
        # Get our saturated mask
        annot_mask = None
        for ki, key in enumerate(("wl1", "wl2")):
            if nan_mask.get(key, None) is None:
                continue
            mask = np.isnan(
                _read_csv_rows_cols(
                    nan_mask[key], 0, last_sample + 1, req_ind, {0: 0, 1: None}
                ).T
            )
            if saturated == "nan":
                nan_mask[key] = mask
            else:
                assert saturated == "annotate"
                if annot_mask is None:
                    annot_mask = np.zeros(
                        (len(info["ch_names"]) // 2, last_sample + 1), bool
                    )
                annot_mask |= mask
                nan_mask[key] = None  # shouldn't need again

        super().__init__(
            info,
            preload,
            filenames=[fname],
            last_samps=[last_sample],
            raw_extras=[raw_extras],
            verbose=verbose,
        )

        # make onset/duration/description
        onset, duration, description, ch_names = list(), list(), list(), list()
        if annot_mask is not None:
            for ci, mask in enumerate(annot_mask):
                on, dur = _mask_to_onsets_offsets(mask)
                on = on / info["sfreq"]
                dur = dur / info["sfreq"]
                dur -= on
                onset.extend(on)
                duration.extend(dur)
                description.extend(["BAD_SATURATED"] * len(on))
                ch_names.extend([self.ch_names[2 * ci : 2 * ci + 2]] * len(on))

        # Read triggers from event file
        if not is_aurora:
            files["tri"] = files["hdr"][:-3] + "evt"
        if op.isfile(files["tri"]):
            with _open(files["tri"]) as fid:
                t = [re.findall(r"(\d+)", line) for line in fid]
            if is_aurora:
                tf_idx, desc_idx = _determine_tri_idxs(t[0])
            for t_ in t:
                if is_aurora:
                    trigger_frame = float(t_[tf_idx])
                    desc = float(t_[desc_idx])
                else:
                    binary_value = "".join(t_[1:])[::-1]
                    desc = float(int(binary_value, 2))
                    trigger_frame = float(t_[0])
                onset.append(trigger_frame / samplingrate)
                duration.append(1.0)  # No duration info stored in files
                description.append(desc)
                ch_names.append(list())
        annot = Annotations(onset, duration, description, ch_names=ch_names)
        self.set_annotations(annot)

    def _read_segment_file(self, data, idx, fi, start, stop, cals, mult):
        """Read a segment of data from a file.

        The NIRX machine records raw data as two different wavelengths.
        The returned data interleaves the wavelengths.
        """
        sd_index = self._raw_extras[fi]["sd_index"]

        wls = list()
        for key in ("wl1", "wl2"):
            d = _read_csv_rows_cols(
                self._raw_extras[fi]["files"][key],
                start,
                stop,
                sd_index,
                self._raw_extras[fi]["bounds"][key],
            ).T
            nan_mask = self._raw_extras[fi]["nan_mask"].get(key, None)
            if nan_mask is not None:
                d[nan_mask[:, start:stop]] = np.nan
            wls.append(d)

        # TODO: Make this more efficient by only indexing above what we need.
        # For now let's just construct the full data matrix and index.
        # Interleave wavelength 1 and 2 to match channel names:
        this_data = np.zeros((len(wls[0]) * 2, stop - start))
        this_data[0::2, :] = wls[0]
        this_data[1::2, :] = wls[1]
        _mult_cal_one(data, this_data, idx, cals, mult)
        return data


def _read_csv_rows_cols(fname, start, stop, cols, bounds, sep=" ", replace=None):
    with open(fname, "rb") as fid:
        fid.seek(bounds[start])
        args = list()
        if bounds[1] is not None:
            args.append(bounds[stop] - bounds[start])
        data = fid.read(*args).decode("latin-1")
        if replace is not None:
            data = replace(data)
        x = np.fromstring(data, float, sep=sep)
    x = _reshape_view(x, (stop - start, -1))
    x = x[:, cols]
    return x


def _convert_fnirs_to_head(trans, fro, to, src_locs, det_locs, ch_locs):
    mri_head_t, _ = _get_trans(trans, fro, to)
    src_locs = apply_trans(mri_head_t, src_locs)
    det_locs = apply_trans(mri_head_t, det_locs)
    ch_locs = apply_trans(mri_head_t, ch_locs)
    return src_locs, det_locs, ch_locs, mri_head_t


def _determine_tri_idxs(trigger):
    """Determine tri file indexes for frame and description."""
    if len(trigger) == 12:
        # Aurora version 2021.9.6 or greater
        trigger_frame_idx = 7
        desc_idx = 10
    elif len(trigger) == 9:
        # Aurora version 2021.9.5 or earlier
        trigger_frame_idx = 7
        desc_idx = 8
    else:
        raise RuntimeError("Unable to read trigger file.")

    return trigger_frame_idx, desc_idx
