import textwrap

import pytest

from falcon import testing
from falcon.routing import DefaultRouter

from _util import create_app  # NOQA


def client(asgi):
    return testing.TestClient(create_app(asgi))


@pytest.fixture
def router():
    router = DefaultRouter()

    router.add_route('/repos', ResourceWithId(1))
    router.add_route('/repos/{org}', ResourceWithId(2))
    router.add_route('/repos/{org}/{repo}', ResourceWithId(3))
    router.add_route('/repos/{org}/{repo}/commits', ResourceWithId(4))
    router.add_route(
        '/repos/{org}/{repo}/compare/{usr0}:{branch0}...{usr1}:{branch1}',
        ResourceWithId(5),
    )

    router.add_route('/teams/{id}', ResourceWithId(6))
    router.add_route('/teams/{id}/members', ResourceWithId(7))

    router.add_route('/teams/default', ResourceWithId(19))
    router.add_route('/teams/default/members/thing', ResourceWithId(19))

    router.add_route('/user/memberships', ResourceWithId(8))
    router.add_route('/emojis', ResourceWithId(9))
    router.add_route(
        '/repos/{org}/{repo}/compare/{usr0}:{branch0}...{usr1}:{branch1}/full',
        ResourceWithId(10),
    )
    router.add_route('/repos/{org}/{repo}/compare/all', ResourceWithId(11))

    # NOTE(kgriffs): The ordering of these calls is significant; we
    # need to test that the {id} field does not match the other routes,
    # regardless of the order they are added.
    router.add_route('/emojis/signs/0', ResourceWithId(12))
    router.add_route('/emojis/signs/{id}', ResourceWithId(13))
    router.add_route('/emojis/signs/42', ResourceWithId(14))
    router.add_route('/emojis/signs/42/small.jpg', ResourceWithId(23))
    router.add_route('/emojis/signs/78/small.png', ResourceWithId(24))

    # Test some more special chars
    router.add_route('/emojis/signs/78/small(png)', ResourceWithId(25))
    router.add_route('/emojis/signs/78/small_png', ResourceWithId(26))
    router.add_route('/images/{id}.gif', ResourceWithId(27))

    router.add_route(
        '/repos/{org}/{repo}/compare/{usr0}:{branch0}...{usr1}:{branch1}/part',
        ResourceWithId(15),
    )
    router.add_route('/repos/{org}/{repo}/compare/{usr0}:{branch0}', ResourceWithId(16))
    router.add_route(
        '/repos/{org}/{repo}/compare/{usr0}:{branch0}/full', ResourceWithId(17)
    )

    router.add_route('/gists/{id}/{representation}', ResourceWithId(21))
    router.add_route('/gists/{id}/raw', ResourceWithId(18))
    router.add_route('/gists/first', ResourceWithId(20))

    router.add_route('/item/{q}', ResourceWithId(28))

    # ----------------------------------------------------------------
    # Routes with field converters
    # ----------------------------------------------------------------

    router.add_route('/cvt/teams/{id:int(min=7)}', ResourceWithId(29))
    router.add_route('/cvt/teams/{id:int(min=7)}/members', ResourceWithId(30))
    router.add_route('/cvt/teams/default', ResourceWithId(31))
    router.add_route(
        '/cvt/teams/default/members/{id:int}-{tenure:int}', ResourceWithId(32)
    )

    router.add_route(
        '/cvt/repos/{org}/{repo}/compare/{usr0}:{branch0:int}'
        '...{usr1}:{branch1:int}/part',
        ResourceWithId(33),
    )
    router.add_route(
        '/cvt/repos/{org}/{repo}/compare/{usr0}:{branch0:int}', ResourceWithId(34)
    )
    router.add_route(
        '/cvt/repos/{org}/{repo}/compare/{usr0}:{branch0:int}/full', ResourceWithId(35)
    )

    return router


class ResourceWithId:
    def __init__(self, resource_id):
        self.resource_id = resource_id

    def __repr__(self):
        return 'ResourceWithId({})'.format(self.resource_id)

    def on_get(self, req, resp):
        resp.text = self.resource_id


class SpamConverter:
    def __init__(self, times, eggs=False):
        self._times = times
        self._eggs = eggs

    def convert(self, fragment):
        item = fragment
        if self._eggs:
            item += '&eggs'

        return ', '.join(item for i in range(self._times))


# =====================================================================
# Regression tests for use cases reported by users
# =====================================================================


def test_user_regression_versioned_url():
    router = DefaultRouter()
    router.add_route('/{version}/messages', ResourceWithId(2))

    resource, __, __, __ = router.find('/v2/messages')
    assert resource.resource_id == 2

    router.add_route('/v2', ResourceWithId(1))

    resource, __, __, __ = router.find('/v2')
    assert resource.resource_id == 1

    resource, __, __, __ = router.find('/v2/messages')
    assert resource.resource_id == 2

    resource, __, __, __ = router.find('/v1/messages')
    assert resource.resource_id == 2

    route = router.find('/v1')
    assert route is None


def test_user_regression_recipes():
    router = DefaultRouter()
    router.add_route('/recipes/{activity}/{type_id}', ResourceWithId(1))
    router.add_route('/recipes/baking', ResourceWithId(2))

    resource, __, __, __ = router.find('/recipes/baking/4242')
    assert resource.resource_id == 1

    resource, __, __, __ = router.find('/recipes/baking')
    assert resource.resource_id == 2

    route = router.find('/recipes/grilling')
    assert route is None


@pytest.mark.parametrize(
    'uri_template,path,expected_params',
    [
        (
            '/serviceRoot/People|{field}',
            '/serviceRoot/People|susie',
            {'field': 'susie'},
        ),
        (
            '/serviceRoot/People[{field}]',
            "/serviceRoot/People['calvin']",
            {'field': "'calvin'"},
        ),
        (
            '/serviceRoot/People({field})',
            "/serviceRoot/People('hobbes')",
            {'field': "'hobbes'"},
        ),
        (
            '/serviceRoot/People({field})',
            "/serviceRoot/People('hob)bes')",
            {'field': "'hob)bes'"},
        ),
        (
            '/serviceRoot/People({field})(z)',
            '/serviceRoot/People(hobbes)(z)',
            {'field': 'hobbes'},
        ),
        (
            "/serviceRoot/People('{field}')",
            "/serviceRoot/People('rosalyn')",
            {'field': 'rosalyn'},
        ),
        ('/^{field}', '/^42', {'field': '42'}),
        ('/+{field}', '/+42', {'field': '42'}),
        (
            '/foo/{first}_{second}/bar',
            '/foo/abc_def_ghijk/bar',
            # NOTE(kgriffs): The regex pattern is greedy, so this is
            # expected. We can not change this behavior in a minor
            # release, since it would be a breaking change. If there
            # is enough demand for it, we could introduce an option
            # to toggle this behavior.
            {'first': 'abc_def', 'second': 'ghijk'},
        ),
        # NOTE(kgriffs): Why someone would use a question mark like this
        # I have no idea (esp. since it would have to be encoded to avoid
        # being mistaken for the query string separator). Including it only
        # for completeness.
        ('/items/{x}?{y}', '/items/1080?768', {'x': '1080', 'y': '768'}),
        ('/items/{x}|{y}', '/items/1080|768', {'x': '1080', 'y': '768'}),
        ('/items/{x},{y}', '/items/1080,768', {'x': '1080', 'y': '768'}),
        ('/items/{x}^^{y}', '/items/1080^^768', {'x': '1080', 'y': '768'}),
        ('/items/{x}*{y}*', '/items/1080*768*', {'x': '1080', 'y': '768'}),
        ('/thing-2/something+{field}+', '/thing-2/something+42+', {'field': '42'}),
        (
            '/thing-2/something*{field}/notes',
            '/thing-2/something*42/notes',
            {'field': '42'},
        ),
        (
            '/thing-2/something+{field}|{q}/notes',
            '/thing-2/something+else|z/notes',
            {'field': 'else', 'q': 'z'},
        ),
        (
            "serviceRoot/$metadata#Airports('{field}')/Name",
            "serviceRoot/$metadata#Airports('KSFO')/Name",
            {'field': 'KSFO'},
        ),
    ],
)
def test_user_regression_special_chars(uri_template, path, expected_params):
    router = DefaultRouter()

    router.add_route(uri_template, ResourceWithId(1))

    route = router.find(path)
    assert route is not None

    resource, __, params, __ = route
    assert resource.resource_id == 1
    assert params == expected_params


# =====================================================================
# Other tests
# =====================================================================


@pytest.mark.parametrize('asgi', [True, False])
@pytest.mark.parametrize('uri_template', [{}, set(), object()])
def test_not_str(asgi, uri_template):
    app = create_app(asgi)
    with pytest.raises(TypeError):
        app.add_route(uri_template, ResourceWithId(-1))


def test_root_path():
    router = DefaultRouter()
    router.add_route('/', ResourceWithId(42))

    resource, __, __, __ = router.find('/')
    assert resource.resource_id == 42

    expected_src = textwrap.dedent(
        """
        def find(path, return_values, patterns, converters, params):
            path_len = len(path)
            if path_len > 0:
                if path[0] == '':
                    if path_len == 1:
                        return return_values[0]
                    return None
                return None
            return None
    """
    ).strip()

    assert router.finder_src == expected_src


@pytest.mark.parametrize(
    'uri_template',
    [
        '/{field}{field}',
        '/{field}...{field}',
        '/{field}/{another}/{field}',
        '/{field}/something/something/{field}/something',
    ],
)
def test_duplicate_field_names(uri_template):
    router = DefaultRouter()
    with pytest.raises(ValueError):
        router.add_route(uri_template, ResourceWithId(1))


@pytest.mark.parametrize(
    'uri_template,path',
    [
        ('/items/thing', '/items/t'),
        ('/items/{x}|{y}|', '/items/1080|768'),
        ('/items/{x}*{y}foo', '/items/1080*768foobar'),
        ('/items/{x}*768*', '/items/1080*768***'),
    ],
)
def test_match_entire_path(uri_template, path):
    router = DefaultRouter()

    router.add_route(uri_template, ResourceWithId(1))

    route = router.find(path)
    assert route is None


@pytest.mark.parametrize(
    'uri_template',
    [
        '/teams/{conflict}',  # simple vs simple
        '/emojis/signs/{id_too}',  # another simple vs simple
        '/repos/{org}/{repo}/compare/{complex}:{vs}...{complex2}:{conflict}',
        '/teams/{id:int}/settings',  # converted vs. non-converted
    ],
)
def test_conflict(router, uri_template):
    with pytest.raises(ValueError):
        router.add_route(uri_template, ResourceWithId(-1))


@pytest.mark.parametrize(
    'uri_template',
    [
        '/repos/{org}/{repo}/compare/{simple_vs_complex}',
        '/repos/{complex}.{vs}.{simple}',
        '/repos/{org}/{repo}/compare/{complex}:{vs}...{complex2}/full',
    ],
)
def test_non_conflict(router, uri_template):
    router.add_route(uri_template, ResourceWithId(-1))


@pytest.mark.parametrize(
    'uri_template',
    [
        # Missing field name
        '/{}',
        '/repos/{org}/{repo}/compare/{}',
        '/repos/{complex}.{}.{thing}',
        # Field names must be valid Python identifiers
        '/{9v}',
        '/{524hello}/world',
        '/hello/{1world}',
        '/repos/{complex}.{9v}.{thing}/etc',
        '/{*kgriffs}',
        '/{@kgriffs}',
        '/repos/{complex}.{v}.{@thing}/etc',
        '/{-kgriffs}',
        '/repos/{complex}.{-v}.{thing}/etc',
        '/repos/{simple-thing}/etc',
        # Neither fields nor literal segments may not contain whitespace
        '/this and that',
        '/this\tand\tthat/this\nand\nthat/{thing }/world',
        '/{thing\t}/world',
        '/{\nthing}/world',
        '/{th\ving}/world',
        '/{ thing}/world',
        '/{ thing }/world',
        '/{thing}/wo rld',
        '/{thing} /world',
        '/repos/{or g}/{repo}/compare/{thing}',
        '/repos/{org}/{repo}/compare/{th\ting}',
    ],
)
def test_invalid_field_name(router, uri_template):
    with pytest.raises(ValueError):
        router.add_route(uri_template, ResourceWithId(-1))


def test_print_src(router):
    """Diagnostic test that simply prints the router's find() source code.

    Example:

        $ tox -e py3_debug -- -k test_print_src -s
    """
    print('\n\n' + router.finder_src + '\n')


def test_override(router):
    router.add_route('/emojis/signs/0', ResourceWithId(-1))

    resource, __, __, __ = router.find('/emojis/signs/0')
    assert resource.resource_id == -1


def test_literal_segment(router):
    resource, __, __, __ = router.find('/emojis/signs/0')
    assert resource.resource_id == 12

    resource, __, __, __ = router.find('/emojis/signs/1')
    assert resource.resource_id == 13

    resource, __, __, __ = router.find('/emojis/signs/42')
    assert resource.resource_id == 14

    resource, __, __, __ = router.find('/emojis/signs/42/small.jpg')
    assert resource.resource_id == 23

    route = router.find('/emojis/signs/1/small')
    assert route is None


@pytest.mark.parametrize(
    'path',
    [
        '/teams',
        '/emojis/signs',
        '/gists',
        '/gists/42',
    ],
)
def test_dead_segment(router, path):
    route = router.find(path)
    assert route is None


@pytest.mark.parametrize(
    'path',
    [
        '/repos/racker/falcon/compare/foo',
        '/repos/racker/falcon/compare/foo/full',
    ],
)
def test_malformed_pattern(router, path):
    route = router.find(path)
    assert route is None


def test_literal(router):
    resource, __, __, __ = router.find('/user/memberships')
    assert resource.resource_id == 8


@pytest.mark.parametrize(
    'path,expected_params',
    [
        ('/cvt/teams/007', {'id': 7}),
        ('/cvt/teams/1234/members', {'id': 1234}),
        ('/cvt/teams/default/members/700-5', {'id': 700, 'tenure': 5}),
        (
            '/cvt/repos/org/repo/compare/xkcd:353',
            {'org': 'org', 'repo': 'repo', 'usr0': 'xkcd', 'branch0': 353},
        ),
        (
            '/cvt/repos/org/repo/compare/gunmachan:1234...kumamon:5678/part',
            {
                'org': 'org',
                'repo': 'repo',
                'usr0': 'gunmachan',
                'branch0': 1234,
                'usr1': 'kumamon',
                'branch1': 5678,
            },
        ),
        (
            '/cvt/repos/xkcd/353/compare/susan:0001/full',
            {'org': 'xkcd', 'repo': '353', 'usr0': 'susan', 'branch0': 1},
        ),
    ],
)
def test_converters(router, path, expected_params):
    __, __, params, __ = router.find(path)
    assert params == expected_params


@pytest.mark.parametrize(
    'uri_template',
    [
        '/foo/{bar:int(0)}',
        '/foo/{bar:int(num_digits=0)}',
        '/foo/{bar:int(-1)}/baz',
        '/foo/{bar:int(num_digits=-1)}/baz',
    ],
)
def test_converters_with_invalid_options(router, uri_template):
    # NOTE(kgriffs): Sanity-check that errors are properly bubbled up
    # when calling add_route(). Additional checks can be found
    # in test_uri_converters.py
    with pytest.raises(ValueError, match='Cannot instantiate converter') as e:
        router.add_route(uri_template, ResourceWithId(1))

    assert e.value.__cause__ is not None


@pytest.mark.parametrize(
    'uri_template',
    [
        '/foo/{bar:}',
        '/foo/{bar:unknown}/baz',
    ],
)
def test_converters_malformed_specification(router, uri_template):
    with pytest.raises(ValueError):
        router.add_route(uri_template, ResourceWithId(1))


def test_variable(router):
    resource, __, params, __ = router.find('/teams/42')
    assert resource.resource_id == 6
    assert params == {'id': '42'}

    __, __, params, __ = router.find('/emojis/signs/stop')
    assert params == {'id': 'stop'}

    __, __, params, __ = router.find('/gists/42/raw')
    assert params == {'id': '42'}

    __, __, params, __ = router.find('/images/42.gif')
    assert params == {'id': '42'}


def test_single_character_field_name(router):
    __, __, params, __ = router.find('/item/1234')
    assert params == {'q': '1234'}


@pytest.mark.parametrize(
    'path,expected_id',
    [
        ('/teams/default', 19),
        ('/teams/default/members', 7),
        ('/cvt/teams/default', 31),
        ('/cvt/teams/default/members/1234-10', 32),
        ('/teams/1234', 6),
        ('/teams/1234/members', 7),
        ('/gists/first', 20),
        ('/gists/first/raw', 18),
        ('/gists/first/pdf', 21),
        ('/gists/1776/pdf', 21),
        ('/emojis/signs/78', 13),
        ('/emojis/signs/78/small.png', 24),
        ('/emojis/signs/78/small(png)', 25),
        ('/emojis/signs/78/small_png', 26),
    ],
)
def test_literal_vs_variable(router, path, expected_id):
    resource, __, __, __ = router.find(path)
    assert resource.resource_id == expected_id


@pytest.mark.parametrize(
    'path',
    [
        # Misc.
        '/this/does/not/exist',
        '/user/bogus',
        '/repos/racker/falcon/compare/johndoe:master...janedoe:dev/bogus',
        # Literal vs variable (teams)
        '/teams',
        '/teams/42/members/undefined',
        '/teams/42/undefined',
        '/teams/42/undefined/segments',
        '/teams/default/members/undefined',
        '/teams/default/members/thing/undefined',
        '/teams/default/members/thing/undefined/segments',
        '/teams/default/undefined',
        '/teams/default/undefined/segments',
        # Literal vs. variable (converters)
        '/cvt/teams/default/members',  # 'default' can't be converted to an int
        '/cvt/teams/NaN',
        '/cvt/teams/default/members/NaN',
        # Literal vs variable (emojis)
        '/emojis/signs',
        '/emojis/signs/0/small',
        '/emojis/signs/0/undefined',
        '/emojis/signs/0/undefined/segments',
        '/emojis/signs/20/small',
        '/emojis/signs/20/undefined',
        '/emojis/signs/42/undefined',
        '/emojis/signs/78/undefined',
    ],
)
def test_not_found(router, path):
    route = router.find(path)
    assert route is None


def test_subsegment_not_found(router):
    route = router.find('/emojis/signs/0/x')
    assert route is None


def test_multivar(router):
    resource, __, params, __ = router.find('/repos/racker/falcon/commits')
    assert resource.resource_id == 4
    assert params == {'org': 'racker', 'repo': 'falcon'}

    resource, __, params, __ = router.find('/repos/racker/falcon/compare/all')
    assert resource.resource_id == 11
    assert params == {'org': 'racker', 'repo': 'falcon'}


@pytest.mark.parametrize(
    'url_postfix,resource_id',
    [
        ('', 5),
        ('/full', 10),
        ('/part', 15),
    ],
)
def test_complex(router, url_postfix, resource_id):
    uri = '/repos/racker/falcon/compare/johndoe:master...janedoe:dev'
    resource, __, params, __ = router.find(uri + url_postfix)

    assert resource.resource_id == resource_id
    assert params == {
        'org': 'racker',
        'repo': 'falcon',
        'usr0': 'johndoe',
        'branch0': 'master',
        'usr1': 'janedoe',
        'branch1': 'dev',
    }


@pytest.mark.parametrize(
    'url_postfix,resource_id,expected_template',
    [
        ('', 16, '/repos/{org}/{repo}/compare/{usr0}:{branch0}'),
        ('/full', 17, '/repos/{org}/{repo}/compare/{usr0}:{branch0}/full'),
    ],
)
def test_complex_alt(router, url_postfix, resource_id, expected_template):
    uri = '/repos/falconry/falcon/compare/johndoe:master' + url_postfix
    resource, __, params, uri_template = router.find(uri)

    assert resource.resource_id == resource_id
    assert params == {
        'org': 'falconry',
        'repo': 'falcon',
        'usr0': 'johndoe',
        'branch0': 'master',
    }
    assert uri_template == expected_template


def test_options_converters_set(router):
    router.options.converters['spam'] = SpamConverter

    router.add_route('/{food:spam(3, eggs=True)}', ResourceWithId(1))
    resource, __, params, __ = router.find('/spam')

    assert params == {'food': 'spam&eggs, spam&eggs, spam&eggs'}


@pytest.mark.parametrize('converter_name', ['spam', 'spam_2'])
def test_options_converters_update(router, converter_name):
    router.options.converters.update(
        {
            'spam': SpamConverter,
            'spam_2': SpamConverter,
        }
    )

    template = '/{food:' + converter_name + '(3, eggs=True)}'
    router.add_route(template, ResourceWithId(1))
    resource, __, params, __ = router.find('/spam')

    assert params == {'food': 'spam&eggs, spam&eggs, spam&eggs'}


@pytest.mark.parametrize(
    'name',
    [
        'has whitespace',
        'whitespace ',
        ' whitespace ',
        ' whitespace',
        'funky$character',
        '42istheanswer',
        'with-hyphen',
    ],
)
def test_options_converters_invalid_name(router, name):
    with pytest.raises(ValueError):
        router.options.converters[name] = object


def test_options_converters_invalid_name_on_update(router):
    with pytest.raises(ValueError):
        router.options.converters.update(
            {
                'valid_name': SpamConverter,
                '7eleven': SpamConverter,
            }
        )


@pytest.fixture
def param_router():
    r = DefaultRouter()

    r.add_route('/c/foo/{bar}/baz', ResourceWithId(1))
    r.add_route('/c/{foo}/bar/other', ResourceWithId(2))
    r.add_route('/c/foo/{a:int}-{b}/a', ResourceWithId(3))
    r.add_route('/upload/{service}/auth/token', ResourceWithId(4))
    r.add_route('/upload/youtube/{project_id}/share', ResourceWithId(5))
    r.add_route('/x/y/{a}.{b}/z', ResourceWithId(6))
    r.add_route('/x/{y}/o.o/w', ResourceWithId(7))
    return r


@pytest.mark.parametrize(
    'route, expected, num',
    (
        ('/c/foo/arg/baz', {'bar': 'arg'}, 1),
        ('/c/foo/bar/other', {'foo': 'foo'}, 2),
        ('/c/foo/42-7/baz', {'bar': '42-7'}, 1),
        ('/upload/youtube/auth/token', {'service': 'youtube'}, 4),
        ('/x/y/o.o/w', {'y': 'y'}, 7),
    ),
)
def test_params_in_non_taken_branches(param_router, route, expected, num):
    resource, __, params, __ = param_router.find(route)

    assert resource.resource_id == num
    assert params == expected
