//
//  NTPClient.swift
//  TrueTime
//
//  Created by Michael Sanders on 10/12/16.
//  Copyright © 2016 Instacart. All rights reserved.
//

struct NTPConfig {
    let timeout: TimeInterval
    let maxRetries: Int
    let maxConnections: Int
    let maxServers: Int
    let numberOfSamples: Int
    let pollInterval: TimeInterval
}

final class NTPClient {
    let config: NTPConfig
    init(config: NTPConfig) {
        self.config = config
    }

    func start(pool: [String], port: Int) {
        precondition(!pool.isEmpty, "Must include at least one pool URL")
        queue.async {
            precondition(self.reachability.callback == nil, "Already started")
            self.pool = pool
            self.port = port
            self.reachability.callbackQueue = self.queue
            self.reachability.callback = self.updateReachability
            self.reachability.startMonitoring()
            self.startTimer()
        }
    }

    func pause() {
        queue.async {
            self.cancelTimer()
            self.reachability.stopMonitoring()
            self.reachability.callback = nil
            self.stopQueue()
        }
    }

    func fetchIfNeeded(queue callbackQueue: DispatchQueue,
                       first: ReferenceTimeCallback?,
                       completion: ReferenceTimeCallback?) {
        queue.async {
            precondition(self.reachability.callback != nil,
                         "Must start client before retrieving time")
            if let time = self.referenceTime {
                callbackQueue.async { first?(.success(time)) }
            } else if let first = first {
                self.startCallbacks.append((callbackQueue, first))
            }

            if let time = self.referenceTime, self.finished {
                callbackQueue.async { completion?(.success(time)) }
            } else {
                if let completion = completion {
                    self.completionCallbacks.append((callbackQueue, completion))
                }
                self.updateReachability(status: self.reachability.status ?? .notReachable)
            }
        }
    }

    private let referenceTimeLock: GCDLock<ReferenceTime?> = GCDLock(value: nil)
    var referenceTime: ReferenceTime? {
        get { return referenceTimeLock.read() }
        set { referenceTimeLock.write(newValue) }
    }

    fileprivate func debugLog(_ message: @autoclosure () -> String) {
#if DEBUG_LOGGING
        logger?(message())
#endif
    }

    var logger: LogCallback? = defaultLogger
    private let queue = DispatchQueue(label: "com.instacart.ntp.client")
    private let reachability = Reachability()
    private var completionCallbacks: [(DispatchQueue, ReferenceTimeCallback)] = []
    private var connections: [NTPConnection] = []
    private var finished: Bool = false
    private var invalidated: Bool = false
    private var startCallbacks: [(DispatchQueue, ReferenceTimeCallback)] = []
    private var startTime: TimeInterval?
    private var timer: DispatchSourceTimer?
    private var port: Int = 123
    private var pool: [String] = [] {
        didSet { invalidate() }
    }
}

private extension NTPClient {
    var started: Bool { return startTime != nil }
    func updateReachability(status: ReachabilityStatus) {
        switch status {
        case .notReachable:
            debugLog("Network unreachable")
            cancelTimer()
            finish(.failure(NSError(trueTimeError: .offline)))
        case .reachableViaWWAN, .reachableViaWiFi:
            debugLog("Network reachable")
            startTimer()
            startPool(pool: pool, port: port)
        }
    }

    func startTimer() {
        cancelTimer()
        if let referenceTime = referenceTime {
            let remainingInterval = max(0, config.pollInterval - referenceTime.underlyingValue.uptimeInterval)
            timer = DispatchSource.makeTimerSource(flags: [], queue: queue)
            timer?.setEventHandler(handler: invalidate)
            timer?.schedule(deadline: .now() + remainingInterval)
            timer?.resume()
        }
    }

    func cancelTimer() {
        timer?.cancel()
        timer = nil
    }

    func startPool(pool: [String], port: Int) {
        guard !started && !finished else {
            debugLog("Already \(started ? "started" : "finished")")
            return
        }

        startTime = CFAbsoluteTimeGetCurrent()
        debugLog("Resolving pool: \(pool)")
        HostResolver.resolve(hosts: pool.map { ($0, port) },
                             timeout: config.timeout,
                             logger: logger,
                             callbackQueue: queue) { resolver, result in
            guard self.started && !self.finished else {
                self.debugLog("Got DNS response after queue stopped: \(resolver), \(result)")
                return
            }
            guard pool == self.pool, port == self.port else {
                self.debugLog("Got DNS response after pool URLs changed: \(resolver), \(result)")
                return
            }

            switch result {
            case let .success(addresses): self.query(addresses: addresses, host: resolver.host)
            case let .failure(error): self.finish(.failure(error))
            }
        }
    }

    func stopQueue() {
        debugLog("Stopping queue")
        startTime = nil
        connections.forEach { $0.close(waitUntilFinished: true) }
        connections = []
    }

    func invalidate() {
        stopQueue()
        finished = false
        if let referenceTime = referenceTime,
               reachability.status != .notReachable && !pool.isEmpty {
            debugLog("Invalidated time \(referenceTime.debugDescription)")
            startPool(pool: pool, port: port)
        }
    }

    func query(addresses: [SocketAddress], host: String) {
        var results: [String: [FrozenNetworkTimeResult]] = [:]
        connections = NTPConnection.query(addresses: addresses,
                                          config: config,
                                          logger: logger,
                                          callbackQueue: queue) { connection, result in
            guard self.started && !self.finished else {
                self.debugLog("Got NTP response after queue stopped: \(result)")
                return
            }

            let host = connection.address.host
            results[host] = (results[host] ?? []) + [result]

            let responses = Array(results.values)
            let sampleSize = responses.map { $0.count }.reduce(0, +)
            let expectedCount = addresses.count * self.config.numberOfSamples
            let atEnd = sampleSize == expectedCount
            let times = responses.map { results in
                results.compactMap { try? $0.get() }
            }

            self.debugLog("Got \(sampleSize) out of \(expectedCount)")
            if let time = bestTime(fromResponses: times) {
                let time = FrozenNetworkTime(networkTime: time, sampleSize: sampleSize, host: host)
                self.debugLog("\(atEnd ? "Final" : "Best") time: \(time), " +
                              "δ: \(time.serverResponse.delay), " +
                              "θ: \(time.serverResponse.offset)")

                let referenceTime = self.referenceTime ?? ReferenceTime(time)
                if self.referenceTime == nil {
                    self.referenceTime = referenceTime
                } else {
                    referenceTime.underlyingValue = time
                }

                if atEnd {
                    self.finish(.success(referenceTime))
                } else {
                    self.updateProgress(.success(referenceTime))
                }
            } else if atEnd {
                let error: NSError
                if case let .failure(failure) = result {
                    error = failure as NSError
                } else {
                    error = NSError(trueTimeError: .noValidPacket)
                }

                self.finish(ReferenceTimeResult.failure(error))
            }
        }
    }

    func updateProgress(_ result: ReferenceTimeResult) {
        let endTime = CFAbsoluteTimeGetCurrent()
        let hasStartCallbacks = !startCallbacks.isEmpty
        startCallbacks.forEach { queue, callback in
            queue.async {
                callback(result)
            }
        }
        startCallbacks = []
        if hasStartCallbacks {
            logDuration(endTime, to: "get first result")
        }

        NotificationCenter.default.post(Notification(name: .TrueTimeUpdated, object: self, userInfo: nil))
    }

    func finish(_ result: ReferenceTimeResult) {
        let endTime = CFAbsoluteTimeGetCurrent()
        updateProgress(result)
        completionCallbacks.forEach { queue, callback in
            queue.async {
                callback(result)
            }
        }
        completionCallbacks = []
        logDuration(endTime, to: "get last result")
        finished = (try? result.get()) != nil
        stopQueue()
        startTimer()
    }

    func logDuration(_ endTime: CFAbsoluteTime, to description: String) {
        if let startTime = startTime {
            debugLog("Took \(endTime - startTime)s to \(description)")
        }
    }
}
