from __future__ import annotations

import contextlib
import copy
import io
import logging
import os
import re
import sys
import textwrap
import unittest
from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeVar, Union
from unittest import mock

if TYPE_CHECKING:
    from typing_extensions import assert_type
else:

    def assert_type(val, typ):
        return None


import mkdocs
from mkdocs.config import config_options as c
from mkdocs.config import defaults
from mkdocs.config.base import Config
from mkdocs.plugins import BasePlugin, PluginCollection
from mkdocs.tests.base import tempdir
from mkdocs.theme import Theme
from mkdocs.utils import write_file, yaml_load

SomeConfig = TypeVar('SomeConfig', bound=Config)


class UnexpectedError(Exception):
    pass


class TestCase(unittest.TestCase):
    @contextlib.contextmanager
    def expect_error(self, **kwargs):
        [(key, msg)] = kwargs.items()
        with self.assertRaises(UnexpectedError) as cm:
            yield
        if isinstance(msg, re.Pattern):
            self.assertRegex(str(cm.exception), f'^{key}="{msg.pattern}"$')
        else:
            self.assertEqual(f'{key}="{msg}"', str(cm.exception))

    def get_config(
        self,
        config_class: type[SomeConfig],
        cfg: dict[str, Any],
        warnings: dict[str, str] = {},
        config_file_path=None,
    ) -> SomeConfig:
        config = config_class(config_file_path=config_file_path)
        config.load_dict(cfg)
        actual_errors, actual_warnings = config.validate()
        if actual_errors:
            raise UnexpectedError(', '.join(f'{key}="{msg}"' for key, msg in actual_errors))
        self.assertEqual(warnings, dict(actual_warnings))
        return config


class TypeTest(TestCase):
    def test_single_type(self) -> None:
        class Schema(Config):
            option = c.Type(str)

        conf = self.get_config(Schema, {'option': "Testing"})
        assert_type(conf.option, str)
        self.assertEqual(conf.option, "Testing")

    def test_multiple_types(self) -> None:
        class Schema(Config):
            option = c.Type((list, tuple))

        conf = self.get_config(Schema, {'option': [1, 2, 3]})
        self.assertEqual(conf.option, [1, 2, 3])

        conf = self.get_config(Schema, {'option': (1, 2, 3)})
        self.assertEqual(conf.option, (1, 2, 3))

        with self.expect_error(
            option="Expected type: (<class 'list'>, <class 'tuple'>) but received: <class 'dict'>"
        ):
            self.get_config(Schema, {'option': {'a': 1}})

    def test_length(self) -> None:
        class Schema(Config):
            option = c.Type(str, length=7)

        conf = self.get_config(Schema, {'option': "Testing"})
        assert_type(conf.option, str)
        self.assertEqual(conf.option, "Testing")

        with self.expect_error(
            option="Expected type: <class 'str'> with length 7 but received: 'Testing Long' with length 12"
        ):
            self.get_config(Schema, {'option': "Testing Long"})

    def test_optional_with_default(self) -> None:
        with self.assertRaisesRegex(ValueError, "doesn't need to be wrapped into Optional"):
            c.Optional(c.Type(int, default=5))


class ChoiceTest(TestCase):
    def test_required(self) -> None:
        class Schema(Config):
            option = c.Choice(('python', 'node'))

        conf = self.get_config(Schema, {'option': 'python'})
        assert_type(conf.option, str)
        self.assertEqual(conf.option, 'python')

    def test_optional(self) -> None:
        class Schema(Config):
            option = c.Optional(c.Choice(('python', 'node')))

        conf = self.get_config(Schema, {'option': 'python'})
        assert_type(conf.option, Optional[str])
        self.assertEqual(conf.option, 'python')

        conf = self.get_config(Schema, {})
        self.assertEqual(conf.option, None)

        conf = self.get_config(Schema, {'option': None})
        self.assertEqual(conf.option, None)

    def test_default(self) -> None:
        class Schema(Config):
            option = c.Choice(('a', 'b', 'c'), default='b')

        conf = self.get_config(Schema, {})
        assert_type(conf.option, str)
        self.assertEqual(conf.option, 'b')

        conf = self.get_config(Schema, {})
        self.assertEqual(conf.option, 'b')

        conf = self.get_config(Schema, {'option': None})
        self.assertEqual(conf.option, 'b')

        with self.expect_error(option="Expected one of: ('a', 'b', 'c') but received: 'go'"):
            self.get_config(Schema, {'option': 'go'})

    def test_invalid_default(self) -> None:
        with self.assertRaises(ValueError):
            c.Choice(('a', 'b'), default='c')
        with self.assertRaises(ValueError):
            c.Choice(('a', 'b'), default='c', required=True)

    def test_invalid_choice(self) -> None:
        class Schema(Config):
            option = c.Choice(('python', 'node'))

        with self.expect_error(option="Expected one of: ('python', 'node') but received: 'go'"):
            self.get_config(Schema, {'option': 'go'})

    def test_invalid_choices(self) -> None:
        with self.assertRaises(ValueError):
            c.Choice('')
        with self.assertRaises(ValueError):
            c.Choice([])


class DeprecatedTest(TestCase):
    def test_deprecated_option_simple(self) -> None:
        class Schema(Config):
            d = c.Deprecated()

        self.get_config(
            Schema,
            {'d': 'value'},
            warnings=dict(
                d="The configuration option 'd' has been deprecated and will be removed in a "
                "future release."
            ),
        )

    def test_deprecated_option_message(self) -> None:
        class Schema(Config):
            d = c.Deprecated(message='custom message for {} key')

        self.get_config(Schema, {'d': 'value'}, warnings={'d': 'custom message for d key'})

    def test_deprecated_option_with_type(self) -> None:
        class Schema(Config):
            d = c.Deprecated(option_type=c.Type(str))

        self.get_config(
            Schema,
            {'d': 'value'},
            warnings=dict(
                d="The configuration option 'd' has been deprecated and will be removed in a "
                "future release."
            ),
        )

    def test_deprecated_option_with_invalid_type(self) -> None:
        class Schema(Config):
            d = c.Deprecated(option_type=c.Type(list))

        with self.expect_error(d="Expected type: <class 'list'> but received: <class 'str'>"):
            self.get_config(
                Schema,
                {'d': 'value'},
                warnings=dict(
                    d="The configuration option 'd' has been deprecated and will be removed in a "
                    "future release."
                ),
            )

    def test_removed_option(self) -> None:
        class Schema(Config):
            d = c.Deprecated(removed=True, moved_to='foo')

        with self.expect_error(
            d="The configuration option 'd' was removed from MkDocs. Use 'foo' instead.",
        ):
            self.get_config(Schema, {'d': 'value'})

    def test_deprecated_option_with_type_undefined(self) -> None:
        class Schema(Config):
            option = c.Deprecated(option_type=c.Type(str))

        self.get_config(Schema, {'option': None})

    def test_deprecated_option_move(self) -> None:
        class Schema(Config):
            new = c.Type(str)
            old = c.Deprecated(moved_to='new')

        conf = self.get_config(
            Schema,
            {'old': 'value'},
            warnings=dict(
                old="The configuration option 'old' has been deprecated and will be removed in a "
                "future release. Use 'new' instead."
            ),
        )
        self.assertEqual(conf, {'new': 'value', 'old': None})

    def test_deprecated_option_move_complex(self) -> None:
        class Schema(Config):
            foo = c.Type(dict)
            old = c.Deprecated(moved_to='foo.bar')

        conf = self.get_config(
            Schema,
            {'old': 'value'},
            warnings=dict(
                old="The configuration option 'old' has been deprecated and will be removed in a "
                "future release. Use 'foo.bar' instead."
            ),
        )
        self.assertEqual(conf, {'foo': {'bar': 'value'}, 'old': None})

    def test_deprecated_option_move_existing(self) -> None:
        class Schema(Config):
            foo = c.Type(dict)
            old = c.Deprecated(moved_to='foo.bar')

        conf = self.get_config(
            Schema,
            {'old': 'value', 'foo': {'existing': 'existing'}},
            warnings=dict(
                old="The configuration option 'old' has been deprecated and will be removed in a "
                "future release. Use 'foo.bar' instead."
            ),
        )
        self.assertEqual(conf, {'foo': {'existing': 'existing', 'bar': 'value'}, 'old': None})

    def test_deprecated_option_move_invalid(self) -> None:
        class Schema(Config):
            foo = c.Type(dict)
            old = c.Deprecated(moved_to='foo.bar')

        with self.expect_error(foo="Expected type: <class 'dict'> but received: <class 'str'>"):
            self.get_config(
                Schema,
                {'old': 'value', 'foo': 'wrong type'},
                warnings=dict(
                    old="The configuration option 'old' has been deprecated and will be removed in a "
                    "future release. Use 'foo.bar' instead."
                ),
            )


class IpAddressTest(TestCase):
    class Schema(Config):
        option = c.IpAddress()

    def test_valid_address(self) -> None:
        addr = '127.0.0.1:8000'

        conf = self.get_config(self.Schema, {'option': addr})

        assert_type(conf.option, c._IpAddressValue)
        assert_type(conf.option.host, str)
        assert_type(conf.option.port, int)

        self.assertEqual(str(conf.option), addr)
        self.assertEqual(conf.option.host, '127.0.0.1')
        self.assertEqual(conf.option.port, 8000)

    def test_valid_IPv6_address(self) -> None:
        addr = '::1:8000'

        conf = self.get_config(self.Schema, {'option': addr})
        self.assertEqual(str(conf.option), addr)
        self.assertEqual(conf.option.host, '::1')
        self.assertEqual(conf.option.port, 8000)

    def test_valid_full_IPv6_address(self) -> None:
        addr = '[2001:db8:85a3::8a2e:370:7334]:123'

        conf = self.get_config(self.Schema, {'option': addr})
        self.assertEqual(conf.option.host, '2001:db8:85a3::8a2e:370:7334')
        self.assertEqual(conf.option.port, 123)

    def test_named_address(self) -> None:
        addr = 'localhost:8000'

        conf = self.get_config(self.Schema, {'option': addr})
        self.assertEqual(str(conf.option), addr)
        self.assertEqual(conf.option.host, 'localhost')
        self.assertEqual(conf.option.port, 8000)

    def test_default_address(self) -> None:
        addr = '127.0.0.1:8000'

        class Schema(Config):
            option = c.IpAddress(default=addr)

        conf = self.get_config(Schema, {'option': None})
        self.assertEqual(str(conf.option), addr)
        self.assertEqual(conf.option.host, '127.0.0.1')
        self.assertEqual(conf.option.port, 8000)

    @unittest.skipIf(
        sys.version_info < (3, 9, 5),
        "Leading zeros allowed in IP addresses before Python3.9.5",
    )
    def test_invalid_leading_zeros(self) -> None:
        with self.expect_error(
            option="'127.000.000.001' does not appear to be an IPv4 or IPv6 address"
        ):
            self.get_config(self.Schema, {'option': '127.000.000.001:8000'})

    def test_invalid_address_range(self) -> None:
        with self.expect_error(option="'277.0.0.1' does not appear to be an IPv4 or IPv6 address"):
            self.get_config(self.Schema, {'option': '277.0.0.1:8000'})

    def test_invalid_address_format(self) -> None:
        with self.expect_error(option="Must be a string of format 'IP:PORT'"):
            self.get_config(self.Schema, {'option': '127.0.0.18000'})

    def test_invalid_address_type(self) -> None:
        with self.expect_error(option="Must be a string of format 'IP:PORT'"):
            self.get_config(self.Schema, {'option': 123})

    def test_invalid_address_port(self) -> None:
        with self.expect_error(option="'foo' is not a valid port"):
            self.get_config(self.Schema, {'option': '127.0.0.1:foo'})

    def test_invalid_address_missing_port(self) -> None:
        with self.expect_error(option="Must be a string of format 'IP:PORT'"):
            self.get_config(self.Schema, {'option': '127.0.0.1'})

    def test_unsupported_address(self) -> None:
        class Schema(Config):
            dev_addr = c.IpAddress()

        self.get_config(
            Schema,
            {'dev_addr': '0.0.0.0:8000'},
            warnings=dict(
                dev_addr="The use of the IP address '0.0.0.0' 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."
            ),
        )

    def test_unsupported_IPv6_address(self) -> None:
        class Schema(Config):
            dev_addr = c.IpAddress()

        self.get_config(
            Schema,
            {'dev_addr': ':::8000'},
            warnings=dict(
                dev_addr="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."
            ),
        )


class URLTest(TestCase):
    def test_valid_url(self) -> None:
        class Schema(Config):
            option = c.URL()

        conf = self.get_config(Schema, {'option': "https://mkdocs.org"})
        assert_type(conf.option, str)
        self.assertEqual(conf.option, "https://mkdocs.org")

        conf = self.get_config(Schema, {'option': ""})
        self.assertEqual(conf.option, "")

    def test_valid_url_is_dir(self) -> None:
        class Schema(Config):
            option = c.URL(is_dir=True)

        conf = self.get_config(Schema, {'option': "http://mkdocs.org/"})
        self.assertEqual(conf.option, "http://mkdocs.org/")

        conf = self.get_config(Schema, {'option': "https://mkdocs.org"})
        self.assertEqual(conf.option, "https://mkdocs.org/")

    def test_optional(self):
        class Schema(Config):
            option = c.Optional(c.URL(is_dir=True))

        conf = self.get_config(Schema, {'option': ''})
        self.assertEqual(conf.option, '')

        conf = self.get_config(Schema, {'option': None})
        self.assertEqual(conf.option, None)

    def test_invalid_url(self) -> None:
        class Schema(Config):
            option = c.URL()

        with self.expect_error(option="Required configuration not provided."):
            self.get_config(Schema, {'option': None})

        for url in "www.mkdocs.org", "//mkdocs.org/test", "http:/mkdocs.org/", "/hello/":
            with self.subTest(url=url):
                with self.expect_error(
                    option="The URL isn't valid, it should include the http:// (scheme)"
                ):
                    self.get_config(Schema, {'option': url})

    def test_invalid_type(self) -> None:
        class Schema(Config):
            option = c.URL()

        with self.expect_error(option="Expected a string, got <class 'int'>"):
            self.get_config(Schema, {'option': 1})


class EditURITest(TestCase):
    class Schema(Config):
        repo_url = c.Optional(c.URL())
        repo_name = c.Optional(c.RepoName('repo_url'))
        edit_uri_template = c.Optional(c.EditURITemplate('edit_uri'))
        edit_uri = c.Optional(c.EditURI('repo_url'))

    def test_repo_name_github(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'repo_url': "https://github.com/mkdocs/mkdocs"},
        )
        assert_type(conf.repo_name, Optional[str])
        self.assertEqual(conf.repo_name, "GitHub")

    def test_repo_name_bitbucket(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'repo_url': "https://bitbucket.org/gutworth/six/"},
        )
        self.assertEqual(conf.repo_name, "Bitbucket")

    def test_repo_name_gitlab(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'repo_url': "https://gitlab.com/gitlab-org/gitlab-ce/"},
        )
        self.assertEqual(conf.repo_name, "GitLab")

    def test_repo_name_custom(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'repo_url': "https://launchpad.net/python-tuskarclient"},
        )
        self.assertEqual(conf.repo_name, "Launchpad")

    def test_edit_uri_github(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'repo_url': "https://github.com/mkdocs/mkdocs"},
        )
        assert_type(conf.edit_uri, Optional[str])
        assert_type(conf.repo_url, Optional[str])
        self.assertEqual(conf.edit_uri, 'edit/master/docs/')
        self.assertEqual(conf.repo_url, "https://github.com/mkdocs/mkdocs")

    def test_edit_uri_bitbucket(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'repo_url': "https://bitbucket.org/gutworth/six/"},
        )
        self.assertEqual(conf.edit_uri, 'src/default/docs/')
        self.assertEqual(conf.repo_url, "https://bitbucket.org/gutworth/six/")

    def test_edit_uri_gitlab(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'repo_url': "https://gitlab.com/gitlab-org/gitlab-ce/"},
        )
        self.assertEqual(conf.edit_uri, 'edit/master/docs/')

    def test_edit_uri_custom(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'repo_url': "https://launchpad.net/python-tuskarclient"},
        )
        self.assertEqual(conf.edit_uri, None)
        self.assertEqual(conf.repo_url, "https://launchpad.net/python-tuskarclient")

    def test_repo_name_custom_and_empty_edit_uri(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'repo_url': "https://github.com/mkdocs/mkdocs", 'repo_name': 'mkdocs'},
        )
        self.assertEqual(conf.edit_uri, 'edit/master/docs/')

    def test_edit_uri_template_ok(self) -> None:
        conf = self.get_config(
            self.Schema,
            {
                'repo_url': "https://github.com/mkdocs/mkdocs",
                'edit_uri_template': 'edit/foo/docs/{path}',
            },
        )
        assert_type(conf.edit_uri_template, Optional[str])
        self.assertEqual(conf.edit_uri_template, 'edit/foo/docs/{path}')

    def test_edit_uri_template_errors(self) -> None:
        with self.expect_error(
            edit_uri_template=re.compile(r'.*[{}].*')  # Complains about unclosed '{' or missing '}'
        ):
            self.get_config(
                self.Schema,
                {
                    'repo_url': "https://github.com/mkdocs/mkdocs",
                    'edit_uri_template': 'edit/master/{path',
                },
            )

        with self.expect_error(edit_uri_template=re.compile(r'.*\bz\b.*')):
            self.get_config(
                self.Schema,
                {
                    'repo_url': "https://github.com/mkdocs/mkdocs",
                    'edit_uri_template': 'edit/master/{path!z}',
                },
            )

        with self.expect_error(edit_uri_template="Unknown template substitute: 'foo'"):
            self.get_config(
                self.Schema,
                {
                    'repo_url': "https://github.com/mkdocs/mkdocs",
                    'edit_uri_template': 'edit/master/{foo}',
                },
            )

    def test_edit_uri_template_warning(self) -> None:
        conf = self.get_config(
            self.Schema,
            {
                'repo_url': "https://github.com/mkdocs/mkdocs",
                'edit_uri': 'edit',
                'edit_uri_template': 'edit/master/{path}',
            },
            warnings=dict(
                edit_uri_template="The option 'edit_uri' has no effect when 'edit_uri_template' is set."
            ),
        )
        self.assertEqual(conf.edit_uri_template, 'edit/master/{path}')


class ListOfItemsTest(TestCase):
    def test_int_type(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.Type(int))

        conf = self.get_config(Schema, {'option': [1, 2, 3]})
        assert_type(conf.option, List[int])
        self.assertEqual(conf.option, [1, 2, 3])

        with self.expect_error(
            option="Expected type: <class 'int'> but received: <class 'NoneType'>"
        ):
            conf = self.get_config(Schema, {'option': [1, None, 3]})

    def test_combined_float_type(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.Type((int, float)))

        conf = self.get_config(Schema, {'option': [1.4, 2, 3]})
        self.assertEqual(conf.option, [1.4, 2, 3])

        with self.expect_error(
            option="Expected type: (<class 'int'>, <class 'float'>) but received: <class 'str'>"
        ):
            self.get_config(Schema, {'option': ['a']})

    def test_list_default(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.Type(int), default=[])

        conf = self.get_config(Schema, {})
        assert_type(conf.option, List[int])
        self.assertEqual(conf.option, [])

        with self.expect_error(option="Required configuration not provided."):
            conf = self.get_config(Schema, {'option': None})

    def test_none_without_default(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.Type(str))

        with self.expect_error(option="Required configuration not provided."):
            conf = self.get_config(Schema, {})

        with self.expect_error(option="Required configuration not provided."):
            conf = self.get_config(Schema, {'option': None})

        conf = self.get_config(Schema, {'option': ['foo']})
        self.assertEqual(conf.option, ['foo'])

    def test_optional(self) -> None:
        class Schema(Config):
            option = c.Optional(c.ListOfItems(c.Type(str)))

        conf = self.get_config(Schema, {})
        assert_type(conf.option, Optional[List[str]])
        self.assertEqual(conf.option, None)

        conf = self.get_config(Schema, {'option': None})
        self.assertEqual(conf.option, None)

        conf = self.get_config(Schema, {'option': ['foo']})
        self.assertEqual(conf.option, ['foo'])

    def test_list_of_optional(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.Optional(c.Type(int)), default=[])

        conf = self.get_config(Schema, {})
        assert_type(conf.option, List[Optional[int]])
        self.assertEqual(conf.option, [])

        conf = self.get_config(Schema, {'option': [4, None]})
        self.assertEqual(conf.option, [4, None])

        with self.expect_error(option="Expected type: <class 'int'> but received: <class 'str'>"):
            conf = self.get_config(Schema, {'option': ['foo']})
            self.assertEqual(conf.option, ['foo'])

    def test_string_not_a_list_of_strings(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.Type(str))

        with self.expect_error(option="Expected a list of items, but a <class 'str'> was given."):
            self.get_config(Schema, {'option': 'foo'})

    def test_post_validation_error(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.IpAddress())

        with self.expect_error(option="'asdf' is not a valid port"):
            self.get_config(Schema, {'option': ["localhost:8000", "1.2.3.4:asdf"]})

    def test_warning(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.Deprecated())

        self.get_config(
            Schema,
            {'option': ['a']},
            warnings=dict(
                option="The configuration option 'option[0]' has been "
                "deprecated and will be removed in a future release."
            ),
        )


class ExtraScriptsTest(TestCase):
    def test_js_async(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.ExtraScript(), default=[])

        conf = self.get_config(Schema, {'option': ['foo.js', {'path': 'bar.js', 'async': True}]})
        assert_type(conf.option, List[Union[c.ExtraScriptValue, str]])
        self.assertEqual(len(conf.option), 2)
        self.assertIsInstance(conf.option[1], c.ExtraScriptValue)
        self.assertEqual(
            conf.option,
            [
                'foo.js',
                {'path': 'bar.js', 'type': '', 'defer': False, 'async': True},
            ],
        )

    def test_mjs(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.ExtraScript(), default=[])

        conf = self.get_config(
            Schema, {'option': ['foo.mjs', {'path': 'bar.js', 'type': 'module'}]}
        )
        assert_type(conf.option, List[Union[c.ExtraScriptValue, str]])
        self.assertEqual(len(conf.option), 2)
        self.assertIsInstance(conf.option[0], c.ExtraScriptValue)
        self.assertEqual(
            conf.option,
            [
                {'path': 'foo.mjs', 'type': 'module', 'defer': False, 'async': False},
                {'path': 'bar.js', 'type': 'module', 'defer': False, 'async': False},
            ],
        )

    def test_wrong_type(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.ExtraScript(), default=[])

        with self.expect_error(
            option="The configuration is invalid. Expected a key-value mapping (dict) but received: <class 'int'>"
        ):
            self.get_config(Schema, {'option': [1]})

    def test_unknown_key(self) -> None:
        class Schema(Config):
            option = c.ListOfItems(c.ExtraScript(), default=[])

        conf = self.get_config(
            Schema,
            {'option': [{'path': 'foo.js', 'foo': 'bar'}]},
            warnings=dict(option="Sub-option 'foo': Unrecognised configuration name: foo"),
        )
        self.assertEqual(
            conf.option,
            [{'path': 'foo.js', 'type': '', 'defer': False, 'async': False, 'foo': 'bar'}],
        )


class DictOfItemsTest(TestCase):
    def test_int_type(self) -> None:
        class Schema(Config):
            option = c.DictOfItems(c.Type(int))

        conf = self.get_config(Schema, {'option': {"a": 1, "b": 2}})
        assert_type(conf.option, Dict[str, int])
        self.assertEqual(conf.option, {"a": 1, "b": 2})

        with self.expect_error(
            option="Expected type: <class 'int'> but received: <class 'NoneType'>"
        ):
            conf = self.get_config(Schema, {'option': {"a": 1, "b": None}})

    def test_combined_float_type(self) -> None:
        class Schema(Config):
            option = c.DictOfItems(c.Type((int, float)))

        conf = self.get_config(Schema, {'option': {"a": 1, "b": 2.3}})
        self.assertEqual(conf.option, {"a": 1, "b": 2.3})

        with self.expect_error(
            option="Expected type: (<class 'int'>, <class 'float'>) but received: <class 'str'>"
        ):
            self.get_config(Schema, {'option': {"a": 1, "b": "2"}})

    def test_dict_default(self) -> None:
        class Schema(Config):
            option = c.DictOfItems(c.Type(int), default={})

        conf = self.get_config(Schema, {})
        assert_type(conf.option, Dict[str, int])
        self.assertEqual(conf.option, {})

        with self.expect_error(option="Required configuration not provided."):
            conf = self.get_config(Schema, {'option': None})

    def test_none_without_default(self) -> None:
        class Schema(Config):
            option = c.DictOfItems(c.Type(str))

        with self.expect_error(option="Required configuration not provided."):
            conf = self.get_config(Schema, {})

        with self.expect_error(option="Required configuration not provided."):
            conf = self.get_config(Schema, {'option': None})

        conf = self.get_config(Schema, {'option': {"foo": "bar"}})
        self.assertEqual(conf.option, {"foo": "bar"})

    def test_optional(self) -> None:
        class Schema(Config):
            option = c.Optional(c.DictOfItems(c.Type(str)))

        conf = self.get_config(Schema, {})
        assert_type(conf.option, Optional[Dict[str, str]])
        self.assertEqual(conf.option, None)

        conf = self.get_config(Schema, {'option': None})
        self.assertEqual(conf.option, None)

        conf = self.get_config(Schema, {'option': {"foo": "bar"}})
        self.assertEqual(conf.option, {"foo": "bar"})

    def test_dict_of_optional(self) -> None:
        class Schema(Config):
            option = c.DictOfItems(c.Optional(c.Type(int)), default={})

        conf = self.get_config(Schema, {})
        assert_type(conf.option, Dict[str, Optional[int]])
        self.assertEqual(conf.option, {})

        conf = self.get_config(Schema, {'option': {"a": 1, "b": None}})
        self.assertEqual(conf.option, {"a": 1, "b": None})

        with self.expect_error(option="Expected type: <class 'int'> but received: <class 'str'>"):
            conf = self.get_config(Schema, {'option': {"foo": "bar"}})
            self.assertEqual(conf.option, {"foo": "bar"})

    def test_string_not_a_dict_of_strings(self) -> None:
        class Schema(Config):
            option = c.DictOfItems(c.Type(str))

        with self.expect_error(option="Expected a dict of items, but a <class 'str'> was given."):
            self.get_config(Schema, {'option': 'foo'})

    def test_post_validation_error(self) -> None:
        class Schema(Config):
            option = c.DictOfItems(c.IpAddress())

        with self.expect_error(option="'asdf' is not a valid port"):
            self.get_config(
                Schema, {'option': {"ip_foo": "localhost:8000", "ip_bar": "1.2.3.4:asdf"}}
            )

    def test_all_keys_are_strings(self) -> None:
        class Schema(Config):
            option = c.DictOfItems(c.Type(int))

        with self.expect_error(
            option="Expected type: <class 'str'> for keys, but received: <class 'int'> (key=2)"
        ):
            self.get_config(Schema, {'option': {"a": 1, 2: 3}})


class FilesystemObjectTest(TestCase):
    def test_valid_dir(self) -> None:
        for cls in c.Dir, c.FilesystemObject:
            with self.subTest(cls):
                d = os.path.dirname(__file__)

                class Schema(Config):
                    option = cls(exists=True)

                conf = self.get_config(Schema, {'option': d})
                assert_type(conf.option, str)
                self.assertEqual(conf.option, d)

    def test_valid_file(self) -> None:
        for cls in c.File, c.FilesystemObject:
            with self.subTest(cls):
                f = __file__

                class Schema(Config):
                    option = cls(exists=True)

                conf = self.get_config(Schema, {'option': f})
                assert_type(conf.option, str)
                self.assertEqual(conf.option, f)

    def test_missing_without_exists(self) -> None:
        for cls in c.Dir, c.File, c.FilesystemObject:
            with self.subTest(cls):
                d = os.path.join("not", "a", "real", "path", "I", "hope")

                class Schema(Config):
                    option = cls()

                conf = self.get_config(Schema, {'option': d})
                assert_type(conf.option, str)
                self.assertEqual(conf.option, os.path.abspath(d))

    def test_missing_but_required(self) -> None:
        for cls in c.Dir, c.File, c.FilesystemObject:
            with self.subTest(cls):
                d = os.path.join("not", "a", "real", "path", "I", "hope")

                class Schema(Config):
                    option = cls(exists=True)

                with self.expect_error(option=re.compile(r"The path '.+' isn't an existing .+")):
                    self.get_config(Schema, {'option': d})

    def test_not_a_dir(self) -> None:
        d = __file__

        class Schema(Config):
            option = c.Dir(exists=True)

        with self.expect_error(option=re.compile(r"The path '.+' isn't an existing directory.")):
            self.get_config(Schema, {'option': d})

    def test_not_a_file(self) -> None:
        d = os.path.dirname(__file__)

        class Schema(Config):
            option = c.File(exists=True)

        with self.expect_error(option=re.compile(r"The path '.+' isn't an existing file.")):
            self.get_config(Schema, {'option': d})

    def test_incorrect_type_error(self) -> None:
        for cls in c.Dir, c.File, c.FilesystemObject:
            with self.subTest(cls):

                class Schema(Config):
                    option = cls()

                with self.expect_error(
                    option="Expected type: <class 'str'> but received: <class 'int'>"
                ):
                    self.get_config(Schema, {'option': 1})
                with self.expect_error(
                    option="Expected type: <class 'str'> but received: <class 'list'>"
                ):
                    self.get_config(Schema, {'option': []})

    def test_with_unicode(self) -> None:
        for cls in c.Dir, c.File, c.FilesystemObject:
            with self.subTest(cls):

                class Schema(Config):
                    dir = cls()

                conf = self.get_config(Schema, {'dir': 'юникод'})
                self.assertIsInstance(conf.dir, str)

    def test_dir_bytes(self) -> None:
        class Schema(Config):
            dir = c.Dir()

        with self.expect_error(dir="Expected type: <class 'str'> but received: <class 'bytes'>"):
            self.get_config(Schema, {'dir': b'foo'})

    def test_config_dir_prepended(self) -> None:
        for cls in c.Dir, c.File, c.FilesystemObject:
            with self.subTest(cls):
                base_path = os.path.dirname(os.path.abspath(__file__))

                class Schema(Config):
                    dir = cls()

                conf = self.get_config(
                    Schema,
                    {'dir': 'foo'},
                    config_file_path=os.path.join(base_path, 'mkdocs.yml'),
                )
                self.assertEqual(conf.dir, os.path.join(base_path, 'foo'))

    def test_site_dir_is_config_dir_fails(self) -> None:
        class Schema(Config):
            dir = c.DocsDir()

        with self.expect_error(
            dir="The 'dir' should not be the parent directory of the config file. "
            "Use a child directory instead so that the 'dir' is a sibling of the config file."
        ):
            self.get_config(
                Schema,
                {'dir': '.'},
                config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
            )


class ListOfPathsTest(TestCase):
    def test_valid_path(self) -> None:
        paths = [os.path.dirname(__file__)]

        class Schema(Config):
            option = c.ListOfPaths()

        self.get_config(Schema, {'option': paths})

    def test_missing_path(self) -> None:
        paths = [os.path.join("does", "not", "exist", "i", "hope")]

        class Schema(Config):
            option = c.ListOfPaths()

        with self.expect_error(
            option=f"The path '{paths[0]}' isn't an existing file or directory."
        ):
            self.get_config(Schema, {'option': paths})

    def test_non_path(self) -> None:
        paths = [os.path.dirname(__file__), None]

        class Schema(Config):
            option = c.ListOfPaths()

        with self.expect_error(
            option="Expected type: <class 'str'> but received: <class 'NoneType'>"
        ):
            self.get_config(Schema, {'option': paths})

    def test_empty_list(self) -> None:
        class Schema(Config):
            option = c.ListOfPaths()

        conf = self.get_config(Schema, {'option': []})
        assert_type(conf.option, List[str])
        self.assertEqual(conf.option, [])

    def test_none(self) -> None:
        class Schema(Config):
            option = c.ListOfPaths()

        with self.expect_error(option="Required configuration not provided."):
            self.get_config(Schema, {'option': None})

    def test_non_list(self) -> None:
        paths = os.path.dirname(__file__)

        class Schema(Config):
            option = c.ListOfPaths()

        with self.expect_error(option="Expected a list of items, but a <class 'str'> was given."):
            self.get_config(Schema, {'option': paths})

    def test_file(self) -> None:
        paths = [__file__]

        class Schema(Config):
            option = c.ListOfPaths()

        conf = self.get_config(Schema, {'option': paths})
        assert_type(conf.option, List[str])

    @tempdir()
    def test_paths_localized_to_config(self, base_path) -> None:
        with open(os.path.join(base_path, 'foo'), 'w') as f:
            f.write('hi')

        class Schema(Config):
            watch = c.ListOfPaths()

        conf = self.get_config(
            Schema,
            {'watch': ['foo']},
            config_file_path=os.path.join(base_path, 'mkdocs.yml'),
        )

        self.assertEqual(conf.watch, [os.path.join(base_path, 'foo')])


class SiteDirTest(TestCase):
    class Schema(Config):
        site_dir = c.SiteDir()
        docs_dir = c.Dir()

    def test_doc_dir_in_site_dir(self) -> None:
        j = os.path.join
        # The parent dir is not the same on every system, so use the actual dir name
        parent_dir = mkdocs.__file__.split(os.sep)[-3]

        test_configs = (
            {'docs_dir': j('site', 'docs'), 'site_dir': 'site'},
            {'docs_dir': 'docs', 'site_dir': '.'},
            {'docs_dir': '.', 'site_dir': '.'},
            {'docs_dir': 'docs', 'site_dir': ''},
            {'docs_dir': '', 'site_dir': ''},
            {'docs_dir': j('..', parent_dir, 'docs'), 'site_dir': 'docs'},
            {'docs_dir': 'docs', 'site_dir': '/'},
        )

        for test_config in test_configs:
            with self.subTest(test_config):
                with self.expect_error(
                    site_dir=re.compile(r"The 'docs_dir' should not be within the 'site_dir'.*")
                ):
                    self.get_config(self.Schema, test_config)

    def test_site_dir_in_docs_dir(self) -> None:
        j = os.path.join

        test_configs = (
            {'docs_dir': 'docs', 'site_dir': j('docs', 'site')},
            {'docs_dir': '.', 'site_dir': 'site'},
            {'docs_dir': '', 'site_dir': 'site'},
            {'docs_dir': '/', 'site_dir': 'site'},
        )

        for test_config in test_configs:
            with self.subTest(test_config):
                with self.expect_error(
                    site_dir=re.compile(r"The 'site_dir' should not be within the 'docs_dir'.*")
                ):
                    self.get_config(self.Schema, test_config)

    def test_common_prefix(self) -> None:
        """Legitimate settings with common prefixes should not fail validation."""
        test_configs = (
            {'docs_dir': 'docs', 'site_dir': 'docs-site'},
            {'docs_dir': 'site-docs', 'site_dir': 'site'},
        )

        for test_config in test_configs:
            with self.subTest(test_config):
                self.get_config(self.Schema, test_config)


class ThemeTest(TestCase):
    def test_theme_as_string(self) -> None:
        class Schema(Config):
            option = c.Theme()

        conf = self.get_config(Schema, {'option': "mkdocs"})
        assert_type(conf.option, Theme)
        assert_type(conf.option.name, Optional[str])
        self.assertEqual(conf.option.name, 'mkdocs')

    def test_uninstalled_theme_as_string(self) -> None:
        class Schema(Config):
            theme = c.Theme()
            plugins = c.Plugins(theme_key='theme')

        with self.expect_error(
            theme=re.compile(
                r"Unrecognised theme name: 'mkdocs2'. The available installed themes are: .+"
            )
        ):
            self.get_config(Schema, {'theme': "mkdocs2", 'plugins': "search"})

    def test_theme_default(self) -> None:
        class Schema(Config):
            option = c.Theme(default='mkdocs')

        conf = self.get_config(Schema, {'option': None})
        self.assertEqual(conf.option.name, 'mkdocs')

    def test_theme_as_simple_config(self) -> None:
        config = {
            'name': 'mkdocs',
        }

        class Schema(Config):
            option = c.Theme()

        conf = self.get_config(Schema, {'option': config})
        self.assertEqual(conf.option.name, 'mkdocs')

    @tempdir()
    def test_theme_as_complex_config(self, custom_dir) -> None:
        config = {
            'name': 'mkdocs',
            'custom_dir': custom_dir,
            'static_templates': ['sitemap.html'],
            'show_sidebar': False,
        }

        class Schema(Config):
            option = c.Theme()

        conf = self.get_config(Schema, {'option': config})
        self.assertEqual(conf.option.name, 'mkdocs')
        self.assertEqual(conf.option.custom_dir, custom_dir)
        self.assertIn(custom_dir, conf.option.dirs)
        self.assertEqual(
            conf.option.static_templates,
            {'404.html', 'sitemap.xml', 'sitemap.html'},
        )
        self.assertEqual(conf.option['show_sidebar'], False)

    def test_theme_name_is_none(self) -> None:
        config = {
            'name': None,
        }

        class Schema(Config):
            option = c.Theme()

        with self.expect_error(option="At least one of 'name' or 'custom_dir' must be defined."):
            self.get_config(Schema, {'option': config})

    def test_theme_config_missing_name(self) -> None:
        config = {
            'custom_dir': 'custom',
        }

        class Schema(Config):
            option = c.Theme()

        with self.expect_error(option="No theme name set."):
            self.get_config(Schema, {'option': config})

    def test_uninstalled_theme_as_config(self) -> None:
        config = {
            'name': 'mkdocs2',
        }

        class Schema(Config):
            option = c.Theme()

        with self.expect_error(
            option=re.compile(
                r"Unrecognised theme name: 'mkdocs2'. The available installed themes are: .+"
            )
        ):
            self.get_config(Schema, {'option': config})

    def test_theme_invalid_type(self) -> None:
        config = ['mkdocs2']

        class Schema(Config):
            option = c.Theme()

        with self.expect_error(
            option="Invalid type <class 'list'>. Expected a string or key/value pairs."
        ):
            self.get_config(Schema, {'option': config})

    def test_post_validation_none_theme_name_and_missing_custom_dir(self) -> None:
        config = {
            'theme': {
                'name': None,
            },
        }

        class Schema(Config):
            theme = c.Theme()

        with self.expect_error(theme="At least one of 'name' or 'custom_dir' must be defined."):
            self.get_config(Schema, config)

    @tempdir()
    def test_post_validation_inexisting_custom_dir(self, abs_base_path) -> None:
        path = os.path.join(abs_base_path, 'inexisting_custom_dir')
        config = {
            'theme': {
                'name': None,
                'custom_dir': path,
            },
        }

        class Schema(Config):
            theme = c.Theme()

        with self.expect_error(theme=f"The path set in custom_dir ('{path}') does not exist."):
            self.get_config(Schema, config)

    def test_post_validation_locale_none(self) -> None:
        config = {
            'theme': {
                'name': 'mkdocs',
                'locale': None,
            },
        }

        class Schema(Config):
            theme = c.Theme()

        with self.expect_error(theme="'locale' must be a string."):
            self.get_config(Schema, config)

    def test_post_validation_locale_invalid_type(self) -> None:
        config = {
            'theme': {
                'name': 'mkdocs',
                'locale': 0,
            },
        }

        class Schema(Config):
            theme = c.Theme()

        with self.expect_error(theme="'locale' must be a string."):
            self.get_config(Schema, config)

    def test_post_validation_locale(self) -> None:
        config = {
            'theme': {
                'name': 'mkdocs',
                'locale': 'fr',
            },
        }

        class Schema(Config):
            theme = c.Theme()

        conf = self.get_config(Schema, config)
        self.assertEqual(conf.theme.locale.language, 'fr')


class NavTest(TestCase):
    class Schema(Config):
        option = c.Nav()

    def test_old_format(self) -> None:
        with self.expect_error(
            option="Expected nav item to be a string or dict, got a list: ['index.md']"
        ):
            self.get_config(self.Schema, {'option': [['index.md']]})

    def test_provided_dict(self) -> None:
        conf = self.get_config(self.Schema, {'option': ['index.md', {"Page": "page.md"}]})
        self.assertEqual(conf.option, ['index.md', {'Page': 'page.md'}])

    def test_provided_empty(self) -> None:
        conf = self.get_config(self.Schema, {'option': []})
        self.assertEqual(conf.option, None)

    def test_normal_nav(self) -> None:
        nav_yaml = textwrap.dedent(
            '''\
            - Home: index.md
            - getting-started.md
            - User Guide:
              - Overview: user-guide/index.md
              - Installation: user-guide/installation.md
            '''
        )
        nav = yaml_load(io.StringIO(nav_yaml))

        conf = self.get_config(self.Schema, {'option': nav})
        self.assertEqual(conf.option, nav)

    def test_invalid_type_dict(self) -> None:
        with self.expect_error(option="Expected nav to be a list, got a dict: {}"):
            self.get_config(self.Schema, {'option': {}})

    def test_invalid_type_int(self) -> None:
        with self.expect_error(option="Expected nav to be a list, got a int: 5"):
            self.get_config(self.Schema, {'option': 5})

    def test_invalid_item_int(self) -> None:
        with self.expect_error(option="Expected nav item to be a string or dict, got a int: 1"):
            self.get_config(self.Schema, {'option': [1]})

    def test_invalid_item_none(self) -> None:
        with self.expect_error(option="Expected nav item to be a string or dict, got None"):
            self.get_config(self.Schema, {'option': [None]})

    def test_invalid_children_config_int(self) -> None:
        with self.expect_error(option="Expected nav to be a list, got a int: 1"):
            self.get_config(self.Schema, {'option': [{"foo.md": [{"bar.md": 1}]}]})

    def test_invalid_children_config_none(self) -> None:
        with self.expect_error(option="Expected nav to be a list, got None"):
            self.get_config(self.Schema, {'option': [{"foo.md": None}]})

    def test_invalid_children_empty_dict(self) -> None:
        nav = ['foo', {}]
        with self.expect_error(option="Expected nav item to be a dict of size 1, got a dict: {}"):
            self.get_config(self.Schema, {'option': nav})

    def test_invalid_nested_list(self) -> None:
        nav = [{'aaa': [[{"bbb": "user-guide/index.md"}]]}]
        with self.expect_error(
            option="Expected nav item to be a string or dict, got a list: [{'bbb': 'user-guide/index.md'}]"
        ):
            self.get_config(self.Schema, {'option': nav})

    def test_invalid_children_oversized_dict(self) -> None:
        nav = [{"aaa": [{"bbb": "user-guide/index.md", "ccc": "user-guide/installation.md"}]}]
        with self.expect_error(
            option="Expected nav item to be a dict of size 1, got dict with keys ('bbb', 'ccc')"
        ):
            self.get_config(self.Schema, {'option': nav})

    def test_warns_for_dict(self) -> None:
        self.get_config(
            self.Schema,
            {'option': [{"a": {"b": "c.md", "d": "e.md"}}]},
            warnings=dict(option="Expected nav to be a list, got dict with keys ('b', 'd')"),
        )


class PrivateTest(TestCase):
    def test_defined(self) -> None:
        class Schema(Config):
            option = c.Private[Any]()

        with self.expect_error(option="For internal use only."):
            self.get_config(Schema, {'option': 'somevalue'})


class SubConfigTest(TestCase):
    def test_subconfig_wrong_type(self) -> None:
        # Test that an error is raised if subconfig does not receive a dict
        class Schema(Config):
            option = c.SubConfig()

        for val in "not_a_dict", ("not_a_dict",), ["not_a_dict"]:
            with self.subTest(val):
                with self.expect_error(
                    option=re.compile(
                        r"The configuration is invalid. Expected a key-value mapping "
                        r"\(dict\) but received: .+"
                    )
                ):
                    self.get_config(Schema, {'option': val})

    def test_subconfig_unknown_option(self) -> None:
        class Schema(Config):
            option = c.SubConfig(validate=True)

        conf = self.get_config(
            Schema,
            {'option': {'unknown': 0}},
            warnings=dict(option="Sub-option 'unknown': Unrecognised configuration name: unknown"),
        )
        self.assertEqual(conf.option, {"unknown": 0})

    def test_subconfig_invalid_option(self) -> None:
        class Sub(Config):
            cc = c.Choice(('foo', 'bar'))

        class Schema(Config):
            option = c.SubConfig(Sub)

        with self.expect_error(
            option="Sub-option 'cc': Expected one of: ('foo', 'bar') but received: True"
        ):
            self.get_config(Schema, {'option': {'cc': True}})

    def test_subconfig_normal(self) -> None:
        class Sub(Config):
            cc = c.Choice(('foo', 'bar'))

        class Schema(Config):
            option = c.SubConfig(Sub)

        conf = self.get_config(Schema, {'option': {'cc': 'foo'}})
        assert_type(conf.option, Sub)
        self.assertEqual(conf.option, {'cc': 'foo'})
        assert_type(conf.option.cc, str)
        self.assertEqual(conf.option.cc, 'foo')

    def test_subconfig_with_multiple_items(self) -> None:
        # This had a bug where subsequent items would get merged into the same dict.
        class Sub(Config):
            value = c.Type(str)

        class Schema(Config):
            the_items = c.ListOfItems(c.SubConfig(Sub))

        conf = self.get_config(
            Schema,
            {
                'the_items': [
                    {'value': 'a'},
                    {'value': 'b'},
                ]
            },
        )
        assert_type(conf.the_items, List[Sub])
        self.assertEqual(conf.the_items, [{'value': 'a'}, {'value': 'b'}])
        assert_type(conf.the_items[1].value, str)
        self.assertEqual(conf.the_items[1].value, 'b')

    def test_optional(self) -> None:
        class Sub(Config):
            opt = c.Optional(c.Type(int))

        class Schema(Config):
            sub = c.Optional(c.ListOfItems(c.SubConfig(Sub)))

        conf = self.get_config(Schema, {})
        self.assertEqual(conf.sub, None)

        conf = self.get_config(Schema, {'sub': None})
        self.assertEqual(conf.sub, None)

        conf = self.get_config(Schema, {'sub': [{'opt': 1}, {}]})
        assert_type(conf.sub, Optional[List[Sub]])
        self.assertEqual(conf.sub, [{'opt': 1}, {'opt': None}])
        assert conf.sub is not None
        assert_type(conf.sub[0].opt, Optional[int])
        self.assertEqual(conf.sub[0].opt, 1)

        conf = self.get_config(Schema, {'sub': []})

        conf = self.get_config(Schema, {'sub': [{'opt': 1}, {'opt': 2}]})
        self.assertEqual(conf.sub, [{'opt': 1}, {'opt': 2}])

    def test_required(self) -> None:
        class Sub(Config):
            opt = c.Type(int)

        class Schema(Config):
            sub = c.ListOfItems(c.SubConfig(Sub))

        with self.expect_error(sub="Required configuration not provided."):
            conf = self.get_config(Schema, {})

        with self.expect_error(sub="Required configuration not provided."):
            conf = self.get_config(Schema, {'sub': None})

        with self.expect_error(
            sub="Sub-option 'opt': Expected type: <class 'int'> but received: <class 'str'>"
        ):
            conf = self.get_config(Schema, {'sub': [{'opt': 'asdf'}, {}]})

        conf = self.get_config(Schema, {'sub': []})

        conf = self.get_config(Schema, {'sub': [{'opt': 1}, {'opt': 2}]})
        assert_type(conf.sub, List[Sub])
        self.assertEqual(conf.sub, [{'opt': 1}, {'opt': 2}])
        assert_type(conf.sub[0].opt, int)
        self.assertEqual(conf.sub[0].opt, 1)

        with self.expect_error(
            sub="Sub-option 'opt': Expected type: <class 'int'> but received: <class 'str'>"
        ):
            self.get_config(Schema, {'sub': [{'opt': 'z'}, {'opt': 2}]})

        with self.expect_error(
            sub="Sub-option 'opt': Expected type: <class 'int'> but received: <class 'str'>"
        ):
            conf = self.get_config(Schema, {'sub': [{'opt': 'z'}, {'opt': 2}]})

        with self.expect_error(
            sub="The configuration is invalid. Expected a key-value mapping "
            "(dict) but received: <class 'int'>"
        ):
            conf = self.get_config(Schema, {'sub': [1, 2]})

    def test_default(self) -> None:
        class Sub(Config):
            opt = c.Type(int)

        class Schema(Config):
            sub = c.ListOfItems(c.SubConfig(Sub), default=[])

        conf = self.get_config(Schema, {})
        assert_type(conf.sub, List[Sub])
        self.assertEqual(conf.sub, [])

        with self.expect_error(sub="Required configuration not provided."):
            conf = self.get_config(Schema, {'sub': None})

    def test_config_file_path_pass_through(self) -> None:
        """Necessary to ensure FilesystemObject validates the correct path."""
        passed_config_path = None

        class SubType(c.BaseConfigOption):
            def pre_validation(self, config: Config, key_name: str) -> None:
                nonlocal passed_config_path
                passed_config_path = config.config_file_path

        class Sub(Config):
            opt = SubType()

        class Schema(Config):
            sub = c.ListOfItems(c.SubConfig(Sub), default=[])

        config_path = "foo/mkdocs.yaml"
        self.get_config(Schema, {"sub": [{"opt": "bar"}]}, config_file_path=config_path)
        self.assertEqual(passed_config_path, config_path)


class NestedSubConfigTest(TestCase):
    def defaults(self):
        return {
            'nav': {
                'omitted_files': logging.INFO,
                'not_found': logging.WARNING,
                'absolute_links': logging.INFO,
            },
            'links': {
                'not_found': logging.WARNING,
                'absolute_links': logging.INFO,
                'unrecognized_links': logging.INFO,
                'anchors': logging.INFO,
            },
        }

    class Schema(Config):
        validation = c.PropagatingSubConfig[defaults.MkDocsConfig.Validation]()

    def test_unspecified(self) -> None:
        cfgs: list[dict] = [{}, {'validation': {}}]
        for cfg in cfgs:
            with self.subTest(cfg):
                conf = self.get_config(
                    self.Schema,
                    {},
                )
                self.assertEqual(conf.validation, self.defaults())

    def test_sets_nested_and_not_nested(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'validation': {'not_found': 'ignore', 'links': {'absolute_links': 'warn'}}},
        )
        expected = self.defaults()
        expected['nav']['not_found'] = logging.DEBUG
        expected['links']['not_found'] = logging.DEBUG
        expected['links']['absolute_links'] = logging.WARNING
        self.assertEqual(conf.validation, expected)

    def test_sets_nested_different(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'validation': {'not_found': 'ignore', 'links': {'not_found': 'warn'}}},
        )
        expected = self.defaults()
        expected['nav']['not_found'] = logging.DEBUG
        expected['links']['not_found'] = logging.WARNING
        self.assertEqual(conf.validation, expected)

    def test_sets_only_one_nested(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'validation': {'omitted_files': 'ignore'}},
        )
        expected = self.defaults()
        expected['nav']['omitted_files'] = logging.DEBUG
        self.assertEqual(conf.validation, expected)

    def test_sets_nested_not_dict(self) -> None:
        with self.expect_error(
            validation="Sub-option 'links': Sub-option 'unrecognized_links': Expected a string, but a <class 'list'> was given."
        ):
            self.get_config(
                self.Schema,
                {'validation': {'unrecognized_links': [], 'links': {'absolute_links': 'warn'}}},
            )

    def test_wrong_key_nested(self) -> None:
        conf = self.get_config(
            self.Schema,
            {'validation': {'foo': 'warn', 'not_found': 'warn'}},
            warnings=dict(validation="Sub-option 'foo': Unrecognised configuration name: foo"),
        )
        expected = self.defaults()
        expected['nav']['not_found'] = logging.WARNING
        expected['links']['not_found'] = logging.WARNING
        expected['foo'] = 'warn'
        self.assertEqual(conf.validation, expected)

    def test_wrong_type_nested(self) -> None:
        with self.expect_error(
            validation="Sub-option 'nav': Sub-option 'omitted_files': Expected one of ['warn', 'info', 'ignore'], got 'hi'"
        ):
            self.get_config(
                self.Schema,
                {'validation': {'omitted_files': 'hi'}},
            )


class MarkdownExtensionsTest(TestCase):
    @mock.patch('markdown.Markdown', mock.Mock())
    def test_simple_list(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions()
            mdx_configs = c.Private[Dict[str, dict]]()

        config = {
            'markdown_extensions': ['foo', 'bar'],
        }
        conf = self.get_config(Schema, config)
        assert_type(conf.markdown_extensions, List[str])
        assert_type(conf.mdx_configs, Dict[str, dict])
        self.assertEqual(conf.markdown_extensions, ['foo', 'bar'])
        self.assertEqual(conf.mdx_configs, {})

    @mock.patch('markdown.Markdown', mock.Mock())
    def test_list_dicts(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions()
            mdx_configs = c.Private[Dict[str, dict]]()

        config = {
            'markdown_extensions': [
                {'foo': {'foo_option': 'foo value'}},
                {'bar': {'bar_option': 'bar value'}},
                {'baz': None},
            ]
        }
        conf = self.get_config(Schema, config)
        self.assertEqual(conf.markdown_extensions, ['foo', 'bar', 'baz'])
        self.assertEqual(
            conf.mdx_configs,
            {
                'foo': {'foo_option': 'foo value'},
                'bar': {'bar_option': 'bar value'},
            },
        )

    @mock.patch('markdown.Markdown', mock.Mock())
    def test_mixed_list(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions()
            mdx_configs = c.Private[Dict[str, dict]]()

        config = {
            'markdown_extensions': [
                'foo',
                {'bar': {'bar_option': 'bar value'}},
            ]
        }
        conf = self.get_config(Schema, config)
        self.assertEqual(conf.markdown_extensions, ['foo', 'bar'])
        self.assertEqual(
            conf.mdx_configs,
            {
                'bar': {'bar_option': 'bar value'},
            },
        )

    @mock.patch('markdown.Markdown', mock.Mock())
    def test_dict_of_dicts(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions()
            mdx_configs = c.Private[Dict[str, dict]]()

        config = {
            'markdown_extensions': {
                'foo': {'foo_option': 'foo value'},
                'bar': {'bar_option': 'bar value'},
                'baz': {},
            }
        }
        conf = self.get_config(Schema, config)
        self.assertEqual(conf.markdown_extensions, ['foo', 'bar', 'baz'])
        self.assertEqual(
            conf.mdx_configs,
            {
                'foo': {'foo_option': 'foo value'},
                'bar': {'bar_option': 'bar value'},
            },
        )

    @mock.patch('markdown.Markdown', mock.Mock())
    def test_builtins(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
            mdx_configs = c.Private[Dict[str, dict]]()

        config = {
            'markdown_extensions': ['foo', 'bar'],
        }
        conf = self.get_config(Schema, config)
        self.assertEqual(conf.markdown_extensions, ['meta', 'toc', 'foo', 'bar'])
        self.assertEqual(conf.mdx_configs, {})

    def test_duplicates(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
            mdx_configs = c.Private[Dict[str, dict]]()

        config = {
            'markdown_extensions': ['meta', 'toc'],
        }
        conf = self.get_config(Schema, config)
        self.assertEqual(conf.markdown_extensions, ['meta', 'toc'])
        self.assertEqual(conf.mdx_configs, {})

    def test_builtins_config(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions(builtins=['meta', 'toc'])
            mdx_configs = c.Private[Dict[str, dict]]()

        config = {
            'markdown_extensions': [
                {'toc': {'permalink': True}},
            ],
        }
        conf = self.get_config(Schema, config)
        self.assertEqual(conf.markdown_extensions, ['meta', 'toc'])
        self.assertEqual(conf.mdx_configs, {'toc': {'permalink': True}})

    @mock.patch('markdown.Markdown', mock.Mock())
    def test_configkey(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions(configkey='bar')
            bar = c.Private[Dict[str, dict]]()

        config = {
            'markdown_extensions': [
                {'foo': {'foo_option': 'foo value'}},
            ]
        }
        conf = self.get_config(Schema, config)
        self.assertEqual(conf.markdown_extensions, ['foo'])
        self.assertEqual(
            conf.bar,
            {
                'foo': {'foo_option': 'foo value'},
            },
        )

    def test_missing_default(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions()
            mdx_configs = c.Private[Dict[str, dict]]()

        conf = self.get_config(Schema, {})
        self.assertEqual(conf.markdown_extensions, [])
        self.assertEqual(conf.mdx_configs, {})

    def test_none(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions(default=[])
            mdx_configs = c.Private[Dict[str, dict]]()

        config = {
            'markdown_extensions': None,
        }
        conf = self.get_config(Schema, config)
        self.assertEqual(conf.markdown_extensions, [])
        self.assertEqual(conf.mdx_configs, {})

    @mock.patch('markdown.Markdown', mock.Mock())
    def test_not_list(self) -> None:
        class Schema(Config):
            option = c.MarkdownExtensions()

        with self.expect_error(option="Invalid Markdown Extensions configuration"):
            self.get_config(Schema, {'option': 'not a list'})

    @mock.patch('markdown.Markdown', mock.Mock())
    def test_invalid_config_option(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions()

        config = {
            'markdown_extensions': [
                {'foo': 'not a dict'},
            ],
        }
        with self.expect_error(
            markdown_extensions="Invalid config options for Markdown Extension 'foo'."
        ):
            self.get_config(Schema, config)

    @mock.patch('markdown.Markdown', mock.Mock())
    def test_invalid_config_item(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions()

        config = {
            'markdown_extensions': [
                ['not a dict'],
            ],
        }
        with self.expect_error(markdown_extensions="Invalid Markdown Extensions configuration"):
            self.get_config(Schema, config)

    @mock.patch('markdown.Markdown', mock.Mock())
    def test_invalid_dict_item(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions()

        config = {
            'markdown_extensions': [
                {'key1': 'value', 'key2': 'too many keys'},
            ],
        }
        with self.expect_error(markdown_extensions="Invalid Markdown Extensions configuration"):
            self.get_config(Schema, config)

    def test_unknown_extension(self) -> None:
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions()

        config = {
            'markdown_extensions': ['unknown'],
        }
        with self.expect_error(
            markdown_extensions=re.compile(r"Failed to load extension 'unknown'.\n.+")
        ):
            self.get_config(Schema, config)

    def test_multiple_markdown_config_instances(self) -> None:
        # This had a bug where an extension config would persist to separate
        # config instances that didn't specify extensions.
        class Schema(Config):
            markdown_extensions = c.MarkdownExtensions()
            mdx_configs = c.Private[Dict[str, dict]]()

        conf = self.get_config(
            Schema,
            {
                'markdown_extensions': [{'toc': {'permalink': '##'}}],
            },
        )
        self.assertEqual(conf.mdx_configs['toc'], {'permalink': '##'})

        conf = self.get_config(
            Schema,
            {},
        )
        self.assertIsNone(conf.mdx_configs.get('toc'))


class _FakePluginConfig(Config):
    foo = c.Type(str, default='default foo')
    bar = c.Type(int, default=0)
    dir = c.Optional(c.Dir(exists=False))


class FakePlugin(BasePlugin[_FakePluginConfig]):
    pass


class _FakePlugin2Config(_FakePluginConfig):
    depr = c.Deprecated()


class FakePlugin2(BasePlugin[_FakePlugin2Config]):
    supports_multiple_instances = True


class _EnabledPluginConfig(Config):
    enabled = c.Type(bool, default=True)
    bar = c.Type(int, default=0)


class EnabledPlugin(BasePlugin[_EnabledPluginConfig]):
    pass


class ThemePlugin(BasePlugin[_FakePluginConfig]):
    pass


class ThemePlugin2(BasePlugin[_FakePluginConfig]):
    pass


class FakeEntryPoint:
    def __init__(self, name, cls):
        self.name = name
        self.cls = cls

    def load(self):
        return self.cls


@mock.patch(
    'mkdocs.plugins.entry_points',
    mock.Mock(
        return_value=[
            FakeEntryPoint('sample', FakePlugin),
            FakeEntryPoint('sample2', FakePlugin2),
            FakeEntryPoint('sample-e', EnabledPlugin),
            FakeEntryPoint('readthedocs/sub_plugin', ThemePlugin),
            FakeEntryPoint('overridden', FakePlugin2),
            FakeEntryPoint('readthedocs/overridden', ThemePlugin2),
        ]
    ),
)
class PluginsTest(TestCase):
    def test_plugin_config_without_options(self) -> None:
        class Schema(Config):
            plugins = c.Plugins()

        cfg = {
            'plugins': ['sample'],
        }
        conf = self.get_config(Schema, cfg)

        assert_type(conf.plugins, PluginCollection)
        self.assertIsInstance(conf.plugins, PluginCollection)
        self.assertIn('sample', conf.plugins)

        plugin = conf.plugins['sample']
        assert_type(plugin, BasePlugin)
        self.assertIsInstance(plugin, FakePlugin)
        self.assertIsInstance(plugin.config, _FakePluginConfig)
        expected = {
            'foo': 'default foo',
            'bar': 0,
            'dir': None,
        }
        self.assertEqual(plugin.config, expected)

    def test_plugin_config_with_options(self) -> None:
        class Schema(Config):
            plugins = c.Plugins()

        cfg = {
            'plugins': [
                {
                    'sample': {
                        'foo': 'foo value',
                        'bar': 42,
                    },
                }
            ],
        }
        conf = self.get_config(Schema, cfg)

        self.assertIsInstance(conf.plugins, PluginCollection)
        self.assertIn('sample', conf.plugins)
        self.assertIsInstance(conf.plugins['sample'], BasePlugin)
        expected = {
            'foo': 'foo value',
            'bar': 42,
            'dir': None,
        }
        self.assertEqual(conf.plugins['sample'].config, expected)

    def test_plugin_config_as_dict(self) -> None:
        class Schema(Config):
            plugins = c.Plugins()

        cfg = {
            'plugins': {
                'sample': {
                    'foo': 'foo value',
                    'bar': 42,
                },
            },
        }
        conf = self.get_config(Schema, cfg)

        self.assertIsInstance(conf.plugins, PluginCollection)
        self.assertIn('sample', conf.plugins)
        self.assertIsInstance(conf.plugins['sample'], BasePlugin)
        expected = {
            'foo': 'foo value',
            'bar': 42,
            'dir': None,
        }
        self.assertEqual(conf.plugins['sample'].config, expected)

    def test_plugin_config_with_explicit_theme_namespace(self) -> None:
        class Schema(Config):
            theme = c.Theme(default='mkdocs')
            plugins = c.Plugins(theme_key='theme')

        cfg = {'theme': 'readthedocs', 'plugins': ['readthedocs/sub_plugin']}
        conf = self.get_config(Schema, cfg)

        self.assertEqual(set(conf.plugins), {'readthedocs/sub_plugin'})
        self.assertIsInstance(conf.plugins['readthedocs/sub_plugin'], ThemePlugin)

        cfg = {'plugins': ['readthedocs/sub_plugin']}
        conf = self.get_config(Schema, cfg)

        self.assertEqual(set(conf.plugins), {'readthedocs/sub_plugin'})
        self.assertIsInstance(conf.plugins['readthedocs/sub_plugin'], ThemePlugin)

    def test_plugin_config_with_deduced_theme_namespace(self) -> None:
        class Schema(Config):
            theme = c.Theme(default='mkdocs')
            plugins = c.Plugins(theme_key='theme')

        cfg = {'theme': 'readthedocs', 'plugins': ['sub_plugin']}
        conf = self.get_config(Schema, cfg)

        self.assertEqual(set(conf.plugins), {'readthedocs/sub_plugin'})
        self.assertIsInstance(conf.plugins['readthedocs/sub_plugin'], ThemePlugin)

        cfg = {'plugins': ['sub_plugin']}
        with self.expect_error(plugins='The "sub_plugin" plugin is not installed'):
            self.get_config(Schema, cfg)

    def test_plugin_config_with_deduced_theme_namespace_overridden(self) -> None:
        class Schema(Config):
            theme = c.Theme(default='mkdocs')
            plugins = c.Plugins(theme_key='theme')

        cfg = {'theme': 'readthedocs', 'plugins': ['overridden']}
        conf = self.get_config(Schema, cfg)

        self.assertEqual(set(conf.plugins), {'readthedocs/overridden'})
        self.assertIsInstance(next(iter(conf.plugins.values())), ThemePlugin2)

        cfg = {'plugins': ['overridden']}
        conf = self.get_config(Schema, cfg)

        self.assertEqual(set(conf.plugins), {'overridden'})
        self.assertIsInstance(conf.plugins['overridden'], FakePlugin2)

    def test_plugin_config_with_explicit_empty_namespace(self) -> None:
        class Schema(Config):
            theme = c.Theme(default='mkdocs')
            plugins = c.Plugins(theme_key='theme')

        cfg = {'theme': 'readthedocs', 'plugins': ['/overridden']}
        conf = self.get_config(Schema, cfg)

        self.assertEqual(set(conf.plugins), {'overridden'})
        self.assertIsInstance(next(iter(conf.plugins.values())), FakePlugin2)

        cfg = {'plugins': ['/overridden']}
        conf = self.get_config(Schema, cfg)

        self.assertEqual(set(conf.plugins), {'overridden'})
        self.assertIsInstance(conf.plugins['overridden'], FakePlugin2)

    def test_plugin_config_enabled_for_any_plugin(self) -> None:
        class Schema(Config):
            theme = c.Theme(default='mkdocs')
            plugins = c.Plugins(theme_key='theme')

        cfg = {'theme': 'readthedocs', 'plugins': {'sample': {'enabled': False, 'bar': 3}}}
        conf = self.get_config(Schema, cfg)
        self.assertEqual(set(conf.plugins), set())

        cfg = {'theme': 'readthedocs', 'plugins': {'sample': {'enabled': True, 'bar': 3}}}
        conf = self.get_config(Schema, cfg)
        self.assertEqual(set(conf.plugins), {'sample'})
        self.assertEqual(conf.plugins['sample'].config.bar, 3)

        cfg = {'theme': 'readthedocs', 'plugins': {'sample': {'enabled': 5}}}
        with self.expect_error(
            plugins="Plugin 'sample' option 'enabled': Expected boolean but received: <class 'int'>"
        ):
            self.get_config(Schema, cfg)

    def test_plugin_config_enabled_for_plugin_with_setting(self) -> None:
        class Schema(Config):
            theme = c.Theme(default='mkdocs')
            plugins = c.Plugins(theme_key='theme')

        cfg = {'theme': 'readthedocs', 'plugins': {'sample-e': {'enabled': False, 'bar': 3}}}
        conf = self.get_config(Schema, cfg)
        self.assertEqual(set(conf.plugins), {'sample-e'})
        self.assertEqual(conf.plugins['sample-e'].config.enabled, False)
        self.assertEqual(conf.plugins['sample-e'].config.bar, 3)

        cfg = {'theme': 'readthedocs', 'plugins': {'sample-e': {'enabled': True, 'bar': 3}}}
        conf = self.get_config(Schema, cfg)
        self.assertEqual(set(conf.plugins), {'sample-e'})
        self.assertEqual(conf.plugins['sample-e'].config.enabled, True)
        self.assertEqual(conf.plugins['sample-e'].config.bar, 3)

        cfg = {'theme': 'readthedocs', 'plugins': {'sample-e': {'enabled': 5}}}
        with self.expect_error(
            plugins="Plugin 'sample-e' option 'enabled': Expected type: <class 'bool'> but received: <class 'int'>"
        ):
            self.get_config(Schema, cfg)

    def test_plugin_config_with_multiple_instances(self) -> None:
        class Schema(Config):
            theme = c.Theme(default='mkdocs')
            plugins = c.Plugins(theme_key='theme')

        cfg = {
            'plugins': [
                {'sample2': {'foo': 'foo value', 'bar': 42}},
                {'sample2': {'foo': 'foo2 value'}},
            ],
        }
        conf = self.get_config(Schema, cfg)

        self.assertEqual(
            set(conf.plugins),
            {'sample2', 'sample2 #2'},
        )
        self.assertEqual(conf.plugins['sample2'].config['bar'], 42)
        self.assertEqual(conf.plugins['sample2 #2'].config['bar'], 0)

    def test_plugin_config_with_multiple_instances_and_warning(self) -> None:
        class Schema(Config):
            theme = c.Theme(default='mkdocs')
            plugins = c.Plugins(theme_key='theme')

        test_cfgs: list[dict[str, Any]] = [
            {
                'theme': 'readthedocs',
                'plugins': [{'sub_plugin': {}}, {'sample2': {}}, {'sub_plugin': {}}, 'sample2'],
            },
            {
                'theme': 'readthedocs',
                'plugins': ['sub_plugin', 'sample2', 'sample2', 'sub_plugin'],
            },
        ]

        for cfg in test_cfgs:
            conf = self.get_config(
                Schema,
                cfg,
                warnings=dict(
                    plugins="Plugin 'readthedocs/sub_plugin' was specified multiple times - "
                    "this is likely a mistake, because the plugin doesn't declare "
                    "`supports_multiple_instances`."
                ),
            )
            self.assertEqual(
                set(conf.plugins),
                {'readthedocs/sub_plugin', 'readthedocs/sub_plugin #2', 'sample2', 'sample2 #2'},
            )

    def test_plugin_config_empty_list_with_empty_default(self) -> None:
        class Schema(Config):
            plugins = c.Plugins(default=[])

        cfg: dict[str, Any] = {'plugins': []}
        conf = self.get_config(Schema, cfg)

        self.assertIsInstance(conf.plugins, PluginCollection)
        self.assertEqual(len(conf.plugins), 0)

    def test_plugin_config_empty_list_with_default(self) -> None:
        class Schema(Config):
            plugins = c.Plugins(default=['sample'])

        # Default is ignored
        cfg: dict[str, Any] = {'plugins': []}
        conf = self.get_config(Schema, cfg)

        self.assertIsInstance(conf.plugins, PluginCollection)
        self.assertEqual(len(conf.plugins), 0)

    def test_plugin_config_none_with_empty_default(self) -> None:
        class Schema(Config):
            plugins = c.Plugins(default=[])

        cfg = {'plugins': None}
        conf = self.get_config(Schema, cfg)

        self.assertIsInstance(conf.plugins, PluginCollection)
        self.assertEqual(len(conf.plugins), 0)

    def test_plugin_config_none_with_default(self) -> None:
        class Schema(Config):
            plugins = c.Plugins(default=['sample'])

        # Default is used.
        cfg = {'plugins': None}
        conf = self.get_config(Schema, cfg)

        self.assertIsInstance(conf.plugins, PluginCollection)
        self.assertIn('sample', conf.plugins)
        self.assertIsInstance(conf.plugins['sample'], BasePlugin)
        expected = {
            'foo': 'default foo',
            'bar': 0,
            'dir': None,
        }
        self.assertEqual(conf.plugins['sample'].config, expected)

    def test_plugin_config_uninstalled(self) -> None:
        class Schema(Config):
            plugins = c.Plugins()

        cfg = {'plugins': ['uninstalled']}
        with self.expect_error(plugins='The "uninstalled" plugin is not installed'):
            self.get_config(Schema, cfg)

    def test_plugin_config_not_list(self) -> None:
        class Schema(Config):
            plugins = c.Plugins()

        cfg = {'plugins': 'sample'}
        with self.expect_error(plugins="Invalid Plugins configuration. Expected a list or dict."):
            self.get_config(Schema, cfg)

    def test_plugin_config_multivalue_dict(self) -> None:
        class Schema(Config):
            plugins = c.Plugins()

        cfg = {
            'plugins': [
                {
                    'sample': {
                        'foo': 'foo value',
                        'bar': 42,
                    },
                    'extra_key': 'baz',
                }
            ],
        }
        with self.expect_error(plugins="Invalid Plugins configuration"):
            self.get_config(Schema, cfg)

        cfg = {
            'plugins': [
                {},
            ],
        }
        with self.expect_error(plugins="Invalid Plugins configuration"):
            self.get_config(Schema, cfg)

    def test_plugin_config_not_string_or_dict(self) -> None:
        class Schema(Config):
            plugins = c.Plugins()

        cfg = {
            'plugins': [('not a string or dict',)],
        }
        with self.expect_error(plugins="'('not a string or dict',)' is not a valid plugin name."):
            self.get_config(Schema, cfg)

    def test_plugin_config_options_not_dict(self) -> None:
        class Schema(Config):
            plugins = c.Plugins()

        cfg = {
            'plugins': [{'sample': 'not a dict'}],
        }
        with self.expect_error(plugins="Invalid config options for the 'sample' plugin."):
            self.get_config(Schema, cfg)

    def test_plugin_config_sub_error(self) -> None:
        class Schema(Config):
            plugins = c.Plugins(default=['sample'])

        cfg = {
            'plugins': {
                'sample': {'bar': 'not an int'},
            }
        }
        with self.expect_error(
            plugins="Plugin 'sample' option 'bar': Expected type: <class 'int'> but received: <class 'str'>"
        ):
            self.get_config(Schema, cfg)

    def test_plugin_config_sub_warning(self) -> None:
        class Schema(Config):
            plugins = c.Plugins()

        cfg = {
            'plugins': {
                'sample2': {'depr': 'deprecated value'},
            }
        }
        conf = self.get_config(
            Schema,
            cfg,
            warnings=dict(
                plugins="Plugin 'sample2' option 'depr': The configuration option "
                "'depr' has been deprecated and will be removed in a future release."
            ),
        )

        self.assertIsInstance(conf.plugins, PluginCollection)
        self.assertIn('sample2', conf.plugins)


class HooksTest(TestCase):
    class Schema(Config):
        plugins = c.Plugins(default=[])
        hooks = c.Hooks('plugins')

    @tempdir()
    def test_hooks(self, src_dir) -> None:
        write_file(
            b'def on_page_markdown(markdown, **kwargs): return markdown.replace("f", "z")',
            os.path.join(src_dir, 'hooks', 'my_hook.py'),
        )
        write_file(
            b'foo foo',
            os.path.join(src_dir, 'docs', 'index.md'),
        )
        conf = self.get_config(
            self.Schema,
            {'hooks': ['hooks/my_hook.py']},
            config_file_path=os.path.join(src_dir, 'mkdocs.yml'),
        )
        self.assertIn('hooks/my_hook.py', conf.plugins)
        hook = conf.plugins['hooks/my_hook.py']
        self.assertTrue(hasattr(hook, 'on_page_markdown'))
        self.assertEqual(
            {**conf.plugins.events, 'page_markdown': [hook.on_page_markdown]},
            conf.plugins.events,
        )
        self.assertEqual(hook.on_page_markdown('foo foo'), 'zoo zoo')  # type: ignore[call-arg]
        self.assertFalse(hasattr(hook, 'on_nav'))

    def test_hooks_wrong_type(self) -> None:
        with self.expect_error(hooks="Expected a list of items, but a <class 'int'> was given."):
            self.get_config(self.Schema, {'hooks': 6})

        with self.expect_error(hooks="Expected type: <class 'str'> but received: <class 'int'>"):
            self.get_config(self.Schema, {'hooks': [7]})


class SchemaTest(TestCase):
    def test_copy(self) -> None:
        class Schema(Config):
            foo = c.MarkdownExtensions()

        copy.deepcopy(Schema())

        copy.deepcopy(self.get_config(IpAddressTest.Schema, {'option': '1.2.3.4:5678'}))
        copy.deepcopy(IpAddressTest.Schema)
        copy.deepcopy(IpAddressTest.Schema())

        copy.deepcopy(self.get_config(EditURITest.Schema, {}))
        copy.deepcopy(EditURITest.Schema)
        copy.deepcopy(EditURITest.Schema())

    def test_subclass(self) -> None:
        class A(Config):
            foo = c.Type(int)
            bar = c.Optional(c.Type(str))

        class B(A):
            baz = c.ListOfItems(c.Type(str))

        conf = self.get_config(B, {'foo': 1, 'baz': ['b']})
        assert_type(conf.foo, int)
        self.assertEqual(conf.foo, 1)
        assert_type(conf.bar, Optional[str])
        self.assertEqual(conf.bar, None)
        assert_type(conf.baz, List[str])
        self.assertEqual(conf.baz, ['b'])

        with self.expect_error(baz="Required configuration not provided."):
            self.get_config(B, {'foo': 1})
        with self.expect_error(foo="Required configuration not provided."):
            self.get_config(B, {'baz': ['b']})

        bconf = self.get_config(A, {'foo': 2, 'bar': 'a'})
        assert_type(bconf.foo, int)
        self.assertEqual(bconf.foo, 2)
        self.assertEqual(bconf.bar, 'a')
