import datetime

import requests
import json
import sys
import shelve
import os.path
import os
import time
import logging.config
import logging
from .school import School
from .user import User
from .course import Course
from .utils.common import get_config
from dbm import error as dbm_error

import re
# from appdirs import *


class Teachable:
    URL_COURSES = '/api/v1/courses'
    URL_GET_ALL_USERS = '/api/v1/users'
    URL_SCHOOL_INFO = '/api/v1/school'
    URL_FIND_USER = '/api/v1/users?name_or_email_cont='
    URL_COURSE_INFO = '/api/v1/courses/COURSE_ID'
    URL_REPORT_CARD = '/api/v1/users/USER_ID/report_card'
    URL_COURSE_REPORT = '/api/v1/users/USER_ID/course_report'
    URL_COURSE_USERS = '/api/v1/users?enrolled_in_specific%5B%5D=COURSE_ID'
    URL_COURSE_COMPLETED = '/api/v1/users?completed_course_in_any[]=COURSE_ID'
    URL_CURRICULUM = '/api/v1/courses/COURSE_ID/curriculum'
    URL_COURSE_LECTURES = '/api/v1/courses/COURSE_ID/lectures'
    URL_FIND_COURSE = '/api/v1/courses?name_cont='
    URL_COURSE_PRODUCTS = '/api/v1/courses/COURSE_ID/products'
    URL_IMPORT_USERS = '/api/v1/import/users'
    URL_ENROLLMENTS_USER = '/api/v1/enrollments?is_active=true&user_id=USER_ID'
    URL_LEADERBOARD = '/api/v1/courses/COURSE_ID/leaderboard.csv?page=1&per=PER_PAGE'
    URL_COURSE_PROGRESS = '/api/v1/course_progresses?user_id=USER_ID'  # no idea what does it do
    URL_PAGES_CERTIFICATE = '/api/v1/pages?feature=certificate'  # no idea what does it do
    URL_ENROLL_USER = '/api/v1/users/USER_ID/enrollments'
    # URL_ENROLL_USER = '/api/v1/users/USER_ID/enrollments/COURSE_ID'  #
    #    -data-binary '{"course_id":int,"user_id":int}' \
    URL_ENROLLED_USER = '/admin/users/USER_ID/enrolled'
    URL_UNENROLL_USER = '/api/v1/enrollments/unenroll'
    URL_STATEMENTS = '/api/v1/earnings_statements?sort_key=period_start&sort_direction=desc'

    TEACHABLE_ETC_DIR = os.path.join(sys.prefix, 'teachable', 'etc')
    TEACHABLE_CACHE_DIR = os.path.join(sys.prefix, 'teachable', 'cache')
    TEACHABLE_LOG_DIR = os.path.join(sys.prefix, 'teachable', 'log')
    TEACHABLE_TEMPLATES_DIR = os.path.join(sys.prefix, 'teachable', 'templates')
    DEFAULT_DIRS = {'TEACHABLE_LOG_DIR': TEACHABLE_LOG_DIR,
                    'TEACHABLE_ETC_DIR': TEACHABLE_ETC_DIR,
                    'TEACHABLE_CACHE_DIR': TEACHABLE_CACHE_DIR,
                    'TEACHABLE_TEMPLATES_DIR': TEACHABLE_TEMPLATES_DIR}

    def __init__(self, config_file=None):
        # Initializing all the needed directories
        for defaultdir in self.DEFAULT_DIRS.keys():
            logging.basicConfig(level=logging.DEBUG)
            tmp_log = logging.getLogger(__name__)
            os.environ[defaultdir] = self.DEFAULT_DIRS[defaultdir]
            if not os.path.exists(self.DEFAULT_DIRS[defaultdir]):
                tmp_log.debug('{} not found -> Creating'.format(self.DEFAULT_DIRS[defaultdir]))
                os.makedirs(self.DEFAULT_DIRS[defaultdir])
        self.logger = self.setup_logging(os.path.join(self.DEFAULT_DIRS['TEACHABLE_ETC_DIR'], 'logconf.ini'))
        self.usecache = True
        self._school = None
        self._courses = None
        self.site_url = None
        self.session = None
        self.cached_data = None
        if not config_file:
            conf_file = os.path.join(self.DEFAULT_DIRS['TEACHABLE_ETC_DIR'], 'config.ini')
        else:
            conf_file = config_file
        self.config = get_config(conf_file)
        if self.config:
            defaults = self.config['DEFAULT']
            cache_dir = self.DEFAULT_DIRS['TEACHABLE_CACHE_DIR']
            if not os.path.exists(cache_dir):
                self.logger.debug('{} not found -> Creating'.format(cache_dir))
                os.makedirs(cache_dir)
            notif_path = os.path.join(cache_dir, defaults['notifications_db'])
            statement_path = os.path.join(cache_dir, defaults['statements_db'])
            self.logger.debug('Using {} as notifications_db'.format(notif_path))
            self.notif_status = self._open_safe_shelve(notif_path)
            self.logger.debug('Using {} as statements_db'.format(statement_path))
            self.statement_status = self._open_safe_shelve(statement_path)
            self.site_url = defaults['site_url']
            self.email_regex = re.compile(defaults['email_regex'])
            self.session = requests.Session()
            self.session.auth = (defaults['username'], defaults['password'])
            self.cache_expire = defaults['cache_expire']
            self.cache_file = defaults['cache_file']
            # self.session.headers.update({'x-test': 'true'})
            self.session.headers.update({'Origin': self.site_url})
            self.expire_cache(os.path.join(cache_dir, self.cache_file))
            self.prepare_cache(os.path.join(cache_dir, self.cache_file))
        else:
            sys.exit(1)

    def _open_safe_shelve(self, path: str) -> shelve:
        """
        A method to safely opening shelves.

        It gracefully waits until the cache file becomes available before returning.

        :return: a shelve object to the cache
        :param path: the path to the shelve cache file
        """
        opened = False
        waittime = 5
        while opened is False:
            try:
                return shelve.open(path)
            except dbm_error:
                self.logger.error('Shelve {} is not available. Waiting {} secs'.format(path, waittime))
                time.sleep(waittime)
                waittime *= 2


    def __del__(self):
        self.logger.debug('Releasing API. Deleting api object.')
        if self.config:
            try:
                if self.cached_data:
                        self.logger.debug('Syncing cache data')
                        self.cached_data.sync()
                        self.cached_data.close()
            except ValueError:
                    pass
            try:
                if self.notif_status:

                        self.logger.debug('Syncing notification status data')
                        self.notif_status.sync()
                        self.notif_status.close()
            except ValueError:
                pass
            try:
                if self.statement_status:

                        self.logger.debug('Syncing statements data')
                        self.statement_status.sync()
                        self.statement_status.close()
            except ValueError:
                pass

    @staticmethod
    def setup_logging(logconf: str) -> logging.Logger:
        """
        A static method to setup the logging facilities

        :return: the logger
        :param logconf: the path to the log config file
        """
        if os.path.exists(logconf):
            logging.debug('Found logconf in {}'.format(logconf))
            logging.config.fileConfig(fname=logconf, disable_existing_loggers=False)
        else:
            logging.error('Log conf doesn\'t exist [{}]'.format(logconf))
            logging.error('we are in dir {}, sys.prefix={}'.format(os.getcwd(), sys.prefix))
            logging.info('Using default logging configuration')
        return logging.getLogger(__name__)

    def check_email(self, email):
        check = self.email_regex.fullmatch(email)
        if not check:
            self.logger.error('{} is not a valid email'.format(email))
        return check

    def prepare_cache(self, cache_path):
        self.logger.debug('Using cache {}'.format(cache_path))
        # TODO: handle conflict situations when different instances of the API want to access cache.db
        self.cached_data = self._open_safe_shelve(cache_path)

    def expire_cache(self, cache_path):
        if os.path.isfile(cache_path):
            cache_antiquity = time.time() - os.path.getctime(cache_path)
            maximum_cache_duration = 60 * 60 * 24 * int(self.cache_expire)  # One day (rate limits
            #                                            are not that
            #                                            aggressive I hope)
            if cache_antiquity > maximum_cache_duration:
                os.remove(cache_path)
                self.logger.warning('Cache file dumped!')

    @property
    def school(self):
        if not self._school:
            self._school = School(self)
        return self._school

    @property
    def courses(self):
        if not self._courses:
            self._courses = self.school.courses
        return self._courses

    def get_leaderboard_csv(self, course, filename):
        """Fetches the leaderboard CSV list directly from Teachable and saves it as a CSV file

        :param course: Course object
        :param filename: name of the CSV file to use as output
        """
        per_page = '100000'  # includes in the leaderboard CSV as many as PER_PAGE users
        path = self.URL_LEADERBOARD.replace('COURSE_ID', str(course.id)).replace('PER_PAGE', str(per_page))
        full_url = self.site_url + path
        r = self.session.get(full_url, allow_redirects=True)
        if filename == '':
            filename = 'leaderboard_{course}.csv'.format(course=course.name)
        open(filename, 'wb').write(r.content)

    def get_user_course_report(self, user_id):
        path = self.URL_COURSE_REPORT.replace('USER_ID', str(user_id))
        return self._get_json_at(path).get('report')

    def get_course_sections(self, course_id):
        url_course_curriculum = self.URL_CURRICULUM.replace('COURSE_ID', str(course_id))
        return self._get_json_at(url_course_curriculum).get('lecture_sections')

    def get_course_list(self):
        course_info = self._get_json_at(self.URL_COURSES)
        return course_info.get('courses')

    def get_earning_statements(self):
        statement = self._get_json_at(self.URL_STATEMENTS)
        return statement.get('earnings_statements')

    def get_course_info(self, course_id):
        url_course_info = self.URL_COURSE_INFO.replace('COURSE_ID', str(course_id))
        course_info = self._get_json_at(url_course_info)
        return course_info

    def get_school_info(self):
        school_info = self._get_json_at(self.URL_SCHOOL_INFO)
        return school_info

    def find_user(self, email: str) -> User or None:
        """Searches for a specific user, the API uses the same endpoint, for
        one or many

        :param email: email address of the user you are searching
        :return: the user you are searching
        """
        user_list = self._get_json_at(self.URL_FIND_USER + email).get('users')
        if len(user_list) == 0:
            return None
        else:
            return User(self, user_list[0]['email'])

    def get_user_info(self, email: str) -> json:
        """Searches for a specific user, the API uses the same endpoint, for
        one or many

        :param email: email address of the user you are searching
        :return: the json with the info of the user you are searching
        """
        user_list = self._get_json_at(self.URL_FIND_USER + email).get('users')
        if len(user_list) == 0:
            return []
        else:
            return user_list[0]

    def find_many_users(self, email: str) -> list[User]:
        """Searches for multiple users, the API uses the same endpoint for one
        or many

        :param email: part of email address of the user you are searching (e.g. @gmail.com)
        :return: list of users you are searching
        """
        user_list = self._get_json_at(self.URL_FIND_USER + email).get('users')
        if len(user_list) == 0:
            return []
        else:
            return [User(self, user['email']) for user in user_list]

    def get_all_users(self) -> list[User]:
        """Gets all the Users registered to the school

        :return: list of all users in the school
        :rtype: [User]
        """
        response = self._get_json_at(self.URL_GET_ALL_USERS)
        # the API returns at least one page, at most N pages
        pages = response.get('meta')['number_of_pages']
        result_list = []
        user_list = response.get('users')
        if len(user_list) > 0:
            result_list += [User(self, user['email']) for user in user_list]

        # cycling through all pages after the first one if existing
        if pages > 1:
            self.logger.debug('There are {} pages of results, gonna take a while'.format(pages))
            for page_no in range(2,pages+1):
                self.logger.debug('Querying page #{}'.format(page_no))
                response = self._get_json_at(self.URL_GET_ALL_USERS+'?page='+str(page_no))
                user_list = response.get('users')
                if len(user_list) > 0:
                    result_list += [User(self, user['email']) for user in user_list]
        return result_list

    def get_last_notif(self, email: str) -> datetime.date:
        """Returns the date of the last notification sent to the user

        :param email: email of the user you are interested into
        :return: last notification date
        :rtype: datetime.date
        """
        try:
            notified = self.notif_status[email]
            self.logger.debug('{} was sent a notification last time on {}'.format(email, notified))
        except KeyError:
            self.logger.debug('{} was never sent a notification'.format(email))
            notified = datetime.date(1970, 1, 1)
        return notified

    def set_last_notif(self, email, newdate):
        self.notif_status[email] = newdate
        self.notif_status.sync()
        
    def get_last_statement_date(self) -> datetime.date:
        """Returns the date of the last statement retrieved

        :return: last statement date
        :rtype: datetime.date
        """
        try:
            last_statement = self.statement_status['last']
            self.logger.debug('Last statement was dated {}'.format(last_statement))
        except KeyError:
            self.logger.debug('No statement was ever retrieved')
            last_statement = datetime.date(1970, 1, 1)
        return last_statement

    def set_last_statement_date(self, newdate: datetime):
        self.statement_status['last'] = newdate
        self.statement_status.sync()
        
    def find_courses(self, course):
        """Searches for courses containing the specific text

        :param course: Course
        :return: Course list
        :rtype: list(Course)
        """
        course_list = self._get_json_at(self.URL_FIND_COURSE +
                                        course).get('courses')
        if len(course_list) == 0:
            return []
        else:
            courses = []
            for course_data in course_list:
                courses.append(Course(self, course_data.get('id')))
            return courses

    def get_course_price(self, course_id):
        products = self.get_course_products(course_id)
        if len(products) > 0:
            return products[0].get('price')
        else:
            return 0

    def get_course_products(self, course_id):
        path = self.URL_COURSE_PRODUCTS.replace('COURSE_ID', str(course_id))
        result = self._get_json_at(path)
        return result.get('products')

    def get_course_users(self, course_id):
        path = self.URL_COURSE_USERS.replace('COURSE_ID', str(course_id))
        users = self._get_json_at(path).get('users')
        if len(users) == 0:
            return []
        else:
            return [User(self, user['email']) for user in users]

    def get_course_users_completed(self, course_id):
        path = self.URL_COURSE_COMPLETED.replace('COURSE_ID', str(course_id))
        users = self._get_json_at(path).get('users')
        if len(users) == 0:
            return []
        else:
            return [User(self, user['email']) for user in users]

    def get_user_report_card(self, user_id: str):
        """Gets the full report card fot userId, returning the full list of
        lessons the user has completed"""
        path = self.URL_REPORT_CARD.replace('USER_ID', str(user_id))
        return self._get_json_at(path)

    def add_users_to_school(self, users_array, course_id):
        users_json_array = []
        for userRow in users_array:
            if self.check_email(userRow['email']):
                user_json = {
                    "email": userRow['email'],
                    "name": userRow['fullname'],
                    "password": None,
                    "role": "student",
                    "course_id": course_id,
                    "unsubscribe_from_marketing_emails": 'false'
                }
                users_json_array.append(user_json)
        payload = {
            "user_list": users_json_array,
            "course_id": course_id,
            "coupon_code": None,
            "users_role": "student",
            "author_bio_data": {}
        }
        resp = self._post_json_at(self.URL_IMPORT_USERS, json.dumps(payload))
        return json.loads(resp)

    def add_user_to_school(self, user: User, course: Course = None) -> str:
        """
        Adds a User to a school and enroll to a specific Course if provided

        :param user: User object
        :param course: Course object (optional)
        :return: json
        """
        users_json_array = []
        if self.check_email(user.email):
            user_json = {
                "email": user.email,
                "name": user.name,
                "password": None,
                "role": "student",
                "unsubscribe_from_marketing_emails": 'false'
            }
            if course:
                user_json["course_id"] = course.id
            users_json_array.append(user_json)
        payload = {
            "user_list": users_json_array,
            "coupon_code": None,
            "users_role": "student",
            "author_bio_data": {}
        }
        if course:
            payload["course_id"] = course.id
        resp = self._post_json_at(self.URL_IMPORT_USERS, json.dumps(payload))
        return json.loads(resp)

    def enroll_user_to_course(self, user: User, course: Course) -> str:
        path = self.URL_ENROLL_USER.replace('USER_ID', str(user.id))
        json_body = json.dumps({"course_id": int(course.id)})
        response = self._post_json_at(path, json_body)
        # Now refreshing the status in the cache
        # while it might seem that redundant Teachable keeps track of enrolled courses in 2 places:
        # reportcard and enrollment APIs
        # and it's very important that we keep the cache consistent across these places
        enrolled_courses = []
        delay = 1
        max_retry = 4
        retry = 0
        while int(course.id) not in enrolled_courses:
            self.usecache = False
            self.logger.debug('Checking if cache update was successful, waiting for {} secs'.format(delay))
            time.sleep(delay)
            # seems that Teachable backend takes a while to refresh :/
            enrolled_courses = [int(c.id) for c in user.courses]
            delay *= 2
            self.usecache = True
            retry += 1
            if retry > max_retry:
                self.logger.error("Something is wrong, we can't update the cache. :/")
                sys.exit(9)
        enrolled_courses = []
        delay = 1
        retry = 0
        while str(course.id) not in enrolled_courses:
            self.usecache = False
            self.logger.debug('Checking if cache update was successful, waiting for {} secs'.format(delay))
            time.sleep(delay)
            enrolled_courses = [i for i in user.reportcard if i != 'meta']
            delay *= 2
            self.usecache = True
            retry += 1
            if retry > max_retry:
                self.logger.error("Something is wrong, we can't update the cache. :/")
                sys.exit(9)
        if response:
            return json.loads(response)
        else:
            return response

    def unenroll_user_from_course(self, user: User, course: Course) -> str:
        """
        Unenrolls a user from a course.

        :param user: User object
        :param course: Course object
        :return: response of the operation
        """
        path = self.URL_UNENROLL_USER
        json_body = json.dumps({"course_id": int(course.id), "user_id": int(user.id)})
        response = self._put_json_at(path, json_body)
        # updating also the cache for the enrolled courses
        # while it might seem that redundant Teachable keeps track of enrolled courses in 2 places:
        # reportcard and enrollment APIs
        # and it's very important that we keep the cache consistent across these places
        enrolled_courses = [str(course.id)]
        delay = 1
        max_retry = 4
        retry = 0
        while str(course.id) in enrolled_courses:
            self.usecache = False
            self.logger.debug('Checking if cache update was successful, waiting for {} secs'.format(delay))
            time.sleep(delay)
            enrolled_courses = [i for i in user.reportcard if i != 'meta']
            delay *= 2
            self.usecache = True
            retry += 1
            if retry > max_retry:
                self.logger.error("Something is wrong, we can't update the cache. :/")
                sys.exit(9)
        enrolled_courses = [int(course.id)]
        delay = 1
        retry = 0
        while int(course.id) in enrolled_courses:
            self.usecache = False
            self.logger.debug('Checking if cache update was successful, waiting for {} secs'.format(delay))
            time.sleep(delay)
            # seems that Teachable backend takes a while to refresh :/
            enrolled_courses = [int(c.id) for c in user.courses]
            delay *= 2
            self.usecache = True
            retry += 1
            if retry > max_retry:
                self.logger.error("Something is wrong, we can't update the cache. :/")
                sys.exit(9)
        if response:
            return json.loads(response)
        else:
            return response

    def get_enrolled_courses(self, user_id: str) -> list[Course]:
        """Gets the courses the user is enrolled in and returns it as a list

        :param user_id: user id
        :return: list of Course objects
        """
        path = self.URL_ENROLLMENTS_USER.replace('USER_ID', str(user_id))
        courses = self._get_json_at(path).get('enrollments')
        return [Course(self, c['course_id']) for c in courses]

    def check_enrollment_to_course(self, user_id: str, course_id: str) -> bool:
        """Check if a user is enrolled in a specific course

        :param user_id: User id
        :param course_id: Course id
        :return: True if enrolled, False if not enrolled
        """
        courses = self.get_enrolled_courses(user_id)
        return int(course_id) in [p.id for p in courses]

    def _get_json_at(self, path):
        if self.usecache and path in self.cached_data:
            self.logger.debug(("Found cached data for " + path))
            return self.cached_data[path]
        else:
            full_url = self.site_url + path
            self.logger.debug(("Downloading data from " + full_url))
            json_data = self.session.get(full_url).json()
            if json_data.get('error'):
                self.logger.error('Check Teachable credentials')
                sys.exit(1)
            self.logger.debug('Updating cache data for ' + path)
            self.cached_data[path] = json_data
            return json_data

    def _post_json_at(self, path, json_body):
        full_url = self.site_url + path
        self.logger.debug(("Uploading POST data to " + full_url))
        self.logger.debug("JSON Body : " + json_body)
        json_txt = json.loads(json_body)
        self.session.headers.update({'Content-Type': 'application/json;charset=UTF-8'})
        self.logger.debug((json.dumps(json_txt, sort_keys=True, indent=4, separators=(',', ': '))))
        json_response_body = self.session.post(full_url, data=json_body)
        return json_response_body.text

    def _put_json_at(self, path, json_body):
        full_url = self.site_url + path
        self.logger.debug(("Uploading PUT data to " + full_url))
        self.logger.debug("JSON Body : " + json_body)
        json_txt = json.loads(json_body)
        self.session.headers.update({'Content-Type': 'application/json;charset=UTF-8'})
        self.logger.debug((json.dumps(json_txt, sort_keys=True, indent=4, separators=(',', ': '))))
        json_response_body = self.session.put(full_url, data=json_body)
        return json_response_body.text
