#!/usr/bin/env python
# -*- coding: utf-8 -*-

# pylint: disable=line-too-long
"""A series of tests that access content outside of this repository."""

import textwrap
import unittest
from unittest import mock
from urllib import error as urllib_error
from urllib import request as urllib_request

from .. import common


def _skip_from_ssl_error(url: str) -> bool:
    """Check if the given URL can be reached."""
    # This function is mostly meant for pypy3
    try:
        from _cffi_ssl._stdssl import error  # type: ignore # pylint: disable=import-outside-toplevel
    except ImportError:
        return False

    try:
        with urllib_request.urlopen(url) as handler:
            result = handler.getcode()

        return result == 200  # 200 means "URL not found"
    except (error.SSLError, urllib_error.URLError):
        return True


class SourceReader(unittest.TestCase):
    """Check that external queries work."""

    @mock.patch("code_include.source_code._get_app_inventory")
    def _test_import(
        self,
        content: list[str],
        expected: str,
        _get_app_inventory: mock.MagicMock,
    ) -> None:
        """Test for some source code from an importable Python object.

        Args:
            content:
                The lines that the user provides in a standard code-include block.
            expected:
                The converted source-code text that will be tested for.
            _get_app_inventory:
                The function that's normally used to query a Sphinx
                project's inventory to find every HTML file-path and
                tag-able header.

        """
        _get_app_inventory.return_value = {"non-empty": {"information": tuple()}}
        directive = common.make_mock_directive(content)
        nodes = directive.run()

        self.assertNotEqual([], nodes)
        self.assertEqual(1, len(nodes))
        self.assertEqual(expected, nodes[0].astext())

    @mock.patch("code_include.source_code._get_source_module_data")
    @mock.patch("code_include.source_code._get_source_code_from_object")
    @mock.patch("code_include.source_code._get_app_inventory")
    @unittest.skipIf(
        _skip_from_ssl_error("https://ways.readthedocs.io/en/latest/objects.inv"),
        "URL could not be reached",
    )
    def test_url(
        self,
        _get_app_inventory: mock.MagicMock,
        _get_source_code_from_object: mock.MagicMock,
        _get_source_module_data: mock.MagicMock,
    ) -> None:
        """Get the source-code of some project from a URL.

        Args:
            _get_app_inventory:
                The function that's normally used to query a Sphinx
                project's inventory to find every HTML file-path and
                tag-able header.
            _get_source_code_from_object:
                Force this function to get the code from intersphinx.
            _get_source_module_data:
                A function that is mocked so that we can skip some of
                the less important tag-parsing functions and get to the
                point of this function - testing generated source-code.

        """
        path = "https://ways.readthedocs.io/en/latest/objects.inv"
        _get_app_inventory.return_value = common.load_cache_from_url(path)
        _get_source_module_data.return_value = (
            "https://ways.readthedocs.io/en/latest/_modules/ways/base/plugin.html",
            "DataPlugin",
        )
        _get_source_code_from_object.return_value = ""

        expected = textwrap.dedent(
            """\
            class DataPlugin(Plugin):

                '''An add-on that was made from a serialized file (JSON/YAML/etc).

                This class behaves exactly like a regular Plugin object and is stored
                in the same space as Plugin objects.

                DataPlugin does not add itself to the cache automatically. It is the
                responsibility of some other class/function to register it to Ways.

                We do this so that we can have better control over the DataPlugin's args
                and its assignment before it hits the cache.

                '''

                add_to_registry = False

                def __init__(self, name, sources, info, assignment):
                    '''Create the object and set its sources.

                    Args:
                        sources (list[str]): The location(s) that defined this Plugin.
                        info (dict[str]): The information that came from a JSON or YAML file
                                          which sets the base settings of the object.
                        assignment (str): The grouping location where this Plugin will go.
                                          This assignment must be the same as the Context
                                          that this Plugin is meant for.

                    Raises:
                        ValueError: If there are missing keys in data that this class needs.

                    '''
                    missing_required_keys = set(self._required_keys()) - set(info.keys())
                    if missing_required_keys:
                        raise ValueError('Info: "{info}" is missing keys, "{keys}".'
                                         ''.format(info=info, keys=missing_required_keys))

                    # Give this plugin a UUID if none was given, for us
                    # Data is assumed to be a core.classes.dict_class.ReadOnlyDict so we
                    # try to unlock it, here. If it's not a custom dict, just let it pass
                    #
                    try:
                        is_settable = info.settable
                        info.settable = True
                    except AttributeError:
                        pass

                    info.setdefault('uuid', str(uuid.uuid4()))

                    try:
                        info.settable = is_settable
                    except AttributeError:
                        pass

                    self.name = name
                    self._info = info
                    self.sources = tuple(sources)
                    self._data = self._info.get('data', dict())
                    self.assignment = assignment

                    super(DataPlugin, self).__init__()

                @classmethod
                def _required_keys(cls):
                    '''tuple[str]: Keys that must be set in our Plugin.'''
                    return ('hierarchy', )

                def is_path(self):
                    '''If the mapping is a filepath or None if unsure.

                    Returns:
                        bool or NoneType: If the mapping is a path to a file/folder on disk.

                    '''
                    try:
                        return self._info['path']
                    except KeyError:
                        return None

                def get_assignment(self):
                    '''str: Where this Plugin lives in Ways, along with its hierarchy.'''
                    return self.assignment

                def get_groups(self):
                    '''Get the groups that this Plugin evaluates onto.

                    Note:
                        The term 'groups' is not the same as the assignment of a Plugin.
                        They are two different things.

                    Returns:
                        tuple[str]: The groups.

                    '''
                    value = check.force_itertype(self._info.get('groups', ('*', )), itertype=tuple)
                    is_empty = not [val for val in value if val.strip()]
                    if is_empty:
                        value = ('*', )
                    return value

                def get_hierarchy(self):
                    '''tuple[str] or str: The location that this Plugin exists within.'''
                    return self._info['hierarchy']

                def get_mapping(self):
                    '''str: The physical location of this Plugin (on the filesystem).'''
                    try:
                        return self._info['mapping']
                    except KeyError:
                        return ''

                def get_mapping_details(self):
                    '''dict[str]: Information about the mapping, if needed.'''
                    return self._info.get('mapping_details', dict())

                def get_max_folder(self):
                    '''str: The furthest location up that this plugin can navigate to.'''
                    return self._info.get('max_folder', '')

                def get_platforms(self):
                    '''set[str]: The platforms that this Plugin is allowed to run on.'''
                    platforms = ways.get_known_platfoms()
                    return set(self._info.get('platforms', platforms))

                def get_uses(self):
                    '''tuple[str]: The Context hierarchies this instance depends on.'''
                    return self._info.get('uses', tuple())

                def get_uuid(self):
                    '''str: A unique ID for this plugin.'''
                    return self._info.get('uuid', '')

                def __repr__(self):
                    '''str: The information needed to reproduce this instance.'''
                    return '{cls_}(sources={sources!r}, data={data})'.format(
                        cls_=self.__class__.__name__,
                        sources=self.sources,
                        data=dict(self._info))

                def __str__(self):
                    '''str: A more concise print-out of this instance.'''
                    return '{cls_}(hierarchy={hierarchy}, sources={sources!r})'.format(
                        cls_=self.__class__.__name__,
                        hierarchy=self.get_hierarchy(),
                        sources=self.sources)"""
        )

        content = [":class:`ways.base.plugin.DataPlugin`"]
        directive = common.make_mock_directive(content)
        nodes = directive.run()

        self.assertNotEqual([], nodes)
        self.assertEqual(1, len(nodes))
        self.assertEqual(expected, nodes[0].astext())

    def test_import(self) -> None:
        """Get the source-code of an importable object."""
        expected = textwrap.dedent(
            '''\
            def fill(text, width=70, **kwargs):
                """Fill a single paragraph of text, returning a new string.

                Reformat the single paragraph in 'text' to fit in lines of no more
                than 'width' columns, and return a new string containing the entire
                wrapped paragraph.  As with wrap(), tabs are expanded and other
                whitespace characters converted to space.  See TextWrapper class for
                available keyword args to customize wrapping behaviour.
                """
                w = TextWrapper(width=width, **kwargs)
                return w.fill(text)'''
        )

        content = [":func:`textwrap.fill`"]
        self._test_import(content, expected)  # pylint: disable=no-value-for-parameter


@unittest.skipIf(
    _skip_from_ssl_error("https://ways.readthedocs.io/en/latest/objects.inv"),
    "URL could not be reached",
)
class InventoryReader(unittest.TestCase):
    """Check that external queries to Sphinx intersphinx inventtory files work."""

    @mock.patch("code_include.source_code._get_source_code_from_object")
    @mock.patch("code_include.source_code._get_app_inventory")
    def _test_import(
        self,
        content: list[str],
        expected: str,
        _get_app_inventory: mock.MagicMock,
        _get_source_code_from_object: mock.MagicMock,
    ) -> None:
        """A generic test function. It tests for some source code from an inventory.

        Args:
            content:
                The lines that the user provides in a standard code-include block.
            expected:
                The converted source-code text that will be tested for.
            _get_app_inventory:
                The function that gets dictionary information (which
                later finds the Sphinx Python source-code).
            _get_source_code_from_object:
                The function that's used to import a Python object to
                get its source-code to find every HTML file-path and
                tag-able header.

        """
        _get_source_code_from_object.return_value = ""
        path = "https://ways.readthedocs.io/en/latest/objects.inv"
        _get_app_inventory.return_value = common.load_cache_from_url(path)
        _get_source_code_from_object.return_value = ""

        directive = common.make_mock_directive(content)
        nodes = directive.run()

        self.assertNotEqual([], nodes)
        self.assertEqual(1, len(nodes))
        self.assertEqual(expected, nodes[0].astext())

    @mock.patch("code_include.source_code._get_source_module_data")
    def test_class(self, _get_source_module_data: mock.MagicMock) -> None:
        """Get the source-code of an importable class."""
        _get_source_module_data.return_value = (
            "https://ways.readthedocs.io/en/latest/_modules/ways/base/plugin.html",
            "Plugin",
        )

        content = [":class:`ways.base.plugin.Plugin`"]
        expected = textwrap.dedent(
            """\
            @six.add_metaclass(PluginRegistry)
            class Plugin(object):

                '''An add-on that is later retrieved by Context to gather its data.'''

                add_to_registry = True
                _data = dict()

                @property
                def data(self):
                    '''dict[str]: The display properties (like {'color': 'red'}).'''
                    return self._data

                @data.setter
                def data(self, value):
                    '''Set the data on this instance with whatever value is.

                    Args:
                        value (dict[str]): The new values for this instance.

                    '''
                    self._data = value"""
        )

        self._test_import(content, expected)  # pylint: disable=no-value-for-parameter

    @mock.patch("code_include.source_code._get_source_module_data")
    def test_function(self, _get_source_module_data: mock.MagicMock) -> None:
        """Get the source-code of an importable function."""
        _get_source_module_data.return_value = (
            "https://ways.readthedocs.io/en/latest/_modules/ways/base/plugin.html",
            "get_assignment",
        )

        content = [":func:`ways.base.plugin.get_assignment`"]
        expected = textwrap.dedent(
            """\
            def get_assignment(obj):
                '''str: Get an object's assignment or fallback to ways.DEFAULT_ASSIGNMENT.'''
                try:
                    return obj.get_assignment()
                except AttributeError:
                    return common.DEFAULT_ASSIGNMENT"""
        )

        self._test_import(content, expected)  # pylint: disable=no-value-for-parameter

    @mock.patch("code_include.source_code._get_source_module_data")
    def test_method(self, _get_source_module_data: mock.MagicMock) -> None:
        """Get the source-code of an importable method."""
        _get_source_module_data.return_value = (
            "https://ways.readthedocs.io/en/latest/_modules/ways/base/plugin.html",
            "DataPlugin.get_groups",
        )

        content = [":meth:`ways.base.plugin.DataPlugin.get_groups`"]
        expected = textwrap.dedent(
            """\
            def get_groups(self):
                '''Get the groups that this Plugin evaluates onto.

                Note:
                    The term 'groups' is not the same as the assignment of a Plugin.
                    They are two different things.

                Returns:
                    tuple[str]: The groups.

                '''
                value = check.force_itertype(self._info.get('groups', ('*', )), itertype=tuple)
                is_empty = not [val for val in value if val.strip()]
                if is_empty:
                    value = ('*', )
                return value"""
        )

        self._test_import(content, expected)  # pylint: disable=no-value-for-parameter

    @mock.patch("code_include.source_code._get_source_module_data")
    def test_module(
        self,
        _get_source_module_data: mock.MagicMock,
    ) -> None:
        """Get the source-code of an importable module."""
        _get_source_module_data.return_value = (
            "https://ways.readthedocs.io/en/latest/_modules/ways/base/plugin.html",
            "",
        )

        content = [":mod:`ways.base.plugin`"]

        expected = textwrap.dedent(
            """\
            #!/usr/bin/env python
            # -*- coding: utf-8 -*-

            '''A module that holds Plugin classes and objects that combine into a Context.'''

            # IMPORT STANDARD LIBRARIES
            import uuid

            # IMPORT THIRD-PARTY LIBRARIES
            import six

            # IMPORT WAYS LIBRARIES
            import ways

            # IMPORT LOCAL LIBRARIES
            from ..core import check
            from ..helper import common


            class PluginRegistry(type):

                '''A metaclass that adds new Plugin objects to a cache.'''

                def __new__(mcs, clsname, bases, attrs):
                    '''Add the created object to the HistoryCache.'''
                    new_class = super(PluginRegistry, mcs).__new__(
                        mcs, clsname, bases, attrs)

                    # TODO : We still need to not be using 'Plugin' ...
                    # If we explicitly state not to register a plugin, don't register it
                    # If add_to_registry isn't defined for this Plugin,
                    # assume that we should register it
                    #
                    try:
                        if new_class.__name__ == 'Plugin' or not new_class.add_to_registry:
                            return new_class
                    except AttributeError:
                        return new_class

                    assignment = get_assignment(new_class)

                    ways.add_plugin(new_class(), assignment)

                    return new_class


            # pylint: disable=too-few-public-methods
            @six.add_metaclass(PluginRegistry)
            class Plugin(object):

                '''An add-on that is later retrieved by Context to gather its data.'''

                add_to_registry = True
                _data = dict()

                @property
                def data(self):
                    '''dict[str]: The display properties (like {'color': 'red'}).'''
                    return self._data

                @data.setter
                def data(self, value):
                    '''Set the data on this instance with whatever value is.

                    Args:
                        value (dict[str]): The new values for this instance.

                    '''
                    self._data = value


            class DataPlugin(Plugin):

                '''An add-on that was made from a serialized file (JSON/YAML/etc).

                This class behaves exactly like a regular Plugin object and is stored
                in the same space as Plugin objects.

                DataPlugin does not add itself to the cache automatically. It is the
                responsibility of some other class/function to register it to Ways.

                We do this so that we can have better control over the DataPlugin's args
                and its assignment before it hits the cache.

                '''

                add_to_registry = False

                def __init__(self, name, sources, info, assignment):
                    '''Create the object and set its sources.

                    Args:
                        sources (list[str]): The location(s) that defined this Plugin.
                        info (dict[str]): The information that came from a JSON or YAML file
                                          which sets the base settings of the object.
                        assignment (str): The grouping location where this Plugin will go.
                                          This assignment must be the same as the Context
                                          that this Plugin is meant for.

                    Raises:
                        ValueError: If there are missing keys in data that this class needs.

                    '''
                    missing_required_keys = set(self._required_keys()) - set(info.keys())
                    if missing_required_keys:
                        raise ValueError('Info: "{info}" is missing keys, "{keys}".'
                                         ''.format(info=info, keys=missing_required_keys))

                    # Give this plugin a UUID if none was given, for us
                    # Data is assumed to be a core.classes.dict_class.ReadOnlyDict so we
                    # try to unlock it, here. If it's not a custom dict, just let it pass
                    #
                    try:
                        is_settable = info.settable
                        info.settable = True
                    except AttributeError:
                        pass

                    info.setdefault('uuid', str(uuid.uuid4()))

                    try:
                        info.settable = is_settable
                    except AttributeError:
                        pass

                    self.name = name
                    self._info = info
                    self.sources = tuple(sources)
                    self._data = self._info.get('data', dict())
                    self.assignment = assignment

                    super(DataPlugin, self).__init__()

                @classmethod
                def _required_keys(cls):
                    '''tuple[str]: Keys that must be set in our Plugin.'''
                    return ('hierarchy', )

                def is_path(self):
                    '''If the mapping is a filepath or None if unsure.

                    Returns:
                        bool or NoneType: If the mapping is a path to a file/folder on disk.

                    '''
                    try:
                        return self._info['path']
                    except KeyError:
                        return None

                def get_assignment(self):
                    '''str: Where this Plugin lives in Ways, along with its hierarchy.'''
                    return self.assignment

                def get_groups(self):
                    '''Get the groups that this Plugin evaluates onto.

                    Note:
                        The term 'groups' is not the same as the assignment of a Plugin.
                        They are two different things.

                    Returns:
                        tuple[str]: The groups.

                    '''
                    value = check.force_itertype(self._info.get('groups', ('*', )), itertype=tuple)
                    is_empty = not [val for val in value if val.strip()]
                    if is_empty:
                        value = ('*', )
                    return value

                def get_hierarchy(self):
                    '''tuple[str] or str: The location that this Plugin exists within.'''
                    return self._info['hierarchy']

                def get_mapping(self):
                    '''str: The physical location of this Plugin (on the filesystem).'''
                    try:
                        return self._info['mapping']
                    except KeyError:
                        return ''

                def get_mapping_details(self):
                    '''dict[str]: Information about the mapping, if needed.'''
                    return self._info.get('mapping_details', dict())

                def get_max_folder(self):
                    '''str: The furthest location up that this plugin can navigate to.'''
                    return self._info.get('max_folder', '')

                def get_platforms(self):
                    '''set[str]: The platforms that this Plugin is allowed to run on.'''
                    platforms = ways.get_known_platfoms()
                    return set(self._info.get('platforms', platforms))

                def get_uses(self):
                    '''tuple[str]: The Context hierarchies this instance depends on.'''
                    return self._info.get('uses', tuple())

                def get_uuid(self):
                    '''str: A unique ID for this plugin.'''
                    return self._info.get('uuid', '')

                def __repr__(self):
                    '''str: The information needed to reproduce this instance.'''
                    return '{cls_}(sources={sources!r}, data={data})'.format(
                        cls_=self.__class__.__name__,
                        sources=self.sources,
                        data=dict(self._info))

                def __str__(self):
                    '''str: A more concise print-out of this instance.'''
                    return '{cls_}(hierarchy={hierarchy}, sources={sources!r})'.format(
                        cls_=self.__class__.__name__,
                        hierarchy=self.get_hierarchy(),
                        sources=self.sources)


            def get_assignment(obj):
                '''str: Get an object's assignment or fallback to ways.DEFAULT_ASSIGNMENT.'''
                try:
                    return obj.get_assignment()
                except AttributeError:
                    return common.DEFAULT_ASSIGNMENT"""
        )

        self._test_import(content, expected)  # pylint: disable=no-value-for-parameter
