# -*- coding: utf-8 -*-
import warnings
from datetime import tzinfo

from . import _compat
from ._exceptions import (
    AmbiguousTimeError,
    NonExistentTimeError,
    PytzUsageWarning,
    UnknownTimeZoneError,
    get_exception,
)

IS_DST_SENTINEL = object()
KEY_SENTINEL = object()


def timezone(key, _cache={}):
    """Builds an IANA database time zone shim.

    This is the equivalent of ``pytz.timezone``.

    :param key:
        A valid key from the IANA time zone database.

    :raises UnknownTimeZoneError:
        If an unknown value is passed, this will raise an exception that can be
        caught by :exc:`pytz_deprecation_shim.UnknownTimeZoneError` or
        ``pytz.UnknownTimeZoneError``. Like
        :exc:`zoneinfo.ZoneInfoNotFoundError`, both of those are subclasses of
        :exc:`KeyError`.
    """
    instance = _cache.get(key, None)
    if instance is None:
        if len(key) == 3 and key.lower() == "utc":
            instance = _cache.setdefault(key, UTC)
        else:
            try:
                zone = _compat.get_timezone(key)
            except KeyError:
                raise get_exception(UnknownTimeZoneError, key)
            instance = _cache.setdefault(key, wrap_zone(zone, key=key))

    return instance


def fixed_offset_timezone(offset, _cache={}):
    """Builds a fixed offset time zone shim.

    This is the equivalent of ``pytz.FixedOffset``. An alias is available as
    ``pytz_deprecation_shim.FixedOffset`` as well.

    :param offset:
        A fixed offset from UTC, in minutes. This must be in the range ``-1439
        <= offset <= 1439``.

    :raises ValueError:
        For offsets whose absolute value is greater than or equal to 24 hours.

    :return:
        A shim time zone.
    """
    if not (-1440 < offset < 1440):
        raise ValueError("absolute offset is too large", offset)

    instance = _cache.get(offset, None)
    if instance is None:
        if offset == 0:
            instance = _cache.setdefault(offset, UTC)
        else:
            zone = _compat.get_fixed_offset_zone(offset)
            instance = _cache.setdefault(offset, wrap_zone(zone, key=None))

    return instance


def build_tzinfo(zone, fp):
    """Builds a shim object from a TZif file.

    This is a shim for ``pytz.build_tzinfo``. Given a value to use as the zone
    IANA key and a file-like object containing a valid TZif file (i.e.
    conforming to :rfc:`8536`), this builds a time zone object and wraps it in
    a shim class.

    The argument names are chosen to match those in ``pytz.build_tzinfo``.

    :param zone:
        A string to be used as the time zone object's IANA key.

    :param fp:
        A readable file-like object emitting bytes, pointing to a valid TZif
        file.

    :return:
        A shim time zone.
    """
    zone_file = _compat.get_timezone_file(fp)

    return wrap_zone(zone_file, key=zone)


def wrap_zone(tz, key=KEY_SENTINEL, _cache={}):
    """Wrap an existing time zone object in a shim class.

    This is likely to be useful if you would like to work internally with
    non-``pytz`` zones, but you expose an interface to callers relying on
    ``pytz``'s interface. It may also be useful for passing non-``pytz`` zones
    to libraries expecting to use ``pytz``'s interface.

    :param tz:
        A :pep:`495`-compatible time zone, such as those provided by
        :mod:`dateutil.tz` or :mod:`zoneinfo`.

    :param key:
        The value for the IANA time zone key. This is optional for ``zoneinfo``
        zones, but required for ``dateutil.tz`` zones.

    :return:
        A shim time zone.
    """
    if key is KEY_SENTINEL:
        key = getattr(tz, "key", KEY_SENTINEL)

    if key is KEY_SENTINEL:
        raise TypeError(
            "The `key` argument is required when wrapping zones that do not "
            + "have a `key` attribute."
        )

    instance = _cache.get((id(tz), key), None)
    if instance is None:
        instance = _cache.setdefault((id(tz), key), _PytzShimTimezone(tz, key))

    return instance


class _PytzShimTimezone(tzinfo):
    # Add instance variables for _zone and _key because this will make error
    # reporting with partially-initialized _BasePytzShimTimezone objects
    # work better.
    _zone = None
    _key = None

    def __init__(self, zone, key):
        self._key = key
        self._zone = zone

    def utcoffset(self, dt):
        return self._zone.utcoffset(dt)

    def dst(self, dt):
        return self._zone.dst(dt)

    def tzname(self, dt):
        return self._zone.tzname(dt)

    def fromutc(self, dt):
        # The default fromutc implementation only works if tzinfo is "self"
        dt_base = dt.replace(tzinfo=self._zone)
        dt_out = self._zone.fromutc(dt_base)

        return dt_out.replace(tzinfo=self)

    def __str__(self):
        if self._key is not None:
            return str(self._key)
        else:
            return repr(self)

    def __repr__(self):
        return "%s(%s, %s)" % (
            self.__class__.__name__,
            repr(self._zone),
            repr(self._key),
        )

    def unwrap_shim(self):
        """Returns the underlying class that the shim is a wrapper for.

        This is a shim-specific method equivalent to
        :func:`pytz_deprecation_shim.helpers.upgrade_tzinfo`. It is provided as
        a method to allow end-users to upgrade shim timezones without requiring
        an explicit dependency on ``pytz_deprecation_shim``, e.g.:

        .. code-block:: python

            if getattr(tz, "unwrap_shim", None) is None:
                tz = tz.unwrap_shim()
        """
        return self._zone

    @property
    def zone(self):
        warnings.warn(
            "The zone attribute is specific to pytz's interface; "
            + "please migrate to a new time zone provider. "
            + "For more details on how to do so, see %s"
            % PYTZ_MIGRATION_GUIDE_URL,
            PytzUsageWarning,
            stacklevel=2,
        )

        return self._key

    def localize(self, dt, is_dst=IS_DST_SENTINEL):
        warnings.warn(
            "The localize method is no longer necessary, as this "
            + "time zone supports the fold attribute (PEP 495). "
            + "For more details on migrating to a PEP 495-compliant "
            + "implementation, see %s" % PYTZ_MIGRATION_GUIDE_URL,
            PytzUsageWarning,
            stacklevel=2,
        )

        if dt.tzinfo is not None:
            raise ValueError("Not naive datetime (tzinfo is already set)")

        dt_out = dt.replace(tzinfo=self)

        if is_dst is IS_DST_SENTINEL:
            return dt_out

        dt_ambiguous = _compat.is_ambiguous(dt_out)
        dt_imaginary = (
            _compat.is_imaginary(dt_out) if not dt_ambiguous else False
        )

        if is_dst is None:
            if dt_imaginary:
                raise get_exception(
                    NonExistentTimeError, dt.replace(tzinfo=None)
                )

            if dt_ambiguous:
                raise get_exception(AmbiguousTimeError, dt.replace(tzinfo=None))

        elif dt_ambiguous or dt_imaginary:
            # Start by normalizing the folds; dt_out may have fold=0 or fold=1,
            # but we need to know the DST offset on both sides anyway, so we
            # will get one datetime representing each side of the fold, then
            # decide which one we're going to return.
            if _compat.get_fold(dt_out):
                dt_enfolded = dt_out
                dt_out = _compat.enfold(dt_out, fold=0)
            else:
                dt_enfolded = _compat.enfold(dt_out, fold=1)

            # Now we want to decide whether the fold=0 or fold=1 represents
            # what pytz would return for `is_dst=True`
            enfolded_dst = bool(dt_enfolded.dst())
            if bool(dt_out.dst()) == enfolded_dst:
                # If this is not a transition between standard time and
                # daylight saving time, pytz will consider the larger offset
                # the DST offset.
                enfolded_dst = dt_enfolded.utcoffset() > dt_out.utcoffset()

            # The default we've established is that dt_out is fold=0; swap it
            # for the fold=1 datetime if is_dst == True and the enfolded side
            # is DST or if is_dst == False and the enfolded side is *not* DST.
            if is_dst == enfolded_dst:
                dt_out = dt_enfolded

        return dt_out

    def normalize(self, dt):
        warnings.warn(
            "The normalize method is no longer necessary, as this "
            + "time zone supports the fold attribute (PEP 495). "
            + "For more details on migrating to a PEP 495-compliant "
            + "implementation, see %s" % PYTZ_MIGRATION_GUIDE_URL,
            PytzUsageWarning,
            stacklevel=2,
        )

        if dt.tzinfo is None:
            raise ValueError("Naive time - no tzinfo set")

        if dt.tzinfo is self:
            return dt

        return dt.astimezone(self)

    def __copy__(self):
        return self

    def __deepcopy__(self, memo=None):
        return self

    def __reduce__(self):
        return wrap_zone, (self._zone, self._key)


UTC = wrap_zone(_compat.UTC, "UTC")
PYTZ_MIGRATION_GUIDE_URL = (
    "https://pytz-deprecation-shim.readthedocs.io/en/latest/migration.html"
)