"""PATCH support for comment nodes."""

import logging

from flask import current_app
import werkzeug.exceptions as wz_exceptions

from pillar.api.utils import authorization, authentication, jsonify, remove_private_keys

from . import register_patch_handler

log = logging.getLogger(__name__)
COMMENT_VOTING_OPS = {'upvote', 'downvote', 'revoke'}
VALID_COMMENT_OPERATIONS = COMMENT_VOTING_OPS.union({'edit'})


@register_patch_handler('comment')
def patch_comment(node_id, patch):
    assert_is_valid_patch(node_id, patch)
    user_id = authentication.current_user_id()

    if patch['op'] in COMMENT_VOTING_OPS:
        result, node = vote_comment(user_id, node_id, patch)
    else:
        assert patch['op'] == 'edit', 'Invalid patch operation %s' % patch['op']
        result, node = edit_comment(user_id, node_id, patch)

    return jsonify({'_status': 'OK',
                    'result': result,
                    'properties': node['properties']
                    })


def vote_comment(user_id, node_id, patch):
    """Performs a voting operation."""

    # Find the node. Includes a query on the properties.ratings array so
    # that we only get the current user's rating.
    nodes_coll = current_app.data.driver.db['nodes']
    node_query = {'_id': node_id,
                  '$or': [{'properties.ratings.$.user': {'$exists': False}},
                          {'properties.ratings.$.user': user_id}]}
    node = nodes_coll.find_one(node_query,
                               projection={'properties': 1, 'user': 1})
    if node is None:
        log.warning('User %s wanted to patch non-existing node %s' % (user_id, node_id))
        raise wz_exceptions.NotFound('Node %s not found' % node_id)

    # We don't allow the user to down/upvote their own nodes.
    if user_id == node['user']:
        raise wz_exceptions.Forbidden('You cannot vote on your own node')

    props = node['properties']

    # Find the current rating (if any)
    rating = next((rating for rating in props.get('ratings', ())
                   if rating.get('user') == user_id), None)

    def revoke():
        if not rating:
            # No rating, this is a no-op.
            return

        label = 'positive' if rating.get('is_positive') else 'negative'
        update = {'$pull': {'properties.ratings': rating},
                  '$inc': {'properties.rating_%s' % label: -1}}
        return update

    def upvote():
        if rating and rating.get('is_positive'):
            # There already was a positive rating, so this is a no-op.
            return

        update = {'$inc': {'properties.rating_positive': 1}}
        if rating:
            update['$inc']['properties.rating_negative'] = -1
            update['$set'] = {'properties.ratings.$.is_positive': True}
        else:
            update['$push'] = {'properties.ratings': {
                'user': user_id, 'is_positive': True,
            }}
        return update

    def downvote():
        if rating and not rating.get('is_positive'):
            # There already was a negative rating, so this is a no-op.
            return

        update = {'$inc': {'properties.rating_negative': 1}}
        if rating:
            update['$inc']['properties.rating_positive'] = -1
            update['$set'] = {'properties.ratings.$.is_positive': False}
        else:
            update['$push'] = {'properties.ratings': {
                'user': user_id, 'is_positive': False,
            }}
        return update

    actions = {
        'upvote': upvote,
        'downvote': downvote,
        'revoke': revoke,
    }
    action = actions[patch['op']]
    mongo_update = action()

    nodes_coll = current_app.data.driver.db['nodes']
    if mongo_update:
        log.info('Running %s', mongo_update)
        if rating:
            result = nodes_coll.update_one({'_id': node_id, 'properties.ratings.user': user_id},
                                           mongo_update)
        else:
            result = nodes_coll.update_one({'_id': node_id}, mongo_update)
    else:
        result = 'no-op'

    # Fetch the new ratings, so the client can show these without querying again.
    node = nodes_coll.find_one(node_id,
                               projection={'properties.rating_positive': 1,
                                           'properties.rating_negative': 1})

    return result, node


def edit_comment(user_id, node_id, patch):
    """Edits a single comment.

    Doesn't do permission checking; users are allowed to edit their own
    comment, and this is not something you want to revoke anyway. Admins
    can edit all comments.
    """

    # Find the node. We need to fetch some more info than we use here, so that
    # we can pass this stuff to Eve's patch_internal; that way the validation &
    # authorisation system has enough info to work.
    nodes_coll = current_app.data.driver.db['nodes']
    node = nodes_coll.find_one(node_id)
    if node is None:
        log.warning('User %s wanted to patch non-existing node %s' % (user_id, node_id))
        raise wz_exceptions.NotFound('Node %s not found' % node_id)

    if node['user'] != user_id and not authorization.user_has_role('admin'):
        raise wz_exceptions.Forbidden('You can only edit your own comments.')

    node = remove_private_keys(node)
    node['properties']['content'] = patch['content']
    node['properties']['attachments'] = patch.get('attachments', {})
    # Use Eve to PUT this node, as that also updates the etag and we want to replace attachments.
    r, _, _, status = current_app.put_internal('nodes',
                                               node,
                                               concurrency_check=False,
                                               _id=node_id)
    if status != 200:
        log.error('Error %i editing comment %s for user %s: %s',
                  status, node_id, user_id, r)
        raise wz_exceptions.InternalServerError('Internal error %i from Eve' % status)
    else:
        log.info('User %s edited comment %s', user_id, node_id)

    # Fetch the new content, so the client can show these without querying again.
    node = nodes_coll.find_one(node_id, projection={
        'properties.content': 1,
        'properties._content_html': 1,
    })
    return status, node


def assert_is_valid_patch(node_id, patch):
    """Raises an exception when the patch isn't valid."""

    try:
        op = patch['op']
    except KeyError:
        raise wz_exceptions.BadRequest("PATCH should have a key 'op' indicating the operation.")

    if op not in VALID_COMMENT_OPERATIONS:
        raise wz_exceptions.BadRequest('Operation should be one of %s',
                                       ', '.join(VALID_COMMENT_OPERATIONS))

    if op not in COMMENT_VOTING_OPS:
        # We can't check here, we need the node owner for that.
        return

    # See whether the user is allowed to patch
    if authorization.user_matches_roles(current_app.config['ROLES_FOR_COMMENT_VOTING']):
        log.debug('User is allowed to upvote/downvote comment')
        return

    # Access denied.
    log.info('User %s wants to PATCH comment node %s, but is not allowed.',
             authentication.current_user_id(), node_id)
    raise wz_exceptions.Forbidden()
