"""This module provides functions for checking Pastas models and their components.

This can be useful to define checks that can be applied to multiple models to ensure
they meet certain criteria.

Examples
--------
Run a checklist of standard checks on a Pastas model::

    checks = [
        {"func": rsq_geq_threshold, "threshold": 0.7},
        {"func": response_memory, "cutoff": 0.95, "factor_length_oseries": 0.5},
        {"func": uncertainty_gain, "n_std": 1.96},
        {"func": parameter_bounds},
    ]

    ps.check.checklist(ml, checks)

Or use a list of checks defined in literature. Note that these checks were derived for
specific questions and hydrogeological contexts and are included as inspiration, but
are by no means exhaustive or applicable to all problems.

Checklists can be obtained with::

    checks = ps.check.get_checks_literature("brakenhoff_2022")
    ps.check.checklist(ml, checks=checks)


Checks
------
The following checks built-in checks are available in this module:

 - stat_ufunc_threshold: model statistic compared to threshold using ufunc
 - rsq_geq_threshold: R^2 greater equal than threshold
 - parameter_ufunc_threshold: single parameter value compared to threshold using ufunc
 - parameters_leq_threshold: parameter values less equal than threshold
 - response_memory: memory of response functions less than fraction of calibration
   period
 - response_memory_vs_warmup: memory of response functions less than warmup period
 - uncertainty_gain: estimated gain less than n_std times std. deviation of the gain
 - uncertainty_parameters: estimated parameter value less than n_std times std.
   deviation
 - parameter_bounds: parameter optimal values not on bounds
 - acf_runs_test: check for significant autocorrelation in the noise using runs test
 - acf_stoffer_toloi_test: check for significant autocorrelation in the noise using
   Stoffer-Toloi test
 - acf_ljung_box_test: check for significant autocorrelation in the noise using
   Ljung-Box test (regular timesteps)
 - correlation_sim_vs_res: correlation between simulated and residuals less than
   threshold

Checklists
----------
The following predefined checklists are available in this module, available via
the `get_checks_literature()` function:

 - brakenhoff_2022: checklist based on Brakenhoff et al. (2022)
 - zaadnoordijk_2019: checklist based on Zaadnoordijk et al. (2019)
"""

import logging
from collections.abc import Callable
from typing import Literal

import numpy as np
from matplotlib.colors import rgb2hex
from pandas import DataFrame, Series, Timedelta, concat

from pastas.model import Model
from pastas.rfunc import RfuncBase
from pastas.stats import tests as diagnostic_tests

logger = logging.getLogger(__name__)


operators = {
    "greater_equal": ">=",
    "less_equal": "<=",
    "greater_than": ">",
    "less_than": "<",
    "equal": "==",
    "not_equal": "!=",
}


def get_empty_check_dataframe():
    """Create an empty DataFrame for storing check results.

    Returns
    -------
    df: pandas.DataFrame
        Empty DataFrame with the appropriate columns for storing check results.
    """
    df = DataFrame(
        columns=["statistic", "operator", "threshold", "dimensions", "pass", "comment"]
    )
    df.index.name = "check"
    return df


def _value_ufunc_threshold(
    val: float,
    ufunc: Callable,
    threshold: float,
    label: str,
):
    """Generic function to compare a value with a threshold using a ufunc.

    Parameters
    ----------
    val: float
        Value to be compared.
    ufunc: callable
        Numpy ufunc (e.g. np.greater_than) to compare the value with the threshold.
    threshold: float
        Threshold value for the statistic.
    label: str
        Label for the check.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    check = ufunc(val, threshold)
    label = f"{label}{operators[ufunc.__name__]}{threshold}"
    df = get_empty_check_dataframe()
    df.loc[label] = [val, operators[ufunc.__name__], threshold, "-", check, ""]
    return df


def stat_ufunc_threshold(
    ml: Model,
    ufunc: Callable,
    statistic: str,
    threshold: float,
):
    """Generic function to compare a model statistic with a threshold using a ufunc.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    ufunc: callable
        Numpy ufunc (e.g. np.greater_than) to compare the model statistic with the
        threshold.
    statistic: str
        Name of the statistic to be compared.
    threshold: float
        Threshold value for the statistic.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    val = getattr(ml.stats, statistic)()
    return _value_ufunc_threshold(val, ufunc, threshold, statistic)


def rsq_geq_threshold(ml: Model, threshold: float = 0.7):
    """Check R^2 >= threshold.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    threshold: float
        Threshold value for the R^2 statistic.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    return stat_ufunc_threshold(ml, np.greater_equal, "rsq", threshold)


def _response_memory(
    ml: Model,
    threshold: float,
    label: str,
    cutoff: float = 0.95,
    names: list[str] | str | None = None,
):
    """Check if response function memory is shorter than threshold.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    threshold : float
        Threshold value for the maximum length of the response function.
    label : str
        Label for the check, e.g. "{factor_length_oseries} Δt_calib" or "warmup".
    cutoff: float, optional
        Cutoff value for the length of the response function. Default is 0.95, which
        means that the response function is cutoff at the time the step response is at
        95% of the gain.
    names: list or str, optional
        List of stressmodel names to check the memory for. Default is None, which
        means all stressmodels are checked.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    if names is None:
        names = list(ml.stressmodels.keys())
    elif names is not None and not isinstance(names, list):
        names = [names]

    df = get_empty_check_dataframe()
    # unit = "days"
    dim = "[T]"

    def interp_step(cutoff: float, p: np.ndarray, rfunc: RfuncBase):
        """Helper function to interpolate the step response to compute the memory.

        Parameters
        ----------
        cutoff: float
            Compute the time to for this cutoff value for the step response.
        p: array
            Parameters of the response function.
        rfunc: pastas.rfunc
            Response function instance.

        Returns
        -------
        tmem: float
            Time to the cutoff value, i.e. the memory of the response function.
        """
        t = rfunc.get_t(p, dt=1.0, cutoff=1.0 - (1.0 - cutoff) / 10.0)
        step = rfunc.step(p, cutoff=1.0 - (1.0 - cutoff) / 10.0) / rfunc.gain(p)
        tmem = np.interp(cutoff, step, t)
        return tmem

    for sm_name in names:
        sm = ml.stressmodels[sm_name]
        if sm._name == "WellModel":
            # HantushWellModel means the response function changes with distance
            # and therefore the memory is also distance dependent. So we compute
            # the memory for each well in the Wellmodel separately.
            nwells = sm.distances.index.size
            for iw in range(nwells):
                lbl = (
                    f"t{cutoff * 100:.0f}_{sm_name} ({sm.distances.index[iw]}) <"
                    f" {label}"
                )
                p = sm.get_parameters(ml, istress=iw)
                tmem = interp_step(cutoff, p, sm.rfunc)
                check = tmem < threshold
                df.loc[lbl] = [
                    tmem,
                    "<",
                    threshold,
                    dim,
                    check,
                    "",
                ]
        elif sm.rfunc._name == "Hantush":
            # get_tmax is a conservative approximation for Hantush,
            # so it is better to interpolate step response to compute the memory
            lbl = f"response_t{cutoff * 100:.0f}_{sm_name}"
            p = ml.get_parameters(sm_name)[0:3]
            tmem = interp_step(cutoff, p, sm.rfunc)
            check = tmem < threshold
            df.loc[lbl] = [
                tmem,
                "<",
                threshold,
                dim,
                check,
                "",
            ]
        else:
            # for response functions where get_tmax is exact
            tmem = ml.get_response_tmax(sm_name, cutoff=cutoff)
            check = tmem < threshold
            lbl = f"t{cutoff * 100:.0f}_{sm_name} < {label}"
            df.loc[lbl] = [
                tmem,
                "<",
                threshold,
                dim,
                check,
                "",
            ]
    return df


def response_memory(
    ml: Model,
    cutoff: float = 0.95,
    factor_length_oseries: float = 0.5,
    names: list[str] | str | None = None,
):
    """Check if response function memory is shorter than fraction of calibration period.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    cutoff: float, optional
        Cutoff value for the length of the response function. Default is 0.95, which
        means that the response function is cutoff at the time the step response is at
        95% of the gain.
    factor_length_oseries: float, optional
        Factor to multiply the length of the observation series with to get the
        maximum allowed memory. Default is 0.5, e.g. half of the calibration period.
    names: list or str, optional
        List of stressmodel names to check the memory for. Default is None, which
        means all stressmodels are checked.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    len_oseries_calib = (ml.settings["tmax"] - ml.settings["tmin"]).days
    threshold = factor_length_oseries * len_oseries_calib
    label = f"{factor_length_oseries} Δt_calib"
    return _response_memory(ml, threshold, label, cutoff=cutoff, names=names)


def response_memory_vs_warmup(
    ml: Model, cutoff: float = 0.95, names: list[str] | str | None = None
):
    """Check if response function memory is shorter than warmup.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    cutoff: float, optional
        Cutoff value for the length of the response function. Default is 0.95, which
        means that the response function is cutoff at the time the step response is at
        95% of the gain.
    names: list or str, optional
        List of stressmodel names to check the memory for. Default is None, which
        means all stressmodels are checked.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    warmup = ml.settings["warmup"]
    threshold = warmup.days if isinstance(warmup, Timedelta) else Timedelta(warmup).days
    label = "warmup"
    return _response_memory(ml, threshold, label, cutoff=cutoff, names=names)


def uncertainty_gain(
    ml: Model,
    n_std: float = 1.96,
    names: list[str] | str | None = None,
):
    """Check if the gain is larger than n_std times the uncertainty in the gain.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    n_std: int, optional
        Number of standard errors to compare the the gain to. Default is 1.96.
    names: list or str, optional
        List of stressmodel names to check the gain for. Default is None, which
        means all stressmodels are checked.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    if names is None:
        names = list(ml.stressmodels.keys())
    elif names is not None and not isinstance(names, list):
        names = [names]

    results = []
    for sm_name in names:
        results.append(_uncertainty_parameter(ml, sm_name + "_A", n_std=n_std))
    df = concat(results)
    df["dimensions"] = "[L] / [unit stress]"
    return df


def parameter_ufunc_threshold(
    ml: Model,
    parameter: str,
    ufunc: Callable,
    threshold: float,
):
    """Generic function to compare a model statistic with a threshold using a ufunc.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    parameter: str
        Name of the parameter to be compared.
    ufunc: callable
        Numpy ufunc (e.g. np.greater_than) to compare the model statistic with the
        threshold.
    threshold: float
        Threshold value for the statistic.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    val = ml.parameters.loc[parameter, "optimal"]
    return _value_ufunc_threshold(val, ufunc, threshold, parameter)


def parameters_leq_threshold(
    ml: Model,
    parameters: list[str] | str | None = None,
    threshold: float = 500.0,
):
    """Check if parameter values are less than or equal to a threshold.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    parameters: list or str, optional
        List of parameter names to check the threshold for. Default is None, which
        means all parameters are checked. If str, partial matches are allowed.
    threshold: float, optional
        Threshold value for the parameters. Default is 500.0.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    if parameters is None:
        parameters = ml.parameters.index.tolist()
    elif isinstance(parameters, str):
        parameters = [iparam for iparam in ml.parameters.index if parameters in iparam]

    results = []
    for parameter in parameters:
        results.append(
            parameter_ufunc_threshold(ml, parameter, np.less_equal, threshold)
        )
    return concat(results)


def parameter_bounds(ml: Model, parameters: list[str] | str | None = None):
    """Check if the optimal parameter values are not on the lower or upper bounds.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    parameters: list or str, optional
        List of parameter names to check the bounds for. Default is None, which
        means all parameters are checked. If str, partial matches are allowed.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    if parameters is None:
        parameters = ml.parameters.index.tolist()
    elif isinstance(parameters, str):
        parameters = [iparam for iparam in ml.parameters.index if parameters in iparam]
    df = get_empty_check_dataframe()
    upper, lower = ml._check_parameters_bounds()
    for param in parameters:
        bounds = (
            ml.parameters.loc[param, "pmin"],
            ml.parameters.loc[param, "pmax"],
        )
        check = ~(upper.loc[param] or lower.loc[param])

        df.loc[f"Bounds: {param}"] = (
            ml.parameters.loc[param, "optimal"],
            "within",
            bounds,
            guess_unit_or_dims(param),
            check,
            "",
        )
    return df


def uncertainty_parameters(
    ml: Model,
    parameters: list[str] | str | None = None,
    n_std: float = 1.96,
):
    """Check if parameter value is larger than n_std times the standard deviation.

    Note that it is the modelers responsibility to check if the estimated uncertainty
    is reliable!

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    parameters: list or str, optional
        List of parameter names to check the uncertainty for. Default is None, which
        means all parameters are checked. If str, partial matches are allowed.
    n_std: float, optional
        Number of standard deviations to compare the parameter to. Default is 1.96.

    """
    if parameters is None:
        parameters = ml.parameters.index.tolist()
    elif isinstance(parameters, str):
        parameters = [iparam for iparam in ml.parameters.index if parameters in iparam]

    # loop through parameters
    results = []
    for parameter in parameters:
        results.append(_uncertainty_parameter(ml, parameter, n_std=n_std))
    return concat(results)


def _uncertainty_parameter(ml: Model, parameter: str, n_std: float = 1.96):
    """Internal method to check if parameter value is larger than n_std * std.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    parameter: str
        Name of the parameter to check.
    n_std: float, optional
        Number of standard deviations to compare the parameter to. Default is 1.96.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    df = get_empty_check_dataframe()
    # get stressmodel
    sm_name = "_".join(parameter.split("_")[:-1])
    if sm_name in ml.stressmodels:
        sm = ml.stressmodels[sm_name]
    else:
        sm = None
    # WellModel gain is a special case, because the parameter depends on distance
    if sm is not None and sm._name == "WellModel" and parameter.endswith("_A"):
        nwells = sm.distances.index.size
        for iw in range(nwells):
            params = sm.get_parameters(model=ml, istress=iw)
            p = sm.rfunc.gain(params)
            std = sm.variance_gain(model=ml, istress=iw)
            check = np.abs(p) > (n_std * std)
            df.loc[f"|{parameter} ({sm.distances.index[iw]})| > {n_std}σ"] = [
                p,
                ">",
                n_std * std,
                guess_unit_or_dims(parameter),
                check,
                "Assumes estimate of σ is reliable.",
            ]
    else:
        p = ml.parameters.loc[parameter, "optimal"]
        std = ml.parameters.loc[parameter, "stderr"]
        check = np.abs(p) > (n_std * std)
        df.loc[f"|{parameter}| > {n_std}σ"] = [
            p,
            ">",
            n_std * std,
            guess_unit_or_dims(parameter),
            check,
            "Assumes estimate of σ is reliable.",
        ]
    return df


def guess_unit_or_dims(parameter: str, return_dims=True):
    """Guess the unit or dimension of a parameter based on its name.

    Parameters
    ----------
    parameter : str
        Name of the parameter.
    return_dims : bool, optional
        if True, return parameter dimensions (default). If False, return
        specific units where they can be determined from the parameter name

    Returns
    -------
    unit or dim: str
        Guessed unit or dimensions of the parameter.
    """
    if "_A" in parameter:
        sm = "_".join(parameter.split("_")[:-1])
        unit = dim = f"[L] / [unit '{sm}' stress]"
    elif parameter == "noise_alpha":
        unit = "days"
        dim = "[T]"
    elif parameter == "constant_d":
        unit = dim = "[L]"
    elif parameter.endswith("_f"):
        unit = "-"
        dim = "[-]"
    else:
        unit = ""
        dim = ""
    return dim if return_dims else unit


def acf_runs_test(ml: Model, p_threshold: float = 0.05):
    """Runs test to check if there is significant autocorrelation in the noise.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    p_threshold: float, optional
        Threshold value for the p-value of the runs test. Default is 0.05.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    return _diagnostic_test(ml, "runs_test", alpha=p_threshold)


def acf_stoffer_toloi_test(ml: Model, p_threshold: float = 0.05, **kwargs):
    """Stoffer-Toloi test to check if there is significant autocorrelation in the noise.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    p_threshold: float, optional
        Threshold value for the p-value of the Stoffer-Toloi test. Default is 0.05.
    **kwargs
        Additional keyword arguments to pass to the Stoffer-Toloi test.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    return _diagnostic_test(ml, "stoffer_toloi", alpha=p_threshold, **kwargs)


def acf_ljung_box_test(ml: Model, p_threshold: float = 0.05, **kwargs):
    """Ljung-Box test to check if there is significant autocorrelation in the noise.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    p_threshold: float, optional
        Threshold value for the p-value of the Ljung-Box test. Default is 0.05.
    **kwargs
        Additional keyword arguments to pass to the Ljung-Box test.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    return _diagnostic_test(ml, "ljung_box", alpha=p_threshold, **kwargs)


def _diagnostic_test(ml: Model, test: str, alpha: float = 0.05, **kwargs):
    """Internal method to get the result of a diagnostic test.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    test: str
        Name of the diagnostic test to perform, must be one of
        ["runs_test", "stoffer_toloi", "durbin_watson", "ljung_box"].
    alpha: float, optional
        Threshold value for the p-value of the diagnostic test. Default is 0.05.
    **kwargs
        Additional keyword arguments to pass to the diagnostic test.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    dtest = getattr(diagnostic_tests, test)
    noise = ml.noise()
    if noise is None:
        logger.warning("No noise model found in model. Using residuals instead.")
        noise = ml.residuals()
    _, p = dtest(noise.iloc[1:], **kwargs)
    check = p > alpha
    label = f"{test} (p > α)"
    df = get_empty_check_dataframe()
    df.loc[label] = [p, ">", alpha, "-", check, ""]
    return df


def correlation_sim_vs_res(ml: Model, threshold: float = 0.2):
    """Check if the correlation between simulated and residuals is less than threshold.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    threshold: float
        Threshold value for the correlation coefficient.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the check.
    """
    sim = ml.simulate()
    res = ml.residuals()

    if sim.index.difference(res.index).size > 0:
        logger.warning(
            "Simulation and residuals have different time indices. "
            "Interpolating simulation to residuals time index."
        )
    sim = Series(
        index=res.index, data=np.interp(res.index.asi8, sim.index.asi8, sim.values)
    )
    label = f"|corr(sim, res)| < {threshold}"
    val = np.abs(res.corr(sim))
    check = val < threshold
    df = get_empty_check_dataframe()
    df.loc[label] = [val, operators["less_than"], threshold, "-", check, ""]
    return df


def checklist(ml: Model, checks: list[str | Callable | dict], report=True):
    """Run a list of checks on a Pastas model.

    Parameters
    ----------
    ml: pastas.Model
        Pastas model instance.
    checks: list
        List of checks to perform. Each entry in the list can be a string, a callable,
        or a dict:
           * If a string, it must be the name of a function in this module,
             e.g. "rsq_geq_threshold".
           * If a callable, it must be a function that takes a model instance as an
             argument and return a DataFrame with columns:
             ["statistic", "operator", "threshold", "dimensions", "pass", "comment"].
           * If a dict, it must have a key "func" with a function to perform the check,
             additional dictionary entries are passed to the function as kwargs.
    report: bool, optional
        If True, display a report of the check results. Default is True.

    Returns
    -------
    df: pandas.DataFrame
        DataFrame with the results of the checks.
    """

    results = []
    for check in checks:
        if isinstance(check, str):
            results.append(globals()[check](ml))
        elif isinstance(check, dict):
            check = check.copy()  # copy so we do not modify original dict
            func = check.pop("func")
            if isinstance(func, str):
                func = globals()[func]  # get function from this module
            results.append(func(ml, **check))  # pass rest of dict to function
        elif callable(check):
            results.append(check(ml))  # call function with model as only argument
        else:
            raise TypeError("Check must be str, callable, or dict.")
    df = concat(results)
    # deal with duplicated index labels by appending .1, .2, etc.
    if df.index.duplicated().any():
        new_index = []
        j = 0
        for i, idx in enumerate(df.index):
            if df.index.duplicated(keep=False)[i]:
                new_index.append(f"{idx}.{j + 1}")
                j += 1
            else:
                new_index.append(idx)
        df.index = new_index
    if report:
        print_check_report(df)
    return df


def print_check_report(df: DataFrame):
    """Print a report of the check results.

    The check result is colored red (fail), green (pass) or yellow (pass with
    conditions) based on the outcome.

    Parameters
    ----------
    df: pandas.DataFrame
        DataFrame with the results of the checks.
    """

    def boolean_row_styler(row, column):
        """Styler function to color rows based on the value in column."""

        colors = [""] * row.size

        # make result based on std deviation check yellow, because we cannot
        # be certain the parameter uncertainty estimate is reliable
        if row[column] and "σ" in row.name:
            colors[row.size - 2] = (
                "background-color: lemonchiffon; color: darkgoldenrod"
            )
        elif row[column]:  # check passed
            colors[row.size - 2] = (
                f"background-color: {rgb2hex((231 / 255, 255 / 255, 239 / 255))}; "
                "color: darkgreen"
            )
        else:  # check failed
            colors[row.size - 2] = (
                f"background-color: {rgb2hex((255 / 255, 238 / 255, 238 / 255))}; "
                "color: darkred"
            )
        return colors

    # try pretty display, otherwise fall back to print
    try:
        from IPython.display import display

        display(df.style.apply(boolean_row_styler, column="pass", axis=1))
    except ModuleNotFoundError:
        print(df)


checks = {
    f.__name__: f
    for f in [
        stat_ufunc_threshold,
        rsq_geq_threshold,
        parameter_ufunc_threshold,
        parameters_leq_threshold,
        response_memory,
        response_memory_vs_warmup,
        uncertainty_gain,
        parameter_bounds,
        uncertainty_parameters,
        acf_runs_test,
        acf_stoffer_toloi_test,
        acf_ljung_box_test,
        correlation_sim_vs_res,
    ]
}

# list of checks, based on Brakenhoff et al. (2022).
checks_brakenhoff_2022 = [
    {"func": rsq_geq_threshold, "threshold": 0.7},
    {"func": response_memory, "cutoff": 0.95, "factor_length_oseries": 0.5},
    {"func": acf_runs_test},
    {"func": uncertainty_gain, "n_std": 1.96},
    {"func": parameter_bounds},
]


def get_checks_literature(
    author: Literal["brakenhoff_2022", "zaadnoordijk_2019"],
    recharge_model_name: str | None = None,
) -> list[str | Callable | dict]:
    """Get predefined checklists based on literature.

    Notes
    -----
    These checklists are not exhaustive and were developed to address specific research
    questions and hydrogeological contexts. They are provided as reference
    implementations but may not be universally applicable across all modeling
    scenarios.

    The Brakenhoff et al. (2022) checklist was specifically designed to assess models
    for quantifying the impact of groundwater pumping on heads.

    The Zaadnoordijk et al. (2019) checklist was developed for checking linear recharge
    models across diverse hydrogeological settings throughout the Netherlands. This
    checklist requires the recharge model name to retrieve the corresponding
    parameters. Applicability is limited to recharge models with the Gamma or
    Exponential response functions. Checks contain duplicate checks with different
    thresholds for the different check levels (MODOK, REGIMEOK, NWARN).

    Parameters
    ----------
    author: str
        Author of the checklist to retrieve. Must be one of
        ["brakenhoff_2022", "zaadnoordijk_2019"].
    recharge_model: str, optional
        Name of the recharge model. Required for "zaadnoordijk_2019" checklist to
        obtain parameters related to the recharge model.

    Returns
    -------
    checks: list
        Checklist based on Brakenhoff et al. (2022) or Zaadnoordijk et al. (2019).
    """
    if author == "brakenhoff_2022":
        return checks_brakenhoff_2022
    elif author == "zaadnoordijk_2019":
        if recharge_model_name is None:
            raise ValueError(
                "Name of recharge model must be provided for "
                "Zaadnoordijk et al. (2019) checklist."
            )

        # list of checks, loosely based on Zaadnoordijk et al. (2019).
        checks_zaadnoordijk_2019 = [
            # MODOK checks:
            {
                "func": parameters_leq_threshold,
                "parameters": recharge_model_name + "_a",
                "threshold": 500.0,
            },
            {"func": rsq_geq_threshold, "threshold": 0.1},
            {"func": correlation_sim_vs_res, "threshold": 0.3},
            # REGIMEOK checks:
            {"func": rsq_geq_threshold, "threshold": 0.3},
            {"func": correlation_sim_vs_res, "threshold": 0.2},
            {
                "func": acf_stoffer_toloi_test,  # Adapted ljungbox for irregular series
                "p_threshold": 0.01,
            },
            # NWARN: criteria for warnings
            # Only reliable when noise meets requirements of white noise:
            {"func": rsq_geq_threshold, "threshold": 0.8},
            {
                "func": uncertainty_parameters,
                "parameters": recharge_model_name,
                "n_std": 1.96,
            },
            {
                "func": acf_stoffer_toloi_test,
                "p_threshold": 0.05,
            },  # check for R² < 0.8
            {
                "func": acf_stoffer_toloi_test,
                "p_threshold": 0.01,
            },  # check when R² > 0.8
        ]

        return checks_zaadnoordijk_2019
    else:
        raise ValueError("Author must be 'brakenhoff_2022' or 'zaadnoordijk_2019'.")
