import os
from collections import Sequence, namedtuple
from urllib.parse import urlparse
import ipaddress
import markdown

from mkdocs import utils, theme, plugins
from mkdocs.config.base import Config, ValidationError


class BaseConfigOption:

    def __init__(self):
        self.warnings = []
        self.default = None

    def is_required(self):
        return False

    def validate(self, value):
        return self.run_validation(value)

    def reset_warnings(self):
        self.warnings = []

    def pre_validation(self, config, key_name):
        """
        Before all options are validated, perform a pre-validation process.

        The pre-validation process method should be implemented by subclasses.
        """

    def run_validation(self, value):
        """
        Perform validation for a value.

        The run_validation method should be implemented by subclasses.
        """
        return value

    def post_validation(self, config, key_name):
        """
        After all options have passed validation, perform a post-validation
        process to do any additional changes dependant on other config values.

        The post-validation process method should be implemented by subclasses.
        """


class SubConfig(BaseConfigOption, Config):
    def __init__(self, *config_options):
        BaseConfigOption.__init__(self)
        Config.__init__(self, config_options)
        self.default = {}

    def validate(self, value):
        self.load_dict(value)
        return self.run_validation(value)

    def run_validation(self, value):
        Config.validate(self)
        return self


class ConfigItems(BaseConfigOption):
    """
    Config Items Option

    Validates a list of mappings that all must match the same set of
    options.
    """
    def __init__(self, *config_options, **kwargs):
        BaseConfigOption.__init__(self)
        self.item_config = SubConfig(*config_options)
        self.required = kwargs.get('required', False)

    def __repr__(self):
        return '{}: {}'.format(self.__class__.__name__, self.item_config)

    def run_validation(self, value):
        if value is None:
            if self.required:
                raise ValidationError("Required configuration not provided.")
            else:
                return ()

        if not isinstance(value, Sequence):
            raise ValidationError('Expected a sequence of mappings, but a %s '
                                  'was given.' % type(value))
        result = []
        for item in value:
            result.append(self.item_config.validate(item))
        return result


class OptionallyRequired(BaseConfigOption):
    """
    A subclass of BaseConfigOption that adds support for default values and
    required values. It is a base class for config options.
    """

    def __init__(self, default=None, required=False):
        super().__init__()
        self.default = default
        self.required = required

    def is_required(self):
        return self.required

    def validate(self, value):
        """
        Perform some initial validation.

        If the option is empty (None) and isn't required, leave it as such. If
        it is empty but has a default, use that. Finally, call the
        run_validation method on the subclass unless.
        """

        if value is None:
            if self.default is not None:
                if hasattr(self.default, 'copy'):
                    # ensure no mutable values are assigned
                    value = self.default.copy()
                else:
                    value = self.default
            elif not self.required:
                return
            elif self.required:
                raise ValidationError("Required configuration not provided.")

        return self.run_validation(value)


class Type(OptionallyRequired):
    """
    Type Config Option

    Validate the type of a config option against a given Python type.
    """

    def __init__(self, type_, length=None, **kwargs):
        super().__init__(**kwargs)
        self._type = type_
        self.length = length

    def run_validation(self, value):

        if not isinstance(value, self._type):
            msg = ("Expected type: {} but received: {}"
                   .format(self._type, type(value)))
        elif self.length is not None and len(value) != self.length:
            msg = ("Expected type: {0} with length {2} but received: {1} with "
                   "length {3}").format(self._type, value, self.length,
                                        len(value))
        else:
            return value

        raise ValidationError(msg)


class Choice(OptionallyRequired):
    """
    Choice Config Option

    Validate the config option against a strict set of values.
    """

    def __init__(self, choices, **kwargs):
        super().__init__(**kwargs)
        try:
            length = len(choices)
        except TypeError:
            length = 0

        if not length or isinstance(choices, str):
            raise ValueError('Expected iterable of choices, got {}', choices)

        self.choices = choices

    def run_validation(self, value):
        if value not in self.choices:
            msg = ("Expected one of: {} but received: {}"
                   .format(self.choices, value))
        else:
            return value

        raise ValidationError(msg)


class Deprecated(BaseConfigOption):

    def __init__(self, moved_to=None):
        super().__init__()
        self.default = None
        self.moved_to = moved_to

    def pre_validation(self, config, key_name):

        if config.get(key_name) is None or self.moved_to is None:
            return

        warning = ('The configuration option {} has been deprecated and '
                   'will be removed in a future release of MkDocs.'
                   ''.format(key_name))
        self.warnings.append(warning)

        if '.' not in self.moved_to:
            target = config
            target_key = self.moved_to
        else:
            move_to, target_key = self.moved_to.rsplit('.', 1)

            target = config
            for key in move_to.split('.'):
                target = target.setdefault(key, {})

                if not isinstance(target, dict):
                    # We can't move it for the user
                    return

        target[target_key] = config.pop(key_name)


class IpAddress(OptionallyRequired):
    """
    IpAddress Config Option

    Validate that an IP address is in an apprioriate format
    """

    def run_validation(self, value):
        try:
            host, port = value.rsplit(':', 1)
        except Exception:
            raise ValidationError("Must be a string of format 'IP:PORT'")

        if host != 'localhost':
            try:
                # Validate and normalize IP Address
                host = str(ipaddress.ip_address(host))
            except ValueError as e:
                raise ValidationError(e)

        try:
            port = int(port)
        except Exception:
            raise ValidationError("'{}' is not a valid port".format(port))

        class Address(namedtuple('Address', 'host port')):
            def __str__(self):
                return '{}:{}'.format(self.host, self.port)

        return Address(host, port)

    def post_validation(self, config, key_name):
        host = config[key_name].host
        if key_name == 'dev_addr' and host in ['0.0.0.0', '::']:
            self.warnings.append(
                ("The use of the IP address '{}' suggests a production environment "
                 "or the use of a proxy to connect to the MkDocs server. However, "
                 "the MkDocs' server is intended for local development purposes only. "
                 "Please use a third party production-ready server instead.").format(host)
            )


class URL(OptionallyRequired):
    """
    URL Config Option

    Validate a URL by requiring a scheme is present.
    """

    def __init__(self, default='', required=False):
        super().__init__(default, required)

    def run_validation(self, value):
        if value == '':
            return value

        try:
            parsed_url = urlparse(value)
        except (AttributeError, TypeError):
            raise ValidationError("Unable to parse the URL.")

        if parsed_url.scheme:
            return value

        raise ValidationError(
            "The URL isn't valid, it should include the http:// (scheme)")


class RepoURL(URL):
    """
    Repo URL Config Option

    A small extension to the URL config that sets the repo_name and edit_uri,
    based on the url if they haven't already been provided.
    """

    def post_validation(self, config, key_name):
        repo_host = urlparse(config['repo_url']).netloc.lower()
        edit_uri = config.get('edit_uri')

        # derive repo_name from repo_url if unset
        if config['repo_url'] is not None and config.get('repo_name') is None:
            if repo_host == 'github.com':
                config['repo_name'] = 'GitHub'
            elif repo_host == 'bitbucket.org':
                config['repo_name'] = 'Bitbucket'
            elif repo_host == 'gitlab.com':
                config['repo_name'] = 'GitLab'
            else:
                config['repo_name'] = repo_host.split('.')[0].title()

        # derive edit_uri from repo_name if unset
        if config['repo_url'] is not None and edit_uri is None:
            if repo_host == 'github.com' or repo_host == 'gitlab.com':
                edit_uri = 'edit/master/docs/'
            elif repo_host == 'bitbucket.org':
                edit_uri = 'src/default/docs/'
            else:
                edit_uri = ''

        # ensure a well-formed edit_uri
        if edit_uri:
            if not edit_uri.startswith(('?', '#')) \
                    and not config['repo_url'].endswith('/'):
                config['repo_url'] += '/'
            if not edit_uri.endswith('/'):
                edit_uri += '/'

        config['edit_uri'] = edit_uri


class FilesystemObject(Type):
    """
    Base class for options that point to filesystem objects.
    """
    def __init__(self, exists=False, **kwargs):
        super().__init__(type_=str, **kwargs)
        self.exists = exists
        self.config_dir = None

    def pre_validation(self, config, key_name):
        self.config_dir = os.path.dirname(config.config_file_path) if config.config_file_path else None

    def run_validation(self, value):
        value = super().run_validation(value)
        if self.config_dir and not os.path.isabs(value):
            value = os.path.join(self.config_dir, value)
        if self.exists and not self.existence_test(value):
            raise ValidationError("The path {path} isn't an existing {name}.".
                                  format(path=value, name=self.name))
        value = os.path.abspath(value)
        assert isinstance(value, str)
        return value


class Dir(FilesystemObject):
    """
    Dir Config Option

    Validate a path to a directory, optionally verifying that it exists.
    """
    existence_test = staticmethod(os.path.isdir)
    name = 'directory'

    def post_validation(self, config, key_name):
        if config.config_file_path is None:
            return

        # Validate that the dir is not the parent dir of the config file.
        if os.path.dirname(config.config_file_path) == config[key_name]:
            raise ValidationError(
                ("The '{0}' should not be the parent directory of the config "
                 "file. Use a child directory instead so that the '{0}' "
                 "is a sibling of the config file.").format(key_name))


class File(FilesystemObject):
    """
    File Config Option

    Validate a path to a file, optionally verifying that it exists.
    """
    existence_test = staticmethod(os.path.isfile)
    name = 'file'


class SiteDir(Dir):
    """
    SiteDir Config Option

    Validates the site_dir and docs_dir directories do not contain each other.
    """

    def post_validation(self, config, key_name):

        super().post_validation(config, key_name)

        # Validate that the docs_dir and site_dir don't contain the
        # other as this will lead to copying back and forth on each
        # and eventually make a deep nested mess.
        if (config['docs_dir'] + os.sep).startswith(config['site_dir'].rstrip(os.sep) + os.sep):
            raise ValidationError(
                ("The 'docs_dir' should not be within the 'site_dir' as this "
                 "can mean the source files are overwritten by the output or "
                 "it will be deleted if --clean is passed to mkdocs build."
                 "(site_dir: '{}', docs_dir: '{}')"
                 ).format(config['site_dir'], config['docs_dir']))
        elif (config['site_dir'] + os.sep).startswith(config['docs_dir'].rstrip(os.sep) + os.sep):
            raise ValidationError(
                ("The 'site_dir' should not be within the 'docs_dir' as this "
                 "leads to the build directory being copied into itself and "
                 "duplicate nested files in the 'site_dir'."
                 "(site_dir: '{}', docs_dir: '{}')"
                 ).format(config['site_dir'], config['docs_dir']))


class Theme(BaseConfigOption):
    """
    Theme Config Option

    Validate that the theme exists and build Theme instance.
    """

    def __init__(self, default=None):
        super().__init__()
        self.default = default

    def validate(self, value):
        if value is None and self.default is not None:
            value = {'name': self.default}

        if isinstance(value, str):
            value = {'name': value}

        themes = utils.get_theme_names()

        if isinstance(value, dict):
            if 'name' in value:
                if value['name'] is None or value['name'] in themes:
                    return value

                raise ValidationError(
                    "Unrecognised theme name: '{}'. The available installed themes "
                    "are: {}".format(value['name'], ', '.join(themes))
                )

            raise ValidationError("No theme name set.")

        raise ValidationError('Invalid type "{}". Expected a string or key/value pairs.'.format(type(value)))

    def post_validation(self, config, key_name):
        theme_config = config[key_name]

        if not theme_config['name'] and 'custom_dir' not in theme_config:
            raise ValidationError("At least one of 'theme.name' or 'theme.custom_dir' must be defined.")

        # Ensure custom_dir is an absolute path
        if 'custom_dir' in theme_config and not os.path.isabs(theme_config['custom_dir']):
            config_dir = os.path.dirname(config.config_file_path)
            theme_config['custom_dir'] = os.path.join(config_dir, theme_config['custom_dir'])

        if 'custom_dir' in theme_config and not os.path.isdir(theme_config['custom_dir']):
            raise ValidationError("The path set in {name}.custom_dir ('{path}') does not exist.".
                                  format(path=theme_config['custom_dir'], name=key_name))

        config[key_name] = theme.Theme(**theme_config)


class Nav(OptionallyRequired):
    """
    Nav Config Option

    Validate the Nav config. Automatically add all markdown files if empty.
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.file_match = utils.is_markdown_file

    def run_validation(self, value):

        if not isinstance(value, list):
            raise ValidationError(
                "Expected a list, got {}".format(type(value)))

        if len(value) == 0:
            return

        config_types = {type(item) for item in value}
        if config_types.issubset({str, dict}):
            return value

        raise ValidationError("Invalid pages config. {} {}".format(
            config_types, {str, dict}
        ))

    def post_validation(self, config, key_name):
        # TODO: remove this when `pages` config setting is fully deprecated.
        if key_name == 'pages' and config['pages'] is not None:
            if config['nav'] is None:
                # copy `pages` config to new 'nav' config setting
                config['nav'] = config['pages']
            warning = ("The 'pages' configuration option has been deprecated and will "
                       "be removed in a future release of MkDocs. Use 'nav' instead.")
            self.warnings.append(warning)


class Private(OptionallyRequired):
    """
    Private Config Option

    A config option only for internal use. Raises an error if set by the user.
    """

    def run_validation(self, value):
        raise ValidationError('For internal use only.')


class MarkdownExtensions(OptionallyRequired):
    """
    Markdown Extensions Config Option

    A list of extensions. If a list item contains extension configs,
    those are set on the private  setting passed to `configkey`. The
    `builtins` keyword accepts a list of extensions which cannot be
    overriden by the user. However, builtins can be duplicated to define
    config options for them if desired.
    """
    def __init__(self, builtins=None, configkey='mdx_configs', **kwargs):
        super().__init__(**kwargs)
        self.builtins = builtins or []
        self.configkey = configkey
        self.configdata = {}

    def run_validation(self, value):
        if not isinstance(value, (list, tuple)):
            raise ValidationError('Invalid Markdown Extensions configuration')
        extensions = []
        for item in value:
            if isinstance(item, dict):
                if len(item) > 1:
                    raise ValidationError('Invalid Markdown Extensions configuration')
                ext, cfg = item.popitem()
                extensions.append(ext)
                if cfg is None:
                    continue
                if not isinstance(cfg, dict):
                    raise ValidationError('Invalid config options for Markdown '
                                          "Extension '{}'.".format(ext))
                self.configdata[ext] = cfg
            elif isinstance(item, str):
                extensions.append(item)
            else:
                raise ValidationError('Invalid Markdown Extensions configuration')

        extensions = utils.reduce_list(self.builtins + extensions)

        # Confirm that Markdown considers extensions to be valid
        try:
            markdown.Markdown(extensions=extensions, extension_configs=self.configdata)
        except Exception as e:
            raise ValidationError(e.args[0])

        return extensions

    def post_validation(self, config, key_name):
        config[self.configkey] = self.configdata


class Plugins(OptionallyRequired):
    """
    Plugins config option.

    A list of plugins. If a plugin defines config options those are used when
    initializing the plugin class.
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.installed_plugins = plugins.get_plugins()
        self.config_file_path = None

    def pre_validation(self, config, key_name):
        self.config_file_path = config.config_file_path

    def run_validation(self, value):
        if not isinstance(value, (list, tuple)):
            raise ValidationError('Invalid Plugins configuration. Expected a list of plugins')
        plgins = plugins.PluginCollection()
        for item in value:
            if isinstance(item, dict):
                if len(item) > 1:
                    raise ValidationError('Invalid Plugins configuration')
                name, cfg = item.popitem()
                cfg = cfg or {}  # Users may define a null (None) config
                if not isinstance(cfg, dict):
                    raise ValidationError('Invalid config options for '
                                          'the "{}" plugin.'.format(name))
                item = name
            else:
                cfg = {}

            if not isinstance(item, str):
                raise ValidationError('Invalid Plugins configuration')

            plgins[item] = self.load_plugin(item, cfg)

        return plgins

    def load_plugin(self, name, config):
        if name not in self.installed_plugins:
            raise ValidationError('The "{}" plugin is not installed'.format(name))

        Plugin = self.installed_plugins[name].load()

        if not issubclass(Plugin, plugins.BasePlugin):
            raise ValidationError('{}.{} must be a subclass of {}.{}'.format(
                Plugin.__module__, Plugin.__name__, plugins.BasePlugin.__module__,
                plugins.BasePlugin.__name__))

        plugin = Plugin()
        errors, warnings = plugin.load_config(config, self.config_file_path)
        self.warnings.extend(warnings)
        errors_message = '\n'.join(
            "Plugin value: '{}'. Error: {}".format(x, y)
            for x, y in errors
        )
        if errors_message:
            raise ValidationError(errors_message)
        return plugin
