"""

    AUTHOR:

        Khushal P Soonderji

    DATE:

        Friday, 21st jun, 2024

    OBJECTIVE:

        To provide an easy way to work with time.

    REFERENCES:

        N/A

    DOWNLOADS:

        N/A

"""

#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                   IMPORT                                                      ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# To make sibling directories accessible for imports:
import sys
sys.path.append(".")
sys.path.append("..")

# For date and time handling:
from datetime import datetime, time, timedelta, tzinfo
import pytz
import tzlocal
import dateparser

# For mathematical operations:
import math

# To handle date-time objects from a Numpy array and Pandas Dataframe:
import numpy as np
import pandas as pd

# To work with datatypes:
from typing import List, Literal


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                           MACROS / ONE-TIME INIT                                              ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# Date-Time Formats:
DATE_TIME_FORMATS = (
    "%d/%m/%y",
    "%d-%b-%y",
    "%d-%m-%y",
    "%d.%m.%y",
    "%d/%m/%Y",
    "%d-%b-%Y",
    "%d-%m-%Y",
    "%d.%m.%Y",
    "%d/%b",
    "%d%m%Y",
    "%Y%m%d",
    "%Y/%m/%d",
    "%Y-%m-%d",
    "%Y-%m-%d %H:%M:%S"
)

# Useful Timezones:
TIMEZONE_UTC = pytz.timezone("UTC")
TIMEZONE_IST = pytz.timezone("Asia/Kolkata")
TIMEZONE_ET = pytz.timezone("America/New_York")
TIMEZONE_CT = pytz.timezone("America/Chicago")
TIMEZONE_MT = pytz.timezone("America/Denver")
TIMEZONE_PT = pytz.timezone("America/Los_Angeles")
TIMEZONE_JST = pytz.timezone("Asia/Tokyo")
TIMEZONE_CET = pytz.timezone("Europe/Paris")
TIMEZONE_GMT = pytz.timezone("GMT")
TIMEZONE_AEST = pytz.timezone("Australia/Sydney")
TIMEZONE_NZST = pytz.timezone("Pacific/Auckland")
TIMEZONE_CST = pytz.timezone("Asia/Shanghai")
TIMEZONE_KST = pytz.timezone("Asia/Seoul")
TIMEZONE_MSK = pytz.timezone("Europe/Moscow")
TIMEZONE_BRT = pytz.timezone("America/Sao_Paulo")
TIMEZONE_GST = pytz.timezone("Asia/Dubai")
TIMEZONE_SAST = pytz.timezone("Africa/Johannesburg")
TIMEZONE_AST = pytz.timezone("Asia/Riyadh")


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                VARIABLES                                                      ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


# --- Nothing Yet


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                                  FUNCTIONS                                                    ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


def translate_date_time_string(
        datetime_string: str,
        source_format: str = None,
        destination_format: str = "%Y-%m-%dT%H:%M:%S"
) -> str | None:

    """
    To convert an input datetime string to a different format.
    :param datetime_string: The datetime string to translate.
    :param source_format: The current format of the string. If not provided, dateparser will be used.
    :param destination_format: The format to convert to.
    :return: The converted datetime string.
    """

    try:

        if source_format is None: datetime_obj = dateparser.parse(datetime_string)
        else: datetime_obj = datetime.strptime(datetime_string, source_format)
        return datetime_obj.strftime(destination_format)

    except Exception as exception:
        return None


# ---------------------------------------------------------------------------------------------------------------------


def parse_date_time(
        input_value: datetime | str | int | float,
        timezone: str | tzinfo = None,
        date_formats: List[str] = None
) -> datetime:

    """
    To take any kind of input and interpret the datetime from it.
    :param input_value: Either a string or an integer or some form of datetime representation.
    :param timezone: The timezone to apply to the interpreted datetime. IF THE INPUT IS NAIVE, THIS TIMEZONE WILL BE
        APPLIED AS IS, ELSE THE TIMEZONE WILL BE TRANSLATED.
    :param date_formats: The string formats to consider when parsing a string input.
    :return: The parsed datetime or null.
    """

    datetime_object = None
    date_formats = date_formats or DATE_TIME_FORMATS

    # It could either be in seconds or milliseconds from epoch time's base date (January 1, 1970),
    # or it could be days since Microsoft Excel's base date (December 31, 1899).
    if isinstance(input_value, (int, float, np.number)) and not np.isnan(input_value):
        if input_value > 9999999999.0: datetime_object = datetime.fromtimestamp(input_value / 1000.0)
        if input_value > 999999.0: datetime_object = datetime.fromtimestamp(input_value)
        else: datetime_object = datetime.fromtimestamp(input_value * 24 * 60 * 60.0) - timedelta(days = 25569)

    # The input can even be a pre-formatted date:
    if isinstance(input_value, str):
        datetime_object = dateparser.parse(
            input_value,
            date_formats = date_formats,
            settings = {
                "DATE_ORDER": "DMY",
                "PREFER_DAY_OF_MONTH": "first",
            }
        )

    # If the type is a datetime object, then return it as it is:
    if isinstance(input_value, datetime):
        datetime_object = input_value

    # If the type is the native datetime format of pandas:
    if isinstance(input_value, pd._libs.tslibs.timestamps.Timestamp):
        datetime_object = input_value.to_pydatetime()

    # Process the timezone:
    if datetime_object is not None and timezone is not None:
        datetime_object = to_timezone(datetime_object, timezone)

    # Done here:
    return datetime_object


# ---------------------------------------------------------------------------------------------------------------------


def get_system_timezone(as_string = False) -> pytz.BaseTzInfo | str:

    """
    Returns the pytz object or name string of the machine this code is running on.
    :param as_string; Set to False for a timezone object, True for the name string of the timezone.
    :return: The machine's timezone or string of the name of the timezone.
    """

    tz_name = tzlocal.get_localzone_name()
    return tz_name if as_string else pytz.timezone(tz_name)


# ---------------------------------------------------------------------------------------------------------------------


def get_current_date_time(
        timezone: str | tzinfo = None,
        as_string: bool = False
) -> datetime:

    """
    Returns the current time as a datetime object.
    :param timezone: The timezone to apply to the returned datetime.
    :param as_string: Whether, or not, you want the output as a string.
    :return: The datetime object/string representing the current time.
    """

    if timezone is not None and isinstance(timezone, str): timezone = pytz.timezone(timezone)
    now = datetime.now(timezone)
    return now.isoformat() if as_string else now


# ---------------------------------------------------------------------------------------------------------------------


def get_current_ist_date_time(
        as_string = False
) -> datetime:

    """
    Gives out the current time in IST timezone.
    :param as_string: Whether, or not, you want the output as a string.
    :return: The datetime object or string representing the current time.
    """

    return get_current_date_time(
        timezone = TIMEZONE_IST,
        as_string = as_string
    )


# ---------------------------------------------------------------------------------------------------------------------


def get_current_utc_date_time(
        as_string: bool = False
) -> datetime:

    """
    Gives out the current time in UTC timezone.
    :param as_string: Whether, or not, you want the output as a string.
    :return: The datetime object or string representing the current time.
    """

    return get_current_date_time(
        timezone = TIMEZONE_UTC,
        as_string = as_string
    )


# ---------------------------------------------------------------------------------------------------------------------


def as_if_timezone(
        datetime_object: datetime,
        timezone: str | tzinfo
) -> datetime:

    """
    Ignores existing timezone info and applies the intended timezone.
    The time stays the same, only the timezone marker changes.
    e.g. for IST to UTC: 2024-08-09 00:00:00+05:30 --> 2024-08-09 00:00:00+00:00
    HINT: IT PRETENDS "AS IF" THE TIMEZONE WAS THE INPUT TIMEZONE.
    :param datetime_object: The datetime object on which the timezone needs to be applied.
    :param timezone: The timezone that needs to be applied.
    :return: A timezone-aware datetime object.
    """

    tz_object = pytz.timezone(timezone) if isinstance(timezone, str) else timezone
    return tz_object.localize(datetime_object.replace(tzinfo = None))


# ---------------------------------------------------------------------------------------------------------------------


def to_timezone(
        datetime_object: datetime,
        timezone: str | tzinfo
) -> datetime:

    """
    Converts from one timezone to another. The time is adjusted by computing the difference between the two timezones.
    NOTE: THIS FUNCTION APPLIES TH EINPUT TIMEZONE IF THE INPUT DATETIME WAS TIMEZONE-NAIVE.
    e.g. for IST to UTC: 2024-08-09 00:00:00+05:30 --> 2024-08-08 18:30:00+00:00
    :param datetime_object: The datetime object on which the timezone needs to be applied.
    :param timezone: The timezone that needs to be applied.
    :return: A timezone-aware datetime object.
    """

    if isinstance(timezone, str): timezone = pytz.timezone(timezone)
    if datetime_object.tzinfo is None: dto = as_if_timezone(datetime_object = datetime_object, timezone = timezone)
    else: dto = datetime_object.astimezone(timezone)
    return dto


# ---------------------------------------------------------------------------------------------------------------------


def round_date_time(
        datetime_object: datetime,
        rounding_seconds: int,
        mode: Literal["floor", "ceil", "nearest"] = "nearest"
) -> datetime:

    """
    'Snaps' the time to the closest 'n-second' window. For example, if you want to round the time off to the nearest
    5-minute period (maybe for use cases like trading), you set the value of seconds to 300. This way any input value
    of, say, 12:01:50 AM gets converted to 12:00:00 AM; and a value of 12:02:31 AM gets converted to 12:05:00 AM.
    :param datetime_object: The input datetime that you want to round off.
    :param rounding_seconds: The period of rounding in seconds. 60 for 1 minute, 300 for 5 minutes and so on.
    :param mode: "floor" means the previous time bucket, "ceil" means the next time bucket, and "nearest" means
        whichever is closer will be picked.
    :return: The rounded date-time (with the original timezone).
    """

    def round_by_mode() -> float | int:
        if mode == "nearest": return round(int(total_seconds) / rounding_seconds) * rounding_seconds
        if mode == "ceil": return math.ceil(int(total_seconds) / rounding_seconds) * rounding_seconds
        if mode == "floor": return math.floor(int(total_seconds) / rounding_seconds) * rounding_seconds

    # For timezone-naive cases:
    if datetime_object.tzinfo is None:
        total_seconds = datetime_object.timestamp()
        rounded_seconds = round_by_mode()
        rounded_datetime = datetime.fromtimestamp(rounded_seconds)
        return rounded_datetime

    # For timezone-aware cases:
    else:
        original_tz = datetime_object.tzinfo
        _dt = to_timezone(datetime_object, timezone = original_tz)
        total_seconds = _dt.timestamp()
        rounded_seconds = round_by_mode()
        rounded_datetime = datetime.fromtimestamp(rounded_seconds, tz = original_tz)
        return rounded_datetime


#   *****************************************************************************************************************
#  *****                                                                                                          ****
# ***                                               MAIN PROGRAM                                                    ***
#  *****                                                                                                          ****
#   *****************************************************************************************************************


if __name__ == "__main__":

    pass
