# =============================================================================
# IMPORTS
# =============================================================================
# Python
from __future__ import unicode_literals
from datetime import datetime, tzinfo, time as datetime_time
import pytz
import warnings

# Django
import django
from django.core import checks
from django.core.exceptions import ValidationError
from django.db.models.fields import DateTimeField, CharField
from django.utils.timezone import get_default_timezone, is_naive, make_aware
from django.utils.translation import ugettext_lazy as _

# App
from timezone_utils import forms


__all__ = ('TimeZoneField', 'LinkedTZDateTimeField')


# =============================================================================
# MODEL FIELDS
# =============================================================================
class TimeZoneField(CharField):
    # Enforce the minimum length of max_length to be the length of the longest
    #   pytz timezone string
    MIN_LENGTH = max(map(len, pytz.all_timezones))
    default_error_messages = {
        'invalid': _("'%(value)s' is not a valid time zone."),
    }

    # pylint: disable=newstyle
    def __init__(self, *args, **kwargs):
        # Retrieve the model field's declared max_length or default to pytz's
        #   maximum length
        declared_max_length = kwargs.get('max_length', self.MIN_LENGTH)

        # Set the max length to the highest value between the timezone maximum
        #   length and the declared max_length
        kwargs['max_length'] = max(declared_max_length, self.MIN_LENGTH)

        # Warn that we changed the value of max_length so that they can
        if declared_max_length and declared_max_length != kwargs['max_length']:
            warnings.warn(
                message='TimeZoneField max_length updated from {declared} to '
                '{current}.'.format(
                    declared=declared_max_length,
                    current=kwargs['max_length']
                ),
                category=UserWarning,
            )

        super(TimeZoneField, self).__init__(*args, **kwargs)

    def validate(self, value, model_instance):
        """
        Validates value and throws ValidationError. Subclasses should override
        this to provide validation logic.
        """
        # pylint: disable=newstyle
        super(TimeZoneField, self).validate(
            value=self.get_prep_value(value),
            model_instance=model_instance
        )

        # Insure the value is can be converted to a timezone
        self.to_python(value)

    def run_validators(self, value):
        # pylint: disable=newstyle
        super(TimeZoneField, self).run_validators(self.get_prep_value(value))

    def get_prep_value(self, value):
        """Converts timezone instances to strings for db storage."""
        # pylint: disable=newstyle
        value = super(TimeZoneField, self).get_prep_value(value)

        if isinstance(value, tzinfo):
            return value.zone

        return value

    # Django 2.0 updates the signature of from_db_value.
    # https://docs.djangoproject.com/en/2.0/releases/2.0/#context-argument-of-field-from-db-value-and-expression-convert-value
    if django.VERSION < (2,):
        def from_db_value(self, value, expression, connection, context):    # noqa
            """
            Converts a value as returned by the database to a Python object. It is
            the reverse of get_prep_value(). - New in Django 1.8
            """
            if value:
                return self.to_python(value)
            return value
    else:
        def from_db_value(self, value, expression, connection):
            """
            Converts a value as returned by the database to a Python object. It is
            the reverse of get_prep_value(). - New in Django 1.8
            """
            if value:
                return self.to_python(value)
            return value

    def to_python(self, value):
        """Returns a datetime.tzinfo instance for the value."""
        # pylint: disable=newstyle
        value = super(TimeZoneField, self).to_python(value)

        if not value:
            return value

        try:
            return pytz.timezone(str(value))
        except pytz.UnknownTimeZoneError:
            raise ValidationError(
                message=self.error_messages['invalid'],
                code='invalid',
                params={'value': value}
            )

    # pylint: disable=E0239
    def formfield(self, **kwargs):
        """Returns a custom form field for the TimeZoneField."""

        defaults = {'form_class': forms.TimeZoneField}
        defaults.update(**kwargs)
        return super(TimeZoneField, self).formfield(**defaults)

    # -------------------------------------------------------------------------
    # Django Checks Framework
    # -------------------------------------------------------------------------
    # pylint: disable=E0239
    def check(self, **kwargs):  # pragma: no cover
        """Calls the TimeZoneField's custom checks."""

        errors = super(TimeZoneField, self).check(**kwargs)
        errors.extend(self._check_timezone_max_length_attribute())
        errors.extend(self._check_choices_attribute())
        return errors

    def _check_timezone_max_length_attribute(self):     # pragma: no cover
        """
        Checks that the `max_length` attribute covers all possible pytz
        timezone lengths.
        """

        # Retrieve the maximum possible length for the time zone string
        possible_max_length = max(map(len, pytz.all_timezones))

        # Make sure that the max_length attribute will handle the longest time
        #   zone string
        if self.max_length < possible_max_length:   # pragma: no cover
            return [
                checks.Error(
                    msg=(
                        "'max_length' is too short to support all possible "
                        "pytz time zones."
                    ),
                    hint=(
                        "pytz {version}'s longest time zone string has a "
                        "length of {value}, although it is recommended that "
                        "you leave room for longer time zone strings to be "
                        "added in the future.".format(
                            version=pytz.VERSION,
                            value=possible_max_length
                        )
                    ),
                    obj=self,
                )
            ]

        # When no error, return an empty list
        return []

    def _check_choices_attribute(self):   # pragma: no cover
        """Checks to make sure that choices contains valid timezone choices."""

        if self.choices:
            warning_params = {
                'msg': (
                    "'choices' contains an invalid time zone value '{value}' "
                    "which was not found as a supported time zone by pytz "
                    "{version}."
                ),
                'hint': "Values must be found in pytz.all_timezones.",
                'obj': self,
            }

            for option_key, option_value in self.choices:
                if isinstance(option_value, (list, tuple)):
                    # This is an optgroup, so look inside the group for
                    # options.
                    for optgroup_key in map(lambda x: x[0], option_value):
                        if optgroup_key not in pytz.all_timezones:
                            # Make sure we don't raise this error on empty
                            #   values
                            if optgroup_key not in self.empty_values:
                                # Update the error message by adding the value
                                warning_params.update({
                                    'msg': warning_params['msg'].format(
                                        value=optgroup_key,
                                        version=pytz.VERSION
                                    )
                                })

                                # Return the warning
                                return [
                                    checks.Warning(**warning_params)
                                ]

                elif option_key not in pytz.all_timezones:
                    # Make sure we don't raise this error on empty
                    #   values
                    if option_key not in self.empty_values:
                        # Update the error message by adding the value
                        warning_params.update({
                            'msg': warning_params['msg'].format(
                                value=option_key,
                                version=pytz.VERSION
                            )
                        })

                        # Return the warning
                        return [
                            checks.Warning(**warning_params)
                        ]

        # When no error, return an empty list
        return []


class LinkedTZDateTimeField(DateTimeField):
    # pylint: disable=newstyle
    def __init__(self, *args, **kwargs):
        self.populate_from = kwargs.pop('populate_from', None)
        self.time_override = kwargs.pop('time_override', None)
        self.timezone = get_default_timezone()

        super(LinkedTZDateTimeField, self).__init__(*args, **kwargs)

    # Django 2.0 updates the signature of from_db_value.
    # https://docs.djangoproject.com/en/2.0/releases/2.0/#context-argument-of-field-from-db-value-and-expression-convert-value
    if django.VERSION < (2,):
        def from_db_value(self, value, expression, connection, context):    # noqa
            """
            Converts a value as returned by the database to a Python object. It is
            the reverse of get_prep_value(). - New in Django 1.8
            """
            if value:
                return self.to_python(value)
            return value
    else:
        def from_db_value(self, value, expression, connection):
            """
            Converts a value as returned by the database to a Python object. It is
            the reverse of get_prep_value(). - New in Django 1.8
            """
            if value:
                return self.to_python(value)
            return value

    def to_python(self, value):
        """Convert the value to the appropriate timezone."""
        # pylint: disable=newstyle
        value = super(LinkedTZDateTimeField, self).to_python(value)

        if not value:
            return value

        return value.astimezone(self.timezone)

    def pre_save(self, model_instance, add):
        """
        Converts the value being saved based on `populate_from` and
        `time_override`
        """
        # pylint: disable=newstyle
        # Retrieve the currently entered datetime
        value = super(
            LinkedTZDateTimeField,
            self
        ).pre_save(
            model_instance=model_instance,
            add=add
        )

        # Convert the value to the correct time/timezone
        value = self._convert_value(
            value=value,
            model_instance=model_instance,
            add=add
        )

        setattr(model_instance, self.attname, value)

        return value

    def deconstruct(self):  # pragma: no cover
        """Add our custom keyword arguments for migrations."""
        # pylint: disable=newstyle
        name, path, args, kwargs = super(
            LinkedTZDateTimeField,
            self
        ).deconstruct()

        # Only include kwarg if it's not the default
        if self.populate_from is not None:
            # Since populate_from requires a model instance and Django does
            #   not allow lambda, we hope that we have been provided a
            #   function that can be parsed
            kwargs['populate_from'] = self.populate_from

        # Only include kwarg if it's not the default
        if self.time_override is not None:
            if hasattr(self.time_override, '__call__'):
                # Call the callable datetime.time instance
                kwargs['time_override'] = self.time_override()
            else:
                kwargs['time_override'] = self.time_override

        return name, path, args, kwargs

    def _get_populate_from(self, model_instance):
        """
        Retrieves the timezone or None from the `populate_from` attribute.
        """

        if hasattr(self.populate_from, '__call__'):
            tz = self.populate_from(model_instance)
        else:
            from_attr = getattr(model_instance, self.populate_from)
            tz = callable(from_attr) and from_attr() or from_attr

        try:
            tz = pytz.timezone(str(tz))
        except pytz.UnknownTimeZoneError:
            # It was a valiant effort. Resistance is futile.
            raise

        # If we have a timezone, set the instance's timezone attribute
        self.timezone = tz

        return tz

    def _get_time_override(self):
        """
        Retrieves the datetime.time or None from the `time_override` attribute.
        """

        if callable(self.time_override):
            time_override = self.time_override()
        else:
            time_override = self.time_override

        if not isinstance(time_override, datetime_time):
            raise ValueError(
                'Invalid type. Must be a datetime.time instance.'
            )

        return time_override

    def _convert_value(self, value, model_instance, add):
        """
        Converts the value to the appropriate timezone and time as declared by
        the `time_override` and `populate_from` attributes.
        """

        if not value:
            return value

        # Retrieve the default timezone as the default
        tz = get_default_timezone()

        # If populate_from exists, override the default timezone
        if self.populate_from is not None:
            tz = self._get_populate_from(model_instance)

        if is_naive(value):
            value = make_aware(value=value, timezone=tz)

        # Convert the value to a datetime object in the correct timezone. This
        #   insures that we will have the correct date if we are performing a
        #   time override below.
        value = value.astimezone(tz)

        # Do not convert the time to the time override if auto_now or
        #   auto_now_add is set
        if self.time_override is not None and not (
            self.auto_now or (self.auto_now_add and add)
        ):
            # Retrieve the time override
            time_override = self._get_time_override()

            # Convert the value to the date/time with the appropriate timezone
            value = make_aware(
                value=datetime.combine(
                    date=value.date(),
                    time=time_override
                ),
                timezone=tz
            )

        return value