"""ArviZ rcparams. Based on matplotlib's implementation."""
import sys
import os
from pathlib import Path
import re
import pprint
import warnings
import logging
import locale
from collections.abc import MutableMapping
import numpy as np

_log = logging.getLogger(__name__)


def _make_validate_choice(accepted_values, allow_none=False, typeof=str):
    """Validate value is in accepted_values.

    Parameters
    ----------
    accepted_values : iterable
        Iterable containing all accepted_values.
    allow_none: boolean, optional
        Whether to accept ``None`` in addition to the values in ``accepted_values``.
    typeof: type, optional
        Type the values should be converted to.
    """
    # no blank lines allowed after function docstring by pydocstyle,
    # but black requires white line before function

    def validate_choice(value):
        if allow_none and (value is None or isinstance(value, str) and value.lower() == "none"):
            return None
        try:
            value = typeof(value)
        except (ValueError, TypeError):
            raise ValueError("Could not convert to {}".format(typeof.__name__))
        if isinstance(value, str):
            value = value.lower()

        if value in accepted_values:
            # Convert value to python boolean if string matches
            value = {"true": True, "false": False}.get(value, value)
            return value
        raise ValueError(
            "{} is not one of {}{}".format(
                value, accepted_values, " nor None" if allow_none else ""
            )
        )

    return validate_choice


def _make_validate_choice_regex(accepted_values, accepted_values_regex, allow_none=False):
    """Validate value is in accepted_values with regex.

    Parameters
    ----------
    accepted_values : iterable
        Iterable containing all accepted_values.
    accepted_values_regex : iterable
        Iterable containing all accepted_values with regex string.
    allow_none: boolean, optional
        Whether to accept ``None`` in addition to the values in ``accepted_values``.
    typeof: type, optional
        Type the values should be converted to.
    """
    # no blank lines allowed after function docstring by pydocstyle,
    # but black requires white line before function

    def validate_choice_regex(value):
        if allow_none and (value is None or isinstance(value, str) and value.lower() == "none"):
            return None
        value = str(value)
        if isinstance(value, str):
            value = value.lower()

        if value in accepted_values:
            # Convert value to python boolean if string matches
            value = {"true": True, "false": False}.get(value, value)
            return value
        elif any(re.match(pattern, value) for pattern in accepted_values_regex):
            return value
        raise ValueError(
            "{} is not one of {} or in regex {}{}".format(
                value, accepted_values, accepted_values_regex, " nor None" if allow_none else ""
            )
        )

    return validate_choice_regex


def _validate_positive_int(value):
    """Validate value is a natural number."""
    try:
        value = int(value)
    except ValueError:
        raise ValueError("Could not convert to int")
    if value > 0:
        return value
    else:
        raise ValueError("Only positive values are valid")


def _validate_positive_int_or_none(value):
    """Validate value is a natural number or None."""
    if value is None or isinstance(value, str) and value.lower() == "none":
        return None
    else:
        return _validate_positive_int(value)


def _validate_float(value):
    """Validate value is a float."""
    try:
        value = float(value)
    except ValueError:
        raise ValueError("Could not convert to float")
    return value


def _validate_float_or_none(value):
    """Validate value is a float or None."""
    if value is None or isinstance(value, str) and value.lower() == "none":
        return None
    else:
        return _validate_float(value)


def _validate_probability(value):
    """Validate a probability: a float between 0 and 1."""
    value = _validate_float(value)
    if (value < 0) or (value > 1):
        raise ValueError("Only values between 0 and 1 are valid.")
    return value


def _validate_boolean(value):
    """Validate value is a float."""
    if value not in {True, "true", False, "false"}:
        raise ValueError("Only boolean values are valid.")
    return value is True or value == "true"


def _validate_bokeh_marker(value):
    """Validate the markers."""
    all_markers = (
        "Asterisk",
        "Circle",
        "CircleCross",
        "CircleX",
        "Cross",
        "Dash",
        "Diamond",
        "DiamondCross",
        "Hex",
        "InvertedTriangle",
        "Square",
        "SquareCross",
        "SquareX",
        "Triangle",
        "X",
    )
    if value not in all_markers:
        raise ValueError("{} is not one of {}".format(value, all_markers))
    return value


def _validate_dict_of_lists(values):
    if isinstance(values, dict):
        return {key: tuple(item) for key, item in values.items()}
    else:
        validated_dict = {}
        for value in values:
            tup = value.split(":", 1)
            if len(tup) != 2:
                raise ValueError(f"Could not interpret '{value}' as key: list or str")
            key, vals = tup
            key = key.strip(' "')
            vals = [val.strip(' "') for val in vals.strip(" [],").split(",")]
            if key in validated_dict:
                warnings.warn(f"Repeated key {key} when validating dict of lists")
            validated_dict[key] = tuple(vals)
        return validated_dict


def make_iterable_validator(scalar_validator, length=None, allow_none=False, allow_auto=False):
    """Validate value is an iterable datatype."""
    # based on matplotlib's _listify_validator function

    def validate_iterable(value):
        if allow_none and (value is None or isinstance(value, str) and value.lower() == "none"):
            return None
        if isinstance(value, str):
            if allow_auto and value.lower() == "auto":
                return "auto"
            value = tuple(v.strip("([ ])") for v in value.split(",") if v.strip())
        if np.iterable(value) and not isinstance(value, (set, frozenset)):
            val = tuple(scalar_validator(v) for v in value)
            if length is not None and len(val) != length:
                raise ValueError("Iterable must be of length: {}".format(length))
            return val
        raise ValueError("Only ordered iterable values are valid")

    return validate_iterable


_validate_bokeh_bounds = make_iterable_validator(  # pylint: disable=invalid-name
    _validate_float_or_none, length=2, allow_none=True, allow_auto=True
)

METAGROUPS = {
    "posterior_groups": ["posterior", "posterior_predictive", "sample_stats", "log_likelihood"],
    "prior_groups": ["prior", "prior_predictive", "sample_stats_prior"],
    "posterior_groups_warmup": [
        "_warmup_posterior",
        "_warmup_posterior_predictive",
        "_warmup_sample_stats",
    ],
    "latent_vars": ["posterior", "prior"],
    "observed_vars": ["posterior_predictive", "observed_data", "prior_predictive"],
}

defaultParams = {  # pylint: disable=invalid-name
    "data.http_protocol": ("https", _make_validate_choice({"https", "http"})),
    "data.load": ("lazy", _make_validate_choice({"lazy", "eager"})),
    "data.metagroups": (METAGROUPS, _validate_dict_of_lists),
    "data.index_origin": (0, _make_validate_choice({0, 1}, typeof=int)),
    "data.save_warmup": (False, _validate_boolean),
    "plot.backend": ("matplotlib", _make_validate_choice({"matplotlib", "bokeh"})),
    "plot.max_subplots": (40, _validate_positive_int_or_none),
    "plot.point_estimate": (
        "mean",
        _make_validate_choice({"mean", "median", "mode"}, allow_none=True),
    ),
    "plot.bokeh.bounds_x_range": ("auto", _validate_bokeh_bounds),
    "plot.bokeh.bounds_y_range": ("auto", _validate_bokeh_bounds),
    "plot.bokeh.figure.dpi": (60, _validate_positive_int),
    "plot.bokeh.figure.height": (500, _validate_positive_int),
    "plot.bokeh.figure.width": (500, _validate_positive_int),
    "plot.bokeh.layout.order": (
        "default",
        _make_validate_choice_regex(
            {"default", r"column", r"row", "square", "square_trimmed"}, {r"\d*column", r"\d*row"}
        ),
    ),
    "plot.bokeh.layout.sizing_mode": (
        "fixed",
        _make_validate_choice(
            {
                "fixed",
                "stretch_width",
                "stretch_height",
                "stretch_both",
                "scale_width",
                "scale_height",
                "scale_both",
            }
        ),
    ),
    "plot.bokeh.layout.toolbar_location": (
        "above",
        _make_validate_choice({"above", "below", "left", "right"}, allow_none=True),
    ),
    "plot.bokeh.marker": ("Cross", _validate_bokeh_marker),
    "plot.bokeh.output_backend": ("webgl", _make_validate_choice({"canvas", "svg", "webgl"})),
    "plot.bokeh.show": (True, _validate_boolean),
    "plot.bokeh.tools": (
        "reset,pan,box_zoom,wheel_zoom,lasso_select,undo,save,hover",
        lambda x: x,
    ),
    "plot.matplotlib.constrained_layout": (True, _validate_boolean),
    "plot.matplotlib.show": (False, _validate_boolean),
    "stats.hdi_prob": (0.94, _validate_probability),
    "stats.information_criterion": ("loo", _make_validate_choice({"waic", "loo"})),
    "stats.ic_pointwise": (False, _validate_boolean),
    "stats.ic_scale": ("log", _make_validate_choice({"deviance", "log", "negative_log"})),
}


class RcParams(MutableMapping, dict):  # pylint: disable=too-many-ancestors
    """Class to contain ArviZ default parameters.

    It is implemented as a dict with validation when setting items.
    """

    validate = {key: validate_fun for key, (_, validate_fun) in defaultParams.items()}

    # validate values on the way in
    def __init__(self, *args, **kwargs):  # pylint: disable=super-init-not-called
        self.update(*args, **kwargs)

    def __setitem__(self, key, val):
        """Add validation to __setitem__ function."""
        try:
            try:
                cval = self.validate[key](val)
            except ValueError as verr:
                raise ValueError("Key %s: %s" % (key, str(verr)))
            dict.__setitem__(self, key, cval)
        except KeyError:
            raise KeyError(
                "{} is not a valid rc parameter (see rcParams.keys() for "
                "a list of valid parameters)".format(key)
            )

    def __getitem__(self, key):
        """Use dict getitem method."""
        return dict.__getitem__(self, key)

    def __delitem__(self, key):
        """Raise TypeError if someone ever tries to delete a key from RcParams."""
        raise TypeError("RcParams keys cannot be deleted")

    def clear(self):
        """Raise TypeError if someone ever tries to delete all keys from RcParams."""
        raise TypeError("RcParams keys cannot be deleted")

    def pop(self, key, default=None):
        """Raise TypeError if someone ever tries to delete a key from RcParams."""
        raise TypeError(
            "RcParams keys cannot be deleted. Use .get(key) of RcParams[key] to check values"
        )

    def popitem(self):
        """Raise TypeError if someone ever tries to delete a key from RcParams."""
        raise TypeError(
            "RcParams keys cannot be deleted. Use .get(key) of RcParams[key] to check values"
        )

    def setdefault(self, key, default=None):
        """Raise error when using setdefault, defaults are handled on initialization."""
        raise TypeError(
            "Defaults in RcParams are handled on object initialization during library"
            "import. Use arvizrc file instead."
            ""
        )

    def items(self):
        """Explicit use of MutableMapping attributes."""
        return MutableMapping.items(self)

    def keys(self):
        """Explicit use of MutableMapping attributes."""
        return MutableMapping.keys(self)

    def values(self):
        """Explicit use of MutableMapping attributes."""
        return MutableMapping.values(self)

    def __repr__(self):
        """Customize repr of RcParams objects."""
        class_name = self.__class__.__name__
        indent = len(class_name) + 1
        repr_split = pprint.pformat(dict(self), indent=1, width=80 - indent).split("\n")
        repr_indented = ("\n" + " " * indent).join(repr_split)
        return "{}({})".format(class_name, repr_indented)

    def __str__(self):
        """Customize str/print of RcParams objects."""
        return "\n".join(map("{0[0]:<22}: {0[1]}".format, sorted(self.items())))

    def __iter__(self):
        """Yield sorted list of keys."""
        yield from sorted(dict.__iter__(self))

    def __len__(self):
        """Use dict len method."""
        return dict.__len__(self)

    def find_all(self, pattern):
        """
        Find keys that match a regex pattern.

        Return the subset of this RcParams dictionary whose keys match,
        using :func:`re.search`, the given ``pattern``.

        Notes
        -----
            Changes to the returned dictionary are *not* propagated to
            the parent RcParams dictionary.
        """
        pattern_re = re.compile(pattern)
        return RcParams((key, value) for key, value in self.items() if pattern_re.search(key))

    def copy(self):
        """Get a copy of the RcParams object."""
        return {k: dict.__getitem__(self, k) for k in self}


def get_arviz_rcfile():
    """Get arvizrc file.

    The file location is determined in the following order:

    - ``$PWD/arvizrc``
    - ``$ARVIZ_DATA/arvizrc``
    - On Linux,
        - ``$XDG_CONFIG_HOME/arviz/arvizrc`` (if ``$XDG_CONFIG_HOME``
          is defined)
        - or ``$HOME/.config/arviz/arvizrc`` (if ``$XDG_CONFIG_HOME``
          is not defined)
    - On other platforms,
        - ``$HOME/.arviz/arvizrc`` if ``$HOME`` is defined

    Otherwise, the default defined in ``rcparams.py`` file will be used.
    """
    # no blank lines allowed after function docstring by pydocstyle,
    # but black requires white line before function

    def gen_candidates():
        yield os.path.join(os.getcwd(), "arvizrc")
        arviz_data_dir = os.environ.get("ARVIZ_DATA")
        if arviz_data_dir:
            yield os.path.join(arviz_data_dir, "arvizrc")
        xdg_base = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
        if sys.platform.startswith(("linux", "freebsd")):
            configdir = str(Path(xdg_base, "arviz"))
        else:
            configdir = str(Path.home() / ".arviz")
        yield os.path.join(configdir, "arvizrc")

    for fname in gen_candidates():
        if os.path.exists(fname) and not os.path.isdir(fname):
            return fname

    return None


def read_rcfile(fname):
    """Return :class:`arviz.RcParams` from the contents of the given file.

    Unlike `rc_params_from_file`, the configuration class only contains the
    parameters specified in the file (i.e. default values are not filled in).
    """
    _error_details_fmt = 'line #%d\n\t"%s"\n\tin file "%s"'

    config = RcParams()
    with open(fname, "r") as rcfile:
        try:
            multiline = False
            for line_no, line in enumerate(rcfile, 1):
                strippedline = line.split("#", 1)[0].strip()
                if not strippedline:
                    continue
                if multiline:
                    if strippedline == "}":
                        multiline = False
                        val = aux_val
                    else:
                        aux_val.append(strippedline)
                        continue
                else:
                    tup = strippedline.split(":", 1)
                    if len(tup) != 2:
                        error_details = _error_details_fmt % (line_no, line, fname)
                        _log.warning("Illegal %s", error_details)
                        continue
                    key, val = tup
                    key = key.strip()
                    val = val.strip()
                    if key in config:
                        _log.warning("Duplicate key in file %r line #%d.", fname, line_no)
                    if key in {"data.metagroups"}:
                        aux_val = []
                        multiline = True
                        continue
                try:
                    config[key] = val
                except ValueError as verr:
                    error_details = _error_details_fmt % (line_no, line, fname)
                    raise ValueError("Bad val {} on {}\n\t{}".format(val, error_details, str(verr)))

        except UnicodeDecodeError:
            _log.warning(
                "Cannot decode configuration file %s with encoding "
                "%s, check LANG and LC_* variables.",
                fname,
                locale.getpreferredencoding(do_setlocale=False) or "utf-8 (default)",
            )
            raise

        return config


def rc_params(ignore_files=False):
    """Read and validate arvizrc file."""
    fname = None
    if not ignore_files:
        fname = get_arviz_rcfile()
    defaults = RcParams([(key, default) for key, (default, _) in defaultParams.items()])
    if fname is not None:
        file_defaults = read_rcfile(fname)
        defaults.update(file_defaults)
    return defaults


rcParams = rc_params()  # pylint: disable=invalid-name


class rc_context:  # pylint: disable=invalid-name
    """
    Return a context manager for managing rc settings.

    Parameters
    ----------
    rc : dict, optional
        Mapping containing the rcParams to modify temporally.
    fname : str, optional
        Filename of the file containig the rcParams to use inside the rc_context.

    Examples
    --------
    This allows one to do::

        with az.rc_context(fname='pystan.rc'):
            idata = az.load_arviz_data("radon")
            az.plot_posterior(idata, var_names=["gamma"])

    The plot would have settings from 'screen.rc'

    A dictionary can also be passed to the context manager::

        with az.rc_context(rc={'plot.max_subplots': None}, fname='pystan.rc'):
            idata = az.load_arviz_data("radon")
            az.plot_posterior(idata, var_names=["gamma"])

    The 'rc' dictionary takes precedence over the settings loaded from
    'fname'. Passing a dictionary only is also valid.
    """

    # Based on mpl.rc_context

    def __init__(self, rc=None, fname=None):
        self._orig = rcParams.copy()
        if fname:
            file_rcparams = read_rcfile(fname)
            rcParams.update(file_rcparams)
        if rc:
            rcParams.update(rc)

    def __enter__(self):
        """Define enter method of context manager."""
        return self

    def __exit__(self, exc_type, exc_value, exc_tb):
        """Define exit method of context manager."""
        rcParams.update(self._orig)
