#  python-holidays
#  ---------------
#  A fast, efficient Python library for generating country and subdivision
#  specific sets of holidays on the fly. It aims to make determining whether a
#  specific date is a holiday as fast and flexible as possible.
#
#  Authors: dr-prodigy <maurizio.montel@gmail.com> (c) 2017-2022
#           ryanss <ryanssdev@icloud.com> (c) 2014-2017
#  Website: https://github.com/dr-prodigy/python-holidays
#  License: MIT (see LICENSE file)

__all__ = (
    "CountryHoliday",
    "country_holidays",
    "financial_holidays",
    "list_supported_countries",
    "list_supported_financial",
)

import inspect
import warnings
from datetime import date, timedelta
from functools import lru_cache
from typing import Dict, Iterable, List, Optional, Union

from hijri_converter import convert
from hijri_converter.ummalqura import GREGORIAN_RANGE

from holidays import countries, financial
from holidays.holiday_base import HolidayBase


def country_holidays(
    country: str,
    subdiv: Optional[str] = None,
    years: Optional[Union[int, Iterable[int]]] = None,
    expand: bool = True,
    observed: bool = True,
    prov: Optional[str] = None,
    state: Optional[str] = None,
) -> HolidayBase:
    """
    Returns a new dictionary-like :py:class:`HolidayBase` object for the public
    holidays of the country matching **country** and other keyword arguments.

    :param country:
        An ISO 3166-1 Alpha-2 country code.

    :param subdiv:
        The subdivision (e.g. state or province); not implemented for all
        countries (see documentation).

    :param years:
        The year(s) to pre-calculate public holidays for at instantiation.

    :param expand:
        Whether the entire year is calculated when one date from that year
        is requested.

    :param observed:
        Whether to include the dates of when public holiday are observed
        (e.g. a holiday falling on a Sunday being observed the following
        Monday). False may not work for all countries.

    :param prov:
        *deprecated* use subdiv instead.

    :param state:
        *deprecated* use subdiv instead.

    :return:
        A :py:class:`HolidayBase` object matching the **country**.

    The key of the :class:`dict`-like :class:`HolidayBase` object is the
    `date` of the holiday, and the value is the name of the holiday itself.
    Dates where a key is not present are not public holidays (or, if
    **observed** is False, days when a public holiday is observed).

    When passing the `date` as a key, the `date` can be expressed in one of the
    following types:

    * :class:`datetime.date`,
    * :class:`datetime.datetime`,
    * a :class:`str` of any format recognized by :func:`dateutil.parser.parse`,
    * or a :class:`float` or :class:`int` representing a POSIX timestamp.

    The key is always returned as a :class:`datetime.date` object.

    To maximize speed, the list of public holidays is built on the fly as
    needed, one calendar year at a time. When the object is instantiated
    without a **years** parameter, it is empty, but, unless **expand** is set
    to False, as soon as a key is accessed the class will calculate that entire
    year's list of holidays and set the keys with them.

    If you need to list the holidays as opposed to querying individual dates,
    instantiate the class with the **years** parameter.

    Example usage:

    >>> from holidays import country_holidays
    >>> us_holidays = country_holidays('US')
    # For a specific subdivision (e.g. state or province):
    >>> calif_holidays = country_holidays('US', subdiv='CA')

    The below will cause 2015 holidays to be calculated on the fly:

    >>> from datetime import date
    >>> assert date(2015, 1, 1) in us_holidays

    This will be faster because 2015 holidays are already calculated:

    >>> assert date(2015, 1, 2) not in us_holidays

    The :class:`HolidayBase` class also recognizes strings of many formats
    and numbers representing a POSIX timestamp:

    >>> assert '2014-01-01' in us_holidays
    >>> assert '1/1/2014' in us_holidays
    >>> assert 1388597445 in us_holidays

    Show the holiday's name:

    >>> us_holidays.get('2014-01-01')
    "New Year's Day"

    Check a range:

    >>> us_holidays['2014-01-01': '2014-01-03']
    [datetime.date(2014, 1, 1)]

    List all 2020 holidays:

    >>> us_holidays = country_holidays('US', years=2020)
    >>> for day in us_holidays.items():
    ...     print(day)
    (datetime.date(2020, 1, 1), "New Year's Day")
    (datetime.date(2020, 1, 20), 'Martin Luther King Jr. Day')
    (datetime.date(2020, 2, 17), "Washington's Birthday")
    (datetime.date(2020, 5, 25), 'Memorial Day')
    (datetime.date(2020, 7, 4), 'Independence Day')
    (datetime.date(2020, 7, 3), 'Independence Day (Observed)')
    (datetime.date(2020, 9, 7), 'Labor Day')
    (datetime.date(2020, 10, 12), 'Columbus Day')
    (datetime.date(2020, 11, 11), 'Veterans Day')
    (datetime.date(2020, 11, 26), 'Thanksgiving')
    (datetime.date(2020, 12, 25), 'Christmas Day')

    Some holidays are only present in parts of a country:

    >>> us_pr_holidays = country_holidays('US', subdiv='PR')
    >>> assert '2018-01-06' not in us_holidays
    >>> assert '2018-01-06' in us_pr_holidays

    Append custom holiday dates by passing one of:

    * a :class:`dict` with date/name key/value pairs (e.g.
      ``{'2010-07-10': 'My birthday!'}``),
    * a list of dates (as a :class:`datetime.date`, :class:`datetime.datetime`,
      :class:`str`, :class:`int`, or :class:`float`); ``'Holiday'`` will be
      used as a description,
    * or a single date item (of one of the types above); ``'Holiday'`` will be
      used as a description:

    >>> custom_holidays = country_holidays('US', years=2015)
    >>> custom_holidays.update({'2015-01-01': "New Year's Day"})
    >>> custom_holidays.update(['2015-07-01', '07/04/2015'])
    >>> custom_holidays.update(date(2015, 12, 25))
    >>> assert date(2015, 1, 1) in custom_holidays
    >>> assert date(2015, 1, 2) not in custom_holidays
    >>> assert '12/25/2015' in custom_holidays

    For more complex logic, like 4th Monday of January, you can inherit the
    :class:`HolidayBase` class and define your own :meth:`_populate` method.
    See documentation for examples.
    """
    try:
        country_classes = inspect.getmembers(countries, inspect.isclass)
        country_class = next(
            obj for name, obj in country_classes if name == country
        )
        country_holiday: HolidayBase = country_class(
            years=years,
            subdiv=subdiv,
            expand=expand,
            observed=observed,
            prov=prov,
            state=state,
        )
    except StopIteration:
        raise NotImplementedError(f"Country {country} not available")
    return country_holiday


def financial_holidays(
    market: str,
    subdiv: Optional[str] = None,
    years: Optional[Union[int, Iterable[int]]] = None,
    expand: bool = True,
    observed: bool = True,
) -> HolidayBase:
    """
    Returns a new dictionary-like :py:class:`HolidayBase` object for the public
    holidays of the financial market matching **market** and other keyword
    arguments.

    :param market:
        An ISO 3166-1 Alpha-2 market code.

    :param subdiv:
        Currently not implemented for markets (see documentation).

    :param years:
        The year(s) to pre-calculate public holidays for at instantiation.

    :param expand:
        Whether the entire year is calculated when one date from that year
        is requested.

    :param observed:
        Whether to include the dates of when public holiday are observed
        (e.g. a holiday falling on a Sunday being observed the following
        Monday). False may not work for all countries.

    :return:
        A :py:class:`HolidayBase` object matching the **market**.

    Example usage:

    >>> from holidays import financial_holidays
    >>> nyse_holidays = financial_holidays('NYSE')

    See :py:func:`country_holidays` documentation for further details and
    examples.
    """
    try:
        financial_classes = inspect.getmembers(financial, inspect.isclass)
        financial_class = next(
            obj for name, obj in financial_classes if name == market
        )
        financial_holiday: HolidayBase = financial_class(
            years=years,
            subdiv=subdiv,
            expand=expand,
            observed=observed,
        )
    except StopIteration:
        raise NotImplementedError(f"Financial market {market} not available")
    return financial_holiday


def CountryHoliday(
    country: str,
    subdiv: Optional[str] = None,
    years: Optional[Union[int, Iterable[int]]] = None,
    expand: bool = True,
    observed: bool = True,
    prov: Optional[str] = None,
    state: Optional[str] = None,
) -> HolidayBase:
    """
    Deprecated name for :py:func:`country_holidays`.

    :meta private:
    """

    warnings.warn(
        "CountryHoliday is deprecated, use country_holidays instead.",
        DeprecationWarning,
    )
    return country_holidays(
        country, subdiv, years, expand, observed, prov, state
    )


def list_supported_countries() -> Dict[str, List[str]]:
    """
    Get all supported countries and their subdivisions.

    :return:
        A dictionary where the key is the ISO 3166-1 Alpha-2 country codes and
        the value is a list of supported subdivision codes.
    """
    return {
        cls.country: cls.subdivisions
        for name, cls in inspect.getmembers(countries, inspect.isclass)
        if len(name) == 2 and issubclass(cls, HolidayBase)
    }


def list_supported_financial() -> Dict[str, List[str]]:
    """
    Get all supported financial markets and their subdivisions.

    :return:
        A dictionary where the key is the market codes and
        the value is a list of supported subdivision codes.
    """
    return {
        cls.market: cls.subdivisions
        for _, cls in inspect.getmembers(financial, inspect.isclass)
        if issubclass(cls, HolidayBase)
    }


def _islamic_to_gre(Gyear: int, Hmonth: int, Hday: int) -> List[date]:
    """
    Find the Gregorian dates of all instances of Islamic (Lunar Hijrī) calendar
    month and day falling within the Gregorian year. There could be up to two
    such instances in a single Gregorian year since the Islamic (Lunar Hijrī)
    calendar is about 11 days shorter.

    Relies on package `hijri_converter
    <https://www.pypy.org/package/hijri_converter>`__.

    :param Gyear:
        The Gregorian year.

    :param Hmonth:
        The Lunar Hijrī (Islamic) month.

    :param Hday:
        The Lunar Hijrī (Islamic) day.

    :return:
        List of Gregorian dates within the Gregorian year specified that
        matches the Islamic (Lunar Hijrī) calendar day and month specified.
    """
    gre_dates: List[date] = []

    # To avoid hijri_converter check range OverflowError.
    dt = (Gyear, Hmonth, Hday)
    dt_min, dt_max = GREGORIAN_RANGE
    if dt < dt_min or dt > dt_max:
        return gre_dates

    Hyear = convert.Gregorian(Gyear, 1, 1).to_hijri().datetuple()[0]
    gres = [
        convert.Hijri(y, Hmonth, Hday).to_gregorian()
        for y in range(Hyear - 1, Hyear + 2)
    ]
    gre_dates.extend(
        (date(*gre.datetuple()) for gre in gres if gre.year == Gyear)
    )

    return gre_dates


class _ChineseLuniSolar:
    def __init__(self) -> None:
        """
        This class has functions that generate Gregorian dates for holidays
        based on the Chinese lunisolar calendar.

        See `Wikipedia
        <https://en.wikipedia.org/wiki/Chinese_New_Year#Dates_in_Chinese_\
        lunisolar_calendar>`__

        Usage example:

        >>> from holidays.utils import _ChineseLuniSolar
        >>> cnls = _ChineseLuniSolar()
        >>> print(cnls.lunar_n_y_date(2010))
        2010-02-14
        """

        # A binary representation starting from year 1901 of the number of
        # days per year, and the number of days from the 1st to the 13th to
        # store the monthly (including the month of the month). 1 means that
        # the month is 30 days. 0 means the month is 29 days.
        # The 12th to 15th digits indicate the month of the next month.
        # If it is 0x0F, it means that there is no leap month.
        self.G_LUNAR_MONTH_DAYS = [
            0xF0EA4,  # 1901
            0xF1D4A,
            0x52C94,
            0xF0C96,
            0xF1536,
            0x42AAC,
            0xF0AD4,
            0xF16B2,
            0x22EA4,
            0xF0EA4,  # 1911
            0x6364A,
            0xF164A,
            0xF1496,
            0x52956,
            0xF055A,
            0xF0AD6,
            0x216D2,
            0xF1B52,
            0x73B24,
            0xF1D24,  # 1921
            0xF1A4A,
            0x5349A,
            0xF14AC,
            0xF056C,
            0x42B6A,
            0xF0DA8,
            0xF1D52,
            0x23D24,
            0xF1D24,
            0x61A4C,  # 1931
            0xF0A56,
            0xF14AE,
            0x5256C,
            0xF16B4,
            0xF0DA8,
            0x31D92,
            0xF0E92,
            0x72D26,
            0xF1526,
            0xF0A56,  # 1941
            0x614B6,
            0xF155A,
            0xF0AD4,
            0x436AA,
            0xF1748,
            0xF1692,
            0x23526,
            0xF152A,
            0x72A5A,
            0xF0A6C,  # 1951
            0xF155A,
            0x52B54,
            0xF0B64,
            0xF1B4A,
            0x33A94,
            0xF1A94,
            0x8152A,
            0xF152E,
            0xF0AAC,
            0x6156A,  # 1961
            0xF15AA,
            0xF0DA4,
            0x41D4A,
            0xF1D4A,
            0xF0C94,
            0x3192E,
            0xF1536,
            0x72AB4,
            0xF0AD4,
            0xF16D2,  # 1971
            0x52EA4,
            0xF16A4,
            0xF164A,
            0x42C96,
            0xF1496,
            0x82956,
            0xF055A,
            0xF0ADA,
            0x616D2,
            0xF1B52,  # 1981
            0xF1B24,
            0x43A4A,
            0xF1A4A,
            0xA349A,
            0xF14AC,
            0xF056C,
            0x60B6A,
            0xF0DAA,
            0xF1D92,
            0x53D24,  # 1991
            0xF1D24,
            0xF1A4C,
            0x314AC,
            0xF14AE,
            0x829AC,
            0xF06B4,
            0xF0DAA,
            0x52D92,
            0xF0E92,
            0xF0D26,  # 2001
            0x42A56,
            0xF0A56,
            0xF14B6,
            0x22AB4,
            0xF0AD4,
            0x736AA,
            0xF1748,
            0xF1692,
            0x53526,
            0xF152A,  # 2011
            0xF0A5A,
            0x4155A,
            0xF156A,
            0x92B54,
            0xF0BA4,
            0xF1B4A,
            0x63A94,
            0xF1A94,
            0xF192A,
            0x42A5C,  # 2021
            0xF0AAC,
            0xF156A,
            0x22B64,
            0xF0DA4,
            0x61D52,
            0xF0E4A,
            0xF0C96,
            0x5192E,
            0xF1956,
            0xF0AB4,  # 2031
            0x315AC,
            0xF16D2,
            0xB2EA4,
            0xF16A4,
            0xF164A,
            0x63496,
            0xF1496,
            0xF0956,
            0x50AB6,
            0xF0B5A,  # 2041
            0xF16D4,
            0x236A4,
            0xF1B24,
            0x73A4A,
            0xF1A4A,
            0xF14AA,
            0x5295A,
            0xF096C,
            0xF0B6A,
            0x31B54,  # 2051
            0xF1D92,
            0x83D24,
            0xF1D24,
            0xF1A4C,
            0x614AC,
            0xF14AE,
            0xF09AC,
            0x40DAA,
            0xF0EAA,
            0xF0E92,  # 2061
            0x31D26,
            0xF0D26,
            0x72A56,
            0xF0A56,
            0xF14B6,
            0x52AB4,
            0xF0AD4,
            0xF16CA,
            0x42E94,
            0xF1694,  # 2071
            0x8352A,
            0xF152A,
            0xF0A5A,
            0x6155A,
            0xF156A,
            0xF0B54,
            0x4174A,
            0xF1B4A,
            0xF1A94,
            0x3392A,  # 2081
            0xF192C,
            0x7329C,
            0xF0AAC,
            0xF156A,
            0x52B64,
            0xF0DA4,
            0xF1D4A,
            0x41C94,
            0xF0C96,
            0x8192E,  # 2091
            0xF0956,
            0xF0AB6,
            0x615AC,
            0xF16D4,
            0xF0EA4,
            0x42E4A,
            0xF164A,
            0xF1516,
            0x22936,  # 2100
        ]
        # Define range of years covered
        self.START_YEAR = 1901
        self.END_YEAR = 2099
        # The 1st day of the 1st month of the Gregorian calendar is 1901/2/19
        self.LUNAR_START_DATE = ((1901, 1, 1),)
        self.SOLAR_START_DATE = date(1901, 2, 19)
        # The Gregorian date for December 30, 2099 is 2100/2/8
        self.LUNAR_END_DATE = (2099, 12, 30)
        self.SOLAR_END_DATE = date(2100, 2, 18)

    @lru_cache()
    def _get_leap_month(self, lunar_year: int) -> int:
        """
        Calculate the leap lunar month in a lunar year.

        :param lunar_year:
            The lunar year.

        :return:
            The number of the leap month if one exists in the year, otherwise
            15.
        """
        return (
            self.G_LUNAR_MONTH_DAYS[lunar_year - self.START_YEAR] >> 16
        ) & 0x0F

    def _lunar_month_days(self, lunar_year: int, lunar_month: int) -> int:
        """
        Calculate the number of days in a lunar month.

        :param lunar_year:
            The lunar year.

        :param lunar_month:
            The lunar month of the lunar year.

        :return:
            The number of days in the lunar month.
        """
        return 29 + (
            (
                self.G_LUNAR_MONTH_DAYS[lunar_year - self.START_YEAR]
                >> lunar_month
            )
            & 0x01
        )

    def _lunar_year_days(self, year: int) -> int:
        """
        Calculate the number of days in a lunar year.

        :param year:
            The lunar year.

        :return:
            The number of days in the lunar year.
        """
        days = 0
        months_day = self.G_LUNAR_MONTH_DAYS[year - self.START_YEAR]
        for i in range(1, 13 if self._get_leap_month(year) == 0x0F else 14):
            day = 29 + ((months_day >> i) & 0x01)
            days += day
        return days

    @lru_cache()
    def _span_days(self, year: int) -> int:
        """
        Calculate the number of days elapsed since self.SOLAR_START_DATE to the
        beginning of the year.

        :param year:
            The year.

        :return:
             The number of days since self.SOLAR_START_DATE.
        """
        span_days = 0
        for y in range(self.START_YEAR, year):
            span_days += self._lunar_year_days(y)
        return span_days

    def lunar_n_y_date(self, year: int) -> date:
        """
        Calculate the Gregorian date of Chinese Lunar New Year.

        This is a faster implementation than calling
        ``lunar_to_gre(year, 1, 1)``.

        :param year:
            The Gregorian year.

        :return:
            The Gregorian date of Chinese Lunar New Year.
        """
        # The Chinese calendar defines the lunar month containing the winter
        # solstice as the eleventh month, which means that Chinese New Year
        # usually falls on the second new moon after the winter solstice
        # (rarely the third if an intercalary month intervenes). In more
        # than 96 percent of the years, Chinese New Year's Day is the closest
        # date to a new moon to lichun (Chinese: 立春; "start of spring") on 4
        # or 5 February, and the first new moon after dahan (Chinese: 大寒;
        # "major cold"). In the Gregorian calendar, the Chinese New Year begins
        # at the new moon that falls between 21 January and 20 February.

        span_days = self._span_days(year)
        # Always in first month (by definition)
        # leap_month = self._get_leap_month(year)
        # for m in range(1, 1 + (1 > leap_month)):
        #     span_days += self._lunar_month_days(year, m)
        return self.SOLAR_START_DATE + timedelta(span_days)

    def lunar_to_gre(
        self, year: int, month: int, day: int, leap: bool = True
    ) -> date:
        """
        Calculate the Gregorian date of a Chinese lunar day and month in a
        given Gregorian year.

        :param year:
            The Gregorian year.

        :param year:
            The Chinese lunar month.

        :param year:
            The Chinese lunar day.

        :return:
            The Gregorian date.
        """
        span_days = self._span_days(year)
        leap_month = self._get_leap_month(year) if leap else 15
        for m in range(1, month + (month > leap_month)):
            span_days += self._lunar_month_days(year, m)
        span_days += day - 1
        return self.SOLAR_START_DATE + timedelta(span_days)

    def vesak_date(self, year: int) -> date:
        """
        Calculate the estimated Gregorian date of Vesak for Thailand, Laos,
        Singapore and Indonesia, corresponding to the fourteenth day of the
        fourth month in the Chinese lunar calendar. See `Wikipedia
        <https://en.wikipedia.org/wiki/Vesak#Dates_of_observance>`__.

        :param year:
            The Gregorian year.

        :return:
            Estimated Gregorian date of Vesak (14th day of 4th month of the
            lunar calendar).
        """
        span_days = self._span_days(year)
        leap_month = self._get_leap_month(year)
        for m in range(1, 4 + (4 > leap_month)):
            span_days += self._lunar_month_days(year, m)
        span_days += 14
        return self.SOLAR_START_DATE + timedelta(span_days)

    def vesak_may_date(self, year: int) -> date:
        """
        Calculate the estimated Gregorian date of Vesak for Sri Lanka, Nepal,
        India, Bangladesh and Malaysia, corresponding to the day of the
        first full moon in May in the Gregorian calendar. See `Wikipedia
        <https://en.wikipedia.org/wiki/Vesak#Dates_of_observance>`__.

        :param year:
            The Gregorian year.

        :return:
            Estimated Gregorian date of Vesak (first full moon in May).
        """
        span_days = self._span_days(year)
        vesak_may_date = self.SOLAR_START_DATE + timedelta(span_days + 14)
        m = 1
        while vesak_may_date.month < 5:
            vesak_may_date += timedelta(self._lunar_month_days(year, m))
            m += 1
        return vesak_may_date

    def s_diwali_date(self, year: int) -> date:
        """
        Calculate the estimated Gregorian date of Southern India (Tamil)
        Diwali.

        Defined as the date of Amāvásyā (new moon) of Kārttikai, which
        corresponds with the months of November or December in the Gregorian
        calendar. See `Wikipedia <https://en.wikipedia.org/wiki/Diwali>`__.

        :param year:
            The Gregorian year.

        :return:
            Estimated Gregorian date of Southern India (Tamil) Diwali.
        """
        span_days = self._span_days(year)
        leap_month = self._get_leap_month(year)
        for m in range(1, 10 + (10 > leap_month)):
            span_days += self._lunar_month_days(year, m)
        span_days -= 2
        return self.SOLAR_START_DATE + timedelta(span_days)

    def thaipusam_date(self, year: int) -> date:
        """
        Calculate the estimated Gregorian date of Thaipusam (Tamil).

        Defined as the date of the full moon in the Tamil month of Thai, which
        corresponds with the months of January or February in the Gregorian
        calendar. See `Wikipedia <https://en.wikipedia.org/wiki/Thaipusam>`__.

        :param year:
            The Gregorian year.

        :return:
            Estimated Gregorian date of Thaipusam (Tamil).
        """
        span_days = self._span_days(year)
        leap_month = self._get_leap_month(year)
        for m in range(1, 1 + (leap_month <= 6)):
            span_days += self._lunar_month_days(year, m)
        span_days -= 15
        return self.SOLAR_START_DATE + timedelta(span_days)
