# -*- coding: utf-8 -*-
#
# This file is part of Invenio.
# Copyright (C) 2015-2018 CERN.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.


"""Module tests."""

from __future__ import absolute_import, print_function

import json
from datetime import datetime

import pkg_resources
import pytest
from flask import Flask, abort, make_response, request
from flask.json import jsonify
from six.moves.urllib.parse import urlencode
from werkzeug.http import quote_etag, unquote_etag

from invenio_rest import ContentNegotiatedMethodView, InvenioREST
from invenio_rest.errors import RESTException


def test_version():
    """Test version import."""
    from invenio_rest import __version__
    assert __version__


def test_init():
    """Test extension initialization."""
    app = Flask('testapp')
    ext = InvenioREST(app)
    assert 'invenio-rest' in app.extensions

    app = Flask('testapp')
    ext = InvenioREST()
    assert 'invenio-rest' not in app.extensions
    ext.init_app(app)
    assert 'invenio-rest' in app.extensions


def test_error_handlers(app):
    """Error handlers view."""
    InvenioREST(app)

    @app.route("/<int:status_code>",
               methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD',
                        'TRACE', 'OPTIONS'])
    def test_view(status_code):
        abort(status_code)

    error_codes = [
        400, 401, 403, 404, 405, 406, 409, 410, 412, 422, 415, 429, 500, 501,
        502, 503, 504]

    with app.test_client() as client:
        verbs = [client.get, client.post, client.put, client.delete,
                 client.patch, client.trace, client.options, client.get]
        for s in error_codes:
            for verb in verbs:
                res = verb("/{0}".format(s))
                assert res.status_code == s
                if pkg_resources.get_distribution("werkzeug").\
                        version == '0.14.1' and s == 412:
                    # skip test because of the following issue:
                    # https://github.com/pallets/werkzeug/issues/1231
                    continue
                if verb != client.head:
                    data = json.loads(res.get_data(as_text=True))
                    assert data['status'] == s
                    assert data['message']


def test_custom_httpexception(app):
    """Test custom RESTException."""
    InvenioREST(app)

    class CustomBadRequest(RESTException):
        code = 400
        description = 'test'

    @app.route('/')
    def test_view():
        raise CustomBadRequest()

    with app.test_client() as client:
        res = client.get('/')
        assert res.status_code == 400
        data = json.loads(res.get_data(as_text=True))
        assert data['message'] == 'test'
        assert data['status'] == 400


def test_cors(app):
    """Test CORS support."""
    app.config['REST_ENABLE_CORS'] = True
    app.config['CORS_ALWAYS_SEND'] = False

    InvenioREST(app)

    @app.route('/')
    def cors_test():
        return 'test'

    with app.test_client() as client:
        res = client.get('/')
        assert not res.headers.get('Access-Control-Allow-Origin', False)
        assert not res.headers.get('Access-Control-Expose-Headers', False)
        res = client.get('/', headers=[('Origin', 'http://example.com')])
        assert res.headers['Access-Control-Allow-Origin'] == '*'
        assert len(res.headers['Access-Control-Expose-Headers'].split(',')) \
            == len(app.config['CORS_EXPOSE_HEADERS'])


def _obj_to_json_serializer(data, code=200, headers=None):
    if data:
        res = jsonify(data)
        res.cache_control.no_cache = True
        res.set_etag('abc')
    else:
        res = make_response()
    res.status_code = code
    return res


class _ObjectItem(ContentNegotiatedMethodView):
    view_name = 'object_item'

    def __init__(self, *args, **kwargs):
        """Constructor."""
        super(_ObjectItem, self).__init__(*args, **kwargs)
        self.modified = datetime.utcnow()

    def get(self, id, **kwargs):
        """GET a resource."""
        self.check_etag('abc', weak='weak_etags' in request.args)
        self.check_if_modified_since(self.modified)
        if id == 42:
            abort(404)
        # return {'id': id, 'method': 'GET'}, 200
        res = self.make_response({'id': id, 'method': 'GET'}, 200)
        res.last_modified = self.modified
        return res

    def put(self, id, **kwargs):
        """PUT a resource."""
        self.check_etag('abc', weak='weak_etags' in request.args)
        if id == 42:
            return self.make_response(None, code=404)
        return self.make_response({'id': id, 'method': 'PUT'}, 200)

    def patch(self, id, **kwargs):
        """PATCH a resource."""
        self.check_etag('abc', weak='weak_etags' in request.args)
        if id == 42:
            return None, 404
        return self.make_response({'id': id, 'method': 'PATCH'})

    def post(self, id, **kwargs):
        """POST a resource."""
        self.check_etag('abc', weak='weak_etags' in request.args)
        if id == 42:
            abort(404)
        return {'id': id, 'method': 'POST'}

    def delete(self, id, **kwargs):
        """DELETE a resource."""
        self.check_etag('abc', weak='weak_etags' in request.args)
        if id == 42:
            abort(404)
        return {'id': id, 'method': 'DELETE'}


def test_content_negotiation_method_view_global_serializers(app):
    """Test ContentNegotiationMethodView. Test default serializers."""
    _subtest_content_negotiation_method_view(app, _ObjectItem, params={
        'serializers': {
            'application/json': _obj_to_json_serializer,
        }
    })


def test_content_negotiation_method_view_method_serializers(app):
    """Test method serializers with no global serializers."""
    _subtest_content_negotiation_method_view(app, _ObjectItem, params={
        'method_serializers': {
            'GET': {'application/json': _obj_to_json_serializer, },
            'PUT': {'application/json': _obj_to_json_serializer, },
            'POST': {'application/json': _obj_to_json_serializer, },
            'PATCH': {'application/json': _obj_to_json_serializer, },
            'DELETE': {'application/json': _obj_to_json_serializer, },
        }
    })


def test_content_negotiation_method_view_method_serializers_2(app):
    """Check that method serializers override global ones."""
    def failing_serializer(data, code=200, headers=None):
        raise Exception('Method serializer was not called.')

    _subtest_content_negotiation_method_view(app, _ObjectItem, params={
        'serializers': {
            'application/json': failing_serializer,
        },
        'method_serializers': {
            'GET': {'application/json': _obj_to_json_serializer, },
            'PUT': {'application/json': _obj_to_json_serializer, },
            'POST': {'application/json': _obj_to_json_serializer, },
            'PATCH': {'application/json': _obj_to_json_serializer, },
            'DELETE': {'application/json': _obj_to_json_serializer, },
        }
    })


def test_content_negotiation_method_view_mixed_serializers(app):
    """Test ContentNegotiationMethodView. Test partial method serializers"""
    _subtest_content_negotiation_method_view(app, _ObjectItem, params={
        'serializers': {
            'application/json': _obj_to_json_serializer,
        },
        'method_serializers': {
            'GET': {'application/json': _obj_to_json_serializer, },
            'PUT': {'application/json': _obj_to_json_serializer, },
        }
    })


def test_default_media_type(app):
    """Test setting default media type."""
    def failing_serializer(data, code=200, headers=None):
        raise Exception('Wrong serializer was called.')
    _subtest_content_negotiation_method_view(app, _ObjectItem, params={
        'serializers': {
            'application/mpeg': failing_serializer,
            'application/json': _obj_to_json_serializer,
        },
        'default_media_type': 'application/json'
    })


def test_default_media_type_for_all_methods(app):
    """Test setting default serializer for all methods."""
    def failing_serializer(data, code=200, headers=None):
        raise Exception('Wrong method serializer was called.')
    _subtest_content_negotiation_method_view(app, _ObjectItem, params={
        'method_serializers': {
            'GET': {
                'application/json': _obj_to_json_serializer,
                'application/mpeg': failing_serializer,
            },
            'PUT': {
                'application/json': _obj_to_json_serializer,
                'application/mpeg': failing_serializer,
            },
            'POST': {
                'application/json': _obj_to_json_serializer,
                'application/mpeg': failing_serializer,
            },
            'PATCH': {
                'application/json': _obj_to_json_serializer,
                'application/mpeg': failing_serializer,
            },
            'DELETE': {
                'application/json': _obj_to_json_serializer,
                'application/mpeg': failing_serializer,
            },
        },
        'default_media_type': 'application/json'
    })


def test_default_method_media_type(app):
    """Test setting default media type for each method."""
    def failing_serializer(data, code=200, headers=None):
        raise Exception('Wrong method serializer was called.')
    _subtest_content_negotiation_method_view(app, _ObjectItem, params={
        'method_serializers': {
            'GET': {
                'application/json': _obj_to_json_serializer,
                'application/mpeg': failing_serializer,
            },
            'PUT': {
                'application/json': _obj_to_json_serializer,
                'application/mpeg': failing_serializer,
            },
            'POST': {
                'application/json': _obj_to_json_serializer,
                'application/mpeg': failing_serializer,
            },
            'PATCH': {
                'application/json': _obj_to_json_serializer,
                'application/mpeg': failing_serializer,
            },
            'DELETE': {
                'application/json': _obj_to_json_serializer,
                'application/mpeg': failing_serializer,
            },
        },
        'default_method_media_type': {
            'GET': 'application/json',
            'PUT': 'application/json',
            'POST': 'application/json',
            'PATCH': 'application/json',
            'DELETE': 'application/json',
        },
        'default_media_type': 'application/doesnotexist'
    })


def test_default_method_media_type_error(app):
    """Test that there is an error when multiple serializers are given with no
    default media type.
    """
    with pytest.raises(ValueError):
        _ObjectItem(
            method_serializers={
                'GET': {
                    'application/json': _obj_to_json_serializer,
                    'application/json-patch+json': _obj_to_json_serializer,
                },
                'PUT': {
                    'application/json': _obj_to_json_serializer,
                    'application/json-patch+json': _obj_to_json_serializer,
                },
                'POST': {
                    'application/json': _obj_to_json_serializer,
                    'application/json-patch+json': _obj_to_json_serializer,
                },
                'PATCH': {
                    'application/json': _obj_to_json_serializer,
                    'application/json-patch+json': _obj_to_json_serializer,
                },
                'DELETE': {
                    'application/json': _obj_to_json_serializer,
                    'application/json-patch+json': _obj_to_json_serializer,
                },
            },
        )


def test_default_media_type_error(app):
    """Test that there is an error when multiple serializers are given with no
    default media type.
    """
    with pytest.raises(ValueError):
        _ObjectItem(serializers={
            'application/json': _obj_to_json_serializer,
            'application/json-patch+json': _obj_to_json_serializer,
        })


def test_get_method_serializers(app):
    """Test get request method serializers."""
    v = ContentNegotiatedMethodView(
        method_serializers=dict(
            DELETE={'*/*': 'any-delete', },
            GET={'text/plain': 'plain-get', 'text/html': 'html-get'},
            POST={'text/plain': 'plain-post', 'text/html': 'html-post'}
        ),
        serializers={
            'text/plain': 'plain',
            'text/html': 'html',
        },
        default_media_type='application/json',
        default_method_media_type=dict(
            GET='text/plain',
            POST='text/html',
        )
    )
    # Default serializers
    assert v.get_method_serializers('GET')[0] == v.method_serializers['GET']
    assert v.get_method_serializers('HEAD')[0] == v.method_serializers['GET']
    assert v.get_method_serializers('POST')[0] == v.method_serializers['POST']
    assert v.get_method_serializers('PUT')[0] == v.serializers

    # Default media type
    assert v.get_method_serializers('GET')[1] == \
        v.default_method_media_type['GET']
    assert v.get_method_serializers('HEAD')[1] == \
        v.default_method_media_type['GET']
    assert v.get_method_serializers('POST')[1] == \
        v.default_method_media_type['POST']
    assert v.get_method_serializers('PUT')[1] == v.default_media_type

    assert v.get_method_serializers('DELETE')[0] == \
        v.method_serializers['DELETE']


def test_match_serializers_headers(app):
    """Test match serializers headers."""
    v = ContentNegotiatedMethodView(
        method_serializers=dict(
            DELETE={
                '*/*': 'any-delete',
            },
            GET={
                'application/json': 'json-get',
                'application/marcxml+xml': 'xml-get'},
            POST={
                'application/json': 'json-post',
                'application/marcxml+xml': 'xml-post'}
        ),
        serializers={
            'application/json': 'json',
            'application/marcxml+xml': 'xml',
        },
        default_media_type='application/json',
        default_method_media_type=dict(
            GET='application/json',
            POST='application/marcxml+xml',
        )
    )

    tests = [
        # Should choose application/json
        ('text/plain,application/json,*/*', 'GET', 'json-get'),
        ('text/plain,application/json,*/*', 'HEAD', 'json-get'),
        ('text/plain,application/json,*/*', 'POST', 'json-post'),
        ('text/plain,application/json,*/*', 'PUT', 'json'),
        # Should choose application/json (even with lower quality)
        ('text/plain,application/json; q=0.5, */*', 'GET', 'json-get'),
        ('text/plain,application/json; q=0.5, */*', 'HEAD', 'json-get'),
        ('text/plain,application/json; q=0.5, */*', 'POST', 'json-post'),
        ('text/plain,application/json; q=0.5, */*', 'PUT', 'json'),
        # Should choose application/marcxml+xml
        ('text/plain,application/marcxml+xml,*/*', 'GET', 'xml-get'),
        ('text/plain,application/marcxml+xml,*/*', 'HEAD', 'xml-get'),
        ('text/plain,application/marcxml+xml,*/*', 'POST', 'xml-post'),
        ('text/plain,application/marcxml+xml,*/*', 'PUT', 'xml'),
        # Should choose default defined by default_media_type and
        # default_method_media_type
        ('text/plain,*/*', 'GET', 'json-get'),
        ('text/plain,*/*', 'HEAD', 'json-get'),
        ('text/plain,*/*', 'POST', 'xml-post'),
        ('text/plain,*/*', 'PUT', 'json'),
        # Should choose none
        ('text/plain', 'GET', None),
        ('text/plain', 'HEAD', None),
        ('text/plain', 'POST', None),
        ('text/plain', 'PUT', None),
        # Should choose default
        (None, 'GET', 'json-get'),
        (None, 'HEAD', 'json-get'),
        (None, 'POST', 'xml-post'),
        (None, 'PUT', 'json'),
        # Should choose application/json
        ('application/json', 'GET', 'json-get'),
        # Should choose highest quality
        ('application/json; q=0.5, application/marcxml+xml', 'GET', 'xml-get'),
        ('application/json, application/marcxml+xml; q=0.5', 'GET',
         'json-get'),
        ('application/json; q=0.4, application/marcxml+xml; q=0.6', 'GET',
         'xml-get'),
        ('application/marcxml+xml; q=0.6, application/json; q=0.4', 'GET',
         'xml-get'),
        # Test delete
        ('application/marcxml+xml; q=0.6, application/json; q=0.4', 'DELETE',
         'any-delete'),
        ('video/mp4', 'DELETE', 'any-delete'),
    ]

    for accept, method, expected_serializer in tests:
        params = dict(headers=[('Accept', accept)]) if accept else {}
        _test_march_serializers(app, v, params, method, accept,
                                expected_serializer)


def test_match_serializers_query_arg(app):
    """Test match serializers query argument."""
    v = ContentNegotiatedMethodView(
        method_serializers=dict(
            GET={
                'application/json': 'json-get',
                'application/marcxml+xml': 'xml-get'}
        ),
        serializers_query_aliases={
            'json': 'application/json',
            'marcxml': 'application/marcxml+xml',
        },
        default_media_type='application/marcxml+xml',
        default_method_media_type=dict(
            GET='application/marcxml+xml',
        )
    )

    # enable serialization by query arg
    app.config['REST_MIMETYPE_QUERY_ARG_NAME'] = 'format'
    InvenioREST(app)

    arg_name = app.config['REST_MIMETYPE_QUERY_ARG_NAME']
    tests = [
        # Should serialize to marcxml
        (urlencode({arg_name: 'marcxml'}), 'application/json', 'xml-get'),
        # Should serialize to json
        (urlencode({arg_name: 'json'}), 'application/marcxml+xml',
         'json-get'),
        # Should serialize to json
        (urlencode({}), 'application/json', 'json-get'),
        # Should serialize to default (marcxml)
        (urlencode({'wrong_name': 'marcxml'}), None, 'xml-get'),
    ]

    for arg, accept, expected_serializer in tests:
        params = dict(headers=[('Accept', accept)],
                      query_string=arg) if accept else {}
        _test_march_serializers(app, v, params, 'GET', accept,
                                expected_serializer)

    # disable query arg, should serialize by headers even if query arg passed
    app.config['REST_MIMETYPE_QUERY_ARG_NAME'] = None
    InvenioREST(app)

    accept = 'application/json'
    params = dict(headers=[('Accept', accept)], query_string='format=marcxml')
    _test_march_serializers(app, v, params, 'GET', accept, 'json-get')


def _test_march_serializers(app, view_method, request_params, request_method,
                            request_accept_header, expected_serializer):
    with app.test_request_context(**request_params):
        assert view_method.match_serializers(
            *view_method.get_method_serializers(request_method)) == \
               expected_serializer, "Accept: {0}, Method: {1}".format(
            request_accept_header, request_method)


def _subtest_content_negotiation_method_view(app, content_negotiated_class,
                                             params):
    """Test a given ContentNegotiatedMethodView subclass."""
    app.add_url_rule('/objects/<int:id>',
                     view_func=content_negotiated_class
                     .as_view(content_negotiated_class.view_name,
                              **params))

    with app.test_client() as client:
        read_methods = [client.get, client.head]
        write_methods = [client.patch, client.put, client.post, client.delete]
        all_methods = read_methods + write_methods
        method_names = {
            'GET': client.get,
            'POST': client.post,
            'PUT': client.put,
            'PATCH': client.patch,
            'DELETE': client.delete,
        }

        def check_normal_response(res, method):
            if method != client.head:
                parsed = json.loads(res.get_data(as_text=True))
                expected = {'id': 1, 'method': parsed['method']}
                assert parsed == expected
                # check that the right method was called
                assert method_names[parsed['method']] == method
                assert res.content_type == 'application/json'
            assert res.status_code == 200
            # check that Cache-Control header is set
            assert res.cache_control.no_cache
            # check that the ETag is correct
            assert unquote_etag(res.headers['ETag']) == \
                unquote_etag(quote_etag('abc'))

        def check_304_response(res):
            assert res.status_code == 304
            # check that Cache-Control header is set
            assert res.cache_control.no_cache
            # check that the ETag is correct
            assert unquote_etag(res.headers['ETag']) == \
                unquote_etag(quote_etag('abc'))

        # check valid call with condition
        headers = [('Accept', 'application/json')]
        for method in all_methods:
            res = method('/objects/1', headers=headers)
            check_normal_response(res, method)

        # check valid call without condition
        for method in all_methods:
            res = method('/objects/1')
            check_normal_response(res, method)

        # check that non accepted mime types are not accepted
        headers = [('Accept', 'application/xml')]
        for method in all_methods:
            res = method('/objects/1', headers=headers)
            assert res.status_code == 406

        # check that errors are forwarded properly
        headers = [('Accept', 'application/json')]
        for method in all_methods:
            res = method('/objects/42', headers=headers)
            assert res.status_code == 404

        # check Matching If-None-Match
        headers_nonmatch_match = [('Accept', 'application/json'),
                                  ('If-None-Match', '"xyz", "abc"')]
        headers_nonmatch_star = [('Accept', 'application/json'),
                                 ('If-None-Match', '"xyz", "*"')]
        for method in read_methods:
            res = method('/objects/1', headers=headers_nonmatch_match)
            check_304_response(res)
            res = method('/objects/1', headers=headers_nonmatch_star)
            check_304_response(res)

        for method in write_methods:
            res = method('/objects/1', headers=headers_nonmatch_match)
            assert res.status_code == 412
            res = method('/objects/1', headers=headers_nonmatch_star)
            assert res.status_code == 412

        # check Matching If-None-Match with weak ETags
        headers_nonmatch_match = [('Accept', 'application/json'),
                                  ('If-None-Match', 'W/"xyz", W/"abc"')]
        headers_nonmatch_star = [('Accept', 'application/json'),
                                 ('If-None-Match', 'W/"xyz", "*"')]
        query_string_weak_etags = {'weak_etags': True}
        for method in read_methods:
            res = method('/objects/1',
                         headers=headers_nonmatch_match,
                         query_string=query_string_weak_etags)
            check_304_response(res)
            res = method('/objects/1',
                         headers=headers_nonmatch_star,
                         query_string=query_string_weak_etags)
            check_304_response(res)

        for method in write_methods:
            res = method('/objects/1',
                         headers=headers_nonmatch_match,
                         query_string=query_string_weak_etags)
            assert res.status_code == 412
            res = method('/objects/1',
                         headers=headers_nonmatch_star,
                         query_string=query_string_weak_etags)
            assert res.status_code == 412

        # check non matching If-None-Match
        headers = [('Accept', 'application/json'),
                   ('If-None-Match', '"xyz", "def"')]
        for method in all_methods:
            res = method('/objects/1', headers=headers)
            check_normal_response(res, method)

        # check non matching If-None-Match with weak ETags
        headers = [('Accept', 'application/json'),
                   ('If-None-Match', 'W/"xyz", W/"def"')]
        query_string_weak_etags = {'weak_etags': True}
        for method in all_methods:
            res = method('/objects/1',
                         headers=headers,
                         query_string=query_string_weak_etags)
            check_normal_response(res, method)

        # check matching If-Match
        headers = [('Accept', 'application/json'),
                   ('If-Match', '"abc", "def"')]
        for method in all_methods:
            res = method('/objects/1', headers=headers)
            check_normal_response(res, method)

        # check matching If-Match with weak ETags
        headers = [('Accept', 'application/json'),
                   ('If-Match', 'W/"abc", W/"def"')]
        query_string_weak_etags = {'weak_etags': True}
        for method in all_methods:
            res = method('/objects/1',
                         headers=headers,
                         query_string=query_string_weak_etags)
            check_normal_response(res, method)

        # check non matching If-Match
        headers = [('Accept', 'application/json'),
                   ('If-Match', '"xyz", "def"')]
        for method in all_methods:
            res = method('/objects/1', headers=headers)
            assert res.status_code == 412

        # check non matching If-Match with weak ETags
        headers = [('Accept', 'application/json'),
                   ('If-Match', 'W/"xyz", W/"def"')]
        query_string_weak_etags = {'weak_etags': True}
        for method in all_methods:
            res = method('/objects/1',
                         headers=headers,
                         query_string=query_string_weak_etags)
            assert res.status_code == 412

        # check If-Modified-Since
        res = client.get('/objects/1')
        last_modified = res.headers['Last-Modified']
        res = client.get(
            '/objects/1', headers={'If-Modified-Since': last_modified})
        assert res.cache_control.no_cache
        assert res.status_code == 304
