"""Checks for nilearn estimators.

This module contains the code to run systematic checks
from sklearn or nilearn
on the nilearn 'estimators' (maskers, decoders, ...).

Some of the code here will help specify which of the sklearn
are expected to fail for some of the nilearn estimators.
In most cases, there will then be a homemade replacement
for that sklearn check:
for example for estimators that expect an image as input and not an array.

This module also contains several nilearn specific checks
that have no equivalent in sklearn:
for example report generation for the maskers.

Most of those checks have pytest dependencies
and importing them will fail if pytest is not installed.
"""

import contextlib
import inspect
import io
import os
import pickle
import re
import warnings
from copy import deepcopy
from pathlib import Path
from tempfile import TemporaryDirectory, mkdtemp

import numpy as np
import pandas as pd
import pytest
from joblib import Memory, hash
from nibabel import Nifti1Image
from numpy.testing import (
    assert_almost_equal,
    assert_array_almost_equal,
    assert_array_equal,
    assert_raises,
)
from numpydoc.docscrape import NumpyDocString
from sklearn import __version__ as sklearn_version
from sklearn import clone
from sklearn.base import is_classifier, is_regressor
from sklearn.datasets import make_classification, make_regression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.utils import _safe_indexing
from sklearn.utils._testing import (
    assert_allclose_dense_sparse,
    set_random_state,
)
from sklearn.utils.estimator_checks import (
    _is_public_parameter,
    check_is_fitted,
)
from sklearn.utils.estimator_checks import (
    check_estimator as sklearn_check_estimator,
)

from nilearn._base import NilearnBaseEstimator
from nilearn._utils.cache_mixin import CacheMixin
from nilearn._utils.helpers import (
    is_gil_enabled,
    is_matplotlib_installed,
    is_windows_platform,
)
from nilearn._utils.logger import find_stack_level
from nilearn._utils.param_validation import check_is_of_allowed_type
from nilearn._utils.tags import (
    accept_niimg_input,
    accept_surf_img_input,
    is_glm,
    is_masker,
)
from nilearn._utils.testing import write_imgs_to_path
from nilearn._utils.versions import SKLEARN_LT_1_6, compare_version
from nilearn.conftest import (
    _affine_eye,
    _affine_mni,
    _drop_surf_img_part,
    _flip_surf_img,
    _img_3d_mni,
    _img_3d_ones,
    _img_3d_rand,
    _img_3d_zeros,
    _img_4d_rand_eye,
    _img_4d_rand_eye_medium,
    _img_mask_mni,
    _make_surface_img,
    _make_surface_img_and_design,
    _make_surface_mask,
    _rng,
    _shape_3d_default,
    _shape_3d_large,
    _surf_img_1d,
    _surf_mask_1d,
)
from nilearn.connectome import GroupSparseCovariance, GroupSparseCovarianceCV
from nilearn.connectome.connectivity_matrices import ConnectivityMeasure
from nilearn.decoding.decoder import Decoder, FREMClassifier, _BaseDecoder
from nilearn.decoding.searchlight import SearchLight
from nilearn.decoding.space_net import BaseSpaceNet, SpaceNetClassifier
from nilearn.decoding.tests.test_same_api import to_niimgs
from nilearn.decomposition._base import _BaseDecomposition
from nilearn.decomposition.tests.conftest import (
    _decomposition_img,
    _decomposition_mesh,
)
from nilearn.exceptions import DimensionError, MeshDimensionError
from nilearn.glm.first_level import FirstLevelModel
from nilearn.glm.second_level import SecondLevelModel
from nilearn.image import get_data, index_img, new_img_like
from nilearn.image.image import check_imgs_equal
from nilearn.maskers import (
    MultiNiftiMapsMasker,
    NiftiLabelsMasker,
    NiftiMapsMasker,
    NiftiMasker,
    NiftiSpheresMasker,
    SurfaceLabelsMasker,
    SurfaceMapsMasker,
    SurfaceMasker,
)
from nilearn.maskers._mixin import _MultiMixin
from nilearn.maskers.tests.test_html_report import (
    generate_and_check_masker_report,
)
from nilearn.masking import load_mask_img
from nilearn.regions import HierarchicalKMeans, Parcellations, RegionExtractor
from nilearn.regions.rena_clustering import ReNA
from nilearn.surface import SurfaceImage
from nilearn.surface.surface import get_data as get_surface_data
from nilearn.surface.utils import assert_surface_image_equal

SKLEARN_GTE_1_5 = compare_version(sklearn_version, ">=", "1.5.0")
SKLEARN_GTE_1_6 = compare_version(sklearn_version, ">=", "1.6.0")


def nilearn_dir() -> Path:
    return Path(__file__).parents[1]


def check_estimator(
    estimators: list[NilearnBaseEstimator], valid: bool = True
):
    """Yield a valid or invalid sklearn estimators check.

    ONLY USED FOR sklearn<1.6

    As some of Nilearn estimators do not comply
    with sklearn recommendations
    (cannot fit Numpy arrays, do input validation in the constructor...)
    we cannot directly use
    sklearn.utils.estimator_checks.check_estimator.

    So this is a home made generator that yields an estimator instance
    along with a
    - valid check from sklearn: those should stay valid
    - or an invalid check that is known to fail.

    See this section rolling-your-own-estimator in
    the scikit-learn doc for more info:
    https://scikit-learn.org/stable/developers/develop.html

    Parameters
    ----------
    estimators : list of estimator object
        Estimator instance to check.

    valid : bool, default=True
        Whether to return only the valid checks or not.
    """
    # TODO (sklearn >= 1.6.0) remove this function
    if not SKLEARN_LT_1_6:  # pragma: no cover
        raise RuntimeError(
            "Use dedicated sklearn utilities to test estimators."
        )

    check_is_of_allowed_type(estimators, (list,), "estimators")

    for est in estimators:
        expected_failed_checks = return_expected_failed_checks(est)

        for e, check in sklearn_check_estimator(
            estimator=est, generate_only=True
        ):
            if not valid and check.func.__name__ in expected_failed_checks:
                yield e, check, check.func.__name__
            if valid and check.func.__name__ not in expected_failed_checks:
                yield e, check, check.func.__name__


def return_expected_failed_checks(
    estimator: NilearnBaseEstimator,
) -> dict[str, str]:
    """Return the expected failures for a given estimator.

    This will say which of the sklearn checks are expected to fail
    for a given nilearn estimator,
    with the reason why or saying what home made check replaces it.

    This is where all the "expected_failed_checks" for all Nilearn estimators
    are centralized.

    "expected_failed_checks" is first created to make sure that all checks
    with the oldest supported sklearn versions pass.

    After the function may tweak the "expected_failed_checks" depending
    on the estimator and sklearn version.

    Returns
    -------
    expected_failed_checks : dict[str, str]
        A dictionary of the form::

            {
                "check_name": "this check is expected to fail because ...",
            }

        Where `"check_name"` is the name of the check, and `"my reason"` is why
        the check fails.
    """
    expected_failed_checks: dict[str, str] = {}

    if isinstance(estimator, ConnectivityMeasure):
        expected_failed_checks = {
            "check_estimator_sparse_data": "remove when dropping sklearn 1.4",
            "check_fit2d_predict1d": "not applicable",
            "check_estimator_sparse_array": "TODO",
            "check_estimator_sparse_matrix": "TODO",
            "check_methods_sample_order_invariance": "TODO",
            "check_methods_subset_invariance": "TODO",
            "check_readonly_memmap_input": "TODO",
            "check_transformer_data_not_an_array": "TODO",
            "check_transformer_general": "TODO",
        }
        if SKLEARN_GTE_1_5:
            expected_failed_checks.pop("check_estimator_sparse_data")
        if SKLEARN_GTE_1_6:
            expected_failed_checks |= {
                "check_transformer_preserve_dtypes": "TODO",
            }

        return expected_failed_checks

    elif isinstance(estimator, (HierarchicalKMeans, ReNA)):
        return expected_failed_checks_clustering()

    elif isinstance(
        estimator, (GroupSparseCovariance, GroupSparseCovarianceCV)
    ):
        expected_failed_checks = {
            "check_estimator_sparse_data": "removed when dropping sklearn 1.4",
            "check_estimator_sparse_array": "TODO",
            "check_estimator_sparse_matrix": "TODO",
            "check_estimator_sparse_tag": "TODO",
        }
        if SKLEARN_GTE_1_5:
            expected_failed_checks.pop("check_estimator_sparse_data")
        if isinstance(estimator, GroupSparseCovarianceCV):
            expected_failed_checks |= {
                "check_estimators_dtypes": "TODO",
                "check_dtype_object": "TODO",
            }
        return expected_failed_checks

    # below this point we should only deal with estimators
    # that accept images as input
    assert accept_niimg_input(estimator) or accept_surf_img_input(estimator)

    if isinstance(estimator, (_BaseDecoder, SearchLight, BaseSpaceNet)):
        return expected_failed_checks_decoders(estimator)

    # keeping track of some of those in
    # https://github.com/nilearn/nilearn/issues/4538
    expected_failed_checks = {
        # the following are skipped
        # because there is nilearn specific replacement
        "check_dict_unchanged": (
            "replaced by check_img_estimator_dict_unchanged"
        ),
        "check_dont_overwrite_parameters": (
            "replaced by check_img_estimator_dont_overwrite_parameters"
        ),
        "check_estimators_dtypes": ("replaced by check_masker_dtypes"),
        "check_estimators_empty_data_messages": (
            "replaced by check_*_empty_data_messages "
        ),
        "check_estimators_fit_returns_self": (
            "replaced by check_fit_returns_self"
        ),
        "check_estimators_overwrite_params": (
            "replaced by check_img_estimator_overwrite_params"
        ),
        "check_estimators_pickle": "replaced by check_img_estimator_pickle",
        "check_fit_check_is_fitted": (
            "replaced by check_img_estimator_fit_check_is_fitted"
        ),
        "check_fit_idempotent": (
            "replaced by check_img_estimator_fit_idempotent"
        ),
        "check_fit_score_takes_y": (
            "replaced by check_img_estimator_fit_score_takes_y"
        ),
        "check_methods_sample_order_invariance": (
            "replaced by check_nilearn_methods_sample_order_invariance"
        ),
        "check_n_features_in": "replaced by check_img_estimator_n_elements",
        "check_n_features_in_after_fitting": (
            "replaced by check_img_estimator_n_elements"
        ),
        "check_pipeline_consistency": (
            "replaced by check_img_estimator_pipeline_consistency"
        ),
        # Those are skipped for now they fail
        # for unknown reasons
        # most often because sklearn inputs expect a numpy array
        # that errors with maskers,
        # or because a suitable nilearn replacement
        # has not yet been created.
        "check_estimators_nan_inf": "TODO",
        "check_methods_subset_invariance": "TODO",
        "check_positive_only_tag_during_fit": "TODO",
        "check_readonly_memmap_input": "TODO",
    }

    expected_failed_checks |= unapplicable_checks()

    if hasattr(estimator, "transform"):
        expected_failed_checks |= {
            "check_transformer_data_not_an_array": (
                "replaced by check_masker_transformer"
            ),
            "check_transformer_general": (
                "replaced by check_masker_transformer"
            ),
            "check_transformer_preserve_dtypes": (
                "replaced by check_masker_transformer"
            ),
        }

    # Adapt some checks for some estimators

    # not entirely sure why some of them pass
    # e.g check_estimator_sparse_data passes for SurfaceLabelsMasker
    # but not SurfaceMasker ????

    if is_glm(estimator):
        expected_failed_checks.pop("check_estimator_sparse_data")
        if SKLEARN_GTE_1_5:
            expected_failed_checks.pop("check_estimator_sparse_matrix")
            expected_failed_checks.pop("check_estimator_sparse_array")
        if SKLEARN_GTE_1_6:
            expected_failed_checks.pop("check_estimator_sparse_tag")

        expected_failed_checks |= {
            # have nilearn replacements
            "check_dict_unchanged": "does not apply - no transform method",
            "check_methods_sample_order_invariance": (
                "does not apply - no relevant method"
            ),
            "check_estimators_dtypes": ("replaced by check_glm_dtypes"),
            "check_estimators_fit_returns_self": (
                "replaced by check_glm_fit_returns_self"
            ),
            "check_fit_check_is_fitted": (
                "replaced by check_img_estimator_fit_check_is_fitted"
            ),
        }

    if isinstance(estimator, (_BaseDecomposition,)):
        expected_failed_checks |= {
            "check_transformer_data_not_an_array": "TODO",
            "check_transformer_general": "TODO",
            "check_transformer_preserve_dtypes": "TODO",
        }
        if SKLEARN_GTE_1_6:
            expected_failed_checks.pop("check_estimator_sparse_tag")

    if is_masker(estimator):
        expected_failed_checks |= {
            "check_n_features_in": (
                "replaced by check_img_estimator_n_elements"
            ),
            "check_n_features_in_after_fitting": (
                "replaced by check_img_estimator_n_elements"
            ),
        }

    return expected_failed_checks


def unapplicable_checks() -> dict[str, str]:
    """Return sklearn checks that do not apply for nilearn estimators \
       when they take images as input.
    """
    return dict.fromkeys(
        [
            "check_complex_data",
            "check_dtype_object",
            "check_estimator_sparse_array",
            "check_estimator_sparse_data",
            "check_estimator_sparse_matrix",
            "check_estimator_sparse_tag",
            "check_f_contiguous_array_estimator",
            "check_fit1d",
            "check_fit2d_1feature",
            "check_fit2d_1sample",
            "check_fit2d_predict1d",
        ],
        "not applicable for image input",
    )


def expected_failed_checks_clustering() -> dict[str, str]:
    expected_failed_checks = {
        "check_estimator_sparse_array": "remove when dropping sklearn 1.4",
        "check_estimator_sparse_matrix": "remove when dropping sklearn 1.4",
        "check_clustering": "TODO",
    }

    if SKLEARN_GTE_1_5:
        expected_failed_checks.pop("check_estimator_sparse_matrix")
        expected_failed_checks.pop("check_estimator_sparse_array")

    return expected_failed_checks


def expected_failed_checks_decoders(estimator) -> dict[str, str]:
    """Return expected failed sklearn checks for nilearn decoders."""
    expected_failed_checks = {
        # the following are have nilearn replacement for masker and/or glm
        # but not for decoders
        "check_dict_unchanged": (
            "replaced by check_img_estimator_dict_unchanged"
        ),
        "check_dont_overwrite_parameters": (
            "replaced by check_img_estimator_dont_overwrite_parameters"
        ),
        "check_estimators_empty_data_messages": (
            "replaced by check_*_empty_data_messages "
        ),
        "check_estimators_fit_returns_self": (
            "replaced by check_fit_returns_self"
        ),
        "check_estimators_overwrite_params": (
            "replaced by check_img_estimator_overwrite_params"
        ),
        "check_estimators_pickle": "replaced by check_img_estimator_pickle",
        "check_fit_check_is_fitted": (
            "replaced by check_img_estimator_fit_check_is_fitted"
        ),
        "check_fit_idempotent": (
            "replaced by check_img_estimator_fit_idempotent"
        ),
        "check_fit_score_takes_y": (
            "replaced by check_img_estimator_fit_score_takes_y"
        ),
        "check_methods_sample_order_invariance": (
            "replaced by check_nilearn_methods_sample_order_invariance"
        ),
        "check_n_features_in": "replaced by check_img_estimator_n_elements",
        "check_n_features_in_after_fitting": (
            "replaced by check_img_estimator_n_elements"
        ),
        "check_pipeline_consistency": (
            "replaced by check_img_estimator_pipeline_consistency"
        ),
        "check_requires_y_none": (
            "replaced by check_img_estimator_requires_y_none"
        ),
        "check_supervised_y_no_nan": (
            "replaced by check_supervised_img_estimator_y_no_nan"
        ),
        # Those are skipped for now they fail
        # for unknown reasons
        # most often because sklearn inputs expect a numpy array
        # that errors with maskers,
        # or because a suitable nilearn replacement
        # has not yet been created.
        "check_estimators_dtypes": "TODO",
        "check_estimators_nan_inf": "TODO",
        "check_methods_subset_invariance": "TODO",
        "check_positive_only_tag_during_fit": "TODO",
        "check_readonly_memmap_input": "TODO",
        "check_supervised_y_2d": "TODO",
    }

    if isinstance(estimator, BaseSpaceNet):
        expected_failed_checks |= {
            "check_non_transformer_estimators_n_iter": ("TODO")
        }

    if is_classifier(estimator):
        expected_failed_checks |= {
            "check_classifier_data_not_an_array": (
                "not applicable for image input"
            ),
            "check_classifiers_classes": "TODO",
            "check_classifiers_one_label": "TODO",
            "check_classifiers_regression_target": "TODO",
            "check_classifiers_train": "TODO",
        }
        if isinstance(estimator, BaseSpaceNet):
            expected_failed_checks |= {
                "check_classifier_multioutput": ("TODO")
            }

    if is_regressor(estimator):
        expected_failed_checks |= {
            "check_regressors_no_decision_function": (
                "replaced by check_img_regressor_no_decision_function"
            ),
            "check_regressor_data_not_an_array": (
                "not applicable for image input"
            ),
            "check_regressor_multioutput": "TODO",
            "check_regressors_int": "TODO",
            "check_regressors_train": "TODO",
        }

    if hasattr(estimator, "transform"):
        expected_failed_checks |= {
            "check_transformer_data_not_an_array": "TODO",
            "check_transformer_general": "TODO",
            "check_transformer_preserve_dtypes": "TODO",
        }

    expected_failed_checks |= unapplicable_checks()

    return expected_failed_checks


def nilearn_check_estimator(estimators: list[NilearnBaseEstimator]):
    check_is_of_allowed_type(estimators, (list,), "estimators")
    for est in estimators:
        for e, check in nilearn_check_generator(estimator=est):
            yield e, check, check.__name__


def nilearn_check_generator(estimator: NilearnBaseEstimator):
    """Yield (estimator, check) tuples.

    This will yield only the nilearn specific checks
    for a nilearn estimator.

    Each nilearn check can be run on an initialized estimator.
    """
    tags = estimator.__sklearn_tags__()

    # TODO (sklearn >= 1.6.0) simplify
    #  for sklearn >= 1.6 tags are always a dataclass
    if isinstance(tags, dict) and "X_types" in tags:
        requires_y = isinstance(estimator, _BaseDecoder)
    else:
        requires_y = getattr(tags.target_tags, "required", False)

    yield (clone(estimator), check_doc_attributes)
    yield (clone(estimator), check_set_output)
    yield (clone(estimator), check_tags)
    yield (clone(estimator), check_verbose)
    yield (clone(estimator), check_doc_link)

    if isinstance(estimator, CacheMixin):
        yield (clone(estimator), check_img_estimator_cache_warning)

    if accept_niimg_input(estimator) or accept_surf_img_input(estimator):
        yield (clone(estimator), check_fit_returns_self)
        yield (clone(estimator), check_img_estimator_dict_unchanged)
        yield (clone(estimator), check_img_estimator_dont_overwrite_parameters)
        yield (clone(estimator), check_img_estimator_fit_check_is_fitted)
        yield (clone(estimator), check_img_estimator_fit_idempotent)
        yield (clone(estimator), check_img_estimator_overwrite_params)
        yield (clone(estimator), check_img_estimator_pickle)
        yield (clone(estimator), check_img_estimator_fit_score_takes_y)
        yield (clone(estimator), check_img_estimator_n_elements)
        yield (clone(estimator), check_img_estimator_pipeline_consistency)
        yield (clone(estimator), check_img_estimator_standardization)
        yield (clone(estimator), check_img_estimator_verbose)
        yield (clone(estimator), check_nilearn_methods_sample_order_invariance)

        if requires_y:
            yield (clone(estimator), check_img_estimator_requires_y_none)

        if is_classifier(estimator) or is_regressor(estimator):
            yield (clone(estimator), check_supervised_img_estimator_y_no_nan)
            yield (clone(estimator), check_decoder_empty_data_messages)
            yield (clone(estimator), check_decoder_compatibility_mask_image)
            yield (clone(estimator), check_decoder_with_surface_data)
            yield (clone(estimator), check_decoder_with_arrays)
            if is_regressor(estimator):
                yield (
                    clone(estimator),
                    check_img_regressor_no_decision_function,
                )

    if is_masker(estimator):
        yield (clone(estimator), check_masker_clean_kwargs)
        yield (clone(estimator), check_masker_compatibility_mask_image)
        yield (clone(estimator), check_masker_dtypes)
        yield (clone(estimator), check_masker_empty_data_messages)
        yield (clone(estimator), check_masker_fit_with_empty_mask)
        yield (
            clone(estimator),
            check_masker_fit_with_non_finite_in_mask,
        )
        yield (clone(estimator), check_masker_generate_report)
        yield (clone(estimator), check_masker_generate_report_constant)
        yield (clone(estimator), check_masker_generate_report_false)
        yield (clone(estimator), check_masker_inverse_transform)
        yield (clone(estimator), check_masker_joblib_cache)
        yield (clone(estimator), check_masker_mask_img)
        yield (clone(estimator), check_masker_mask_img_from_imgs)
        yield (clone(estimator), check_masker_no_mask_no_img)
        yield (clone(estimator), check_masker_refit)
        yield (clone(estimator), check_masker_smooth)
        yield (clone(estimator), check_masker_standardization)
        yield (clone(estimator), check_masker_transform_resampling)
        yield (clone(estimator), check_masker_transformer)
        yield (
            clone(estimator),
            check_masker_transformer_high_variance_confounds,
        )

        if isinstance(estimator, NiftiMasker):
            # TODO enforce for other maskers
            yield (clone(estimator), check_masker_shelving)

        yield (clone(estimator), check_masker_clean)
        yield (clone(estimator), check_masker_detrending)

        if not isinstance(estimator, _MultiMixin):
            yield (clone(estimator), check_masker_transformer_sample_mask)
            yield (clone(estimator), check_masker_with_confounds)

        else:
            yield (
                clone(estimator),
                check_multimasker_generate_report,
            )
            yield (
                clone(estimator),
                check_multimasker_transformer_high_variance_confounds,
            )
            yield (
                clone(estimator),
                check_multimasker_transformer_sample_mask,
            )
            yield (clone(estimator), check_multimasker_with_confounds)

            if isinstance(estimator, NiftiMasker):
                # TODO enforce for other maskers
                yield (clone(estimator), check_multi_nifti_masker_shelving)

        if accept_niimg_input(estimator):
            yield (clone(estimator), check_nifti_masker_dtype)
            yield (clone(estimator), check_nifti_masker_fit_transform)
            yield (clone(estimator), check_nifti_masker_fit_transform_5d)
            yield (clone(estimator), check_nifti_masker_fit_transform_files)
            yield (clone(estimator), check_nifti_masker_fit_with_3d_mask)
            yield (
                clone(estimator),
                check_nifti_masker_generate_report_after_fit_with_only_mask,
            )

        else:
            yield (clone(estimator), check_surface_masker_fit_transform_errors)
            yield (
                clone(estimator),
                check_surface_masker_list_surf_images_no_mask,
            )
            yield (
                clone(estimator),
                check_surface_masker_list_surf_images_with_mask,
            )

    if is_glm(estimator):
        yield (clone(estimator), check_glm_dtypes)
        yield (clone(estimator), check_glm_empty_data_messages)


def _not_fitted_error_message(estimator) -> str:
    return (
        f"This {estimator.__class__.__name__} instance is not fitted yet. "
        "Call 'fit' with appropriate arguments before using this estimator."
    )


def generate_data_to_fit(estimator: NilearnBaseEstimator):
    if is_glm(estimator):
        data, design_matrices = _make_surface_img_and_design()
        return data, design_matrices

    elif isinstance(estimator, SearchLight):
        n_samples = 30
        data = _rng().random((5, 5, 5, n_samples))
        # Create a condition array, with balanced classes
        y = np.arange(n_samples, dtype=int) >= (n_samples // 2)

        data[2, 2, 2, :] = 0
        data[2, 2, 2, y] = 2
        X = Nifti1Image(data, np.eye(4))

        return X, y

    elif is_classifier(estimator):
        dim = 5
        X, y = make_classification(
            n_samples=30,
            n_features=dim**3,
            scale=3.0,
            n_informative=5,
            n_classes=2,
            random_state=42,
            shift=100,
        )
        X, _ = to_niimgs(X, [dim, dim, dim])
        return X, y

    elif is_regressor(estimator):
        dim = 5
        X, y = make_regression(
            n_samples=30,
            n_features=dim**3,
            n_informative=dim,
            noise=1.5,
            bias=1.0,
            random_state=42,
        )
        X = StandardScaler().fit_transform(X)
        X, _ = to_niimgs(X, [dim, dim, dim])
        return X, y

    elif is_masker(estimator):
        if accept_niimg_input(estimator):
            imgs = Nifti1Image(_rng().random(_shape_3d_large()), _affine_eye())
        else:
            imgs = _make_surface_img(10)
        return imgs, None

    elif isinstance(estimator, _BaseDecomposition):
        decomp_input = _decomposition_img(
            data_type="surface",
            rng=_rng(),
            mesh=_decomposition_mesh(),
            with_activation=True,
        )
        return decomp_input, None

    elif not (
        accept_niimg_input(estimator) or accept_surf_img_input(estimator)
    ):
        return _rng().random((5, 5)), None

    else:
        imgs = Nifti1Image(_rng().random(_shape_3d_large()), _affine_eye())
        return imgs, None


def fit_estimator(estimator: NilearnBaseEstimator) -> NilearnBaseEstimator:
    """Fit on a nilearn estimator with appropriate input and return it."""
    X, y = generate_data_to_fit(estimator)

    if is_glm(estimator):
        # FirstLevel
        if hasattr(estimator, "hrf_model"):
            return estimator.fit(X, design_matrices=y)
        # SecondLevel
        else:
            return estimator.fit(X, design_matrix=y)

    elif (
        isinstance(estimator, SearchLight)
        or is_classifier(estimator)
        or is_regressor(estimator)
    ):
        return estimator.fit(X, y)

    else:
        return estimator.fit(X)


# ------------------ GENERIC CHECKS ------------------


def check_tags(estimator_orig) -> None:
    """Check tags are the same with old and new methods.

    TODO (sklearn >= 1.6) remove this check when bumping sklearn above 1.5
    """
    estimator = clone(estimator_orig)

    old_tags = estimator._more_tags()
    new_tags = estimator.__sklearn_tags__()
    assert old_tags == new_tags


def check_verbose(estimator) -> None:
    """Check verbose.

    All estimators should have:
    - verbose set to 0
    - a default verbose == 0
    """
    assert estimator.verbose == 0

    signature = dict(**inspect.signature(estimator.__init__).parameters)
    default_verbose = signature["verbose"].default
    assert default_verbose == 0


def check_set_output(estimator_orig) -> None:
    """Check that set_ouput can be used."""
    if not hasattr(estimator_orig, "transform") or isinstance(
        estimator_orig, (SearchLight, ReNA)
    ):
        return

    if isinstance(
        estimator_orig, (_BaseDecomposition, ConnectivityMeasure, _MultiMixin)
    ):
        with pytest.raises(NotImplementedError):
            estimator_orig.set_output(transform="pandas")
        return

    estimator = clone(estimator_orig)
    estimator = fit_estimator(estimator)

    img, _ = generate_data_to_fit(estimator)

    signal = estimator.transform(img)
    if isinstance(estimator, _BaseDecomposition):
        assert isinstance(signal[0], np.ndarray)
    else:
        assert isinstance(signal, np.ndarray)

    estimator.set_output(transform="pandas")
    signal = estimator.transform(img)
    assert isinstance(signal, pd.DataFrame)

    estimator.set_output(transform="polars")
    # if user wants to output to polars,
    # sklearn will raise error if it's not installed
    with pytest.raises(ImportError, match="requires polars to be installed"):
        signal = estimator.transform(img)

    # check on 1D image for estimators that accepts surface
    if accept_surf_img_input(estimator_orig):
        estimator = clone(estimator_orig)
        estimator = fit_estimator(estimator)
        estimator.set_output(transform="pandas")
        signal = estimator.transform(img)
        assert isinstance(signal, pd.DataFrame)


def check_doc_attributes(estimator) -> None:
    """Check that parameters and attributes are documented.

    - Public parameters should be documented.
    - Attributes should be in same order as in __init__()
    - All documented parameters should exist after init.
    - Fitted attributes (ending with a "_") should be documented.
    - All documented fitted attributes should exist after fit.
    """
    doc = NumpyDocString(estimator.__doc__)
    for section in ["Parameters", "Attributes"]:
        if section not in doc:
            raise ValueError(
                f"Estimator {estimator.__class__.__name__} "
                f"has no '{section} section."
            )

    if not is_gil_enabled():
        pytest.xfail("May fail without the GIL")

    # check public attributes before fit
    parameters = [x for x in estimator.__dict__ if not x.startswith("_")]
    documented_parameters = {
        param.name: param.type for param in doc["Parameters"]
    }
    undocumented_parameters = [
        param for param in parameters if param not in documented_parameters
    ]
    if undocumented_parameters:
        msg = (
            "Missing docstring for parameters "
            f"[{', '.join(undocumented_parameters)}] "
            f"in estimator {estimator.__class__.__name__}."
        )
        if not isinstance(estimator, Parcellations):
            raise ValueError(msg)
        else:
            warnings.warn(msg, stacklevel=2)
    extra_parameters = [
        attr
        for attr in documented_parameters
        if attr not in parameters and attr != "kwargs"
    ]
    if extra_parameters:
        raise ValueError(
            "Extra docstring for parameters "
            f"[{', '.join(extra_parameters)}] "
            f"in estimator {estimator.__class__.__name__}."
        )

    # avoid duplicates
    assert len(documented_parameters) == len(set(documented_parameters))

    verbose_doc = documented_parameters["verbose"]
    assert "default=0" in verbose_doc

    # Attributes should be in same order as in __init__()
    tmp = dict(**inspect.signature(estimator.__init__).parameters)

    assert [str(x) for x in documented_parameters] == [str(x) for x in tmp], (
        f"Parameters of {estimator.__class__.__name__} "
        f"should be in order\n{list(tmp)}. "
        f"Got\n{list(documented_parameters)}"
    )

    if isinstance(estimator, (ReNA, GroupSparseCovarianceCV)):
        # TODO
        # adapt fit_estimator to handle ReNA and GroupSparseCovarianceCV
        return

    # check fitted attributes after fit
    fitted_estimator = fit_estimator(estimator)

    fitted_attributes = [
        x
        for x in fitted_estimator.__dict__
        if x.endswith("_") and not x.startswith("_")
    ]

    documented_attributes: dict[str, str] = {
        attr.name: attr.type for attr in doc["Attributes"]
    }
    undocumented_attributes: list[str] = [
        attr for attr in fitted_attributes if attr not in documented_attributes
    ]
    if undocumented_attributes:
        raise ValueError(
            "Missing docstring for attributes "
            f"[{', '.join(undocumented_attributes)}] "
            f"in estimator {estimator.__class__.__name__}."
        )

    extra_attributes = [
        attr for attr in documented_attributes if attr not in fitted_attributes
    ]
    if extra_attributes:
        msg = (
            "Extra docstring for attributes "
            f"[{', '.join(extra_attributes)}] "
            f"in estimator {estimator.__class__.__name__}."
        )
        if not isinstance(estimator, Parcellations):
            raise ValueError(msg)
        else:
            warnings.warn(msg, stacklevel=2)

    # avoid duplicates
    assert len(documented_attributes) == len(set(documented_attributes))

    # nice to have
    # if possible attributes should be in alphabetical order
    # not always possible as sometimes doc string are composed from
    # nilearn._utils.docs
    if list(documented_attributes) != sorted(documented_attributes):
        warnings.warn(
            (
                f"Attributes of {estimator.__class__.__name__} "
                f"should be in order {sorted(documented_attributes)}. "
                f"Got {list(documented_attributes)}"
            ),
            stacklevel=find_stack_level(),
        )


def check_doc_link(estimator_orig) -> None:
    """Check that _get_doc_link provides the correct link to the doc.

    All estimators but the GLM ones follow the same pattern.

    Parameters
    ----------
    estimator_orig : a Nilearn estimator instance
    """
    estimator = clone(estimator_orig)
    estimator_name = estimator.__class__.__name__

    modules = estimator.__class__.__module__.split(".")

    extra = r"\."
    if isinstance(estimator, FirstLevelModel):
        extra = r"\.first_level\."
    elif isinstance(estimator, SecondLevelModel):
        extra = r"\.second_level\."

    expected_pattern = (
        rf"https://nilearn\.github\.io/"
        rf"(dev|stable|\d+\.\d+\.\d+)/modules/generated/"
        rf"nilearn\.{modules[1]}{extra}{estimator_name}\.html"
    )

    doc_link = estimator._get_doc_link()

    assert re.fullmatch(expected_pattern, doc_link), (
        f"Doc link '{doc_link}' does not match expected pattern "
        f"'{expected_pattern}'"
    )


# ------------------ GENERIC IMG ESTIMATORS CHECKS ------------------


def _check_mask_img_(estimator):
    if accept_niimg_input(estimator):
        assert isinstance(estimator.mask_img_, Nifti1Image)
    else:
        assert isinstance(estimator.mask_img_, SurfaceImage)
    load_mask_img(estimator.mask_img_)


def check_img_estimator_verbose(estimator_orig) -> None:
    """Check verbose behavior.

    All estimators:
    - should be quiet by default (when verbose == 0)
    - should say something (anything) when verbose > 0
    - check that outputs are the same for
      - verbose False and verbose = 0
      - verbose True and verbose = 1
    - verbose 2 should have more than output verbose 1
    """
    estimator = clone(estimator_orig)

    if not is_gil_enabled():
        pytest.xfail("May fail without the GIL")

    buffer = io.StringIO()
    with contextlib.redirect_stdout(buffer):
        fit_estimator(estimator)
    output = buffer.getvalue()
    assert output == ""

    # verbose False == verbose 0
    estimator = clone(estimator_orig)
    estimator.verbose = False

    buffer = io.StringIO()
    with contextlib.redirect_stdout(buffer):
        fit_estimator(estimator)
    output_false = buffer.getvalue()
    assert output == output_false

    estimator = clone(estimator_orig)
    estimator.verbose = 1

    buffer = io.StringIO()
    with contextlib.redirect_stdout(buffer):
        fit_estimator(estimator)
    output = buffer.getvalue()
    assert output != ""

    if isinstance(estimator, SearchLight):
        # TODO
        pytest.skip("Some failures with SearchLight.")

    # verbose True == verbose 1
    # should mostly be the same except for object reference
    estimator.verbose = True

    buffer = io.StringIO()
    with contextlib.redirect_stdout(buffer):
        fit_estimator(estimator)
    output_true = buffer.getvalue()
    if os.getenv("CI") is None:
        # when running locally the output
        # can be easily 'cleaned' to be compared
        assert _sanitize_standard_output(
            output_true
        ) == _sanitize_standard_output(output), (
            f"\n{_sanitize_standard_output(output_true)=}"
            f"\n{_sanitize_standard_output(output)=}"
        )

    # verbose 2 should have more than output verbose 1
    estimator = clone(estimator_orig)
    estimator.verbose = 2

    buffer = io.StringIO()
    with contextlib.redirect_stdout(buffer):
        fit_estimator(estimator)
    output_2 = buffer.getvalue()
    if os.getenv("CI") is not None and isinstance(
        estimator, SurfaceMapsMasker
    ):
        # For SurfaceMapsMasker the output is harder to sanitize in CI
        return
    assert len(output_2) >= len(output), f"\n{output=}\n{output_2=}"


def _sanitize_standard_output(output):
    output = re.sub(
        r"<nibabel.nifti1.Nifti1Image object at .*>", "Nifti1Image", output
    )
    output = re.sub(r", .* seconds remaining", ", X seconds remaining", output)
    output = re.sub(
        r"Time Elapsed: .* seconds", "Time Elapsed: X seconds", output
    )
    output = re.sub(r"done in .* seconds", "done in X seconds", output)
    return output


def check_img_estimator_fit_check_is_fitted(estimator_orig) -> None:
    """Check appropriate response to check_fitted from sklearn before fitting.

    Should act as a replacement in the case of the maskers
    for sklearn's check_fit_check_is_fitted

    check that before fitting
    - transform() and inverse_transform() \
      throw same error
    - estimator has a __sklearn_is_fitted__ method and does not return True
    - running sklearn check_is_fitted on estimator throws an error

    check that after fitting
    - __sklearn_is_fitted__ returns True
    - running sklearn check_is_fitted throws no error
    """
    estimator = clone(estimator_orig)

    if not hasattr(estimator, "__sklearn_is_fitted__"):
        raise TypeError(
            "All nilearn estimators must have __sklearn_is_fitted__ method."
        )

    if estimator.__sklearn_is_fitted__() is True:
        raise ValueError(
            "Estimator __sklearn_is_fitted__ must return False before fit."
        )

    with pytest.raises(ValueError, match=_not_fitted_error_message(estimator)):
        check_is_fitted(estimator)

    # Failure should happen before the input type is determined
    # so we can pass nifti image (or array) to surface maskers.
    signals = np.ones((10, 11))
    methods_to_check = [
        "transform",
        "transform_single_imgs",
        "transform_imgs",
        "inverse_transform",
        "decision_function",
        "score",
        "predict",
    ]
    method_input = [_img_3d_rand(), _img_3d_rand(), [_img_3d_rand()], signals]
    for meth, input in zip(methods_to_check, method_input, strict=False):
        method = getattr(estimator, meth, None)
        if method is None:
            continue
        with pytest.raises(
            ValueError, match=_not_fitted_error_message(estimator)
        ):
            method(input)

    estimator = fit_estimator(estimator)

    assert estimator.__sklearn_is_fitted__()

    check_is_fitted(estimator)


def check_nilearn_methods_sample_order_invariance(estimator_orig) -> None:
    """Check method gives invariant results \
        if applied on a subset with different sample order.

    Replace sklearn check_methods_sample_order_invariance.
    """
    estimator = clone(estimator_orig)

    set_random_state(estimator)

    estimator = fit_estimator(estimator)

    X, _ = generate_data_to_fit(estimator)

    n_samples = 30
    idx = _rng().permutation(n_samples)

    if isinstance(X, SurfaceImage):
        data = {
            x: _rng().random((v.shape[0], n_samples))
            for x, v in X.data.parts.items()
        }
        new_X = new_img_like(X, data=data)

    elif isinstance(X, Nifti1Image):
        data = _rng().random((*X.shape[:3], n_samples))

        new_X = new_img_like(X, data=data)

    for method in [
        "predict",
        "transform",
        "decision_function",
        "score_samples",
        "predict_proba",
    ]:
        if (
            isinstance(estimator, (SearchLight, _BaseDecomposition))
            and method == "transform"
        ):
            # TODO
            continue

        msg = (
            f"'{method}' of {estimator.__class__.__name__} "
            "is not invariant when applied to a dataset "
            "with different sample order."
        )

        if hasattr(estimator, method):
            assert_allclose_dense_sparse(
                getattr(estimator, method)(index_img(new_X, idx)),
                _safe_indexing(getattr(estimator, method)(new_X), idx),
                atol=1e-9,
                err_msg=msg,
            )


def check_estimator_set_output(estimator_orig) -> None:
    """Check that set_ouput can be used."""
    if not hasattr(estimator_orig, "transform") or isinstance(
        estimator_orig, (SearchLight, ReNA)
    ):
        return

    if isinstance(
        estimator_orig, (_BaseDecomposition, ConnectivityMeasure, _MultiMixin)
    ):
        with pytest.raises(NotImplementedError):
            estimator_orig.set_output(transform="pandas")
        return

    estimator = clone(estimator_orig)
    estimator = fit_estimator(estimator)

    img, _ = generate_data_to_fit(estimator)

    signal = estimator.transform(img)
    if isinstance(estimator, _BaseDecomposition):
        assert isinstance(signal[0], np.ndarray)
    else:
        assert isinstance(signal, np.ndarray)

    estimator.set_output(transform="pandas")
    signal = estimator.transform(img)
    assert isinstance(signal, pd.DataFrame)

    estimator.set_output(transform="polars")
    # if user wants to output to polars,
    # sklearn will raise error if it's not installed
    with pytest.raises(ImportError, match="requires polars to be installed"):
        signal = estimator.transform(img)

    # check on 1D image for estimators that accepts surface
    if accept_surf_img_input(estimator_orig):
        estimator = clone(estimator_orig)
        estimator = fit_estimator(estimator)
        estimator.set_output(transform="pandas")
        signal = estimator.transform(img)
        assert isinstance(signal, pd.DataFrame)


def check_fit_returns_self(estimator_orig) -> None:
    """Check maskers return itself after fit.

    Replace sklearn check_estimators_fit_returns_self
    """
    estimator = clone(estimator_orig)

    fitted_estimator = fit_estimator(estimator)
    assert fitted_estimator is estimator


def check_img_estimator_doc_attributes(estimator_orig) -> None:
    """Check that parameters and attributes are documented.

    - Public parameters should be documented.
    - Attributes should be in same order as in __init__()
    - All documented parameters should exist after init.
    - Fitted attributes (ending with a "_") should be documented.
    - All documented fitted attributes should exist after fit.
    """
    estimator = clone(estimator_orig)

    doc = NumpyDocString(estimator.__doc__)
    for section in ["Parameters", "Attributes"]:
        if section not in doc:
            raise ValueError(
                f"Estimator {estimator.__class__.__name__} "
                f"has no '{section} section."
            )

    # check public attributes before fit
    parameters = [x for x in estimator.__dict__ if not x.startswith("_")]
    documented_parameters = {
        param.name: param.type for param in doc["Parameters"]
    }
    undocumented_parameters = [
        param for param in parameters if param not in documented_parameters
    ]
    if undocumented_parameters:
        raise ValueError(
            "Missing docstring for "
            f"[{', '.join(undocumented_parameters)}] "
            f"in estimator {estimator.__class__.__name__}."
        )
    extra_parameters = [
        attr
        for attr in documented_parameters
        if attr not in parameters and attr != "kwargs"
    ]
    if extra_parameters:
        raise ValueError(
            "Extra docstring for "
            f"[{', '.join(extra_parameters)}] "
            f"in estimator {estimator.__class__.__name__}."
        )

    # avoid duplicates
    assert len(documented_parameters) == len(set(documented_parameters))

    # Attributes should be in same order as in __init__()
    tmp = dict(**inspect.signature(estimator.__init__).parameters)

    assert [str(x) for x in documented_parameters] == [str(x) for x in tmp], (
        f"Parameters of {estimator.__class__.__name__} "
        f"should be in order {list(tmp)}. "
        f"Got {list(documented_parameters)}"
    )

    if isinstance(estimator, (ReNA, GroupSparseCovarianceCV)):
        # TODO
        # adapt fit_estimator to handle ReNA and GroupSparseCovarianceCV
        return

    # check fitted attributes after fit
    fitted_estimator = fit_estimator(estimator)

    fitted_attributes = [
        x
        for x in fitted_estimator.__dict__
        if x.endswith("_") and not x.startswith("_")
    ]

    documented_attributes: dict[str, str] = {
        attr.name: attr.type for attr in doc["Attributes"]
    }
    undocumented_attributes: list[str] = [
        attr for attr in fitted_attributes if attr not in documented_attributes
    ]
    if undocumented_attributes:
        raise ValueError(
            "Missing docstring for "
            f"[{', '.join(undocumented_attributes)}] "
            f"in estimator {estimator.__class__.__name__}."
        )

    extra_attributes = [
        attr for attr in documented_attributes if attr not in fitted_attributes
    ]
    if extra_attributes:
        raise ValueError(
            "Extra docstring for "
            f"[{', '.join(extra_attributes)}] "
            f"in estimator {estimator.__class__.__name__}."
        )

    # avoid duplicates
    assert len(documented_attributes) == len(set(documented_attributes))

    # nice to have
    # if possible attributes should be in alphabetical order
    # not always possible as sometimes doc string are composed from
    # nilearn._utils.docs
    if list(documented_attributes) != sorted(documented_attributes):
        warnings.warn(
            (
                f"Attributes of {estimator.__class__.__name__} "
                f"should be in order {sorted(documented_attributes)}. "
                f"Got {list(documented_attributes)}"
            ),
            stacklevel=find_stack_level(),
        )


def check_img_estimator_dont_overwrite_parameters(estimator_orig) -> None:
    """Check that fit method only changes or sets private attributes.

    Only for estimator that work with images.

    Replaces check_dont_overwrite_parameters from sklearn.
    """
    estimator = clone(estimator_orig)

    set_random_state(estimator, 1)

    dict_before_fit = estimator.__dict__.copy()

    fitted_estimator = fit_estimator(estimator)

    dict_after_fit = fitted_estimator.__dict__

    public_keys_after_fit = [
        key for key in dict_after_fit if _is_public_parameter(key)
    ]

    attrs_added_by_fit = [
        key for key in public_keys_after_fit if key not in dict_before_fit
    ]

    # check that fit doesn't add any public attribute
    assert not attrs_added_by_fit, (
        f"Estimator {estimator.__class__.__name__} "
        "adds public attribute(s) during"
        " the fit method."
        " Estimators are only allowed to add private attributes"
        " either started with _ or ended"
        f" with _ but [{', '.join(attrs_added_by_fit)}] added"
    )

    # check that fit doesn't change any public attribute
    attrs_changed_by_fit = [
        key
        for key in public_keys_after_fit
        if (dict_before_fit[key] is not dict_after_fit[key])
    ]

    assert not attrs_changed_by_fit, (
        f"Estimator {estimator.__class__.__name__} "
        "changes public attribute(s) during"
        " the fit method. Estimators are only allowed"
        " to change attributes started"
        " or ended with _, but"
        f" [{', '.join(attrs_changed_by_fit)}] changed"
    )


def check_img_estimator_cache_warning(estimator_orig) -> None:
    """Check estimator behavior with caching.

    Make sure some warnings are thrown at the appropriate time.
    """
    estimator = clone(estimator_orig)

    assert hasattr(estimator, "memory")
    assert hasattr(estimator, "memory_level")

    if hasattr(estimator, "smoothing_fwhm"):
        # to avoid not supported warnings
        estimator.smoothing_fwhm = None

    X, _ = generate_data_to_fit(estimator)
    if isinstance(estimator, NiftiSpheresMasker):
        # NiftiSpheresMasker needs mask_img to do some caching during fit
        mask_img = new_img_like(X, np.ones(X.shape[:3]))
        estimator.mask_img = mask_img

    # ensure warnings are NOT thrown
    for memory, memory_level in zip([None, "tmp"], [0, 1], strict=False):
        estimator = clone(estimator)
        estimator.memory = memory
        estimator.memory_level = memory_level

        with warnings.catch_warnings(record=True) as warning_list:
            fit_estimator(estimator)
            if is_masker(estimator):
                # some maskers only cache during transform
                estimator.transform(X)
            elif isinstance(estimator, SecondLevelModel):
                # second level only cache during contrast computation
                estimator.compute_contrast(np.asarray([1]))
        assert all(
            "memory_level is currently set to 0 but a Memory object"
            not in str(x.message)
            for x in warning_list
        )

    # ensure warning are thrown
    estimator = clone(estimator)
    with TemporaryDirectory() as tmp_dir:
        estimator.memory = tmp_dir
        estimator.memory_level = 0

        with pytest.warns(
            UserWarning,
            match="memory_level is currently set to 0 but a Memory object",
        ):
            fit_estimator(estimator)
            if is_masker(estimator):
                # some maskers also cache during transform
                estimator.transform(X)
            elif isinstance(estimator, SecondLevelModel):
                # second level only cache during contrast computation
                estimator.compute_contrast(np.asarray([1]))


def check_img_estimator_fit_idempotent(estimator_orig) -> None:
    """Check that est.fit(X) is the same as est.fit(X).fit(X).

    So we check that
    predict(), decision_function() and transform() return
    the same results.

    replaces sklearn check_fit_idempotent
    """
    check_methods = ["predict", "transform", "decision_function"]

    for method in check_methods:
        if not hasattr(estimator_orig, method) or (
            isinstance(estimator_orig, FREMClassifier)
            and method == "decision_function"
        ):
            # TODO
            # Fails for FREMClassifier
            # mostly on Mac and sometimes linux
            continue

        estimator = clone(estimator_orig)

        X, _ = generate_data_to_fit(estimator)

        # Fit for the first time
        set_random_state(estimator)
        estimator = fit_estimator(estimator)

        result = getattr(estimator, method)(X)

        # Fit again
        set_random_state(estimator)
        estimator = fit_estimator(estimator)

        new_result = getattr(estimator, method)(X)

        if hasattr(new_result, "dtype") and np.issubdtype(
            new_result.dtype, np.floating
        ):
            tol = 2 * np.finfo(new_result.dtype).eps
        else:
            tol = 2 * np.finfo(np.float64).eps

        # TODO
        # some estimator can return some pretty different results
        # investigate why
        if isinstance(estimator, Decoder):
            tol = 1e-5
        elif isinstance(estimator, SearchLight):
            tol = 1e-4
        elif isinstance(estimator, FREMClassifier):
            tol = 0.1

        assert_allclose_dense_sparse(
            result,
            new_result,
            atol=max(tol, 1e-9),
            rtol=max(tol, 1e-7),
            err_msg=f"Idempotency check failed for method {method}",
        )


def check_img_estimator_overwrite_params(estimator_orig) -> None:
    """Check that we do not change or mutate the internal state of input.

    Replaces sklearn check_estimators_overwrite_params
    """
    estimator = clone(estimator_orig)

    # Make a physical copy of the original estimator parameters before fitting.
    params = estimator.get_params()
    original_params = deepcopy(params)

    # Fit the model
    fitted_estimator = fit_estimator(estimator)

    # Compare the state of the model parameters with the original parameters
    new_params = fitted_estimator.get_params()

    for param_name, original_value in original_params.items():
        new_value = new_params[param_name]

        # We should never change or mutate the internal state of input
        # parameters by default. To check this we use the joblib.hash function
        # that introspects recursively any subobjects to compute a checksum.
        # The only exception to this rule of immutable constructor parameters
        # is possible RandomState instance but in this check we explicitly
        # fixed the random_state params recursively to be integer seeds.
        assert hash(new_value) == hash(original_value), (
            f"Estimator {estimator.__class__.__name__} "
            "should not change or mutate "
            f"the parameter {param_name} from {original_value} "
            f"to {new_value} during fit."
        )


def check_img_estimator_dict_unchanged(estimator_orig) -> None:
    """Replace check_dict_unchanged from sklearn.

    Several methods should not change the dict of the object.
    """
    estimator = clone(estimator_orig)

    estimator = fit_estimator(estimator)

    dict_before = estimator.__dict__.copy()

    input_img, y = generate_data_to_fit(estimator)

    for method in [
        "predict",
        "transform",
        "decision_function",
        "score",
        "generate_report",
    ]:
        if not hasattr(estimator, method):
            continue

        if method == "transform" and isinstance(estimator, _BaseDecomposition):
            getattr(estimator, method)([input_img])
        elif method == "score":
            getattr(estimator, method)(input_img, y)
        elif method == "generate_report":
            # TODO
            # fix for free threaded python
            if not is_gil_enabled():
                pytest.xfail("May fail without the GIL")
            getattr(estimator, method)()
        else:
            getattr(estimator, method)(input_img)

        dict_after = estimator.__dict__

        # TODO NiftiLabelsMasker is modified at transform time
        # see issue https://github.com/nilearn/nilearn/issues/2720
        if isinstance(estimator, (NiftiLabelsMasker)):
            with pytest.raises(AssertionError):
                assert dict_after == dict_before
        else:
            # The following try / except is mostly
            # to give more informative error messages when this check fails.
            try:
                assert dict_after == dict_before
            except AssertionError as e:
                unmatched_keys = set(dict_after.keys()) ^ set(
                    dict_before.keys()
                )
                if len(unmatched_keys) > 0:
                    raise ValueError(
                        "Estimator changes '__dict__' keys "
                        f"during '{method}'.\n"
                        f"{unmatched_keys} \n"
                    ) from e

                difference = {}
                for x in dict_before:
                    if type(dict_before[x]) is not type(dict_after[x]):
                        difference[x] = {
                            "before": dict_before[x],
                            "after": dict_after[x],
                        }
                        continue
                    if (
                        isinstance(dict_before[x], np.ndarray)
                        and not np.array_equal(dict_before[x], dict_after[x])
                        and not check_imgs_equal(dict_before[x], dict_after[x])
                    ) or (
                        not isinstance(
                            dict_before[x], (np.ndarray, Nifti1Image)
                        )
                        and dict_before[x] != dict_after[x]
                    ):
                        difference[x] = {
                            "before": dict_before[x],
                            "after": dict_after[x],
                        }
                        continue
                if difference:
                    raise ValueError(
                        "Estimator changes the following '__dict__' keys \n"
                        f"during '{method}'.\n"
                        f"{difference}"
                    ) from e
                else:
                    raise e
            except Exception as e:
                raise e


def check_img_estimator_pickle(estimator_orig) -> None:
    """Test that we can pickle all estimators.

    Adapted from sklearn's check_estimators_pickle
    """
    estimator = clone(estimator_orig)

    X, y = generate_data_to_fit(estimator)

    if isinstance(estimator, NiftiSpheresMasker):
        # NiftiSpheresMasker needs mask_img to run inverse_transform
        mask_img = new_img_like(X, np.ones(X.shape[:3]))
        estimator.mask_img = mask_img

    set_random_state(estimator)

    fitted_estimator = fit_estimator(estimator)

    pickled_estimator = pickle.dumps(fitted_estimator)
    unpickled_estimator = pickle.loads(pickled_estimator)

    result = {}

    check_methods = ["transform"]
    input_data = [X] if isinstance(estimator, SearchLight) else [[X]]

    for method in ["predict", "decision_function"]:
        check_methods.append(method)
        input_data.append(X)

    check_methods.append("score")
    input_data.append((X, y))

    if hasattr(estimator, "inverse_transform"):
        check_methods.append("inverse_transform")
        signal = _rng().random((1, fitted_estimator.n_elements_))
        if isinstance(estimator, _BaseDecomposition):
            signal = [signal]
        input_data.append(signal)

    for method, input in zip(check_methods, input_data, strict=False):
        if hasattr(estimator, method):
            result["input"] = input
            if method == "score":
                result[method] = getattr(estimator, method)(*input)
            else:
                result[method] = getattr(estimator, method)(input)

    for method, input in zip(check_methods, input_data, strict=False):
        if method not in result:
            continue

        if method == "score":
            unpickled_result = getattr(unpickled_estimator, method)(*input)
        else:
            unpickled_result = getattr(unpickled_estimator, method)(input)

        if isinstance(unpickled_result, np.ndarray):
            if isinstance(estimator, SearchLight):
                # TODO check why Searchlight has lower absolute tolerance
                ...
            else:
                assert_allclose_dense_sparse(result[method], unpickled_result)
        elif isinstance(unpickled_result, SurfaceImage):
            assert_surface_image_equal(result[method], unpickled_result)
        elif isinstance(unpickled_result, Nifti1Image):
            check_imgs_equal(result[method], unpickled_result)


def check_img_estimator_pipeline_consistency(estimator_orig) -> None:
    """Check pipeline consistency for nilearn estimators.

    Substitute for sklearn check_pipeline_consistency.
    """
    estimator = clone(estimator_orig)

    X, y = generate_data_to_fit(estimator)

    if isinstance(estimator, NiftiSpheresMasker):
        # NiftiSpheresMasker needs mask_img to run inverse_transform
        mask_img = new_img_like(X, np.ones(X.shape[:3]))
        estimator.mask_img = mask_img

    set_random_state(estimator)

    pipeline = make_pipeline(estimator)

    if is_glm(estimator):
        # FirstLevel
        if hasattr(estimator, "hrf_model"):
            pipeline.fit(X, firstlevelmodel__design_matrices=y)
        # SecondLevel
        else:
            pipeline.fit(X, secondlevelmodel__design_matrix=y)

    elif (
        isinstance(estimator, SearchLight)
        or is_classifier(estimator)
        or is_regressor(estimator)
    ):
        pipeline.fit(X, y)

    else:
        pipeline.fit(X)

    funcs = ["score", "transform"]

    for func_name in funcs:
        func = getattr(estimator, func_name, None)
        if func is not None:
            func_pipeline = getattr(pipeline, func_name)
            if func_name == "transform":
                result = func(X)
                result_pipe = func_pipeline(X)
            else:
                result = func(X, y)
                result_pipe = func_pipeline(X, y)
            if isinstance(estimator, SearchLight) and func_name == "transform":
                # TODO flaky test
                # SearchLight transform seem to return
                # slightly different results
                ...
            else:
                assert_allclose_dense_sparse(result, result_pipe)


def check_img_estimator_requires_y_none(estimator_orig) -> None:
    """Check estimator with requires_y=True fails gracefully for y=None.

    Replaces sklearn check_requires_y_none
    """
    estimator = clone(estimator_orig)

    expected_err_msgs = "requires y to be passed, but the target y is None"
    shape = (5, 5, 5) if isinstance(estimator, SearchLight) else (30, 31, 32)
    input_img = Nifti1Image(_rng().random(shape), _affine_eye())
    try:
        estimator.fit(input_img, None)
    except ValueError as ve:
        if all(msg not in str(ve) for msg in expected_err_msgs):
            raise ve


def check_img_estimator_fit_score_takes_y(estimator_orig) -> None:
    """Replace sklearn check_fit_score_takes_y for maskers.

    Check that all estimators accept an (optional) y
    in fit / fit_transform and score so they can be used in pipelines.

    For decoders, y is not optional
    """
    estimator = clone(estimator_orig)

    if is_glm(estimator):
        # GLM estimators take no "y" at all.
        return

    for attr in ["fit", "fit_transform", "score"]:
        if not hasattr(estimator, attr):
            continue

        if is_classifier(estimator) or is_regressor(estimator):
            tmp = {
                k: v.default
                for k, v in inspect.signature(
                    getattr(estimator, attr)
                ).parameters.items()
                if v.default is inspect.Parameter.empty
            }
            if "y" not in tmp:
                raise ValueError(
                    f"{estimator.__class__.__name__} "
                    f"is missing parameter 'y' for the method '{attr}'."
                )

        else:
            tmp = {
                k: v.default
                for k, v in inspect.signature(
                    getattr(estimator, attr)
                ).parameters.items()
                if v.default is not inspect.Parameter.empty
            }
            if "y" not in tmp:
                raise ValueError(
                    f"{estimator.__class__.__name__} "
                    f"is missing 'y=None' for the method '{attr}'."
                )
            assert tmp["y"] is None


def check_img_estimator_n_elements(estimator_orig) -> None:
    """Check n_elements is set during fitting and used after that.

    check that after fitting
    - n_elements_ does not exist before fit
    - masker have a n_elements_ attribute that is positive int

    replaces sklearn "check_n_features_in" and
    "check_n_features_in_after_fitting"
    """
    estimator = clone(estimator_orig)

    assert not hasattr(estimator, "n_features_in_")

    estimator = fit_estimator(estimator)

    assert hasattr(estimator, "n_elements_")
    assert (
        isinstance(estimator.n_elements_, (int, np.integer))
        and estimator.n_elements_ > 0
    ), f"Got {estimator.n_elements_=}"

    for method in [
        "decision_function",
        "inverse_transform",
        "predict",
        "score",  # TODO
        "transform",  # TODO
    ]:
        if not hasattr(estimator, method):
            continue

        X = _rng().random((1, estimator.n_elements_ + 1))
        if method == "inverse_transform":
            if isinstance(estimator, _BaseDecomposition) and not isinstance(
                estimator, Parcellations
            ):
                X = [X]
            with pytest.raises(
                ValueError,
                match=r"Input to 'inverse_transform' has wrong shape.",
            ):
                getattr(estimator, method)(X)
        elif method in ["predict", "decision_function"]:
            with pytest.raises(
                ValueError,
                match=r"X has .* features per sample; expecting .*",
            ):
                getattr(estimator, method)(X)


def check_img_estimator_standardization(estimator_orig) -> None:
    """Check non-masker estimator with several value for standardize.

    Check that the different values give different results.

    For _BaseDecomposition derived classes
    this is mostly only a smoke test.
    """
    if not hasattr(estimator_orig, "standardize") or is_masker(estimator_orig):
        # maskers have their own tests
        return

    # TODO
    # fix for free threaded python
    if not is_gil_enabled():
        pytest.xfail("May fail without the GIL")

    # We need to use data with several samples
    # for the standardize to actually do something
    input_img, y = generate_data_to_fit(estimator_orig)

    for method in ["predict", "transform"]:
        if not hasattr(estimator_orig, method):
            continue

        estimator = clone(estimator_orig)

        if is_classifier(estimator) or is_regressor(estimator):
            estimator.fit(input_img, y)
        else:
            estimator.fit(input_img)

        results = {}
        standardize_values = [
            "zscore",
            "zscore_sample",
            "psc",
            True,
            False,
            None,
        ]
        for standardize in standardize_values:
            if standardize == "psc" and isinstance(
                estimator, _BaseDecomposition
            ):
                # FIXME flaky test
                # psc with _BaseDecomposition
                # sometimes leads to an array of inf / nan
                continue

            estimator = clone(estimator_orig)

            estimator.standardize = standardize

            if is_classifier(estimator) or is_regressor(estimator):
                estimator.fit(input_img, y)
            else:
                estimator.fit(input_img)

            # TODO (nilearn >= 0.14.0) adapt if necessary
            # Make sure that a FutureWarning warning is thrown
            # and not one during call to fit and then call to clean.
            if standardize in ["zscore", True]:
                with warnings.catch_warnings(record=True) as warnings_list:
                    results[str(standardize)] = getattr(estimator, method)(
                        input_img
                    )
                if not isinstance(estimator, _BaseDecomposition):
                    n_future_warnings = len(
                        [
                            x
                            for x in warnings_list
                            if issubclass(x.category, FutureWarning)
                        ]
                    )
                    assert n_future_warnings == 1
            else:
                results[str(standardize)] = getattr(estimator, method)(
                    input_img
                )

        unstandarized_result = results[str(None)]

        if isinstance(estimator, _BaseDecomposition):
            # TODO
            return

        # check which options are equal or different
        assert_array_equal(results["zscore"], results[str(True)])
        try:
            assert_array_equal(results[str(None)], results[str(False)])
        except AssertionError:
            # FIXME
            print(f"Flaky test for {estimator.__class__.__name__}?")

        if isinstance(estimator, Decoder):
            # differences are too small to have an effect in this test
            return

        if not isinstance(estimator, (FREMClassifier, SpaceNetClassifier)):
            # differences are too small to have an effect in this test
            with pytest.raises(AssertionError):
                assert_array_equal(results["zscore"], results["zscore_sample"])
        for x in ["zscore_sample", "zscore", "psc"]:
            with pytest.raises(AssertionError):
                assert_array_equal(unstandarized_result, results[x])
        with pytest.raises(AssertionError):
            assert_array_equal(results["psc"], results["zscore_sample"])
        with pytest.raises(AssertionError):
            assert_array_equal(results["psc"], results["zscore"])


# ------------------ DECODERS CHECKS ------------------


def check_supervised_img_estimator_y_no_nan(estimator_orig) -> None:
    """Check estimator fails if y contains nan or inf.

    Replaces sklearn check_supervised_y_no_nan
    """
    estimator = clone(estimator_orig)

    dim = 5
    if isinstance(estimator, SearchLight):
        n_samples = 30
        # Create a condition array, with balanced classes
        y = np.arange(n_samples, dtype=int) >= (n_samples // 2)

        data = _rng().random((dim, dim, dim, n_samples))
        data[2, 2, 2, :] = 0
        data[2, 2, 2, y] = 2
        X = Nifti1Image(data, np.eye(4))

    else:
        # we can use classification data even for regressors
        # because fit should fail early
        X, y = make_classification(
            n_samples=20,
            n_features=dim**3,
            scale=3.0,
            n_informative=5,
            n_classes=2,
            random_state=42,
        )
        X, _ = to_niimgs(X, [dim, dim, dim])

    y = _rng().random(y.shape)

    for value in [np.inf, np.nan]:
        y[5,] = value
        with pytest.raises(ValueError, match=r"Input .*contains"):
            estimator.fit(X, y)


def check_decoder_empty_data_messages(estimator_orig):
    """Check that empty images are caught properly.

    Run on both surfance and volume data.

    Replaces sklearn check_estimators_empty_data_messages.
    """
    estimator = clone(estimator_orig)

    n_samples = 30
    if isinstance(estimator, (SearchLight, BaseSpaceNet)):
        # SearchLight, BaseSpaceNet do not support surface data directly
        return None

    # we can use classification data even for regressors
    # because fit should fail early
    dim = 5
    _, y = make_classification(
        n_samples=20,
        n_features=dim**3,
        scale=3.0,
        n_informative=5,
        n_classes=2,
        random_state=42,
    )

    data = np.zeros((64, 64, 0))
    X = Nifti1Image(data, np.eye(4))

    y = _rng().random(y.shape)

    with pytest.raises(ValueError, match="empty"):
        estimator.fit(X, y)

    estimator = clone(estimator_orig)

    imgs = _make_surface_img(n_samples)
    data = {
        part: np.empty(0).reshape((imgs.data.parts[part].shape[0], 0))
        for part in imgs.data.parts
    }
    X = SurfaceImage(imgs.mesh, data)

    with pytest.raises(ValueError, match="empty"):
        estimator.fit(X, y)


def check_decoder_compatibility_mask_image(estimator_orig):
    """Check compatibility of the mask_img and images for decoders.

    Compatibility should be check for fit, score, predict, decision function.
    """
    if isinstance(estimator_orig, SearchLight):
        # note searchlight does not fit Surface data
        return

    estimator = clone(estimator_orig)

    # fitting volume data when the mask is a surface should fail
    estimator.mask = _make_surface_mask()

    if isinstance(estimator, BaseSpaceNet):
        # TODO: remove when BaseSpaceNet support surface data
        with pytest.raises(
            TypeError, match=("input should be a NiftiLike object")
        ):
            fit_estimator(estimator)
        return

    with pytest.raises(
        TypeError, match=("Mask and input images must be of compatible types")
    ):
        fit_estimator(estimator)

    estimator = clone(estimator_orig)

    X, y = generate_data_to_fit(estimator)
    estimator.fit(X, y)

    # decoders were fitted with nifti images
    # so running the following method with surface image should fail
    for method in ["score", "predict", "decision_function"]:
        if not hasattr(estimator, method):
            continue

        input = (_make_surface_img(3),)
        if method == "score":
            input = (_make_surface_img(3), y)

        with pytest.raises(
            TypeError,
            match=("Mask and input images must be of compatible types"),
        ):
            getattr(estimator, method)(*input)


def check_decoder_with_surface_data(estimator_orig) -> None:
    """Test fit and other methods with surface image."""
    if isinstance(estimator_orig, SearchLight):
        # note searchlight does not fit Surface data
        return

    n_samples = 50
    y = _rng().choice([0, 1], size=n_samples)
    X = _make_surface_img(n_samples)

    estimator = clone(estimator_orig)

    for mask in [None, SurfaceMasker(), _surf_mask_1d(), _make_surface_mask()]:
        estimator.mask = mask
        if hasattr(estimator, "clustering_percentile"):
            # for FREM decoders include all elements
            # to avoid getting 0 clusters to work with
            estimator.clustering_percentile = 100

        if isinstance(estimator, BaseSpaceNet):
            # TODO: remove when BaseSpaceNet support surface data
            with pytest.raises(NotImplementedError):
                estimator.fit(X, y)
            continue

        estimator.fit(X, y)

        assert estimator.coef_ is not None

        for method in ["score", "predict", "decision_function"]:
            if not hasattr(estimator, method):
                continue
            if method == "score":
                getattr(estimator, method)(X, y)
            else:
                getattr(estimator, method)(X)


def check_img_regressor_no_decision_function(regressor_orig) -> None:
    """Check that regressors don't have some method, attributes.

    replaces sklearn check_regressors_no_decision_function
    """
    regressor = clone(regressor_orig)

    X, y = generate_data_to_fit(regressor)

    regressor.fit(X, y)
    attrs = ["decision_function", "classes_", "n_classes_"]
    for attr in attrs:
        assert not hasattr(regressor, attr), (
            f"'{regressor.__class__.__name__}' should not have '{attr}'"
        )


def check_decoder_with_arrays(estimator_orig) -> None:
    """Check that several methods of decoders work with ndarray and images.

    Test for backward compatibility.
    """
    estimator = clone(estimator_orig)

    estimator = fit_estimator(estimator)

    for method in [
        "decision_function",
        "predict",
        "score",
    ]:
        if not hasattr(estimator, method):
            continue

        X, y = generate_data_to_fit(estimator)
        X_as_array = estimator.masker_.transform(X)
        if method == "score":
            result_1 = getattr(estimator, method)(X, y)
            result_2 = getattr(estimator, method)(X_as_array, y)
        else:
            result_1 = getattr(estimator, method)(X)
            result_2 = getattr(estimator, method)(X_as_array)

        assert_array_equal(result_1, result_2)


# ------------------ MASKER CHECKS ------------------


def check_masker_clean_kwargs(estimator_orig) -> None:
    """Check attributes for cleaning.

    Maskers accept a clean_args dict
    and store in clean_args and contains parameters to pass to clean.
    """
    estimator = clone(estimator_orig)

    assert estimator.clean_args is None


def check_masker_detrending(estimator_orig) -> None:
    """Check detrending does something.

    Fit transform on same input should give different results
    if detrend is true or false.
    """
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        input_img = _img_4d_rand_eye_medium()
    else:
        input_img = _make_surface_img(100)

    signal = estimator.fit_transform(input_img)

    estimator.detrend = True
    detrended_signal = estimator.fit_transform(input_img)

    assert_raises(AssertionError, assert_array_equal, detrended_signal, signal)


def check_masker_standardization(estimator_orig) -> None:
    """Check maskers with several value for standardize.

    Check that the different values give different results.
    """
    # We need to use data with several samples
    # for the standardize to actually do something
    n_samples = 100
    # decimal precision for 100 n_samples
    decimal = 2

    # TODO
    # fix for free threaded python
    if not is_gil_enabled():
        pytest.xfail("May fail without the GIL")

    if accept_niimg_input(estimator_orig):
        signals = _rng().standard_normal(
            size=(np.prod(_shape_3d_default()), n_samples)
        )
        means = (
            _rng().standard_normal(size=(np.prod(_shape_3d_default()), 1)) * 50
            + 1000
        )
        signals += means
        input_img = Nifti1Image(
            signals.reshape((*_shape_3d_default(), n_samples)),
            _affine_eye(),
        )
    elif accept_surf_img_input(estimator_orig):
        input_img = _make_surface_img(n_samples)

        estimator = clone(estimator_orig)

        estimator.fit(input_img)

        default_result = estimator.transform(input_img)

        results = {}
        standardize_values = [
            "zscore",
            "zscore_sample",
            "psc",
            True,
            False,
            None,
        ]
        for standardize in standardize_values:
            estimator = clone(estimator_orig)

            estimator.standardize = standardize

            estimator.fit(input_img)

            # TODO (nilearn >= 0.14.0) adapt if necessary
            # Make sure that a FutureWarning warning is thrown
            # and not one during call to fit and then call to clean.
            if standardize in ["zscore", True]:
                with warnings.catch_warnings(record=True) as warnings_list:
                    results[str(standardize)] = estimator.transform(input_img)
                n_future_warnings = len(
                    [
                        x
                        for x in warnings_list
                        if issubclass(x.category, FutureWarning)
                    ]
                )
                assert n_future_warnings == 1
            else:
                results[str(standardize)] = estimator.transform(input_img)

        unstandarized_result = results[str(False)]

        # check which options are equal or different
        assert_array_equal(results["zscore"], results[str(True)])
        assert_array_equal(results[str(None)], results[str(False)])

        with pytest.raises(AssertionError):
            assert_array_equal(results["zscore"], results["zscore_sample"])
        for x in ["zscore_sample", "zscore", "psc"]:
            with pytest.raises(AssertionError):
                assert_array_equal(unstandarized_result, results[x])
        with pytest.raises(AssertionError):
            assert_array_equal(results["psc"], results["zscore_sample"])
        with pytest.raises(AssertionError):
            assert_array_equal(results["psc"], results["zscore"])

        # check output values
        if not is_masker(estimator_orig) or isinstance(
            estimator_orig, RegionExtractor
        ):
            return

        assert_array_equal(default_result, unstandarized_result)

        for x in ["zscore_sample", "zscore"]:
            assert_almost_equal(results[x].mean(0), 0)
            assert_almost_equal(results[x].std(0), 1, decimal=decimal)

        assert_almost_equal(results["psc"].mean(0), 0)
        if not isinstance(estimator, SurfaceMapsMasker):
            assert_almost_equal(
                results["psc"],
                (
                    unstandarized_result / unstandarized_result.mean(0) * 100
                    - 100
                ),
                decimal=1,
            )


def check_masker_compatibility_mask_image(estimator_orig) -> None:
    """Check compatibility of the mask_img and images to masker.

    Compatibility should be check at fit and transform time.

    For nifti maskers this is handled by one the check_nifti functions.
    For surface maskers, check_compatibility_mask_and_images does it.
    But this means we do not have exactly the same error messages.
    """
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        mask_img = _img_mask_mni()
        input_img = _make_surface_img()
    else:
        mask_img = _make_surface_mask()
        input_img = _img_3d_mni()

    estimator.mask_img = mask_img
    with pytest.raises(TypeError):
        estimator.fit(input_img)

    if accept_niimg_input(estimator):
        # using larger images to be compatible
        # with regions extraction tests
        mask = np.zeros(_shape_3d_large(), dtype=np.int8)
        mask[1:-1, 1:-1, 1:-1] = 1
        mask_img = Nifti1Image(mask, _affine_eye())
        image_to_transform = _make_surface_img()
    else:
        mask_img = _make_surface_mask()
        image_to_transform = _img_3d_mni()

    estimator = clone(estimator)
    estimator.mask_img = mask_img
    estimator.fit()
    with pytest.raises(TypeError):
        estimator.transform(image_to_transform)

    _check_mask_img_(estimator)


def check_masker_no_mask_no_img(estimator_orig) -> None:
    """Check maskers mask_img_ when no mask passed at init or imgs at fit.

    For (Multi)NiftiMasker and SurfaceMasker fit should raise ValueError.
    For all other maskers mask_img_ should be None after fit.
    """
    estimator = clone(estimator_orig)

    assert not hasattr(estimator, "mask_img_")
    if isinstance(estimator, (NiftiMasker, SurfaceMasker)):
        with pytest.raises(
            ValueError, match="Parameter 'imgs' must be provided to "
        ):
            estimator.fit()
    else:
        estimator.fit()
        assert estimator.mask_img_ is None


def check_masker_mask_img_from_imgs(estimator_orig) -> None:
    """Check maskers mask_img_ inferred from imgs when no mask is provided.

    For (Multi)NiftiMasker and SurfaceMasker:
    they must have a valid mask_img_ after fit.
    For all other maskers mask_img_ should be None after fit.
    """
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        # Small image with shape=(7, 8, 9) would fail with MultiNiftiMasker
        # giving mask_img_that mask all the data : do not know why!!!
        input_img = Nifti1Image(
            _rng().random(_shape_3d_large()), _affine_mni()
        )

    else:
        input_img = _make_surface_img(2)

    # Except for (Multi)NiftiMasker and SurfaceMasker,
    # maskers have mask_img_ = None after fitting some input image
    # when no mask was passed at construction
    estimator = clone(estimator)
    assert not hasattr(estimator, "mask_img_")

    estimator.fit(input_img)

    if isinstance(estimator, (NiftiMasker, SurfaceMasker)):
        _check_mask_img_(estimator)
    else:
        assert estimator.mask_img_ is None


def check_masker_mask_img(estimator_orig) -> None:
    """Check maskers mask_img_ post fit is valid.

    If a mask is passed at construction,
    then mask_img_ should be a valid mask after fit.

    Maskers should be fittable
    even when passing a non-binary image
    with multiple samples (4D for volume, 2D for surface) as mask.
    Resulting mask_img_ should be binary and have a single sample.
    """
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        # Small image with shape=(7, 8, 9) would fail with MultiNiftiMasker
        # giving mask_img_that mask all the data : do not know why!!!
        mask_data = np.zeros(_shape_3d_large(), dtype="int8")
        mask_data[2:-2, 2:-2, 2:-2] = 1
        binary_mask_img = Nifti1Image(mask_data, _affine_eye())

        input_img = Nifti1Image(
            _rng().random(_shape_3d_large()), _affine_eye()
        )

        non_binary_mask_img = Nifti1Image(
            _rng().random((*_shape_3d_large(), 2)), _affine_eye()
        )

    else:
        binary_mask_img = _make_surface_mask()
        non_binary_mask_img = _make_surface_img()

        input_img = _make_surface_img(2)

    # happy path
    estimator = clone(estimator)
    estimator.mask_img = binary_mask_img
    assert not hasattr(estimator, "mask_img_")

    estimator.fit()

    _check_mask_img_(estimator)

    # use non binary multi-sample image as mask
    estimator = clone(estimator)
    estimator.mask_img = non_binary_mask_img
    assert not hasattr(estimator, "mask_img_")

    estimator.fit()

    _check_mask_img_(estimator)

    # use mask at init and imgs at fit
    # mask at init should prevail
    estimator = clone(estimator)
    estimator.mask_img = binary_mask_img

    estimator.fit()
    ref_mask_img_ = estimator.mask_img_

    estimator = clone(estimator)
    estimator.mask_img = binary_mask_img

    assert not hasattr(estimator, "mask_img_")

    if isinstance(estimator, (NiftiMasker, SurfaceMasker)):
        with pytest.warns(
            UserWarning,
            match=(
                "Generation of a mask has been requested .* "
                "while a mask was given at masker creation."
            ),
        ):
            estimator.fit(input_img)
    else:
        estimator.fit(input_img)

    _check_mask_img_(estimator)
    if accept_niimg_input(estimator):
        assert_array_equal(
            ref_mask_img_.get_fdata(), estimator.mask_img_.get_fdata()
        )
    else:
        assert_array_equal(
            get_surface_data(ref_mask_img_),
            get_surface_data(estimator.mask_img_),
        )


def check_masker_clean(estimator_orig) -> None:
    """Check that cleaning does something on fit transform.

    Fit transform on same input should give different results
    if some cleaning parameters are passed.
    """
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        input_img = _img_4d_rand_eye_medium()
    else:
        input_img = _make_surface_img(100)

    signal = estimator.fit_transform(input_img)

    estimator.t_r = 2.0
    estimator.high_pass = 1 / 128
    estimator.clean_args = {"filter": "cosine"}
    detrended_signal = estimator.fit_transform(input_img)

    assert_raises(AssertionError, assert_array_equal, detrended_signal, signal)


def check_masker_transformer(estimator_orig) -> None:
    """Replace sklearn _check_transformer for maskers.

    - for maskers transform is in the base class and
      implemented via a transform_single_imgs
    - checks that "imgs" (and not X) is the parameter
      for input for fit / transform
    - fit_transform method should work on non fitted estimator
    - fit_transform should give same result as fit then transform
    """
    estimator = clone(estimator_orig)

    # transform_single_imgs should not be an abstract method anymore
    assert not getattr(
        estimator.transform_single_imgs, "__isabstractmethod__", False
    )

    for attr in ["fit", "transform", "fit_transform"]:
        tmp = dict(**inspect.signature(getattr(estimator, attr)).parameters)
        assert next(iter(tmp)) == "imgs"
        assert "X" not in tmp

    if accept_niimg_input(estimator):
        input_img = _img_4d_rand_eye_medium()
    else:
        input_img = _make_surface_img(100)

    signal_1 = estimator.fit_transform(input_img)

    estimator = clone(estimator)
    signal_2 = estimator.fit(input_img).transform(input_img)

    assert_array_equal(signal_1, signal_2)


def check_masker_transformer_high_variance_confounds(estimator_orig):
    """Check high_variance_confounds use in maskers.

    Make sure that using high_variance_confounds returns different result.

    Ensure that high_variance_confounds can be used with regular confounds,
    and that results are different than when just using the confounds alone.
    """
    estimator = clone(estimator_orig)

    length = 10

    if accept_niimg_input(estimator):
        data = _rng().random((*_shape_3d_default(), length))
        input_img = Nifti1Image(data, _affine_eye())
    else:
        input_img = _make_surface_img(length)

    estimator.high_variance_confounds = False

    signal = estimator.fit_transform(input_img)

    estimator = clone(estimator)
    estimator.high_variance_confounds = True

    signal_hvc = estimator.fit_transform(input_img)

    assert_raises(AssertionError, assert_array_equal, signal, signal_hvc)

    with TemporaryDirectory() as tmp_dir:
        array = _rng().random((length, 3))

        dataframe = pd.DataFrame(array)

        tmp_dir = Path(tmp_dir)
        dataframe.to_csv(tmp_dir / "confounds.csv")

        for c in [array, dataframe, tmp_dir / "confounds.csv"]:
            confounds = [c] if isinstance(estimator, _MultiMixin) else c

            estimator = clone(estimator)
            estimator.high_variance_confounds = False
            signal_c = estimator.fit_transform(input_img, confounds=confounds)

            estimator = clone(estimator)
            estimator.high_variance_confounds = True
            signal_c_hvc = estimator.fit_transform(
                input_img, confounds=confounds
            )

            assert_raises(
                AssertionError, assert_array_equal, signal_c, signal_c_hvc
            )


def check_masker_transformer_sample_mask(estimator_orig):
    """Check sample_mask use in maskers.

    Make sure that using sample_mask returns different result
    compare to when it's not used.

    Try different types of sample_mask
    that always keep the same samples (sample 1, 2 and 4)
    that should all return the same thing.
    """
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        input_img = _img_4d_rand_eye()
    else:
        input_img = _make_surface_img(5)

    estimator.fit(input_img)
    signal_1 = estimator.transform(input_img, sample_mask=None)

    assert signal_1.ndim == 2

    # index sample to keep
    sample_mask = np.asarray([1, 2, 4])

    signal_2 = estimator.transform(input_img, sample_mask=sample_mask)

    assert signal_2.shape[0] == 3

    assert_raises(AssertionError, assert_array_equal, signal_1, signal_2)

    # logical indexing
    n_sample = signal_1.shape[0]
    sample_mask = np.full((n_sample,), True)
    np.put(sample_mask, [0, 3], [False, False])

    signal_3 = estimator.transform(input_img, sample_mask=sample_mask)

    assert_array_equal(signal_2, signal_3)

    # list of explicit index
    sample_mask = [[1, 2, 4]]

    signal_4 = estimator.transform(input_img, sample_mask=sample_mask)

    assert_array_equal(signal_2, signal_4)

    # list of logical index
    sample_mask = [[False, True, True, False, True]]

    signal_5 = estimator.transform(input_img, sample_mask=sample_mask)

    assert_array_equal(signal_2, signal_5)


def check_masker_with_confounds(estimator_orig):
    """Test fit_transform with confounds.

    Check different types of confounds
    (array, dataframe, str or path to txt, csv, tsv)
    and ensure results is different
    than when not using confounds.

    Check proper errors are raised if file is not found
    or if confounds do not match signal length.

    For more tests see those of signal.clean.
    """
    estimator = clone(estimator_orig)

    length = 20
    if accept_niimg_input(estimator):
        input_img = Nifti1Image(
            _rng().random((4, 5, 6, length)), affine=_affine_eye()
        )
    else:
        input_img = _make_surface_img(length)

    signal_1 = estimator.fit_transform(input_img, confounds=None)

    array = _rng().random((length, 3))

    dataframe = pd.DataFrame(array)

    confounds_path = nilearn_dir() / "tests" / "data" / "spm_confounds.txt"

    for confounds in [array, dataframe, confounds_path, str(confounds_path)]:
        signal_2 = estimator.fit_transform(input_img, confounds=confounds)

        assert_raises(AssertionError, assert_array_equal, signal_1, signal_2)

    with TemporaryDirectory() as tmp_dir:
        tmp_dir = Path(tmp_dir)
        dataframe.to_csv(tmp_dir / "confounds.csv")
        signal_2 = estimator.fit_transform(
            input_img, confounds=tmp_dir / "confounds.csv"
        )

        assert_raises(AssertionError, assert_array_equal, signal_1, signal_2)

        dataframe.to_csv(tmp_dir / "confounds.tsv", sep="\t")
        signal_2 = estimator.fit_transform(
            input_img, confounds=tmp_dir / "confounds.tsv"
        )

        assert_raises(AssertionError, assert_array_equal, signal_1, signal_2)

    with pytest.raises(FileNotFoundError):
        estimator.fit_transform(input_img, confounds="not_a_file.txt")

    with pytest.raises(
        ValueError, match="Confound signal has an incorrect length"
    ):
        estimator.fit_transform(
            input_img, confounds=_rng().random((length * 2, 3))
        )


def check_masker_refit(estimator_orig):
    """Check masker can be refitted and give different results."""
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        # using larger images to be compatible
        # with regions extraction tests
        mask = np.zeros(_shape_3d_large(), dtype=np.int8)
        mask[1:-1, 1:-1, 1:-1] = 1
        mask_img_1 = Nifti1Image(mask, _affine_eye())

        mask = np.zeros(_shape_3d_large(), dtype=np.int8)
        mask[3:-3, 3:-3, 3:-3] = 1
        mask_img_2 = Nifti1Image(mask, _affine_eye())
    else:
        mask_img_1 = _make_surface_mask()
        data = {
            part: np.ones(mask_img_1.data.parts[part].shape)
            for part in mask_img_1.data.parts
        }
        mask_img_2 = SurfaceImage(mask_img_1.mesh, data)

    estimator.mask_img = mask_img_1
    estimator.fit()
    fitted_mask_1 = estimator.mask_img_

    estimator.mask_img = mask_img_2
    estimator.fit()
    fitted_mask_2 = estimator.mask_img_

    if accept_niimg_input(estimator):
        with pytest.raises(AssertionError):
            assert_array_equal(
                fitted_mask_1.get_fdata(), fitted_mask_2.get_fdata()
            )
    else:
        with pytest.raises(ValueError):
            assert_surface_image_equal(fitted_mask_1, fitted_mask_2)


def check_masker_empty_data_messages(estimator_orig):
    """Check that empty images are caught properly.

    Replaces sklearn check_estimators_empty_data_messages.
    """
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        data = np.zeros((64, 64, 0))
        imgs = Nifti1Image(data, np.eye(4))
        mask = np.ones(_shape_3d_large())
        mask_img = Nifti1Image(mask, affine=_affine_eye())
    else:
        imgs = _make_surface_img()
        data = {
            part: np.empty(0).reshape((imgs.data.parts[part].shape[0], 0))
            for part in imgs.data.parts
        }
        imgs = SurfaceImage(imgs.mesh, data)

        mask_img = _make_surface_mask()

    with pytest.raises(ValueError, match="The image is empty"):
        estimator.fit(imgs)

    estimator.mask_img = mask_img
    estimator.fit()
    with pytest.raises(ValueError, match="The image is empty"):
        estimator.transform(imgs)


def check_masker_fit_with_empty_mask(estimator_orig) -> None:
    """Check mask that excludes all voxels raise an error."""
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        mask_img = _img_3d_zeros()
        imgs = [_img_3d_rand()]
    else:
        mask_img = _make_surface_mask()
        for k, v in mask_img.data.parts.items():
            mask_img.data.parts[k] = np.zeros(v.shape)
        imgs = _make_surface_img(1)

    estimator.mask_img = mask_img

    with pytest.raises(
        ValueError,
        match="The mask is invalid as it is empty: it masks all data",
    ):
        estimator.fit(imgs)


def check_masker_fit_with_non_finite_in_mask(estimator_orig) -> None:
    """Check mask with non finite values can be used with maskers.

    - Warning is thrown.
    - Output of transform must contain only finite values.
    """
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        # _shape_3d_large() is used,
        # this test would fail for RegionExtractor otherwise
        mask = np.ones(_shape_3d_large())
        mask[:, :, 7] = np.nan
        mask[:, :, 4] = np.inf
        mask_img = Nifti1Image(mask, affine=_affine_eye())

        imgs = _img_3d_rand()

    else:
        mask_img = _make_surface_mask()
        for k, v in mask_img.data.parts.items():
            mask_img.data.parts[k] = np.zeros(v.shape)
        mask_img.data.parts["left"][0:3, 0] = [np.nan, np.inf, 1]
        mask_img.data.parts["right"][0:3, 0] = [np.nan, np.inf, 1]

        imgs = _make_surface_img(1)

    estimator.mask_img = mask_img
    with pytest.warns(UserWarning, match="Non-finite values detected."):
        estimator.fit()

    signal = estimator.transform(imgs)
    assert np.all(np.isfinite(signal))


def check_masker_dtypes(estimator_orig) -> None:
    """Check masker can fit/transform with inputs of varying dtypes.

    Replacement for sklearn check_estimators_dtypes.

    np.int64 not tested: see no_int64_nifti in nilearn/conftest.py
    """
    estimator = clone(estimator_orig)

    length = 20
    for dtype in [np.float32, np.float64, np.int32]:
        estimator = clone(estimator)

        if accept_niimg_input(estimator):
            data = np.zeros((*_shape_3d_large(), length))
            data[1:28, 1:28, 1:28, ...] = (
                _rng().random((27, 27, 27, length)) + 2.0
            )
            imgs = Nifti1Image(data.astype(dtype), affine=_affine_eye())

        else:
            imgs = _make_surface_img(length)
            for k, v in imgs.data.parts.items():
                imgs.data.parts[k] = v.astype(dtype)

        estimator.fit(imgs)
        estimator.transform(imgs)


def check_masker_smooth(estimator_orig) -> None:
    """Check that masker can smooth data when extracting.

    Check that masker instance has smoothing_fwhm attribute.
    Check that output is different with and without smoothing.

    For Surface maskers:
    - Check smoothing on surface maskers raises NotImplemented warning.
    - Check that output is the same with and without smoothing.
    TODO: update once smoothing is implemented.
    """
    estimator = clone(estimator_orig)

    assert hasattr(estimator, "smoothing_fwhm")

    if accept_niimg_input(estimator):
        imgs = _img_3d_rand()
    else:
        n_sample = 1
        imgs = _make_surface_img(n_sample)

    signal = estimator.fit_transform(imgs)

    estimator.smoothing_fwhm = 3
    estimator.fit(imgs)

    if accept_niimg_input(estimator):
        smoothed_signal = estimator.transform(imgs)

        assert_raises(
            AssertionError, assert_array_equal, smoothed_signal, signal
        )

    else:
        with pytest.warns(UserWarning, match="not yet supported"):
            smoothed_signal = estimator.transform(imgs)

        assert_array_equal(smoothed_signal, signal)


def check_masker_inverse_transform(estimator_orig) -> None:
    """Check output of inverse_transform.

    For signal with 1 or more samples.

    For nifti maskers:
        - 1D arrays -> 3D images
        - 2D arrays -> 4D images

    For surface maskers:
        - 1D arrays -> 1D images
        - 2D arrays -> 2D images

    Check that running transform() is not required to run inverse_transform().

    Check that running inverse_transform() before and after running transform()
    give same result.
    """
    estimator = clone(estimator_orig)

    if accept_niimg_input(estimator):
        # using different shape for imgs, mask
        # to force resampling
        input_shape = (28, 29, 30)
        imgs = Nifti1Image(_rng().random(input_shape), _affine_eye())

        mask_shape = (15, 16, 17)
        mask_img = Nifti1Image(np.ones(mask_shape), _affine_eye())

        if isinstance(estimator, NiftiSpheresMasker):
            tmp = mask_img.shape
        else:
            tmp = input_shape
        expected_shapes = [tmp, (*tmp, 1), (*tmp, 10)]

    else:
        imgs = _make_surface_img(1)

        mask_img = _make_surface_mask()

        expected_shapes = [
            (imgs.shape[0],),
            (imgs.shape[0], 1),
            (imgs.shape[0], 10),
        ]

    for i, expected_shape in enumerate(
        expected_shapes,
    ):
        estimator = clone(estimator)

        if isinstance(estimator, (NiftiSpheresMasker)):
            estimator.mask_img = mask_img

        estimator.fit(imgs)

        if i == 0:
            signals = _rng().random((estimator.n_elements_,))
        elif i == 1:
            signals = _rng().random((1, estimator.n_elements_))
        elif i == 2:
            signals = _rng().random((10, estimator.n_elements_))

        new_imgs = estimator.inverse_transform(signals)

        if accept_niimg_input(estimator):
            actual_shape = new_imgs.shape
            assert_array_almost_equal(imgs.affine, new_imgs.affine)
        else:
            actual_shape = new_imgs.data.shape
        assert actual_shape == expected_shape

        # same result before and after running transform()
        estimator.transform(imgs)

        new_imgs_2 = estimator.inverse_transform(signals)

        if accept_niimg_input(estimator):
            assert check_imgs_equal(new_imgs, new_imgs_2)
        else:
            assert_surface_image_equal(new_imgs, new_imgs_2)


def check_masker_transform_resampling(estimator_orig) -> None:
    """Check transform / inverse_transform for maskers with resampling.

    Similar to check_masker_inverse_transform
    but for nifti masker that can do some resampling
    (labels and maps maskers).

    Check that output has the shape of the data or the labels/maps image
    depending on which resampling_target was requested at init.

    Check that using a mask does not affect shape of output.

    Check that running transform() is not required to run inverse_transform().

    Check that running inverse_transform() before and after running transform()
    give same result.

    Check that running transform on images with different fov
    than those used at fit is possible.
    """
    estimator = clone(estimator_orig)

    if not hasattr(estimator, "resampling_target"):
        return None

    # using different shape for imgs, mask
    # to force resampling
    n_sample = 10
    input_shape = (28, 29, 30, n_sample)
    imgs = Nifti1Image(_rng().random(input_shape), _affine_eye())

    imgs2 = Nifti1Image(_rng().random((20, 21, 22)), _affine_eye())

    mask_shape = (15, 16, 17)
    mask_img = Nifti1Image(np.ones(mask_shape), _affine_eye())

    # TODO
    # fix for free threaded python
    if not is_gil_enabled():
        pytest.xfail("May fail without the GIL")

    for resampling_target in ["data", "labels"]:
        expected_shape = input_shape
        if resampling_target == "labels":
            if isinstance(estimator, NiftiMapsMasker):
                expected_shape = (*estimator.maps_img.shape[:3], n_sample)
                resampling_target = "maps"
            else:
                expected_shape = (*estimator.labels_img.shape, n_sample)

        for mask in [None, mask_img]:
            estimator = clone(estimator)
            estimator.resampling_target = resampling_target
            estimator.mask_img = mask

            # no resampling warning at fit time
            with warnings.catch_warnings(record=True) as warning_list:
                estimator.fit(imgs)
            assert all(
                "at transform time" not in str(x.message) for x in warning_list
            )

            signals = _rng().random((n_sample, estimator.n_elements_))

            new_imgs = estimator.inverse_transform(signals)

            assert_array_almost_equal(imgs.affine, new_imgs.affine)
            actual_shape = new_imgs.shape
            assert actual_shape == expected_shape

            # no resampling warning when using same imgs as for fit()
            with warnings.catch_warnings(record=True) as warning_list:
                estimator.transform(imgs)
            assert all(
                "at transform time" not in str(x.message) for x in warning_list
            )

            # same result before and after running transform()
            new_imgs_2 = estimator.inverse_transform(signals)

            assert check_imgs_equal(new_imgs, new_imgs_2)

            # no error transforming an image with different fov
            # than the one used at fit time,
            # but there should be a resampling warning
            # we are resampling to data
            with warnings.catch_warnings(record=True) as warning_list:
                estimator.transform(imgs2)

            if resampling_target == "data":
                assert any(
                    "at transform time" in str(x.message) for x in warning_list
                )
            else:
                assert all(
                    "at transform time" not in str(x.message)
                    for x in warning_list
                )


def check_masker_shelving(estimator_orig) -> None:
    """Check behavior when shelving masker."""
    estimator = clone(estimator_orig)

    if is_windows_platform():
        # TODO (run check without xdist)
        # rare failure of this test on python on windows
        #   PermissionError: [WinError 32]
        #   The process cannot access the file
        #   because it is being used by another process
        return

    img, _ = generate_data_to_fit(estimator)

    estimator.verbose = 0

    masker = clone(estimator)

    epi = masker.fit_transform(img)

    with TemporaryDirectory() as tmp_dir:
        masker_shelved = clone(estimator)

        masker_shelved.memory = Memory(location=tmp_dir, mmap_mode="r")
        masker_shelved._shelving = True

        epi_shelved = masker_shelved.fit_transform(img)

        epi_shelved = epi_shelved.get()

        assert_array_equal(epi_shelved, epi)


def check_masker_joblib_cache(estimator_orig) -> None:
    """Check cached data."""
    estimator = clone(estimator_orig)

    img, _ = generate_data_to_fit(estimator)

    if accept_niimg_input(estimator):
        mask_img = new_img_like(img, np.ones(img.shape[:3]))
    else:
        mask_img = _make_surface_mask()

    estimator.mask_img = mask_img
    estimator.fit(img)

    mask_hash = hash(estimator.mask_img_)

    if accept_niimg_input(estimator):
        get_data(estimator.mask_img_)
    else:
        get_surface_data(estimator.mask_img_)

    assert mask_hash == hash(estimator.mask_img_)

    # Test a tricky issue with memmapped joblib.memory that makes
    # imgs return by inverse_transform impossible to save
    if accept_niimg_input(estimator):
        cachedir = Path(mkdtemp())
        estimator.memory = Memory(location=cachedir, mmap_mode="r")
        X = estimator.transform(img)

        # inverse_transform a first time, so that the result is cached
        out_img = estimator.inverse_transform(X)

        out_img = estimator.inverse_transform(X)

        out_img.to_filename(cachedir / "test.nii")


# ------------------ SURFACE MASKER CHECKS ------------------


def check_surface_masker_fit_transform_errors(estimator_orig) -> None:
    """Check fit / transform errors.

    Check 'shape' errors between images to fit and mask.
    """
    estimator = clone(estimator_orig)

    mask_img = _make_surface_mask()

    imgs = _make_surface_img(5)

    estimator = clone(estimator)
    estimator.mask_img = mask_img

    # errors
    with pytest.raises(
        MeshDimensionError,
        match=r"Number of vertices do not match for between meshes.",
    ):
        estimator.fit(_flip_surf_img(imgs))
    with pytest.raises(
        MeshDimensionError,
        match=r"Number of vertices do not match for between meshes.",
    ):
        estimator.fit(imgs)
        estimator.transform(_flip_surf_img(imgs))

    with pytest.raises(
        MeshDimensionError, match=r"PolyMeshes do not have the same keys."
    ):
        estimator.fit(_drop_surf_img_part(imgs))
    with pytest.raises(
        MeshDimensionError, match=r"PolyMeshes do not have the same keys."
    ):
        estimator.fit(imgs)
        estimator.transform(_drop_surf_img_part(imgs))


def check_surface_masker_list_surf_images_no_mask(estimator_orig):
    """Test transform / inverse_transform on list of surface images.

    No mask provided at init.

    - transform
        - masker
            - 1D surface image -> 1D array
            - 2D surface image -> 2D array
            - list of 1D surface images -> 2D array
            - list of 2D surface images -> ERROR (TODO)
        - multimasker
            - 1D surface image -> 1D array
            - 2D surface image -> 2D array
            - list of 1D surface images -> list of 1D array
            - list of 2D surface images -> list of 2D array (TODO)

    - inverse_transform
        - masker
            - 1D array -> 1D surface image
            - 2D array -> 2D surface image
        - multimasker
            - 1D array -> 1D surface image
            - 2D array -> 2D surface image
    """
    images_to_transform = [
        _surf_img_1d(),
        _make_surface_img(1),
        _make_surface_img(5),
        [_make_surface_img()] * 5,
    ]
    expected_n_sample = [
        0,
        1,
        5,
        5,
    ]
    for imgs, n_sample in zip(
        images_to_transform, expected_n_sample, strict=False
    ):
        estimator = clone(estimator_orig)

        estimator = estimator.fit(imgs)

        signals = estimator.transform(imgs)

        if isinstance(estimator, _MultiMixin) and isinstance(imgs, list):
            assert isinstance(signals, list)
            assert all(isinstance(x, np.ndarray) for x in signals)
            assert all(x.shape == (1, estimator.n_elements_) for x in signals)

            img = estimator.inverse_transform(signals[0])
            assert img.shape == (_make_surface_img().mesh.n_vertices, 1)

        elif n_sample == 0:
            assert signals.shape == (estimator.n_elements_,)

            img = estimator.inverse_transform(signals)
            assert img.shape == (_make_surface_img().mesh.n_vertices,)

        else:
            assert signals.shape == (n_sample, estimator.n_elements_)

            img = estimator.inverse_transform(signals)
            assert img.shape == (
                _make_surface_img().mesh.n_vertices,
                n_sample,
            )

    estimator = clone(estimator_orig)

    if isinstance(estimator, _MultiMixin):
        signals = estimator.fit_transform(
            [_make_surface_img(5), _make_surface_img(2)]
        )
        assert isinstance(signals, list)
        assert all(isinstance(x, np.ndarray) for x in signals)
        assert signals[0].shape == (5, estimator.n_elements_)
        assert signals[1].shape == (2, estimator.n_elements_)
    else:
        # TODO Turn that into a dimension error like for NiftiMaskers
        # TODO error msg should give hint as to how to fix:
        # point to the appropriate masker to use
        with pytest.raises(
            ValueError, match=r"Data for each part of .* should be 1D"
        ):
            estimator.fit([_make_surface_img(5), _make_surface_img(5)])


def check_surface_masker_list_surf_images_with_mask(estimator_orig):
    """Test transform / inverse_transform on list of surface images.

    Check that 1D mask or 2D mask work.

    TODO there are some unexpected outcomes with
    SurfaceMapsMasker, SurfaceLabelsMasker (see details below)
    once those are fixed this test should be mergeable with its "no mask"
    equivalent

    Theoretically expected input / output shape relations:

    - transform
        - masker
            - 1D surface image -> 1D array
            - 2D surface image -> 2D array
            - list of 1D surface images -> 2D array
            - list of 2D surface images -> ERROR
        - multimasker
            - 1D surface image -> 1D array
            - 2D surface image -> 2D array
            - list of 1D surface images -> list of 1D array
            - list of 2D surface images -> list of 2D array

    - inverse_transform
        - masker
            - 1D array -> 1D surface image
            - 2D array -> 2D surface image
        - multimasker
            - 1D array -> 1D surface image
            - 2D array -> 2D surface image
    """
    images_to_transform = [
        _surf_img_1d(),
        _make_surface_img(1),
        _make_surface_img(5),
        [_make_surface_img()] * 5,
    ]
    expected_n_sample = [
        0,
        1,
        5,
        5,
    ]
    for imgs, n_sample in zip(
        images_to_transform, expected_n_sample, strict=False
    ):
        for mask_img in [_surf_mask_1d(), _make_surface_mask()]:
            estimator = clone(estimator_orig)

            estimator.mask_img = mask_img

            estimator = estimator.fit(imgs)

            signals = estimator.transform(imgs)

            n_dim_mask = mask_img.data.parts["left"].ndim

            if isinstance(estimator, _MultiMixin) and isinstance(imgs, list):
                assert isinstance(signals, list)
                assert all(isinstance(x, np.ndarray) for x in signals)
                assert all(
                    x.shape == (1, estimator.n_elements_) for x in signals
                )

                img = estimator.inverse_transform(signals[0])
                assert img.shape == (_make_surface_img().mesh.n_vertices, 1)

            elif n_sample == 0:
                assert signals.size == estimator.n_elements_

                if isinstance(estimator, (SurfaceLabelsMasker)):
                    # TODO having a test where SurfaceLabelsMasker
                    # with mask leads to have only 1 ROI
                    # is a bit of an edge case
                    # Should this be changed?
                    # We should maybe also check what happens
                    # to the surface maps masker with only 1 map.
                    assert estimator.n_elements_ == 1

                # TODO We have some unexpected behavior from
                # SurfaceMapsMasker, SurfaceLabelsMasker
                # This should be fixed (in a follow up PR).
                if (
                    isinstance(
                        estimator, (SurfaceMapsMasker, SurfaceLabelsMasker)
                    )
                    and n_dim_mask == 2
                ):
                    assert signals.shape == (1, estimator.n_elements_)
                elif isinstance(estimator, (SurfaceLabelsMasker)):
                    # we probably get a scalar here because
                    # the label masker has only 1 valid ROI.
                    assert signals.shape == ()
                else:
                    assert signals.shape == (estimator.n_elements_,)

                img = estimator.inverse_transform(signals)

                if (
                    isinstance(
                        estimator, (SurfaceMapsMasker, SurfaceLabelsMasker)
                    )
                    and n_dim_mask == 2
                ):
                    assert img.shape == (
                        _make_surface_img().mesh.n_vertices,
                        1,
                    )
                else:
                    assert img.shape == (_make_surface_img().mesh.n_vertices,)

            else:
                assert signals.shape == (n_sample, estimator.n_elements_)

                img = estimator.inverse_transform(signals)

                assert img.shape == (
                    _make_surface_img().mesh.n_vertices,
                    n_sample,
                )


# ------------------ NIFTI MASKER CHECKS ------------------


def check_nifti_masker_fit_transform(estimator_orig):
    """Run several checks on maskers.

    - can fit 3D / 4D image
    - fitted maskers can transform:
      - 3D image
      - list of 3D images with same affine
    - array from transformed 3D images should have 1D
    - array from transformed 4D images should have 2D
    """
    estimator = clone(estimator_orig)

    estimator.fit(_img_3d_rand())

    # 3D images
    signal = estimator.transform(_img_3d_rand())

    assert isinstance(signal, np.ndarray)
    assert signal.shape == (estimator.n_elements_,)

    signal_2 = estimator.fit_transform(_img_3d_rand())

    assert_array_equal(signal, signal_2)

    # list of 3D images
    signal = estimator.transform([_img_3d_rand(), _img_3d_rand()])

    if isinstance(estimator, _MultiMixin):
        assert isinstance(signal, list)
        assert len(signal) == 2
        for x in signal:
            assert isinstance(x, np.ndarray)
            assert x.ndim == 1
            assert x.shape == (estimator.n_elements_,)
    else:
        assert isinstance(signal, np.ndarray)
        assert signal.ndim == 2
        assert signal.shape[1] == estimator.n_elements_

    # 4D images
    signal = estimator.transform(_img_4d_rand_eye())

    assert isinstance(signal, np.ndarray)
    assert signal.ndim == 2
    assert signal.shape == (_img_4d_rand_eye().shape[3], estimator.n_elements_)


def check_nifti_masker_fit_transform_5d(estimator_orig) -> None:
    """Run checks on nifti maskers for transforming 5D images.

    - multi masker should be fine
      and return a list of 2D numpy arrays
    - non multimasker should fail
    """
    estimator = clone(estimator_orig)

    n_subject = 3

    estimator.fit(_img_3d_rand())

    input_5d_img = [_img_4d_rand_eye() for _ in range(n_subject)]

    if not isinstance(estimator, _MultiMixin):
        with pytest.raises(
            DimensionError,
            match="Input data has incompatible dimensionality",
        ):
            estimator.transform(input_5d_img)

        with pytest.raises(
            DimensionError,
            match="Input data has incompatible dimensionality",
        ):
            estimator.fit_transform(input_5d_img)

    else:
        signal = estimator.transform(input_5d_img)

        assert isinstance(signal, list)
        assert all(isinstance(x, np.ndarray) for x in signal)
        assert len(signal) == n_subject
        assert all(x.ndim == 2 for x in signal)

        signal = estimator.fit_transform(input_5d_img)

        assert isinstance(signal, list)
        assert all(isinstance(x, np.ndarray) for x in signal)
        assert len(signal) == n_subject
        assert all(x.ndim == 2 for x in signal)

        # TODO
        # check type with set_output
        # estimator.set_output(transform="pandas")
        # signal = estimator.transform(input_5d_img)
        # assert isinstance(signal, list)
        # assert all(isinstance(x, pd.DataFrame) for x in signal)


def check_nifti_masker_fit_transform_files(estimator_orig) -> None:
    """Check that nifti maskers can work directly on files."""
    estimator = clone(estimator_orig)

    with TemporaryDirectory() as tmp_dir:
        filename = write_imgs_to_path(
            _img_3d_rand(),
            file_path=Path(tmp_dir),
            create_files=True,
        )

        estimator.fit(filename)
        estimator.transform(filename)
        estimator.fit_transform(filename)


def check_nifti_masker_dtype(estimator_orig) -> None:
    """Check dtype of output of maskers."""
    estimator = clone(estimator_orig)

    data_32 = _rng().random(_shape_3d_default(), dtype=np.float32)
    affine_32 = np.eye(4, dtype=np.float32)
    img_32 = Nifti1Image(data_32, affine_32)

    data_64 = _rng().random(_shape_3d_default(), dtype=np.float64)
    affine_64 = np.eye(4, dtype=np.float64)
    img_64 = Nifti1Image(data_64, affine_64)

    for img in [img_32, img_64]:
        estimator = clone(estimator)
        estimator.dtype = "auto"
        assert estimator.fit_transform(img).dtype == np.float32

    for img in [img_32, img_64]:
        estimator = clone(estimator)
        estimator.dtype = "float64"
        assert estimator.fit_transform(img).dtype == np.float64


def check_nifti_masker_fit_with_3d_mask(estimator_orig) -> None:
    """Check 3D mask can be used with nifti maskers.

    Mask can have different shape than fitted image.
    """
    estimator = clone(estimator_orig)

    # _shape_3d_large() is used
    # this test would fail for RegionExtractor otherwise
    mask = np.ones(_shape_3d_large())
    mask_img = Nifti1Image(mask, affine=_affine_eye())

    estimator.mask_img = mask_img

    assert not hasattr(estimator, "mask_img_")

    estimator.fit([_img_3d_rand()])

    assert hasattr(estimator, "mask_img_")


# ------------------ MULTI MASKER CHECKS ------------------


def check_multi_nifti_masker_shelving(estimator_orig) -> None:
    """Check behavior when shelving masker."""
    estimator = clone(estimator_orig)

    if is_windows_platform():
        # TODO (run check without xdist)
        # rare failure of this test on python on windows
        #   PermissionError: [WinError 32]
        #   The process cannot access the file
        #   because it is being used by another process
        return

    mask_img = Nifti1Image(
        np.ones((2, 2, 2), dtype=np.int8), affine=np.diag((2, 2, 2, 1))
    )
    epi_img1 = Nifti1Image(
        _rng().random((2, 3, 4, 5)), affine=np.diag((4, 4, 4, 1))
    )
    epi_img2 = Nifti1Image(
        _rng().random((2, 3, 4, 6)), affine=np.diag((4, 4, 4, 1))
    )

    estimator.verbose = 0

    masker = clone(estimator)
    masker.mask_img = mask_img

    epis = masker.fit_transform([epi_img1, epi_img2])

    with TemporaryDirectory() as tmp_dir:
        masker_shelved = clone(estimator)

        masker_shelved.mask_img = mask_img
        masker_shelved.memory = Memory(location=tmp_dir, mmap_mode="r")
        masker_shelved._shelving = True

        epis_shelved = masker_shelved.fit_transform([epi_img1, epi_img2])

        for e_shelved, e in zip(epis_shelved, epis, strict=False):
            e_shelved = e_shelved.get()
            assert_array_equal(e_shelved, e)


def check_multimasker_with_confounds(estimator_orig) -> None:
    """Test multi maskers with a list of confounds.

    Ensure results is different than when not using confounds.

    Check that confounds are applied when passing
    a 4D image (not iterable) to transform (or 2D for surface).

    Check that error is raised if number of confounds
    does not match number of images.
    """
    estimator = clone(estimator_orig)

    length = _img_4d_rand_eye_medium().shape[3]

    if accept_niimg_input(estimator):
        input_imgs = [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()]
        single_img = _img_4d_rand_eye_medium()
    else:
        input_imgs = [_make_surface_img(length), _make_surface_img(length)]
        single_img = _make_surface_img(length)

    array = _rng().random((length, 3))

    signals_list_1 = estimator.fit_transform(input_imgs)
    signals_list_2 = estimator.fit_transform(
        input_imgs, confounds=[array, array]
    )

    for signal_1, signal_2 in zip(
        signals_list_1, signals_list_2, strict=False
    ):
        assert_raises(AssertionError, assert_array_equal, signal_1, signal_2)

    # should also work with a single 4D image (has no __iter__ )
    signals_list_1 = estimator.fit_transform(single_img)
    signals_list_2 = estimator.fit_transform(single_img, confounds=[array])
    for signal_1, signal_2 in zip(
        signals_list_1, signals_list_2, strict=False
    ):
        assert_raises(AssertionError, assert_array_equal, signal_1, signal_2)

    # Mismatch n imgs and n confounds
    with pytest.raises(
        ValueError, match=r"number of confounds .* unequal to number of images"
    ):
        estimator.fit_transform(input_imgs, confounds=[array])

    with pytest.raises(
        TypeError, match=r"'confounds' must be a None or a list."
    ):
        estimator.fit_transform(input_imgs, confounds=1)


def check_multimasker_transformer_sample_mask(estimator_orig) -> None:
    """Test multi maskers with a list of "sample_mask".

    "sample_mask" was directly sent as input to the parallel calls of
    "transform_single_imgs" instead of sending iterations.
    See https://github.com/nilearn/nilearn/issues/3967 for more details.
    """
    estimator = clone(estimator_orig)

    length = _img_4d_rand_eye_medium().shape[3]

    if accept_niimg_input(estimator):
        input_imgs = [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()]
        single_img = _img_4d_rand_eye_medium()
    else:
        input_imgs = [_make_surface_img(length), _make_surface_img(length)]
        single_img = _make_surface_img(length)

    n_scrub1 = 3
    sample_mask1 = np.arange(length - n_scrub1)

    n_scrub2 = 2
    sample_mask2 = np.arange(length - n_scrub2)

    signals_list = estimator.fit_transform(
        input_imgs, sample_mask=[sample_mask1, sample_mask2]
    )

    for ts, n_scrub in zip(signals_list, [n_scrub1, n_scrub2], strict=False):
        assert ts.shape[0] == length - n_scrub

    # should also work with a single 4D image (has no __iter__ )
    signals_list = estimator.fit_transform(
        single_img, sample_mask=[sample_mask1]
    )

    assert signals_list.shape[0] == length - n_scrub1

    with pytest.raises(
        ValueError,
        match=r"number of sample_mask .* unequal to number of images",
    ):
        estimator.fit_transform(input_imgs, sample_mask=[sample_mask1])

    with pytest.raises(
        TypeError, match=r"'sample_mask' must be a None or a list."
    ):
        estimator.fit_transform(input_imgs, sample_mask=1)


def check_multimasker_transformer_high_variance_confounds(estimator_orig):
    """Check high_variance_confounds in multi maskers with data many samples.

    Make sure that using high_variance_confounds returns different result.

    Ensure that high_variance_confounds can be used with regular confounds,
    and that results are different than when just using the confounds alone.

    Also checks that confounds can be accepted as different formats.
    """
    estimator = clone(estimator_orig)

    length = _img_4d_rand_eye_medium().shape[3]

    if accept_niimg_input(estimator):
        input_imgs = [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()]
    else:
        input_imgs = [_make_surface_img(length), _make_surface_img(length)]

    estimator.high_variance_confounds = False

    signal = estimator.fit_transform(input_imgs)

    estimator = clone(estimator)
    estimator.high_variance_confounds = True

    signal_hvc = estimator.fit_transform(input_imgs)

    for s1, s2 in zip(signal, signal_hvc, strict=False):
        assert_raises(AssertionError, assert_array_equal, s1, s2)

    with TemporaryDirectory() as tmp_dir:
        array = _rng().random((length, 3))

        dataframe = pd.DataFrame(array)

        tmp_dir = Path(tmp_dir)
        dataframe.to_csv(tmp_dir / "confounds.csv")

        for c in [array, dataframe, tmp_dir / "confounds.csv"]:
            confounds = [c, c]

            estimator = clone(estimator)
            estimator.high_variance_confounds = False
            signal_c = estimator.fit_transform(input_imgs, confounds=confounds)

            estimator = clone(estimator)
            estimator.high_variance_confounds = True
            signal_c_hvc = estimator.fit_transform(
                input_imgs, confounds=confounds
            )

            for s1, s2 in zip(signal_c, signal_c_hvc, strict=False):
                assert_raises(AssertionError, assert_array_equal, s1, s2)


# ------------------ GLM CHECKS ------------------


def check_glm_empty_data_messages(
    estimator_orig: FirstLevelModel | SecondLevelModel,
) -> None:
    """Check that empty images are caught properly.

    Test on both surface and volume data.

    Replaces sklearn check_estimators_empty_data_messages.
    """
    estimator = clone(estimator_orig)

    surface_imgs, design_matrices = _make_surface_img_and_design()

    data = {
        part: np.empty(0).reshape((surface_imgs.data.parts[part].shape[0], 0))
        for part in surface_imgs.data.parts
    }
    surface_imgs = SurfaceImage(surface_imgs.mesh, data)

    with pytest.raises(ValueError, match="empty"):
        # FirstLevel
        if hasattr(estimator, "hrf_model"):
            estimator.fit(surface_imgs, design_matrices=design_matrices)
        # SecondLevel
        else:
            estimator.fit(surface_imgs, design_matrix=design_matrices)

    estimator = clone(estimator_orig)

    data_nifti = np.zeros((7, 9, 0))

    with pytest.raises(ValueError, match="empty"):
        # FirstLevel
        if hasattr(estimator, "hrf_model"):
            estimator.fit(
                Nifti1Image(data_nifti, np.eye(4)),
                design_matrices=design_matrices,
            )
        # SecondLevel
        else:
            estimator.fit(
                [Nifti1Image(data_nifti, np.eye(4)) for _ in range(3)],
                design_matrix=design_matrices,
            )


def check_glm_dtypes(estimator_orig) -> None:
    """Check glm can fit with inputs of varying dtypes.

    Replacement for sklearn check_estimators_dtypes.

    np.int64 not tested: see no_int64_nifti in nilearn/conftest.py
    """
    estimator = clone(estimator_orig)

    imgs, design_matrices = _make_surface_img_and_design()

    for dtype in [np.float32, np.float64, np.int32]:
        estimator = clone(estimator)

        for k, v in imgs.data.parts.items():
            imgs.data.parts[k] = v.astype(dtype)

        # FirstLevel
        if hasattr(estimator, "hrf_model"):
            estimator.fit(imgs, design_matrices=design_matrices)
        # SecondLevel
        else:
            estimator.fit(imgs, design_matrix=design_matrices)


# ------------------ REPORT GENERATION CHECKS ------------------


def _extra_kwargs(estimator):
    """Adapt the call to generate_report to limit warnings.

    For example by only passing the number of displayed maps
    that a map masker contains.
    """
    if isinstance(
        estimator,
        (NiftiMapsMasker, MultiNiftiMapsMasker, SurfaceMapsMasker),
    ) and hasattr(estimator, "n_elements_"):
        return {"displayed_maps": estimator.n_elements_}
    else:
        return {}


def _generate_report_with_no_warning(estimator) -> None:
    """Check that report generation throws no warning."""
    with warnings.catch_warnings(record=True) as warning_list:
        estimator.generate_report(**_extra_kwargs(estimator))

        # TODO
        # RegionExtractor, SurfaceMapsMasker still throws too many warnings
        warnings_to_ignore = [
            # only thrown with older dependencies
            "No contour levels were found within the data range.",
        ]

        if not is_matplotlib_installed():
            # in case we are generating reports with no matplotlib
            warnings_to_ignore.append("Report will be missing figures")

        unknown_warnings = []
        for x in warning_list:
            message = str(x.message)
            if any(y in message for y in warnings_to_ignore):
                continue
            unknown_warnings.append(message)

        if not isinstance(estimator, (RegionExtractor, SurfaceMapsMasker)):
            assert not unknown_warnings, unknown_warnings


def check_masker_generate_report(estimator_orig) -> None:
    """Check that maskers can generate report.

    - check that we get a warning:
      - when matplotlib is not installed
      - when generating reports before fit
    - check content of report before fit and after fit
    - check that the masker has a non empty _report_content after
      initialization
    - check that the masker has report data after fit

    """
    estimator = clone(estimator_orig)

    generate_and_check_masker_report(estimator)

    assert isinstance(estimator._report_content, dict)
    assert estimator._report_content["description"] != ""
    assert estimator._has_report_data() is False

    # TODO
    # fix for free threaded python
    if not is_gil_enabled():
        pytest.xfail("May fail without the GIL")

    if accept_niimg_input(estimator):
        input_img = _img_3d_rand()
    else:
        input_img = _make_surface_img(2)

    estimator.fit(input_img)

    assert estimator._has_report_data() is True

    assert estimator._report_content["warning_messages"] == []

    # TODO
    # SurfaceMapsMasker, RegionExtractor still throws a warning
    _generate_report_with_no_warning(estimator)

    extra_warnings_allowed = False
    duplicate_warnings_allowed = False
    if isinstance(estimator, (SurfaceMapsMasker, RegionExtractor)):
        extra_warnings_allowed = True
    if isinstance(estimator, (RegionExtractor)):
        duplicate_warnings_allowed = True

    with TemporaryDirectory() as tmp_dir:
        generate_and_check_masker_report(
            estimator,
            pth=Path(tmp_dir),
            extra_warnings_allowed=extra_warnings_allowed,
            duplicate_warnings_allowed=duplicate_warnings_allowed,
            **_extra_kwargs(estimator),
        )


def check_masker_generate_report_constant(estimator_orig) -> None:
    """Check report is constant across calls."""
    estimator = clone(estimator_orig)
    if accept_niimg_input(estimator):
        input_img = _img_3d_rand()
    else:
        input_img = _make_surface_img(2)
    estimator.fit(input_img)

    # TODO
    # fix for free threaded python
    if not is_gil_enabled():
        pytest.xfail("May fail without the GIL")

    report = estimator.generate_report(**_extra_kwargs(estimator))
    report_new = estimator.generate_report(**_extra_kwargs(estimator))

    report_str = str(report)
    report_new_str = str(report_new)

    substitution_mapping = {
        # svg/xml of images and UUID may be slightly different across calls
        # so we redact them out
        'src="data:image/..."': [r'src="data:image/svg\+xml;base64,.*"'],
        "UUID-XXXX-": [r"UUID-.*-", r"UUID-.*"],
        'Carousel("XXXX"': [r'Carousel\(".*"'],
        # slklearn repr may vary slightly when generating successive reports
        "sk-XXXX-id": [r"sk-(?:container|estimator)-id-[0-9]*"],
    }

    for k, v in substitution_mapping.items():
        for regexp in v:
            report_str = re.sub(regexp, k, report_str)
            report_new_str = re.sub(regexp, k, report_new_str)

    assert report_str == report_new_str


def check_nifti_masker_generate_report_after_fit_with_only_mask(
    estimator_orig,
) -> None:
    """Check 3D mask is enough to run with fit and generate report."""
    estimator = clone(estimator_orig)

    mask = np.ones(_shape_3d_large())
    mask_img = Nifti1Image(mask, affine=_affine_eye())

    estimator.mask_img = mask_img

    assert not hasattr(estimator, "mask_img_")

    estimator.fit()

    assert estimator._report_content["warning_messages"] == []

    # TODO
    # fix for free threaded python
    if not is_gil_enabled():
        pytest.xfail("May fail without the GIL")

    generate_and_check_masker_report(
        estimator, warnings_msg_to_check=["No image provided to fit"]
    )

    input_img = _img_4d_rand_eye_medium()

    estimator.fit(input_img)

    assert estimator._report_content["warning_messages"] == []

    # TODO
    # NiftiSpheresMasker still throws a warning
    if isinstance(estimator, NiftiSpheresMasker):
        return

    _generate_report_with_no_warning(estimator)

    extra_warnings_allowed = False
    duplicate_warnings_allowed = False
    if isinstance(estimator, RegionExtractor):
        extra_warnings_allowed = True
        duplicate_warnings_allowed = True

    generate_and_check_masker_report(
        estimator,
        **_extra_kwargs(estimator),
        extra_warnings_allowed=extra_warnings_allowed,
        duplicate_warnings_allowed=duplicate_warnings_allowed,
    )


def check_masker_generate_report_false(estimator_orig) -> None:
    """Test with reports set to False."""
    estimator = clone(estimator_orig)

    estimator.reports = False

    if accept_niimg_input(estimator):
        input_img = _img_4d_rand_eye_medium()
    else:
        input_img = _make_surface_img(2)

    estimator.fit(input_img)

    # TODO
    # fix for free threaded python
    if not is_gil_enabled():
        pytest.xfail("May fail without the GIL")

    assert estimator._has_report_data() is False

    generate_and_check_masker_report(estimator)


def check_multimasker_generate_report(estimator_orig) -> None:
    """Test calling generate report on multiple subjects raises warning."""
    estimator = clone(estimator_orig)

    # TODO
    # fix for free threaded python
    if not is_gil_enabled():
        pytest.xfail("May fail without the GIL")

    if accept_niimg_input(estimator):
        input_img = [_img_4d_rand_eye_medium(), _img_4d_rand_eye_medium()]
    else:
        input_img = [_make_surface_img(100), _make_surface_img(100)]

    if accept_niimg_input(estimator):
        if isinstance(estimator, NiftiMapsMasker):
            estimator.maps_img = _img_3d_ones()

        estimator.fit(input_img)

        generate_and_check_masker_report(
            estimator,
            warnings_msg_to_check=[
                "A list of 4D subject images were provided to fit"
            ],
        )

    else:
        warnings_msg_to_check = []
        if isinstance(estimator, SurfaceMapsMasker):
            warnings_msg_to_check = [
                "has not been transformed",
                "masker only has",
            ]
        # TODO add a warning
        estimator.fit(input_img)
        generate_and_check_masker_report(
            estimator, warnings_msg_to_check=warnings_msg_to_check
        )
