"""Module responsible to parse Exif information from a image"""
import math
import datetime
from enum import Enum
from typing import Optional
# third party
import exifread
import piexif

MPH_TO_KMH_FACTOR = 1.60934
"""miles per hour to kilometers per hour conversion factor"""
KNOTS_TO_KMH_FACTOR = 1.852
"""knots to kilometers per hour conversion factor"""


class ExifTags(Enum):
    """This is a enumeration of exif tags. More info here
    http://owl.phy.queensu.ca/~phil/exiftool/TagNames/GPS.html """
    DATE_TIME_ORIGINAL = "EXIF DateTimeOriginal"
    DATE_Time_DIGITIZED = "EXIF DateTimeDigitized"
    # latitude
    GPS_LATITUDE = "GPS GPSLatitude"
    GPS_LATITUDE_REF = "GPS GPSLatitudeRef"
    # longitude
    GPS_LONGITUDE = "GPS GPSLongitude"
    GPS_LONGITUDE_REF = "GPS GPSLongitudeRef"
    # altitude
    GPS_ALTITUDE_REF = "GPS GPSAltitudeRef"
    GPS_ALTITUDE = "GPS GPSAltitude"
    # timestamp
    GPS_TIMESTAMP = "GPS GPSTimeStamp"
    GPS_DATE_STAMP = "GPS GPSDateStamp"
    GPS_DATE = "GPS GPSDate"
    # speed
    GPS_SPEED_REF = "GPS GPSSpeedRef"
    GPS_SPEED = "GPS GPSSpeed"
    # direction
    GPS_DIRECTION_REF = "GPS GPSImgDirectionRef"
    GPS_DIRECTION = "GPS GPSImgDirection"


class CardinalDirection(Enum):
    """Exif Enum with all cardinal directions"""
    N = "N"
    S = "S"
    E = "E"
    W = "W"
    TrueNorth = "T"
    MagneticNorth = "M"


class SeaLevel(Enum):
    """Exif Enum
    If the reference is sea level and the
    altitude is above sea level, 0 is given.
    If the altitude is below sea level, a value of 1 is given and
    the altitude is indicated as an absolute value in the GPSAltitude tag.
    The reference unit is meters. Note that this tag is BYTE type,
     unlike other reference tags."""
    ABOVE = 0
    BELOW = 1


class SpeedUnit(Enum):
    """Exif speed unit enum"""
    KMH = "K"
    MPH = "M"
    KNOTS = "N"

    @classmethod
    def convert_mph_to_kmh(cls, mph) -> float:
        """This method converts from miles per hour to kilometers per hour"""
        return mph * MPH_TO_KMH_FACTOR

    @classmethod
    def convert_knots_to_kmh(cls, knots) -> float:
        """This method converts from knots to kilometers per hour"""
        return knots * KNOTS_TO_KMH_FACTOR


def all_tags(path) -> {str: str}:
    """Method to return Exif tags"""
    file = open(path, "rb")
    tags = exifread.process_file(file, details=False)
    return tags


def __dms_to_dd(dms_value) -> float:
    """DMS is Degrees Minutes Seconds, DD is Decimal Degrees.
     A typical format would be dd/1,mm/1,ss/1.
     When degrees and minutes are used and, for example,
     fractions of minutes are given up to two decimal places,
     the format would be dd/1,mmmm/100,0/1 """
    # degrees
    degrees_nominator = dms_value.values[0].num
    degrees_denominator = dms_value.values[0].den
    degrees = float(degrees_nominator) / float(degrees_denominator)
    # minutes
    minutes_nominator = dms_value.values[1].num
    minutes_denominator = dms_value.values[1].den
    minutes = float(minutes_nominator) / float(minutes_denominator)
    # seconds
    seconds_nominator = dms_value.values[2].num
    seconds_denominator = dms_value.values[2].den
    seconds = float(seconds_nominator) / float(seconds_denominator)
    # decimal degrees
    return degrees + (minutes / 60.0) + (seconds / 3600.0)


def gps_latitude(gps_data: {str: str}) -> Optional[float]:
    """Exif latitude from gps_data represented by gps tags found in image exif"""
    if ExifTags.GPS_LATITUDE.value in gps_data:
        # latitude exists
        dms_values = gps_data[ExifTags.GPS_LATITUDE.value]
        _latitude = __dms_to_dd(dms_values)
        if ExifTags.GPS_LATITUDE_REF.value in gps_data and \
                (str(gps_data[ExifTags.GPS_LATITUDE_REF.value]) == str(CardinalDirection.S.value)):
            # cardinal direction is S so the latitude should be negative
            _latitude = -1 * _latitude

        return _latitude
    # no latitude info found
    return None


def gps_longitude(gps_data: {str: str}) -> Optional[float]:
    """Exif longitude from gps_data represented by gps tags found in image exif"""
    if ExifTags.GPS_LONGITUDE.value in gps_data:
        # longitude exists
        dms_values = gps_data[ExifTags.GPS_LONGITUDE.value]
        _longitude = __dms_to_dd(dms_values)
        if ExifTags.GPS_LONGITUDE_REF.value in gps_data and \
                str(gps_data[ExifTags.GPS_LONGITUDE_REF.value]) == str(CardinalDirection.W.value):
            # cardinal direction is W so the longitude should be negative
            _longitude = -1 * _longitude

        return _longitude
    # no longitude info found
    return None


def gps_compass(gps_data: {str: str}) -> Optional[float]:
    """Exif compass from gps_data represented by gps tags found in image exif.
    reference relative to true north"""
    if ExifTags.GPS_DIRECTION.value in gps_data:
        # compass exists
        compass_ratio = gps_data[ExifTags.GPS_DIRECTION.value].values[0]
        if ExifTags.GPS_DIRECTION_REF.value in gps_data and \
                gps_data[ExifTags.GPS_DIRECTION_REF.value] == CardinalDirection.MagneticNorth:
            # if we find magnetic north then we don't consider a valid compass
            return None
        return compass_ratio.num / compass_ratio.den
    # no compass found
    return None


def gps_timestamp(gps_data: {str: str}) -> Optional[float]:
    """Exif gps time from gps_data represented by gps tags found in image exif.
    In exif there are values giving the hour, minute, and second.
    This is UTC time"""
    if ExifTags.GPS_TIMESTAMP.value in gps_data:
        # timestamp exists
        _timestamp = gps_data[ExifTags.GPS_TIMESTAMP.value]
        hours: exifread.Ratio = _timestamp.values[0]
        minutes: exifread.Ratio = _timestamp.values[1]
        seconds: exifread.Ratio = _timestamp.values[2]

        day_timestamp = hours.num / hours.den * 3600 + \
                        minutes.num / minutes.den * 60 + \
                        seconds.num / seconds.den

        if ExifTags.GPS_DATE_STAMP.value in gps_data:
            # this tag is the one present in the exif documentation
            # but from experience ExifTags.GPS_DATE is replacing this tag
            gps_date = gps_data[ExifTags.GPS_DATE_STAMP.value].values
            date_timestamp = datetime.datetime.strptime(gps_date, "%Y:%m:%d").timestamp()

            return day_timestamp + date_timestamp

        if ExifTags.GPS_DATE.value in gps_data:
            # this tag is a replacement for ExifTags.GPS_DATE_STAMP
            gps_date = gps_data[ExifTags.GPS_DATE.value].values
            date_timestamp = datetime.datetime.strptime(gps_date, "%Y:%m:%d").timestamp()

            return day_timestamp + date_timestamp

        # no date information only hour minutes second of day -> no valid gps timestamp
        return None
    # no gps timestamp found
    return None


def timestamp(tags: {str: str}) -> Optional[float]:
    """Original timestamp determined by the digital still camera. This is timezone corrected."""
    if ExifTags.DATE_TIME_ORIGINAL.value in tags:
        date_taken = tags[ExifTags.DATE_TIME_ORIGINAL.value].values
        _timestamp = datetime.datetime.strptime(date_taken, "%Y:%m:%d %H:%M:%S").timestamp()
        return _timestamp
    if ExifTags.DATE_Time_DIGITIZED.value in tags:
        date_taken = tags[ExifTags.DATE_Time_DIGITIZED.value].values
        _timestamp = datetime.datetime.strptime(date_taken, "%Y:%m:%d %H:%M:%S").timestamp()
        return _timestamp
    # no timestamp information found
    return None


def gps_altitude(gps_tags: {str: str}) -> Optional[float]:
    """GPS altitude form exif """
    if ExifTags.GPS_ALTITUDE.value in gps_tags:
        # altitude exists
        altitude_ratio = gps_tags[ExifTags.GPS_ALTITUDE.value].values[0]
        altitude = altitude_ratio.num / altitude_ratio.den
        if ExifTags.GPS_ALTITUDE_REF.value in gps_tags and \
                gps_tags[ExifTags.GPS_ALTITUDE_REF.value] == SeaLevel.BELOW.value:
            altitude = -1 * altitude
        return altitude
    return None


def gps_speed(gps_tags: {str: str}) -> Optional[float]:
    """Returns GPS speed from exif in km per hour or None if no gps speed tag found"""
    if ExifTags.GPS_SPEED.value in gps_tags:
        # gps speed exist
        speed_ratio = gps_tags[ExifTags.GPS_SPEED.value].values[0]
        speed = speed_ratio.num / speed_ratio.den
        if ExifTags.GPS_SPEED_REF.value in gps_tags:
            if gps_tags[ExifTags.GPS_SPEED_REF.value] == SpeedUnit.MPH.value:
                speed = SpeedUnit.convert_mph_to_kmh(speed)
            if gps_tags[ExifTags.GPS_SPEED_REF.value] == SpeedUnit.KNOTS.value:
                speed = SpeedUnit.convert_knots_to_kmh(speed)
        return speed
    # no gps speed tag found
    return None


def add_gps_tags(path: str, gps_tags: {str: any}):
    """This method will add gps tags to the photo found at path"""
    exif_dict = piexif.load(path)
    for tag, tag_value in gps_tags.items():
        exif_dict["GPS"][tag] = tag_value
    exif_bytes = piexif.dump(exif_dict)
    piexif.insert(exif_bytes, path)


def create_required_gps_tags(timestamp_gps: float,
                             latitude: float,
                             longitude: float) -> {str: any}:
    """This method will creates gps required tags """
    exif_gps = {}
    dms_latitude = __dd_to_dms(latitude)
    dms_longitude = __dd_to_dms(longitude)
    day = int(timestamp_gps / 86400) * 86400
    hour = int((timestamp_gps - day) / 3600)
    minutes = int((timestamp_gps - day - hour * 3600) / 60)
    seconds = int(timestamp_gps - day - hour * 3600 - minutes * 60)

    day_timestamp_str = datetime.date.fromtimestamp(day).strftime("%Y:%m:%d")
    exif_gps[piexif.GPSIFD.GPSTimeStamp] = [(hour, 1),
                                            (minutes, 1),
                                            (seconds, 1)]
    exif_gps[piexif.GPSIFD.GPSDateStamp] = day_timestamp_str
    exif_gps[piexif.GPSIFD.GPSLatitudeRef] = "S" if latitude < 0 else "N"
    exif_gps[piexif.GPSIFD.GPSLatitude] = dms_latitude
    exif_gps[piexif.GPSIFD.GPSLongitudeRef] = "W" if longitude < 0 else "E"
    exif_gps[piexif.GPSIFD.GPSLongitude] = dms_longitude
    return exif_gps


def add_optional_gps_tags(exif_gps: {str: any},
                          speed: float,
                          altitude: float,
                          compass: float) -> {str: any}:
    """This method will append optional tags to exif_gps tags dictionary"""
    if speed:
        exif_gps[piexif.GPSIFD.GPSSpeed] = (speed, 1)
        exif_gps[piexif.GPSIFD.GPSSpeedRef] = SpeedUnit.KMH.value
    if altitude:
        exif_gps[piexif.GPSIFD.GPSAltitude] = (altitude, 1)
        sea_level = SeaLevel.BELOW.value if altitude < 0 else SeaLevel.ABOVE.value
        exif_gps[piexif.GPSIFD.GPSAltitudeRef] = sea_level
    if compass:
        exif_gps[piexif.GPSIFD.GPSImgDirection] = (compass, 1)
        exif_gps[piexif.GPSIFD.GPSImgDirectionRef] = CardinalDirection.TrueNorth.value


def __dd_to_dms(decimal_degree) -> [(float, int)]:
    decimal_degree_abs = abs(decimal_degree)

    degrees = math.floor(decimal_degree_abs)
    minute_float = (decimal_degree_abs - degrees) * 60
    minute = math.floor(minute_float)
    seconds = round((minute_float - minute) * 60 * 100)

    return [(degrees, 1), (minute, 1), (seconds, 100)]