# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
This module provides the tools used to internally run the astropy test suite
from the installed astropy.  It makes use of the `pytest` testing framework.
"""
from __future__ import (absolute_import, division, print_function,
                        unicode_literals)

import functools
import os
import sys
import types
import warnings

import pytest

from ..extern import six
from ..extern.six.moves import cPickle as pickle

try:
    # Import pkg_resources to prevent it from issuing warnings upon being
    # imported from within py.test.  See
    # https://github.com/astropy/astropy/pull/537 for a detailed explanation.
    import pkg_resources  # pylint: disable=W0611
except ImportError:
    pass

from ..utils.exceptions import (AstropyDeprecationWarning,
                                AstropyPendingDeprecationWarning)


# For backward-compatibility with affiliated packages
from .runner import TestRunner  # pylint: disable=W0611

__all__ = ['raises', 'enable_deprecations_as_exceptions', 'remote_data',
           'treat_deprecations_as_exceptions', 'catch_warnings',
           'assert_follows_unicode_guidelines', 'quantity_allclose',
           'assert_quantity_allclose', 'check_pickling_recovery',
           'pickle_protocol', 'generic_recursive_equality_test']

# pytest marker to mark tests which get data from the web
remote_data = pytest.mark.remote_data


# This is for Python 2.x and 3.x compatibility.  distutils expects
# options to all be byte strings on Python 2 and Unicode strings on
# Python 3.
def _fix_user_options(options):
    def to_str_or_none(x):
        if x is None:
            return None
        return str(x)

    return [tuple(to_str_or_none(x) for x in y) for y in options]


def _save_coverage(cov, result, rootdir, testing_path):
    """
    This method is called after the tests have been run in coverage mode
    to cleanup and then save the coverage data and report.
    """
    from ..utils.console import color_print

    if result != 0:
        return

    # The coverage report includes the full path to the temporary
    # directory, so we replace all the paths with the true source
    # path. Note that this will not work properly for packages that still
    # rely on 2to3.
    try:
        # Coverage 4.0: _harvest_data has been renamed to get_data, the
        # lines dict is private
        cov.get_data()
    except AttributeError:
        # Coverage < 4.0
        cov._harvest_data()
        lines = cov.data.lines
    else:
        lines = cov.data._lines

    for key in list(lines.keys()):
        new_path = os.path.relpath(
            os.path.realpath(key),
            os.path.realpath(testing_path))
        new_path = os.path.abspath(
            os.path.join(rootdir, new_path))
        lines[new_path] = lines.pop(key)

    color_print('Saving coverage data in .coverage...', 'green')
    cov.save()

    color_print('Saving HTML coverage report in htmlcov...', 'green')
    cov.html_report(directory=os.path.join(rootdir, 'htmlcov'))


class raises(object):
    """
    A decorator to mark that a test should raise a given exception.
    Use as follows::

        @raises(ZeroDivisionError)
        def test_foo():
            x = 1/0

    This can also be used a context manager, in which case it is just
    an alias for the ``pytest.raises`` context manager (because the
    two have the same name this help avoid confusion by being
    flexible).
    """

    # pep-8 naming exception -- this is a decorator class
    def __init__(self, exc):
        self._exc = exc
        self._ctx = None

    def __call__(self, func):
        @functools.wraps(func)
        def run_raises_test(*args, **kwargs):
            pytest.raises(self._exc, func, *args, **kwargs)
        return run_raises_test

    def __enter__(self):
        self._ctx = pytest.raises(self._exc)
        return self._ctx.__enter__()

    def __exit__(self, *exc_info):
        return self._ctx.__exit__(*exc_info)


_deprecations_as_exceptions = False
_include_astropy_deprecations = True
_modules_to_ignore_on_import = set([
    'compiler',  # A deprecated stdlib module used by py.test
    'scipy',
    'pygments',
    'ipykernel',
    'setuptools'])
_warnings_to_ignore_by_pyver = {
    (3, 4): set([
        # py.test reads files with the 'U' flag, which is now
        # deprecated in Python 3.4.
        r"'U' mode is deprecated",
        # BeautifulSoup4 triggers warning in stdlib's html module.x
        r"The strict argument and mode are deprecated\.",
        r"The value of convert_charrefs will become True in 3\.5\. "
        r"You are encouraged to set the value explicitly\."]),
    (3, 5): set([
        # py.test raises this warning on Python 3.5.
        # This can be removed when fixed in py.test.
        # See https://github.com/pytest-dev/pytest/pull/1009
        r"inspect\.getargspec\(\) is deprecated, use "
        r"inspect\.signature\(\) instead"])}


def enable_deprecations_as_exceptions(include_astropy_deprecations=True,
                                      modules_to_ignore_on_import=[],
                                      warnings_to_ignore_by_pyver={}):
    """
    Turn on the feature that turns deprecations into exceptions.

    Parameters
    ----------
    include_astropy_deprecations : bool
        If set to `True`, ``AstropyDeprecationWarning`` and
        ``AstropyPendingDeprecationWarning`` are also turned into exceptions.

    modules_to_ignore_on_import : list of str
        List of additional modules that generate deprecation warnings
        on import, which are to be ignored. By default, these are already
        included: ``compiler``, ``scipy``, ``pygments``, ``ipykernel``, and
        ``setuptools``.

    warnings_to_ignore_by_pyver : dict
        Dictionary mapping tuple of ``(major, minor)`` Python version to
        a list of deprecation warning messages to ignore. This is in
        addition of those already ignored by default
        (see ``_warnings_to_ignore_by_pyver`` values).

    """
    global _deprecations_as_exceptions
    _deprecations_as_exceptions = True

    global _include_astropy_deprecations
    _include_astropy_deprecations = include_astropy_deprecations

    global _modules_to_ignore_on_import
    _modules_to_ignore_on_import.update(modules_to_ignore_on_import)

    global _warnings_to_ignore_by_pyver
    for key, val in six.iteritems(warnings_to_ignore_by_pyver):
        if key in _warnings_to_ignore_by_pyver:
            _warnings_to_ignore_by_pyver[key].update(val)
        else:
            _warnings_to_ignore_by_pyver[key] = set(val)


def treat_deprecations_as_exceptions():
    """
    Turn all DeprecationWarnings (which indicate deprecated uses of
    Python itself or Numpy, but not within Astropy, where we use our
    own deprecation warning class) into exceptions so that we find
    out about them early.

    This completely resets the warning filters and any "already seen"
    warning state.
    """
    # First, totally reset the warning state. The modules may change during
    # this iteration thus we copy the original state to a list to iterate
    # on. See https://github.com/astropy/astropy/pull/5513.
    for module in list(six.itervalues(sys.modules)):
        # We don't want to deal with six.MovedModules, only "real"
        # modules.
        if (isinstance(module, types.ModuleType) and
                hasattr(module, '__warningregistry__')):
            del module.__warningregistry__

    if not _deprecations_as_exceptions:
        return

    warnings.resetwarnings()

    # Hide the next couple of DeprecationWarnings
    warnings.simplefilter('ignore', DeprecationWarning)
    # Here's the wrinkle: a couple of our third-party dependencies
    # (py.test and scipy) are still using deprecated features
    # themselves, and we'd like to ignore those.  Fortunately, those
    # show up only at import time, so if we import those things *now*,
    # before we turn the warnings into exceptions, we're golden.
    for m in _modules_to_ignore_on_import:
        try:
            __import__(m)
        except ImportError:
            pass

    # Now, start over again with the warning filters
    warnings.resetwarnings()
    # Now, turn DeprecationWarnings into exceptions
    warnings.filterwarnings("error", ".*", DeprecationWarning)

    # Only turn astropy deprecation warnings into exceptions if requested
    if _include_astropy_deprecations:
        warnings.filterwarnings("error", ".*", AstropyDeprecationWarning)
        warnings.filterwarnings("error", ".*", AstropyPendingDeprecationWarning)

    for v in _warnings_to_ignore_by_pyver:
        if sys.version_info[:2] >= v:
            for s in _warnings_to_ignore_by_pyver[v]:
                warnings.filterwarnings("ignore", s, DeprecationWarning)


class catch_warnings(warnings.catch_warnings):
    """
    A high-powered version of warnings.catch_warnings to use for testing
    and to make sure that there is no dependence on the order in which
    the tests are run.

    This completely blitzes any memory of any warnings that have
    appeared before so that all warnings will be caught and displayed.

    ``*args`` is a set of warning classes to collect.  If no arguments are
    provided, all warnings are collected.

    Use as follows::

        with catch_warnings(MyCustomWarning) as w: