import json
import os
import re

from cerberus import Validator
from copy import deepcopy
from hashlib import sha512
from pyramid.httpexceptions import HTTPBadRequest, HTTPUnauthorized, HTTPNotFound, HTTPNoContent
from secrets import token_hex

from digi_edit.models import User, Branch, File
from digi_edit.jsonapi import jsonapi_type_schema


DB_CLASSES = {
    'branches': Branch,
    'files': File,
    'users': User,
}


def includeme(config):
    """Setup the API routes."""
    config.add_route('api', '/api')
    config.add_route('api.login', '/api/login', request_method='POST')
    config.add_view(user_login, route_name='api.login', renderer='json')
    generate_db_api(config, 'users', User)
    generate_db_api(config, 'branches', Branch)
    generate_db_api(config, 'files', File)


def flatten_errors(errors, path=''):
    """Flatten Cerberus errors into the JSONAPI structure."""
    flat_errors = []
    for key, values in errors.items():
        for value in values:
            if isinstance(value, dict):
                flat_errors.extend(flatten_errors(value, path='{0}/{1}'.format(path, key)))
            else:
                flat_errors.append({'title': '{0}{1}'.format(value[0].title(), value[1:]),
                                    'source': {'pointer': '{0}/{1}'.format(path, key)}})
    return flat_errors


def validate_body(body, schema):
    """Validate the request ``body`` against the Cerberus ``schema``. Returns the content of the ``data`` key.

    Raises :class:`~pyramid.httpexceptions.HTTPBadRequest` if the body does not validate. The response contains
    the error(s) formatted in the JSONAPI error structure.

    :param body: The request body
    :type body: ``string``
    :param schema: The Cerberus schema to validate against
    :type schema: ``dict``
    :return: The validated object
    :rtype: ``dict``
    """
    try:
        body = json.loads(body)
        validator = Validator({'data': {'type': 'dict',
                                        'schema': deepcopy(schema),
                                        'required': True}})
        if validator.validate(body):
            return validator.validated(body)['data']
        else:
            raise HTTPBadRequest(body=json.dumps({'errors': flatten_errors(validator.errors)}))
    except json.decoder.JSONDecodeError:
        raise HTTPBadRequest(body=json.dumps({'errors': [
            {'title': 'Invalid JSON Data'}
        ]}))


user_login_schema = {
    'type': jsonapi_type_schema('users'),
    'attributes': {'type': 'dict',
                   'schema': {'email': {'type': 'string',
                                        'required': True,
                                        'empty': False},
                              'password': {'type': 'string',
                                           'required': True,
                                           'empty': False}}}}


def user_login(request):
    """Handle user login."""
    body = validate_body(request.body, user_login_schema)
    user = request.dbsession.query(User).filter(User.email == body['attributes']['email']).first()
    if user:
        hash = sha512()
        hash.update(user.salt.encode('utf-8'))
        hash.update(b'$$')
        hash.update(body['attributes']['password'].encode('utf-8'))
        if user.password == <PASSWORD>():
            user.attributes['token'] = token_hex(64)
            return {'data': user.as_jsonapi(request)}
        else:
            raise HTTPBadRequest(body=json.dumps({
                'errors': [
                    {'title': 'User not found, password incorrect, or blocked',
                     'source': {'pointer': '/data/attributes/email'}},
                    {'title': 'User not found, password incorrect, or blocked',
                     'source': {'pointer': '/data/attributes/password'}},
                ]
            }))
    else:
        raise HTTPBadRequest(body=json.dumps({
            'errors': [
                {'title': 'User not found, password incorrect, or blocked',
                 'source': {'pointer': '/data/attributes/email'}},
                {'title': 'User not found, password incorrect, or blocked',
                 'source': {'pointer': '/data/attributes/password'}},
            ]
        }))


def check_authorization(request):
    """Checks the request for the required authorisation."""
    if 'X-Authorization' in request.headers:
        try:
            userId, token = request.headers['X-Authorization'].split(' ')
            user = request.dbsession.query(User).filter(User.id == userId).first()
            if user and 'token' in user.attributes and user.attributes['token'] == token:
                return user
            else:
                raise HTTPUnauthorized()
        except:
            raise HTTPUnauthorized()
    else:
        raise HTTPUnauthorized()


def set_cache_headers(request):
    """Sets the default no-caching headers."""
    request.response.headers['Cache-Control'] = 'no-store'


def generate_db_api(config, type_name, db_class):
    """Generates the API endpoints for models backed by the database.

    :param config: The Pyramid configuration
    :param type_name: The name of the type
    :type type_name: ``string``
    :param db_class: The DB-model class to generate the endpoints for
    """
    def collection_get(request):
        """Fetch the set of all items."""
        check_authorization(request)
        objs = request.dbsession.query(db_class)
        for key, value in request.params.items():
            match = re.match(r'filter\[([a-zA-Z_\-]+)\]', key)
            if match:
                objs = objs.filter(getattr(db_class, match.group(1)) == value)
        set_cache_headers(request)
        return {'data': [obj.as_jsonapi(request) for obj in objs]}

    def collection_post(request):
        """Create a new item."""
        user = check_authorization(request)
        body = validate_body(request.body, db_class.create_schema())
        obj = db_class()
        for key, value in list(body['attributes'].items()):
            if hasattr(db_class, key):
                setattr(obj, key, value)
                del body['attributes'][key]
        obj.attributes = body['attributes']
        if 'relationships' in body:
            for key, value in body['relationships'].items():
                if isinstance(value['data'], list):
                    setattr(obj, key, [request.dbsession.query(DB_CLASSES[entry['type']]).
                                       filter(DB_CLASSES[entry['type']].id == entry['id']).
                                       first()
                                       for entry in value['data']])
                else:
                    setattr(obj, key, request.dbsession.query(DB_CLASSES[value['data']['type']]).
                        filter(DB_CLASSES[value['data']['type']].id == value['data']['id']).
                        first())
        if hasattr(obj, 'pre_create'):
            obj.pre_create(request, user)
        request.dbsession.add(obj)
        request.dbsession.flush()
        if hasattr(obj, 'post_create'):
            request.dbsession.add(obj)
            obj.post_create(request)
        return {'data': obj.as_jsonapi(request)}

    def item_get(request):
        """Fetch a single item."""
        check_authorization(request)
        obj = request.dbsession.query(db_class).filter(db_class.id == request.matchdict['iid']).first()
        if obj:
            data = obj.as_jsonapi(request)
            set_cache_headers(request)
            return {'data': data}
        else:
            raise HTTPNotFound()

    def item_post(request):
        """Run an action on a single item."""
        check_authorization(request)
        obj = request.dbsession.query(db_class).filter(db_class.id == request.matchdict['iid']).first()
        if obj and hasattr(obj, 'action'):
            if 'X-Action' in request.headers:
                obj.action(request, request.headers['X-Action'])
                return {'data': obj.as_jsonapi(request)}
            else:
                raise HTTPBadRequest()
        else:
            raise HTTPNotFound()


    def item_patch(request):
        """Update a single item."""
        user = check_authorization(request)
        obj = request.dbsession.query(db_class).filter(db_class.id == request.matchdict['iid']).first()
        if obj:
            body = validate_body(request.body, db_class.patch_schema(request))
            for key, value in list(body['attributes'].items()):
                if hasattr(db_class, key):
                    setattr(obj, key, value)
                    del body['attributes'][key]
            obj.attributes = body['attributes']
            if hasattr(obj, 'pre_patch'):
                obj.pre_patch(request, user)
            request.dbsession.add(obj)
            if hasattr(obj, 'post_patch'):
                request.dbsession.flush()
                request.dbsession.add(obj)
                request.dbsession.add(user)
                obj.post_patch(request, user)
            return {'data': obj.as_jsonapi(request)}
        else:
            raise HTTPNotFound()

    def item_delete(request):
        """Delete a single item."""
        check_authorization(request)
        obj = request.dbsession.query(db_class).filter(db_class.id == request.matchdict['iid']).first()
        if obj:
            if hasattr(obj, 'pre_delete'):
                obj.pre_delete(request)
            if 'status' in obj.attributes:
                obj.attributes['status'] = 'deleted'
                return {'data': obj.as_jsonapi(request)}
            else:
                request.dbsession.delete(obj)
                return HTTPNoContent()
        else:
            raise HTTPNotFound()

    config.add_route(f'api.{type_name}.collection.get', f'/api/{type_name}', request_method='GET')
    config.add_view(collection_get, route_name=f'api.{type_name}.collection.get', renderer='json')
    config.add_route(f'api.{type_name}.collection.post', f'/api/{type_name}', request_method='POST')
    config.add_view(collection_post, route_name=f'api.{type_name}.collection.post', renderer='json')
    config.add_route(f'api.{type_name}.item.get', f'/api/{type_name}/{{iid}}', request_method='GET')
    config.add_view(item_get, route_name=f'api.{type_name}.item.get', renderer='json')
    config.add_route(f'api.{type_name}.item.post', f'/api/{type_name}/{{iid}}', request_method='POST')
    config.add_view(item_post, route_name=f'api.{type_name}.item.post', renderer='json')
    config.add_route(f'api.{type_name}.item.patch', f'/api/{type_name}/{{iid}}', request_method='PATCH')
    config.add_view(item_patch, route_name=f'api.{type_name}.item.patch', renderer='json')
    config.add_route(f'api.{type_name}.item.delete', f'/api/{type_name}/{{iid}}', request_method='DELETE')
    config.add_view(item_delete, route_name=f'api.{type_name}.item.delete', renderer='json')
