"""This file contains (or should), all access control logic for the courseware.
Ideally, it will be the only place that needs to know about any special settings
like DISABLE_START_DATES"""

import logging
import time

from django.conf import settings
from django.contrib.auth.models import Group

from xmodule.course_module import CourseDescriptor
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore import Location
from xmodule.x_module import XModule, XModuleDescriptor

DEBUG_ACCESS = False

log = logging.getLogger(__name__)


class CourseContextRequired(Exception):
    """
    Raised when a course_context is required to determine permissions
    """
    pass


def debug(*args, **kwargs):
    # to avoid overly verbose output, this is off by default
    if DEBUG_ACCESS:
        log.debug(*args, **kwargs)


def has_access(user, obj, action, course_context=None):
    """
    Check whether a user has the access to do action on obj.  Handles any magic
    switching based on various settings.

    Things this module understands:
    - start dates for modules
    - DISABLE_START_DATES
    - different access for instructor, staff, course staff, and students.

    user: a Django user object. May be anonymous.

    obj: The object to check access for.  For now, a module or descriptor.

    action: A string specifying the action that the client is trying to perform.

    actions depend on the obj type, but include e.g. 'enroll' for courses.  See the
    type-specific functions below for the known actions for that type.

    course_context: A course_id specifying which course run this access is for.
        Required when accessing anything other than a CourseDescriptor, 'global',
        or a location with category 'course'

    Returns a bool.  It is up to the caller to actually deny access in a way
    that makes sense in context.
    """
    # delegate the work to type-specific functions.
    # (start with more specific types, then get more general)
    if isinstance(obj, CourseDescriptor):
        return _has_access_course_desc(user, obj, action)

    if isinstance(obj, ErrorDescriptor):
        return _has_access_error_desc(user, obj, action, course_context)

    # NOTE: any descriptor access checkers need to go above this
    if isinstance(obj, XModuleDescriptor):
        return _has_access_descriptor(user, obj, action, course_context)

    if isinstance(obj, XModule):
        return _has_access_xmodule(user, obj, action, course_context)

    if isinstance(obj, Location):
        return _has_access_location(user, obj, action, course_context)

    if isinstance(obj, basestring):
        return _has_access_string(user, obj, action, course_context)

    # Passing an unknown object here is a coding error, so rather than
    # returning a default, complain.
    raise TypeError("Unknown object type in has_access(): '{0}'"
                    .format(type(obj)))


def get_access_group_name(obj, action):
    '''
    Returns group name for user group which has "action" access to the given object.

    Used in managing access lists.
    '''

    if isinstance(obj, CourseDescriptor):
        return _get_access_group_name_course_desc(obj, action)

    # Passing an unknown object here is a coding error, so rather than
    # returning a default, complain.
    raise TypeError("Unknown object type in get_access_group_name(): '{0}'"
                    .format(type(obj)))


# ================ Implementation helpers ================================
def _has_access_course_desc(user, course, action):
    """
    Check if user has access to a course descriptor.

    Valid actions:

    'load' -- load the courseware, see inside the course
    'enroll' -- enroll.  Checks for enrollment window,
                  ACCESS_REQUIRE_STAFF_FOR_COURSE,
    'see_exists' -- can see that the course exists.
    'staff' -- staff access to course.
    """
    def can_load():
        """
        Can this user load this course?

        NOTE: this is not checking whether user is actually enrolled in the course.
        """
        # delegate to generic descriptor check to check start dates
        return _has_access_descriptor(user, course, 'load')

    def can_enroll():
        """
        If the course has an enrollment period, check whether we are in it.
        (staff can always enroll)
        """

        now = time.gmtime()
        start = course.enrollment_start
        end = course.enrollment_end

        if (start is None or now > start) and (end is None or now < end):
            # in enrollment period, so any user is allowed to enroll.
            debug("Allow: in enrollment period")
            return True

        # otherwise, need staff access
        return _has_staff_access_to_descriptor(user, course)

    def see_exists():
        """
        Can see if can enroll, but also if can load it: if user enrolled in a course and now
        it's past the enrollment period, they should still see it.

        TODO (vshnayder): This means that courses with limited enrollment periods will not appear
        to non-staff visitors after the enrollment period is over.  If this is not what we want, will
        need to change this logic.
        """
        # VS[compat] -- this setting should go away once all courses have
        # properly configured enrollment_start times (if course should be
        # staff-only, set enrollment_start far in the future.)
        if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
            # if this feature is on, only allow courses that have ispublic set to be
            # seen by non-staff
            if course.metadata.get('ispublic'):
                debug("Allow: ACCESS_REQUIRE_STAFF_FOR_COURSE and ispublic")
                return True
            return _has_staff_access_to_descriptor(user, course)

        return can_enroll() or can_load()

    checkers = {
        'load': can_load,
        'enroll': can_enroll,
        'see_exists': see_exists,
        'staff': lambda: _has_staff_access_to_descriptor(user, course),
        'instructor': lambda: _has_instructor_access_to_descriptor(user, course),
        }

    return _dispatch(checkers, action, user, course)


def _get_access_group_name_course_desc(course, action):
    '''
    Return name of group which gives staff access to course.  Only understands action = 'staff'
    '''
    if not action == 'staff':
        return []
    return _course_staff_group_name(course.location)


def _has_access_error_desc(user, descriptor, action, course_context):
    """
    Only staff should see error descriptors.

    Valid actions:
    'load' -- load this descriptor, showing it to the user.
    'staff' -- staff access to descriptor.
    """
    def check_for_staff():
        return _has_staff_access_to_descriptor(user, descriptor, course_context)

    checkers = {
        'load': check_for_staff,
        'staff': check_for_staff
        }

    return _dispatch(checkers, action, user, descriptor)


def _has_access_descriptor(user, descriptor, action, course_context=None):
    """
    Check if user has access to this descriptor.

    Valid actions:
    'load' -- load this descriptor, showing it to the user.
    'staff' -- staff access to descriptor.

    NOTE: This is the fallback logic for descriptors that don't have custom policy
    (e.g. courses).  If you call this method directly instead of going through
    has_access(), it will not do the right thing.
    """
    def can_load():
        """
        NOTE: This does not check that the student is enrolled in the course
        that contains this module.  We may or may not want to allow non-enrolled
        students to see modules.  If not, views should check the course, so we
        don't have to hit the enrollments table on every module load.
        """
        # If start dates are off, can always load
        if settings.MITX_FEATURES['DISABLE_START_DATES']:
            debug("Allow: DISABLE_START_DATES")
            return True

        # Check start date
        if descriptor.start is not None:
            now = time.gmtime()
            if now > descriptor.start:
                # after start date, everyone can see it
                debug("Allow: now > start date")
                return True
            # otherwise, need staff access
            return _has_staff_access_to_descriptor(user, descriptor, course_context)

        # No start date, so can always load.
        debug("Allow: no start date")
        return True

    checkers = {
        'load': can_load,
        'staff': lambda: _has_staff_access_to_descriptor(user, descriptor, course_context)
        }

    return _dispatch(checkers, action, user, descriptor)


def _has_access_xmodule(user, xmodule, action, course_context):
    """
    Check if user has access to this xmodule.

    Valid actions:
      - same as the valid actions for xmodule.descriptor
    """
    # Delegate to the descriptor
    return has_access(user, xmodule.descriptor, action, course_context)


def _has_access_location(user, location, action, course_context):
    """
    Check if user has access to this location.

    Valid actions:
    'staff' : True if the user has staff access to this location

    NOTE: if you add other actions, make sure that

     has_access(user, location, action) == has_access(user, get_item(location), action)

    And in general, prefer checking access on loaded items, rather than locations.
    """
    checkers = {
        'staff': lambda: _has_staff_access_to_location(user, location, course_context)
        }

    return _dispatch(checkers, action, user, location)


def _has_access_string(user, perm, action, course_context):
    """
    Check if user has certain special access, specified as string.  Valid strings:

    'global'

    Valid actions:

    'staff' -- global staff access.
    """

    def check_staff():
        if perm != 'global':
            debug("Deny: invalid permission '%s'", perm)
            return False
        return _has_global_staff_access(user)

    checkers = {
        'staff': check_staff
        }

    return _dispatch(checkers, action, user, perm)


#####  Internal helper methods below

def _dispatch(table, action, user, obj):
    """
    Helper: call table[action], raising a nice pretty error if there is no such key.

    user and object passed in only for error messages and debugging
    """
    if action in table:
        result = table[action]()
        debug("%s user %s, object %s, action %s",
              'ALLOWED' if result else 'DENIED',
              user,
              obj.location.url() if isinstance(obj, XModuleDescriptor) else str(obj)[:60],
              action)
        return result

    raise ValueError("Unknown action for object type '{0}': '{1}'".format(
        type(obj), action))


def _does_course_group_name_exist(name):
    return len(Group.objects.filter(name=name)) > 0


def _course_staff_group_name(location, course_context=None):
    """
    Get the name of the staff group for a location in the context of a course run.

    location: something that can passed to Location
    course_context: A course_id that specifies the course run in which the location occurs.
        Required if location doesn't have category 'course'

    cdodge: We're changing the name convention of the group to better epxress different runs of courses by
    using course_id rather than just the course number. So first check to see if the group name exists
    """
    loc = Location(location)
    legacy_name = 'staff_%s' % loc.course
    if _does_course_group_name_exist(legacy_name):
        return legacy_name

    if loc.category == 'course':
        course_id = loc.course_id
    else:
        if course_context is None:
            raise CourseContextRequired()
        course_id = course_context

    return 'staff_%s' % course_id


def _course_instructor_group_name(location, course_context=None):
    """
    Get the name of the instructor group for a location, in the context of a course run.
    A course instructor has all staff privileges, but also can manage list of course staff (add, remove, list).

    location: something that can passed to Location.
    course_context: A course_id that specifies the course run in which the location occurs.
        Required if location doesn't have category 'course'

    cdodge: We're changing the name convention of the group to better epxress different runs of courses by
    using course_id rather than just the course number. So first check to see if the group name exists
    """
    loc = Location(location)
    legacy_name = 'instructor_%s' % loc.course
    if _does_course_group_name_exist(legacy_name):
        return legacy_name

    if loc.category == 'course':
        course_id = loc.course_id
    else:
        if course_context is None:
            raise CourseContextRequired()
        course_id = course_context

    return 'instructor_%s' % course_id


def _has_global_staff_access(user):
    if user.is_staff:
        debug("Allow: user.is_staff")
        return True
    else:
        debug("Deny: not user.is_staff")
        return False


def _has_instructor_access_to_location(user, location, course_context=None):
    return _has_access_to_location(user, location, 'instructor', course_context)


def _has_staff_access_to_location(user, location, course_context=None):
    return _has_access_to_location(user, location, 'staff', course_context)


def _has_access_to_location(user, location, access_level, course_context):
    '''
    Returns True if the given user has access_level (= staff or
    instructor) access to a location.  For now this is equivalent to
    having staff / instructor access to the course location.course.

    This means that user is in the staff_* group or instructor_* group, or is an overall admin.

    TODO (vshnayder): this needs to be changed to allow per-course_id permissions, not per-course
    (e.g. staff in 2012 is different from 2013, but maybe some people always have access)

    course is a string: the course field of the location being accessed.
    location = location
    access_level = string, either "staff" or "instructor"
    '''
    if user is None or (not user.is_authenticated()):
        debug("Deny: no user or anon user")
        return False
    if user.is_staff:
        debug("Allow: user.is_staff")
        return True

    # If not global staff, is the user in the Auth group for this class?
    user_groups = [g.name for g in user.groups.all()]

    if access_level == 'staff':
        staff_group = _course_staff_group_name(location, course_context)
        if staff_group in user_groups:
            debug("Allow: user in group %s", staff_group)
            return True
        debug("Deny: user not in group %s", staff_group)

    if access_level == 'instructor' or access_level == 'staff': 	# instructors get staff privileges
        instructor_group = _course_instructor_group_name(location, course_context)
        if instructor_group in user_groups:
            debug("Allow: user in group %s", instructor_group)
            return True
        debug("Deny: user not in group %s", instructor_group)

    else:
        log.debug("Error in access._has_access_to_location access_level=%s unknown" % access_level)

    return False


def _has_staff_access_to_course_id(user, course_id):
    """Helper method that takes a course_id instead of a course name"""
    loc = CourseDescriptor.id_to_location(course_id)
    return _has_staff_access_to_location(user, loc, course_id)


def _has_instructor_access_to_descriptor(user, descriptor, course_context=None):
    """Helper method that checks whether the user has staff access to
    the course of the location.

    descriptor: something that has a location attribute
    """
    return _has_instructor_access_to_location(user, descriptor.location, course_context)


def _has_staff_access_to_descriptor(user, descriptor, course_context=None):
    """Helper method that checks whether the user has staff access to
    the course of the location.

    descriptor: something that has a location attribute
    """
    return _has_staff_access_to_location(user, descriptor.location, course_context)
