#!/usr/bin/env python

import dataclasses
import datetime
import logging
import os
import posixpath
import stat
import unittest
from unittest import mock

from mkdocs import exceptions, utils
from mkdocs.tests.base import dedent, tempdir
from mkdocs.utils import meta

BASEYML = """
INHERIT: parent.yml
foo: bar
baz:
    sub1: replaced
    sub3: new
deep1:
    deep2-1:
        deep3-1: replaced
"""
PARENTYML = """
foo: foo
baz:
    sub1: 1
    sub2: 2
deep1:
    deep2-1:
        deep3-1: foo
        deep3-2: bar
    deep2-2: baz
"""


class UtilsTests(unittest.TestCase):
    def test_is_markdown_file(self):
        expected_results = {
            'index.md': True,
            'index.markdown': True,
            'index.MARKDOWN': False,
            'index.txt': False,
            'indexmd': False,
        }
        for path, expected_result in expected_results.items():
            with self.subTest(path):
                is_markdown = utils.is_markdown_file(path)
                self.assertEqual(is_markdown, expected_result)

    def test_get_relative_url(self):
        for case in [
            dict(url='foo/bar', other='foo', expected='bar'),
            dict(url='foo/bar.txt', other='foo', expected='bar.txt'),
            dict(url='foo', other='foo/bar', expected='..'),
            dict(url='foo', other='foo/bar.txt', expected='.'),
            dict(url='foo/../../bar', other='.', expected='bar'),
            dict(url='foo/../../bar', other='foo', expected='../bar'),
            dict(url='foo//./bar/baz', other='foo/bar/baz', expected='.'),
            dict(url='a/b/.././../c', other='.', expected='c'),
            dict(url='a/b/c/d/ee', other='a/b/c/d/e', expected='../ee'),
            dict(url='a/b/c/d/ee', other='a/b/z/d/e', expected='../../../c/d/ee'),
            dict(url='foo', other='bar.', expected='foo'),
            dict(url='foo', other='bar./', expected='../foo'),
            dict(url='foo', other='foo/bar./', expected='..'),
            dict(url='foo', other='foo/bar./.', expected='..'),
            dict(url='foo', other='foo/bar././', expected='..'),
            dict(url='foo/', other='foo/bar././', expected='../'),
            dict(url='foo', other='foo', expected='.'),
            dict(url='.foo', other='.foo', expected='.foo'),
            dict(url='.foo/', other='.foo', expected='.foo/'),
            dict(url='.foo', other='.foo/', expected='.'),
            dict(url='.foo/', other='.foo/', expected='./'),
            dict(url='///', other='', expected='./'),
            dict(url='a///', other='', expected='a/'),
            dict(url='a///', other='a', expected='./'),
            dict(url='.', other='here', expected='..'),
            dict(url='..', other='here', expected='..'),
            dict(url='../..', other='here', expected='..'),
            dict(url='../../a', other='here', expected='../a'),
            dict(url='..', other='here.txt', expected='.'),
            dict(url='a', other='', expected='a'),
            dict(url='a', other='..', expected='a'),
            dict(url='a', other='b', expected='../a'),
            # The dots are considered a file. Documenting a long-standing bug:
            dict(url='a', other='b/..', expected='../a'),
            dict(url='a', other='b/../..', expected='a'),
            dict(url='a/..../b', other='a/../b', expected='../a/..../b'),
            dict(url='a/я/b', other='a/я/c', expected='../b'),
            dict(url='a/я/b', other='a/яя/c', expected='../../я/b'),
        ]:
            url, other, expected = case['url'], case['other'], case['expected']
            with self.subTest(url=url, other=other):
                # Leading slash intentionally ignored
                self.assertEqual(utils.get_relative_url(url, other), expected)
                self.assertEqual(utils.get_relative_url('/' + url, other), expected)
                self.assertEqual(utils.get_relative_url(url, '/' + other), expected)
                self.assertEqual(utils.get_relative_url('/' + url, '/' + other), expected)

    def test_get_relative_url_empty(self):
        for url in ['', '.', '/.']:
            for other in ['', '.', '/', '/.']:
                with self.subTest(url=url, other=other):
                    self.assertEqual(utils.get_relative_url(url, other), '.')

        self.assertEqual(utils.get_relative_url('/', ''), './')
        self.assertEqual(utils.get_relative_url('/', '/'), './')
        self.assertEqual(utils.get_relative_url('/', '.'), './')
        self.assertEqual(utils.get_relative_url('/', '/.'), './')

    def test_normalize_url(self):
        def test(path, base, expected):
            self.assertEqual(utils.normalize_url(path, _Page(base)), expected)

        # Absolute paths and anchors are unaffected.
        for base in 'about.html', 'foo/bar.html', 'index.html', '', 'about/', 'foo/bar/':
            test('https://media.cdn.org/jq.js', base, 'https://media.cdn.org/jq.js')
            test('http://media.cdn.org/jquery.js', base, 'http://media.cdn.org/jquery.js')
            test('//media.cdn.org/jquery.js', base, '//media.cdn.org/jquery.js')
            test('#some_id', base, '#some_id')

        path = 'media.cdn.org/jquery.js'
        test(path, '', 'media.cdn.org/jquery.js')
        test(path, 'index.html', 'media.cdn.org/jquery.js')
        test(path, 'about.html', 'media.cdn.org/jquery.js')
        test(path, 'about/', '../media.cdn.org/jquery.js')
        test(path, 'foo/bar.html', '../media.cdn.org/jquery.js')
        test(path, 'foo/bar/', '../../media.cdn.org/jquery.js')

        path = 'local/file/jquery.js'
        test(path, '', 'local/file/jquery.js')
        test(path, 'index.html', 'local/file/jquery.js')
        test(path, 'about.html', 'local/file/jquery.js')
        test(path, 'about/', '../local/file/jquery.js')
        test(path, 'foo/bar.html', '../local/file/jquery.js')
        test(path, 'foo/bar/', '../../local/file/jquery.js')

        path = '../../../../above/jquery.js'
        test(path, '', '../../../../above/jquery.js')
        test(path, 'index.html', '../../../../above/jquery.js')
        test(path, 'about.html', '../../../../above/jquery.js')
        test(path, 'about/', '../../../../../above/jquery.js')
        test(path, 'foo/bar.html', '../../../../../above/jquery.js')
        test(path, 'foo/bar/', '../../../../../../above/jquery.js')

        path = '../some/dir/'
        test(path, '', '../some/dir/')
        test(path, 'index.html', '../some/dir/')
        test(path, 'about.html', '../some/dir/')
        test(path, 'about/', '../../some/dir/')
        test(path, 'foo/bar.html', '../../some/dir/')
        test(path, 'foo/bar/', '../../../some/dir/')

        path = 'image.png'
        test(path, '', 'image.png')
        test(path, 'index.html', 'image.png')
        test(path, 'about.html', 'image.png')
        test(path, 'about/', '../image.png')
        test(path, 'foo/bar.html', '../image.png')
        test(path, 'foo/bar/', '../../image.png')

        path = 'style.css?v=20180308c'
        test(path, '', 'style.css?v=20180308c')
        test(path, 'index.html', 'style.css?v=20180308c')
        test(path, 'about.html', 'style.css?v=20180308c')
        test(path, 'about/', '../style.css?v=20180308c')
        test(path, 'foo/bar.html', '../style.css?v=20180308c')
        test(path, 'foo/bar/', '../../style.css?v=20180308c')

    # TODO: This shouldn't pass on Linux
    # @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
    def test_normalize_url_windows(self):
        def test(path, base, expected):
            self.assertEqual(utils.normalize_url(path, _Page(base)), expected)

        with self.assertLogs('mkdocs', level='WARNING'):
            path = 'local\\windows\\file\\jquery.js'
            test(path, '', 'local/windows/file/jquery.js')
            test(path, 'about/', '../local/windows/file/jquery.js')
            test(path, 'foo/bar/', '../../local/windows/file/jquery.js')

    def test_reduce_list(self):
        self.assertEqual(
            utils.reduce_list([1, 2, 3, 4, 5, 5, 2, 4, 6, 7, 8]),
            [1, 2, 3, 4, 5, 6, 7, 8],
        )

    def test_insort(self):
        a = [1, 2, 3]
        utils.insort(a, 5)
        self.assertEqual(a, [1, 2, 3, 5])
        utils.insort(a, -1)
        self.assertEqual(a, [-1, 1, 2, 3, 5])
        utils.insort(a, 2)
        self.assertEqual(a, [-1, 1, 2, 2, 3, 5])
        utils.insort(a, 4)
        self.assertEqual(a, [-1, 1, 2, 2, 3, 4, 5])

    def test_insort_key(self):
        a = [(1, 'a'), (1, 'b'), (2, 'c')]
        utils.insort(a, (1, 'a'), key=lambda v: v[0])
        self.assertEqual(a, [(1, 'a'), (1, 'b'), (1, 'a'), (2, 'c')])

    def test_nest_paths(self, j=posixpath.join):
        result = utils.nest_paths(
            [
                'index.md',
                j('user-guide', 'configuration.md'),
                j('user-guide', 'styling-your-docs.md'),
                j('user-guide', 'writing-your-docs.md'),
                j('about', 'contributing.md'),
                j('about', 'license.md'),
                j('about', 'release-notes.md'),
            ]
        )

        self.assertEqual(
            result,
            [
                'index.md',
                {
                    'User guide': [
                        j('user-guide', 'configuration.md'),
                        j('user-guide', 'styling-your-docs.md'),
                        j('user-guide', 'writing-your-docs.md'),
                    ]
                },
                {
                    'About': [
                        j('about', 'contributing.md'),
                        j('about', 'license.md'),
                        j('about', 'release-notes.md'),
                    ]
                },
            ],
        )

    def test_nest_paths_native(self):
        self.test_nest_paths(os.path.join)

    def test_unicode_yaml(self):
        yaml_src = dedent(
            '''
            key: value
            key2:
              - value
            '''
        )

        config = utils.yaml_load(yaml_src)
        self.assertTrue(isinstance(config['key'], str))
        self.assertTrue(isinstance(config['key2'][0], str))

    @mock.patch.dict(os.environ, {'VARNAME': 'Hello, World!', 'BOOLVAR': 'false'})
    def test_env_var_in_yaml(self):
        yaml_src = dedent(
            '''
            key1: !ENV VARNAME
            key2: !ENV UNDEFINED
            key3: !ENV [UNDEFINED, default]
            key4: !ENV [UNDEFINED, VARNAME, default]
            key5: !ENV BOOLVAR
            '''
        )
        config = utils.yaml_load(yaml_src)
        self.assertEqual(config['key1'], 'Hello, World!')
        self.assertIsNone(config['key2'])
        self.assertEqual(config['key3'], 'default')
        self.assertEqual(config['key4'], 'Hello, World!')
        self.assertIs(config['key5'], False)

    @tempdir(files={'base.yml': BASEYML, 'parent.yml': PARENTYML})
    def test_yaml_inheritance(self, tdir):
        expected = {
            'foo': 'bar',
            'baz': {
                'sub1': 'replaced',
                'sub2': 2,
                'sub3': 'new',
            },
            'deep1': {
                'deep2-1': {
                    'deep3-1': 'replaced',
                    'deep3-2': 'bar',
                },
                'deep2-2': 'baz',
            },
        }
        with open(os.path.join(tdir, 'base.yml')) as fd:
            result = utils.yaml_load(fd)
        self.assertEqual(result, expected)

    @tempdir(files={'base.yml': BASEYML})
    def test_yaml_inheritance_missing_parent(self, tdir):
        with open(os.path.join(tdir, 'base.yml')) as fd:
            with self.assertRaises(exceptions.ConfigurationError):
                utils.yaml_load(fd)

    @tempdir()
    @tempdir()
    def test_copy_files(self, src_dir, dst_dir):
        cases = [
            dict(
                src_path='foo.txt',
                dst_path='foo.txt',
                expected='foo.txt',
            ),
            dict(
                src_path='bar.txt',
                dst_path='foo/',  # ensure src filename is appended
                expected='foo/bar.txt',
            ),
            dict(
                src_path='baz.txt',
                dst_path='foo/bar/baz.txt',  # ensure missing dirs are created
                expected='foo/bar/baz.txt',
            ),
        ]

        for case in cases:
            src, dst, expected = case['src_path'], case['dst_path'], case['expected']
            with self.subTest(src):
                src = os.path.join(src_dir, src)
                with open(src, 'w') as f:
                    f.write('content')
                dst = os.path.join(dst_dir, dst)
                utils.copy_file(src, dst)
                self.assertTrue(os.path.isfile(os.path.join(dst_dir, expected)))

    @tempdir()
    @tempdir()
    def test_copy_files_without_permissions(self, src_dir, dst_dir):
        cases = [
            dict(src_path='foo.txt', expected='foo.txt'),
            dict(src_path='bar.txt', expected='bar.txt'),
            dict(src_path='baz.txt', expected='baz.txt'),
        ]

        try:
            for case in cases:
                src, expected = case['src_path'], case['expected']
                with self.subTest(src):
                    src = os.path.join(src_dir, src)
                    with open(src, 'w') as f:
                        f.write('content')
                    # Set src file to read-only
                    os.chmod(src, stat.S_IRUSR)
                    utils.copy_file(src, dst_dir)
                    self.assertTrue(os.path.isfile(os.path.join(dst_dir, expected)))
                    self.assertNotEqual(
                        os.stat(src).st_mode, os.stat(os.path.join(dst_dir, expected)).st_mode
                    )
                    # While src was read-only, dst must remain writable
                    self.assertTrue(os.access(os.path.join(dst_dir, expected), os.W_OK))
        finally:
            for case in cases:
                # Undo read-only so we can delete temp files
                src = os.path.join(src_dir, case['src_path'])
                if os.path.exists(src):
                    os.chmod(src, stat.S_IRUSR | stat.S_IWUSR)

    def test_mm_meta_data(self):
        doc = dedent(
            """
            Title: Foo Bar
            Date: 2018-07-10
            Summary: Line one
                Line two
            Tags: foo
            Tags: bar

            Doc body
            """
        )
        self.assertEqual(
            meta.get_data(doc),
            (
                "Doc body",
                {
                    'title': 'Foo Bar',
                    'date': '2018-07-10',
                    'summary': 'Line one Line two',
                    'tags': 'foo bar',
                },
            ),
        )

    def test_mm_meta_data_blank_first_line(self):
        doc = '\nfoo: bar\nDoc body'
        self.assertEqual(meta.get_data(doc), (doc.lstrip(), {}))

    def test_yaml_meta_data(self):
        doc = dedent(
            """
            ---
            Title: Foo Bar
            Date: 2018-07-10
            Summary: Line one
                Line two
            Tags:
                - foo
                - bar
            ---
            Doc body
            """
        )
        self.assertEqual(
            meta.get_data(doc),
            (
                "Doc body",
                {
                    'Title': 'Foo Bar',
                    'Date': datetime.date(2018, 7, 10),
                    'Summary': 'Line one Line two',
                    'Tags': ['foo', 'bar'],
                },
            ),
        )

    def test_yaml_meta_data_not_dict(self):
        doc = dedent(
            """
            ---
            - List item
            ---
            Doc body
            """
        )
        self.assertEqual(meta.get_data(doc), (doc, {}))

    def test_yaml_meta_data_invalid(self):
        doc = dedent(
            """
            ---
            foo: bar: baz
            ---
            Doc body
            """
        )
        self.assertEqual(meta.get_data(doc), (doc, {}))

    def test_no_meta_data(self):
        doc = dedent(
            """
            Doc body
            """
        )
        self.assertEqual(meta.get_data(doc), (doc, {}))


class ThemeUtilsTests(unittest.TestCase):
    def setUp(self):
        utils.get_themes.cache_clear()

    def test_get_themes(self):
        themes = utils.get_theme_names()
        self.assertIn('mkdocs', themes)
        self.assertIn('readthedocs', themes)

    @mock.patch('mkdocs.utils.entry_points', autospec=True)
    def test_get_theme_dir(self, mock_iter):
        path = 'some/path'

        theme = mock.Mock()
        theme.name = 'mkdocs2'
        theme.dist.name = 'mkdocs2'
        theme.load().__file__ = os.path.join(path, '__init__.py')

        mock_iter.return_value = [theme]

        self.assertEqual(utils.get_theme_dir(theme.name), os.path.abspath(path))

    def test_get_theme_dir_error(self):
        with self.assertRaises(KeyError):
            utils.get_theme_dir('nonexistanttheme')

    @mock.patch('mkdocs.utils.entry_points', autospec=True)
    def test_get_theme_dir_importerror(self, mock_iter):
        theme = mock.Mock()
        theme.name = 'mkdocs2'
        theme.dist.name = 'mkdocs2'
        theme.load.side_effect = ImportError()

        mock_iter.return_value = [theme]

        with self.assertRaises(ImportError):
            utils.get_theme_dir(theme.name)

    @mock.patch('mkdocs.utils.entry_points', autospec=True)
    def test_get_themes_warning(self, mock_iter):
        theme1 = mock.Mock()
        theme1.name = 'mkdocs2'
        theme1.dist.name = 'mkdocs2'
        theme1.load().__file__ = "some/path1"

        theme2 = mock.Mock()
        theme2.name = 'mkdocs2'
        theme2.dist.name = 'mkdocs3'
        theme2.load().__file__ = "some/path2"

        mock_iter.return_value = [theme1, theme2]

        with self.assertLogs('mkdocs') as cm:
            theme_names = utils.get_theme_names()
        self.assertEqual(
            '\n'.join(cm.output),
            "WARNING:mkdocs.utils:A theme named 'mkdocs2' is provided by the Python "
            "packages 'mkdocs3' and 'mkdocs2'. The one in 'mkdocs3' will be used.",
        )
        self.assertCountEqual(theme_names, ['mkdocs2'])

    @mock.patch('mkdocs.utils.entry_points', autospec=True)
    def test_get_themes_error(self, mock_iter):
        theme1 = mock.Mock()
        theme1.name = 'mkdocs'
        theme1.dist.name = 'mkdocs'
        theme1.load().__file__ = "some/path1"

        theme2 = mock.Mock()
        theme2.name = 'mkdocs'
        theme2.dist.name = 'mkdocs2'
        theme2.load().__file__ = "some/path2"

        mock_iter.return_value = [theme1, theme2]

        with self.assertRaisesRegex(
            exceptions.ConfigurationError,
            "The theme 'mkdocs' is a builtin theme but the package 'mkdocs2' "
            "attempts to provide a theme with the same name.",
        ):
            utils.get_theme_names()


class LogCounterTests(unittest.TestCase):
    def setUp(self):
        self.log = logging.getLogger('dummy')
        self.log.propagate = False
        self.log.setLevel(1)
        self.counter = utils.CountHandler()
        self.log.addHandler(self.counter)

    def tearDown(self):
        self.log.removeHandler(self.counter)

    def test_default_values(self):
        self.assertEqual(self.counter.get_counts(), [])

    def test_count_critical(self):
        self.assertEqual(self.counter.get_counts(), [])
        self.log.critical('msg')
        self.assertEqual(self.counter.get_counts(), [('CRITICAL', 1)])

    def test_count_error(self):
        self.assertEqual(self.counter.get_counts(), [])
        self.log.error('msg')
        self.assertEqual(self.counter.get_counts(), [('ERROR', 1)])

    def test_count_warning(self):
        self.assertEqual(self.counter.get_counts(), [])
        self.log.warning('msg')
        self.assertEqual(self.counter.get_counts(), [('WARNING', 1)])

    def test_count_info(self):
        self.assertEqual(self.counter.get_counts(), [])
        self.log.info('msg')
        self.assertEqual(self.counter.get_counts(), [('INFO', 1)])

    def test_count_debug(self):
        self.assertEqual(self.counter.get_counts(), [])
        self.log.debug('msg')
        self.assertEqual(self.counter.get_counts(), [('DEBUG', 1)])

    def test_count_multiple(self):
        self.assertEqual(self.counter.get_counts(), [])
        self.log.warning('msg 1')
        self.assertEqual(self.counter.get_counts(), [('WARNING', 1)])
        self.log.warning('msg 2')
        self.assertEqual(self.counter.get_counts(), [('WARNING', 2)])
        self.log.debug('msg 3')
        self.assertEqual(self.counter.get_counts(), [('WARNING', 2), ('DEBUG', 1)])
        self.log.error('mdg 4')
        self.assertEqual(self.counter.get_counts(), [('ERROR', 1), ('WARNING', 2), ('DEBUG', 1)])

    def test_log_level(self):
        self.assertEqual(self.counter.get_counts(), [])
        self.counter.setLevel(logging.ERROR)
        self.log.error('counted')
        self.log.warning('not counted')
        self.log.info('not counted')
        self.assertEqual(self.counter.get_counts(), [('ERROR', 1)])
        self.counter.setLevel(logging.WARNING)
        self.log.error('counted')
        self.log.warning('counted')
        self.log.info('not counted')
        self.assertEqual(self.counter.get_counts(), [('ERROR', 2), ('WARNING', 1)])


@dataclasses.dataclass
class _Page:
    url: str
