import datetime as dt
from enum import Enum
import functools
import itertools
import re
from typing import Callable
from typing import Iterable
from typing import Optional
from typing import Union

from userinputgetter.utils.date_utils import get_day_suffix
from userinputgetter.user_input_getter import UserInputGetter


def format_date_string(date: dt.date, fmt: str) -> dt.date:
    """
    Defined to allow us to get the projection onto the first
    argument using functools.partial
    """
    return date.strftime(fmt)


full_day_regex_str = "(0[1-9]|[12][0-9]|3[01])"
single_digit_or_full_day_regex_str = f"([1-9]|{full_day_regex_str})"
full_month_regex_str = "(0[1-9]|1[012])"
full_year_regex_str = "[0-9]{4}"


class _SpecifiedDateInputGetter(UserInputGetter):
    """
    UserInputGetter which parses strings as dates, in one of the
    required formats, but only accepts subsets of the specified
    supported dates.

    Not intended to be called directly - use DateInputGetter with
    arguments in the initialiser to make use of this class
    """

    class _DateStrftimeFormats(Enum):
        def __init__(self, fmt: str, regex: re.Pattern):
            self.fmt = fmt
            self.regex = regex

        YearDashMonthDashDay = (
            "%Y-%m-%d",
            re.compile(f"^{full_year_regex_str}-{full_month_regex_str}-{full_day_regex_str}$")
        )
        YearSlashMonthSlashDay = (
            "%Y/%m/%d",
            re.compile(f"^{full_year_regex_str}/{full_month_regex_str}/{full_day_regex_str}$")
        )
        YearMonthDay = (
            "%Y%m%d",
            re.compile(f"^{full_year_regex_str}{full_month_regex_str}{full_day_regex_str}$")
        )
        DayDashMonthDashYear = (
            "%d-%m-%Y",
            re.compile(f"^{full_day_regex_str}-{full_month_regex_str}-{full_year_regex_str}$")
        )
        DaySlashMonthSlashYear = (
            "%d/%m/%Y",
            re.compile(f"^{full_day_regex_str}/{full_month_regex_str}/{full_year_regex_str}$")
        )
        DayMonthYear = (
            "%d%m%Y",
            re.compile(f"^{full_day_regex_str}{full_month_regex_str}{full_year_regex_str}$")
        )
        DayDashMonth = (
            "%d-%m",
            re.compile(f"^{full_day_regex_str}-{full_month_regex_str}$")
        )
        DaySlashMonth = (
            "%d/%m",
            re.compile(f"^{full_day_regex_str}/{full_month_regex_str}$")
        )

    class _DateFormatLambdas(Enum):
        def __init__(self, func: Callable[dt.date, str], regex: re.Pattern):
            self.func = func
            self.regex = regex

        DayWithSuffix = (
            lambda date: f"{date.day}{get_day_suffix(date.day)}",
            re.compile(f"^{single_digit_or_full_day_regex_str}(st|nd|rd|th)$")
        )
        Day = (
            lambda date: str(date.day),
            re.compile(f"^{single_digit_or_full_day_regex_str}$")
        )
        DayName = (
            lambda date: date.strftime("%A").lower(),
            re.compile("^(monday|tuesday|wednesday|thursday|friday|saturday|sunday)$")
        )
        ThreeLetterDayName = (
            lambda date: date.strftime("%A")[:3].lower(),
            re.compile("^(mon|tue|wed|thu|fri|sat|sun)$")
        )

    DATE_TO_STRING_MAPS = tuple(
        functools.partial(format_date_string, fmt=x.fmt)
        for x in _DateStrftimeFormats
    )
    DATE_TO_STRING_MAPS += tuple(x.func for x in _DateFormatLambdas)

    def __init__(self, supported_options: Union[dt.date, Iterable[dt.date]]):
        if isinstance(supported_options, dt.date):
            supported_options = (supported_options, )

        supported_options = tuple(x for x in supported_options)
        if not all(isinstance(x, dt.date) for x in supported_options):
            raise TypeError("All supported options must be datetime.dates")

        super().__init__(supported_options)

        if len(self.supported_options) == 0:
            raise ValueError("_SpecifiedDateInputGetter must be passed supported options")

        self.lookup_map = self.get_lookup_map()

    def get_lookup_map(self):
        """
        Generate a mapping from supported user-input strings to datetime instances.

        The mapping is generated by applying all supported date-to-string
        functions in the DATE_TO_STRING_MAPS instance variable to all dates
        specified in the supported options, and noting down the correspondence.

        The DATE_TO_STRING_MAPS are assumed to chosen so that no two distinct
        date instances will map to the same string, so this property is asserted
        on construction
        """

        lookup_map = {}
        for date_to_string_map in _SpecifiedDateInputGetter.DATE_TO_STRING_MAPS:
            string_to_date_map = {
                date_to_string_map(date): date
                for date in self.supported_options
            }

            # This representation of dates as strings only makes sense if it's a bijection
            # and the choice of date to string maps are chosen to not have collisions
            if len(string_to_date_map) == len(self.supported_options):
                if any(k in lookup_map for k in string_to_date_map):
                    raise ValueError("Found an unexpected lookup map collision")

                lookup_map |= string_to_date_map

        return lookup_map

    @classmethod
    def is_valid(cls, value: str) -> bool:
        for x in itertools.chain(cls._DateStrftimeFormats, cls._DateFormatLambdas):
            if x.regex.match(value.lower()):
                return True

        return False

    def parse(self, value: str) -> Union[dt.date, str]:
        return self.lookup_map.get(value.lower(), value)

    def is_supported(self, value: str) -> bool:
        return value in self.lookup_map.values()


class _AnyDateInputGetter(UserInputGetter):
    """
    UserInputGetter which parses any string as a date, as long
    as it is parsable in one of the specified formats.

    Not intended to be called directly - use DateInputGetter with
    no arguments in the initialiser to make use of this class
    """

    DATE_FORMATS = (
        "%Y-%m-%d",
        "%Y/%m/%d",
        "%d/%m/%Y",
        "%d-%m-%Y",
    )

    def __init__(self):
        super().__init__()

    @classmethod
    def is_valid(cls, value: str) -> bool:
        try:
            cls.parse(value)
            return True
        except ValueError:
            return False

    @classmethod
    def parse(cls, value: str) -> dt.date:
        for fmt in _AnyDateInputGetter.DATE_FORMATS:
            try:
                return dt.datetime.strptime(value, fmt).date()
            except ValueError:
                continue

        raise ValueError(f"Unable to parse input {value} in _AnyDateInputGetter.parse")


def DateInputGetter(supported_options: Optional[Iterable[dt.date]] = None):
    """
    A (disguised) factory function which returns the relevant
    date input getter depending on whether input arguments are
    specified or not.

    This class abstracts away from the user the exact handling of
    date strings, which is different for when the user specifies
    supported dates and when they do not
    """

    if supported_options:
        return _SpecifiedDateInputGetter(supported_options)

    return _AnyDateInputGetter()
