#!/usr/bin/env python3

#    Kosmorrolib - The Library To Compute Your Ephemerides
#    Copyright (C) 2021  Jérôme Deuchnord <jerome@deuchnord.fr>
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU Affero General Public License as
#    published by the Free Software Foundation, either version 3 of the
#    License, or (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU Affero General Public License for more details.
#
#    You should have received a copy of the GNU Affero General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.

from datetime import date, timedelta

from skyfield.errors import EphemerisRangeError
from skyfield.timelib import Time
from skyfield.searchlib import find_discrete, find_maxima, find_minima
from skyfield.units import Angle
from skyfield import almanac, eclipselib
from math import pi

from kosmorrolib.model import (
    Event,
    Object,
    Star,
    Planet,
    get_aster,
    ASTERS,
    EARTH,
)
from kosmorrolib.dateutil import translate_to_timezone
from kosmorrolib.enum import EventType, ObjectIdentifier, SeasonType, LunarEclipseType
from kosmorrolib.exceptions import OutOfRangeDateError
from kosmorrolib.core import get_timescale, get_skf_objects, flatten_list


def _search_conjunctions_occultations(
    start_time: Time, end_time: Time, timezone: int
) -> [Event]:
    """Function to search conjunction.

    **Warning:** this is an internal function, not intended for use by end-developers.

    Will return MOON and VENUS opposition on 2021-06-12:

    >>> conjunction = _search_conjunctions_occultations(get_timescale().utc(2021, 6, 12), get_timescale().utc(2021, 6, 13), 0)
    >>> len(conjunction)
    1
    >>> conjunction[0].objects
    [<Object type=SATELLITE name=MOON />, <Object type=PLANET name=VENUS />]

    Will return nothing if no conjunction happens:

    >>> _search_conjunctions_occultations(get_timescale().utc(2021, 6, 17),get_timescale().utc(2021, 6, 18), 0)
    []

    This function detects occultations too:

    >>> _search_conjunctions_occultations(get_timescale().utc(2021, 4, 17),get_timescale().utc(2021, 4, 18), 0)
    [<Event type=OCCULTATION objects=[<Object type=SATELLITE name=MOON />, <Object type=PLANET name=MARS />] start=2021-04-17 12:08:16.115650+00:00 end=None details=None />]
    """
    earth = get_skf_objects()["earth"]
    aster1 = None
    aster2 = None

    def is_in_conjunction(time: Time):
        earth_pos = earth.at(time)
        _, aster1_lon, _ = (
            earth_pos.observe(aster1.skyfield_object).apparent().ecliptic_latlon()
        )
        _, aster2_lon, _ = (
            earth_pos.observe(aster2.skyfield_object).apparent().ecliptic_latlon()
        )

        return ((aster1_lon.radians - aster2_lon.radians) / pi % 2.0).astype(
            "int8"
        ) == 0

    is_in_conjunction.rough_period = 60.0

    computed = []
    events = []

    for aster1 in ASTERS:
        # Ignore the Sun
        if isinstance(aster1, Star):
            continue

        for aster2 in ASTERS:
            if isinstance(aster2, Star) or aster2 == aster1 or aster2 in computed:
                continue

            times, is_conjs = find_discrete(start_time, end_time, is_in_conjunction)

            for i, time in enumerate(times):
                if is_conjs[i]:
                    aster1_pos = (aster1.skyfield_object - earth).at(time)
                    aster2_pos = (aster2.skyfield_object - earth).at(time)
                    distance = aster1_pos.separation_from(aster2_pos).degrees

                    if (
                        distance - aster2.get_apparent_radius(time).degrees
                        < aster1.get_apparent_radius(time).degrees
                    ):
                        occulting_aster = (
                            [aster1, aster2]
                            if aster1_pos.distance().km < aster2_pos.distance().km
                            else [aster2, aster1]
                        )

                        events.append(
                            Event(
                                EventType.OCCULTATION,
                                occulting_aster,
                                translate_to_timezone(time.utc_datetime(), timezone),
                            )
                        )
                    else:
                        events.append(
                            Event(
                                EventType.CONJUNCTION,
                                [aster1, aster2],
                                translate_to_timezone(time.utc_datetime(), timezone),
                            )
                        )

        computed.append(aster1)

    return events


def _search_oppositions(start_time: Time, end_time: Time, timezone: int) -> [Event]:
    """Function to search oppositions.

    **Warning:** this is an internal function, not intended for use by end-developers.

    Will return Mars opposition on 2020-10-13:

    >>> oppositions = _search_oppositions(get_timescale().utc(2020, 10, 13), get_timescale().utc(2020, 10, 14), 0)
    >>> len(oppositions)
    1
    >>> oppositions[0].objects[0]
    <Object type=PLANET name=MARS />

    Will return nothing if no opposition happens:

    >>> _search_oppositions(get_timescale().utc(2021, 3, 20), get_timescale().utc(2021, 3, 21), 0)
    []

    >>> _search_oppositions(get_timescale().utc(2022, 12, 24), get_timescale().utc(2022, 12, 25), 0)
    []
    """
    earth = get_skf_objects()["earth"]
    sun = get_skf_objects()["sun"]
    aster = None

    def is_oppositing(time: Time) -> [bool]:
        diff = get_angle(time)
        return diff > 180

    def get_angle(time: Time):
        earth_pos = earth.at(time)

        sun_pos = earth_pos.observe(
            sun
        ).apparent()  # Never do this without eyes protection!
        aster_pos = earth_pos.observe(aster.skyfield_object).apparent()

        _, lon1, _ = sun_pos.ecliptic_latlon()
        _, lon2, _ = aster_pos.ecliptic_latlon()

        return lon1.degrees - lon2.degrees

    is_oppositing.rough_period = 1.0
    events = []

    for aster in ASTERS:
        if not isinstance(aster, Planet) or aster.identifier in [
            ObjectIdentifier.MERCURY,
            ObjectIdentifier.VENUS,
        ]:
            continue

        times, _ = find_discrete(start_time, end_time, is_oppositing)
        for time in times:
            if int(get_angle(time)) != 180:
                # If the angle is negative, then it is actually a false positive.
                # Just ignoring it.
                continue

            events.append(
                Event(
                    EventType.OPPOSITION,
                    [aster],
                    translate_to_timezone(time.utc_datetime(), timezone),
                )
            )

    return events


def _search_maximal_elongations(
    start_time: Time, end_time: Time, timezone: int
) -> [Event]:
    """Function to search oppositions.

    **Warning:** this is an internal function, not intended for use by end-developers.

    Will return Mercury maimum elogation for September 14, 2021:

    >>> get_events(date(2021, 9, 14))
    [<Event type=MAXIMAL_ELONGATION objects=[<Object type=PLANET name=MERCURY />] start=2021-09-14 04:13:46.664879+00:00 end=None details={'deg': 26.8} />]
    """
    earth = get_skf_objects()["earth"]
    sun = get_skf_objects()["sun"]

    def get_elongation(planet: Object):
        def f(time: Time):
            sun_pos = (sun - earth).at(time)
            aster_pos = (planet.skyfield_object - earth).at(time)
            separation = sun_pos.separation_from(aster_pos)
            return separation.degrees

        f.rough_period = 1.0

        return f

    events = []

    for identifier in [ObjectIdentifier.MERCURY, ObjectIdentifier.VENUS]:
        planet = get_aster(identifier)
        times, elongations = find_maxima(
            start_time,
            end_time,
            f=get_elongation(planet),
            epsilon=1.0 / 24 / 3600,
            num=12,
        )

        for i, time in enumerate(times):
            elongation = round(elongations[i], 1)
            events.append(
                Event(
                    EventType.MAXIMAL_ELONGATION,
                    [planet],
                    translate_to_timezone(time.utc_datetime(), timezone),
                    details={"deg": float(elongation)},
                )
            )

    return events


def _get_distance(to_aster: Object, from_aster: Object):
    def get_distance(time: Time):
        from_pos = from_aster.skyfield_object.at(time)
        to_pos = from_pos.observe(to_aster.skyfield_object)

        return to_pos.distance().km

    get_distance.rough_period = 1.0

    return get_distance


def _search_apogee(to_aster: Object, from_aster: Object = EARTH) -> callable:
    """Search for moon apogee

    **Warning:** this is an internal function, not intended for use by end-developers.

    Get the moon apogee:
    >>> _search_apogee(ASTERS[1])(get_timescale().utc(2021, 6, 8), get_timescale().utc(2021, 6, 9), 0)
    [<Event type=APOGEE objects=[<Object type=SATELLITE name=MOON />] start=2021-06-08 02:39:40.165271+00:00 end=None details={'distance_km': 406211.04850197025} />]

    Get the Earth's apogee:
    >>> _search_apogee(EARTH, from_aster=ASTERS[0])(get_timescale().utc(2021, 7, 5), get_timescale().utc(2021, 7, 6), 0)
    [<Event type=APOGEE objects=[<Object type=PLANET name=EARTH />] start=2021-07-05 22:35:42.148792+00:00 end=None details={'distance_km': 152100521.91712126} />]
    """

    def f(start_time: Time, end_time: Time, timezone: int) -> [Event]:
        events = []

        times, distances = find_maxima(
            start_time,
            end_time,
            f=_get_distance(to_aster, from_aster),
            epsilon=1.0 / 24 / 60,
        )

        for i, time in enumerate(times):
            events.append(
                Event(
                    EventType.APOGEE,
                    [to_aster],
                    translate_to_timezone(time.utc_datetime(), timezone),
                    details={"distance_km": float(distances[i])},
                )
            )

        return events

    return f


def _search_perigee(aster: Object, from_aster: Object = EARTH) -> callable:
    """Search for moon perigee

    **Warning:** this is an internal function, not intended for use by end-developers.

    Get the moon perigee:
    >>> _search_perigee(ASTERS[1])(get_timescale().utc(2021, 5, 26), get_timescale().utc(2021, 5, 27), 0)
    [<Event type=PERIGEE objects=[<Object type=SATELLITE name=MOON />] start=2021-05-26 01:56:01.983455+00:00 end=None details={'distance_km': 357313.9680798693} />]

    Get the Earth's perigee:
    >>> _search_perigee(EARTH, from_aster=ASTERS[0])(get_timescale().utc(2021, 1, 2), get_timescale().utc(2021, 1, 3), 0)
    [<Event type=PERIGEE objects=[<Object type=PLANET name=EARTH />] start=2021-01-02 13:59:00.495905+00:00 end=None details={'distance_km': 147093166.1686309} />]
    """

    def f(start_time: Time, end_time: Time, timezone: int) -> [Event]:
        events = []

        times, distances = find_minima(
            start_time,
            end_time,
            f=_get_distance(aster, from_aster),
            epsilon=1.0 / 24 / 60,
        )

        for i, time in enumerate(times):
            events.append(
                Event(
                    EventType.PERIGEE,
                    [aster],
                    translate_to_timezone(time.utc_datetime(), timezone),
                    details={"distance_km": float(distances[i])},
                )
            )

        return events

    return f


def _search_earth_season_change(
    start_time: Time, end_time: Time, timezone: int
) -> [Event]:
    """Function to find earth season change event.

    **Warning:** this is an internal function, not intended for use by end-developers.

    Will return JUNE SOLSTICE on 2020/06/20:

    >>> season_change = _search_earth_season_change(get_timescale().utc(2020, 6, 20), get_timescale().utc(2020, 6, 21), 0)
    >>> len(season_change)
    1
    >>> season_change[0].event_type
    <EventType.SEASON_CHANGE: 7>
    >>> season_change[0].details
    {'season': <SeasonType.JUNE_SOLSTICE: 1>}

    Will return nothing if there is no season change event in the period of time being calculated:

    >>> _search_earth_season_change(get_timescale().utc(2021, 6, 17), get_timescale().utc(2021, 6, 18), 0)
    []
    """
    events = []
    event_time, event_id = almanac.find_discrete(
        start_time, end_time, almanac.seasons(get_skf_objects())
    )
    if len(event_time) == 0:
        return []
    events.append(
        Event(
            EventType.SEASON_CHANGE,
            [],
            translate_to_timezone(event_time.utc_datetime()[0], timezone),
            details={"season": SeasonType(event_id[0])},
        )
    )
    return events


def _search_lunar_eclipse(start_time: Time, end_time: Time, timezone: int) -> [Event]:
    """Function to detect lunar eclipses.

    **Warning:** this is an internal function, not intended for use by end-developers.

    >>> _search_lunar_eclipse(get_timescale().utc(2021, 5, 26), get_timescale().utc(2021, 5, 27), 0)
    [<Event type=LUNAR_ECLIPSE objects=[<Object type=SATELLITE name=MOON />] start=2021-05-26 08:49:13.314888+00:00 end=2021-05-26 13:48:15.757096+00:00 details={'type': <LunarEclipseType.TOTAL: 2>, 'maximum': datetime.datetime(2021, 5, 26, 11, 18, 42, 328842, tzinfo=datetime.timezone.utc)} />]

    >>> _search_lunar_eclipse(get_timescale().utc(2019, 7, 16), get_timescale().utc(2019, 7, 17), 0)
    [<Event type=LUNAR_ECLIPSE objects=[<Object type=SATELLITE name=MOON />] start=2019-07-16 18:41:24.400419+00:00 end=2019-07-17 00:20:20.079536+00:00 details={'type': <LunarEclipseType.PARTIAL: 1>, 'maximum': datetime.datetime(2019, 7, 16, 21, 30, 44, 170096, tzinfo=datetime.timezone.utc)} />]

    >>> _search_lunar_eclipse(get_timescale().utc(2017, 2, 11), get_timescale().utc(2017, 2, 12), 0)
    [<Event type=LUNAR_ECLIPSE objects=[<Object type=SATELLITE name=MOON />] start=2017-02-10 22:04:24.192412+00:00 end=2017-02-11 03:23:42.046415+00:00 details={'type': <LunarEclipseType.PENUMBRAL: 0>, 'maximum': datetime.datetime(2017, 2, 11, 0, 43, 51, 793786, tzinfo=datetime.timezone.utc)} />]
    """
    moon = get_aster(ObjectIdentifier.MOON)
    events = []
    t, y, details = eclipselib.lunar_eclipses(start_time, end_time, get_skf_objects())

    for ti, yi in zip(t, y):
        penumbra_radius = Angle(radians=details["penumbra_radius_radians"][0])
        _, max_lon, _ = (
            EARTH.skyfield_object.at(ti)
            .observe(moon.skyfield_object)
            .apparent()
            .ecliptic_latlon()
        )

        def is_in_penumbra(time: Time):
            _, lon, _ = (
                EARTH.skyfield_object.at(time)
                .observe(moon.skyfield_object)
                .apparent()
                .ecliptic_latlon()
            )

            moon_radius = details["moon_radius_radians"]

            return (
                abs(max_lon.radians - lon.radians)
                < penumbra_radius.radians + moon_radius
            )

        is_in_penumbra.rough_period = 60.0

        search_start_time = get_timescale().from_datetime(
            start_time.utc_datetime() - timedelta(days=1)
        )
        search_end_time = get_timescale().from_datetime(
            end_time.utc_datetime() + timedelta(days=1)
        )

        eclipse_start, _ = find_discrete(search_start_time, ti, is_in_penumbra)
        eclipse_end, _ = find_discrete(ti, search_end_time, is_in_penumbra)

        events.append(
            Event(
                EventType.LUNAR_ECLIPSE,
                [moon],
                start_time=translate_to_timezone(
                    eclipse_start[0].utc_datetime(), timezone
                ),
                end_time=translate_to_timezone(eclipse_end[0].utc_datetime(), timezone),
                details={
                    "type": LunarEclipseType(yi),
                    "maximum": translate_to_timezone(ti.utc_datetime(), timezone),
                },
            )
        )

    return events


def get_events(for_date: date = date.today(), timezone: int = 0) -> [Event]:
    """Calculate and return a list of events for the given date, adjusted to the given timezone if any.

    Find events that happen on April 4th, 2020 (show hours in UTC):

    >>> get_events(date(2020, 4, 4))
    [<Event type=CONJUNCTION objects=[<Object type=PLANET name=MERCURY />, <Object type=PLANET name=NEPTUNE />] start=2020-04-04 01:14:39.063308+00:00 end=None details=None />]

    Find events that happen on April 4th, 2020 (show timezones in UTC+2):

    >>> get_events(date(2020, 4, 4), 2)
    [<Event type=CONJUNCTION objects=[<Object type=PLANET name=MERCURY />, <Object type=PLANET name=NEPTUNE />] start=2020-04-04 03:14:39.063267+02:00 end=None details=None />]

    Find events that happen on April 3rd, 2020 (show timezones in UTC-2):

    >>> get_events(date(2020, 4, 3), -2)
    [<Event type=CONJUNCTION objects=[<Object type=PLANET name=MERCURY />, <Object type=PLANET name=NEPTUNE />] start=2020-04-03 23:14:39.063388-02:00 end=None details=None />]

    If there is no events for the given date, then an empty list is returned:

    >>> get_events(date(2021, 4, 20))
    []

    Note that the events can only be found for a date range.
    Asking for the events with an out of range date will result in an exception:

    >>> get_events(date(1000, 1, 1))
    Traceback (most recent call last):
        ...
    kosmorrolib.exceptions.OutOfRangeDateError: The date must be between 1899-07-28 and 2053-10-08

    :param for_date: the date for which the events must be calculated
    :param timezone: the timezone to adapt the results to. If not given, defaults to 0.
    :return: a list of events found for the given date.
    """

    start_time = get_timescale().utc(
        for_date.year, for_date.month, for_date.day, -timezone
    )
    end_time = get_timescale().utc(
        for_date.year, for_date.month, for_date.day + 1, -timezone
    )

    try:
        found_events = []

        for fun in [
            _search_oppositions,
            _search_conjunctions_occultations,
            _search_maximal_elongations,
            _search_apogee(ASTERS[1]),
            _search_perigee(ASTERS[1]),
            _search_apogee(EARTH, from_aster=ASTERS[0]),
            _search_perigee(EARTH, from_aster=ASTERS[0]),
            _search_earth_season_change,
            _search_lunar_eclipse,
        ]:
            found_events.append(fun(start_time, end_time, timezone))

        return sorted(flatten_list(found_events), key=lambda event: event.start_time)
    except EphemerisRangeError as error:
        start_date = translate_to_timezone(error.start_time.utc_datetime(), timezone)
        end_date = translate_to_timezone(error.end_time.utc_datetime(), timezone)

        start_date = date(start_date.year, start_date.month, start_date.day)
        end_date = date(end_date.year, end_date.month, end_date.day)

        raise OutOfRangeDateError(start_date, end_date) from error
