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

# The computations in this code were primarily derived from Matti Hämäläinen's
# C code.

import os
import os.path as op
from contextlib import contextmanager
from copy import deepcopy
from pathlib import Path

import numpy as np

from .._fiff.compensator import get_current_comp, make_compensator
from .._fiff.constants import FIFF, FWD
from .._fiff.meas_info import Info, read_info
from .._fiff.pick import _has_kit_refs, pick_info, pick_types
from .._fiff.tag import _loc_to_coil_trans, _loc_to_eeg_loc
from ..bem import ConductorModel, _bem_find_surface, read_bem_solution
from ..source_estimate import VolSourceEstimate
from ..source_space._source_space import (
    _complete_vol_src,
    _ensure_src,
    _filter_source_spaces,
    _make_discrete_source_space,
)
from ..surface import _CheckInside, _normalize_vectors
from ..transforms import (
    Transform,
    _coord_frame_name,
    _ensure_trans,
    _get_trans,
    _print_coord_trans,
    apply_trans,
    invert_transform,
    transform_surface_to,
)
from ..utils import _check_fname, _pl, _validate_type, logger, verbose, warn
from ._compute_forward import _compute_forwards
from .forward import _FWD_ORDER, Forward, _merge_fwds, convert_forward_solution

_accuracy_dict = dict(
    point=FWD.COIL_ACCURACY_POINT,
    normal=FWD.COIL_ACCURACY_NORMAL,
    accurate=FWD.COIL_ACCURACY_ACCURATE,
)
_extra_coil_def_fname = None


@verbose
def _read_coil_defs(verbose=None):
    """Read a coil definition file.

    Parameters
    ----------
    %(verbose)s

    Returns
    -------
    res : list of dict
        The coils. It is a dictionary with valid keys:
        'cosmag' | 'coil_class' | 'coord_frame' | 'rmag' | 'type' |
        'chname' | 'accuracy'.
        cosmag contains the direction of the coils and rmag contains the
        position vector.

    Notes
    -----
    The global variable "_extra_coil_def_fname" can be used to prepend
    additional definitions. These are never added to the registry.
    """
    coil_dir = op.join(op.split(__file__)[0], "..", "data")
    coils = list()
    if _extra_coil_def_fname is not None:
        coils += _read_coil_def_file(_extra_coil_def_fname, use_registry=False)
    coils += _read_coil_def_file(op.join(coil_dir, "coil_def.dat"))
    return coils


# Typically we only have 1 or 2 coil def files, but they can end up being
# read a lot. Let's keep a list of them and just reuse them:
_coil_registry = {}


def _read_coil_def_file(fname, use_registry=True):
    """Read a coil def file."""
    if not use_registry or fname not in _coil_registry:
        big_val = 0.5
        coils = list()
        with open(fname) as fid:
            lines = fid.readlines()
        lines = lines[::-1]
        while len(lines) > 0:
            line = lines.pop().strip()
            if line[0] == "#" and len(line) > 0:
                continue
            desc_start = line.find('"')
            desc_end = len(line) - 1
            assert line.strip()[desc_end] == '"'
            desc = line[desc_start:desc_end]
            vals = np.fromstring(line[:desc_start].strip(), dtype=float, sep=" ")
            assert len(vals) == 6
            npts = int(vals[3])
            coil = dict(
                coil_type=vals[1],
                coil_class=vals[0],
                desc=desc,
                accuracy=vals[2],
                size=vals[4],
                base=vals[5],
            )
            # get parameters of each component
            rmag = list()
            cosmag = list()
            w = list()
            for p in range(npts):
                # get next non-comment line
                line = lines.pop()
                while line[0] == "#":
                    line = lines.pop()
                vals = np.fromstring(line, sep=" ")
                if len(vals) != 7:
                    raise RuntimeError(
                        f"Could not interpret line {p + 1} as 7 points:\n{line}"
                    )
                # Read and verify data for each integration point
                w.append(vals[0])
                rmag.append(vals[[1, 2, 3]])
                cosmag.append(vals[[4, 5, 6]])
            w = np.array(w)
            rmag = np.array(rmag)
            cosmag = np.array(cosmag)
            size = np.sqrt(np.sum(cosmag**2, axis=1))
            if np.any(np.sqrt(np.sum(rmag**2, axis=1)) > big_val):
                raise RuntimeError("Unreasonable integration point")
            if np.any(size <= 0):
                raise RuntimeError("Unreasonable normal")
            cosmag /= size[:, np.newaxis]
            coil.update(dict(w=w, cosmag=cosmag, rmag=rmag))
            coils.append(coil)
        if use_registry:
            _coil_registry[fname] = coils
    if use_registry:
        coils = deepcopy(_coil_registry[fname])
    logger.info("%d coil definition%s read", len(coils), _pl(coils))
    return coils


def _create_meg_coil(coilset, ch, acc, do_es):
    """Create a coil definition using templates, transform if necessary."""
    # Also change the coordinate frame if so desired
    if ch["kind"] not in [FIFF.FIFFV_MEG_CH, FIFF.FIFFV_REF_MEG_CH]:
        raise RuntimeError(f"{ch['ch_name']} is not a MEG channel")

    # Simple linear search from the coil definitions
    for coil in coilset:
        if coil["coil_type"] == (ch["coil_type"] & 0xFFFF) and coil["accuracy"] == acc:
            break
    else:
        raise RuntimeError(
            "Desired coil definition not found "
            f"(type = {ch['coil_type']} acc = {acc})"
        )

    # Apply a coordinate transformation if so desired
    coil_trans = _loc_to_coil_trans(ch["loc"])

    # Create the result
    res = dict(
        chname=ch["ch_name"],
        coil_class=coil["coil_class"],
        accuracy=coil["accuracy"],
        base=coil["base"],
        size=coil["size"],
        type=ch["coil_type"],
        w=coil["w"],
        desc=coil["desc"],
        coord_frame=FIFF.FIFFV_COORD_DEVICE,
        rmag_orig=coil["rmag"],
        cosmag_orig=coil["cosmag"],
        coil_trans_orig=coil_trans,
        r0=coil_trans[:3, 3],
        rmag=apply_trans(coil_trans, coil["rmag"]),
        cosmag=apply_trans(coil_trans, coil["cosmag"], False),
    )
    if do_es:
        r0_exey = np.dot(coil["rmag"][:, :2], coil_trans[:3, :2].T) + coil_trans[:3, 3]
        res.update(
            ex=coil_trans[:3, 0],
            ey=coil_trans[:3, 1],
            ez=coil_trans[:3, 2],
            r0_exey=r0_exey,
        )
    return res


def _create_eeg_el(ch, t=None):
    """Create an electrode definition, transform coords if necessary."""
    if ch["kind"] != FIFF.FIFFV_EEG_CH:
        raise RuntimeError(
            f"{ch['ch_name']} is not an EEG channel. Cannot create an electrode "
            "definition."
        )
    if t is None:
        t = Transform("head", "head")  # identity, no change
    if t.from_str != "head":
        raise RuntimeError("Inappropriate coordinate transformation")

    r0ex = _loc_to_eeg_loc(ch["loc"])
    if r0ex.shape[1] == 1:  # no reference
        w = np.array([1.0])
    else:  # has reference
        w = np.array([1.0, -1.0])

    # Optional coordinate transformation
    r0ex = apply_trans(t["trans"], r0ex.T)

    # The electrode location
    cosmag = r0ex.copy()
    _normalize_vectors(cosmag)
    res = dict(
        chname=ch["ch_name"],
        coil_class=FWD.COILC_EEG,
        w=w,
        accuracy=_accuracy_dict["normal"],
        type=ch["coil_type"],
        coord_frame=t["to"],
        rmag=r0ex,
        cosmag=cosmag,
    )
    return res


def _create_meg_coils(chs, acc, t=None, coilset=None, do_es=False):
    """Create a set of MEG coils in the head coordinate frame."""
    acc = _accuracy_dict[acc] if isinstance(acc, str) else acc
    coilset = _read_coil_defs(verbose=False) if coilset is None else coilset
    coils = [_create_meg_coil(coilset, ch, acc, do_es) for ch in chs]
    _transform_orig_meg_coils(coils, t, do_es=do_es)
    return coils


def _transform_orig_meg_coils(coils, t, do_es=True):
    """Transform original (device) MEG coil positions."""
    if t is None:
        return
    for coil in coils:
        coil_trans = np.dot(t["trans"], coil["coil_trans_orig"])
        coil.update(
            coord_frame=t["to"],
            r0=coil_trans[:3, 3],
            rmag=apply_trans(coil_trans, coil["rmag_orig"]),
            cosmag=apply_trans(coil_trans, coil["cosmag_orig"], False),
        )
        if do_es:
            r0_exey = (
                np.dot(coil["rmag_orig"][:, :2], coil_trans[:3, :2].T)
                + coil_trans[:3, 3]
            )
            coil.update(
                ex=coil_trans[:3, 0],
                ey=coil_trans[:3, 1],
                ez=coil_trans[:3, 2],
                r0_exey=r0_exey,
            )


def _create_eeg_els(chs):
    """Create a set of EEG electrodes in the head coordinate frame."""
    return [_create_eeg_el(ch) for ch in chs]


@verbose
def _setup_bem(bem, bem_extra, neeg, mri_head_t, allow_none=False, verbose=None):
    """Set up a BEM for forward computation, making a copy and modifying."""
    if allow_none and bem is None:
        return None
    logger.info("")
    _validate_type(bem, ("path-like", ConductorModel), bem)
    if not isinstance(bem, ConductorModel):
        logger.info(f"Setting up the BEM model using {bem_extra}...\n")
        bem = read_bem_solution(bem)
    else:
        bem = bem.copy()
    if bem["is_sphere"]:
        logger.info("Using the sphere model.\n")
        if len(bem["layers"]) == 0 and neeg > 0:
            raise RuntimeError(
                "Spherical model has zero shells, cannot use with EEG data"
            )
        if bem["coord_frame"] != FIFF.FIFFV_COORD_HEAD:
            raise RuntimeError("Spherical model is not in head coordinates")
    else:
        if bem["surfs"][0]["coord_frame"] != FIFF.FIFFV_COORD_MRI:
            raise RuntimeError(
                f'BEM is in {_coord_frame_name(bem["surfs"][0]["coord_frame"])} '
                'coordinates, should be in MRI'
            )
        if neeg > 0 and len(bem["surfs"]) == 1:
            raise RuntimeError(
                "Cannot use a homogeneous (1-layer BEM) model "
                "for EEG forward calculations, consider "
                "using a 3-layer BEM instead"
            )
        logger.info("Employing the head->MRI coordinate transform with the BEM model.")
        # fwd_bem_set_head_mri_t: Set the coordinate transformation
        bem["head_mri_t"] = _ensure_trans(mri_head_t, "head", "mri")
        logger.info(f"BEM model {op.split(bem_extra)[1]} is now set up")
        logger.info("")
    return bem


@verbose
def _prep_meg_channels(
    info,
    accuracy="accurate",
    exclude=(),
    *,
    ignore_ref=False,
    head_frame=True,
    do_es=False,
    verbose=None,
):
    """Prepare MEG coil definitions for forward calculation."""
    # Find MEG channels
    ref_meg = True if not ignore_ref else False
    picks = pick_types(info, meg=True, ref_meg=ref_meg, exclude=exclude)

    # Make sure MEG coils exist
    if len(picks) <= 0:
        raise RuntimeError("Could not find any MEG channels")
    info_meg = pick_info(info, picks)
    del picks

    # Get channel info and names for MEG channels
    logger.info(f'Read {len(info_meg["chs"])} MEG channels from info')

    # Get MEG compensation channels
    compensator = post_picks = None
    ch_names = info_meg["ch_names"]
    if not ignore_ref:
        ref_picks = pick_types(info, meg=False, ref_meg=True, exclude=exclude)
        ncomp = len(ref_picks)
        if ncomp > 0:
            logger.info(f"Read {ncomp} MEG compensation channels from info")
            # We need to check to make sure these are NOT KIT refs
            if _has_kit_refs(info, ref_picks):
                raise NotImplementedError(
                    "Cannot create forward solution with KIT reference "
                    'channels. Consider using "ignore_ref=True" in '
                    "calculation"
                )
            logger.info(f'{len(info["comps"])} compensation data sets in info')
            # Compose a compensation data set if necessary
            # adapted from mne_make_ctf_comp() from mne_ctf_comp.c
            logger.info("Setting up compensation data...")
            comp_num = get_current_comp(info)
            if comp_num is None or comp_num == 0:
                logger.info("    No compensation set. Nothing more to do.")
            else:
                compensator = make_compensator(
                    info_meg, 0, comp_num, exclude_comp_chs=False
                )
                logger.info(f"    Desired compensation data ({comp_num}) found.")
                logger.info("    All compensation channels found.")
                logger.info("    Preselector created.")
                logger.info("    Compensation data matrix created.")
                logger.info("    Postselector created.")
            post_picks = pick_types(info_meg, meg=True, ref_meg=False, exclude=exclude)
            ch_names = [ch_names[pick] for pick in post_picks]

    # Create coil descriptions with transformation to head or device frame
    templates = _read_coil_defs()

    if head_frame:
        _print_coord_trans(info["dev_head_t"])
        transform = info["dev_head_t"]
    else:
        transform = None

    megcoils = _create_meg_coils(
        info_meg["chs"], accuracy, transform, templates, do_es=do_es
    )

    # Check that coordinate frame is correct and log it
    if head_frame:
        assert megcoils[0]["coord_frame"] == FIFF.FIFFV_COORD_HEAD
        logger.info("MEG coil definitions created in head coordinates.")
    else:
        assert megcoils[0]["coord_frame"] == FIFF.FIFFV_COORD_DEVICE
        logger.info("MEG coil definitions created in device coordinate.")

    return dict(
        defs=megcoils,
        ch_names=ch_names,
        compensator=compensator,
        info=info_meg,
        post_picks=post_picks,
    )


@verbose
def _prep_eeg_channels(info, exclude=(), verbose=None):
    """Prepare EEG electrode definitions for forward calculation."""
    info_extra = "info"

    # Find EEG electrodes
    picks = pick_types(info, meg=False, eeg=True, ref_meg=False, exclude=exclude)

    # Make sure EEG electrodes exist
    neeg = len(picks)
    if neeg <= 0:
        raise RuntimeError("Could not find any EEG channels")

    # Get channel info and names for EEG channels
    eegchs = pick_info(info, picks)["chs"]
    eegnames = [info["ch_names"][p] for p in picks]
    logger.info(f"Read {len(picks):3} EEG channels from {info_extra}")

    # Create EEG electrode descriptions
    eegels = _create_eeg_els(eegchs)
    logger.info("Head coordinate coil definitions created.")

    return dict(defs=eegels, ch_names=eegnames)


@verbose
def _prepare_for_forward(
    src,
    mri_head_t,
    info,
    bem,
    mindist,
    n_jobs,
    bem_extra="",
    trans="",
    info_extra="",
    meg=True,
    eeg=True,
    ignore_ref=False,
    allow_bem_none=False,
    verbose=None,
):
    """Prepare for forward computation.

    The sensors dict contains keys for each sensor type, e.g. 'meg', 'eeg'.
    The vale for each of these is a dict that comes from _prep_meg_channels or
    _prep_eeg_channels. Each dict contains:

    - defs : a list of dicts (one per channel) with 'rmag', 'cosmag', etc.
    - ch_names: a list of str channel names corresponding to the defs
    - compensator (optional): the ndarray compensation matrix to apply
    - post_picks (optional): the ndarray of indices to pick after applying the
      compensator
    """
    # Read the source locations
    logger.info("")
    # let's make a copy in case we modify something
    src = _ensure_src(src).copy()
    nsource = sum(s["nuse"] for s in src)
    if nsource == 0:
        raise RuntimeError(
            "No sources are active in these source spaces. "
            '"do_all" option should be used.'
        )
    logger.info(
        "Read %d source spaces a total of %d active source locations", len(src), nsource
    )
    # Delete some keys to clean up the source space:
    for key in ["working_dir", "command_line"]:
        if key in src.info:
            del src.info[key]

    # Read the MRI -> head coordinate transformation
    logger.info("")
    _print_coord_trans(mri_head_t)

    # make a new dict with the relevant information
    arg_list = [info_extra, trans, src, bem_extra, meg, eeg, mindist, n_jobs, verbose]
    cmd = f"make_forward_solution({', '.join(str(a) for a in arg_list)})"
    mri_id = dict(machid=np.zeros(2, np.int32), version=0, secs=0, usecs=0)

    info_trans = str(trans) if isinstance(trans, Path) else trans
    info = Info(
        chs=info["chs"],
        comps=info["comps"],
        dev_head_t=info["dev_head_t"],
        mri_file=info_trans,
        mri_id=mri_id,
        meas_file=info_extra,
        meas_id=None,
        working_dir=os.getcwd(),
        command_line=cmd,
        bads=info["bads"],
        mri_head_t=mri_head_t,
    )
    info._update_redundant()
    info._check_consistency()
    logger.info("")

    sensors = dict()
    if meg and len(pick_types(info, meg=True, ref_meg=False, exclude=[])) > 0:
        sensors["meg"] = _prep_meg_channels(info, ignore_ref=ignore_ref)
    if eeg and len(pick_types(info, eeg=True, exclude=[])) > 0:
        sensors["eeg"] = _prep_eeg_channels(info)

    # Check that some channels were found
    if len(sensors) == 0:
        raise RuntimeError("No MEG or EEG channels found.")

    # pick out final info
    info = pick_info(
        info, pick_types(info, meg=meg, eeg=eeg, ref_meg=False, exclude=[])
    )

    # Transform the source spaces into the appropriate coordinates
    # (will either be HEAD or MRI)
    for s in src:
        transform_surface_to(s, "head", mri_head_t)
    logger.info(
        f"Source spaces are now in {_coord_frame_name(s['coord_frame'])} coordinates."
    )

    # Prepare the BEM model
    eegnames = sensors.get("eeg", dict()).get("ch_names", [])
    bem = _setup_bem(
        bem, bem_extra, len(eegnames), mri_head_t, allow_none=allow_bem_none
    )
    del eegnames

    # Circumvent numerical problems by excluding points too close to the skull,
    # and check that sensors are not inside any BEM surface
    if bem is not None:
        if not bem["is_sphere"]:
            check_surface = "inner skull surface"
            inner_skull = _bem_find_surface(bem, "inner_skull")
            check_inside = _filter_source_spaces(
                inner_skull, mindist, mri_head_t, src, n_jobs
            )
            logger.info("")
            if len(bem["surfs"]) == 3:
                check_surface = "scalp surface"
                check_inside = _CheckInside(_bem_find_surface(bem, "head"))
        else:
            check_surface = "outermost sphere shell"
            if len(bem["layers"]) == 0:

                def check_inside(x):
                    return np.zeros(len(x), bool)

            else:

                def check_inside(x):
                    return (
                        np.linalg.norm(x - bem["r0"], axis=1) < bem["layers"][-1]["rad"]
                    )

        if "meg" in sensors:
            meg_loc = apply_trans(
                invert_transform(mri_head_t),
                np.array([coil["r0"] for coil in sensors["meg"]["defs"]]),
            )
            n_inside = check_inside(meg_loc).sum()
            if n_inside:
                raise RuntimeError(
                    f"Found {n_inside} MEG sensor{_pl(n_inside)} inside the "
                    f"{check_surface}, perhaps coordinate frames and/or "
                    "coregistration must be incorrect"
                )

    rr = np.concatenate([s["rr"][s["vertno"]] for s in src])
    if len(rr) < 1:
        raise RuntimeError(
            "No points left in source space after excluding "
            "points close to inner skull."
        )

    # deal with free orientations:
    source_nn = np.tile(np.eye(3), (len(rr), 1))
    update_kwargs = dict(
        nchan=len(info["ch_names"]),
        nsource=len(rr),
        info=info,
        src=src,
        source_nn=source_nn,
        source_rr=rr,
        surf_ori=False,
        mri_head_t=mri_head_t,
    )
    return sensors, rr, info, update_kwargs, bem


@verbose
def make_forward_solution(
    info,
    trans,
    src,
    bem,
    meg=True,
    eeg=True,
    *,
    mindist=0.0,
    ignore_ref=False,
    n_jobs=None,
    verbose=None,
):
    """Calculate a forward solution for a subject.

    Parameters
    ----------
    %(info_str)s
    %(trans)s

        .. versionchanged:: 0.19
            Support for ``'fsaverage'`` argument.
    src : path-like | instance of SourceSpaces
        Either a path to a source space file or a loaded or generated
        :class:`~mne.SourceSpaces`.
    bem : path-like | ConductorModel
        Filename of the BEM (e.g., ``"sample-5120-5120-5120-bem-sol.fif"``) to
        use, or a loaded :class:`~mne.bem.ConductorModel`. See
        :func:`~mne.make_bem_model` and :func:`~mne.make_bem_solution` to create a
        :class:`mne.bem.ConductorModel`.
    meg : bool
        If True (default), include MEG computations.
    eeg : bool
        If True (default), include EEG computations.
    mindist : float
        Minimum distance of sources from inner skull surface (in mm).
    ignore_ref : bool
        If True, do not include reference channels in compensation. This
        option should be True for KIT files, since forward computation
        with reference channels is not currently supported.
    %(n_jobs)s
    %(verbose)s

    Returns
    -------
    fwd : instance of Forward
        The forward solution.

    See Also
    --------
    convert_forward_solution

    Notes
    -----
    The ``--grad`` option from MNE-C (to compute gradients) is not implemented
    here.

    To create a fixed-orientation forward solution, use this function
    followed by :func:`mne.convert_forward_solution`.

    .. note::
        If the BEM solution was computed with `OpenMEEG <https://openmeeg.github.io>`__
        in :func:`mne.make_bem_solution`, then OpenMEEG will automatically
        be used to compute the forward solution.

    .. versionchanged:: 1.2
       Added support for OpenMEEG-based forward solution calculations.
    """
    # Currently not (sup)ported:
    # 1. --grad option (gradients of the field, not used much)
    # 2. --fixed option (can be computed post-hoc)
    # 3. --mricoord option (probably not necessary)

    # read the transformation from MRI to HEAD coordinates
    # (could also be HEAD to MRI)
    mri_head_t, trans = _get_trans(trans)
    if isinstance(bem, ConductorModel):
        bem_extra = "instance of ConductorModel"
    else:
        bem_extra = bem
    _validate_type(info, ("path-like", Info), "info")
    if not isinstance(info, Info):
        info_extra = op.split(info)[1]
        info = _check_fname(info, must_exist=True, overwrite="read", name="info")
        info = read_info(info, verbose=False)
    else:
        info_extra = "instance of Info"

    # Report the setup
    logger.info(f"Source space          : {src}")
    logger.info(f"MRI -> head transform : {trans}")
    logger.info(f"Measurement data      : {info_extra}")
    if isinstance(bem, ConductorModel) and bem["is_sphere"]:
        logger.info(f"Sphere model      : origin at {bem['r0']} mm")
        logger.info("Standard field computations")
    else:
        logger.info(f"Conductor model   : {bem_extra}")
        logger.info("Accurate field computations")
    logger.info(
        "Do computations in %s coordinates", _coord_frame_name(FIFF.FIFFV_COORD_HEAD)
    )
    logger.info("Free source orientations")

    # Create MEG coils and EEG electrodes in the head coordinate frame
    sensors, rr, info, update_kwargs, bem = _prepare_for_forward(
        src,
        mri_head_t,
        info,
        bem,
        mindist,
        n_jobs,
        bem_extra,
        trans,
        info_extra,
        meg,
        eeg,
        ignore_ref,
    )
    del (src, mri_head_t, trans, info_extra, bem_extra, mindist, meg, eeg, ignore_ref)

    # Time to do the heavy lifting: MEG first, then EEG
    fwds = _compute_forwards(rr, bem=bem, sensors=sensors, n_jobs=n_jobs)

    # merge forwards
    fwds = {
        key: _to_forward_dict(fwds[key], sensors[key]["ch_names"])
        for key in _FWD_ORDER
        if key in fwds
    }
    fwd = _merge_fwds(fwds, verbose=False)
    del fwds
    logger.info("")

    # Don't transform the source spaces back into MRI coordinates (which is
    # done in the C code) because mne-python assumes forward solution source
    # spaces are in head coords.
    fwd.update(**update_kwargs)
    logger.info("Finished.")
    return fwd


@verbose
def make_forward_dipole(dipole, bem, info, trans=None, n_jobs=None, *, verbose=None):
    """Convert dipole object to source estimate and calculate forward operator.

    The instance of Dipole is converted to a discrete source space,
    which is then combined with a BEM or a sphere model and
    the sensor information in info to form a forward operator.

    The source estimate object (with the forward operator) can be projected to
    sensor-space using :func:`mne.simulation.simulate_evoked`.

    .. note:: If the (unique) time points of the dipole object are unevenly
              spaced, the first output will be a list of single-timepoint
              source estimates.

    Parameters
    ----------
    %(dipole)s
    bem : str | dict
        The BEM filename (str) or a loaded sphere model (dict).
    info : instance of Info
        The measurement information dictionary. It is sensor-information etc.,
        e.g., from a real data file.
    trans : str | None
        The head<->MRI transform filename. Must be provided unless BEM
        is a sphere model.
    %(n_jobs)s
    %(verbose)s

    Returns
    -------
    fwd : instance of Forward
        The forward solution corresponding to the source estimate(s).
    stc : instance of VolSourceEstimate | list of VolSourceEstimate
        The dipoles converted to a discrete set of points and associated
        time courses. If the time points of the dipole are unevenly spaced,
        a list of single-timepoint source estimates are returned.

    See Also
    --------
    mne.simulation.simulate_evoked

    Notes
    -----
    .. versionadded:: 0.12.0
    """
    if isinstance(dipole, list):
        from ..dipole import _concatenate_dipoles  # To avoid circular import

        dipole = _concatenate_dipoles(dipole)

    # Make copies to avoid mangling original dipole
    times = dipole.times.copy()
    pos = dipole.pos.copy()
    amplitude = dipole.amplitude.copy()
    ori = dipole.ori.copy()

    # Convert positions to discrete source space (allows duplicate rr & nn)
    # NB information about dipole orientation enters here, then no more
    sources = dict(rr=pos, nn=ori)
    # Dipole objects must be in the head frame
    src = _complete_vol_src([_make_discrete_source_space(sources, coord_frame="head")])

    # Forward operator created for channels in info (use pick_info to restrict)
    # Use defaults for most params, including min_dist
    fwd = make_forward_solution(info, trans, src, bem, n_jobs=n_jobs, verbose=verbose)
    # Convert from free orientations to fixed (in-place)
    convert_forward_solution(
        fwd, surf_ori=False, force_fixed=True, copy=False, use_cps=False, verbose=None
    )

    # Check for omissions due to proximity to inner skull in
    # make_forward_solution, which will result in an exception
    if fwd["src"][0]["nuse"] != len(pos):
        inuse = fwd["src"][0]["inuse"].astype(bool)
        head = "The following dipoles are outside the inner skull boundary"
        msg = len(head) * "#" + "\n" + head + "\n"
        for t, pos in zip(times[np.logical_not(inuse)], pos[np.logical_not(inuse)]):
            msg += (
                f"    t={t * 1000.0:.0f} ms, pos=({pos[0] * 1000.0:.0f}, "
                f"{pos[1] * 1000.0:.0f}, {pos[2] * 1000.0:.0f}) mm\n"
            )
        msg += len(head) * "#"
        logger.error(msg)
        raise ValueError("One or more dipoles outside the inner skull.")

    # multiple dipoles (rr and nn) per time instant allowed
    # uneven sampling in time returns list
    timepoints = np.unique(times)
    if len(timepoints) > 1:
        tdiff = np.diff(timepoints)
        if not np.allclose(tdiff, tdiff[0]):
            warn(
                "Unique time points of dipoles unevenly spaced: returned "
                "stc will be a list, one for each time point."
            )
            tstep = -1.0
        else:
            tstep = tdiff[0]
    elif len(timepoints) == 1:
        tstep = 0.001

    # Build the data matrix, essentially a block-diagonal with
    # n_rows: number of dipoles in total (dipole.amplitudes)
    # n_cols: number of unique time points in dipole.times
    # amplitude with identical value of times go together in one col (others=0)
    data = np.zeros((len(amplitude), len(timepoints)))  # (n_d, n_t)
    row = 0
    for tpind, tp in enumerate(timepoints):
        amp = amplitude[np.isin(times, tp)]
        data[row : row + len(amp), tpind] = amp
        row += len(amp)

    if tstep > 0:
        stc = VolSourceEstimate(
            data,
            vertices=[fwd["src"][0]["vertno"]],
            tmin=timepoints[0],
            tstep=tstep,
            subject=None,
        )
    else:  # Must return a list of stc, one for each time point
        stc = []
        for col, tp in enumerate(timepoints):
            stc += [
                VolSourceEstimate(
                    data[:, col][:, np.newaxis],
                    vertices=[fwd["src"][0]["vertno"]],
                    tmin=tp,
                    tstep=0.001,
                    subject=None,
                )
            ]
    return fwd, stc


def _to_forward_dict(
    fwd,
    names,
    fwd_grad=None,
    coord_frame=FIFF.FIFFV_COORD_HEAD,
    source_ori=FIFF.FIFFV_MNE_FREE_ORI,
):
    """Convert forward solution matrices to dicts."""
    assert names is not None
    sol = dict(
        data=fwd.T, nrow=fwd.shape[1], ncol=fwd.shape[0], row_names=names, col_names=[]
    )
    fwd = Forward(
        sol=sol,
        source_ori=source_ori,
        nsource=sol["ncol"],
        coord_frame=coord_frame,
        sol_grad=None,
        nchan=sol["nrow"],
        _orig_source_ori=source_ori,
        _orig_sol=sol["data"].copy(),
        _orig_sol_grad=None,
    )
    if fwd_grad is not None:
        sol_grad = dict(
            data=fwd_grad.T,
            nrow=fwd_grad.shape[1],
            ncol=fwd_grad.shape[0],
            row_names=names,
            col_names=[],
        )
        fwd.update(dict(sol_grad=sol_grad), _orig_sol_grad=sol_grad["data"].copy())
    return fwd


@contextmanager
def use_coil_def(fname):
    """Use a custom coil definition file.

    Parameters
    ----------
    fname : path-like
        The filename of the coil definition file.

    Returns
    -------
    context : contextmanager
        The context for using the coil definition.

    Notes
    -----
    This is meant to be used a context manager such as:

    >>> with use_coil_def(my_fname):  # doctest:+SKIP
    ...     make_forward_solution(...)

    This allows using custom coil definitions with functions that require
    forward modeling.
    """
    global _extra_coil_def_fname
    _extra_coil_def_fname = fname
    try:
        yield
    finally:
        _extra_coil_def_fname = None
