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

import numpy as np

from ._fiff.constants import FIFF
from ._fiff.open import fiff_open
from ._fiff.pick import _picks_to_idx, pick_types, pick_types_forward
from ._fiff.proj import (
    Projection,
    _has_eeg_average_ref_proj,
    _read_proj,
    _write_proj,
    make_eeg_average_ref_proj,
    make_projector,
)
from ._fiff.write import start_and_end_file
from .cov import _check_n_samples
from .epochs import Epochs
from .event import make_fixed_length_events
from .fixes import _safe_svd
from .forward import _subject_from_forward, convert_forward_solution, is_fixed_orient
from .parallel import parallel_func
from .source_estimate import _make_stc
from .utils import (
    _check_fname,
    _check_option,
    _validate_type,
    check_fname,
    logger,
    verbose,
)


@verbose
def read_proj(fname, *, verbose=None):
    """Read projections from a FIF file.

    Parameters
    ----------
    fname : path-like
        The name of file containing the projections vectors. It should end with
        ``-proj.fif`` or ``-proj.fif.gz``.
    %(verbose)s

    Returns
    -------
    projs : list of Projection
        The list of projection vectors.

    See Also
    --------
    write_proj
    """
    check_fname(
        fname, "projection", ("-proj.fif", "-proj.fif.gz", "_proj.fif", "_proj.fif.gz")
    )
    fname = _check_fname(fname, overwrite="read", must_exist=True)

    ff, tree, _ = fiff_open(fname)
    with ff as fid:
        projs = _read_proj(fid, tree)
    return projs


@verbose
def write_proj(fname, projs, *, overwrite=False, verbose=None):
    """Write projections to a FIF file.

    Parameters
    ----------
    fname : path-like
        The name of file containing the projections vectors. It should end with
        ``-proj.fif`` or ``-proj.fif.gz``.
    projs : list of Projection
        The list of projection vectors.
    %(overwrite)s

        .. versionadded:: 1.0
    %(verbose)s

        .. versionadded:: 1.0

    See Also
    --------
    read_proj
    """
    fname = _check_fname(fname, overwrite=overwrite)
    check_fname(
        fname, "projection", ("-proj.fif", "-proj.fif.gz", "_proj.fif", "_proj.fif.gz")
    )
    with start_and_end_file(fname) as fid:
        _write_proj(fid, projs)


@verbose
def _compute_proj(
    data, info, n_grad, n_mag, n_eeg, desc_prefix, meg="separate", verbose=None
):
    _validate_type(n_grad, "numeric", "n_grad", "float or int")
    _validate_type(n_mag, "numeric", "n_grad", "float or int")
    _validate_type(n_eeg, "numeric", "n_eeg", "float or int")
    for n_, n_name in ((n_grad, "n_grad"), (n_mag, "n_mag"), (n_eeg, "n_eeg")):
        if n_ < 0:
            raise ValueError(
                f"Argument '{n_name}' must be either a positive integer or a float "
                f"between 0 and 1. '{n_}' is invalid."
            )
    _check_option("meg", meg, ("separate", "combined"))
    if meg == "combined":
        if n_grad != n_mag:
            raise ValueError(
                f"n_grad ({n_grad}) must be equal to n_mag ({n_mag}) when using "
                "meg='combined'."
            )
        ch_types = ("meg", "eeg")
        n_vectors = (n_grad, n_eeg)
        kinds = ("meg", "eeg")
    else:
        ch_types = ("grad", "mag", "eeg")
        n_vectors = (n_grad, n_mag, n_eeg)
        kinds = ("planar", "axial", "eeg")

    projs = []
    for ch_type, n_vector, kind in zip(ch_types, n_vectors, kinds):
        # select channels to use
        try:
            idx = _picks_to_idx(info, ch_type, with_ref_meg=False, exclude="bads")
        except ValueError:
            logger.info("No channels '%s' found. Skipping.", ch_type)
            continue
        names = [info["ch_names"][k] for k in idx]

        data_ = data[idx][:, idx]  # data is the covariance matrix: U * S**2 * Ut
        U, Sexp2, _ = _safe_svd(data_, full_matrices=False)
        exp_var = Sexp2 / Sexp2.sum()

        # select vectors to use
        if 0 < n_vector < 1:
            n_vector = np.searchsorted(np.cumsum(exp_var), n_vector, "left") + 1
        U = U[:, :n_vector]
        exp_var = exp_var[:n_vector]

        # create projectors
        for k, (u, var) in enumerate(zip(U.T, exp_var)):
            proj_data = dict(
                col_names=names,
                row_names=None,
                data=u[np.newaxis, :],
                nrow=1,
                ncol=u.size,
            )
            desc = f"{kind}-{desc_prefix}-PCA-{k + 1:02d}"
            logger.info(f"Adding projection: {desc} (exp var={100 * float(var):0.1f}%)")
            proj = Projection(
                active=False,
                data=proj_data,
                desc=desc,
                kind=FIFF.FIFFV_PROJ_ITEM_FIELD,
                explained_var=var,
            )
            projs.append(proj)
    return projs


@verbose
def compute_proj_epochs(
    epochs,
    n_grad=2,
    n_mag=2,
    n_eeg=2,
    n_jobs=None,
    desc_prefix=None,
    meg="separate",
    verbose=None,
):
    """Compute SSP (signal-space projection) vectors on epoched data.

    %(compute_ssp)s

    Parameters
    ----------
    epochs : instance of Epochs
        The epochs containing the artifact.
    %(n_proj_vectors)s
    %(n_jobs)s
        Number of jobs to use to compute covariance.
    desc_prefix : str | None
        The description prefix to use. If None, one will be created based on
        the event_id, tmin, and tmax.
    meg : str
        Can be ``'separate'`` (default) or ``'combined'`` to compute projectors
        for magnetometers and gradiometers separately or jointly.
        If ``'combined'``, ``n_mag == n_grad`` is required and the number of
        projectors computed for MEG will be ``n_mag``.

        .. versionadded:: 0.18
    %(verbose)s

    Returns
    -------
    projs: list of Projection
        List of projection vectors.

    See Also
    --------
    compute_proj_raw, compute_proj_evoked
    """
    # compute data covariance
    data = _compute_cov_epochs(epochs, n_jobs)
    event_id = epochs.event_id
    if event_id is None or len(list(event_id.keys())) == 0:
        event_id = "0"
    elif len(event_id.keys()) == 1:
        event_id = str(list(event_id.values())[0])
    else:
        event_id = "Multiple-events"
    if desc_prefix is None:
        desc_prefix = f"{event_id}-{epochs.tmin:<.3f}-{epochs.tmax:<.3f}"
    return _compute_proj(data, epochs.info, n_grad, n_mag, n_eeg, desc_prefix, meg=meg)


def _compute_cov_epochs(epochs, n_jobs, *, log_drops=False):
    """Compute epochs covariance."""
    parallel, p_fun, n_jobs = parallel_func(np.dot, n_jobs)
    n_start = len(epochs.events)
    data = parallel(p_fun(e, e.T) for e in epochs)
    n_epochs = len(data)
    if n_epochs == 0:
        raise RuntimeError("No good epochs found")
    if log_drops:
        logger.info(f"Dropped {n_start - n_epochs}/{n_start} epochs")

    n_chan, n_samples = epochs.info["nchan"], len(epochs.times)
    _check_n_samples(n_samples * n_epochs, n_chan)
    data = sum(data)
    return data


@verbose
def compute_proj_evoked(
    evoked, n_grad=2, n_mag=2, n_eeg=2, desc_prefix=None, meg="separate", verbose=None
):
    """Compute SSP (signal-space projection) vectors on evoked data.

    %(compute_ssp)s

    Parameters
    ----------
    evoked : instance of Evoked
        The Evoked obtained by averaging the artifact.
    %(n_proj_vectors)s
    desc_prefix : str | None
        The description prefix to use. If None, one will be created based on
        tmin and tmax.

        .. versionadded:: 0.17
    meg : str
        Can be ``'separate'`` (default) or ``'combined'`` to compute projectors
        for magnetometers and gradiometers separately or jointly.
        If ``'combined'``, ``n_mag == n_grad`` is required and the number of
        projectors computed for MEG will be ``n_mag``.

        .. versionadded:: 0.18
    %(verbose)s

    Returns
    -------
    projs : list of Projection
        List of projection vectors.

    See Also
    --------
    compute_proj_raw, compute_proj_epochs
    """
    data = np.dot(evoked.data, evoked.data.T)  # compute data covariance
    if desc_prefix is None:
        desc_prefix = f"{evoked.times[0]:<.3f}-{evoked.times[-1]:<.3f}"
    return _compute_proj(data, evoked.info, n_grad, n_mag, n_eeg, desc_prefix, meg=meg)


@verbose
def compute_proj_raw(
    raw,
    start=0,
    stop=None,
    duration=1,
    n_grad=2,
    n_mag=2,
    n_eeg=0,
    reject=None,
    flat=None,
    n_jobs=None,
    meg="separate",
    verbose=None,
):
    """Compute SSP (signal-space projection) vectors on continuous data.

    %(compute_ssp)s

    Parameters
    ----------
    raw : instance of Raw
        A raw object to use the data from.
    start : float
        Time (in seconds) to start computing SSP.
    stop : float | None
        Time (in seconds) to stop computing SSP. None will go to the end of the file.
    duration : float | None
        Duration (in seconds) to chunk data into for SSP
        If duration is ``None``, data will not be chunked.
    %(n_proj_vectors)s
    reject : dict | None
        Epoch PTP rejection threshold used if ``duration != None``. See `~mne.Epochs`.
    flat : dict | None
        Epoch flatness rejection threshold used if ``duration != None``. See
        `~mne.Epochs`.
    %(n_jobs)s
        Number of jobs to use to compute covariance.
    meg : str
        Can be ``'separate'`` (default) or ``'combined'`` to compute projectors
        for magnetometers and gradiometers separately or jointly.
        If ``'combined'``, ``n_mag == n_grad`` is required and the number of
        projectors computed for MEG will be ``n_mag``.

        .. versionadded:: 0.18
    %(verbose)s

    Returns
    -------
    projs: list of Projection
        List of projection vectors.

    See Also
    --------
    compute_proj_epochs, compute_proj_evoked
    """
    if duration is not None:
        duration = np.round(duration * raw.info["sfreq"]) / raw.info["sfreq"]
        events = make_fixed_length_events(raw, 999, start, stop, duration)
        picks = pick_types(
            raw.info, meg=True, eeg=True, eog=True, ecg=True, emg=True, exclude="bads"
        )
        epochs = Epochs(
            raw,
            events,
            None,
            tmin=0.0,
            tmax=duration - 1.0 / raw.info["sfreq"],
            picks=picks,
            reject=reject,
            flat=flat,
            baseline=None,
            proj=False,
        )
        data = _compute_cov_epochs(epochs, n_jobs, log_drops=True)
        info = epochs.info
        if not stop:
            stop = raw.n_times / raw.info["sfreq"]
    else:
        # convert to sample indices
        start = max(raw.time_as_index(start)[0], 0)
        stop = raw.time_as_index(stop)[0] if stop else raw.n_times
        stop = min(stop, raw.n_times)
        data, times = raw[:, start:stop]
        _check_n_samples(stop - start, data.shape[0])
        data = np.dot(data, data.T)  # compute data covariance
        info = raw.info
        # convert back to times
        start = start / raw.info["sfreq"]
        stop = stop / raw.info["sfreq"]

    desc_prefix = f"Raw-{start:<.3f}-{stop:<.3f}"
    projs = _compute_proj(data, info, n_grad, n_mag, n_eeg, desc_prefix, meg=meg)
    return projs


@verbose
def sensitivity_map(
    fwd, projs=None, ch_type="grad", mode="fixed", exclude=(), *, verbose=None
):
    """Compute sensitivity map.

    Such maps are used to know how much sources are visible by a type
    of sensor, and how much projections shadow some sources.

    Parameters
    ----------
    fwd : Forward
        The forward operator.
    projs : list
        List of projection vectors.
    ch_type : ``'grad'`` | ``'mag'`` | ``'eeg'``
        The type of sensors to use.
    mode : ``'free'`` | ``'fixed'`` | ``'ratio'`` | ``'radiality'`` | ``'angle'`` | ``'remaining'`` | ``'dampening'``

        Which sensitivity quantity to compute:

        - 'free' : Maximum obtainable signal over all source orientations.
        - 'fixed' : Signal from sources constrained to be normal to the
          cortical surface.
        - 'ratio' : Ratio of 'fixed' sensitivity to the 'free'
          (maximum-over-orientations) sensitivity.
        - 'radiality' : Ratio of signal from sources normal to the cortex
          to the maximum signal among all orientations.
        - 'angle' : Subspace angle with the noise subspace.
        - 'remaining' : Fraction of signal remaining after the projection
          (e.g., after SSS/SSP).
        - 'dampening' : Fraction of signal dampening due to the projection.
    exclude : list of str | str
        List of channels to exclude. If empty do not exclude any (default).
        If ``'bads'``, exclude channels in ``fwd['info']['bads']``.
    %(verbose)s

    Returns
    -------
    stc : SourceEstimate | VolSourceEstimate
        The sensitivity map as a SourceEstimate or VolSourceEstimate instance
        for visualization.

    Notes
    -----
    When mode is ``'fixed'`` or ``'free'``, the sensitivity map is normalized
    by its maximum value.
    """  # noqa: E501
    # check strings
    _check_option("ch_type", ch_type, ["eeg", "grad", "mag"])
    _check_option(
        "mode",
        mode,
        ["free", "fixed", "ratio", "radiality", "angle", "remaining", "dampening"],
    )

    # check forward
    if is_fixed_orient(fwd, orig=True):
        raise ValueError("fwd should must be computed with free orientation")

    # limit forward (this will make a copy of the data for us)
    if ch_type == "eeg":
        fwd = pick_types_forward(fwd, meg=False, eeg=True, exclude=exclude)
    else:
        fwd = pick_types_forward(fwd, meg=ch_type, eeg=False, exclude=exclude)

    convert_forward_solution(
        fwd, surf_ori=True, force_fixed=False, copy=False, verbose=False
    )
    assert fwd["surf_ori"] and not is_fixed_orient(fwd)

    gain = fwd["sol"]["data"]

    # Make sure EEG has average
    if ch_type == "eeg":
        if projs is None or not _has_eeg_average_ref_proj(fwd["info"], projs=projs):
            eeg_ave = [make_eeg_average_ref_proj(fwd["info"])]
        else:
            eeg_ave = []
        projs = eeg_ave if projs is None else projs + eeg_ave

    # Construct the projector
    residual_types = ["angle", "remaining", "dampening"]
    if projs is not None:
        proj, ncomp, U = make_projector(
            projs, fwd["sol"]["row_names"], include_active=True
        )
        # do projection for most types
        if mode not in residual_types:
            gain = np.dot(proj, gain)
        elif ncomp == 0:
            raise RuntimeError(
                "No valid projectors found for channel type "
                f"{ch_type}, cannot compute {mode}"
            )
    # can only run the last couple methods if there are projectors
    elif mode in residual_types:
        raise ValueError(f"No projectors used, cannot compute {mode}")

    _, n_dipoles = gain.shape
    n_locations = n_dipoles // 3
    del n_dipoles
    sensitivity_map = np.empty(n_locations)

    for k in range(n_locations):
        gg = gain[:, 3 * k : 3 * (k + 1)]  # noqa: E203
        if mode != "fixed":
            s = _safe_svd(gg, full_matrices=False, compute_uv=False)
        if mode == "free":
            sensitivity_map[k] = s[0]
        else:
            gz = np.linalg.norm(gg[:, 2])  # the normal component
            if mode == "fixed":
                sensitivity_map[k] = gz
            elif mode == "ratio":
                sensitivity_map[k] = gz / s[0]
            elif mode == "radiality":
                sensitivity_map[k] = 1.0 - (gz / s[0])
            else:
                if mode == "angle":
                    co = np.linalg.norm(np.dot(gg[:, 2], U))
                    sensitivity_map[k] = co / gz
                else:
                    p = np.linalg.norm(np.dot(proj, gg[:, 2]))
                    if mode == "remaining":
                        sensitivity_map[k] = p / gz
                    elif mode == "dampening":
                        sensitivity_map[k] = 1.0 - p / gz
                    else:
                        raise ValueError(f"Unknown mode type (got {mode})")

    # only normalize fixed and free methods
    if mode in ["fixed", "free"]:
        sensitivity_map /= np.max(sensitivity_map)

    subject = _subject_from_forward(fwd)
    vertices = [s["vertno"] for s in fwd["src"]]
    return _make_stc(
        sensitivity_map[:, np.newaxis],
        vertices,
        fwd["src"].kind,
        tmin=0.0,
        tstep=1.0,
        subject=subject,
    )
