# coding: utf-8
#
# Copyright 2015 The Oppia Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS-IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Commands that can be used to operate on collections.

All functions here should be agnostic of how CollectionModel objects are
stored in the database. In particular, the various query methods should
delegate to the Collection model class. This will enable the collection
storage model to be changed without affecting this module and others above it.
"""

import collections
import copy
import datetime
import logging
import os

from core.domain import collection_domain
from core.domain import exp_services
from core.domain import rights_manager
from core.domain import summary_services
from core.domain import user_services
from core.platform import models
import feconf
import utils

(collection_models, user_models) = models.Registry.import_models([
    models.NAMES.collection, models.NAMES.user])
memcache_services = models.Registry.import_memcache_services()
search_services = models.Registry.import_search_services()

# This takes additional 'title' and 'category' parameters.
CMD_CREATE_NEW = 'create_new'

# Name for the collection search index.
SEARCH_INDEX_COLLECTIONS = 'collections'

# The maximum number of iterations allowed for populating the results of a
# search query.
MAX_ITERATIONS = 10

# TODO(bhenning): Improve the ranking calculation. Some possible suggestions
# for a better ranking include using an average of the search ranks of each
# exploration referenced in the collection and/or demoting collections
# for any validation errors from explorations referenced in the collection.
_STATUS_PUBLICIZED_BONUS = 30
# This is done to prevent the rank hitting 0 too easily. Note that
# negative ranks are disallowed in the Search API.
_DEFAULT_RANK = 20
_MS_IN_ONE_DAY = 24 * 60 * 60 * 1000


def _migrate_collection_to_latest_schema(versioned_collection):
    """Holds the responsibility of performing a step-by-step, sequential update
    of the collection structure based on the schema version of the input
    collection dictionary. This is very similar to the exploration migration
    process seen in exp_services. If any of the current collection schemas
    change, a new conversion function must be added and some code appended to
    this function to account for that new version.

    Args:
        versioned_collection: A dict with two keys:
          - schema_version: the schema version for the collection.
          - nodes: the list of collection nodes comprising the collection.
    """
    collection_schema_version = versioned_collection['schema_version']
    if not (1 <= collection_schema_version
            <= feconf.CURRENT_COLLECTION_SCHEMA_VERSION):
        raise Exception(
            'Sorry, we can only process v1-v%d collection schemas at '
            'present.' % feconf.CURRENT_COLLECTION_SCHEMA_VERSION)

    # This is where conversion functions will be placed once updates to the
    # collection schemas happen.
    # TODO(sll): Ensure that there is a test similar to
    # exp_domain_test.SchemaMigrationMethodsUnitTests to ensure that the
    # appropriate migration functions are declared.


# Repository GET methods.
def _get_collection_memcache_key(collection_id, version=None):
    """Returns a memcache key for an collection."""
    if version:
        return 'collection-version:%s:%s' % (collection_id, version)
    else:
        return 'collection:%s' % collection_id


def get_collection_from_model(collection_model, run_conversion=True):
    """Returns a Collection domain object given a collection model loaded
    from the datastore.

    If run_conversion is True, then the collection's schema version will be
    checked against the current schema version. If they do not match, the
    collection will be automatically updated to the latest schema version.

    IMPORTANT NOTE TO DEVELOPERS: In general, run_conversion should never be
    False. This option is only used for testing that the schema version
    migration works correctly, and it should never be changed otherwise.
    """

    # Ensure the original collection model does not get altered.
    versioned_collection = {
        'schema_version': collection_model.schema_version,
        'nodes': copy.deepcopy(collection_model.nodes)
    }

    # Migrate the collection if it is not using the latest schema version.
    if (run_conversion and collection_model.schema_version !=
            feconf.CURRENT_COLLECTION_SCHEMA_VERSION):
        _migrate_collection_to_latest_schema(versioned_collection)

    return collection_domain.Collection(
        collection_model.id, collection_model.title,
        collection_model.category, collection_model.objective,
        versioned_collection['schema_version'], [
            collection_domain.CollectionNode.from_dict(collection_node_dict)
            for collection_node_dict in versioned_collection['nodes']
        ],
        collection_model.version, collection_model.created_on,
        collection_model.last_updated)


def get_collection_summary_from_model(collection_summary_model):
    return collection_domain.CollectionSummary(
        collection_summary_model.id, collection_summary_model.title,
        collection_summary_model.category, collection_summary_model.objective,
        collection_summary_model.status,
        collection_summary_model.community_owned,
        collection_summary_model.owner_ids,
        collection_summary_model.editor_ids,
        collection_summary_model.viewer_ids,
        collection_summary_model.contributor_ids,
        collection_summary_model.contributors_summary,
        collection_summary_model.version,
        collection_summary_model.collection_model_created_on,
        collection_summary_model.collection_model_last_updated
    )


def get_collection_by_id(collection_id, strict=True, version=None):
    """Returns a domain object representing a collection."""
    collection_memcache_key = _get_collection_memcache_key(
        collection_id, version=version)
    memcached_collection = memcache_services.get_multi(
        [collection_memcache_key]).get(collection_memcache_key)

    if memcached_collection is not None:
        return memcached_collection
    else:
        collection_model = collection_models.CollectionModel.get(
            collection_id, strict=strict, version=version)
        if collection_model:
            collection = get_collection_from_model(collection_model)
            memcache_services.set_multi({collection_memcache_key: collection})
            return collection
        else:
            return None


def get_collection_summary_by_id(collection_id):
    """Returns a domain object representing a collection summary."""
    # TODO(msl): Maybe use memcache similarly to get_collection_by_id.
    collection_summary_model = collection_models.CollectionSummaryModel.get(
        collection_id)
    if collection_summary_model:
        collection_summary = get_collection_summary_from_model(
            collection_summary_model)
        return collection_summary
    else:
        return None


def get_multiple_collections_by_id(collection_ids, strict=True):
    """Returns a dict of domain objects representing collections with the
    given ids as keys. If a collection_id is not present it is not included in
    the return dict.
    """
    collection_ids = set(collection_ids)
    result = {}
    uncached = []
    memcache_keys = [_get_collection_memcache_key(i) for i in collection_ids]
    cache_result = memcache_services.get_multi(memcache_keys)

    for collection_obj in cache_result.itervalues():
        result[collection_obj.id] = collection_obj

    for _id in collection_ids:
        if _id not in result:
            uncached.append(_id)

    db_collection_models = collection_models.CollectionModel.get_multi(
        uncached)
    db_results_dict = {}
    not_found = []
    for index, cid in enumerate(uncached):
        model = db_collection_models[index]
        if model:
            collection = get_collection_from_model(model)
            db_results_dict[cid] = collection
        else:
            logging.info('Tried to fetch collection with id %s, but no such '
                         'collection exists in the datastore' % cid)
            not_found.append(cid)

    if strict and not_found:
        raise ValueError(
            'Couldn\'t find collections with the following ids:\n%s'
            % '\n'.join(not_found))

    cache_update = {
        cid: db_results_dict[cid] for cid in db_results_dict.iterkeys()
        if db_results_dict[cid] is not None
    }

    if cache_update:
        memcache_services.set_multi(cache_update)

    result.update(db_results_dict)
    return result


def get_new_collection_id():
    """Returns a new collection id."""
    return collection_models.CollectionModel.get_new_id('')


def is_collection_summary_editable(collection_summary, user_id=None):
    """Checks if a given user may edit an collection by checking
    the given domain object.
    """
    return user_id is not None and (
        user_id in collection_summary.editor_ids
        or user_id in collection_summary.owner_ids
        or collection_summary.community_owned)


def get_learner_collection_dict_by_id(
        collection_id, user_id, strict=True, allow_invalid_explorations=False,
        version=None):
    """Creates and returns a dictionary representation of a collection given by
    the provided collection ID. This dictionary contains extra information
    along with the dict returned by collection_domain.Collection.to_dict()
    which includes useful data for the collection learner view. The information
    includes progress in the collection, information about explorations
    referenced within the collection, and a slightly nicer data structure for
    frontend work.

    This raises a ValidationError if the collection retrieved using the given ID
    references non-existent explorations.
    """
    collection = get_collection_by_id(
        collection_id, strict=strict, version=version)

    exp_ids = collection.exploration_ids
    exp_summary_dicts = (
        summary_services.get_displayable_exp_summary_dicts_matching_ids(
            exp_ids))
    exp_summaries_dict_map = {
        exp_summary_dict['id']: exp_summary_dict
        for exp_summary_dict in exp_summary_dicts
    }

    # TODO(bhenning): Users should not be recommended explorations they have
    # completed outside the context of a collection (see #1461).
    next_exploration_ids = None
    completed_exploration_ids = None
    if user_id:
        completed_exploration_ids = _get_valid_completed_exploration_ids(
            user_id, collection_id, collection)
        next_exploration_ids = collection.get_next_exploration_ids(
            completed_exploration_ids)
    else:
        # If the user is not logged in or they have not completed any of
        # the explorations yet within the context of this collection,
        # recommend the initial explorations.
        next_exploration_ids = collection.init_exploration_ids
        completed_exploration_ids = []

    collection_dict = collection.to_dict()
    collection_dict['skills'] = collection.skills
    collection_dict['playthrough_dict'] = {
        'next_exploration_ids': next_exploration_ids,
        'completed_exploration_ids': completed_exploration_ids
    }
    collection_dict['version'] = collection.version

    collection_is_public = rights_manager.is_collection_public(collection_id)

    # Insert an 'exploration' dict into each collection node, where the
    # dict includes meta information about the exploration (ID and title).
    for collection_node in collection_dict['nodes']:
        exploration_id = collection_node['exploration_id']
        summary_dict = exp_summaries_dict_map.get(exploration_id)
        if not allow_invalid_explorations:
            if not summary_dict:
                raise utils.ValidationError(
                    'Expected collection to only reference valid '
                    'explorations, but found an exploration with ID: %s (was '
                    'the exploration deleted?)' % exploration_id)
            if collection_is_public and rights_manager.is_exploration_private(
                    exploration_id):
                raise utils.ValidationError(
                    'Cannot reference a private exploration within a public '
                    'collection, exploration ID: %s' % exploration_id)

        collection_node['exploration'] = {
            'exists': bool(summary_dict)
        }
        if summary_dict:
            collection_node['exploration'].update(summary_dict)

    return collection_dict


# Query methods.
def get_collection_titles_and_categories(collection_ids):
    """Returns collection titles and categories for the given ids.

    The result is a dict with collection ids as keys. The corresponding values
    are dicts with the keys 'title' and 'category'.

    Any invalid collection_ids will not be included in the return dict. No
    error will be raised.
    """
    collection_list = [
        (get_collection_from_model(e) if e else None)
        for e in collection_models.CollectionModel.get_multi(collection_ids)]

    result = {}
    for collection in collection_list:
        if collection is None:
            logging.error('Could not find collection corresponding to id')
        else:
            result[collection.id] = {
                'title': collection.title,
                'category': collection.category,
            }
    return result


def get_completed_exploration_ids(user_id, collection_id):
    """Returns a list of explorations the user has completed within the context
    of the provided collection. Returns an empty list if the user has not yet
    completed any explorations within the collection. Note that this function
    will also return an empty list if either the collection and/or user do not
    exist.

    A progress model isn't added until the first exploration of a collection is
    completed, so, if a model is missing, there isn't enough information to
    infer whether that means the collection doesn't exist, the user doesn't
    exist, or if they just haven't mdae any progress in that collection yet.
    Thus, we just assume the user and collection exist for the sake of this
    call, so it returns an empty list, indicating that no progress has yet been
    made.
    """
    progress_model = user_models.CollectionProgressModel.get(
        user_id, collection_id)
    return progress_model.completed_explorations if progress_model else []


def _get_valid_completed_exploration_ids(user_id, collection_id, collection):
    """Returns a filtered version of the return value of
    get_completed_exploration_ids, where explorations not also found within the
    collection are removed from the returned list.
    """
    completed_exploration_ids = get_completed_exploration_ids(
        user_id, collection_id)
    return [
        exp_id for exp_id in completed_exploration_ids
        if collection.get_node(exp_id)
    ]


def get_next_exploration_ids_to_complete_by_user(user_id, collection_id):
    """Returns a list of exploration IDs in the specified collection that the
    given user has not yet attempted and has the prerequisite skills to play.

    Returns the collection's initial explorations if the user has yet to
    complete any explorations within the collection. Returns an empty list if
    the user has completed all of the explorations within the collection.

    See collection_domain.Collection.get_next_exploration_ids for more
    information.
    """
    completed_exploration_ids = get_completed_exploration_ids(
        user_id, collection_id)

    collection = get_collection_by_id(collection_id)
    if completed_exploration_ids:
        return collection.get_next_exploration_ids(completed_exploration_ids)
    else:
        # The user has yet to complete any explorations inside the collection.
        return collection.init_exploration_ids


def record_played_exploration_in_collection_context(
        user_id, collection_id, exploration_id):
    progress_model = user_models.CollectionProgressModel.get_or_create(
        user_id, collection_id)

    if exploration_id not in progress_model.completed_explorations:
        progress_model.completed_explorations.append(exploration_id)
        progress_model.put()


def _get_collection_summary_dicts_from_models(collection_summary_models):
    """Given an iterable of CollectionSummaryModel instances, create a dict
    containing corresponding collection summary domain objects, keyed by id.
    """
    collection_summaries = [
        get_collection_summary_from_model(collection_summary_model)
        for collection_summary_model in collection_summary_models]
    result = {}
    for collection_summary in collection_summaries:
        result[collection_summary.id] = collection_summary
    return result


def get_collection_summaries_matching_ids(collection_ids):
    """Given a list of collection ids, return a list with the corresponding
    summary domain objects (or None if the corresponding summary does not
    exist).
    """
    return [
        (get_collection_summary_from_model(model) if model else None)
        for model in collection_models.CollectionSummaryModel.get_multi(
            collection_ids)]


# TODO(bhenning): Update this function to support also matching the query to
# explorations contained within this collection. Introduce tests to verify this
# behavior.
def get_collection_summaries_matching_query(query_string, cursor=None):
    """Returns a list with all collection summary domain objects matching the
    given search query string, as well as a search cursor for future fetches.

    This method returns exactly feconf.GALLERY_PAGE_SIZE results if there are
    at least that many, otherwise it returns all remaining results. (If this
    behaviour does not occur, an error will be logged.) The method also returns
    a search cursor.
    """
    summary_models = []
    search_cursor = cursor

    for _ in range(MAX_ITERATIONS):
        remaining_to_fetch = feconf.GALLERY_PAGE_SIZE - len(summary_models)

        collection_ids, search_cursor = search_collections(
            query_string, remaining_to_fetch, cursor=search_cursor)

        invalid_collection_ids = []
        for ind, model in enumerate(
                collection_models.CollectionSummaryModel.get_multi(
                    collection_ids)):
            if model is not None:
                summary_models.append(model)
            else:
                invalid_collection_ids.append(collection_ids[ind])

        if len(summary_models) == feconf.GALLERY_PAGE_SIZE or (
                search_cursor is None):
            break
        else:
            logging.error(
                'Search index contains stale collection ids: %s' %
                ', '.join(invalid_collection_ids))

    if (len(summary_models) < feconf.GALLERY_PAGE_SIZE
            and search_cursor is not None):
        logging.error(
            'Could not fulfill search request for query string %s; at least '
            '%s retries were needed.' % (query_string, MAX_ITERATIONS))

    return ([
        get_collection_summary_from_model(summary_model)
        for summary_model in summary_models
    ], search_cursor)


# Repository SAVE and DELETE methods.
def apply_change_list(collection_id, change_list):
    """Applies a changelist to a pristine collection and returns the result.

    Each entry in change_list is a dict that represents an CollectionChange
    object.

    Returns:
      the resulting collection domain object.
    """
    collection = get_collection_by_id(collection_id)
    try:
        changes = [collection_domain.CollectionChange(change_dict)
                   for change_dict in change_list]

        for change in changes:
            if change.cmd == collection_domain.CMD_ADD_COLLECTION_NODE:
                collection.add_node(change.exploration_id)
            elif change.cmd == collection_domain.CMD_DELETE_COLLECTION_NODE:
                collection.delete_node(change.exploration_id)
            elif (
                    change.cmd ==
                    collection_domain.CMD_EDIT_COLLECTION_NODE_PROPERTY):
                collection_node = collection.get_node(change.exploration_id)
                if (change.property_name ==
                        collection_domain.COLLECTION_NODE_PROPERTY_PREREQUISITE_SKILLS): # pylint: disable=line-too-long
                    collection_node.update_prerequisite_skills(
                        change.new_value)
                elif (change.property_name ==
                      collection_domain.COLLECTION_NODE_PROPERTY_ACQUIRED_SKILLS): # pylint: disable=line-too-long
                    collection_node.update_acquired_skills(change.new_value)
            elif change.cmd == collection_domain.CMD_EDIT_COLLECTION_PROPERTY:
                if (change.property_name ==
                        collection_domain.COLLECTION_PROPERTY_TITLE):
                    collection.update_title(change.new_value)
                elif (change.property_name ==
                      collection_domain.COLLECTION_PROPERTY_CATEGORY):
                    collection.update_category(change.new_value)
                elif (change.property_name ==
                      collection_domain.COLLECTION_PROPERTY_OBJECTIVE):
                    collection.update_objective(change.new_value)
            elif (
                    change.cmd ==
                    collection_domain.CMD_MIGRATE_SCHEMA_TO_LATEST_VERSION):
                # Loading the collection model from the datastore into an
                # Collection domain object automatically converts it to use the
                # latest schema version. As a result, simply resaving the
                # collection is sufficient to apply the schema migration.
                continue
        return collection

    except Exception as e:
        logging.error(
            '%s %s %s %s' % (
                e.__class__.__name__, e, collection_id, change_list)
        )
        raise


def validate_exps_in_collection_are_public(collection):
    for exploration_id in collection.exploration_ids:
        if rights_manager.is_exploration_private(exploration_id):
            raise utils.ValidationError(
                'Cannot reference a private exploration within a public '
                'collection, exploration ID: %s' % exploration_id)


def _save_collection(committer_id, collection, commit_message, change_list):
    """Validates an collection and commits it to persistent storage.

    If successful, increments the version number of the incoming collection
    domain object by 1.
    """
    if not change_list:
        raise Exception(
            'Unexpected error: received an invalid change list when trying to '
            'save collection %s: %s' % (collection.id, change_list))

    collection_rights = rights_manager.get_collection_rights(collection.id)
    if collection_rights.status != rights_manager.ACTIVITY_STATUS_PRIVATE:
        collection.validate(strict=True)
    else:
        collection.validate(strict=False)

    # Validate that all explorations referenced by the collection exist.
    exp_ids = collection.exploration_ids
    exp_summaries = (
        exp_services.get_exploration_summaries_matching_ids(exp_ids))
    exp_summaries_dict = {
        exp_id: exp_summaries[ind] for (ind, exp_id) in enumerate(exp_ids)
    }
    for collection_node in collection.nodes:
        if not exp_summaries_dict[collection_node.exploration_id]:
            raise utils.ValidationError(
                'Expected collection to only reference valid explorations, '
                'but found an exploration with ID: %s (was it deleted?)' %
                collection_node.exploration_id)

    # Ensure no explorations are being added that are 'below' the public status
    # of this collection. If the collection is private, it can have both
    # private and public explorations. If it's public, it can only have public
    # explorations.
    # TODO(bhenning): Ensure the latter is enforced above when trying to
    # publish a collection.
    if rights_manager.is_collection_public(collection.id):
        validate_exps_in_collection_are_public(collection)

    collection_model = collection_models.CollectionModel.get(
        collection.id, strict=False)
    if collection_model is None:
        collection_model = collection_models.CollectionModel(id=collection.id)
    else:
        if collection.version > collection_model.version:
            raise Exception(
                'Unexpected error: trying to update version %s of collection '
                'from version %s. Please reload the page and try again.'
                % (collection_model.version, collection.version))
        elif collection.version < collection_model.version:
            raise Exception(
                'Trying to update version %s of collection from version %s, '
                'which is too old. Please reload the page and try again.'
                % (collection_model.version, collection.version))

    collection_model.category = collection.category
    collection_model.title = collection.title
    collection_model.objective = collection.objective
    collection_model.schema_version = collection.schema_version
    collection_model.nodes = [
        collection_node.to_dict() for collection_node in collection.nodes
    ]

    collection_model.commit(committer_id, commit_message, change_list)
    memcache_services.delete(_get_collection_memcache_key(collection.id))
    index_collections_given_ids([collection.id])

    collection.version += 1


def _create_collection(committer_id, collection, commit_message, commit_cmds):
    """Ensures that rights for a new collection are saved first.

    This is because _save_collection() depends on the rights object being
    present to tell it whether to do strict validation or not.
    """
    # This line is needed because otherwise a rights object will be created,
    # but the creation of an collection object will fail.
    collection.validate(strict=False)
    rights_manager.create_new_collection_rights(collection.id, committer_id)
    model = collection_models.CollectionModel(
        id=collection.id,
        category=collection.category,
        title=collection.title,
        objective=collection.objective,
        schema_version=collection.schema_version,
        nodes=[
            collection_node.to_dict() for collection_node in collection.nodes
        ],
    )
    model.commit(committer_id, commit_message, commit_cmds)
    collection.version += 1
    create_collection_summary(collection.id, committer_id)


def save_new_collection(committer_id, collection):
    commit_message = (
        'New collection created with title \'%s\'.' % collection.title)
    _create_collection(committer_id, collection, commit_message, [{
        'cmd': CMD_CREATE_NEW,
        'title': collection.title,
        'category': collection.category,
    }])


def delete_collection(committer_id, collection_id, force_deletion=False):
    """Deletes the collection with the given collection_id.

    IMPORTANT: Callers of this function should ensure that committer_id has
    permissions to delete this collection, prior to calling this function.

    If force_deletion is True the collection and its history are fully deleted
    and are unrecoverable. Otherwise, the collection and all its history are
    marked as deleted, but the corresponding models are still retained in the
    datastore. This last option is the preferred one.
    """
    collection_rights_model = collection_models.CollectionRightsModel.get(
        collection_id)
    collection_rights_model.delete(