import copy
import logging

from bson import ObjectId
from eve.methods.get import get
from flask import Blueprint, current_app, request
from pillar.api import utils
from pillar.api.utils import authentication, authorization, utcnow
from werkzeug import exceptions as wz_exceptions

from pillar.api.projects import utils as proj_utils

blueprint = Blueprint('blender_cloud.home_project', __name__)
log = logging.getLogger(__name__)

# Users with any of these roles will get a home project.
HOME_PROJECT_USERS = set()

# Users with any of these roles will get full write access to their home project.
HOME_PROJECT_WRITABLE_USERS = {'subscriber', 'demo'}

HOME_PROJECT_DESCRIPTION = ('# Your home project\n\n'
                            'This is your home project. It allows synchronisation '
                            'of your Blender settings using the [Blender Cloud addon]'
                            '(https://cloud.blender.org/services#blender-addon).')
HOME_PROJECT_SUMMARY = 'This is your home project. Here you can sync your Blender settings!'
# HOME_PROJECT_DESCRIPTION = ('# Your home project\n\n'
#                             'This is your home project. It has functionality to act '
#                             'as a pastebin for text, images and other assets, and '
#                             'allows synchronisation of your Blender settings.')
# HOME_PROJECT_SUMMARY = 'This is your home project. Pastebin and Blender settings sync in one!'
SYNC_GROUP_NODE_NAME = 'Blender Sync'
SYNC_GROUP_NODE_DESC = ('The [Blender Cloud Addon](https://cloud.blender.org/services'
                        '#blender-addon) will synchronize your Blender settings here.')


def create_blender_sync_node(project_id, admin_group_id, user_id):
    """Creates a node for Blender Sync, with explicit write access for the admin group.

    Writes the node to the database.

    :param project_id: ID of the home project
    :type project_id: ObjectId
    :param admin_group_id: ID of the admin group of the project. This group will
        receive write access to the node.
    :type admin_group_id: ObjectId
    :param user_id: ID of the owner of the node.
    :type user_id: ObjectId

    :returns: The created node.
    :rtype: dict
    """

    log.debug('Creating sync node for project %s, user %s', project_id, user_id)

    node = {
        'project': ObjectId(project_id),
        'node_type': 'group',
        'name': SYNC_GROUP_NODE_NAME,
        'user': ObjectId(user_id),
        'description': SYNC_GROUP_NODE_DESC,
        'properties': {'status': 'published'},
        'permissions': {
            'users': [],
            'groups': [
                {'group': ObjectId(admin_group_id),
                 'methods': ['GET', 'PUT', 'POST', 'DELETE']}
            ],
            'world': [],
        }
    }

    r, _, _, status = current_app.post_internal('nodes', node)
    if status != 201:
        log.warning('Unable to create Blender Sync node for home project %s: %s',
                    project_id, r)
        raise wz_exceptions.InternalServerError('Unable to create Blender Sync node')

    node.update(r)
    return node


def create_home_project(user_id, write_access):
    """Creates a home project for the given user.

    :param user_id: the user ID of the owner
    :param write_access: whether the user has full write access to the home project.
    :type write_access: bool
    :returns: the project
    :rtype: dict
    """

    log.info('Creating home project for user %s', user_id)
    overrides = {
        'category': 'home',
        'url': 'home',
        'summary': HOME_PROJECT_SUMMARY,
        'description': HOME_PROJECT_DESCRIPTION
    }

    # Maybe the user has a deleted home project.
    proj_coll = current_app.data.driver.db['projects']
    deleted_proj = proj_coll.find_one({'user': user_id, 'category': 'home', '_deleted': True})
    if deleted_proj:
        log.info('User %s has a deleted project %s, restoring', user_id, deleted_proj['_id'])
        project = deleted_proj
    else:
        log.debug('User %s does not have a deleted project', user_id)
        project = proj_utils.create_new_project(project_name='Home',
                                                user_id=ObjectId(user_id),
                                                overrides=overrides)

    # Re-validate the authentication token, so that the put_internal call sees the
    # new group created for the project.
    authentication.validate_token(force=True)

    # There are a few things in the on_insert_projects hook we need to adjust.

    # Ensure that the project is private, even for admins.
    project['permissions']['world'] = []

    # Set up the correct node types. No need to set permissions for them,
    # as the inherited project permissions are fine.
    from pillar.api.node_types.group import node_type_group
    from pillar.api.node_types.asset import node_type_asset
    # from pillar.api.node_types.text import node_type_text
    from pillar.api.node_types.comment import node_type_comment

    # For non-subscribers: take away write access from the admin group,
    # and grant it to certain node types.
    project['permissions']['groups'][0]['methods'] = home_project_permissions(write_access)

    # Everybody should be able to comment on anything in this project.
    # This allows people to comment on shared images and see comments.
    node_type_comment = assign_permissions(
        node_type_comment,
        subscriber_methods=['GET', 'POST'],
        world_methods=['GET'])

    project['node_types'] = [
        node_type_group,
        node_type_asset,
        # node_type_text,
        node_type_comment,
    ]

    result, _, _, status = current_app.put_internal('projects', utils.remove_private_keys(project),
                                                    _id=project['_id'])
    if status != 200:
        log.error('Unable to update home project %s for user %s: %s',
                  project['_id'], user_id, result)
        raise wz_exceptions.InternalServerError('Unable to update home project')
    project.update(result)

    # Create the Blender Sync node, with explicit write permissions on the node itself.
    create_blender_sync_node(project['_id'],
                             project['permissions']['groups'][0]['group'],
                             user_id)

    return project


def assign_permissions(node_type, subscriber_methods, world_methods):
    """Assigns permissions to the node type object.

    :param node_type: a node type from pillar.api.node_types.
    :type node_type: dict
    :param subscriber_methods: allowed HTTP methods for users of role 'subscriber',
        'demo' and 'admin'.
    :type subscriber_methods: list
    :param subscriber_methods: allowed HTTP methods for world
    :type subscriber_methods: list
    :returns: a copy of the node type, with embedded permissions
    :rtype: dict
    """

    from pillar.api import service

    nt_with_perms = copy.deepcopy(node_type)

    perms = nt_with_perms.setdefault('permissions', {})
    perms['groups'] = [
        {'group': service.role_to_group_id['subscriber'],
         'methods': subscriber_methods[:]},
        {'group': service.role_to_group_id['demo'],
         'methods': subscriber_methods[:]},
        {'group': service.role_to_group_id['admin'],
         'methods': subscriber_methods[:]},
    ]
    perms['world'] = world_methods[:]

    return nt_with_perms


@blueprint.route('/home-project')
@authorization.require_login()
def home_project():
    """Fetches the home project, creating it if necessary.

    Eve projections are supported, but at least the following fields must be present:
        'permissions', 'category', 'user'
    """
    from pillar.auth import current_user

    user_id = current_user.user_id
    roles = current_user.roles

    log.debug('Possibly creating home project for user %s with roles %s', user_id, roles)
    if HOME_PROJECT_USERS and not HOME_PROJECT_USERS.intersection(roles):
        log.debug('User %s is not a subscriber, not creating home project.', user_id)
        return 'No home project', 404

    # Create the home project before we do the Eve query. This costs an extra round-trip
    # to the database, but makes it easier to do projections correctly.
    if not has_home_project(user_id):
        write_access = write_access_with_roles(roles)
        create_home_project(user_id, write_access)

    resp, _, _, status, _ = get('projects', category='home', user=user_id)
    if status != 200:
        return utils.jsonify(resp), status

    if resp['_items']:
        project = resp['_items'][0]
    else:
        log.warning('Home project for user %s not found, while we just created it! Could be '
                    'due to projections and other arguments on the query string: %s',
                    user_id, request.query_string)
        return 'No home project', 404

    return utils.jsonify(project), status


def write_access_with_roles(roles):
    """Returns whether or not one of these roles grants write access to the home project.

    :rtype: bool
    """

    write_access = bool(not HOME_PROJECT_WRITABLE_USERS or
                        HOME_PROJECT_WRITABLE_USERS.intersection(roles))
    return write_access


def home_project_permissions(write_access):
    """Returns the project permissions, given the write access of the user.

    :rtype: list
    """

    if write_access:
        return ['GET', 'PUT', 'POST', 'DELETE']
    return ['GET']


def has_home_project(user_id):
    """Returns True iff the user has a home project."""

    proj_coll = current_app.data.driver.db['projects']
    return proj_coll.count_documents({'user': user_id, 'category': 'home', '_deleted': False}) > 0


def get_home_project(user_id: ObjectId, projection=None) -> dict:
    """Returns the home project"""

    proj_coll = current_app.data.driver.db['projects']
    return proj_coll.find_one({'user': user_id, 'category': 'home', '_deleted': False},
                              projection=projection)


def is_home_project(project_id, user_id):
    """Returns True iff the given project exists and is the user's home project."""

    proj_coll = current_app.data.driver.db['projects']
    return proj_coll.count_documents({'_id': project_id,
                                      'user': user_id,
                                      'category': 'home',
                                      '_deleted': False}) > 0


def mark_node_updated(node_id):
    """Uses pymongo to set the node's _updated to "now"."""

    now = utcnow()
    nodes_coll = current_app.data.driver.db['nodes']

    return nodes_coll.update_one({'_id': node_id},
                                 {'$set': {'_updated': now}})


def get_home_project_parent_node(node, projection, name_for_log):
    """Returns a partial parent node document, but only if the node is a home project node."""

    user_id = authentication.current_user_id()
    if not user_id:
        log.debug('%s: user not logged in.', name_for_log)
        return None

    parent_id = node.get('parent')
    if not parent_id:
        log.debug('%s: ignoring top-level node.', name_for_log)
        return None

    project_id = node.get('project')
    if not project_id:
        log.debug('%s: ignoring node without project ID', name_for_log)
        return None

    project_id = ObjectId(project_id)
    if not is_home_project(project_id, user_id):
        log.debug('%s: node not part of home project.', name_for_log)
        return None

    # Get the parent node for permission checking.
    parent_id = ObjectId(parent_id)

    nodes_coll = current_app.data.driver.db['nodes']
    projection['project'] = 1
    parent_node = nodes_coll.find_one(parent_id, projection=projection)

    if parent_node['project'] != project_id:
        log.warning('%s: User %s is trying to reference '
                    'parent node %s from different project %s, expected project %s.',
                    name_for_log, user_id, parent_id, parent_node['project'], project_id)
        raise wz_exceptions.BadRequest('Trying to create cross-project links.')

    return parent_node


def check_home_project_nodes_permissions(nodes):
    for node in nodes:
        check_home_project_node_permissions(node)


def check_home_project_node_permissions(node):
    """Grants POST access to the node when the user has POST access on its parent."""

    parent_node = get_home_project_parent_node(node,
                                               {'permissions': 1,
                                                'project': 1,
                                                'node_type': 1},
                                               'check_home_project_node_permissions')
    if parent_node is None or 'permissions' not in parent_node:
        return

    parent_id = parent_node['_id']

    has_access = authorization.has_permissions('nodes', parent_node, 'POST')
    if not has_access:
        log.debug('check_home_project_node_permissions: No POST access to parent node %s, '
                  'ignoring.', parent_id)
        return

    # Grant access!
    log.debug('check_home_project_node_permissions: POST access at parent node %s, '
              'so granting POST access to new child node.', parent_id)

    # Make sure the permissions of the parent node are copied to this node.
    node['permissions'] = copy.deepcopy(parent_node['permissions'])


def mark_parents_as_updated(nodes):
    for node in nodes:
        mark_parent_as_updated(node)


def mark_parent_as_updated(node, original=None):
    parent_node = get_home_project_parent_node(node,
                                               {'permissions': 1,
                                                'node_type': 1},
                                               'mark_parent_as_updated')
    if parent_node is None:
        return

    # Mark the parent node as 'updated' if this is an asset and the parent is a group.
    if node.get('node_type') == 'asset' and parent_node['node_type'] == 'group':
        log.debug('Node %s updated, marking parent=%s as updated too',
                  node['_id'], parent_node['_id'])
        mark_node_updated(parent_node['_id'])


def user_changed_role(sender, user):
    """Responds to the 'user changed' signal from the Badger service.

    Changes the permissions on the home project based on the 'subscriber' role.

    :returns: whether this function actually made changes.
    :rtype: bool
    """

    user_id = user['_id']
    if not has_home_project(user_id):
        log.debug('User %s does not have a home project, not changing access permissions', user_id)
        return

    proj_coll = current_app.data.driver.db['projects']
    proj = get_home_project(user_id, projection={'permissions': 1, '_id': 1})

    write_access = write_access_with_roles(user['roles'])
    target_permissions = home_project_permissions(write_access)

    current_perms = proj['permissions']['groups'][0]['methods']
    if set(current_perms) == set(target_permissions):
        return False

    project_id = proj['_id']
    log.info('Updating permissions on user %s home project %s from %s to %s',
             user_id, project_id, current_perms, target_permissions)
    proj_coll.update_one({'_id': project_id},
                         {'$set': {'permissions.groups.0.methods': list(target_permissions)}})

    return True


def setup_app(app, url_prefix):
    app.register_api_blueprint(blueprint, url_prefix=url_prefix)

    app.on_insert_nodes += check_home_project_nodes_permissions
    app.on_inserted_nodes += mark_parents_as_updated
    app.on_updated_nodes += mark_parent_as_updated
    app.on_replaced_nodes += mark_parent_as_updated

    from pillar.api import service
    service.signal_user_changed_role.connect(user_changed_role)
