"""
Run these tests @ Devstack:
    paver test_system -s lms --fasttest --verbose --test_id=lms/djangoapps/course_structure_api
"""
# pylint: disable=missing-docstring,invalid-name,maybe-no-member,attribute-defined-outside-init
from abc import ABCMeta
from datetime import datetime
from mock import patch, Mock
from itertools import product

from django.core.urlresolvers import reverse

from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
from opaque_keys.edx.locator import CourseLocator
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
from xmodule.modulestore.xml import CourseLocationManager
from xmodule.tests import get_test_system

from student.tests.factories import UserFactory, CourseEnrollmentFactory
from courseware.tests.factories import GlobalStaffFactory, StaffFactory
from openedx.core.djangoapps.content.course_structures.models import CourseStructure
from openedx.core.djangoapps.content.course_structures.tasks import update_course_structure


TEST_SERVER_HOST = 'http://testserver'


class CourseViewTestsMixin(object):
    """
    Mixin for course view tests.
    """
    view = None

    raw_grader = [
        {
            "min_count": 24,
            "weight": 0.2,
            "type": "Homework",
            "drop_count": 0,
            "short_label": "HW"
        },
        {
            "min_count": 4,
            "weight": 0.8,
            "type": "Exam",
            "drop_count": 0,
            "short_label": "Exam"
        }
    ]

    def setUp(self):
        super(CourseViewTestsMixin, self).setUp()
        self.create_user_and_access_token()

    def create_user(self):
        self.user = GlobalStaffFactory.create()

    def create_user_and_access_token(self):
        self.create_user()
        self.oauth_client = ClientFactory.create()
        self.access_token = AccessTokenFactory.create(user=self.user, client=self.oauth_client).token

    @classmethod
    def create_course_data(cls):
        cls.invalid_course_id = 'foo/bar/baz'
        cls.course = CourseFactory.create(display_name='An Introduction to API Testing', raw_grader=cls.raw_grader)
        cls.course_id = unicode(cls.course.id)
        with cls.store.bulk_operations(cls.course.id, emit_signals=False):
            cls.sequential = ItemFactory.create(
                category="sequential",
                parent_location=cls.course.location,
                display_name="Lesson 1",
                format="Homework",
                graded=True
            )

            factory = MultipleChoiceResponseXMLFactory()
            args = {'choices': [False, True, False]}
            problem_xml = factory.build_xml(**args)
            cls.problem = ItemFactory.create(
                category="problem",
                parent_location=cls.sequential.location,
                display_name="Problem 1",
                format="Homework",
                data=problem_xml,
            )

            cls.video = ItemFactory.create(
                category="video",
                parent_location=cls.sequential.location,
                display_name="Video 1",
            )

            cls.html = ItemFactory.create(
                category="html",
                parent_location=cls.sequential.location,
                display_name="HTML 1",
            )

        cls.empty_course = CourseFactory.create(
            start=datetime(2014, 6, 16, 14, 30),
            end=datetime(2015, 1, 16),
            org="MTD",
            # Use mongo so that we can get a test with a SlashSeparatedCourseKey
            default_store=ModuleStoreEnum.Type.mongo
        )

    def build_absolute_url(self, path=None):
        """ Build absolute URL pointing to test server.
        :param path: Path to append to the URL
        """
        url = TEST_SERVER_HOST

        if path:
            url += path

        return url

    def assertValidResponseCourse(self, data, course):
        """ Determines if the given response data (dict) matches the specified course. """

        course_key = course.id
        self.assertEqual(data['id'], unicode(course_key))
        self.assertEqual(data['name'], course.display_name)
        self.assertEqual(data['course'], course_key.course)
        self.assertEqual(data['org'], course_key.org)
        self.assertEqual(data['run'], course_key.run)

        uri = self.build_absolute_url(
            reverse('course_structure_api:v0:detail', kwargs={'course_id': unicode(course_key)}))
        self.assertEqual(data['uri'], uri)

    def http_get(self, uri, **headers):
        """Submit an HTTP GET request"""

        default_headers = {
            'HTTP_AUTHORIZATION': 'Bearer ' + self.access_token
        }
        default_headers.update(headers)

        response = self.client.get(uri, follow=True, **default_headers)
        return response

    def http_get_for_course(self, course_id=None, **headers):
        """Submit an HTTP GET request to the view for the given course"""

        return self.http_get(
            reverse(self.view, kwargs={'course_id': course_id or self.course_id}),
            **headers
        )

    def test_not_authenticated(self):
        """
        Verify that access is denied to non-authenticated users.
        """
        raise NotImplementedError

    def test_not_authorized(self):
        """
        Verify that access is denied to non-authorized users.
        """
        raise NotImplementedError


class CourseDetailTestMixin(object):
    """
    Mixin for views utilizing only the course_id kwarg.
    """
    view_supports_debug_mode = True

    def test_get_invalid_course(self):
        """
        The view should return a 404 if the course ID is invalid.
        """
        response = self.http_get_for_course(self.invalid_course_id)
        self.assertEqual(response.status_code, 404)

    def test_get(self):
        """
        The view should return a 200 if the course ID is valid.
        """
        response = self.http_get_for_course()
        self.assertEqual(response.status_code, 200)

        # Return the response so child classes do not have to repeat the request.
        return response

    def test_not_authenticated(self):
        """ The view should return HTTP status 401 if no user is authenticated. """
        # HTTP 401 should be returned if the user is not authenticated.
        response = self.http_get_for_course(HTTP_AUTHORIZATION=None)
        self.assertEqual(response.status_code, 401)

    def test_not_authorized(self):
        user = StaffFactory(course_key=self.course.id)
        access_token = AccessTokenFactory.create(user=user, client=self.oauth_client).token
        auth_header = 'Bearer ' + access_token

        # Access should be granted if the proper access token is supplied.
        response = self.http_get_for_course(HTTP_AUTHORIZATION=auth_header)
        self.assertEqual(response.status_code, 200)

        # Access should be denied if the user is not course staff.
        response = self.http_get_for_course(course_id=unicode(self.empty_course.id), HTTP_AUTHORIZATION=auth_header)
        self.assertEqual(response.status_code, 404)


class CourseListTests(CourseViewTestsMixin, SharedModuleStoreTestCase):
    view = 'course_structure_api:v0:list'

    @classmethod
    def setUpClass(cls):
        super(CourseListTests, cls).setUpClass()
        cls.create_course_data()

    def test_get(self):
        """
        The view should return a list of all courses.
        """
        response = self.http_get(reverse(self.view))
        self.assertEqual(response.status_code, 200)
        data = response.data
        courses = data['results']
        self.assertEqual(len(courses), 2)
        self.assertEqual(data['count'], 2)
        self.assertEqual(data['num_pages'], 1)

        self.assertValidResponseCourse(courses[0], self.empty_course)
        self.assertValidResponseCourse(courses[1], self.course)

    def test_get_with_pagination(self):
        """
        The view should return a paginated list of courses.
        """
        url = "{}?page_size=1".format(reverse(self.view))
        response = self.http_get(url)
        self.assertEqual(response.status_code, 200)

        courses = response.data['results']
        self.assertEqual(len(courses), 1)
        self.assertValidResponseCourse(courses[0], self.empty_course)

    def test_get_filtering(self):
        """
        The view should return a list of details for the specified courses.
        """
        url = "{}?course_id={}".format(reverse(self.view), self.course_id)
        response = self.http_get(url)
        self.assertEqual(response.status_code, 200)

        courses = response.data['results']
        self.assertEqual(len(courses), 1)
        self.assertValidResponseCourse(courses[0], self.course)

    def test_not_authenticated(self):
        response = self.http_get(reverse(self.view), HTTP_AUTHORIZATION=None)
        self.assertEqual(response.status_code, 401)

    def test_not_authorized(self):
        """
        Unauthorized users should get an empty list.
        """
        user = StaffFactory(course_key=self.course.id)
        access_token = AccessTokenFactory.create(user=user, client=self.oauth_client).token
        auth_header = 'Bearer ' + access_token

        # Data should be returned if the user is authorized.
        response = self.http_get(reverse(self.view), HTTP_AUTHORIZATION=auth_header)
        self.assertEqual(response.status_code, 200)

        url = "{}?course_id={}".format(reverse(self.view), self.course_id)
        response = self.http_get(url, HTTP_AUTHORIZATION=auth_header)
        self.assertEqual(response.status_code, 200)
        data = response.data['results']
        self.assertEqual(len(data), 1)
        self.assertEqual(data[0]['name'], self.course.display_name)

        # The view should return an empty list if the user cannot access any courses.
        url = "{}?course_id={}".format(reverse(self.view), unicode(self.empty_course.id))
        response = self.http_get(url, HTTP_AUTHORIZATION=auth_header)
        self.assertEqual(response.status_code, 200)
        self.assertDictContainsSubset({'count': 0, u'results': []}, response.data)

    def test_course_error(self):
        """
        Ensure the view still returns results even if get_courses() returns an ErrorDescriptor. The ErrorDescriptor
        should be filtered out.
        """

        error_descriptor = ErrorDescriptor.from_xml(
            '<course></course>',
            get_test_system(),
            CourseLocationManager(CourseLocator(org='org', course='course', run='run')),
            None
        )

        descriptors = [error_descriptor, self.empty_course, self.course]

        with patch('xmodule.modulestore.mixed.MixedModuleStore.get_courses', Mock(return_value=descriptors)):
            self.test_get()


class CourseDetailTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
    view = 'course_structure_api:v0:detail'

    @classmethod
    def setUpClass(cls):
        super(CourseDetailTests, cls).setUpClass()
        cls.create_course_data()

    def test_get(self):
        response = super(CourseDetailTests, self).test_get()
        self.assertValidResponseCourse(response.data, self.course)


class CourseStructureTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
    view = 'course_structure_api:v0:structure'

    @classmethod
    def setUpClass(cls):
        super(CourseStructureTests, cls).setUpClass()
        cls.create_course_data()

    def setUp(self):
        super(CourseStructureTests, self).setUp()

        # Ensure course structure exists for the course
        update_course_structure(unicode(self.course.id))

    def test_get(self):
        """
        If the course structure exists in the database, the view should return the data. Otherwise, the view should
        initiate an asynchronous course structure generation and return a 503.
        """

        # Attempt to retrieve data for a course without stored structure
        CourseStructure.objects.all().delete()
        self.assertFalse(CourseStructure.objects.filter(course_id=self.course.id).exists())
        response = self.http_get_for_course()
        self.assertEqual(response.status_code, 503)
        self.assertEqual(response['Retry-After'], '120')

        # Course structure generation shouldn't take long. Generate the data and try again.
        self.assertTrue(CourseStructure.objects.filter(course_id=self.course.id).exists())
        response = self.http_get_for_course()
        self.assertEqual(response.status_code, 200)

        blocks = {}

        def add_block(xblock):
            children = xblock.get_children()
            blocks[unicode(xblock.location)] = {
                u'id': unicode(xblock.location),
                u'type': xblock.category,
                u'parent': None,
                u'display_name': xblock.display_name,
                u'format': xblock.format,
                u'graded': xblock.graded,
                u'children': [unicode(child.location) for child in children]
            }

            for child in children:
                add_block(child)

        course = self.store.get_course(self.course.id, depth=None)
        add_block(course)

        expected = {
            u'root': unicode(self.course.location),
            u'blocks': blocks
        }

        self.maxDiff = None
        self.assertDictEqual(response.data, expected)


class CourseGradingPolicyTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
    view = 'course_structure_api:v0:grading_policy'

    @classmethod
    def setUpClass(cls):
        super(CourseGradingPolicyTests, cls).setUpClass()
        cls.create_course_data()

    def test_get(self):
        """
        The view should return grading policy for a course.
        """
        response = super(CourseGradingPolicyTests, self).test_get()

        expected = [
            {
                "count": 24,
                "weight": 0.2,
                "assignment_type": "Homework",
                "dropped": 0
            },
            {
                "count": 4,
                "weight": 0.8,
                "assignment_type": "Exam",
                "dropped": 0
            }
        ]
        self.assertListEqual(response.data, expected)


class CourseGradingPolicyMissingFieldsTests(CourseDetailTestMixin, CourseViewTestsMixin, SharedModuleStoreTestCase):
    view = 'course_structure_api:v0:grading_policy'

    # Update the raw grader to have missing keys
    raw_grader = [
        {
            "min_count": 24,
            "weight": 0.2,
            "type": "Homework",
            "drop_count": 0,
            "short_label": "HW"
        },
        {
            # Deleted "min_count" key
            "weight": 0.8,
            "type": "Exam",
            "drop_count": 0,
            "short_label": "Exam"
        }
    ]

    @classmethod
    def setUpClass(cls):
        super(CourseGradingPolicyMissingFieldsTests, cls).setUpClass()
        cls.create_course_data()

    def test_get(self):
        """
        The view should return grading policy for a course.
        """
        response = super(CourseGradingPolicyMissingFieldsTests, self).test_get()

        expected = [
            {
                "count": 24,
                "weight": 0.2,
                "assignment_type": "Homework",
                "dropped": 0
            },
            {
                "count": None,
                "weight": 0.8,
                "assignment_type": "Exam",
                "dropped": 0
            }
        ]
        self.assertListEqual(response.data, expected)


#####################################################################################
#
# The following Mixins/Classes collectively test the CourseBlocksAndNavigation view.
#
# The class hierarchy is:
#
#      ----------------->  CourseBlocksOrNavigationTestMixin  <--------------
#      |                                   ^                                |
#      |                                   |                                |
#      |        CourseNavigationTestMixin  |   CourseBlocksTestMixin        |
#      |          ^                  ^     |    ^               ^           |
#      |          |                  |     |    |               |           |
#      |          |                  |     |    |               |           |
#   CourseNavigationTests   CourseBlocksAndNavigationTests   CourseBlocksTests
#
#
# Each Test Mixin is an abstract class that implements tests specific to its
# corresponding functionality.
#
# The concrete Test classes are expected to define the following class fields:
#
#   block_navigation_view_type - The view's name as it should be passed to the django
#       reverse method.
#   container_fields - A list of fields that are expected to be included in the view's
#       response for all container block types.
#   block_fields - A list of fields that are expected to be included in the view's
#       response for all block types.
#
######################################################################################


class CourseBlocksOrNavigationTestMixin(CourseDetailTestMixin, CourseViewTestsMixin):
    """
    A Mixin class for testing all views related to Course blocks and/or navigation.
    """
    __metaclass__ = ABCMeta

    view_supports_debug_mode = False

    def setUp(self):
        """
        Override the base `setUp` method to enroll the user in the course, since these views
        require enrollment for non-staff users.
        """
        super(CourseBlocksOrNavigationTestMixin, self).setUp()
        CourseEnrollmentFactory(user=self.user, course_id=self.course.id)

    def create_user(self):
        """
        Override the base `create_user` method to test with non-staff users for these views.
        """
        self.user = UserFactory.create()

    @property
    def view(self):
        """
        Returns the name of the view for testing to use in the django `reverse` call.
        """
        return 'course_structure_api:v0:' + self.block_navigation_view_type

    def test_get(self):
        with check_mongo_calls(3):
            response = super(CourseBlocksOrNavigationTestMixin, self).test_get()

        # verify root element
        self.assertIn('root', response.data)
        root_string = unicode(self.course.location)
        self.assertEquals(response.data['root'], root_string)

        # verify ~blocks element
        self.assertTrue(self.block_navigation_view_type in response.data)
        blocks = response.data[self.block_navigation_view_type]

        # verify number of blocks
        self.assertEquals(len(blocks), 5)

        # verify fields in blocks
        for field, block in product(self.block_fields, blocks.values()):
            self.assertIn(field, block)

        # verify container fields in container blocks
        for field in self.container_fields:
            self.assertIn(field, blocks[root_string])

    def test_parse_error(self):
        """
        Verifies the view returns a 400 when a query parameter is incorrectly formatted.
        """
        response = self.http_get_for_course(data={'block_json': 'incorrect'})
        self.assertEqual(response.status_code, 400)

    @SharedModuleStoreTestCase.modifies_courseware
    def test_no_access_to_block(self):
        """
        Verifies the view returns only the top-level course block, excluding the sequential block
        and its descendants when the user does not have access to the sequential.
        """
        self.sequential.visible_to_staff_only = True
        modulestore().update_item(self.sequential, self.user.id)

        response = super(CourseBlocksOrNavigationTestMixin, self).test_get()
        self.assertEquals(len(response.data[self.block_navigation_view_type]), 1)


class CourseBlocksTestMixin(object):
    """
    A Mixin class for testing all views related to Course blocks.
    """
    __metaclass__ = ABCMeta

    view_supports_debug_mode = False
    block_fields = ['id', 'type', 'display_name', 'web_url', 'block_url', 'graded', 'format']

    def test_block_json(self):
        """
        Verifies the view's response when the block_json data is requested.
        """
        response = self.http_get_for_course(
            data={'block_json': '{"video":{"profiles":["mobile_low"]}}'}
        )
        self.assertEquals(response.status_code, 200)
        video_block = response.data[self.block_navigation_view_type][unicode(self.video.location)]
        self.assertIn('block_json', video_block)

    def test_block_count(self):
        """
        Verifies the view's response when the block_count data is requested.
        """
        response = self.http_get_for_course(
            data={'block_count': 'problem'}
        )
        self.assertEquals(response.status_code, 200)
        root_block = response.data[self.block_navigation_view_type][unicode(self.course.location)]
        self.assertIn('block_count', root_block)
        self.assertIn('problem', root_block['block_count'])
        self.assertEquals(root_block['block_count']['problem'], 1)

    def test_multi_device_support(self):
        """
        Verifies the view's response when multi_device support is requested.
        """
        response = self.http_get_for_course(
            data={'fields': 'multi_device'}
        )
        self.assertEquals(response.status_code, 200)

        for block, expected_multi_device_support in (
                (self.problem, True),
                (self.html, True),
                (self.video, False)
        ):
            block_response = response.data[self.block_navigation_view_type][unicode(block.location)]
            self.assertEquals(block_response['multi_device'], expected_multi_device_support)


class CourseNavigationTestMixin(object):
    """
    A Mixin class for testing all views related to Course navigation.
    """
    __metaclass__ = ABCMeta

    def test_depth_zero(self):
        """
        Tests that all descendants are bundled into the root block when the navigation_depth is set to 0.
        """
        response = self.http_get_for_course(
            data={'navigation_depth': '0'}
        )
        root_block = response.data[self.block_navigation_view_type][unicode(self.course.location)]
        self.assertIn('descendants', root_block)
        self.assertEquals(len(root_block['descendants']), 4)

    def test_depth(self):
        """
        Tests that all container blocks have descendants listed in their data.
        """
        response = self.http_get_for_course()

        container_descendants = (
            (self.course.location, 1),
            (self.sequential.location, 3),
        )
        for container_location, expected_num_descendants in container_descendants:
            block = response.data[self.block_navigation_view_type][unicode(container_location)]
            self.assertIn('descendants', block)
            self.assertEquals(len(block['descendants']), expected_num_descendants)


class CourseBlocksTests(CourseBlocksOrNavigationTestMixin, CourseBlocksTestMixin, SharedModuleStoreTestCase):
    """
    A Test class for testing the Course 'blocks' view.
    """
    block_navigation_view_type = 'blocks'
    container_fields = ['children']

    @classmethod
    def setUpClass(cls):
        super(CourseBlocksTests, cls).setUpClass()
        cls.create_course_data()


class CourseNavigationTests(CourseBlocksOrNavigationTestMixin, CourseNavigationTestMixin, SharedModuleStoreTestCase):
    """
    A Test class for testing the Course 'navigation' view.
    """
    block_navigation_view_type = 'navigation'
    container_fields = ['descendants']
    block_fields = []

    @classmethod
    def setUpClass(cls):
        super(CourseNavigationTests, cls).setUpClass()
        cls.create_course_data()


class CourseBlocksAndNavigationTests(CourseBlocksOrNavigationTestMixin, CourseBlocksTestMixin,
                                     CourseNavigationTestMixin, SharedModuleStoreTestCase):
    """
    A Test class for testing the Course 'blocks+navigation' view.
    """
    block_navigation_view_type = 'blocks+navigation'
    container_fields = ['children', 'descendants']

    @classmethod
    def setUpClass(cls):
        super(CourseBlocksAndNavigationTests, cls).setUpClass()
        cls.create_course_data()
