"""Populate measurement info."""

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

import os.path as op
from calendar import timegm
from time import strptime

import numpy as np

from ..._fiff.constants import FIFF
from ..._fiff.ctf_comp import _add_kind, _calibrate_comp
from ..._fiff.meas_info import _empty_info
from ..._fiff.write import get_new_file_id
from ...annotations import Annotations
from ...transforms import (
    _coord_frame_name,
    apply_trans,
    combine_transforms,
    invert_transform,
)
from ...utils import _clean_names, logger, warn
from .constants import CTF

_ctf_to_fiff = {
    CTF.CTFV_COIL_LPA: FIFF.FIFFV_POINT_LPA,
    CTF.CTFV_COIL_RPA: FIFF.FIFFV_POINT_RPA,
    CTF.CTFV_COIL_NAS: FIFF.FIFFV_POINT_NASION,
}


def _pick_isotrak_and_hpi_coils(res4, coils, t):
    """Pick the HPI coil locations given in device coordinates."""
    if coils is None:
        return list(), list()
    dig = list()
    hpi_result = dict(dig_points=list())
    n_coil_dev = 0
    n_coil_head = 0
    for p in coils:
        if p["valid"]:
            if p["kind"] in [CTF.CTFV_COIL_LPA, CTF.CTFV_COIL_RPA, CTF.CTFV_COIL_NAS]:
                kind = FIFF.FIFFV_POINT_CARDINAL
                ident = _ctf_to_fiff[p["kind"]]
            else:  # CTF.CTFV_COIL_SPARE
                kind = FIFF.FIFFV_POINT_HPI
                ident = p["kind"]
            if p["coord_frame"] == FIFF.FIFFV_MNE_COORD_CTF_DEVICE:
                if t is None or t["t_ctf_dev_dev"] is None:
                    raise RuntimeError(
                        "No coordinate transformation "
                        "available for HPI coil locations"
                    )
                d = dict(
                    kind=kind,
                    ident=ident,
                    r=apply_trans(t["t_ctf_dev_dev"], p["r"]),
                    coord_frame=FIFF.FIFFV_COORD_UNKNOWN,
                )
                hpi_result["dig_points"].append(d)
                n_coil_dev += 1
            elif p["coord_frame"] == FIFF.FIFFV_MNE_COORD_CTF_HEAD:
                if t is None or t["t_ctf_head_head"] is None:
                    raise RuntimeError(
                        "No coordinate transformation "
                        "available for (virtual) Polhemus data"
                    )
                d = dict(
                    kind=kind,
                    ident=ident,
                    r=apply_trans(t["t_ctf_head_head"], p["r"]),
                    coord_frame=FIFF.FIFFV_COORD_HEAD,
                )
                dig.append(d)
                n_coil_head += 1
    if n_coil_head > 0:
        logger.info("    Polhemus data for %d HPI coils added" % n_coil_head)
    if n_coil_dev > 0:
        logger.info(
            "    Device coordinate locations for %d HPI coils added" % n_coil_dev
        )
    return dig, [hpi_result]


def _convert_time(date_str, time_str):
    """Convert date and time strings to float time."""
    if date_str == time_str == "":
        date_str = "01/01/1970"
        time_str = "00:00:00"
        logger.info(
            "No date or time found, setting to the start of the "
            "POSIX epoch (1970/01/01 midnight)"
        )

    for fmt in ("%d/%m/%Y", "%d-%b-%Y", "%a, %b %d, %Y", "%Y/%m/%d"):
        try:
            date = strptime(date_str.strip(), fmt)
        except ValueError:
            pass
        else:
            break
    else:
        raise RuntimeError(
            f"Illegal date: {date_str}.\nIf the language of the date does not "
            "correspond to your local machine's language try to set the "
            "locale to the language of the date string:\n"
            'locale.setlocale(locale.LC_ALL, "en_US")'
        )

    for fmt in ("%H:%M:%S", "%H:%M"):
        try:
            time = strptime(time_str, fmt)
        except ValueError:
            pass
        else:
            break
    else:
        raise RuntimeError(f"Illegal time: {time_str}")
    # MNE-C uses mktime which uses local time, but here we instead decouple
    # conversion location from the process, and instead assume that the
    # acquisition was in GMT. This will be wrong for most sites, but at least
    # the value we obtain here won't depend on the geographical location
    # that the file was converted.
    res = timegm(
        (
            date.tm_year,
            date.tm_mon,
            date.tm_mday,
            time.tm_hour,
            time.tm_min,
            time.tm_sec,
            date.tm_wday,
            date.tm_yday,
            date.tm_isdst,
        )
    )
    return res


def _get_plane_vectors(ez):
    """Get two orthogonal vectors orthogonal to ez (ez will be modified)."""
    assert ez.shape == (3,)
    ez_len = np.sqrt(np.sum(ez * ez))
    if ez_len == 0:
        raise RuntimeError("Zero length normal. Cannot proceed.")
    if np.abs(ez_len - np.abs(ez[2])) < 1e-5:  # ez already in z-direction
        ex = np.array([1.0, 0.0, 0.0])
    else:
        ex = np.zeros(3)
        if ez[1] < ez[2]:
            ex[0 if ez[0] < ez[1] else 1] = 1.0
        else:
            ex[0 if ez[0] < ez[2] else 2] = 1.0
    ez /= ez_len
    ex -= np.dot(ez, ex) * ez
    ex /= np.sqrt(np.sum(ex * ex))
    ey = np.cross(ez, ex)
    return ex, ey


def _at_origin(x):
    """Determine if a vector is at the origin."""
    return np.sum(x * x) < 1e-8


def _check_comp_ch(cch, kind, desired=None):
    if desired is None:
        desired = cch["grad_order_no"]
    if cch["grad_order_no"] != desired:
        raise RuntimeError(
            f"{kind} channel with inconsistent compensation "
            f"grade {cch['grad_order_no']}, should be {desired}"
        )
    return desired


def _convert_channel_info(res4, t, use_eeg_pos):
    """Convert CTF channel information to fif format."""
    nmeg = neeg = nstim = nmisc = nref = 0
    chs = list()
    this_comp = None
    for k, cch in enumerate(res4["chs"]):
        cal = float(1.0 / (cch["proper_gain"] * cch["qgain"]))
        ch = dict(
            scanno=k + 1,
            range=1.0,
            cal=cal,
            loc=np.full(12, np.nan),
            unit_mul=FIFF.FIFF_UNITM_NONE,
            ch_name=cch["ch_name"][:15],
            coil_type=FIFF.FIFFV_COIL_NONE,
        )
        del k
        chs.append(ch)
        # Create the channel position information
        if cch["sensor_type_index"] in (
            CTF.CTFV_REF_MAG_CH,
            CTF.CTFV_REF_GRAD_CH,
            CTF.CTFV_MEG_CH,
        ):
            # Extra check for a valid MEG channel
            if (
                np.sum(cch["coil"]["pos"][0] ** 2) < 1e-6
                or np.sum(cch["coil"]["norm"][0] ** 2) < 1e-6
            ):
                nmisc += 1
                ch.update(
                    logno=nmisc,
                    coord_frame=FIFF.FIFFV_COORD_UNKNOWN,
                    kind=FIFF.FIFFV_MISC_CH,
                    unit=FIFF.FIFF_UNIT_V,
                )
                text = "MEG"
                if cch["sensor_type_index"] != CTF.CTFV_MEG_CH:
                    text += " ref"
                warn(
                    f"{text} channel {ch['ch_name']} did not have position "
                    "assigned, so it was changed to a MISC channel"
                )
                continue
            ch["unit"] = FIFF.FIFF_UNIT_T
            # Set up the local coordinate frame
            r0 = cch["coil"]["pos"][0].copy()
            ez = cch["coil"]["norm"][0].copy()
            # It turns out that positive proper_gain requires swapping
            # of the normal direction
            if cch["proper_gain"] > 0.0:
                ez *= -1
            # Check how the other vectors should be defined
            off_diag = False
            # Default: ex and ey are arbitrary in the plane normal to ez
            if cch["sensor_type_index"] == CTF.CTFV_REF_GRAD_CH:
                # The off-diagonal gradiometers are an exception:
                #
                # We use the same convention for ex as for Neuromag planar
                # gradiometers: ex pointing in the positive gradient direction
                diff = cch["coil"]["pos"][0] - cch["coil"]["pos"][1]
                size = np.sqrt(np.sum(diff * diff))
                if size > 0.0:
                    diff /= size
                # Is ez normal to the line joining the coils?
                if np.abs(np.dot(diff, ez)) < 1e-3:
                    off_diag = True
                    # Handle the off-diagonal gradiometer coordinate system
                    r0 -= size * diff / 2.0
                    ex = diff
                    ey = np.cross(ez, ex)
                else:
                    ex, ey = _get_plane_vectors(ez)
            else:
                ex, ey = _get_plane_vectors(ez)
            # Transform into a Neuromag-like device coordinate system
            ch["loc"] = np.concatenate(
                [
                    apply_trans(t["t_ctf_dev_dev"], r0),
                    apply_trans(t["t_ctf_dev_dev"], ex, move=False),
                    apply_trans(t["t_ctf_dev_dev"], ey, move=False),
                    apply_trans(t["t_ctf_dev_dev"], ez, move=False),
                ]
            )
            del r0, ex, ey, ez
            # Set the coil type
            if cch["sensor_type_index"] == CTF.CTFV_REF_MAG_CH:
                ch["kind"] = FIFF.FIFFV_REF_MEG_CH
                ch["coil_type"] = FIFF.FIFFV_COIL_CTF_REF_MAG
                nref += 1
                ch["logno"] = nref
            elif cch["sensor_type_index"] == CTF.CTFV_REF_GRAD_CH:
                ch["kind"] = FIFF.FIFFV_REF_MEG_CH
                if off_diag:
                    ch["coil_type"] = FIFF.FIFFV_COIL_CTF_OFFDIAG_REF_GRAD
                else:
                    ch["coil_type"] = FIFF.FIFFV_COIL_CTF_REF_GRAD
                nref += 1
                ch["logno"] = nref
            else:
                this_comp = _check_comp_ch(cch, "Gradiometer", this_comp)
                ch["kind"] = FIFF.FIFFV_MEG_CH
                ch["coil_type"] = FIFF.FIFFV_COIL_CTF_GRAD
                nmeg += 1
                ch["logno"] = nmeg
            # Encode the software gradiometer order
            ch["coil_type"] = int(ch["coil_type"] | (cch["grad_order_no"] << 16))
            ch["coord_frame"] = FIFF.FIFFV_COORD_DEVICE
        elif cch["sensor_type_index"] == CTF.CTFV_EEG_CH:
            coord_frame = FIFF.FIFFV_COORD_HEAD
            if use_eeg_pos:
                # EEG electrode coordinates may be present but in the
                # CTF head frame
                ch["loc"][:3] = cch["coil"]["pos"][0]
                if not _at_origin(ch["loc"][:3]):
                    if t["t_ctf_head_head"] is None:
                        warn(
                            f"EEG electrode ({ch['ch_name']}) location omitted because "
                            "of missing HPI information"
                        )
                        ch["loc"].fill(np.nan)
                        coord_frame = FIFF.FIFFV_MNE_COORD_CTF_HEAD
                    else:
                        ch["loc"][:3] = apply_trans(t["t_ctf_head_head"], ch["loc"][:3])
            neeg += 1
            ch.update(
                logno=neeg,
                kind=FIFF.FIFFV_EEG_CH,
                unit=FIFF.FIFF_UNIT_V,
                coord_frame=coord_frame,
                coil_type=FIFF.FIFFV_COIL_EEG,
            )
        elif cch["sensor_type_index"] == CTF.CTFV_STIM_CH:
            nstim += 1
            ch.update(
                logno=nstim,
                coord_frame=FIFF.FIFFV_COORD_UNKNOWN,
                kind=FIFF.FIFFV_STIM_CH,
                unit=FIFF.FIFF_UNIT_V,
            )
        else:
            nmisc += 1
            ch.update(
                logno=nmisc,
                coord_frame=FIFF.FIFFV_COORD_UNKNOWN,
                kind=FIFF.FIFFV_MISC_CH,
                unit=FIFF.FIFF_UNIT_V,
            )
    return chs


def _comp_sort_keys(c):
    """Sort the compensation data."""
    return (int(c["coeff_type"]), int(c["scanno"]))


def _check_comp(comp):
    """Check that conversion to named matrices is possible."""
    ref_sens = None
    kind = -1
    for k, c_k in enumerate(comp):
        if c_k["coeff_type"] != kind:
            c_ref = c_k
            ref_sens = c_ref["sensors"]
            kind = c_k["coeff_type"]
        elif not c_k["sensors"] == ref_sens:
            raise RuntimeError("Cannot use an uneven compensation matrix")


def _conv_comp(comp, first, last, chs):
    """Add a new converted compensation data item."""
    ch_names = [c["ch_name"] for c in chs]
    n_col = comp[first]["ncoeff"]
    col_names = comp[first]["sensors"][:n_col]
    row_names = [comp[p]["sensor_name"] for p in range(first, last + 1)]
    mask = np.isin(col_names, ch_names)  # missing channels excluded
    col_names = np.array(col_names)[mask].tolist()
    n_col = len(col_names)
    n_row = len(row_names)
    ccomp = dict(ctfkind=comp[first]["coeff_type"], save_calibrated=False)
    _add_kind(ccomp)

    data = np.empty((n_row, n_col))
    for ii, coeffs in enumerate(comp[first : last + 1]):
        # Pick the elements to the matrix
        data[ii, :] = coeffs["coeffs"][mask]
    ccomp["data"] = dict(
        row_names=row_names,
        col_names=col_names,
        data=data,
        nrow=len(row_names),
        ncol=len(col_names),
    )
    mk = ("proper_gain", "qgain")
    _calibrate_comp(ccomp, chs, row_names, col_names, mult_keys=mk, flip=True)
    return ccomp


def _convert_comp_data(res4):
    """Convert the compensation data into named matrices."""
    if res4["ncomp"] == 0:
        return
    # Sort the coefficients in our favorite order
    res4["comp"] = sorted(res4["comp"], key=_comp_sort_keys)
    # Check that all items for a given compensation type have the correct
    # number of channels
    _check_comp(res4["comp"])
    # Create named matrices
    first = 0
    kind = -1
    comps = list()
    for k in range(len(res4["comp"])):
        if res4["comp"][k]["coeff_type"] != kind:
            if k > 0:
                comps.append(_conv_comp(res4["comp"], first, k - 1, res4["chs"]))
            kind = res4["comp"][k]["coeff_type"]
            first = k
    comps.append(_conv_comp(res4["comp"], first, k, res4["chs"]))
    return comps


def _pick_eeg_pos(c):
    """Pick EEG positions."""
    eeg = dict(
        coord_frame=FIFF.FIFFV_COORD_HEAD,
        assign_to_chs=False,
        labels=list(),
        ids=list(),
        rr=list(),
        kinds=list(),
        np=0,
    )
    for ch in c["chs"]:
        if ch["kind"] == FIFF.FIFFV_EEG_CH and not _at_origin(ch["loc"][:3]):
            eeg["labels"].append(ch["ch_name"])
            eeg["ids"].append(ch["logno"])
            eeg["rr"].append(ch["loc"][:3])
            eeg["kinds"].append(FIFF.FIFFV_POINT_EEG)
            eeg["np"] += 1
    if eeg["np"] == 0:
        return None
    logger.info("Picked positions of %d EEG channels from channel info" % eeg["np"])
    return eeg


def _add_eeg_pos(eeg, t, c):
    """Pick the (virtual) EEG position data."""
    if eeg is None:
        return
    if t is None or t["t_ctf_head_head"] is None:
        raise RuntimeError(
            "No coordinate transformation available for EEG position data"
        )
    eeg_assigned = 0
    if eeg["assign_to_chs"]:
        for k in range(eeg["np"]):
            # Look for a channel name match
            for ch in c["chs"]:
                if ch["ch_name"].lower() == eeg["labels"][k].lower():
                    r0 = ch["loc"][:3]
                    r0[:] = eeg["rr"][k]
                    if eeg["coord_frame"] == FIFF.FIFFV_MNE_COORD_CTF_HEAD:
                        r0[:] = apply_trans(t["t_ctf_head_head"], r0)
                    elif eeg["coord_frame"] != FIFF.FIFFV_COORD_HEAD:
                        raise RuntimeError(
                            "Illegal coordinate frame for EEG electrode "
                            f"positions : {_coord_frame_name(eeg['coord_frame'])}"
                        )
                    # Use the logical channel number as an identifier
                    eeg["ids"][k] = ch["logno"]
                    eeg["kinds"][k] = FIFF.FIFFV_POINT_EEG
                    eeg_assigned += 1
                    break

    # Add these to the Polhemus data
    fid_count = eeg_count = extra_count = 0
    for k in range(eeg["np"]):
        d = dict(
            r=eeg["rr"][k].copy(),
            kind=eeg["kinds"][k],
            ident=eeg["ids"][k],
            coord_frame=FIFF.FIFFV_COORD_HEAD,
        )
        c["dig"].append(d)
        if eeg["coord_frame"] == FIFF.FIFFV_MNE_COORD_CTF_HEAD:
            d["r"] = apply_trans(t["t_ctf_head_head"], d["r"])
        elif eeg["coord_frame"] != FIFF.FIFFV_COORD_HEAD:
            raise RuntimeError(
                "Illegal coordinate frame for EEG electrode positions: "
                + _coord_frame_name(eeg["coord_frame"])
            )
        if eeg["kinds"][k] == FIFF.FIFFV_POINT_CARDINAL:
            fid_count += 1
        elif eeg["kinds"][k] == FIFF.FIFFV_POINT_EEG:
            eeg_count += 1
        else:
            extra_count += 1
    if eeg_assigned > 0:
        logger.info(
            "    %d EEG electrode locations assigned to channel info." % eeg_assigned
        )
    for count, kind in zip(
        (fid_count, eeg_count, extra_count),
        ("fiducials", "EEG locations", "extra points"),
    ):
        if count > 0:
            logger.info("    %d %s added to Polhemus data." % (count, kind))


_filt_map = {CTF.CTFV_FILTER_LOWPASS: "lowpass", CTF.CTFV_FILTER_HIGHPASS: "highpass"}


def _compose_meas_info(res4, coils, trans, eeg):
    """Create meas info from CTF data."""
    info = _empty_info(res4["sfreq"])

    # Collect all the necessary data from the structures read
    info["meas_id"] = get_new_file_id()
    info["meas_id"]["usecs"] = 0
    info["meas_id"]["secs"] = _convert_time(res4["data_date"], res4["data_time"])
    info["meas_date"] = (info["meas_id"]["secs"], info["meas_id"]["usecs"])
    info["experimenter"] = res4["nf_operator"]
    info["subject_info"] = dict(his_id=res4["nf_subject_id"])
    for filt in res4["filters"]:
        if filt["type"] in _filt_map:
            info[_filt_map[filt["type"]]] = filt["freq"]
    info["dig"], info["hpi_results"] = _pick_isotrak_and_hpi_coils(res4, coils, trans)
    if trans is not None:
        if len(info["hpi_results"]) > 0:
            info["hpi_results"][0]["coord_trans"] = trans["t_ctf_head_head"]
        if trans["t_dev_head"] is not None:
            info["dev_head_t"] = trans["t_dev_head"]
            info["dev_ctf_t"] = combine_transforms(
                trans["t_dev_head"],
                invert_transform(trans["t_ctf_head_head"]),
                FIFF.FIFFV_COORD_DEVICE,
                FIFF.FIFFV_MNE_COORD_CTF_HEAD,
            )
        if trans["t_ctf_head_head"] is not None:
            info["ctf_head_t"] = trans["t_ctf_head_head"]
    info["chs"] = _convert_channel_info(res4, trans, eeg is None)
    info["comps"] = _convert_comp_data(res4)
    if eeg is None:
        # Pick EEG locations from chan info if not read from a separate file
        eeg = _pick_eeg_pos(info)
    _add_eeg_pos(eeg, trans, info)
    logger.info("    Measurement info composed.")
    info._unlocked = False
    info._update_redundant()
    return info


def _read_bad_chans(directory, info):
    """Read Bad channel list and match to internal names."""
    fname = op.join(directory, "BadChannels")
    if not op.exists(fname):
        return []
    mapping = dict(zip(_clean_names(info["ch_names"]), info["ch_names"]))
    with open(fname) as fid:
        bad_chans = [mapping[f.strip()] for f in fid.readlines()]
    return bad_chans


def _annotate_bad_segments(directory, start_time, meas_date):
    fname = op.join(directory, "bad.segments")
    if not op.exists(fname):
        return None

    # read in bad segment file
    onsets = []
    durations = []
    desc = []
    with open(fname) as fid:
        for f in fid.readlines():
            tmp = f.strip().split()
            desc.append(f"bad_{tmp[0]}")
            onsets.append(np.float64(tmp[1]) - start_time)
            durations.append(np.float64(tmp[2]) - np.float64(tmp[1]))
    # return None if there are no bad segments
    if len(onsets) == 0:
        return None

    return Annotations(onsets, durations, desc, meas_date)
