import dataclasses
import datetime

try:
    import zoneinfo
except ImportError:
    from backports import zoneinfo

from typing import Any, Dict, Optional, Tuple, Union

import astral.moon
import astral.sun
from astral import (
    Depression,
    Elevation,
    LocationInfo,
    Observer,
    SunDirection,
    dms_to_float,
    today,
)


class Location:
    """Provides access to information for single location."""

    def __init__(self, info: Optional[LocationInfo] = None):
        """Initializes the Location with a LocationInfo object.

        The tuple should contain items in the following order

        ================ =============
        Field            Default
        ================ =============
        name             Greenwich
        region           England
        time zone name   Europe/London
        latitude         51.4733
        longitude        -0.0008333
        ================ =============

        See the :attr:`timezone` property for a method of obtaining time zone
        names
        """

        self._location_info: LocationInfo
        self._solar_depression: float = Depression.CIVIL.value

        if not info:
            self._location_info = LocationInfo(
                "Greenwich", "England", "Europe/London", 51.4733, -0.0008333
            )
        else:
            self._location_info = info

    def __eq__(self, other: object) -> bool:
        if type(other) is Location:
            return self._location_info == other._location_info  # type: ignore
        return NotImplemented

    def __repr__(self) -> str:
        if self.region:
            _repr = "%s/%s" % (self.name, self.region)
        else:
            _repr = self.name
        return (
            f"{_repr}, tz={self.timezone}, "
            f"lat={self.latitude:0.02f}, "
            f"lon={self.longitude:0.02f}"
        )

    @property
    def info(self) -> LocationInfo:
        return LocationInfo(
            self.name,
            self.region,
            self.timezone,
            self.latitude,
            self.longitude,
        )

    @property
    def observer(self) -> Observer:
        return Observer(self.latitude, self.longitude, 0.0)

    @property
    def name(self) -> str:
        return self._location_info.name

    @name.setter
    def name(self, name: str) -> None:
        self._location_info = dataclasses.replace(self._location_info, name=name)

    @property
    def region(self) -> str:
        return self._location_info.region

    @region.setter
    def region(self, region: str) -> None:
        self._location_info = dataclasses.replace(self._location_info, region=region)

    @property
    def latitude(self) -> float:
        """The location's latitude

        ``latitude`` can be set either as a string or as a number

        For strings they must be of the form

            degrees°minutes'[N|S] e.g. 51°31'N

        For numbers, positive numbers signify latitudes to the North.
        """

        return self._location_info.latitude

    @latitude.setter
    def latitude(self, latitude: Union[float, str]) -> None:
        self._location_info = dataclasses.replace(
            self._location_info, latitude=dms_to_float(latitude, 90.0)
        )

    @property
    def longitude(self) -> float:
        """The location's longitude.

        ``longitude`` can be set either as a string or as a number

        For strings they must be of the form

            degrees°minutes'[E|W] e.g. 51°31'W

        For numbers, positive numbers signify longitudes to the East.
        """

        return self._location_info.longitude

    @longitude.setter
    def longitude(self, longitude: Union[float, str]) -> None:
        self._location_info = dataclasses.replace(
            self._location_info, longitude=dms_to_float(longitude, 180.0)
        )

    @property
    def timezone(self) -> str:
        """The name of the time zone for the location.

        A list of time zone names can be obtained from the zoneinfo module.
        For example.

        >>> import zoneinfo
        >>> assert "CET" in zoneinfo.available_timezones()
        """

        return self._location_info.timezone

    @timezone.setter
    def timezone(self, name: str) -> None:
        if name not in zoneinfo.available_timezones():  # type: ignore
            raise ValueError("Timezone '%s' not recognized" % name)

        self._location_info = dataclasses.replace(self._location_info, timezone=name)

    @property
    def tzinfo(self) -> zoneinfo.ZoneInfo:  # type: ignore
        """Time zone information."""

        try:
            tz = zoneinfo.ZoneInfo(self._location_info.timezone)  # type: ignore
            return tz  # type: ignore
        except zoneinfo.ZoneInfoNotFoundError as exc:  # type: ignore
            raise ValueError(
                "Unknown timezone '%s'" % self._location_info.timezone
            ) from exc

    tz = tzinfo

    @property
    def solar_depression(self) -> float:
        """The number of degrees the sun must be below the horizon for the
        dawn/dusk calculation.

        Can either be set as a number of degrees below the horizon or as
        one of the following strings

        ============= =======
        String        Degrees
        ============= =======
        civil            6.0
        nautical        12.0
        astronomical    18.0
        ============= =======
        """

        return self._solar_depression

    @solar_depression.setter
    def solar_depression(self, depression: Union[float, str, Depression]) -> None:
        if isinstance(depression, str):
            try:
                self._solar_depression = {
                    "civil": 6.0,
                    "nautical": 12.0,
                    "astronomical": 18.0,
                }[depression]
            except KeyError:
                raise KeyError(
                    (
                        "solar_depression must be either a number "
                        "or one of 'civil', 'nautical' or "
                        "'astronomical'"
                    )
                )
        elif isinstance(depression, Depression):
            self._solar_depression = depression.value
        else:
            self._solar_depression = float(depression)

    def today(self, local: bool = True) -> datetime.date:
        if local:
            return today(self.tzinfo)
        else:
            return today()

    def sun(
        self,
        date: Optional[datetime.date] = None,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ) -> Dict[str, Any]:
        """Returns dawn, sunrise, noon, sunset and dusk as a dictionary.

        :param date: The date for which to calculate the times.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :returns: Dictionary with keys ``dawn``, ``sunrise``, ``noon``,
            ``sunset`` and ``dusk`` whose values are the results of the
            corresponding methods.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.sun(observer, date, self.solar_depression, self.tzinfo)
        else:
            return astral.sun.sun(observer, date, self.solar_depression)

    def dawn(
        self,
        date: Optional[datetime.date] = None,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ) -> datetime.datetime:
        """Calculates the time in the morning when the sun is a certain number
        of degrees below the horizon. By default this is 6 degrees but can be
        changed by setting the :attr:`Astral.solar_depression` property.

        :param date: The date for which to calculate the dawn time.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :returns: The date and time at which dawn occurs.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.dawn(observer, date, self.solar_depression, self.tzinfo)
        else:
            return astral.sun.dawn(observer, date, self.solar_depression)

    def sunrise(
        self,
        date: Optional[datetime.date] = None,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ) -> datetime.datetime:
        """Return sunrise time.

        Calculates the time in the morning when the sun is a 0.833 degrees
        below the horizon. This is to account for refraction.

        :param date: The date for which to calculate the sunrise time.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :returns: The date and time at which sunrise occurs.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.sunrise(observer, date, self.tzinfo)
        else:
            return astral.sun.sunrise(observer, date)

    def noon(
        self, date: Optional[datetime.date] = None, local: bool = True
    ) -> datetime.datetime:
        """Calculates the solar noon (the time when the sun is at its highest
        point.)

        :param date: The date for which to calculate the noon time.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :returns: The date and time at which the solar noon occurs.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude)
        if local:
            return astral.sun.noon(observer, date, self.tzinfo)
        else:
            return astral.sun.noon(observer, date)

    def sunset(
        self,
        date: Optional[datetime.date] = None,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ) -> datetime.datetime:
        """Calculates sunset time (the time in the evening when the sun is a
        0.833 degrees below the horizon. This is to account for refraction.)

        :param date: The date for which to calculate the sunset time.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :returns: The date and time at which sunset occurs.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.sunset(observer, date, self.tzinfo)
        else:
            return astral.sun.sunset(observer, date)

    def dusk(
        self,
        date: Optional[datetime.date] = None,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ) -> datetime.datetime:
        """Calculates the dusk time (the time in the evening when the sun is a
        certain number of degrees below the horizon. By default this is 6
        degrees but can be changed by setting the
        :attr:`solar_depression` property.)

        :param date: The date for which to calculate the dusk time.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :returns: The date and time at which dusk occurs.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.dusk(observer, date, self.solar_depression, self.tzinfo)
        else:
            return astral.sun.dusk(observer, date, self.solar_depression)

    def midnight(
        self, date: Optional[datetime.date] = None, local: bool = True
    ) -> datetime.datetime:
        """Calculates the solar midnight (the time when the sun is at its lowest
        point.)

        :param date: The date for which to calculate the midnight time.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :returns: The date and time at which the solar midnight occurs.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude)

        if local:
            return astral.sun.midnight(observer, date, self.tzinfo)
        else:
            return astral.sun.midnight(observer, date)

    def daylight(
        self,
        date: Optional[datetime.date] = None,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ) -> Tuple[datetime.datetime, datetime.datetime]:
        """Calculates the daylight time (the time between sunrise and sunset)

        :param date: The date for which to calculate daylight.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :returns: A tuple containing the start and end times
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.daylight(observer, date, self.tzinfo)
        else:
            return astral.sun.daylight(observer, date)

    def night(
        self,
        date: Optional[datetime.date] = None,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ) -> Tuple[datetime.datetime, datetime.datetime]:
        """Calculates the night time (the time between astronomical dusk and
        astronomical dawn of the next day)

        :param date: The date for which to calculate the start of the night time.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :returns: A tuple containing the start and end times
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.night(observer, date, self.tzinfo)
        else:
            return astral.sun.night(observer, date)

    def twilight(
        self,
        date: Optional[datetime.date] = None,
        direction: SunDirection = SunDirection.RISING,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ):
        """Returns the start and end times of Twilight in the UTC timezone when
        the sun is traversing in the specified direction.

        This method defines twilight as being between the time
        when the sun is at -6 degrees and sunrise/sunset.

        :param direction:  Determines whether the time is for the sun rising or setting.
                           Use ``astral.SUN_RISING`` or ``astral.SunDirection.SETTING``.

        :param date: The date for which to calculate the times.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :return: A tuple of the UTC date and time at which twilight starts and ends.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.twilight(observer, date, direction, self.tzinfo)
        else:
            return astral.sun.twilight(observer, date, direction)

    def moonrise(
        self,
        date: Optional[datetime.date] = None,
        local: bool = True,
    ) -> Optional[datetime.datetime]:
        """Calculates the time when the moon rises.

        :param date: The date for which to calculate the moonrise time.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :returns: The date and time at which moonrise occurs.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, 0)

        if local:
            return astral.moon.moonrise(observer, date, self.tzinfo)
        else:
            return astral.moon.moonrise(observer, date)

    def moonset(
        self,
        date: Optional[datetime.date] = None,
        local: bool = True,
    ) -> Optional[datetime.datetime]:
        """Calculates the time when the moon sets.

        :param date: The date for which to calculate the moonset time.
                     If no date is specified then the current date will be used.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :returns: The date and time at which moonset occurs.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, 0)

        if local:
            return astral.moon.moonset(observer, date, self.tzinfo)
        else:
            return astral.moon.moonset(observer, date)

    def time_at_elevation(
        self,
        elevation: float,
        date: Optional[datetime.date] = None,
        direction: SunDirection = SunDirection.RISING,
        local: bool = True,
    ) -> datetime.datetime:
        """Calculate the time when the sun is at the specified elevation.

        Note:
            This method uses positive elevations for those above the horizon.

            Elevations greater than 90 degrees are converted to a setting sun
            i.e. an elevation of 110 will calculate a setting sun at 70 degrees.

        :param elevation:  Elevation in degrees above the horizon to calculate for.

        :param date: The date for which to calculate the elevation time.
                     If no date is specified then the current date will be used.

        :param direction:  Determines whether the time is for the sun rising or setting.
                           Use ``SunDirection.RISING`` or ``SunDirection.SETTING``.
                           Default is rising.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.
                      If not specified then the time will be returned in local time

        :returns: The date and time at which dusk occurs.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        if elevation > 90.0:
            elevation = 180.0 - elevation
            direction = SunDirection.SETTING

        observer = Observer(self.latitude, self.longitude, 0.0)

        if local:
            return astral.sun.time_at_elevation(
                observer, elevation, date, direction, self.tzinfo
            )
        else:
            return astral.sun.time_at_elevation(observer, elevation, date, direction)

    def rahukaalam(
        self,
        date: Optional[datetime.date] = None,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ) -> Tuple[datetime.datetime, datetime.datetime]:
        """Calculates the period of rahukaalam.

        :param date: The date for which to calculate the rahukaalam period.
                     A value of ``None`` uses the current date.

        :param local: True  = Time to be returned in location's time zone;
                      False = Time to be returned in UTC.

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :return: Tuple containing the start and end times for Rahukaalam.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.rahukaalam(observer, date, tzinfo=self.tzinfo)
        else:
            return astral.sun.rahukaalam(observer, date)

    def golden_hour(
        self,
        direction: SunDirection = SunDirection.RISING,
        date: Optional[datetime.date] = None,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ) -> Tuple[datetime.datetime, datetime.datetime]:
        """Returns the start and end times of the Golden Hour when the sun is traversing
        in the specified direction.

        This method uses the definition from PhotoPills i.e. the
        golden hour is when the sun is between 4 degrees below the horizon
        and 6 degrees above.

        :param direction:  Determines whether the time is for the sun rising or setting.
                           Use ``SunDirection.RISING`` or ``SunDirection.SETTING``.
                           Default is rising.

        :param date: The date for which to calculate the times.

        :param local: True  = Times to be returned in location's time zone;
                      False = Times to be returned in UTC.
                      If not specified then the time will be returned in local time

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :return: A tuple of the date and time at which the Golden Hour starts and ends.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.golden_hour(observer, date, direction, self.tzinfo)
        else:
            return astral.sun.golden_hour(observer, date, direction)

    def blue_hour(
        self,
        direction: SunDirection = SunDirection.RISING,
        date: Optional[datetime.date] = None,
        local: bool = True,
        observer_elevation: Elevation = 0.0,
    ) -> Tuple[datetime.datetime, datetime.datetime]:
        """Returns the start and end times of the Blue Hour when the sun is traversing
        in the specified direction.

        This method uses the definition from PhotoPills i.e. the
        blue hour is when the sun is between 6 and 4 degrees below the horizon.

        :param direction:  Determines whether the time is for the sun rising or setting.
                           Use ``SunDirection.RISING`` or ``SunDirection.SETTING``.
                           Default is rising.

        :param date: The date for which to calculate the times.
                     If no date is specified then the current date will be used.

        :param local: True  = Times to be returned in location's time zone;
                      False = Times to be returned in UTC.
                      If not specified then the time will be returned in local time

        :param observer_elevation: Elevation of the observer in metres above
                                   the location.

        :return: A tuple of the date and time at which the Blue Hour starts and ends.
        """

        if local and self.timezone is None:
            raise ValueError("Local time requested but Location has no timezone set.")

        if date is None:
            date = self.today(local)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        if local:
            return astral.sun.blue_hour(observer, date, direction, self.tzinfo)
        else:
            return astral.sun.blue_hour(observer, date, direction)

    def solar_azimuth(
        self,
        dateandtime: Optional[datetime.datetime] = None,
        observer_elevation: Elevation = 0.0,
    ) -> float:
        """Calculates the solar azimuth angle for a specific date/time.

        :param dateandtime: The date and time for which to calculate the angle.
        :returns: The azimuth angle in degrees clockwise from North.
        """

        if dateandtime is None:
            dateandtime = astral.sun.now(self.tzinfo)
        elif not dateandtime.tzinfo:
            dateandtime = dateandtime.replace(tzinfo=self.tzinfo)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        dateandtime = dateandtime.astimezone(datetime.timezone.utc)  # type: ignore
        return astral.sun.azimuth(observer, dateandtime)

    def solar_elevation(
        self,
        dateandtime: Optional[datetime.datetime] = None,
        observer_elevation: Elevation = 0.0,
    ) -> float:
        """Calculates the solar elevation angle for a specific time.

        :param dateandtime: The date and time for which to calculate the angle.

        :returns: The elevation angle in degrees above the horizon.
        """

        if dateandtime is None:
            dateandtime = astral.sun.now(self.tzinfo)
        elif not dateandtime.tzinfo:
            dateandtime = dateandtime.replace(tzinfo=self.tzinfo)

        observer = Observer(self.latitude, self.longitude, observer_elevation)

        dateandtime = dateandtime.astimezone(datetime.timezone.utc)  # type: ignore
        return astral.sun.elevation(observer, dateandtime)

    def solar_zenith(
        self,
        dateandtime: Optional[datetime.datetime] = None,
        observer_elevation: Elevation = 0.0,
    ) -> float:
        """Calculates the solar zenith angle for a specific time.

        :param dateandtime: The date and time for which to calculate the angle.
        :returns: The zenith angle in degrees from vertical.
        """

        return 90.0 - self.solar_elevation(dateandtime, observer_elevation)

    def moon_phase(self, date: Optional[datetime.date] = None, local: bool = True):
        """Calculates the moon phase for a specific date.

        :param date:    The date to calculate the phase for. If ommitted the
                        current date is used.

        :returns:
            A number designating the phase

            ============  ==============
            0 .. 6.99     New moon
            7 .. 13.99    First quarter
            14 .. 20.99   Full moon
            21 .. 27.99   Last quarter
            ============  ==============
        """

        if date is None:
            date = self.today(local)

        return astral.moon.phase(date)
