// Copyright © 2015 Abhishek Banthia

import Cocoa
import CoreLocation

public struct Solar {
    /// The coordinate that is used for the calculation
    public let coordinate: CLLocationCoordinate2D

    /// The date to generate sunrise / sunset times for
    public private(set) var date: Date

    public private(set) var sunrise: Date?
    public private(set) var sunset: Date?
    public private(set) var civilSunrise: Date?
    public private(set) var civilSunset: Date?
    public private(set) var nauticalSunrise: Date?
    public private(set) var nauticalSunset: Date?
    public private(set) var astronomicalSunrise: Date?
    public private(set) var astronomicalSunset: Date?

    // MARK: Init

    public init?(for date: Date = Date(), coordinate: CLLocationCoordinate2D) {
        self.date = date

        guard CLLocationCoordinate2DIsValid(coordinate) else {
            return nil
        }

        self.coordinate = coordinate

        // Fill this Solar object with relevant data
        calculate()
    }

    // MARK: - Public functions

    /// Sets all of the Solar object's sunrise / sunset variables, if possible.
    /// - Note: Can return `nil` objects if sunrise / sunset does not occur on that day.
    public mutating func calculate() {
        sunrise = calculate(.sunrise, for: date, and: .official)
        sunset = calculate(.sunset, for: date, and: .official)
        civilSunrise = calculate(.sunrise, for: date, and: .civil)
        civilSunset = calculate(.sunset, for: date, and: .civil)
        nauticalSunrise = calculate(.sunrise, for: date, and: .nautical)
        nauticalSunset = calculate(.sunset, for: date, and: .nautical)
        astronomicalSunrise = calculate(.sunrise, for: date, and: .astronimical)
        astronomicalSunset = calculate(.sunset, for: date, and: .astronimical)
    }

    // MARK: - Private functions

    private enum SunriseSunset {
        case sunrise
        case sunset
    }

    /// Used for generating several of the possible sunrise / sunset times
    private enum Zenith: Double {
        case official = 90.83
        case civil = 96
        case nautical = 102
        case astronimical = 108
    }

    private func calculate(_ sunriseSunset: SunriseSunset, for date: Date, and zenith: Zenith) -> Date? {
        guard let utcTimezone = TimeZone(identifier: "UTC") else { return nil }

        // Get the day of the year
        var calendar = Calendar(identifier: .gregorian)
        calendar.timeZone = utcTimezone
        guard let dayInt = calendar.ordinality(of: .day, in: .year, for: date) else { return nil }
        let day = Double(dayInt)

        // Convert longitude to hour value and calculate an approx. time
        let lngHour = coordinate.longitude / 15

        let hourTime: Double = sunriseSunset == .sunrise ? 6 : 18
        let time = day + ((hourTime - lngHour) / 24)

        // Calculate the suns mean anomaly
        let meanAnomaly = (0.9856 * time) - 3.289

        // Calculate the sun's true longitude
        let subexpression1 = 1.916 * sin(meanAnomaly.degreesToRadians)
        let subexpression2 = 0.020 * sin(2 * meanAnomaly.degreesToRadians)
        var longitude = meanAnomaly + subexpression1 + subexpression2 + 282.634

        // Normalise L into [0, 360] range
        longitude = normalise(longitude, withMaximum: 360)

        // Calculate the Sun's right ascension
        var rightAscenscion = atan(0.91764 * tan(longitude.degreesToRadians)).radiansToDegrees

        // Normalise RA into [0, 360] range
        rightAscenscion = normalise(rightAscenscion, withMaximum: 360)

        // Right ascension value needs to be in the same quadrant as L...
        let leftQuadrant = floor(longitude / 90) * 90
        let rightQuadrant = floor(rightAscenscion / 90) * 90
        rightAscenscion += (leftQuadrant - rightQuadrant)

        // Convert RA into hours
        rightAscenscion /= 15

        // Calculate Sun's declination
        let sinDec = 0.39782 * sin(longitude.degreesToRadians)
        let cosDec = cos(asin(sinDec))

        // Calculate the Sun's local hour angle
        let cosH = (cos(zenith.rawValue.degreesToRadians) - (sinDec * sin(coordinate.latitude.degreesToRadians))) / (cosDec * cos(coordinate.latitude.degreesToRadians))

        // No sunrise
        guard cosH < 1 else {
            return nil
        }

        // No sunset
        guard cosH > -1 else {
            return nil
        }

        // Finish calculating H and convert into hours
        let tempH = sunriseSunset == .sunrise ? 360 - acos(cosH).radiansToDegrees : acos(cosH).radiansToDegrees
        let hours = tempH / 15.0

        // Calculate local mean time of rising
        let localMeanRisingTime = hours + rightAscenscion - (0.06571 * time) - 6.622

        // Adjust time back to UTC
        var utcCompatibleTime = localMeanRisingTime - lngHour

        // Normalise UT into [0, 24] range
        utcCompatibleTime = normalise(utcCompatibleTime, withMaximum: 24)

        // Calculate all of the sunrise's / sunset's date components
        let hour = floor(utcCompatibleTime)
        let minute = floor((utcCompatibleTime - hour) * 60.0)
        let second = (((utcCompatibleTime - hour) * 60) - minute) * 60.0

        let shouldBeYesterday = lngHour > 0 && utcCompatibleTime > 12 && sunriseSunset == .sunrise
        let shouldBeTomorrow = lngHour < 0 && utcCompatibleTime < 12 && sunriseSunset == .sunset

        let setDate: Date
        if shouldBeYesterday {
            setDate = Date(timeInterval: -(60 * 60 * 24), since: date)
        } else if shouldBeTomorrow {
            setDate = Date(timeInterval: 60 * 60 * 24, since: date)
        } else {
            setDate = date
        }

        var components = calendar.dateComponents([.day, .month, .year], from: setDate)
        components.hour = Int(hour)
        components.minute = Int(minute)
        components.second = Int(second)

        calendar.timeZone = utcTimezone
        return calendar.date(from: components)
    }

    /// Normalises a value between 0 and `maximum`, by adding or subtracting `maximum`
    private func normalise(_ value: Double, withMaximum maximum: Double) -> Double {
        var value = value

        if value < 0 {
            value += maximum
        }

        if value > maximum {
            value -= maximum
        }

        return value
    }
}

public extension Solar {
    /// Whether the location specified by the `latitude` and `longitude` is in daytime on `date`
    /// - Complexity: O(1)
    var isDaytime: Bool {
        guard
            let sunrise = sunrise,
            let sunset = sunset
        else {
            return false
        }

        let beginningOfDay = sunrise.timeIntervalSince1970
        let endOfDay = sunset.timeIntervalSince1970
        let currentTime = date.timeIntervalSince1970

        let isSunriseOrLater = currentTime >= beginningOfDay
        let isBeforeSunset = currentTime < endOfDay

        return isSunriseOrLater && isBeforeSunset
    }

    /// Whether the location specified by the `latitude` and `longitude` is in nighttime on `date`
    /// - Complexity: O(1)
    var isNighttime: Bool {
        return !isDaytime
    }
}

// MARK: - Helper extensions

private extension Double {
    var degreesToRadians: Double {
        return Double(self) * (Double.pi / 180.0)
    }

    var radiansToDegrees: Double {
        return (Double(self) * 180.0) / Double.pi
    }
}