# -*- coding: utf-8 -*-
"""Dynamic resource-based unittest suites made easy."""

import re
import unittest
import functools

import six

try:  # python 3.5+
    import typing
except ImportError:
    pass

try:  # python 3.7+
    import importlib.resources as res
except ImportError:
    import importlib_resources as res  # type: ignore  # noqa


__version__ = '0.2.0'
__all__ = ['ResourceTestMeta', 'ResourceTestCase', '__version__']


class ResourceTestMeta(type):
    """
    Resource-based :class:`unittest.TestCase` metaclass.

    This metaclass generates test methods for every resource matching
    given pattern (ie. regex).

    Automatically-generated resource test methods will end calling
    :meth:`ResourceTestCase.meta_test` method with both module name and
    resource name as arguments, which will be responsible of implementing
    the actual test logic.

    The following functions from :mod:`importlib.resources` are
    injected as :class:`staticmethod`:

    - :func:`importlib.resources.contents` as `contents`
    - :func:`importlib.resources.is_resource` as `is_resource`
    - :func:`importlib.resources.open_binary` as `open_binary`
    - :func:`importlib.resources.open_text` as `open_text`
    - :func:`importlib.resources.path` as `path`
    - :func:`importlib.resources.read_binary` as `read_binary`
    - :func:`importlib.resources.read_text` as `read_text`

    Honored class attributes:

    - ``meta_test``: actual test function accepting module and resource.
    - ``meta_resources``: iterable module/resource pairs (defaults to empty).
    - ``meta_module``: module to inspect for resources (defaults to None).
    - ``meta_module_pattern``: regexp matching resources (defaults to None).
    - ``meta_resource_pattern``: regexp matching resources (defaults to None).
    - ``meta_prefix``: prefix to add to tests (defaults to `resource`).
    - ``meta_suffix``: suffix to add to tests (defaults to empty string).

    You must especify either :attr:`meta_resources` or both
    attr:`meta_resource_pattern` and :attr:`meta_module`, otherwise no
    test will be generated.

    Attribute :attr:`meta_module_pattern` is optional and defaults
    to ``None``, meaning no module will be filtered out by default.
    """

    resource_methods = (
        'contents',
        'is_resource',
        'open_binary',
        'open_text',
        'path',
        'read_binary',
        'read_text',
        )
    escape_pattern = re.compile(r'[^a-zA-Z0-9]+')
    autogenerated_flag = '_from_resource_test_meta'

    @classmethod
    def _filter(cls,
                module_pattern,  # type: typing.Pattern
                resource_pattern,  # type: typing.Pattern
                module,  # type: str
                resource,  # type: str
                ):
        # type: (...) -> bool
        """
        Filter given resource based on regex patterns.

        :param module_pattern: regular expression used to match modules
        :type module_pattern: typing.Pattern
        :param resource_pattern: regular expression used to match resources
        :type resource_pattern: typing.Pattern
        :param module: module name
        :type module: str
        :param resource: resource name
        :type resource: str
        :returns: True if resource is accepted, False otherwise
        :rtype: bool
        """
        return bool(
            (module_pattern is None or module_pattern.search(module)) and
            resource_pattern and resource_pattern.search(resource)
            )

    @classmethod
    def iter_content_items(cls,
                           module,  # type: str
                           item,  # type: str
                           filter,  # type: typing.Callable[[str, str], bool]
                           ):
        # type: (...) -> typing.Generator[typing.Tuple[str, str], None, None]
        """
        Iterate resources from given submodule or resource, recursively.

        Only resources matching any of given given pattern will be
        returned.

        :param module: module name where resources will be search on
        :type module: str
        :param item: submodule or resource name
        :type item: str
        :param filter: resource filtering function
        :type filter: typing.Callable[[str, str], bool]
        :returns: iterator of tuples with module and resource name
        :rtype: typing.Generator[typing.Tuple[str, str], None, None]
        """
        if not res.is_resource(module, item):
            submodule = '%s.%s' % (module, item)
            for subitem in cls.iter_contents(submodule, filter):
                yield subitem
        elif filter(module, item):
            yield (module, item)

    @classmethod
    def iter_contents(cls,
                      module,  # type: str
                      filter,  # type: typing.Callable[[str, str], bool]
                      ):
        # type: (...) -> typing.Generator[typing.Tuple[str, str], None, None]
        """
        Iterate resources from given module, recursively.

        Only resources matching any of given given pattern will be
        returned.

        :param module: module name where resources will be search on
        :type module: str
        :param filter: resource filtering function
        :type filter: typing.Callable[[str, str], bool]
        :returns: iterator of tuples with module and resource name
        :rtype: typing.Iterator[typing.Tuple[str, str], None]
        """
        if module is None:
            return
        try:
            for item in res.contents(module):
                for subitem in cls.iter_content_items(module, item, filter):
                    yield subitem
        except ImportError:
            pass

    @classmethod
    def create_method(cls,
                      base,  # type: str
                      module,  # type: str
                      resource,  # type: str
                      prefix,  # type: str
                      suffix,  # type: str
                      ):
        # type: (...) -> typing.Callable[[object], None]
        """
        Genetrate a test_method from given parameters.

        This method creates an instance method which applies an already
        defined :meth:`ResourceTestCase.meta_test` to the given
        resource.

        :param base: base module name will be stripped from test name
        :type base: str
        :param module: resource module name, used as namespace
        :type module: str
        :param resource: resource name
        :type resource: str
        :param pattern: regular expression used to strip resources
        :type pattern: typing.Pattern
        :param prefix: test name prefix
        :type prefix: str
        :param suffix: test name suffix
        :type suffix: str
        :return: method function named test_{prefix}_{namespace}_{name}
        :type: typing.Callable[[], None]
        """
        def test(self):
            self.meta_test(module, resource)

        under_base = base and (module == base or module.startswith(base + '.'))
        test.__doc__ = (
            'Test resource :mod:`{module}` ``{resource}``.'
            .format(
                module=module,
                resource=resource,
                )
            )
        test.__name__ = cls.escape_pattern.sub('_', '_'.join(
            filter(None, (
                'test',
                prefix,
                module[len(base) + 1:] if under_base else module,
                resource,
                suffix
                ))
            ))
        return test

    def __init__(self,
                 name,  # type: str
                 bases,  # type: typing.List[type]
                 dct,  # type: typing.Mapping[str, typing.Any]
                 ):
        # type: (...) -> None
        """
        Metaclass constructor, initializes the target class.

        :param name: target class name
        :type name: str
        :param bases: base class hierarchy
        :type bases: typing.Iterable[type]
        :param dct: class __dict__
        :type dct: typing.Mapping[str, typing.Any]
        """
        super_init = super(ResourceTestMeta, self).__init__
        super_init(name, bases, dct)  # type: ignore

        base = getattr(self, 'meta_module', None)
        mpattern = getattr(self, 'meta_module_pattern', None)
        rpattern = getattr(self, 'meta_resource_pattern', None)
        prefix = getattr(self, 'meta_prefix', 'resource')
        suffix = getattr(self, 'meta_suffix', '')
        extra = getattr(self, 'meta_resources', ())

        filter = functools.partial(self._filter, mpattern, rpattern)

        # generate tests from files
        for resources in (self.iter_contents(base, filter), extra):
            for module, resource in resources:
                test = self.create_method(
                    base, module, resource, prefix, suffix)
                setattr(self, test.__name__, test)

        # add resource methods if not already defined
        for method in self.resource_methods:
            if not hasattr(self, method):
                setattr(self, method, staticmethod(getattr(res, method)))


class ResourceTestCase(six.with_metaclass(ResourceTestMeta,
                                          unittest.TestCase)):
    """
    TestCase base class implementing ResourceTestMeta.

    This class is just a :class:`unittest.TestCase` base class which
    already includes :class:`ResourceTestMeta` metaclass for convenience.

    If you need other kind of inheritance (ie, using your own base),
    just apply :class:`ResourceTestMeta` metaclass as usual.

    The following functions from :mod:`importlib.resources` are
    available as static methods:

    - :func:`importlib.resources.contents` as :meth:`contents`.
    - :func:`importlib.resources.is_resource` as :meth:`is_resource`.
    - :func:`importlib.resources.open_binary` as :meth:`open_binary`.
    - :func:`importlib.resources.open_text` as :meth:`open_text`.
    - :func:`importlib.resources.path` as :meth:`path`.
    - :func:`importlib.resources.read_binary` as :meth:`read_binary`.
    - :func:`importlib.resources.read_text` as :meth:`read_text`.

    :cvar meta_resources: iterable of resources
    :vartype meta_resources: typing.Iterable[typing.Tuple[str, str]]
    :cvar meta_module: module to inspect for resources
    :vartype meta_module: typing.Optional[str]
    :cvar meta_module_pattern:  module regexp
    :vartype meta_module_pattern: typing.Optional[typing.Pattern]
    :cvar meta_resource_pattern: resource regexp
    :vartype meta_resource_pattern: typing.Optional[typing.Pattern]
    :cvar meta_prefix: prefix to add to tests
    :vartype meta_prefix: typing.Optional[str]
    :cvar meta_suffix: suffix to add to tests
    :vartype meta_suffix: typing.Optional[str]
    """

    # resource methods (to help linters)
    contents = staticmethod(res.contents)
    is_resource = staticmethod(res.is_resource)
    open_binary = staticmethod(res.open_binary)
    open_text = staticmethod(res.open_text)
    path = staticmethod(res.path)
    read_binary = staticmethod(res.read_binary)
    read_text = staticmethod(res.read_text)

    # default values (to help linters)
    meta_resources = ()  # type: typing.Iterable[typing.Tuple[str, str]]
    meta_module = None  # type: typing.Optional[str]
    meta_module_pattern = None  # type: typing.Optional[typing.Pattern]
    meta_resource_pattern = None  # type: typing.Optional[typing.Pattern]
    meta_prefix = 'resource'  # type: typing.Optional[str]
    meta_suffix = None  # type: typing.Optional[str]

    def meta_test(self, module, resource):
        # type: (str, str) -> None
        """
        Abstract method to implement tests for given module resource.

        This method is not implemented.

        :param module: resource module name
        :type module: str
        :param resource: resource name
        :type resource: str

        :raises NotImplementedError: unconditionally
        """
        raise NotImplementedError
