import re
import os
import base64
import pytz
import zipfile
import calendar

from io import BytesIO
from dateutil.relativedelta import relativedelta
from requests import get
from datetime import timedelta
from requests.exceptions import SSLError

from odoo import models, fields, _
from odoo.exceptions import UserError, ValidationError

from ..controllers.my_ip import MY_IP_ROUTE


class TOBase(models.AbstractModel):
    _name = 'to.base'
    _description = 'TVTMA Base Model'

    def barcode_exists(self, barcode, model_name=None, barcode_field='barcode', inactive_rec=True):
        """
        Method to check if the barcode exists in the input model

        @param barcode: the barcode to check its existance in the model
        @param model_name: The technical name of the model to check. For example, product.template, product.product, etc. If not passed, the current model will be used
        @param barcode_field: the name of the field storing barcode in the corresponding model
        @param inactive_rec: search both active and inactive records of the model for barcode existance check. Please pass False for this arg if the model does not have active field

        @return: Boolean
        """
        Object = model_name and self.env[model_name] or self
        domain = [(barcode_field, '=', barcode)]
        if inactive_rec:
            found = Object.with_context(active_test=False).search(domain)
        else:
            found = Object.search(domain)
        if found:
            return True
        return False

    def get_ean13(self, base_number):
        if len(str(base_number)) > 12:
            raise UserError(_("Invalid input base number for EAN13 code"))
        # weight number
        ODD_WEIGHT = 1
        EVEN_WEIGHT = 3
        # Build a 12 digits base_number_str by adding 0 for missing first characters
        base_number_str = '%s%s' % ('0' * (12 - len(str(base_number))), str(base_number))
        # sum_value
        sum_value = 0
        for i in range(0, 12):
            if i % 2 == 0:
                sum_value += int(base_number_str[i]) * ODD_WEIGHT
            else:
                sum_value += int(base_number_str[i]) * EVEN_WEIGHT
        # calculate the last digit
        sum_last_digit = sum_value % 10
        calculated_digit = 0
        if sum_last_digit != 0:
            calculated_digit = 10 - sum_last_digit
        barcode = base_number_str + str(calculated_digit)
        return barcode

    def convert_time_to_utc(self, dt, tz_name=None, is_dst=None):
        """
        :param dt: datetime obj to convert to UTC
        :param tz_name: the name of the timezone to convert. In case of no tz_name passed, this method will try to find the timezone in context or the login user record
        :param is_dst: respecting daylight saving time or not

        :return: an instance of datetime object
        """
        tz_name = tz_name or self._context.get('tz') or self.env.user.tz
        if not tz_name:
            raise ValidationError(_("Local time zone is not defined. You may need to set a time zone in your user's Preferences."))
        local = pytz.timezone(tz_name)
        local_dt = local.localize(dt, is_dst=is_dst)
        return local_dt.astimezone(pytz.utc)

    def convert_utc_time_to_tz(self, utc_dt, tz_name=None, is_dst=None):
        """
        Method to convert UTC time to local time
        :param utc_dt: datetime in UTC
        :param tz_name: the name of the timezone to convert. In case of no tz_name passed, this method will try to find the timezone in context or the login user record
        :param is_dst: respecting daylight saving time or not
        
        :return: datetime object presents local time
        """
        tz_name = tz_name or self._context.get('tz') or self.env.user.tz
        if not tz_name:
            raise ValidationError(_("Local time zone is not defined. You may need to set a time zone in your user's Preferences."))
        tz = pytz.timezone(tz_name)
        return pytz.utc.localize(utc_dt, is_dst=is_dst).astimezone(tz)

    def convert_local_to_utc(self, dt, force_local_tz_name=None, naive=None, is_dst=None):
        """
        Alias method for convert_time_to_utc with backward compatibility parameters
        :param dt: datetime obj to convert to UTC
        :param force_local_tz_name: the timezone name (alias for tz_name parameter)
        :param naive: whether the datetime is naive (ignored, handled automatically)
        :param is_dst: respecting daylight saving time or not
        :return: datetime object in UTC
        """
        tz_name = force_local_tz_name
        return self.convert_time_to_utc(dt, tz_name=tz_name, is_dst=is_dst)

    def convert_utc_to_local(self, utc_dt, tz_name=None, naive=None, is_dst=None):
        """
        Alias method for convert_utc_time_to_tz with backward compatibility parameters
        :param utc_dt: datetime in UTC
        :param tz_name: the timezone name to convert to
        :param naive: whether to return naive datetime (ignored, method always returns timezone-aware)
        :param is_dst: respecting daylight saving time or not
        :return: datetime object in local timezone
        """
        result = self.convert_utc_time_to_tz(utc_dt, tz_name=tz_name, is_dst=is_dst)
        if naive:
            # If naive=True requested, return naive datetime
            return result.replace(tzinfo=None)
        return result

    def time_to_float_hour(self, dt):
        """
        This method will convert a datetime object to a float that present the corresponding time without date. For example,
            datetime.datetime(2019, 3, 24, 12, 44, 0, 307664) will become 12.733418795555554
        @param dt: datetime object
        @param type: datetime
        
        @return: The extracted time in float. For example, 12.733418795555554 for datetime.time(12, 44, 0, 307664)
        @rtype: float
        """
        return dt.hour + dt.minute / 60.0 + dt.second / (60.0 * 60.0) + dt.microsecond / (60.0 * 60.0 * 1000000.0)

    def _find_last_date_of_period_from_period_start_date(self, period_name, period_start_date):
        """
        This method finds the last date of the given period defined by the period_name and the start date of the period. For example:
        - if you pass 'monthly' as the period_name, date('2018-05-20') as the period_start_date, the result will be date('2018-02-20')
        - if you pass 'quarterly' as the period_name, date('2018-05-20') as the date, the result will be date('2018-08-20')
        
        @param period_name: (string) the name of the given period which is either 'weekly' or 'monthly' or 'quarterly' or 'biannually' or 'annually'
        @param period_start_date: (datetime.datetime) the starting date of the period from which the period will be started
        @return: (datetime.datetime) the last date of the period
        @raise ValidationError: when the passed period_name is invalid
        """
        if period_name not in ('weekly', 'monthly', 'quarterly', 'biannually', 'annually'):
            raise ValidationError(_("Wrong value passed to the argument `period_name` of the method `find_last_date_of_period`."
                                    " The value for `period_name` should be either 'weekly' or 'monthly' or 'quarterly' or 'biannually' or 'annually'"))
        if period_name == 'weekly':
            dt = period_start_date + relativedelta(days=6)
        elif period_name == 'monthly':
            dt = period_start_date + relativedelta(months=1) - relativedelta(days=1)
        elif period_name == 'quarterly':
            dt = period_start_date + relativedelta(months=3) - relativedelta(days=1)
        elif period_name == 'biannually':
            dt = period_start_date + relativedelta(months=6) - relativedelta(days=1)
        else:
            dt = period_start_date + relativedelta(years=1) - relativedelta(days=1)
        return dt

    def _validate_period_name(self, period_name):
        msg = ''
        if period_name not in ('weekly', 'monthly', 'quarterly', 'biannually', 'annually'):
            msg = _("Wrong value passed to the argument `period_name` of the method `find_last_date_of_period`."
                    " The value for `period_name` should be either 'weekly' or 'monthly' or 'quarterly' or 'biannually' or 'annually'")
            return False, msg
        else:
            return True, msg

    def find_first_date_of_period(self, period_name, date):
        """
        This method finds the first date of the given period defined by period name and any date of the period
        @param period_name: (string) the name of the given period which is either 'weekly' or 'monthly' or 'quarterly' or 'biannually' or 'annually'
        @param date: (datetime.datetime) any date of the period to find
        @return: (datetime.datetime) the first date of the period
        """
        ret, msg = self._validate_period_name(period_name)
        if not ret:
            raise ValidationError(msg)

        if period_name == 'weekly':
            dt = date - relativedelta(days=date.weekday())
        elif period_name == 'monthly':
            dt = date + relativedelta(day=1)  # force day as 1 while keep year and month unchanged
        elif period_name == 'quarterly':
            if date.month >= 1 and date.month <= 3:
                dt = fields.Date.from_string('%s-%s-%s' % (date.year, 1, 1))
            elif date.month >= 4 and date.month <= 6:
                dt = fields.Date.from_string('%s-%s-%s' % (date.year, 4, 1))
            elif date.month >= 7 and date.month <= 9:
                dt = fields.Date.from_string('%s-%s-%s' % (date.year, 7, 1))
            else:
                dt = fields.Date.from_string('%s-%s-%s' % (date.year, 10, 1))
        elif period_name == 'biannually':
            if date.month <= 6:
                dt = fields.Date.from_string('%s-01-01' % date.year)
            else:
                dt = fields.Date.from_string('%s-07-01' % date.year)
        else:
            dt = fields.Date.from_string('%s-01-01' % date.year)
        return dt

    def find_last_date_of_period(self, period_name, date, date_is_start_date=False):
        """
        This method finds the last date of the given period defined by period name and any date of the period. For example:
        - if you pass 'monthly' as the period_name, date('2018-05-20') as the date, the result will be date('2018-05-31')
        - if you pass 'quarterly' as the period_name, date('2018-05-20') as the date, the result will be date('2018-06-30')
        @param period_name: (string) the name of the given period which is either 'weekly' or 'monthly' or 'quarterly' or 'biannually' or 'annually'
        @param date: (datetime.datetime) either the start date of the given period or any date of the period, depending on the passed value of the arg. date_is_start_date
        @param date_is_start_date: (bool) True to indicate the given date is also the starting date of the given period_name, otherwise, the given date is any of the period's dates
        @return: (datetime.datetime) the last date of the period
        @raise ValidationError: when the passed period_name is invalid
        """
        ret, msg = self._validate_period_name(period_name)
        if not ret:
            raise ValidationError(msg)

        # If the given date is the start date of the period
        if date_is_start_date:
            return self._find_last_date_of_period_from_period_start_date(period_name=period_name, period_start_date=date)

        # else
        if period_name == 'weekly':
            dt = date + relativedelta(days=6 - date.weekday())
        elif period_name == 'monthly':
            days_of_month = self.get_days_of_month_from_date(date)
            dt = fields.Datetime.from_string('%s-%s-%s' % (date.year, date.month, days_of_month))
        elif period_name == 'quarterly':
            if date.month >= 1 and date.month <= 3:
                dt = fields.Datetime.from_string('%s-%s-%s' % (date.year, 3, 31))
            elif date.month >= 4 and date.month <= 6:
                dt = fields.Datetime.from_string('%s-%s-%s' % (date.year, 6, 30))
            elif date.month >= 7 and date.month <= 9:
                dt = fields.Datetime.from_string('%s-%s-%s' % (date.year, 9, 30))
            else:
                dt = fields.Datetime.from_string('%s-%s-%s' % (date.year, 12, 31))
        elif period_name == 'biannually':
            if date.month <= 6:
                dt = fields.Datetime.from_string('%s-%s-%s' % (date.year, 6, 30))
            else:
                dt = fields.Datetime.from_string('%s-12-31' % date.year)
        else:
            dt = fields.Datetime.from_string('%s-12-31' % date.year)
        return dt

    def period_iter(self, period_name, dt_start, dt_end, start_day_offset=0):
        """
        Method to generate sorted dates for periods of the given period_name and dt_start and dt_end
        @param period_name: (string) the name of the given period which is either 'weekly' or 'monthly' or 'quarterly' or 'biannually' or 'annually'
        @param dt_start: (datetime.datetime)
        @param dt_end: (datetime.datetime)
        @param start_day_offset: default value is zero, which means that the start days are always the very first day of the period
        @return: [list] list of datetime objects contain dt_start and end dates of found periods. For example:
                if we pass [datetime.date(2018, 7, 4) and datetime.date(2018, 10, 31) and 0 as the dt_start and the dt_end and the
                start_day_offset correspondingly, the result will be
                    [datetime.date(2018, 7, 4),
                    datetime.date(2018, 7, 31), datetime.date(2018, 8, 31), datetime.date(2018, 9, 30), datetime.date(2018, 10, 31)]
        """
        if not start_day_offset >= 0:
            raise ValidationError(_("The `start_day_offset` passed to the method `period_iter` must be greater than or equal to zero!"))
        res = [dt_start]
        period_start_date = self.find_first_date_of_period(period_name, dt_start) + relativedelta(days=start_day_offset)
        if period_start_date > dt_start:
            res.append(period_start_date - relativedelta(days=1))

        while period_start_date <= dt_end:
            last_dt = self._find_last_date_of_period_from_period_start_date(period_name=period_name, period_start_date=period_start_date)
            if last_dt > dt_end:
                last_dt = dt_end
            res.append(last_dt)
            period_start_date = last_dt + relativedelta(days=1)

        res.sort()
        return res

    def get_days_of_month_from_date(self, dt):
        return calendar.monthrange(dt.year, dt.month)[1]

    def get_day_of_year_from_date(self, date):
        """
        Return the day of year from date. For example, 2018-01-06 will return 6
        """
        first_date = fields.Date.from_string('%-01-01' % date.year)
        day = self.get_days_between_dates(first_date, date) + 1
        return day

    def get_days_between_dates(self, dt_from, dt_to):
        """
        Return number of days between two dates
        """
        return (dt_to - dt_from).days

    def get_weekdays_for_period(self, dt_from, dt_to):
        """
        Method to return the a dictionary in form of {int0:date, wd1:date, ...} where int0/int1
            are integer 0~6 presenting weekdays and date1/date2 are dates that are the correspong weekdays
        @param dt_from: datetime.datetime|datetime.date
        @param dt_to: datetime.datetime|datetime.date
        @return: dict{int0:date, wd1:date, ...}
        """
        nb_of_days = self.get_days_between_dates(dt_from, dt_to) + 1
        if nb_of_days > 7:
            raise ValidationError(_("The method get_weekdays_for_period(dt_from, dt_to) does not support the periods having more than 7 days"))
        weekdays = {}
        for day in range(0, nb_of_days):
            day_rec = dt_from + timedelta(days=day)
            weekdays[day_rec.weekday()] = day_rec.date()
        return weekdays

    def next_weekday(self, date, weekday=None):
        """
        Method to get the date in the nex tweek of the given `date`'s week with weekday is equal to the given `weekday`. For example,
        - date: 2018-10-18 (Thursday)
        - weekday:
            0: will return 2018-10-22 (Monday next week)
            1: will return 2018-10-23 (Tuesday next week)
            2: will return 2018-10-24 (Wednesday next week)
            3: will return 2018-10-25 (Thursday next week)
            4: will return 2018-10-26 (Friday next week)
            5: will return 2018-10-27 (Saturday next week)
            6: will return 2018-10-28 (Sunday next week)
            None: will return 2018-10-25 (the same week day next week)        
        @param date: (datetime.datetime or datetime.date) the given date to find the date next week
        @param weekday: week day of the next week which is an integer from 0 to 6 presenting a day of week, or None to find the date of the same week day next week

        @return: date of the same weekday next week
        """
        # if weekday is None, set it as the same as the weekday of the given date
        if weekday is None:
            weekday = date.weekday()

        days_ahead = weekday - date.weekday()
        if days_ahead <= 0:  # Target day already happened this week
            days_ahead += 7
        return date + timedelta(days_ahead)

    def split_date(self, date):
        """
        Method to split a date into year,month,day separatedly
        @param date date:
        """
        year = date.year
        month = date.month
        day = date.day
        return year, month, day

    def hours_time_string(self, hours):
        """ convert a number of hours (float) into a string with format '%H:%M' """
        minutes = int(round(hours * 60))
        return "%02d:%02d" % divmod(minutes, 60)

    def _zip_dir(self, path, zf, incl_dir=False):
        """
        @param path: the path to the directory to zip
        @param zf: the ZipFile object which is an instance of zipfile.ZipFile
        @type zf: ZipFile
        
        @return: zipfile.ZipFile object that contain all the content of the path
        """
        path = os.path.normpath(path)

        dlen = len(path)
        if incl_dir:
            dir_name = os.path.split(path)[1]
            minus = len(dir_name) + 1
            dlen -= minus
        for root, dirs, files in os.walk(path):
            for name in files:
                full = os.path.join(root, name)
                rel = root[dlen:]
                dest = os.path.join(rel, name)
                zf.write(full, dest)
        return zf

    def zip_dir(self, path, incl_dir=False):
        """
        zip a directory tree into a bytes object which is ready for storing in Binary field

        @param path: the absolute path to the directory to zip
        @type path: string
        
        @return: return bytes object containing data for storing in Binary fields
        @rtype: bytes
        """
        # initiate A BytesIO object
        file_data = BytesIO()

        # open file_data as ZipFile with write mode
        with zipfile.ZipFile(file_data, "w", compression=zipfile.ZIP_DEFLATED) as zf:
            self._zip_dir(path, zf, incl_dir=incl_dir)

        # Change the stream position to the start of the stream
        # see https://docs.python.org/3/library/io.html#io.IOBase.seek
        file_data.seek(0)
        # read bytes to the EOF
        file_data_read = file_data.read()
        # encode bytes for output to return
        out = base64.encodebytes(file_data_read)
        return out

    def zip_dirs(self, paths):
        """
        zip a tree of directories (defined by paths) into a bytes object which is ready for storing in Binary field

        @param paths: list of absolute paths (string) to the directories to zip
        @type paths: list
        
        @return: return bytes object containing data for storing in Binary fields
        @rtype: bytes
        """
        # initiate A BytesIO object
        file_data = BytesIO()

        # open file_data as ZipFile with write mode
        with zipfile.ZipFile(file_data, "w", compression=zipfile.ZIP_DEFLATED) as zf:
            for path in paths:
                self._zip_dir(path, zf, incl_dir=True)

        # Change the stream position to the start of the stream
        # see https://docs.python.org/3/library/io.html#io.IOBase.seek
        file_data.seek(0)
        # read bytes to the EOF
        file_data_read = file_data.read()
        # encode bytes for output to return
        out = base64.encodebytes(file_data_read)
        return out

    def guess_lang(self, sample):
        """
        This method is for others to implement.
        """
        raise NotImplementedError(_("the method guess_lang has not been implemented yet"))

    def no_accent_vietnamese(self, s):
        """
        Convert Vietnamese unicode string from 'Tiếng Việt có dấu' thanh 'Tieng Viet khong dau'
        :param s: text: input string to be converted
        :return : string converted
        """
    #     s = s.decode('utf-8')
        s = re.sub(u'[àáạảãâầấậẩẫăằắặẳẵ]', 'a', s)
        s = re.sub(u'[ÀÁẠẢÃĂẰẮẶẲẴÂẦẤẬẨẪ]', 'A', s)
        s = re.sub(u'[èéẹẻẽêềếệểễ]', 'e', s)
        s = re.sub(u'[ÈÉẸẺẼÊỀẾỆỂỄ]', 'E', s)
        s = re.sub(u'[òóọỏõôồốộổỗơờớợởỡ]', 'o', s)
        s = re.sub(u'[ÒÓỌỎÕÔỒỐỘỔỖƠỜỚỢỞỠ]', 'O', s)
        s = re.sub(u'[ìíịỉĩ]', 'i', s)
        s = re.sub(u'[ÌÍỊỈĨ]', 'I', s)
        s = re.sub(u'[ùúụủũưừứựửữ]', 'u', s)
        s = re.sub(u'[ƯỪỨỰỬỮÙÚỤỦŨ]', 'U', s)
        s = re.sub(u'[ỳýỵỷỹ]', 'y', s)
        s = re.sub(u'[ỲÝỴỶỸ]', 'Y', s)
        s = re.sub(u'[Đ]', 'D', s)
        s = re.sub(u'[đ]', 'd', s)
        return s

    def sum_digits(self, n, number_of_digit_return=None):
        """
        This will sum all digits of the given number until the result has x digits where x is number_of_digit_return
        @param n: the given number for sum of its digits
        @type n: int|float
        @param number_of_digit_return: the number of digist in the return result.
            For example, if n=178 and number_of_digit_return=2, the result will be 16. However, if number_of_digit_return <= 1, the result will be 7 (=1+6 again)
            
        @return: the sum of all the digits until the result has `number_of_digit_return` digits
        @rtype: int
        """
        s = 0
        for d in str(n):
            if d.isdigit():
                s += int(d)

        str_len = len(str(s))
        if isinstance(number_of_digit_return, int) and str_len > number_of_digit_return and str_len > 1 :
            return self.sum_digits(s)
        return s

    def find_nearest_lucky_number(self, n, rounding=0, round_up=False):
        """
        9 is lucky number
        This will find the nearest integer if the given number that have digits sum = 9 (sum digits until 1 digit is returned)
        @param n: the given number for finding its nearest lucky number
        @type n: int|float
        @param rounding: the number of digist for rounding
            For example, if n=178999 and rounding=2, the result will be 178900. However, if rounding = 4, the result will be 170000
            
        @return: the lucky number
        @rtype: int
        """
        if rounding < 0:
            rounding = 0
        # replace last x digits with zero by rounding up/down, where x is the given round_digits
        n = round(n, rounding) if round_up else round(n, -rounding)
        # calculate adjusting step
        step = 1
        for x in range(rounding):
            step = step * 10

        while self.sum_digits(n, 1) != 9:
            if isinstance(n, int):
                if round_up:
                    n += step
                else:
                    n -= step
            else:
                n = round(n)
        return n

    def get_host_ip(self):
        """
        This method return the IP of the host where the Odoo instance is running.
        If the instance is deployed behind a reverse proxy, the returned IP will be the IP of the proxy instead.
        """
        url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + MY_IP_ROUTE
        try:
            respond = get(url)
        # catch SSLError when the url comes with an invalid SSL certificate (e.g, a self-signed one)
        except SSLError as e:
            # ignore ssl certificate validation
            respond = get(url, verify=False)
        return respond.text

