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

import os
import re
import shutil
import zipfile

import numpy as np
import pooch
import pytest

from mne._fiff.constants import (
    FIFF,
    FWD,
    _ch_coil_type_named,
    _ch_kind_named,
    _ch_unit_mul_named,
    _ch_unit_named,
    _coord_frame_named,
    _dig_cardinal_named,
    _dig_kind_named,
)
from mne.forward._make_forward import _read_coil_defs
from mne.transforms import _frame_to_str, _verbose_frames
from mne.utils import requires_good_network

# https://github.com/mne-tools/fiff-constants/commits/master
REPO = "mne-tools"
COMMIT = "e27f68cbf74dbfc5193ad429cc77900a59475181"

# These are oddities that we won't address:
iod_dups = (355, 359)  # these are in both MEGIN and MNE files
tag_dups = (3501,)  # in both MEGIN and MNE files

_dir_ignore_names = (
    "clear",
    "copy",
    "fromkeys",
    "get",
    "items",
    "keys",
    "pop",
    "popitem",
    "setdefault",
    "update",
    "values",
    "has_key",
    "iteritems",
    "iterkeys",
    "itervalues",  # Py2
    "viewitems",
    "viewkeys",
    "viewvalues",  # Py2
)
_tag_ignore_names = ()  # for fiff-constants pending updates
_ignore_incomplete_enums = (  # XXX eventually we could complete these
    "bem_surf_id",
    "cardinal_point_cardiac",
    "cond_model",
    "coord",
    "dacq_system",
    "diffusion_param",
    "gantry_type",
    "map_surf",
    "mne_lin_proj",
    "mne_ori",
    "mri_format",
    "mri_pixel",
    "proj_by",
    "tags",
    "type",
    "iod",
    "volume_type",
    "vol_type",
)
# not in coil_def.dat but in DictionaryTypes:enum(coil)
_missing_coil_def = (
    0,  # The location info contains no data
    1,  # EEG electrode position in r0
    3,  # Old 24 channel system in HUT
    4,  # The axial devices in the HUCS MCG system
    5,  # Bipolar EEG electrode position
    6,  # CSD-transformed EEG electrodes
    200,  # Time-varying dipole definition
    300,  # fNIRS oxyhemoglobin
    301,  # fNIRS deoxyhemoglobin
    302,  # fNIRS continuous wave
    303,  # fNIRS optical density
    304,  # fNIRS frequency domain AC amplitude
    305,  # fNIRS frequency domain phase
    306,  # fNIRS time domain gated amplitude
    307,  # fNIRS time domain moments amplitude
    400,  # Eye-tracking gaze position
    401,  # Eye-tracking pupil size
    1000,  # For testing the MCG software
    2001,  # Generic axial gradiometer
    3011,  # VV prototype wirewound planar sensor
    3014,  # Vectorview SQ20950N planar gradiometer
    3021,  # VV prototype wirewound magnetometer
)
# explicit aliases in constants.py
_aliases = dict(
    FIFFV_COIL_MAGNES_R_MAG="FIFFV_COIL_MAGNES_REF_MAG",
    FIFFV_COIL_MAGNES_R_GRAD="FIFFV_COIL_MAGNES_REF_GRAD",
    FIFFV_COIL_MAGNES_R_GRAD_OFF="FIFFV_COIL_MAGNES_OFFDIAG_REF_GRAD",
    FIFFV_COIL_FNIRS_RAW="FIFFV_COIL_FNIRS_CW_AMPLITUDE",
    FIFFV_MNE_COORD_CTF_HEAD="FIFFV_MNE_COORD_4D_HEAD",
    FIFFV_MNE_COORD_KIT_HEAD="FIFFV_MNE_COORD_4D_HEAD",
    FIFFV_MNE_COORD_DIGITIZER="FIFFV_COORD_ISOTRAK",
    FIFFV_MNE_COORD_SURFACE_RAS="FIFFV_COORD_MRI",
    FIFFV_MNE_SENSOR_COV="FIFFV_MNE_NOISE_COV",
    FIFFV_POINT_EEG="FIFFV_POINT_ECG",
    FIFF_DESCRIPTION="FIFF_COMMENT",
    FIFF_REF_PATH="FIFF_MRI_SOURCE_PATH",
)


@pytest.mark.flaky
@requires_good_network
@pytest.mark.ultraslowtest  # not that slow, just doesn't need to run very often
def test_constants(tmp_path):
    """Test compensation."""
    fname = "fiff.zip"
    dest = tmp_path / fname
    pooch.retrieve(
        url=f"https://codeload.github.com/{REPO}/fiff-constants/zip/{COMMIT}",
        path=tmp_path,
        fname=fname,
        known_hash=None,
    )
    names = list()
    with zipfile.ZipFile(dest, "r") as ff:
        for name in ff.namelist():
            if "Dictionary" in name:
                ff.extract(name, tmp_path)
                names.append(os.path.basename(name))
                shutil.move(tmp_path / name, tmp_path / names[-1])
    names = sorted(names)
    assert names == [
        "DictionaryIOD.txt",
        "DictionaryIOD_MNE.txt",
        "DictionaryStructures.txt",
        "DictionaryTags.txt",
        "DictionaryTags_MNE.txt",
        "DictionaryTypes.txt",
        "DictionaryTypes_MNE.txt",
    ]
    # IOD (MEGIN and MNE)
    fif = dict(iod=dict(), tags=dict(), types=dict(), defines=dict())
    con = dict(iod=dict(), tags=dict(), types=dict(), defines=dict())
    fiff_version = None
    for name in ["DictionaryIOD.txt", "DictionaryIOD_MNE.txt"]:
        with open(tmp_path / name, "rb") as fid:
            for line in fid:
                line = line.decode("latin1").strip()
                if line.startswith("# Packing revision"):
                    assert fiff_version is None
                    fiff_version = line.split()[-1]
                if line.startswith("#") or line.startswith("alias") or len(line) == 0:
                    continue
                line = line.split('"')
                assert len(line) in (1, 2, 3)
                desc = "" if len(line) == 1 else line[1]
                line = line[0].split()
                assert len(line) in (2, 3)
                if len(line) == 2:
                    kind, id_ = line
                else:
                    kind, id_, tagged = line
                    assert tagged in ("tagged",)
                id_ = int(id_)
                if id_ not in iod_dups:
                    assert id_ not in fif["iod"]
                fif["iod"][id_] = [kind, desc]
    # Tags (MEGIN)
    with open(tmp_path / "DictionaryTags.txt", "rb") as fid:
        for line in fid:
            line = line.decode("ISO-8859-1").strip()
            if (
                line.startswith("#")
                or line.startswith("alias")
                or line.startswith(":")
                or len(line) == 0
            ):
                continue
            line = line.split('"')
            assert len(line) in (1, 2, 3), line
            desc = "" if len(line) == 1 else line[1]
            line = line[0].split()
            assert len(line) == 4, line
            kind, id_, dtype, unit = line
            id_ = int(id_)
            val = [kind, dtype, unit]
            assert id_ not in fif["tags"], (fif["tags"].get(id_), val)
            fif["tags"][id_] = val
    # Tags (MNE)
    with open(tmp_path / "DictionaryTags_MNE.txt", "rb") as fid:
        for li, line in enumerate(fid):
            line = line.decode("ISO-8859-1").strip()
            # ignore continuation lines (*)
            if (
                line.startswith("#")
                or line.startswith("alias")
                or line.startswith(":")
                or line.startswith("*")
                or len(line) == 0
            ):
                continue
            # weird syntax around line 80:
            if line in ("/*", '"'):
                continue
            line = line.split('"')
            assert len(line) in (1, 2, 3), line
            if len(line) == 3 and len(line[2]) > 0:
                l2 = line[2].strip()
                assert l2.startswith("/*") and l2.endswith("*/"), l2
            desc = "" if len(line) == 1 else line[1]
            line = line[0].split()
            assert len(line) == 3, (li + 1, line)
            kind, id_, dtype = line
            unit = "-"
            id_ = int(id_)
            val = [kind, dtype, unit]
            if id_ not in tag_dups:
                assert id_ not in fif["tags"], (fif["tags"].get(id_), val)
            fif["tags"][id_] = val

    # Types and enums
    in_ = None
    re_prim = re.compile(r'^primitive\((.*)\)\s*(\S*)\s*"(.*)"$')
    re_enum = re.compile(r'^enum\((\S*)\)\s*".*"$')
    re_enum_entry = re.compile(r'\s*(\S*)\s*(\S*)\s*"(.*)"$')
    re_deri = re.compile(r"^derived_type\((.*)\)\s*(\S*)\s*$")
    re_defi = re.compile(r'#define\s*(\S*)\s*(\S*)\s*"(.*)"$')
    used_enums = list()
    for extra in ("", "_MNE"):
        with open(tmp_path / f"DictionaryTypes{extra}.txt", "rb") as fid:
            for li, line in enumerate(fid):
                line = line.decode("ISO-8859-1").strip()
                if in_ is None:
                    p = re_prim.match(line)
                    e = re_enum.match(line)
                    d = re_defi.match(line)
                    r = re_deri.match(line)
                    if p is not None:
                        t, s, d = p.groups()
                        s = int(s)
                        assert s not in fif["types"]
                        fif["types"][s] = [t, d]
                    elif e is not None:
                        # entering an enum
                        this_enum = e.group(1)
                        if this_enum not in fif:
                            used_enums.append(this_enum)
                            fif[this_enum] = dict()
                            con[this_enum] = dict()
                        in_ = fif[this_enum]
                    elif d is not None:
                        t, s, d = d.groups()
                        s = int(s)
                        fif["defines"][t] = [s, d]
                    elif r is not None:
                        t, s = r.groups()
                        s = int(s, 16)
                        fif["types"][s] = t
                    else:
                        assert not line.startswith("enum(")
                        assert not line.startswith("derived_type")
                else:  # in an enum
                    if line == "{":
                        continue
                    elif line == "}":
                        in_ = None
                        continue
                    t, s, d = re_enum_entry.match(line).groups()
                    s = int(s)
                    if t != "ecg" and s != 3:  # ecg defined the same way
                        assert s not in in_
                    in_[s] = [t, d]

    #
    # Assertions
    #

    # Version
    mne_version = f"{FIFF.FIFFC_MAJOR_VERSION:d}.{FIFF.FIFFC_MINOR_VERSION:d}"
    assert fiff_version == mne_version
    unknowns = list()

    # Assert that all our constants are in the FIF def
    assert "FIFFV_SSS_JOB_NOTHING" in dir(FIFF)
    for name in sorted(dir(FIFF)):
        if name.startswith("_") or name in _dir_ignore_names:
            continue
        check = None
        val = getattr(FIFF, name)
        if name in fif["defines"]:
            assert fif["defines"][name][0] == val
        elif name.startswith("FIFFC_"):
            # Checked above
            assert name in (
                "FIFFC_MAJOR_VERSION",
                "FIFFC_MINOR_VERSION",
                "FIFFC_VERSION",
            )
        elif name.startswith("FIFFB_"):
            check = "iod"
        elif name.startswith("FIFFT_"):
            check = "types"
        elif name.startswith("FIFFV_"):
            if name.startswith("FIFFV_MNE_") and name.endswith("_ORI"):
                check = "mne_ori"
            elif name.startswith("FIFFV_MNE_") and name.endswith("_COV"):
                check = "covariance_type"
            elif name.startswith("FIFFV_MNE_COORD"):
                check = "coord"  # weird wrapper
            elif (
                name.endswith("_CH")
                or "_QUAT_" in name
                or name
                in (
                    "FIFFV_DIPOLE_WAVE",
                    "FIFFV_GOODNESS_FIT",
                    "FIFFV_HPI_ERR",
                    "FIFFV_HPI_G",
                    "FIFFV_HPI_MOV",
                )
            ):
                check = "ch_type"
            elif name.startswith("FIFFV_SUBJ_"):
                check = name.split("_")[2].lower()
            elif name in (
                "FIFFV_POINT_LPA",
                "FIFFV_POINT_NASION",
                "FIFFV_POINT_RPA",
                "FIFFV_POINT_INION",
            ):
                check = "cardinal_point"
            else:
                for check in used_enums:
                    if name.startswith("FIFFV_" + check.upper()):
                        break
                else:
                    if name not in _tag_ignore_names:
                        raise RuntimeError(f"Could not find {name}")
            assert check in used_enums, name
            if "SSS" in check:
                raise RuntimeError
        elif name.startswith("FIFF_UNIT"):  # units and multipliers
            check = name.split("_")[1].lower()
        elif name.startswith("FIFF_"):
            check = "tags"
        else:
            unknowns.append((name, val))
        if check is not None and name not in _tag_ignore_names:
            assert val in fif[check], f"{check}: {val}, {name}"
            if val in con[check]:
                msg = f"{name}='{con[check][val]}'  ?"
                assert _aliases.get(name) == con[check][val], msg
            else:
                con[check][val] = name
    unknowns = "\n\t".join(f"{u[0]} ({u[1]})" for u in unknowns)
    assert len(unknowns) == 0, f"Unknown types\n\t{unknowns}"

    # Assert that all the FIF defs are in our constants
    assert set(fif.keys()) == set(con.keys())
    for key in sorted(set(fif.keys()) - {"defines"}):
        this_fif, this_con = fif[key], con[key]
        assert len(set(this_fif.keys())) == len(this_fif)
        assert len(set(this_con.keys())) == len(this_con)
        missing_from_con = sorted(set(this_con.keys()) - set(this_fif.keys()))
        assert missing_from_con == [], key
        if key not in _ignore_incomplete_enums:
            missing_from_fif = sorted(set(this_fif.keys()) - set(this_con.keys()))
            assert missing_from_fif == [], key

    # Assert that `coil_def.dat` has accurate descriptions of all enum(coil)
    coil_def = _read_coil_defs()
    coil_desc = np.array([c["desc"] for c in coil_def])
    coil_def = np.array([(c["coil_type"], c["accuracy"]) for c in coil_def], int)
    mask = coil_def[:, 1] == FWD.COIL_ACCURACY_ACCURATE
    coil_def = coil_def[mask, 0]
    coil_desc = coil_desc[mask]
    bad_list = []
    for key in fif["coil"]:
        if key not in _missing_coil_def and key not in coil_def:
            bad_list.append((f"    {key},").ljust(10) + "  # " + fif["coil"][key][1])
    assert len(bad_list) == 0, (
        "\nIn fiff-constants, missing from coil_def:\n" + "\n".join(bad_list)
    )
    # Assert that enum(coil) has all `coil_def.dat` entries
    for key, desc in zip(coil_def, coil_desc):
        if key not in fif["coil"]:
            bad_list.append((f"    {key},").ljust(10) + "  # " + desc)
    assert len(bad_list) == 0, (
        "In coil_def, missing  from fiff-constants:\n" + "\n".join(bad_list)
    )


@pytest.mark.parametrize(
    "dict_, match, extras",
    [
        ({**_dig_kind_named, **_dig_cardinal_named}, "FIFFV_POINT_", ()),
        (
            _ch_kind_named,
            "^FIFFV_.*_CH$",
            (FIFF.FIFFV_DIPOLE_WAVE, FIFF.FIFFV_GOODNESS_FIT),
        ),
        pytest.param(
            _coord_frame_named,
            "FIFFV_(MNE_)?COORD_",
            (),
            marks=pytest.mark.xfail(
                reason="Intentional mismatch tested by test_coord_frame_consistency",
            ),
        ),
        (_ch_unit_named, "FIFF_UNIT_", ()),
        (_ch_unit_mul_named, "FIFF_UNITM_", ()),
        (_ch_coil_type_named, "FIFFV_COIL_", ()),
    ],
)
def test_dict_completion(dict_, match, extras):
    """Test readable dict completions."""
    regex = re.compile(match)
    got = set(FIFF[key] for key in FIFF if regex.search(key) is not None)
    for e in extras:
        got.add(e)
    want = set(dict_)
    assert got == want, match


def test_coord_frame_consistency():
    """Test consistency between coord frame mappings."""
    all_frames = set(
        key for key in dir(FIFF) if key.startswith(("FIFFV_COORD_", "FIFFV_MNE_COORD"))
    )
    # ... but there are some frames that we never work in so let's cull those for now
    ignore_frames = set(
        f"FIFFV_COORD_{name}"
        for name in """
        MRI_SLICE MRI_DISPLAY DICOM_DEVICE IMAGING_DEVICE
        """.strip().split()
    )
    ignore_frames |= set(
        f"FIFFV_MNE_COORD_{name}"
        for name in """
        DIGITIZER TUFTS_EEG FS_TAL_GTZ FS_TAL_LTZ
        """.strip().split()
    )
    assert ignore_frames.issubset(all_frames)
    all_frames -= ignore_frames
    all_ints = set(FIFF[key] for key in all_frames)
    assert set(_frame_to_str) == all_ints
    assert set(_verbose_frames) == all_ints
    assert set(_coord_frame_named) == all_ints
