# -*- coding: utf-8 -*-
from __future__ import unicode_literals

import re
import datetime
import pendulum
import typing

from pendulum.helpers import local_time
from pendulum.locales.locale import Locale
from pendulum.utils._compat import decode


_MATCH_1 = '\d'
_MATCH_2 = '\d\d'
_MATCH_3 = '\d{3}'
_MATCH_4 = '\d{4}'
_MATCH_6 = '[+-]?\d{6}'
_MATCH_1_TO_2 = '\d\d?'
_MATCH_1_TO_3 = '\d{1,3}'
_MATCH_1_TO_4 = '\d{1,4}'
_MATCH_1_TO_6 = '[+-]?\d{1,6}'
_MATCH_3_TO_4 = '\d{3}\d?'
_MATCH_5_TO_6 = '\d{5}\d?'
_MATCH_UNSIGNED = '\d+'
_MATCH_SIGNED = '[+-]?\d+'
_MATCH_OFFSET = '(?i)Z|[+-]\d\d:?\d\d'
_MATCH_SHORT_OFFSET = '(?i)Z|[+-]\d\d(?::?\d\d)?'
_MATCH_TIMESTAMP = '[+-]?\d+(\.\d{1,6})?'
_MATCH_WORD = "(?i)[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}"
_MATCH_TIMEZONE = '[A-za-z0-9-+]+(/[A-Za-z0-9-+]+)?'


class Formatter:

    _TOKENS = '\[([^\[]*)\]|\\\(.)|' \
              '(' \
              'Mo|MM?M?M?' \
              '|Do|DDDo|DD?D?D?|ddd?d?|do?' \
              '|E{1,4}' \
              '|w[o|w]?|W[o|W]?|Qo?' \
              '|YYYY|YY|Y' \
              '|gg(ggg?)?|GG(GGG?)?' \
              '|a|A' \
              '|hh?|HH?|kk?' \
              '|mm?|ss?|S{1,9}' \
              '|x|X' \
              '|zz?|ZZ?' \
              '|LTS|LT|LL?L?L?' \
              ')'

    _FORMAT_RE = re.compile(_TOKENS)

    _LOCALIZABLE_TOKENS = {
        'Qo': None,
        'MMMM': 'months.wide',
        'MMM': 'months.abbreviated',
        'Mo': None,
        'DDDo': None,
        'Do': lambda locale: tuple(
            '\d+{}'.format(o) for o in locale.get('custom.ordinal').values()
        ),
        'dddd': 'days.wide',
        'ddd': 'days.abbreviated',
        'dd': 'days.short',
        'do': None,
        'Wo': None,
        'wo': None,
        'A': lambda locale: (locale.translation('day_periods.am'),
                             locale.translation('day_periods.pm')),
        'a': lambda locale: (locale.translation('day_periods.am').lower(),
                             locale.translation('day_periods.pm').lower()),
    }

    _TOKENS_RULES = {
        # Year
        'YYYY': lambda dt: '{:d}'.format(dt.year),
        'YY': lambda dt: '{:d}'.format(dt.year)[2:],
        'Y': lambda dt: '{:d}'.format(dt.year),

        # Quarter
        'Q': lambda dt: '{:d}'.format(dt.quarter),

        # Month
        'MM': lambda dt: '{:02d}'.format(dt.month),
        'M': lambda dt: '{:d}'.format(dt.month),

        # Day
        'DD': lambda dt: '{:02d}'.format(dt.day),
        'D': lambda dt: '{:d}'.format(dt.day),

        # Day of Year
        'DDDD': lambda dt: '{:03d}'.format(dt.day_of_year),
        'DDD': lambda dt: '{:d}'.format(dt.day_of_year),

        # Day of Week
        'd': lambda dt: '{:d}'.format(dt.day_of_week),

        # Day of ISO Week
        'E': lambda dt: '{:d}'.format(dt.isoweekday()),

        # Hour
        'HH': lambda dt: '{:02d}'.format(dt.hour),
        'H': lambda dt: '{:d}'.format(dt.hour),
        'hh': lambda dt: '{:02d}'.format(dt.hour % 12 or 12),
        'h': lambda dt: '{:d}'.format(dt.hour % 12 or 12),

        # Minute
        'mm': lambda dt: '{:02d}'.format(dt.minute),
        'm': lambda dt: '{:d}'.format(dt.minute),

        # Second
        'ss': lambda dt: '{:02d}'.format(dt.second),
        's': lambda dt: '{:d}'.format(dt.second),

        # Fractional second
        'S': lambda dt: '{:01d}'.format(dt.microsecond // 100000),
        'SS': lambda dt: '{:02d}'.format(dt.microsecond // 10000),
        'SSS': lambda dt: '{:03d}'.format(dt.microsecond // 1000),
        'SSSS': lambda dt: '{:04d}'.format(dt.microsecond // 100),
        'SSSSS': lambda dt: '{:05d}'.format(dt.microsecond // 10),
        'SSSSSS': lambda dt: '{:06d}'.format(dt.microsecond),

        # Timestamp
        'X': lambda dt: '{:d}'.format(dt.int_timestamp),

        # Timezone
        'zz': lambda dt: "{}".format(dt.tzname() if dt.tzinfo is not None else ''),
        'z': lambda dt: "{}".format(dt.timezone_name or ''),
    }

    _DATE_FORMATS = {
        'LTS': 'formats.time.full',
        'LT': 'formats.time.short',
        'L': 'formats.date.short',
        'LL': 'formats.date.long',
        'LLL': 'formats.datetime.long',
        'LLLL': 'formats.datetime.full',
    }

    _DEFAULT_DATE_FORMATS = {
        'LTS': 'h:mm:ss A',
        'LT': 'h:mm A',
        'L': 'MM/DD/YYYY',
        'LL': 'MMMM D, YYYY',
        'LLL': 'MMMM D, YYYY h:mm A',
        'LLLL': 'dddd, MMMM D, YYYY h:mm A',
    }

    _REGEX_TOKENS = {
        'Y': _MATCH_SIGNED,
        'YY': (_MATCH_1_TO_2, _MATCH_2),
        'YYYY': (_MATCH_1_TO_4, _MATCH_4),
        'Q': _MATCH_1,
        'Qo': None,
        'M': _MATCH_1_TO_2,
        'MM': (_MATCH_1_TO_2, _MATCH_2),
        'MMM': _MATCH_WORD,
        'MMMM': _MATCH_WORD,
        'D': _MATCH_1_TO_2,
        'DD': (_MATCH_1_TO_2, _MATCH_2),
        'DDD': _MATCH_1_TO_3,
        'DDDD': _MATCH_3,
        'dddd': _MATCH_WORD,
        'ddd': _MATCH_WORD,
        'dd': _MATCH_WORD,
        'd': _MATCH_1,
        'E': _MATCH_1,
        'Do': None,
        'H': _MATCH_1_TO_2,
        'HH': (_MATCH_1_TO_2, _MATCH_2),
        'h': _MATCH_1_TO_2,
        'hh': (_MATCH_1_TO_2, _MATCH_2),
        'm': _MATCH_1_TO_2,
        'mm': (_MATCH_1_TO_2, _MATCH_2),
        's': _MATCH_1_TO_2,
        'ss': (_MATCH_1_TO_2, _MATCH_2),
        'S': (_MATCH_1_TO_3, _MATCH_1),
        'SS': (_MATCH_1_TO_3, _MATCH_2),
        'SSS': (_MATCH_1_TO_3, _MATCH_3),
        'SSSS': _MATCH_UNSIGNED,
        'SSSSS': _MATCH_UNSIGNED,
        'SSSSSS': _MATCH_UNSIGNED,
        'x': _MATCH_SIGNED,
        'X': _MATCH_TIMESTAMP,
        'ZZ': _MATCH_SHORT_OFFSET,
        'Z': _MATCH_OFFSET,
        'z': _MATCH_TIMEZONE
    }

    _PARSE_TOKENS = {
        'YYYY': lambda year: int(year),
        'YY': lambda year: int(year),
        'Q': lambda quarter: int(quarter),
        'MMMM': lambda month: month,
        'MMM': lambda month: month,
        'MM': lambda month: int(month),
        'M': lambda month: int(month),
        'DDDD': lambda day: int(day),
        'DDD': lambda day: int(day),
        'DD': lambda day: int(day),
        'D': lambda day: int(day),
        'dddd': lambda weekday: weekday,
        'ddd': lambda weekday: weekday,
        'dd': lambda weekday: weekday,
        'd': lambda weekday: int(weekday) % 7,
        'E': lambda weekday: int(weekday),
        'HH': lambda hour: int(hour),
        'H': lambda hour: int(hour),
        'hh': lambda hour: int(hour),
        'h': lambda hour: int(hour),
        'mm': lambda minute: int(minute),
        'm': lambda minute: int(minute),
        'ss': lambda second: int(second),
        's': lambda second: int(second),
        'S': lambda us: int(us) * 100000,
        'SS': lambda us: int(us) * 10000,
        'SSS': lambda us: int(us) * 1000,
        'SSSS': lambda us: int(us) * 100,
        'SSSSS': lambda us: int(us) * 10,
        'SSSSSS': lambda us: int(us),
        'a': lambda meridiem: meridiem,
        'X': lambda ts: float(ts),
        'x': lambda ts: float(ts) / 1e3,
        'ZZ': str,
        'Z': str,
        'z': str
    }

    def format(self, dt, fmt, locale=None):
        """
        Formats a DateTime instance with a given format and locale.

        :param dt: The instance to format
        :type dt: pendulum.DateTime

        :param fmt: The format to use
        :type fmt: str

        :param locale: The locale to use
        :type locale: str or Locale or None

        :rtype: str
        """
        if not locale:
            locale = pendulum.get_locale()

        locale = Locale.load(locale)

        result = self._FORMAT_RE.sub(
            lambda m: m.group(1)
                if m.group(1)
                else m.group(2)
                if m.group(2)
                else self._format_token(dt, m.group(3), locale),
            fmt
        )

        return decode(result)

    def _format_token(self, dt, token, locale):
        """
        Formats a DateTime instance with a given token and locale.

        :param dt: The instance to format
        :type dt: pendulum.DateTime

        :param token: The token to use
        :type token: str

        :param locale: The locale to use
        :type locale: Locale

        :rtype: str
        """
        if token in self._DATE_FORMATS:
            fmt = locale.get('custom.date_formats.{}'.format(token))
            if fmt is None:
                fmt = self._DEFAULT_DATE_FORMATS[token]

            return self.format(dt, fmt, locale)

        if token in self._LOCALIZABLE_TOKENS:
            return self._format_localizable_token(dt, token, locale)

        if token in self._TOKENS_RULES:
            return self._TOKENS_RULES[token](dt)

        # Timezone
        if token in ['ZZ', 'Z']:
            if dt.tzinfo is None:
                return ''

            separator = ':' if token == 'Z' else ''
            offset = dt.utcoffset() or datetime.timedelta()
            minutes = offset.total_seconds() / 60

            if minutes >= 0:
                sign = '+'
            else:
                sign = '-'

            hour, minute = divmod(abs(int(minutes)), 60)

            return '{}{:02d}{}{:02d}'.format(sign, hour, separator, minute)

    def _format_localizable_token(self, dt, token, locale):
        """
        Formats a DateTime instance
        with a given localizable token and locale.

        :param dt: The instance to format
        :type dt: pendulum.DateTime

        :param token: The token to use
        :type token: str

        :param locale: The locale to use
        :type locale: Locale

        :rtype: str
        """
        if token == 'MMM':
            return locale.get('translations.months.abbreviated')[dt.month]
        elif token == 'MMMM':
            return locale.get('translations.months.wide')[dt.month]
        elif token == 'dd':
            return locale.get('translations.days.short')[dt.day_of_week]
        elif token == 'ddd':
            return locale.get('translations.days.abbreviated')[dt.day_of_week]
        elif token == 'dddd':
            return locale.get('translations.days.wide')[dt.day_of_week]
        elif token == 'Do':
            return locale.ordinalize(dt.day)
        elif token == 'do':
            return locale.ordinalize(dt.day_of_week)
        elif token == 'Mo':
            return locale.ordinalize(dt.month)
        elif token == 'Qo':
            return locale.ordinalize(dt.quarter)
        elif token == 'wo':
            return locale.ordinalize(dt.week_of_year)
        elif token == 'DDDo':
            return locale.ordinalize(dt.day_of_year)
        elif token == 'A':
            key = 'translations.day_periods'
            if dt.hour >= 12:
                key += '.pm'
            else:
                key += '.am'

            return locale.get(key)
        else:
            return token

    def parse(self,
              time,         # type: str
              fmt,          # type: str
              now,          # type: pendulum.DateTime
              locale=None,  # type:  typing.Union[str, None]
              ):  # type: (...) -> dict
        """
        Parses a time string matching a given format as a tuple.

        :param time: The timestring
        :param fmt: The format
        :param now: The datetime to use as "now"
        :param locale: The locale to use

        :return: The parsed elements
        """
        escaped_fmt = re.escape(fmt)

        tokens = self._FORMAT_RE.findall(escaped_fmt)
        if not tokens:
            return time

        if not locale:
            locale = pendulum.get_locale()

        locale = Locale.load(locale)

        parsed = {
            'year': None,
            'month': None,
            'day': None,
            'hour': None,
            'minute': None,
            'second': None,
            'microsecond': None,
            'tz': None,
            'quarter': None,
            'day_of_week': None,
            'day_of_year': None,
            'meridiem': None,
            'timestamp': None
        }

        pattern = self._FORMAT_RE.sub(
            lambda m: self._replace_tokens(m.group(0), locale),
            escaped_fmt
        )

        if not re.match(pattern, time):
            raise ValueError('String does not match format {}'.format(fmt))

        re.sub(pattern, lambda m: self._get_parsed_values(m, parsed, locale, now), time)

        return self._check_parsed(parsed, now)

    def _check_parsed(self, parsed, now
                      ):  # type: (dict, pendulum.DateTime) -> dict
        """
        Checks validity of parsed elements.

        :param parsed: The elements to parse.

        :return: The validated elements.
        """
        validated = {
            'year': parsed['year'],
            'month': parsed['month'],
            'day': parsed['day'],
            'hour': parsed['hour'],
            'minute': parsed['minute'],
            'second': parsed['second'],
            'microsecond': parsed['microsecond'],
            'tz': None
        }

        # If timestamp has been specified
        # we use it and don't go any further
        if parsed['timestamp'] is not None:
            str_us = str(parsed['timestamp'])
            if '.' in str_us:
                microseconds = int(
                    "{}".format(str_us.split('.')[1].ljust(6, '0'))
                )
            else:
                microseconds = 0

            time = local_time(parsed['timestamp'], 0, microseconds)
            validated['year'] = time[0]
            validated['month'] = time[1]
            validated['day'] = time[2]
            validated['hour'] = time[3]
            validated['minute'] = time[4]
            validated['second'] = time[5]
            validated['microsecond'] = time[6]

            return validated

        if parsed['quarter'] is not None:
            if validated['year'] is not None:
                dt = pendulum.datetime(validated['year'], 1, 1)
            else:
                dt = now

            dt = dt.start_of('year')

            while dt.quarter != parsed['quarter']:
                dt = dt.add(months=3)

            validated['year'] = dt.year
            validated['month'] = dt.month
            validated['day'] = dt.day

        if validated['year'] is None:
            validated['year'] = now.year

        if parsed['day_of_year'] is not None:
            dt = pendulum.parse(
                "{}-{}".format(
                    validated['year'], parsed['day_of_year']
                )
            )

            validated['month'] = dt.month
            validated['day'] = dt.day

        if parsed['day_of_week'] is not None:
            dt = pendulum.datetime(
                validated['year'],
                validated['month'] or now.month,
                validated['day'] or now.day
            )
            dt = dt.start_of('week').subtract(days=1)
            dt = dt.next(parsed['day_of_week'])
            validated['year'] = dt.year
            validated['month'] = dt.month
            validated['day'] = dt.day

        # Meridiem
        if parsed['meridiem'] is not None:
            # If the time is greater than 13:00:00
            # This is not valid
            if validated['hour'] is None:
                raise ValueError('Invalid Date')

            t = (
                validated['hour'],
                validated['minute'],
                validated['second'],
                validated['microsecond']
            )
            if t >= (13, 0, 0, 0):
                raise ValueError('Invalid date')

            pm = parsed['meridiem'] == 'pm'
            validated['hour'] %= 12
            if pm:
                validated['hour'] += 12

        if validated['month'] is None:
            if parsed['year'] is not None:
                validated['month'] = parsed['month'] or 1
            else:
                validated['month'] = parsed['month'] or now.month

        if validated['day'] is None:
            if parsed['year'] is not None or parsed['month'] is not None:
                validated['day'] = parsed['day'] or 1
            else:
                validated['day'] = parsed['day'] or now.day

        for part in ['hour', 'minute', 'second', 'microsecond']:
            if validated[part] is None:
                validated[part] = 0

        validated['tz'] = parsed['tz']

        return validated

    def _get_parsed_values(self, m, parsed, locale, now
                           ):  # type: (..., dict, Locale, pendulum.DateTime) -> None
        for token, index in m.re.groupindex.items():
            if token in self._LOCALIZABLE_TOKENS:
                self._get_parsed_locale_value(
                    token, m.group(index), parsed, locale
                )
            else:
                self._get_parsed_value(token, m.group(index), parsed, now)

    def _get_parsed_value(self, token, value, parsed, now
                          ):  # type: (str, str, dict, pendulum.DateTime) -> None
        parsed_token = self._PARSE_TOKENS[token](value)

        if 'Y' in token:
            if token == 'YY':
                parsed_token = now.year // 100 * 100 + parsed_token

            parsed['year'] = parsed_token
        elif 'Q' == token:
            parsed['quarter'] = parsed_token
        elif token in ['MM', 'M']:
            parsed['month'] = parsed_token
        elif token in ['DDDD', 'DDD']:
            parsed['day_of_year'] = parsed_token
        elif 'D' in token:
            parsed['day'] = parsed_token
        elif 'H' in token:
            parsed['hour'] = parsed_token
        elif token in ['hh', 'h']:
            if parsed_token > 12:
                raise ValueError('Invalid date')

            parsed['hour'] = parsed_token
        elif 'm' in token:
            parsed['minute'] = parsed_token
        elif 's' in token:
            parsed['second'] = parsed_token
        elif 'S' in token:
            parsed['microsecond'] = parsed_token
        elif token in ['d', 'E']:
            parsed['day_of_week'] = parsed_token
        elif token in ['X', 'x']:
            parsed['timestamp'] = parsed_token
        elif token in ['ZZ', 'Z']:
            negative = True if value.startswith('-') else False
            tz = value[1:]
            if ':' not in tz:
                if len(tz) == 2:
                    tz = '{}00'.format(tz)

                off_hour = tz[0:2]
                off_minute = tz[2:4]
            else:
                off_hour, off_minute = tz.split(':')

            offset = ((int(off_hour) * 60) + int(off_minute)) * 60

            if negative:
                offset = -1 * offset

            parsed['tz'] = pendulum.timezone(offset)
        elif token == 'z':
            # Full timezone
            if value not in pendulum.timezones:
                raise ValueError('Invalid date')

            parsed['tz'] = pendulum.timezone(value)

    def _get_parsed_locale_value(self, token, value, parsed, locale
                                 ):  # type: (str, str, dict, Locale) -> None
        if token == 'MMMM':
            unit = 'month'
            match = 'months.wide'
        elif token == 'MMM':
            unit = 'month'
            match = 'months.abbreviated'
        elif token == 'Do':
            parsed['day'] = int(re.match('(\d+)', value).group(1))

            return
        elif token == 'dddd':
            unit = 'day_of_week'
            match = 'days.wide'
        elif token == 'ddd':
            unit = 'day_of_week'
            match = 'days.abbreviated'
        elif token == 'dd':
            unit = 'day_of_week'
            match = 'days.short'
        elif token in ['a', 'A']:
            valid_values = [
                locale.translation('day_periods.am'),
                locale.translation('day_periods.pm'),
            ]

            if token == 'a':
                value = value.lower()
                valid_values = list(map(lambda x: x.lower(), valid_values))

            if value not in valid_values:
                raise ValueError('Invalid date')

            parsed['meridiem'] = ['am', 'pm'][valid_values.index(value)]

            return
        else:
            raise ValueError('Invalid token "{}"'.format(token))

        parsed[unit] = locale.match_translation(match, value)
        if value is None:
            raise ValueError('Invalid date')

    def _replace_tokens(self, token, locale):  # type: (str, Locale) -> str
        if token.startswith('[') and token.endswith(']'):
            return token[1:-1]
        elif token.startswith('\\'):
            return token
        elif (token not in self._REGEX_TOKENS
              and token not in self._LOCALIZABLE_TOKENS):
            raise ValueError('Unsupported token: {}'.format(token))

        if token in self._LOCALIZABLE_TOKENS:
            values = self._LOCALIZABLE_TOKENS[token]
            if callable(values):
                candidates = values(locale)
            else:
                candidates = tuple(
                    locale.translation(self._LOCALIZABLE_TOKENS[token]).values()
                )
        else:
            candidates = self._REGEX_TOKENS[token]

        if not candidates:
            raise ValueError('Unsupported token: {}'.format(token))

        if not isinstance(candidates, tuple):
            candidates = (candidates,)

        pattern = '(?P<{}>{})'.format(token, '|'.join([decode(p) for p in candidates]))

        return pattern
