# jdatetime is (c) 2010-2011 Milad Rastian <eslashmili at gmail.com>.
# The jdatetime module was contributed to Python as of Python 2.7 and thus
# was licensed under the Python license. Same license applies to all files in
# the jdatetime package project.

from __future__ import annotations

import datetime as py_datetime
import locale as _locale
import platform
import re
import time as py_time
import typing
from functools import partial as _partial
from typing import ClassVar as _ClassVar, overload as _overload

try:
    from greenlet import getcurrent as get_ident  # type: ignore
except ImportError:
    from _thread import get_ident

from jalali_core import GregorianToJalali, JalaliToGregorian, j_days_in_month

__VERSION__ = '6.1.0'
MINYEAR = 1
MAXYEAR = 9377

STRFTIME_MAPPING = {
    # A mapping between symbol to it's helper function and function kwargs
    # symbol: (helper_function_name, {kwargs})
    '%a': ('_strftime_get_method_value', {'attr': 'jweekday_short', 'fmt': '%s'}),
    '%A': ('_strftime_get_method_value', {'attr': 'jweekday', 'fmt': '%s'}),
    '%b': ('_strftime_get_method_value', {'attr': 'jmonth_short', 'fmt': '%s'}),
    '%B': ('_strftime_get_method_value', {'attr': 'jmonth', 'fmt': '%s'}),
    '%c': ('_strftime_c', {}),
    '%d': ('_strftime_get_attr_value', {'attr': 'day', 'fmt': '%02.d'}),
    '%-d': ('_strftime_get_attr_value', {'attr': 'day', 'fmt': '%d'}),
    '%j': ('_strftime_get_method_value', {'attr': 'yday', 'fmt': '%03.d'}),
    '%m': ('_strftime_get_attr_value', {'attr': 'month', 'fmt': '%02.d'}),
    '%-m': ('_strftime_get_attr_value', {'attr': 'month', 'fmt': '%d'}),
    '%w': ('_strftime_get_method_value', {'attr': 'weekday', 'fmt': '%d'}),
    '%W': ('_strftime_get_method_value', {'attr': 'weeknumber', 'fmt': '%d'}),
    '%Y': ('_strftime_get_attr_value', {'attr': 'year', 'fmt': '%d'}),
    '%y': ('_strftime_get_attr_value', {'attr': 'cyear', 'fmt': '%02.d'}),
    '%f': ('_strftime_get_attr_value', {'attr': 'microsecond', 'fmt': '%06.d', 'fb': '000000'}),
    '%H': ('_strftime_get_attr_value', {'attr': 'hour', 'fmt': '%02.d', 'fb': '00'}),
    '%-H': ('_strftime_get_attr_value', {'attr': 'hour', 'fmt': '%d', 'fb': '0'}),
    '%I': ('_strftime_get_attr_value', {'attr': '_hour12', 'fmt': '%02.d', 'fb': '12'}),
    '%-I': ('_strftime_get_attr_value', {'attr': '_hour12', 'fmt': '%d', 'fb': '12'}),
    '%M': ('_strftime_get_attr_value', {'attr': 'minute', 'fmt': '%02.d', 'fb': '00'}),
    '%-M': ('_strftime_get_attr_value', {'attr': 'minute', 'fmt': '%d', 'fb': '0'}),
    '%S': ('_strftime_get_attr_value', {'attr': 'second', 'fmt': '%02.d', 'fb': '00'}),
    '%-S': ('_strftime_get_attr_value', {'attr': 'second', 'fmt': '%d', 'fb': '0'}),
    '%p': ('_strftime_p', {}),
    '%x': ('_strftime_x', {}),
    '%X': ('_strftime_cap_x', {}),
    '%z': ('_strftime_z', {}),
    '%Z': ('_strftime_cap_z', {}),
}

timedelta = py_datetime.timedelta
tzinfo = py_datetime.tzinfo

if platform.system() == 'Windows':
    FA_LOCALE = 'Persian_Iran'
else:
    FA_LOCALE = 'fa_IR'


def _format_time(hour, minute, second, microsecond, timespec='auto') -> str:
    specs = {
        'hours': '{:02d}',
        'minutes': '{:02d}:{:02d}',
        'seconds': '{:02d}:{:02d}:{:02d}',
        'milliseconds': '{:02d}:{:02d}:{:02d}.{:03d}',
        'microseconds': '{:02d}:{:02d}:{:02d}.{:06d}',
    }

    if timespec == 'auto':
        # Skip trailing microseconds when equals to 0
        timespec = 'microseconds' if microsecond else 'seconds'
    elif timespec == 'milliseconds':
        # convert to millisecond
        microsecond //= 1000

    try:
        fmt = specs[timespec]
    except KeyError:
        raise ValueError(f'Unknown timespec value: {timespec}')
    else:
        return fmt.format(hour, minute, second, microsecond)


class time(py_datetime.time):
    def __repr__(self) -> str:
        return f'jdatetime.time({self.hour}, {self.minute}, {self.second})'


_thread_local_locales = {}


def set_locale(locale: str | None) -> str | None:
    """Set the thread local module locale. This will be the default locale
    for new date/datetime instances in current thread.
    Returns the previous value of locale set on current thread.

    Note: since Python thread identities maybe recycled and reused,
    always ensure the desied locale is set for current thread,
    or the locale maybe affected by previous threads with the same
    identity.

    :param str|None: locale
    :return: str|None
    """
    thread_identity = get_ident()
    prev_locale = _thread_local_locales.get(thread_identity)
    _thread_local_locales[thread_identity] = locale
    return prev_locale


def get_locale() -> str | None:
    """Get the thread local module locale. This will be the default locale
    for newly date/datetime instances in current thread.

    :return: str|None
    """
    return _thread_local_locales.get(get_ident())


class date:
    """date(year, month, day) --> date object"""

    """The earliest representable date, date(MINYEAR, 1, 1)"""
    min: _ClassVar[date]
    """The latest representable date, date(MAXYEAR, 12, 31)."""
    max: _ClassVar[date]
    """The smallest possible difference between
    non-equal date objects, timedelta(days=1)."""
    resolution: _ClassVar[timedelta] = py_datetime.date.resolution

    j_months_en: _ClassVar = [
        'Farvardin',
        'Ordibehesht',
        'Khordad',
        'Tir',
        'Mordad',
        'Shahrivar',
        'Mehr',
        'Aban',
        'Azar',
        'Dey',
        'Bahman',
        'Esfand',
    ]
    j_months_short_en: _ClassVar = [
        'Far',
        'Ord',
        'Kho',
        'Tir',
        'Mor',
        'Sha',
        'Meh',
        'Aba',
        'Aza',
        'Dey',
        'Bah',
        'Esf',
    ]
    j_weekdays_en: _ClassVar = [
        'Saturday',
        'Sunday',
        'Monday',
        'Tuesday',
        'Wednesday',
        'Thursday',
        'Friday',
    ]
    j_weekdays_short_en: _ClassVar = [
        'Sat',
        'Sun',
        'Mon',
        'Tue',
        'Wed',
        'Thu',
        'Fri',
    ]
    j_ampm_en: _ClassVar = {'PM': 'PM', 'AM': 'AM'}
    j_months_fa: _ClassVar = [
        'فروردین',
        'اردیبهشت',
        'خرداد',
        'تیر',
        'مرداد',
        'شهریور',
        'مهر',
        'آبان',
        'آذر',
        'دی',
        'بهمن',
        'اسفند',
    ]
    j_months_short_fa: _ClassVar = [
        'فرو',
        'ارد',
        'خرد',
        'تیر',
        'مرد',
        'شهر',
        'مهر',
        'آبا',
        'آذر',
        'دی',
        'بهم',
        'اسف',
    ]
    j_weekdays_fa: _ClassVar = [
        'شنبه',
        'یک‌شنبه',
        'دوشنبه',
        'سه‌شنبه',
        'چهارشنبه',
        'پنج‌شنبه',
        'جمعه',
    ]
    j_ampm_fa: _ClassVar = {'PM': 'بعد از ظهر', 'AM': 'قبل از ظهر'}

    @property
    def year(self) -> int:
        return self.__year

    @property
    def cyear(self) -> int:
        return self.__year % 100

    @property
    def month(self) -> int:
        return self.__month

    @property
    def day(self) -> int:
        return self.__day

    def timetuple(self) -> py_time.struct_time:
        "Return local time tuple compatible with time.localtime()."
        return self.togregorian().timetuple()

    @property
    def locale(self) -> str | None:
        return self.__locale

    __year = 0
    __month = 0
    __day = 0
    __locale = None

    def _check_arg(self, value) -> bool:
        return isinstance(value, int)

    def __init__(self, year: int, month: int, day: int, **kwargs):
        """date(year, month, day) --> date object"""
        if not (self._check_arg(year) and self._check_arg(month) and self._check_arg(day)):
            raise TypeError('an integer is required' + repr(type(year)))
        if year < MINYEAR or year > MAXYEAR:
            raise ValueError('year is out of range')
        self.__year = year

        if month < 1 or month > 12:
            raise ValueError('month must be in 1..12')
        self.__month = month

        if day < 1:
            raise ValueError('day is out of range for month')
        if self.__month == 12 and day == 30 and self.isleap():
            # for leap years it's ok to have 30 days in Esfand
            pass
        elif (
            self.__month == 12 and day == 30 and not self.isleap() or day > j_days_in_month[self.__month - 1]
        ):
            raise ValueError('day is out of range for month')
        self.__day = day
        self.__locale = kwargs['locale'] if (kwargs.get('locale')) else get_locale()

        if self._is_fa_locale():
            self.j_months = self.j_months_fa
            self.j_months_short = self.j_months_short_fa
            self.j_weekdays = self.j_weekdays_fa
            self.j_weekdays_short = self.j_weekdays_fa
            self.j_ampm = self.j_ampm_fa
        else:
            self.j_months = self.j_months_en
            self.j_months_short = self.j_months_short_en
            self.j_weekdays = self.j_weekdays_en
            self.j_weekdays_short = self.j_weekdays_short_en
            self.j_ampm = self.j_ampm_en

    def _is_fa_locale(self) -> bool:
        if self.__locale and self.__locale == FA_LOCALE:
            return True
        if FA_LOCALE in _locale.getlocale():
            return True
        if None not in _locale.getlocale():
            return False
        return FA_LOCALE in _locale.getdefaultlocale()

    def isleap(self) -> bool:
        """check if year is leap year
        algortim is based on http://en.wikipedia.org/wiki/Leap_year"""
        return self.year % 33 in (1, 5, 9, 13, 17, 22, 26, 30)

    def togregorian(self) -> py_datetime.date:
        """Convert current jalali date to gregorian and return datetime.date"""
        (y, m, d) = JalaliToGregorian(self.year, self.month, self.day).getGregorianList()
        return py_datetime.date(y, m, d)

    @staticmethod
    def fromgregorian(**kw) -> date:
        """Convert gregorian to jalali and return jdatetime.date

        jdatetime.date.fromgregorian(day=X,month=X,year=X)
        jdatetime.date.fromgregorian(date=datetime.date)
        jdatetime.date.fromgregorian(date=datetime.date, locale='fa_IR')
        """
        locale = kw.get('locale')
        if 'date' in kw:
            d = kw['date']
            try:
                (y, m, d) = GregorianToJalali(d.year, d.month, d.day).getJalaliList()
                return date(y, m, d, locale=locale)
            except AttributeError:
                raise ValueError(
                    'When calling fromgregorian(date=) the parameter should be a date like object.'
                )
        if 'day' in kw and 'month' in kw and 'year' in kw:
            (year, month, day) = (kw['year'], kw['month'], kw['day'])
            (y, m, d) = GregorianToJalali(year, month, day).getJalaliList()
            return date(y, m, d, locale=locale)

        error_msg = ['fromgregorian have to be be called']
        error_msg += ['or']
        error_msg += ['fromgregorian(day=X,month=X,year=X)']
        error_msg += ['fromgregorian(date=datetime.date)']
        raise ValueError(' '.join(error_msg))

    @staticmethod
    def today() -> date:
        """Current date or datetime:  same as self.__class__.fromtimestamp(time.time())."""
        to = py_datetime.date.today()
        (y, m, d) = GregorianToJalali(to.year, to.month, to.day).getJalaliList()
        return date(y, m, d)

    @staticmethod
    def fromtimestamp(timestamp) -> date:
        d = py_datetime.date.fromtimestamp(timestamp)
        (y, m, d) = GregorianToJalali(d.year, d.month, d.day).getJalaliList()
        return date(y, m, d)

    @classmethod
    def fromisoformat(cls, date_string: str) -> date:
        """
        Convert an ISO 8601 formatted string to a jdatetime.date
        """
        if not isinstance(date_string, str):
            raise TypeError('fromisoformat: argument must be str')

        iso_format_regex = r'(\d{4})-?(\d{2})-?(\d{2})'
        matched_str = re.fullmatch(iso_format_regex, date_string)
        if matched_str is not None:
            return date(*map(int, matched_str.groups()))
        else:
            raise ValueError(f'Invalid isoformat string: {date_string!r}')

    def toordinal(self) -> int:
        """Return proleptic jalali ordinal. Farvardin 1 of year 1 which is equal to 622-3-21 of Gregorian."""
        d = self.togregorian()
        return d.toordinal() - 226894

    @staticmethod
    def fromordinal(ordinal: int) -> date:
        """int -> date corresponding to a proleptic Jalali ordinal.
        it starts from Farvardin 1 of year 1, which is equal to 622-3-21 of Gregorian"""
        if ordinal < 1:
            raise ValueError('ordinal must be >= 1')
        d = py_datetime.date.fromordinal(226894 + ordinal)
        (y, m, d) = GregorianToJalali(d.year, d.month, d.day).getJalaliList()
        return date(y, m, d)

    @staticmethod
    def j_month_to_num(month_name: str) -> int:
        return date.j_months_en.index(month_name.lower().capitalize()) + 1

    @staticmethod
    def j_month_short_to_num(month_name: str) -> int:
        return date.j_months_short_en.index(month_name.lower().capitalize()) + 1

    @staticmethod
    def j_month_fa_to_num(month_name: str) -> int:
        return date.j_months_fa.index(month_name) + 1

    @staticmethod
    def j_month_short_fa_to_num(month_name: str) -> int:
        return date.j_months_short_fa.index(month_name) + 1

    def __repr__(self) -> str:
        return f'jdatetime.date({self.year}, {self.month}, {self.day})'

    def __str__(self) -> str:
        return self.strftime('%Y-%m-%d')

    def __add__(self, timedelta: timedelta, /) -> date:
        """x.__add__(y) <==> x+y"""
        if isinstance(timedelta, py_datetime.timedelta):
            return date.fromgregorian(date=self.togregorian() + timedelta, locale=self.locale)
        return NotImplemented

    @_overload
    def __sub__(self, other: py_datetime.datetime | datetime, /) -> typing.NoReturn: ...
    @_overload
    def __sub__(self, other: date | py_datetime.date, /) -> timedelta: ...
    @_overload
    def __sub__(self, other: timedelta, /) -> date: ...
    def __sub__(self, other: object, /) -> date | timedelta:
        """x.__sub__(y) <==> x-y"""

        if isinstance(other, timedelta):
            return self.fromgregorian(date=self.togregorian() - other, locale=self.locale)
        if isinstance(other, py_datetime.date):
            return self.togregorian() - other
        if isinstance(other, date):
            return self.togregorian() - other.togregorian()

        return NotImplemented

    def __radd__(self, timedelta: timedelta, /) -> date:
        """x.__radd__(y) <==> y+x"""
        if isinstance(timedelta, py_datetime.timedelta):
            return self.__add__(timedelta)
        return NotImplemented

    def __rsub__(self, other: date | py_datetime.date, /) -> timedelta:
        """x.__rsub__(y) <==> y-x"""
        if isinstance(other, date):
            return other.__sub__(self)
        if isinstance(other, py_datetime.date):
            return other - self.togregorian()
        return NotImplemented

    def __eq__(self, other_date: object, /) -> bool:
        """x.__eq__(y) <==> x==y"""
        if other_date is None:
            return False
        if isinstance(other_date, py_datetime.date):
            return self.__eq__(date.fromgregorian(date=other_date))
        if not isinstance(other_date, date):
            return NotImplemented
        return (
            self.year == other_date.year
            and self.month == other_date.month
            and self.day == other_date.day
            and self.locale == other_date.locale
        )

    def __ge__(self, other_date: date, /) -> bool:
        """x.__ge__(y) <==> x>=y"""
        if isinstance(other_date, py_datetime.date):
            return self.__ge__(date.fromgregorian(date=other_date))
        if not isinstance(other_date, date):
            return NotImplemented

        if self.year > other_date.year:
            return True
        elif self.year == other_date.year:
            return (
                self.month > other_date.month or self.month == other_date.month and self.day >= other_date.day
            )
        return False

    def __gt__(self, other_date: date, /) -> bool:
        """x.__gt__(y) <==> x>y"""
        if isinstance(other_date, py_datetime.date):
            return self.__gt__(date.fromgregorian(date=other_date))
        if not isinstance(other_date, date):
            return NotImplemented

        if self.year > other_date.year:
            return True
        elif self.year == other_date.year:
            return (
                self.month > other_date.month or self.month >= other_date.month and self.day > other_date.day
            )
        return False

    def __le__(self, other_date: date, /) -> bool:
        """x.__le__(y) <==> x<=y"""
        if isinstance(other_date, py_datetime.date):
            return self.__le__(date.fromgregorian(date=other_date))
        if not isinstance(other_date, date):
            return NotImplemented

        return not self.__gt__(other_date)

    def __lt__(self, other_date: date, /) -> bool:
        """x.__lt__(y) <==> x<y"""
        if isinstance(other_date, py_datetime.date):
            return self.__lt__(date.fromgregorian(date=other_date))
        if not isinstance(other_date, date):
            return NotImplemented

        return not self.__ge__(other_date)

    def __hash__(self) -> int:
        """x.__hash__() <==> hash(x)"""
        gd = self.togregorian()
        return gd.__hash__()

    def ctime(self) -> str:
        """Return ctime() style string."""
        return self.strftime('%c')

    def replace(self, year: int = 0, month: int = 0, day: int = 0) -> date:
        """Return date with new specified fields."""
        new_year = self.year
        new_month = self.month
        new_day = self.day

        if year != 0:
            new_year = year
        if month != 0:
            new_month = month
        if day != 0:
            new_day = day

        return date(new_year, new_month, new_day, locale=self.locale)

    def yday(self) -> int:
        """return day of year"""
        day = 0
        for i in range(self.month - 1):
            day = day + j_days_in_month[i]
        day = day + self.day
        return day

    def weekday(self) -> int:
        """Return the day of the week represented by the date.
        Shanbeh == 0 ... Jomeh == 6"""
        gd = self.togregorian()
        return (gd.weekday() - 5) % 7

    def isoweekday(self) -> int:
        """Return the day of the week as an integer, where Shanbeh is 1 and Jomeh is 7"""
        return self.weekday() + 1

    def jweekday_short(self) -> str:
        return self.j_weekdays_short[self.weekday()]

    def jweekday(self) -> str:
        return self.j_weekdays[self.weekday()]

    def weeknumber(self) -> int:
        """Return week number"""
        return (self.yday() + date(self.year, 1, 1).weekday() - 1) // 7 + 1

    def jmonth_short(self) -> str:
        return self.j_months_short[self.month - 1]

    def jmonth(self) -> str:
        return self.j_months[self.month - 1]

    def isocalendar(self) -> tuple[int, int, int]:
        """Return a 3-tuple, (ISO year, ISO week number, ISO weekday)."""
        return (self.year, self.weeknumber(), self.isoweekday())

    def isoformat(self) -> str:
        """Return a string representing the date in ISO 8601 format, 'YYYY-MM-DD'"""
        return self.strftime('%Y-%m-%d')

    def __format__(self, format: str) -> str:
        """
        PEP-3101
        Make string formating work!
        """
        return self.strftime(format)

    # strftime helper functions
    def _strftime_get_attr_value(self, attr: str, fmt: str, fb: str | None = None) -> str | None:
        try:
            return fmt % getattr(self, attr)
        except AttributeError:
            return fb

    def _strftime_get_method_value(self, attr: str, fmt: str) -> str:
        return fmt % getattr(self, attr)()

    def _strftime_p(self) -> str:
        return self.j_ampm['AM']

    def _strftime_z(self) -> str:
        return ''

    def _strftime_cap_z(self) -> str:
        return ''

    def _strftime_c(self) -> str:
        return self.strftime('%a %b %d %H:%M:%S %Y')

    def _strftime_cap_x(self) -> str:
        return self.strftime('%H:%M:%S')

    def _strftime_x(self) -> str:
        return self.strftime('%m/%d/%y')

    def strftime(self, format: str) -> str:
        def repl(match):
            symbol = match[0]
            if symbol in STRFTIME_MAPPING:
                replace_method_name, kwargs = STRFTIME_MAPPING[symbol]
                return getattr(self, replace_method_name)(**kwargs)
            if symbol == '%%':
                return '%'
            return symbol

        return re.sub(r'%-?[A-Za-z%-]', repl, format)

    def aslocale(self, locale: str) -> date:
        return date(self.year, self.month, self.day, locale=locale)

    @classmethod
    def strptime(cls, date_string: str, format: str) -> date:
        """Return a date corresponding to date_string, parsed according to format."""
        dt = datetime.strptime(date_string, format)
        return cls(dt.year, dt.month, dt.day, locale=dt.locale)


date.min = date(MINYEAR, 1, 1)
date.max = date(MAXYEAR, 12, 30)

_DIRECTIVE_PATTERNS = {
    '%A': '(?:' + '|'.join([*date.j_weekdays_fa, *date.j_weekdays_en]) + ')',
    '%a': '(?:' + '|'.join([*date.j_weekdays_fa, *date.j_weekdays_short_en]) + ')',
    '%Y': r'(?P<Y>\d{4})',
    '%y': r'(?P<y>\d{2})',
    '%m': r'(?P<m>\d{1,2})',
    '%d': r'(?P<d>\d{1,2})',
    '%H': r'(?P<H>\d{1,2})',
    '%j': r'[0-3]\d\d',
    '%M': r'(?P<M>\d{1,2})',
    '%p': '(?:' + '|'.join([*date.j_ampm_fa.values(), *date.j_ampm_en.values()]) + ')',
    '%S': r'(?P<S>\d{1,2})',
    '%f': r'(?P<f>\d{1,6})',
    '%B': r'(?P<B>[a-zA-Z\u0600-\u06EF\uFB8A\u067E\u0686\u06AF]{2,12})',
    '%b': r'(?P<b>[a-zA-Z\u0600-\u06EF\uFB8A\u067E\u0686\u06AF]{2,4})',
    '%z': r'(?P<z>[+-]\d\d:?[0-5\u06F0-\u06F5]\d(:?[0-5\u06F0-\u06F5]\d(\.\d{1,6})?)?)',
}

# Replace directives with patterns according to _DIRECTIVE_PATTERNS
_directives_to_pattern = _partial(
    re.compile('|'.join(_DIRECTIVE_PATTERNS)).sub,
    lambda match: _DIRECTIVE_PATTERNS[match.group()],
)


class datetime(date):
    """datetime(
        year, month, day, [hour, [minute, [seconds, [microsecond, [tzinfo]]]]]
    )-> datetime objects"""

    """The smallest possible difference between
    non-equal datetime objects, timedelta(microseconds=1)."""
    resolution: _ClassVar[timedelta] = py_datetime.datetime.resolution

    def time(self) -> time:
        """Return time object with same time but with tzinfo=None."""
        return time(self.hour, self.minute, self.second, self.microsecond, fold=self.fold)

    def date(self) -> date:
        """Return date object with same year, month and day."""
        return date(self.year, self.month, self.day, locale=self.locale)

    def __init__(
        self,
        year: int,
        month: int,
        day: int,
        hour: int | None = None,
        minute: int | None = None,
        second: int | None = None,
        microsecond: int | None = None,
        tzinfo: tzinfo | None = None,
        *,
        fold: int = 0,
        **kwargs,
    ):
        date.__init__(self, year, month, day, **kwargs)
        tmp_hour = 0
        tmp_min = 0
        tmp_sec = 0
        tmp_micr = 0
        if hour is not None:
            tmp_hour = hour
        if minute is not None:
            tmp_min = minute
        if second is not None:
            tmp_sec = second
        if microsecond is not None:
            tmp_micr = microsecond

        if fold not in (0, 1):
            raise ValueError('fold must be either 0 or 1', fold)
        self._fold = fold

        if not (
            self._check_arg(tmp_hour)
            and self._check_arg(tmp_min)
            and self._check_arg(tmp_sec)
            and self._check_arg(tmp_micr)
        ):
            raise TypeError('an integer is required')

        self.__time = time(tmp_hour, tmp_min, tmp_sec, tmp_micr, tzinfo, fold=fold)

    def __repr__(self) -> str:
        if self.__time.tzinfo is not None:
            return (
                f'jdatetime.datetime({self.year}, {self.month}, {self.day}, {self.hour}, {self.minute},'
                f' {self.second}, {self.microsecond}, tzinfo={self.tzinfo})'
            )

        if self.__time.microsecond != 0:
            return (
                f'jdatetime.datetime({self.year}, {self.month}, {self.day}, '
                f'{self.hour}, {self.minute}, {self.second}, {self.microsecond})'
            )

        if self.__time.second != 0:
            return (
                f'jdatetime.datetime({self.year}, {self.month}, {self.day}, '
                f'{self.hour}, {self.minute}, {self.second})'
            )

        return f'jdatetime.datetime({self.year}, {self.month}, {self.day}, {self.hour}, {self.minute})'

    @staticmethod
    def today() -> datetime:
        """Current date or datetime"""
        return datetime.now()

    @staticmethod
    def now(tz: py_datetime.tzinfo | None = None) -> datetime:
        """[tz] -> new datetime with tz's local day and time."""
        now_datetime = py_datetime.datetime.now(tz)
        now = date.fromgregorian(date=now_datetime.date())
        return datetime(
            now.year,
            now.month,
            now.day,
            now_datetime.hour,
            now_datetime.minute,
            now_datetime.second,
            now_datetime.microsecond,
            tz,
        )

    @staticmethod
    def utcnow() -> datetime:
        """Return a new datetime representing UTC day and time."""
        now_datetime = py_datetime.datetime.utcnow()
        now = date.fromgregorian(date=now_datetime.date())
        return datetime(
            now.year,
            now.month,
            now.day,
            now_datetime.hour,
            now_datetime.minute,
            now_datetime.second,
            now_datetime.microsecond,
        )

    @classmethod
    def fromisoformat(cls, date_string: str) -> datetime:
        """
        Convert an ISO 8601 formatted string to a jdatetime.datetime
        """
        # Since we do not (yet?) support ISO week dates, the date and time
        # separator is either at 8th or 10th position, see:
        # https://github.com/python/cpython/blob/b2b85b5db9cfdb24f966b61757536a898abc3830/Lib/datetime.py#L271
        separator_position = 10 if date_string[4] == '-' else 8
        time_string = date_string[separator_position + 1 :]
        return cls.combine(
            date.fromisoformat(date_string[:separator_position]),
            time.fromisoformat(time_string) if time_string else time(),
        )

    @staticmethod
    def fromtimestamp(timestamp: float, tz: py_datetime.tzinfo | None = None) -> datetime:
        """timestamp[, tz] -> tz's local time from POSIX timestamp."""
        now_datetime = py_datetime.datetime.fromtimestamp(timestamp, tz)
        now = date.fromgregorian(date=now_datetime.date())
        return datetime(
            now.year,
            now.month,
            now.day,
            now_datetime.hour,
            now_datetime.minute,
            now_datetime.second,
            now_datetime.microsecond,
            tz,
        )

    @staticmethod
    def utcfromtimestamp(timestamp: float) -> datetime:
        """timestamp -> UTC datetime from a POSIX timestamp (like time.time())."""
        now_datetime = py_datetime.datetime.fromtimestamp(timestamp)
        now = date.fromgregorian(date=now_datetime.date())
        return datetime(
            now.year,
            now.month,
            now.day,
            now_datetime.hour,
            now_datetime.minute,
            now_datetime.second,
            now_datetime.microsecond,
        )

    @staticmethod
    def combine(d: date | None = None, t: time | None = None, **kw) -> datetime:
        """date, time -> datetime with same date and time fields"""

        c_date = None
        if d is not None:
            c_date = d
        elif 'date' in kw:
            c_date = kw['date']

        c_time = None
        if t is not None:
            c_time = t
        elif 'time' in kw:
            c_time = kw['time']

        if c_date is None:
            raise TypeError("Required argument 'date' (pos 1) not found")
        if c_time is None:
            raise TypeError("Required argument 'time' (pos 2) not found")

        if not isinstance(c_date, date):
            raise TypeError(f'combine() argument 1 must be jdatetime.date, not {type(c_date)}')
        if not isinstance(c_time, time):
            raise TypeError(f'combine() argument 2 must be jdatetime.time, not {type(c_time)}')

        return datetime(
            c_date.year,
            c_date.month,
            c_date.day,
            c_time.hour,
            c_time.minute,
            c_time.second,
            c_time.microsecond,
            c_time.tzinfo,
            locale=c_date.locale,
            fold=c_time.fold,
        )

    def timestamp(self) -> float:
        return self.togregorian().timestamp()

    @staticmethod
    def fromordinal(ordinal: int) -> datetime:
        """int -> date corresponding to a proleptic Jalali ordinal.
        it starts from Farvardin 1 of year 1, which is equal to 622-3-21 of Gregorian
        """
        if ordinal < 1:
            raise ValueError('ordinal must be >= 1')
        d = py_datetime.date.fromordinal(226894 + ordinal)
        j_date = date.fromgregorian(date=d)
        return datetime(j_date.year, j_date.month, j_date.day, 0, 0)

    @property
    def hour(self) -> int:
        return self.__time.hour

    @property
    def _hour12(self) -> int:
        """Return the hour on a 12-hour clock (1..12), used by the %I directive."""
        hour = self.hour % 12
        return 12 if hour == 0 else hour

    @property
    def minute(self) -> int:
        return self.__time.minute

    @property
    def second(self) -> int:
        return self.__time.second

    @property
    def microsecond(self) -> int:
        return self.__time.microsecond

    @property
    def tzinfo(self) -> py_datetime.tzinfo | None:
        return self.__time.tzinfo

    @property
    def fold(self) -> int:
        return self._fold

    @classmethod
    def strptime(cls, date_string: str, format: str) -> datetime:
        """string, format -> new datetime parsed from a string (like time.strptime())"""
        regex = _directives_to_pattern(re.escape(format))

        match = re.fullmatch(regex, date_string)
        if match is None:
            raise ValueError(f"time data '{date_string}' does not match format '{format}'")

        get = match.groupdict().get

        year = int(get('Y') or get('y') or 1279)
        if year < 100:  # %y, see the discussion at #100
            year += 1400 if year <= 68 else 1300
        month = get('B') or get('b') or int(get('m', 1))
        if isinstance(month, str):
            try:
                if get('b'):
                    if month.isascii():
                        month = date.j_month_short_to_num(month_name=month)
                    else:
                        month = date.j_month_short_fa_to_num(month_name=month)
                elif month.isascii():
                    month = date.j_month_to_num(month_name=month)
                else:
                    month = date.j_month_fa_to_num(month_name=month)
            except ValueError:
                raise ValueError(f"time data '{date_string}' does not match format '{format}'")

        timezone_string = get('z', None)
        timezone = cls._timezone_from_string(timezone_string)

        return cls(
            year,
            month,
            int(get('d', 1)),
            int(get('H', 0)),
            int(get('M', 0)),
            int(get('S', 0)),
            None if get('f') is None else int('{:0<6}'.format(get('f'))),
            timezone,
        )

    def replace(
        self,
        year: int | None = None,
        month: int | None = None,
        day: int | None = None,
        hour: int | None = None,
        minute: int | None = None,
        second: int | None = None,
        microsecond: int | None = None,
        tzinfo: py_datetime.tzinfo | None | typing.Literal[True] = True,
        fold: int | None = None,
    ) -> datetime:
        """Return datetime with new specified fields."""
        t_year = self.year
        if year is not None:
            t_year = year

        t_month = self.month
        if month is not None:
            t_month = month

        t_day = self.day
        if day is not None:
            t_day = day

        t_hour = self.hour
        if hour is not None:
            t_hour = hour

        t_min = self.minute
        if minute is not None:
            t_min = minute

        t_sec = self.second
        if second is not None:
            t_sec = second

        t_mic = self.microsecond
        if microsecond is not None:
            t_mic = microsecond

        t_tz = self.tzinfo
        if tzinfo is not True:
            t_tz = tzinfo

        if fold is None:
            fold = self._fold

        return datetime(
            t_year,
            t_month,
            t_day,
            t_hour,
            t_min,
            t_sec,
            t_mic,
            t_tz,
            locale=self.locale,
            fold=fold,
        )

    def __add__(self, timedelta: timedelta, /) -> datetime:
        """x.__add__(y) <==> x+y"""
        if isinstance(timedelta, py_datetime.timedelta):
            return datetime.fromgregorian(datetime=self.togregorian() + timedelta, locale=self.locale)
        return NotImplemented

    # Match the stdlib datetime API. The subclass intentionally narrows
    # the base-class overloads, so suppress override warnings.
    @_overload
    def __sub__(self, other: timedelta, /) -> datetime: ...
    @_overload
    def __sub__(self, other: datetime | py_datetime.datetime, /) -> timedelta: ...
    def __sub__(self, other: object, /) -> datetime | timedelta:  # type: ignore[override] # ty: ignore[invalid-method-override]
        """x.__sub__(y) <==> x-y"""

        if isinstance(other, timedelta):
            return self.fromgregorian(datetime=self.togregorian() - other, locale=self.locale)
        if isinstance(other, py_datetime.datetime):
            return self.togregorian() - other
        if isinstance(other, datetime):
            return self.togregorian() - other.togregorian()
        return NotImplemented

    def __radd__(self, timedelta: timedelta, /) -> datetime:
        """x.__radd__(y) <==> y+x"""
        if isinstance(timedelta, py_datetime.timedelta):
            return self.__add__(timedelta)
        return NotImplemented

    def __rsub__(self, other: datetime | py_datetime.datetime, /) -> timedelta:  # type: ignore[override] # ty: ignore[invalid-method-override]
        """x.__rsub__(y) <==> y-x"""
        if isinstance(other, datetime):
            return other.__sub__(self)
        if isinstance(other, py_datetime.datetime):
            return other - self.togregorian()
        return NotImplemented

    def __eq__(self, other_datetime: object, /) -> bool:
        """x.__eq__(y) <==> x==y"""
        if other_datetime is None:
            return False

        other_locale = other_datetime.locale if isinstance(other_datetime, datetime) else get_locale()
        if self.locale != other_locale:
            return False

        if isinstance(other_datetime, datetime):
            other_datetime = other_datetime.togregorian()

        if not isinstance(other_datetime, py_datetime.datetime):
            return NotImplemented

        return self.togregorian() == other_datetime

    def __ge__(self, other_datetime: datetime | py_datetime.datetime, /) -> bool:  # type: ignore[override] # ty: ignore[invalid-method-override]
        """x.__ge__(y) <==> x>=y"""
        if isinstance(other_datetime, datetime):
            other_datetime = other_datetime.togregorian()

        if not isinstance(other_datetime, py_datetime.datetime):
            return NotImplemented

        return self.togregorian() >= other_datetime

    def __gt__(self, other_datetime: datetime | py_datetime.datetime, /) -> bool:  # type: ignore[override] # ty: ignore[invalid-method-override]
        """x.__gt__(y) <==> x>y"""
        if isinstance(other_datetime, datetime):
            other_datetime = other_datetime.togregorian()

        if not isinstance(other_datetime, py_datetime.datetime):
            return NotImplemented

        return self.togregorian() > other_datetime

    def __le__(self, other_datetime: datetime | py_datetime.datetime, /) -> bool:  # type: ignore[override] # ty: ignore[invalid-method-override]
        """x.__le__(y) <==> x<=y"""
        if isinstance(other_datetime, datetime):
            other_datetime = other_datetime.togregorian()

        if not isinstance(other_datetime, py_datetime.datetime):
            return NotImplemented

        return self.togregorian() <= other_datetime

    def __lt__(self, other_datetime: datetime | py_datetime.datetime, /) -> bool:  # type: ignore[override] # ty: ignore[invalid-method-override]
        """x.__lt__(y) <==> x<y"""
        if isinstance(other_datetime, datetime):
            other_datetime = other_datetime.togregorian()

        if not isinstance(other_datetime, py_datetime.datetime):
            return NotImplemented

        return self.togregorian() < other_datetime

    def __hash__(self) -> int:
        """x.__hash__() <==> hash(x)"""
        gdt = self.togregorian()
        return gdt.__hash__()

    @staticmethod
    def fromgregorian(**kw) -> datetime:
        """Convert gregorian to jalali and return jadatetime.datetime
        jadatetime.date.fromgregorian(day=X,month=X,year=X,[hour=X, [minute=X, [second=X, [tzinfo=X]]]])
        jadatetime.date.fromgregorian(date=datetime.date)
        jadatetime.date.fromgregorian(datetime=datetime.date)
        jadatetime.date.fromgregorian(datetime=datetime.datetime)
        jadatetime.date.fromgregorian(datetime=datetime.datetime, locale='fa_IR')
        """
        locale = kw.get('locale')
        date_param = kw.get('date') or kw.get('datetime')
        if date_param:
            try:
                (y, m, d) = GregorianToJalali(
                    date_param.year, date_param.month, date_param.day
                ).getJalaliList()
            except AttributeError:
                raise ValueError(
                    'When calling fromgregorian(date=) or fromgregorian(datetime=) '
                    'the parameter should be date like.'
                )
            try:
                return datetime(
                    y,
                    m,
                    d,
                    date_param.hour,
                    date_param.minute,
                    date_param.second,
                    date_param.microsecond,
                    date_param.tzinfo,
                    locale=locale,
                )
            except AttributeError:
                return datetime(y, m, d, locale=locale)

        if 'day' in kw and 'month' in kw and 'year' in kw:
            (year, month, day) = (kw['year'], kw['month'], kw['day'])
            (y, m, d) = GregorianToJalali(year, month, day).getJalaliList()
            hour = None
            minute = None
            second = None
            microsecond = None
            tzinfo = None
            if 'hour' in kw:
                hour = kw['hour']
                if 'minute' in kw:
                    minute = kw['minute']
                    if 'second' in kw:
                        second = kw['second']
                        if 'microsecond' in kw:
                            microsecond = kw['microsecond']
                            if 'tzinfo' in kw:
                                tzinfo = kw['tzinfo']
            return datetime(y, m, d, hour, minute, second, microsecond, tzinfo, locale=locale)

        raise ValueError(
            'fromgregorian have to called fromgregorian'
            '(day=X,month=X,year=X, [hour=X, [minute=X, [second=X, [tzinfo=X]]]]) '
            'or fromgregorian(date=datetime.date) or fromgregorian(datetime=datetime.datetime)'
        )

    def togregorian(self) -> py_datetime.datetime:
        """Convert current jalali date to gregorian and return datetime.datetime"""
        gdate = date.togregorian(self)
        return py_datetime.datetime.combine(gdate, self.__time)

    def astimezone(self, tz: py_datetime.tzinfo) -> datetime:
        """tz -> convert to local time in new timezone tz"""
        gdt = self.togregorian()
        gdt = gdt.astimezone(tz)
        return datetime.fromgregorian(datetime=gdt, locale=self.locale)

    def ctime(self) -> str:
        """Return ctime() style string."""
        return self.strftime('%c')

    def dst(self) -> timedelta | None:
        """Return self.tzinfo.dst(self)"""
        if self.tzinfo:
            return self.tzinfo.dst(self.togregorian())
        return None

    def isoformat(self, sep: str = 'T', timespec: str = 'auto') -> str:
        """[sep] -> string in ISO 8601 format,
        YYYY-MM-DDTHH:MM:SS[.mmmmmm][+HH:MM]."""

        assert isinstance(sep, str) and len(sep) == 1, f'argument 1 must be a single character: {sep}'

        tz = self.strftime('%z')

        date_ = self.strftime('%Y-%m-%d')
        time_ = _format_time(self.hour, self.minute, self.second, self.microsecond, timespec)

        return f'{date_}{sep}{time_}{tz}'

    def timetuple(self) -> py_time.struct_time:
        """Return time tuple, compatible with time.localtime().
        It returns Gregorian object!
        """
        dt = self.togregorian()
        return dt.timetuple()

    def timetz(self) -> time:
        """Return time object with same time and tzinfo."""
        return self.__time

    def tzname(self) -> str | None:
        """Return self.tzinfo.tzname(self)"""
        if self.tzinfo:
            return self.tzinfo.tzname(self.togregorian())
        return None

    def utcoffset(self) -> timedelta | None:
        """Return self.tzinfo.utcoffset(self)."""
        if self.tzinfo:
            return self.tzinfo.utcoffset(self.togregorian())

    def utctimetuple(self) -> py_time.struct_time:
        """Return UTC time tuple, compatible with time.localtime().
        It returns Gregorian object !
        """
        dt = self.togregorian()
        return dt.utctimetuple()

    def __str__(self) -> str:
        if self.microsecond == 0:
            mil = ''
        else:
            mil = '.' + str(self.microsecond)
        tz = self.strftime('%z')
        return self.strftime('%Y-%m-%d %H:%M:%S') + f'{mil}{tz}'

    def aslocale(self, locale) -> datetime:
        return datetime(
            self.year,
            self.month,
            self.day,
            self.hour,
            self.minute,
            self.second,
            self.microsecond,
            tzinfo=self.tzinfo,
            locale=locale,
        )

    @staticmethod
    def _timezone_from_string(timezone_string: str | None) -> py_datetime.timezone | None:
        if timezone_string is None:
            return None
        z = timezone_string  # keep the original string for value error exception.
        if z[3] == ':':
            z = z[:3] + z[4:]
            if len(z) > 5:
                if z[5] != ':':
                    msg = f'Inconsistent use of : in {timezone_string}'
                    raise ValueError(msg)
                z = z[:5] + z[6:]
        hours = int(z[1:3])
        minutes = int(z[3:5])
        seconds = int(z[5:7] or 0)
        gmtoff = (hours * 60 * 60) + (minutes * 60) + seconds
        gmtoff_remainder = z[8:]
        # Pad to always return microseconds.
        gmtoff_remainder_padding = '0' * (6 - len(gmtoff_remainder))
        gmtoff_fraction = int(gmtoff_remainder + gmtoff_remainder_padding)
        if z.startswith('-'):
            gmtoff = -gmtoff
            gmtoff_fraction = -gmtoff_fraction
        timezone = py_datetime.timezone(timedelta(seconds=gmtoff, microseconds=gmtoff_fraction))
        return timezone

    def _strftime_p(self) -> str:
        if self.hour >= 12:
            return self.j_ampm['PM']
        return self.j_ampm['AM']

    def _strftime_z(self) -> str:
        diff = self.utcoffset()
        if diff is None:
            return ''
        sign = '+'
        diff_sec = diff.seconds
        if diff.days > 0 or diff.days < -1:
            raise ValueError('tzinfo.utcoffset() returned big time delta! ; must be in -1439 .. 1439')
        if diff.days != 0:
            sign = '-'
            diff_sec = (1 * 24 * 60 * 60) - diff_sec
        tmp_min = diff_sec // 60
        diff_hour = tmp_min // 60
        diff_min = tmp_min % 60
        return f'{sign}{diff_hour:02d}{diff_min:02d}'

    def _strftime_cap_z(self) -> str:
        return self.tzname() or ''


datetime.max = datetime(MAXYEAR, 12, 12, 23, 59, 59, 999999)
datetime.min = datetime(1, 1, 1)
