import datetime
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from .type_conversions import from_dss_string_date_to_datetime


def compute_antecedent_date(datetime_date: datetime.datetime, time_unit: str, time_value:int):
    """
    Computes an antecedent date based on a reference date.

    :param: datetime_date:  datetime.datetime: The date with a timezone to switch to UTC.
    :param: time_unit:  str: The time unit to substract from the 'datetime_date'.
    :param: time_value:  int: The number of time units to substract from the 'datetime_date'.
    :returns: antecedent_date: datetime.datetime: Date antecedent to 'datetime_date'.
    """
    ALLOWED_TIME_UNITS = ["years", "months", "weeks", "days", "hours"]
    if time_unit == "years":
        time_delta = relativedelta(years=-time_value)
    elif time_unit == "months":
        time_delta = relativedelta(months=-time_value)
    elif time_unit == "weeks":
        time_delta = relativedelta(weeks=-time_value)
    elif time_unit == "days":
        time_delta = relativedelta(days=-time_value)
    elif time_unit == "hours":
        time_delta = relativedelta(hours=-time_value)
    else:
        raise ValueError(f"{time_unit} is not a recognized time_unit. Allowed time units are {ALLOWED_TIME_UNITS}.")
    antecedent_date = datetime_date + time_delta
    return antecedent_date


def compute_future_date(datetime_date: datetime.datetime, time_unit: str, time_value: int):
    """
    Computes a future date based on a reference date.

    :param: datetime_date:  datetime.datetime: The date with a timezone to switch to UTC.
    :param: time_unit:  str: The time unit to substract from the 'datetime_date'.
    :param: time_value:  int: The number of time units to substract from the 'datetime_date'.
    :returns: antecedent_date: datetime.datetime: Date antecedent to 'datetime_date'.
    """
    return compute_antecedent_date(datetime_date, time_unit, -time_value)


def simplify_date(datetime_date: datetime.datetime, date_components_to_simplify: list):
    """
    Simplifies the components of a date. 
    
    :param: datetime_date: datetime.datetime: The date with a timezone to switch to UTC.
    :param: date_components_to_simplify list: List of the date components to simplify.
        Available options are ["month", "day", "hour", "minute", "second", "microsecond"].

        Example: Simplifying the date '2013-05-30T15:16:13.000Z' on components ["hour", "minute", "second"]
            would lead to '2013-05-30T00:00:00.000Z'.
    :returns: simplified_date: datetime.datetime: Result of date components simplifications applied on 'datetime_date'.
    """
    time_delta = relativedelta()

    if "month" in date_components_to_simplify:
        time_delta += relativedelta(months=-datetime_date.month + 1)

    if "day" in date_components_to_simplify:
        time_delta += relativedelta(days=-datetime_date.day + 1)

    if "hour" in date_components_to_simplify:
        time_delta += relativedelta(hours=-datetime_date.hour)

    if "minute" in date_components_to_simplify:
        time_delta += relativedelta(minutes=-datetime_date.minute)

    if "second" in date_components_to_simplify:
        time_delta += relativedelta(seconds=-datetime_date.second)

    if "microsecond" in date_components_to_simplify:
        time_delta += relativedelta(microseconds=-datetime_date.microsecond)

    simplified_date = datetime_date + time_delta

    return simplified_date


def extract_date_components(datetime_date: datetime.datetime, date_components_to_extract: list):
    """
    Extracts date components from a date.

    :param: datetime_date: datetime.datetime: The date with a timezone to switch to UTC.
    :param: date_components_to_extract list: List of the date components to extract. Available options are: 
        ["year", "month", "week_of_year", "day_of_year", "week_of_month",
         "day_of_month", "day_of_week", "hour", "minute", "second", "microsecond"].

    :returns: date_components: dict: Date components extracted from 'datetime_date'.
    """
    
    date_component_labels = [
        "year", "month", "week_of_year",
        "day_of_year", "week_of_month", "day_of_month",
        "day_of_week", "hour", "minute",
        "second", "microsecond"
    ]
    date_component_values = [
        datetime_date.year, datetime_date.month, int(datetime_date.strftime("%V")),
        datetime_date.timetuple().tm_yday, 1 + int(datetime_date.day/7.0), datetime_date.day,
        datetime_date.weekday() + 1, datetime_date.hour, datetime_date.minute,
        datetime_date.second, datetime_date.microsecond

    ]
    date_components = {label: value  for label, value in zip(date_component_labels, date_component_values)
                       if label in date_components_to_extract}
    
    return date_components


def compute_difference_between_dates(datetime_date_1: datetime.datetime, datetime_date_2: datetime.datetime,
                                     time_unit: str, bool_return_absolute_delta: bool=False,
                                     bool_log_errors: bool=False):
    """
    Computes the difference between dates.
    :param: datetime_date_1: datetime.datetime: A first datetime date.
    :param: datetime_date_2: datetime.datetime: A second datetime date.
    :param: time_unit: str: The time unit of the dates difference.
    :param: bool_return_absolute_delta: bool: Precises wheter you want to return the diffence in absolute value or not.
    :param: bool_log_errors: bool: Precises whether you want to log errors or not.

    :returns: time_difference: int: The time difference between 'datetime_date_1' and 'datetime_date_2'.
    """
    try:
        time_delta = datetime_date_1 - datetime_date_2

        if time_unit == "days":
            time_difference = time_delta.days

        elif time_unit == "seconds":
            time_difference = time_delta.seconds

        elif time_unit == "microseconds":
            time_difference = time_delta.microseconds

        else:
            log_message = f"time unit '{time_unit}' is not handled by this function! "\
            "Please choose a value in ['days', 'seconds', 'microseconds']."
            raise ValueError(log_message)

        if bool_return_absolute_delta:
            time_difference = abs(time_difference)
            pass

    except Exception as e:
        exception_message = ". ".join(arg for arg in e.args)
        log_message = "Exception met during the computation : 'None' will be returned.\n"\
        f"Exception was '{exception_message}'"
        if bool_log_errors:
            print(log_message)
        time_difference = None

    return time_difference


def compute_date_time_granularity(datetime_date: datetime, time_granularity_to_compute: str):
    """
    Computes the time granularity of a datetime date.
    
    :param datetime_date: datetime: A datetime date.
    :param time_granularity_to_compute: str: The time granularity to compute.
        Available choices are ["days", "weeks", "months", "quarters"]

    :returns: date_time_granularity: str: A string representing the time granularity of the given date.
        Format is:
        - 'YYYY-MM-DD' for days
        - 'YYYY-week-WW' for weeks
        - 'YYYY-month-MM' for months
        - 'YYYY-quarter-Q' for quarters.
    """
    ALLOWED_TIME_GRANULARITIES = ["days", "weeks", "months", "quarters"]
    if time_granularity_to_compute not in ALLOWED_TIME_GRANULARITIES:
        log_message = f"Time granularity '{time_granularity_to_compute}' is not implemented. "
        log_message +=  "Please choose an option among ['days', 'weeks', 'months', 'quarters']"
        raise ValueError(log_message)
    
    year = datetime_date.year
    month = datetime_date.month
    day_of_month = datetime_date.day
    week_of_year = datetime_date.isocalendar()[1]
    
    if time_granularity_to_compute == "days":
        date_time_granularity = f"{year}-{month:02d}-{day_of_month:02d}"

    elif time_granularity_to_compute == "weeks":
        # Correcting starting/ending weeks of year based on 'ISO 8601' convention:
        if (month == 12) and (week_of_year == 1):
            year += 1
        elif (month == 1) and week_of_year >= 52:
            year -= 1
        date_time_granularity = f"{year}-week-{week_of_year}"

    elif time_granularity_to_compute == "months":
        date_time_granularity = f"{year}-month-{month:02d}"

    elif time_granularity_to_compute == "quarters":
        quarter = (month - 1) // 3 + 1
        date_time_granularity = f"{year}-quarter-{quarter}"

    return date_time_granularity


def compute_datetime_date_period_information(datetime_date, time_unit):
    """
    Takes a date and a period type ('week', 'month', 'quarter', 'year') and returns the start date of the time period,
    a label for the time period, and the end date of the time period.

    :param datetime_date: datetime.date or datetime.datetime - The date for which to find the time period.
    :param time_unit: str - The type of time period ('weeks', 'months', 'quarters', 'years').
    :return: datetime_date_time_period_boundaries: dict: A dictionary containing the start and end dates of the time period,
        as well as their label. It has the format: 
            datetime_date_time_period_boundaries = {
                "period_start_date": period_start_date, # datetime.datetime --> start date of the period
                "period_label": period_label, # str --> label of the period
                "period_end_date": period_end_date, # datetime.datetime --> end date of the period
            }
    """
    ALLOWED_TIME_UNITS = ['weeks', 'months', 'quarters', 'years']
    if time_unit not in ALLOWED_TIME_UNITS:
        raise ValueError(f"Invalid time period. Choose from {ALLOWED_TIME_UNITS} .")

    if time_unit == 'weeks':
        # Start of the week (Monday)
        period_start_date = datetime_date - timedelta(days=datetime_date.weekday())
        period_end_date = period_start_date + timedelta(days=6)

    elif time_unit == 'months':
        # First day of the month
        period_start_date = datetime_date.replace(day=1)
        # Last day of the month
        next_month = period_start_date.replace(day=28) + timedelta(days=4)
        period_end_date = next_month - timedelta(days=next_month.day)

    elif time_unit == 'quarters':
        # Determine the quarter
        quarter = (datetime_date.month - 1) // 3 + 1
        # First day of the quarter
        period_start_date = datetime.datetime(datetime_date.year, 3 * quarter - 2, 1)
        # Last day of the quarter
        if quarter < 4:
            period_end_date = datetime.datetime(datetime_date.year, 3 * quarter + 1, 1) - timedelta(days=1)
        else:
            period_end_date = datetime.datetime(datetime_date.year, 12, 31)

    elif time_unit == 'years':
        # First day of the year
        period_start_date = datetime.datetime(datetime_date.year, 1, 1)
        # Last day of the year
        period_end_date = datetime.datetime(datetime_date.year, 12, 31)

    period_label = compute_date_time_granularity(period_start_date, time_unit)
    period_information = {
        "period_start_date": period_start_date,
        "period_label": period_label,
        "period_end_date": period_end_date,
    }
    return period_information


class datesFilteringManager:
    """
    Computes dates boundaries to filter data on a specific batch of dates.
    Dates boundaries will be available through attributes: 
        - 'datesFilteringManager.min_date'
        - 'datesFilteringManager.max_date'
    """

    DEFAULT_MIN_DATE = "1900-01-01T00:00:00.000Z"
    DEFAULT_MAX_DATE = "2999-12-31T23:59:59.999Z"

    def __init__(
        self,
        dates_filtering_strategy: str="keep_dates_in_a_range_before_today",
        filtering_time_unit: str=None,
        filtering_time_frame: int=None,
        filtering_reference_date: str or datetime.datetime=None,
        target_timezone="UTC"
    ):
        """
        :param dates_filtering_strategy: str: This parameter precises which strategy should be applied to 
            filter the data. Available options are: 
                - 'keep_dates_in_a_range_before_today': Choose this option if you want to filter the data on all dates
                    that are between 'today' and  a date antecedent to 'today'.
                - 'keep_dates_in_a_range_before_a_past_date': Choose this option if you want to filter the data on all dates
                    that are between 'a past date' and  a date antecedent to 'a past date'.
                - 'keep_all_dates_before_a_reference_date': Choose this option if you want to filter the data on all dates
                    that are prior to a reference date.
                - 'keep_all_dates_after_a_reference_date': Choose this option if you want to filter the data on all dates
                    that follows a reference date.
                - 'keep_all_dates': Choose this option if you don't want to filter the data. In that case:
                    - 'datesFilteringManager.min_date' will be a datetime.datetime date associated to the DEFAULT_MIN_DATE.
                    - 'datesFilteringManager.max_date' will be a datetime.datetime date associated to the DEFAULT_MAX_DATE. 

        :param filtering_time_unit: str:  The time unit of the computations to apply on 'filtering_reference_date'.
            It should be 'None' if the 'dates_filtering_strategy' is NOT 'keep_dates_in_a_range_before_a_past_date', 
            'keep_all_dates_before_a_reference_date' or 'keep_all_dates_after_a_reference_date'

        :param: filtering_time_frame:  int: The number of time units of the computations to apply on 'filtering_reference_date'.
        It should be 'None' if the 'dates_filtering_strategy' is NOT 'keep_dates_in_a_range_before_a_past_date', 
            'keep_all_dates_before_a_reference_date' or 'keep_all_dates_after_a_reference_date'

        :param filtering_reference_date: str|datetime.datetime: Reference date to set.
            It should be 'None' if the 'dates_filtering_strategy' is 'keep_dates_in_a_range_before_a_past_date', 
            'keep_all_dates_before_a_reference_date' or 'keep_all_dates_after_a_reference_date'.
            Both datetime.datetime and string of a 'ISO-8601' date are accepted
        
        :param target_timezone: str: Timezone where to convert the dates.
            Supported timezones are the ones listed in pytz using 'pytz.all_timezones'.
        """
        self.target_timezone = target_timezone
        self.dates_filtering_strategy = dates_filtering_strategy
        if filtering_reference_date is not None:
            self.filtering_reference_date = self.read_date_in_datetime(
                filtering_reference_date
                )
        else:
            self.filtering_reference_date = None
        self.filtering_time_unit = filtering_time_unit
        self.filtering_time_frame = filtering_time_frame
        self.min_date = None
        self.max_date = None 
        self.compute_date_boundaries()

    def read_date_in_datetime(self, date: str or datetime.datetime):
        """
        Reads a date in datetime.datetime .

        :param date: str|datetime.datetime: Date to read in datetime.
            Both datetime.datetime and string of a 'ISO-8601' date are accepted
        :returns: datetime_date: datetime.datetime: 'date' as a datetime.datetime object.
        """
        if isinstance(date, datetime.datetime):
            datetime_date = date
        elif isinstance(date, str):
            datetime_date = from_dss_string_date_to_datetime(date, self.target_timezone)
        return datetime_date
    
    def compute_date_boundaries(self):
        """
        Computes the min and max dates boundaries based on the class parameters.
        """
        if self.dates_filtering_strategy == "keep_dates_in_a_range_before_today":
            self.max_date = datetime.datetime.now()
            self.min_date = compute_antecedent_date(
                self.max_date, self.filtering_time_unit, self.filtering_time_frame
            )

        elif (
            self.dates_filtering_strategy == "keep_dates_in_a_range_before_a_past_date"
        ):
            self.max_date = self.filtering_reference_date
            self.min_date = compute_antecedent_date(
                self.max_date, self.filtering_time_unit, self.filtering_time_frame
            )

        elif self.dates_filtering_strategy == "keep_all_dates_before_a_reference_date":
            self.max_date = self.filtering_reference_date
            self.min_date = from_dss_string_date_to_datetime(
                self.DEFAULT_MIN_DATE,
                self.target_timezone
            )

        elif self.dates_filtering_strategy == "keep_all_dates_after_a_reference_date":
            self.max_date = from_dss_string_date_to_datetime(
                self.DEFAULT_MAX_DATE,
                self.target_timezone
            )
            self.min_date = self.filtering_reference_date

        elif self.dates_filtering_strategy == "keep_all_dates":
            self.min_date = from_dss_string_date_to_datetime(
                self.DEFAULT_MIN_DATE,
                self.target_timezone
            )
            self.max_date = from_dss_string_date_to_datetime(
                self.DEFAULT_MAX_DATE,
                self.target_timezone
            )
        pass