# Copyright 2013 Google Inc. 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.

"""Implementation of the review subsystem."""

__author__ = [
    'johncox@google.com (John Cox)',
]

import datetime
import random

from models import counters
from models import custom_modules
from models import entities
from models import student_work
from models import utils
import models.review
from modules.review import domain
from modules.review import peer
from google.appengine.ext import db


# In-process increment-only performance counters.
COUNTER_ADD_REVIEWER_BAD_SUMMARY_KEY = counters.PerfCounter(
    'gcb-pr-add-reviewer-bad-summary-key',
    'number of times add_reviewer() failed due to a bad review summary key')
COUNTER_ADD_REVIEWER_SET_ASSIGNER_KIND_HUMAN = counters.PerfCounter(
    'gcb-pr-add-reviewer-set-assigner-kind-human',
    ("number of times add_reviewer() changed an existing step's assigner_kind "
     'to ASSIGNER_KIND_HUMAN'))
COUNTER_ADD_REVIEWER_CREATE_REVIEW_STEP = counters.PerfCounter(
    'gcb-pr-add-reviewer-create-review-step',
    'number of times add_reviewer() created a new review step')
COUNTER_ADD_REVIEWER_EXPIRED_STEP_REASSIGNED = counters.PerfCounter(
    'gcb-pr-add-reviewer-expired-step-reassigned',
    'number of times add_reviewer() reassigned an expired step')
COUNTER_ADD_REVIEWER_FAILED = counters.PerfCounter(
    'gcb-pr-add-reviewer-failed',
    'number of times add_reviewer() had a fatal error')
COUNTER_ADD_REVIEWER_REMOVED_STEP_UNREMOVED = counters.PerfCounter(
    'gcb-pr-add-reviewer-removed-step-unremoved',
    'number of times add_reviewer() unremoved a removed review step')
COUNTER_ADD_REVIEWER_START = counters.PerfCounter(
    'gcb-pr-add-reviewer-start',
    'number of times add_reviewer() has started processing')
COUNTER_ADD_REVIEWER_SUCCESS = counters.PerfCounter(
    'gcb-pr-add-reviewer-success',
    'number of times add_reviewer() completed successfully')
COUNTER_ADD_REVIEWER_UNREMOVED_STEP_FAILED = counters.PerfCounter(
    'gcb-pr-add-reviewer-unremoved-step-failed',
    ('number of times add_reviewer() failed on an unremoved step with a fatal '
     'error'))

COUNTER_ASSIGNMENT_CANDIDATES_QUERY_RESULTS_RETURNED = counters.PerfCounter(
    'gcb-pr-assignment-candidates-query-results-returned',
    ('number of results returned by the query returned by '
     'get_assignment_candidates_query()'))

COUNTER_DELETE_REVIEWER_ALREADY_REMOVED = counters.PerfCounter(
    'gcb-pr-review-delete-reviewer-already-removed',
    ('number of times delete_reviewer() called on review step with removed '
     'already True'))
COUNTER_DELETE_REVIEWER_FAILED = counters.PerfCounter(
    'gcb-pr-review-delete-reviewer-failed',
    'number of times delete_reviewer() had a fatal error')
COUNTER_DELETE_REVIEWER_START = counters.PerfCounter(
    'gcb-pr-review-delete-reviewer-start',
    'number of times delete_reviewer() has started processing')
COUNTER_DELETE_REVIEWER_STEP_MISS = counters.PerfCounter(
    'gcb-pr-review-delete-reviewer-step-miss',
    'number of times delete_reviewer() found a missing review step')
COUNTER_DELETE_REVIEWER_SUCCESS = counters.PerfCounter(
    'gcb-pr-review-delete-reviewer-success',
    'number of times delete_reviewer() completed successfully')
COUNTER_DELETE_REVIEWER_SUMMARY_MISS = counters.PerfCounter(
    'gcb-pr-review-delete-reviewer-summary-miss',
    'number of times delete_reviewer() found a missing review summary')

COUNTER_EXPIRE_REVIEW_CANNOT_TRANSITION = counters.PerfCounter(
    'gcb-pr-expire-review-cannot-transition',
    ('number of times expire_review() was called on a review step that could '
     'not be transitioned to REVIEW_STATE_EXPIRED'))
COUNTER_EXPIRE_REVIEW_FAILED = counters.PerfCounter(
    'gcb-pr-expire-review-failed',
    'number of times expire_review() had a fatal error')
COUNTER_EXPIRE_REVIEW_START = counters.PerfCounter(
    'gcb-pr-expire-review-start',
    'number of times expire_review() has started processing')
COUNTER_EXPIRE_REVIEW_STEP_MISS = counters.PerfCounter(
    'gcb-pr-expire-review-step-miss',
    'number of times expire_review() found a missing review step')
COUNTER_EXPIRE_REVIEW_SUCCESS = counters.PerfCounter(
    'gcb-pr-expire-review-success',
    'number of times expire_review() completed successfully')
COUNTER_EXPIRE_REVIEW_SUMMARY_MISS = counters.PerfCounter(
    'gcb-pr-expire-review-summary-miss',
    'number of times expire_review() found a missing review summary')

COUNTER_EXPIRE_OLD_REVIEWS_FOR_UNIT_EXPIRE = counters.PerfCounter(
    'gcb-pr-expire-old-reviews-for-unit-expire',
    'number of records expire_old_reviews_for_unit() has expired')
COUNTER_EXPIRE_OLD_REVIEWS_FOR_UNIT_SKIP = counters.PerfCounter(
    'gcb-pr-expire-old-reviews-for-unit-skip',
    ('number of times expire_old_reviews_for_unit() skipped a record due to an '
     'error'))
COUNTER_EXPIRE_OLD_REVIEWS_FOR_UNIT_START = counters.PerfCounter(
    'gcb-pr-expire-old-reviews-for-unit-start',
    'number of times expire_old_reviews_for_unit() has started processing')
COUNTER_EXPIRE_OLD_REVIEWS_FOR_UNIT_SUCCESS = counters.PerfCounter(
    'gcb-pr-expire-old-reviews-for-unit-success',
    'number of times expire_old_reviews_for_unit() completed successfully')

COUNTER_EXPIRY_QUERY_KEYS_RETURNED = counters.PerfCounter(
    'gcb-pr-expiry-query-keys-returned',
    'number of keys returned by the query returned by get_expiry_query()')

COUNTER_GET_NEW_REVIEW_ALREADY_ASSIGNED = counters.PerfCounter(
    'gcb-pr-get-new-review-already-assigned',
    ('number of times get_new_review() rejected a candidate because the '
     'reviewer is already assigned to or has already completed it'))
COUNTER_GET_NEW_REVIEW_ASSIGNMENT_ATTEMPTED = counters.PerfCounter(
    'gcb-pr-get-new-review-assignment-attempted',
    'number of times get_new_review() attempted to assign a candidate')
COUNTER_GET_NEW_REVIEW_CANNOT_UNREMOVE_COMPLETED = counters.PerfCounter(
    'gcb-pr-get-new-review-cannot-unremove-completed',
    ('number of times get_new_review() failed because the reviewer already had '
     'a completed, removed review step'))
COUNTER_GET_NEW_REVIEW_FAILED = counters.PerfCounter(
    'gcb-pr-get-new-review-failed',
    'number of times get_new_review() had a fatal error')
COUNTER_GET_NEW_REVIEW_NOT_ASSIGNABLE = counters.PerfCounter(
    'gcb-pr-get-new-review-none-assignable',
    'number of times get_new_review() failed to find an assignable review')
COUNTER_GET_NEW_REVIEW_REASSIGN_EXISTING = counters.PerfCounter(
    'gcb-pr-get-new-review-reassign-existing',
    ('number of times get_new_review() unremoved and reassigned an existing '
     'review step'))
COUNTER_GET_NEW_REVIEW_START = counters.PerfCounter(
    'gcb-pr-get-new-review-start',
    'number of times get_new_review() has started processing')
COUNTER_GET_NEW_REVIEW_SUCCESS = counters.PerfCounter(
    'gcb-pr-get-new-review-success',
    'number of times get_new_review() found and assigned a new review')
COUNTER_GET_NEW_REVIEW_SUMMARY_CHANGED = counters.PerfCounter(
    'gcb-pr-get-new-review-summary-changed',
    ('number of times get_new_review() rejected a candidate because the review '
     'summary changed during processing'))

COUNTER_GET_REVIEW_STEP_KEYS_BY_KEYS_RETURNED = counters.PerfCounter(
    'gcb-pr-get-review-step-keys-by-keys-returned',
    'number of keys get_review_step_keys_by() returned')
COUNTER_GET_REVIEW_STEP_KEYS_BY_FAILED = counters.PerfCounter(
    'gcb-pr-get-review-step-keys-by-failed',
    'number of times get_review_step_keys_by() had a fatal error')
COUNTER_GET_REVIEW_STEP_KEYS_BY_START = counters.PerfCounter(
    'gcb-pr-get-review-step-keys-by-start',
    'number of times get_review_step_keys_by() started processing')
COUNTER_GET_REVIEW_STEP_KEYS_BY_SUCCESS = counters.PerfCounter(
    'gcb-pr-get-review-step-keys-by-success',
    'number of times get_review_step_keys_by() completed successfully')

COUNTER_GET_SUBMISSION_AND_REVIEW_STEP_KEYS_FAILED = counters.PerfCounter(
    'gcb-pr-get-submission-and-review-step-keys-failed',
    'number of times get_submission_and_review_step_keys() had a fatal error')
COUNTER_GET_SUBMISSION_AND_REVIEW_STEP_KEYS_RETURNED = counters.PerfCounter(
    'gcb-pr-get-submission-and-review-step-keys-keys-returned',
    'number of keys get_submission_and_review_step_keys() returned')
COUNTER_GET_SUBMISSION_AND_REVIEW_STEP_KEYS_START = counters.PerfCounter(
    'gcb-pr-get-submission-and-review-step-keys-start',
    ('number of times get_submission_and_review_step_keys() has begun '
     'processing'))
COUNTER_GET_SUBMISSION_AND_REVIEW_STEP_KEYS_SUBMISSION_MISS = (
    counters.PerfCounter(
        'gcb-pr-get-submission-and-review-step-keys-submission-miss',
        ('number of times get_submission_and_review_step_keys() failed to find '
         'a submission_key')))
COUNTER_GET_SUBMISSION_AND_REVIEW_STEP_KEYS_SUCCESS = counters.PerfCounter(
    'gcb-pr-get-submission-and-review-step_keys-success',
    ('number of times get_submission-and-review-step-keys() completed '
     'successfully'))

COUNTER_START_REVIEW_PROCESS_FOR_ALREADY_STARTED = counters.PerfCounter(
    'gcb-pr-start-review-process-for-already-started',
    ('number of times start_review_process_for() called when review already '
     'started'))
COUNTER_START_REVIEW_PROCESS_FOR_FAILED = counters.PerfCounter(
    'gcb-pr-start-review-process-for-failed',
    'number of times start_review_process_for() had a fatal error')
COUNTER_START_REVIEW_PROCESS_FOR_START = counters.PerfCounter(
    'gcb-pr-start-review-process-for-start',
    'number of times start_review_process_for() has started processing')
COUNTER_START_REVIEW_PROCESS_FOR_SUCCESS = counters.PerfCounter(
    'gcb-pr-start-review-process-for-success',
    'number of times start_review_process_for() completed successfully')

COUNTER_WRITE_REVIEW_COMPLETED_ASSIGNED_STEP = counters.PerfCounter(
    'gcb-pr-write-review-completed-assigned-step',
    'number of times write_review() transitioned an assigned step to completed')
COUNTER_WRITE_REVIEW_COMPLETED_EXPIRED_STEP = counters.PerfCounter(
    'gcb-pr-write-review-completed-expired-step',
    'number of times write_review() transitioned an expired step to completed')
COUNTER_WRITE_REVIEW_CREATED_NEW_REVIEW = counters.PerfCounter(
    'gcb-pr-write-review-created-new-review',
    'number of times write_review() created a new review')
COUNTER_WRITE_REVIEW_FAILED = counters.PerfCounter(
    'gcb-pr-write-review-failed',
    'number of times write_review() had a fatal error')
COUNTER_WRITE_REVIEW_REVIEW_MISS = counters.PerfCounter(
    'gcb-pr-write-review-review-miss',
    'number of times write_review() found a missing review')
COUNTER_WRITE_REVIEW_START = counters.PerfCounter(
    'gcb-pr-write-review-start',
    'number of times write_review() started processing')
COUNTER_WRITE_REVIEW_STEP_MISS = counters.PerfCounter(
    'gcb-pr-write-review-step-miss',
    'number of times write_review() found a missing review step')
COUNTER_WRITE_REVIEW_SUMMARY_MISS = counters.PerfCounter(
    'gcb-pr-write-review-summary-miss',
    'number of times write_review() found a missing review summary')
COUNTER_WRITE_REVIEW_SUCCESS = counters.PerfCounter(
    'gcb-pr-write-review-success',
    'number of times write_review() completed successfully')
COUNTER_WRITE_REVIEW_UPDATED_EXISTING_REVIEW = counters.PerfCounter(
    'gcb-pr-write-review-updated-existing-review',
    'number of times write_review() updated an existing review')


# Number of entities to fetch when querying for all review steps that meet
# given criteria. Ideally we'd cursor through results rather than setting a
# ceiling, but for now let's allow as many removed results as unremoved.
_REVIEW_STEP_QUERY_LIMIT = 2 * domain.MAX_UNREMOVED_REVIEW_STEPS


class Manager(object):
    """Object that manages the review subsystem."""

    @classmethod
    def add_reviewer(cls, unit_id, submission_key, reviewee_key, reviewer_key):
        """Adds a reviewer for a submission.

        If there is no pre-existing review step, one will be created.

        Attempting to add an existing unremoved step in REVIEW_STATE_ASSIGNED or
        REVIEW_STATE_COMPLETED is an error.

        If there is an existing unremoved review in REVIEW_STATE_EXPIRED, it
        will be put in REVIEW_STATE_ASSIGNED. If there is a removed review in
        REVIEW_STATE_ASSIGNED or REVIEW_STATE_EXPIRED, it will be put in
        REVIEW_STATE_ASSIGNED and unremoved. If it is in REVIEW_STATE_COMPLETED,
        it will be unremoved but its state will not change. In all these cases
        the assigner kind will be set to ASSIGNER_KIND_HUMAN.

        Args:
            unit_id: string. Unique identifier for a unit.
            submission_key: db.Key of models.student_work.Submission. The
                submission being registered.
            reviewee_key: db.Key of models.models.Student. The student who
                authored the submission.
            reviewer_key: db.Key of models.models.Student. The student to add as
                a reviewer.

        Raises:
            domain.TransitionError: if there is a pre-existing review step found
                in domain.REVIEW_STATE_ASSIGNED|COMPLETED.

        Returns:
            db.Key of written review step.
        """
        try:
            COUNTER_ADD_REVIEWER_START.inc()
            key = cls._add_reviewer(
                unit_id, submission_key, reviewee_key, reviewer_key)
            COUNTER_ADD_REVIEWER_SUCCESS.inc()
            return key
        except Exception as e:
            COUNTER_ADD_REVIEWER_FAILED.inc()
            raise e

    @classmethod
    @db.transactional(xg=True)
    def _add_reviewer(cls, unit_id, submission_key, reviewee_key, reviewer_key):
        found = peer.ReviewStep.get_by_key_name(
            peer.ReviewStep.key_name(submission_key, reviewer_key))
        if not found:
            return cls._add_new_reviewer(
                unit_id, submission_key, reviewee_key, reviewer_key)
        else:
            return cls._add_reviewer_update_step(found)

    @classmethod
    def _add_new_reviewer(
        cls, unit_id, submission_key, reviewee_key, reviewer_key):
        summary = peer.ReviewSummary(
            assigned_count=1, reviewee_key=reviewee_key,
            submission_key=submission_key, unit_id=unit_id)
        # Synthesize summary key to avoid a second synchronous put op.
        summary_key = db.Key.from_path(
            peer.ReviewSummary.kind(),
            peer.ReviewSummary.key_name(submission_key))
        step = peer.ReviewStep(
            assigner_kind=domain.ASSIGNER_KIND_HUMAN,
            review_summary_key=summary_key, reviewee_key=reviewee_key,
            reviewer_key=reviewer_key, state=domain.REVIEW_STATE_ASSIGNED,
            submission_key=submission_key, unit_id=unit_id)
        step_key, written_summary_key = entities.put([step, summary])

        if summary_key != written_summary_key:
            COUNTER_ADD_REVIEWER_BAD_SUMMARY_KEY.inc()
            raise AssertionError(
                'Synthesized invalid review summary key %s' % repr(summary_key))

        COUNTER_ADD_REVIEWER_CREATE_REVIEW_STEP.inc()
        return step_key

    @classmethod
    def _add_reviewer_update_step(cls, step):
        should_increment_human = False
        should_increment_reassigned = False
        should_increment_unremoved = False
        summary = entities.get(step.review_summary_key)

        if not summary:
            COUNTER_ADD_REVIEWER_BAD_SUMMARY_KEY.inc()
            raise AssertionError(
                'Found invalid review summary key %s' % repr(
                    step.review_summary_key))

        if not step.removed:

            if step.state == domain.REVIEW_STATE_EXPIRED:
                should_increment_reassigned = True
                step.state = domain.REVIEW_STATE_ASSIGNED
                summary.decrement_count(domain.REVIEW_STATE_EXPIRED)
                summary.increment_count(domain.REVIEW_STATE_ASSIGNED)
            elif (step.state == domain.REVIEW_STATE_ASSIGNED or
                  step.state == domain.REVIEW_STATE_COMPLETED):
                COUNTER_ADD_REVIEWER_UNREMOVED_STEP_FAILED.inc()
                raise domain.TransitionError(
                    'Unable to add new reviewer to step %s' % (
                        repr(step.key())),
                    step.state, domain.REVIEW_STATE_ASSIGNED)
        else:
            should_increment_unremoved = True
            step.removed = False

            if step.state != domain.REVIEW_STATE_EXPIRED:
                summary.increment_count(step.state)
            else:
                should_increment_reassigned = True
                step.state = domain.REVIEW_STATE_ASSIGNED
                summary.decrement_count(domain.REVIEW_STATE_EXPIRED)
                summary.increment_count(domain.REVIEW_STATE_ASSIGNED)

        if step.assigner_kind != domain.ASSIGNER_KIND_HUMAN:
            should_increment_human = True
            step.assigner_kind = domain.ASSIGNER_KIND_HUMAN

        step_key = entities.put([step, summary])[0]

        if should_increment_human:
            COUNTER_ADD_REVIEWER_SET_ASSIGNER_KIND_HUMAN.inc()
        if should_increment_reassigned:
            COUNTER_ADD_REVIEWER_EXPIRED_STEP_REASSIGNED.inc()
        if should_increment_unremoved:
            COUNTER_ADD_REVIEWER_REMOVED_STEP_UNREMOVED.inc()

        return step_key

    @classmethod
    def delete_reviewer(cls, review_step_key):
        """Deletes the given review step.

        We do not physically delete the review step; we mark it as removed,
        meaning it will be ignored from most queries and the associated review
        summary will have its corresponding state count decremented. Calling
        this method on a removed review step is an error.

        Args:
            review_step_key: db.Key of models.student_work.ReviewStep. The
                review step to delete.

        Raises:
            domain.RemovedError: if called on a review step that has already
                been marked removed.
            KeyError: if there is no review step with the given key, or if the
                step references a review summary that does not exist.

        Returns:
            db.Key of deleted review step.
        """
        try:
            COUNTER_DELETE_REVIEWER_START.inc()
            key = cls._mark_review_step_removed(review_step_key)
            COUNTER_DELETE_REVIEWER_SUCCESS.inc()
            return key
        except Exception as e:
            COUNTER_DELETE_REVIEWER_FAILED.inc()
            raise e

    @classmethod
    @db.transactional(xg=True)
    def _mark_review_step_removed(cls, review_step_key):
        step = entities.get(review_step_key)
        if not step:
            COUNTER_DELETE_REVIEWER_STEP_MISS.inc()
            raise KeyError(
                'No review step found with key %s' % repr(review_step_key))
        if step.removed:
            COUNTER_DELETE_REVIEWER_ALREADY_REMOVED.inc()
            raise domain.RemovedError(
                'Cannot remove step %s' % repr(review_step_key), step.removed)
        summary = entities.get(step.review_summary_key)

        if not summary:
            COUNTER_DELETE_REVIEWER_SUMMARY_MISS.inc()
            raise KeyError(
                'No review summary found with key %s' % repr(
                    step.review_summary_key))

        step.removed = True
        summary.decrement_count(step.state)
        return entities.put([step, summary])[0]

    @classmethod
    def expire_review(cls, review_step_key):
        """Puts a review step in state REVIEW_STATE_EXPIRED.

        Args:
            review_step_key: db.Key of models.student_work.ReviewStep. The
                review step to expire.

        Raises:
            domain.RemovedError: if called on a step that is removed.
            domain.TransitionError: if called on a review step that cannot be
                transitioned to REVIEW_STATE_EXPIRED (that is, it is already in
                REVIEW_STATE_COMPLETED or REVIEW_STATE_EXPIRED).
            KeyError: if there is no review with the given key, or the step
                references a review summary that does not exist.

        Returns:
            db.Key of the expired review step.
        """
        try:
            COUNTER_EXPIRE_REVIEW_START.inc()
            key = cls._transition_state_to_expired(review_step_key)
            COUNTER_EXPIRE_REVIEW_SUCCESS.inc()
            return key
        except Exception as e:
            COUNTER_EXPIRE_REVIEW_FAILED.inc()
            raise e

    @classmethod
    @db.transactional(xg=True)
    def _transition_state_to_expired(cls, review_step_key):
        step = entities.get(review_step_key)

        if not step:
            COUNTER_EXPIRE_REVIEW_STEP_MISS.inc()
            raise KeyError(
                'No review step found with key %s' % repr(review_step_key))

        if step.removed:
            COUNTER_EXPIRE_REVIEW_CANNOT_TRANSITION.inc()
            raise domain.RemovedError(
                'Cannot transition step %s' % repr(review_step_key),
                step.removed)

        if step.state in (
                domain.REVIEW_STATE_COMPLETED, domain.REVIEW_STATE_EXPIRED):
            COUNTER_EXPIRE_REVIEW_CANNOT_TRANSITION.inc()
            raise domain.TransitionError(
                'Cannot transition step %s' % repr(review_step_key),
                step.state, domain.REVIEW_STATE_EXPIRED)

        summary = entities.get(step.review_summary_key)

        if not summary:
            COUNTER_EXPIRE_REVIEW_SUMMARY_MISS.inc()
            raise KeyError(
                'No review summary found with key %s' % repr(
                    step.review_summary_key))

        summary.decrement_count(step.state)
        step.state = domain.REVIEW_STATE_EXPIRED
        summary.increment_count(step.state)
        return entities.put([step, summary])[0]

    @classmethod