# -*- coding: utf-8 -*-
"""Date and time interfaces."""

import abc
import decimal

from dfdatetime import definitions


class DateTimeEpoch(object):
  """Date and time epoch interface.

  This is the super class of different epoch representations.

  Attributes:
    year (int): year that is the start of the epoch e.g. 1970.
    month (int): month that is the start of the epoch, where 1 represents
        January.
    day_of_month (int): day of the month that is the start of the epoch,
        where 1 represents the first day.
  """

  def __init__(self, year, month, day_of_month):
    """Initializes a date time epoch.

    Args:
      year (int): year that is the start of the epoch e.g. 1970.
      month (int): month that is the start of the epoch, where 1 represents
          January.
      day_of_month (int): day of the month that is the start of the epoch,
          where 1 represents the first day.
    """
    super(DateTimeEpoch, self).__init__()
    self.day_of_month = day_of_month
    self.month = month
    self.year = year


class NormalizedTimeEpoch(DateTimeEpoch):
  """dfDateTime normalized time epoch."""

  def __init__(self):
    """Initializes a dfDateTime normalized time epoch."""
    super(NormalizedTimeEpoch, self).__init__(1970, 1, 1)


class DateTimeValues(object):
  """Date and time values interface.

  This is the super class of different date and time representations.

  Attributes:
    is_local_time (bool): True if the date and time value is in local time.
    time_zone_hint (str): time zone hint, such as "Europe/Amsterdam", "CET" or
        "UTC+1", or None if not set.
  """

  # pylint: disable=redundant-returns-doc

  _EPOCH_NORMALIZED_TIME = NormalizedTimeEpoch()

  _100_MILLISECONDS_PER_SECOND = 10
  _10_MILLISECONDS_PER_SECOND = 100
  _1_MILLISECOND_PER_SECOND = 1000
  _100_MICROSECONDS_PER_SECOND = 10000
  _10_MICROSECONDS_PER_SECOND = 100000
  _1_MICROSECOND_PER_SECOND = 1000000
  _100_NANOSECONDS_PER_SECOND = 10000000
  _10_NANOSECONDS_PER_SECOND = 100000000
  _1_NANOSECOND_PER_SECOND = definitions.NANOSECONDS_PER_SECOND

  _100_NANOSECONDS_PER_MICROSECOND = 10

  _INT64_MIN = -(1 << 63)
  _INT64_MAX = (1 << 63) - 1

  _UINT32_MAX = (1 << 32) - 1
  _UINT60_MAX = (1 << 60) - 1
  _UINT64_MAX = (1 << 64) - 1

  _REMAINDER_MULTIPLIER = {
      definitions.PRECISION_1_MILLISECOND: _1_MILLISECOND_PER_SECOND,
      definitions.PRECISION_10_MILLISECONDS: _10_MILLISECONDS_PER_SECOND,
      definitions.PRECISION_100_MILLISECONDS: _100_MILLISECONDS_PER_SECOND,
      definitions.PRECISION_1_MICROSECOND: _1_MICROSECOND_PER_SECOND,
      definitions.PRECISION_10_MICROSECONDS: _10_MICROSECONDS_PER_SECOND,
      definitions.PRECISION_100_MICROSECONDS: _100_MICROSECONDS_PER_SECOND,
      definitions.PRECISION_1_NANOSECOND: _1_NANOSECOND_PER_SECOND,
      definitions.PRECISION_10_NANOSECONDS: _10_NANOSECONDS_PER_SECOND,
      definitions.PRECISION_100_NANOSECONDS: _100_NANOSECONDS_PER_SECOND}

  def __init__(self, is_delta=False, precision=None, time_zone_offset=None):
    """Initializes date time values.

    Args:
      is_delta (Optional[bool]): True if the date and time value is relative to
          another date and time value.
      precision (Optional[str]): precision of the date and time value, which
          should be one of the PRECISION_VALUES in definitions.
      time_zone_offset (Optional[int]): time zone offset in number of minutes
          from UTC or None if not set.
    """
    super(DateTimeValues, self).__init__()
    self._cached_date_time_values = None
    self._is_delta = is_delta
    self._normalized_timestamp = None
    self._precision = precision
    self._time_zone_offset = time_zone_offset

    self.is_local_time = False
    self.time_zone_hint = False

  @property
  def is_delta(self):
    """Is delta (bool): True if the date and time is relative to another."""
    return self._is_delta

  @property
  def precision(self):
    """Precision (str): precision of the date and time value."""
    return self._precision

  @property
  def time_zone_offset(self):
    """Time zone offset (int): time zone offset in minutes from UTC."""
    return self._time_zone_offset

  @time_zone_offset.setter
  def time_zone_offset(self, time_zone_offset):
    """Sets the time zone offset.

    Args:
      time_zone_offset (int): time zone offset in number of minutes from UTC or
          None if not set.
    """
    self._normalized_timestamp = None
    self._time_zone_offset = time_zone_offset

  def __eq__(self, other):
    """Determines if the date time values are equal to other.

    Args:
      other (DateTimeValues): date time values to compare against.

    Returns:
      bool: True if the date time values are equal to other.
    """
    if not isinstance(other, DateTimeValues):
      return False

    normalized_timestamp = self._GetNormalizedTimestamp()
    other_normalized_timestamp = other._GetNormalizedTimestamp()  # pylint: disable=protected-access

    if normalized_timestamp is None and other_normalized_timestamp is not None:
      return False

    if normalized_timestamp is not None and other_normalized_timestamp is None:
      return False

    return normalized_timestamp == other_normalized_timestamp

  def __ge__(self, other):
    """Determines if the date time values are greater than or equal to other.

    Args:
      other (DateTimeValues): date time values to compare against.

    Returns:
      bool: True if the date time values are greater than or equal to other.

    Raises:
      ValueError: if other is not an instance of DateTimeValues.
    """
    if not isinstance(other, DateTimeValues):
      raise ValueError('Other not an instance of DateTimeValues')

    normalized_timestamp = self._GetNormalizedTimestamp()
    other_normalized_timestamp = other._GetNormalizedTimestamp()  # pylint: disable=protected-access

    if normalized_timestamp is None:
      return other_normalized_timestamp is None

    if other_normalized_timestamp is None:
      return True

    return normalized_timestamp >= other_normalized_timestamp

  def __gt__(self, other):
    """Determines if the date time values are greater than other.

    Args:
      other (DateTimeValues): date time values to compare against.

    Returns:
      bool: True if the date time values are greater than other.

    Raises:
      ValueError: if other is not an instance of DateTimeValues.
    """
    if not isinstance(other, DateTimeValues):
      raise ValueError('Other not an instance of DateTimeValues')

    normalized_timestamp = self._GetNormalizedTimestamp()
    other_normalized_timestamp = other._GetNormalizedTimestamp()  # pylint: disable=protected-access

    if normalized_timestamp is None:
      return False

    if other_normalized_timestamp is None:
      return True

    return normalized_timestamp > other_normalized_timestamp

  def __le__(self, other):
    """Determines if the date time values are greater than or equal to other.

    Args:
      other (DateTimeValues): date time values to compare against.

    Returns:
      bool: True if the date time values are greater than or equal to other.

    Raises:
      ValueError: if other is not an instance of DateTimeValues.
    """
    if not isinstance(other, DateTimeValues):
      raise ValueError('Other not an instance of DateTimeValues')

    normalized_timestamp = self._GetNormalizedTimestamp()
    other_normalized_timestamp = other._GetNormalizedTimestamp()  # pylint: disable=protected-access

    if normalized_timestamp is None:
      return True

    if other_normalized_timestamp is None:
      return False

    return normalized_timestamp <= other_normalized_timestamp

  def __lt__(self, other):
    """Determines if the date time values are less than other.

    Args:
      other (DateTimeValues): date time values to compare against.

    Returns:
      bool: True if the date time values are less than other.

    Raises:
      ValueError: if other is not an instance of DateTimeValues.
    """
    if not isinstance(other, DateTimeValues):
      raise ValueError('Other not an instance of DateTimeValues')

    normalized_timestamp = self._GetNormalizedTimestamp()
    other_normalized_timestamp = other._GetNormalizedTimestamp()  # pylint: disable=protected-access

    if normalized_timestamp is None:
      return other_normalized_timestamp is not None

    if other_normalized_timestamp is None:
      return False

    return normalized_timestamp < other_normalized_timestamp

  def __ne__(self, other):
    """Determines if the date time values are not equal to other.

    Args:
      other (DateTimeValues): date time values to compare against.

    Returns:
      bool: True if the date time values are not equal to other.
    """
    if not isinstance(other, DateTimeValues):
      return True

    normalized_timestamp = self._GetNormalizedTimestamp()
    other_normalized_timestamp = other._GetNormalizedTimestamp()  # pylint: disable=protected-access

    if normalized_timestamp is None and other_normalized_timestamp is not None:
      return True

    if normalized_timestamp is not None and other_normalized_timestamp is None:
      return True

    return normalized_timestamp != other_normalized_timestamp

  def _CopyDateFromString(self, date_string):
    """Copies a date from a string.

    Args:
      date_string (str): date value formatted as: YYYY-MM-DD

    Returns:
      tuple[int, int, int]: year, month, day of month.

    Raises:
      ValueError: if the date string is invalid or not supported.
    """
    date_string_length = len(date_string)

    # The date string should at least contain 'YYYY-MM-DD'.
    if date_string_length < 10:
      raise ValueError('Date string too short.')

    if date_string[4] != '-' or date_string[7] != '-':
      raise ValueError('Invalid date string.')

    try:
      year = int(date_string[0:4], 10)
    except ValueError:
      raise ValueError('Unable to parse year.')

    try:
      month = int(date_string[5:7], 10)
    except ValueError:
      raise ValueError('Unable to parse month.')

    try:
      day_of_month = int(date_string[8:10], 10)
    except ValueError:
      raise ValueError('Unable to parse day of month.')

    days_per_month = self._GetDaysPerMonth(year, month)
    if day_of_month < 1 or day_of_month > days_per_month:
      raise ValueError('Day of month value out of bounds.')

    return year, month, day_of_month

  def _CopyDateTimeFromString(self, time_string):
    """Copies a date and time from a string.

    Args:
      time_string (str): date and time value formatted as:
          YYYY-MM-DD hh:mm:ss.######[+-]##:##

          Where # are numeric digits ranging from 0 to 9 and the seconds
          fraction can be either 3, 6 or 9 digits. The time of day, seconds
          fraction and time zone offset are optional. The default time zone
          is UTC.

    Returns:
      dict[str, int]: date and time values, such as year, month, day of month,
          hours, minutes, seconds, nanoseconds, time zone offset in minutes.

    Raises:
      ValueError: if the time string is invalid or not supported.
    """
    if not time_string:
      raise ValueError('Invalid time string.')

    time_string_length = len(time_string)

    year, month, day_of_month = self._CopyDateFromString(time_string)

    if time_string_length <= 10:
      return {
          'year': year,
          'month': month,
          'day_of_month': day_of_month}

    # If a time of day is specified the time string it should at least
    # contain 'YYYY-MM-DD hh:mm:ss'.
    if time_string[10] != ' ':
      raise ValueError(
          'Invalid time string - space missing as date and time separator.')

    hours, minutes, seconds, nanoseconds, time_zone_offset = (
        self._CopyTimeFromString(time_string[11:]))

    date_time_values = {
        'year': year,
        'month': month,
        'day_of_month': day_of_month,
        'hours': hours,
        'minutes': minutes,
        'seconds': seconds}

    if nanoseconds is not None:
      date_time_values['nanoseconds'] = nanoseconds
    if time_zone_offset is not None:
      date_time_values['time_zone_offset'] = time_zone_offset

    return date_time_values

  def _CopyTimeFromString(self, time_string):
    """Copies a time from a string.

    Args:
      time_string (str): time value formatted as:
          hh:mm:ss.######[+-]##:##

          Where # are numeric digits ranging from 0 to 9 and the seconds
          fraction can be either 3, 6 or 9 digits. The seconds fraction and
          time zone offset are optional.

    Returns:
      tuple[int, int, int, int, int]: hours, minutes, seconds, nanoseconds,
          time zone offset in minutes.

    Raises:
      ValueError: if the time string is invalid or not supported.
    """
    time_string_length = len(time_string)

    # The time string should at least contain 'hh:mm:ss'.
    if time_string_length < 8:
      raise ValueError('Time string too short.')

    if time_string[2] != ':' or time_string[5] != ':':
      raise ValueError('Invalid time string.')

    try:
      hours = int(time_string[0:2], 10)
    except ValueError:
      raise ValueError('Unable to parse hours.')

    if hours not in range(0, 24):
      raise ValueError(f'Hours value: {hours:d} out of bounds.')

    try:
      minutes = int(time_string[3:5], 10)
    except ValueError:
      raise ValueError('Unable to parse minutes.')

    if minutes not in range(0, 60):
      raise ValueError(f'Minutes value: {minutes:d} out of bounds.')

    try:
      seconds = int(time_string[6:8], 10)
    except ValueError:
      raise ValueError('Unable to parse day of seconds.')

    # TODO: support a leap second?
    if seconds not in range(0, 60):
      raise ValueError(f'Seconds value: {seconds:d} out of bounds.')

    nanoseconds = None
    time_zone_offset = None

    time_zone_string_index = 8
    while time_zone_string_index < time_string_length:
      if time_string[time_zone_string_index] in ('+', '-'):
        break

      time_zone_string_index += 1

    # The calculations that follow rely on the time zone string index
    # to point beyond the string in case no time zone offset was defined.
    if time_zone_string_index == time_string_length - 1:
      time_zone_string_index += 1

    if time_string_length > 8 and time_string[8] == '.':
      time_fraction_length = time_zone_string_index - 9
      if time_fraction_length not in (3, 6, 9):
        raise ValueError('Invalid time string.')

      try:
        time_fraction = time_string[9:time_zone_string_index]
        time_fraction = int(time_fraction, 10)
      except ValueError:
        raise ValueError('Unable to parse time fraction.')

      if time_fraction_length == 3:
        time_fraction *= 1000000
      elif time_fraction_length == 6:
        time_fraction *= 1000

      nanoseconds = time_fraction

    if time_zone_string_index < time_string_length:
      if (time_string_length - time_zone_string_index != 6 or
          time_string[time_zone_string_index + 3] != ':'):
        raise ValueError('Invalid time string.')

      try:
        hours_from_utc = int(time_string[
            time_zone_string_index + 1:time_zone_string_index + 3])
      except ValueError:
        raise ValueError('Unable to parse time zone hours offset.')

      if hours_from_utc not in range(0, 15):
        raise ValueError('Time zone hours offset value out of bounds.')

      try:
        minutes_from_utc = int(time_string[
            time_zone_string_index + 4:time_zone_string_index + 6])
      except ValueError:
        raise ValueError('Unable to parse time zone minutes offset.')

      if minutes_from_utc not in range(0, 60):
        raise ValueError('Time zone minutes offset value out of bounds.')

      # pylint: disable=invalid-unary-operand-type
      time_zone_offset = (hours_from_utc * 60) + minutes_from_utc

      if time_string[time_zone_string_index] == '-':
        time_zone_offset = -time_zone_offset

    return hours, minutes, seconds, nanoseconds, time_zone_offset

  def _GetDateValues(
      self, number_of_days, epoch_year, epoch_month, epoch_day_of_month):
    """Determines date values.

    Args:
      number_of_days (int): number of days since epoch.
      epoch_year (int): year that is the start of the epoch e.g. 1970.
      epoch_month (int): month that is the start of the epoch, where
          1 represents January.
      epoch_day_of_month (int): day of month that is the start of the epoch,
          where 1 represents the first day.

    Returns:
       tuple[int, int, int]: year, month, day of month.

    Raises:
      ValueError: if the epoch year, month or day of month values are out
          of bounds.
    """
    if epoch_year < 0:
      raise ValueError(f'Epoch year value: {epoch_year:d} out of bounds.')

    if epoch_month not in range(1, 13):
      raise ValueError(f'Epoch month value: {epoch_month:d} out of bounds.')

    epoch_days_per_month = self._GetDaysPerMonth(epoch_year, epoch_month)
    if epoch_day_of_month < 1 or epoch_day_of_month > epoch_days_per_month:
      raise ValueError(
          f'Epoch day of month value: {epoch_day_of_month:d} out of bounds.')

    before_epoch = number_of_days < 0

    year = epoch_year
    month = epoch_month
    if before_epoch:
      month -= 1
      if month <= 0:
        month = 12
        year -= 1

    number_of_days += epoch_day_of_month
    if before_epoch:
      number_of_days *= -1

    # Align with the start of the year.
    while month > 1:
      days_per_month = self._GetDaysPerMonth(year, month)
      if number_of_days < days_per_month:
        break

      if before_epoch:
        month -= 1
      else:
        month += 1

      if month > 12:
        month = 1
        year += 1

      number_of_days -= days_per_month

    # Align with the start of the next century.
    _, remainder = divmod(year, 100)
    for _ in range(remainder, 100):
      days_in_year = self._GetNumberOfDaysInYear(year)
      if number_of_days < days_in_year:
        break

      if before_epoch:
        year -= 1
      else:
        year += 1

      number_of_days -= days_in_year

    days_in_century = self._GetNumberOfDaysInCentury(year)
    while number_of_days > days_in_century:
      if before_epoch:
        year -= 100
      else:
        year += 100

      number_of_days -= days_in_century
      days_in_century = self._GetNumberOfDaysInCentury(year)

    days_in_year = self._GetNumberOfDaysInYear(year)
    while number_of_days > days_in_year:
      if before_epoch:
        year -= 1
      else:
        year += 1

      number_of_days -= days_in_year
      days_in_year = self._GetNumberOfDaysInYear(year)

    days_per_month = self._GetDaysPerMonth(year, month)
    while number_of_days > days_per_month:
      if before_epoch:
        month -= 1
      else:
        month += 1

      if month <= 0:
        month = 12
        year -= 1
      elif month > 12:
        month = 1
        year += 1

      number_of_days -= days_per_month
      days_per_month = self._GetDaysPerMonth(year, month)

    if before_epoch:
      days_per_month = self._GetDaysPerMonth(year, month)
      number_of_days = days_per_month - number_of_days

    elif number_of_days == 0:
      number_of_days = 31
      month = 12
      year -= 1

    return year, month, number_of_days

  def _GetDateValuesWithEpoch(self, number_of_days, date_time_epoch):
    """Determines date values.

    Args:
      number_of_days (int): number of days since epoch.
      date_time_epoch (DateTimeEpoch): date and time of the epoch.

    Returns:
       tuple[int, int, int]: year, month, day of month.
    """
    return self._GetDateValues(
        number_of_days, date_time_epoch.year, date_time_epoch.month,
        date_time_epoch.day_of_month)

  def _GetDateWithTimeOfDay(self):
    """Retrieves the date with time of day.

    Note that the date and time are adjusted to UTC.

    Returns:
       tuple[int, int, int, int, int, int]: year, month, day of month, hours,
           minutes, seconds or (None, None, None, None, None, None)
           if the date and time values do not represent a date or time of day.
    """
    normalized_timestamp = self._GetNormalizedTimestamp()
    if normalized_timestamp is None:
      return None, None, None, None, None, None

    if (not self._cached_date_time_values or
        self._cached_date_time_values[0] != normalized_timestamp):
      number_of_days, hours, minutes, seconds = self._GetTimeValues(
          normalized_timestamp)

      try:
        year, month, day_of_month = self._GetDateValuesWithEpoch(
            number_of_days, self._EPOCH_NORMALIZED_TIME)

      except ValueError:
        return None, None, None, None, None, None

      self._cached_date_time_values = (
          normalized_timestamp, year, month, day_of_month, hours, minutes,
          seconds)

    return self._cached_date_time_values[1:]

  def _GetDayOfYear(self, year, month, day_of_month):
    """Retrieves the day of the year for a specific day of a month in a year.

    Args:
      year (int): year e.g. 1970.
      month (int): month, where 1 represents January.
      day_of_month (int): day of the month, where 1 represents the first day.

    Returns:
      int: day of year.

    Raises:
      ValueError: if the month or day of month value is out of bounds.
    """
    if month not in range(1, 13):
      raise ValueError('Month value out of bounds.')

    days_per_month = self._GetDaysPerMonth(year, month)
    if day_of_month < 1 or day_of_month > days_per_month:
      raise ValueError('Day of month value out of bounds.')

    day_of_year = day_of_month
    for past_month in range(1, month):
      day_of_year += self._GetDaysPerMonth(year, past_month)

    return day_of_year

  def _GetDaysPerMonth(self, year, month):
    """Retrieves the number of days in a month of a specific year.

    Args:
      year (int): year e.g. 1970.
      month (int): month, where 1 represents January.

    Returns:
      int: number of days in the month.

    Raises:
      ValueError: if the month value is out of bounds.
    """
    if month not in range(1, 13):
      raise ValueError('Month value out of bounds.')

    days_per_month = definitions.DAYS_PER_MONTH[month - 1]
    if month == 2 and (self._is_delta or self._IsLeapYear(year)):
      days_per_month += 1

    return days_per_month

  @abc.abstractmethod
  def _GetNormalizedTimestamp(self):
    """Retrieves the normalized timestamp.

    Returns:
      decimal.Decimal: normalized timestamp, which contains the number of
          seconds since January 1, 1970 00:00:00 and a fraction of second used
          for increased precision, or None if the normalized timestamp cannot be
          determined.
    """

  def _GetNumberOfDaysInCentury(self, year):
    """Retrieves the number of days in a century.

    Args:
      year (int): year in the century e.g. 1970.

    Returns:
      int: number of (remaining) days in the century.

    Raises:
      ValueError: if the year value is out of bounds.
    """
    if year < 0:
      raise ValueError('Year value out of bounds.')

    year, _ = divmod(year, 100)
    year *= 100

    number_of_days = definitions.DAYS_PER_CENTURY.get(year, None)
    if number_of_days is not None:
      return number_of_days

    if self._IsLeapYear(year):
      return 36525
    return 36524

  def _GetNumberOfDaysInYear(self, year):
    """Retrieves the number of days in a specific year.

    Args:
      year (int): year e.g. 1970.

    Returns:
      int: number of days in the year.
    """
    number_of_days = definitions.DAYS_PER_YEAR.get(year, None)
    if number_of_days is not None:
      return number_of_days

    if self._IsLeapYear(year):
      return 366
    return 365

  def _GetNumberOfSecondsFromElements(
      self, year, month, day_of_month, hours, minutes, seconds):
    """Retrieves the number of seconds from the date and time elements.

    Args:
      year (int): year e.g. 1970.
      month (int): month, where 1 represents January.
      day_of_month (int): day of the month, where 1 represents the first day.
      hours (int): hours.
      minutes (int): minutes.
      seconds (int): seconds.

    Returns:
      int: number of seconds since January 1, 1970 00:00:00 or None if year,
          month or day of month are not set.

    Raises:
      ValueError: if the time elements are invalid.
    """
    if not month or not day_of_month:
      return None

    if hours is None:
      hours = 0
    elif hours not in range(0, 24):
      raise ValueError(f'Hours value: {hours!s} out of bounds.')

    if minutes is None:
      minutes = 0
    elif minutes not in range(0, 60):
      raise ValueError(f'Minutes value: {minutes!s} out of bounds.')

    # TODO: support a leap second?
    if seconds is None:
      seconds = 0
    elif seconds not in range(0, 60):
      raise ValueError(f'Seconds value: {seconds!s} out of bounds.')

    number_of_days = definitions.DAYS_PER_YEAR_IN_POSIX_EPOCH.get(year, None)
    if number_of_days is None:
      raise ValueError(f'Year value: {year!s} out of bounds.')

    number_of_days += sum(
        definitions.DAYS_PER_MONTH[index] for index in range(month - 1))
    if month > 2 and self._IsLeapYear(year):
      number_of_days += 1

    days_per_month = self._GetDaysPerMonth(year, month)
    if day_of_month < 1 or day_of_month > days_per_month:
      raise ValueError(f'Day of month value: {day_of_month:d} out of bounds.')

    number_of_days += day_of_month - 1
    number_of_hours = (number_of_days * 24) + hours
    number_of_minutes = (number_of_hours * 60) + minutes
    number_of_seconds = (number_of_minutes * 60) + seconds

    return number_of_seconds

  def _GetTimeValues(self, number_of_seconds):
    """Determines time values.

    Args:
      number_of_seconds (int|decimal.Decimal): number of seconds.

    Returns:
       tuple[int, int, int, int]: days, hours, minutes, seconds.
    """
    number_of_seconds = int(number_of_seconds)
    number_of_minutes, seconds = divmod(number_of_seconds, 60)
    number_of_hours, minutes = divmod(number_of_minutes, 60)
    number_of_days, hours = divmod(number_of_hours, 24)
    return number_of_days, hours, minutes, seconds

  def _IsLeapYear(self, year):
    """Determines if a year is a leap year.

    Args:
      year (int): year e.g. 1970.

    Returns:
      bool: True if the year is a leap year.
    """
    # pylint: disable=consider-using-ternary
    return (year % 4 == 0 and year % 100 != 0) or year % 400 == 0

  @abc.abstractmethod
  def CopyFromDateTimeString(self, time_string):
    """Copies a date time value from a date and time string.

    Args:
      time_string (str): date and time value formatted as:
          YYYY-MM-DD hh:mm:ss.######[+-]##:##

          Where # are numeric digits ranging from 0 to 9 and the seconds
          fraction can be either 3, 6 or 9 digits. The time of day, seconds
          fraction and time zone offset are optional. The default time zone
          is UTC.

    Raises:
      ValueError: if the time string is invalid or not supported.
    """

  def CopyToPosixTimestamp(self):
    """Copies the date time value to a POSIX timestamp.

    Returns:
      int: a POSIX timestamp in seconds or None if no timestamp is available.
    """
    normalized_timestamp = self._GetNormalizedTimestamp()
    if normalized_timestamp is None:
      return None

    return int(normalized_timestamp)

  def CopyToPosixTimestampWithFractionOfSecond(self):
    """Copies the date time value to a POSIX timestamp with fraction of second.

    Returns:
      tuple[int, int]: a POSIX timestamp in seconds with fraction of second or
          None, None if no timestamp is available.
    """
    normalized_timestamp = self._GetNormalizedTimestamp()
    if normalized_timestamp is None:
      return None, None

    remainder_multiplier = self._REMAINDER_MULTIPLIER.get(self._precision, None)
    if not remainder_multiplier:
      remainder = None
    elif normalized_timestamp >= 0:
      remainder = int((normalized_timestamp % 1) * remainder_multiplier)
    else:
      remainder = int((normalized_timestamp % 1) * -remainder_multiplier)

    return int(normalized_timestamp), remainder

  @abc.abstractmethod
  def CopyToDateTimeString(self):
    """Copies the date time value to a date and time string.

    Returns:
      str: date and time value formatted as: "YYYY-MM-DD hh:mm:ss.######" or
          None if the timestamp cannot be copied to a date and time string.
    """

  def CopyToDateTimeStringISO8601(self):
    """Copies the date time value to an ISO 8601 date and time string.

    Returns:
      str: date and time value formatted as an ISO 8601 date and time string or
          None if the timestamp cannot be copied to a date and time string.
    """
    date_time_string = self.CopyToDateTimeString()
    if date_time_string:
      date_time_string = date_time_string.replace(' ', 'T')

      if self._time_zone_offset is not None or not self.is_local_time:
        time_zone_offset_hours, time_zone_offset_minutes = divmod(
            self._time_zone_offset or 0, 60)
        if time_zone_offset_hours >= 0:
          time_zone_offset_sign = '+'
        else:
          time_zone_offset_sign = '-'
          time_zone_offset_hours *= -1

        time_zone_string = (
            f'{time_zone_offset_hours:02d}:{time_zone_offset_minutes:02d}')
        date_time_string = time_zone_offset_sign.join([
            date_time_string, time_zone_string])

    return date_time_string

  def GetDate(self):
    """Retrieves the date represented by the date and time values.

    Note that the date is adjusted to UTC.

    Returns:
       tuple[int, int, int]: year, month, day of month or (None, None, None)
           if the date and time values do not represent a date.
    """
    year, month, day_of_month, _, _, _ = self._GetDateWithTimeOfDay()
    return year, month, day_of_month

  def GetDateWithTimeOfDay(self):
    """Retrieves the date with time of day.

    Note that the date and time are adjusted to UTC.

    Returns:
       tuple[int, int, int, int, int, int]: year, month, day of month, hours,
           minutes, seconds or (None, None, None, None, None, None)
           if the date and time values do not represent a date or time of day.
    """
    return self._GetDateWithTimeOfDay()

  # TODO: remove this method when there is no more need for it in Plaso.
  def GetPlasoTimestamp(self):
    """Retrieves a timestamp that is compatible with Plaso.

    Returns:
      int: a POSIX timestamp in microseconds or None if no timestamp is
          available.

    Raises:
      ValueError: if the timestamp cannot be determined.
    """
    normalized_timestamp = self._GetNormalizedTimestamp()
    if normalized_timestamp is None:
      return None

    normalized_timestamp *= definitions.MICROSECONDS_PER_SECOND

    try:
      normalized_timestamp = normalized_timestamp.quantize(
          1, rounding=decimal.ROUND_HALF_UP)
    except decimal.InvalidOperation as exception:
      raise ValueError(
          f'Unable to round normalized timestamp with error: {exception!s}')

    return int(normalized_timestamp)

  def GetTimeOfDay(self):
    """Retrieves the time of day represented by the date and time values.

    Note that the time is adjusted to UTC.

    Returns:
       tuple[int, int, int]: hours, minutes, seconds or (None, None, None)
           if the date and time values do not represent a time of day.
    """
    _, _, _, hours, minutes, seconds = self._GetDateWithTimeOfDay()
    return hours, minutes, seconds
