//
//  OneDriveHelper.swift
//  FileProvider
//
//  Created by Amir Abbas Mousavian.
//  Copyright © 2017 Mousavian. Distributed under MIT license.
//

import Foundation

/// Error returned by OneDrive server when trying to access or do operations on a file or folder.
public struct FileProviderOneDriveError: FileProviderHTTPError {
    public let code: FileProviderHTTPErrorCode
    public let path: String
    public let serverDescription: String?
}

/// Containts path, url and attributes of a OneDrive file or resource.
public final class OneDriveFileObject: FileObject {
    internal convenience init? (baseURL: URL?, route: OneDriveFileProvider.Route, jsonStr: String) {
        guard let json = jsonStr.deserializeJSON() else { return nil }
        self.init(baseURL: baseURL, route: route, json: json)
    }
    
    internal init? (baseURL: URL?, route: OneDriveFileProvider.Route, json: [String: Any]) {
        guard let name = json["name"] as? String else { return nil }
        guard let id = json["id"] as? String else { return nil }
        let path: String
        if let refpath = (json["parentReference"] as? [String: Any])?["path"] as? String {
            let parentPath: String
            if let colonIndex = refpath.firstIndex(of: ":") {
                parentPath = String(refpath[refpath.index(after: colonIndex)...])
            } else {
                parentPath = refpath
            }
             path = parentPath.appendingPathComponent(name)
        } else {
            path = "id:\(id)"
        }
        let url = baseURL.map { OneDriveFileObject.url(of: path, modifier: nil, baseURL: $0, route: route) }
        super.init(url: url, name: name, path: path)
        self.downloadURL = (json["@microsoft.graph.downloadUrl"] as? String).flatMap { URL(string: $0) }
        self.id = id
        self.size = (json["size"] as? NSNumber)?.int64Value ?? -1
        self.childrensCount = (json["folder"] as? [String: Any])?["childCount"] as? Int
        self.modifiedDate = (json["lastModifiedDateTime"] as? String).flatMap { Date(rfcString: $0) }
        self.creationDate = (json["createdDateTime"] as? String).flatMap { Date(rfcString: $0) }
        self.type = json["folder"] != nil ? .directory : .regular
        self.contentType = ((json["file"] as? [String: Any])?["mimeType"] as? String).flatMap(ContentMIMEType.init(rawValue:)) ?? .stream
        self.entryTag = json["eTag"] as? String
        let hashes = (json["file"] as? [String: Any])?["hashes"] as? [String: Any]
        // checks for both sha1 or quickXor. First is available in personal drives, second in business one.
        self.fileHash = (hashes?["sha1Hash"] as? String) ?? (hashes?["quickXorHash"] as? String)
    }
    
    /// This value is a temporary URL that can be used to download a file from OneDrive
    public internal(set) var downloadURL: URL? {
        get {
            return allValues[.downloadURLKey] as? URL
        }
        set {
            allValues[.downloadURLKey] = newValue
        }
    }
    
    /// The document identifier is a value assigned by the OneDrive to a file.
    /// This value is used to identify the document regardless of where it is moved on a volume.
    public internal(set) var id: String? {
        get {
            return allValues[.fileResourceIdentifierKey] as? String
        }
        set {
            allValues[.fileResourceIdentifierKey] = newValue
        }
    }
    
    /// MIME type of file contents returned by OneDrive server.
    public internal(set) var contentType: ContentMIMEType {
        get {
            return (allValues[.mimeTypeKey] as? String).flatMap(ContentMIMEType.init(rawValue:)) ?? .stream
        }
        set {
            allValues[.mimeTypeKey] = newValue.rawValue
        }
    }
    
    /// HTTP E-Tag, can be used to mark changed files.
    public internal(set) var entryTag: String? {
        get {
            return allValues[.entryTagKey] as? String
        }
        set {
            allValues[.entryTagKey] = newValue
        }
    }
    
    /// Calculated hash from OneDrive server. Hex string SHA1 in personal or Base65 string [QuickXOR](https://dev.onedrive.com/snippets/quickxorhash.htm) in business drives.
    public internal(set) var fileHash: String? {
        get {
            return allValues[.documentIdentifierKey] as? String
        }
        set {
            allValues[.documentIdentifierKey] = newValue
        }
    }
    
    static func url(of path: String, modifier: String?, baseURL: URL, route: OneDriveFileProvider.Route) -> URL {
        var url: URL = baseURL
        let isId = path.hasPrefix("id:")
        var rpath: String = path.replacingOccurrences(of: "id:", with: "", options: .anchored)
        
        //url.appendPathComponent("v1.0")
        url.appendPathComponent(route.drivePath)
        
        if rpath.isEmpty {
            url.appendPathComponent("root")
        } else if isId {
            url.appendPathComponent("items")
        } else {
            url.appendPathComponent("root:")
        }
        
        rpath = rpath.trimmingCharacters(in: pathTrimSet)
        
        switch (modifier == nil, rpath.isEmpty, isId) {
        case (true, false, _):
            url.appendPathComponent(rpath)
        case (true, true, _):
            break
        case (false, true, _):
            url.appendPathComponent(modifier!)
        case (false, false, true):
            url.appendPathComponent(rpath)
            url.appendPathComponent(modifier!)
        case (false, false, false):
            url.appendPathComponent(rpath + ":")
            url.appendPathComponent(modifier!)
        }
        
        return url
    }
    
    static func relativePathOf(url: URL, baseURL: URL?, route: OneDriveFileProvider.Route) -> String {
        let base = baseURL?.appendingPathComponent(route.drivePath).path ?? ""
        
        let crudePath = url.path.replacingOccurrences(of: base, with: "", options: .anchored)
            .trimmingCharacters(in: CharacterSet(charactersIn: "/"))
        
        switch crudePath {
        case hasPrefix("items/"):
            let components = crudePath.components(separatedBy: "/")
            return components.dropFirst().first.map { "id:\($0)" } ?? ""
        case hasPrefix("root:"):
            return crudePath.components(separatedBy: ":").dropFirst().first ?? ""
        default:
            return ""
        }
    }
}

extension OneDriveFileProvider {
    func upload_multipart_data(_ targetPath: String, data: Data, operation: FileOperationType,
                                        overwrite: Bool, completionHandler: SimpleCompletionHandler) -> Progress? {
        return self.upload_multipart(targetPath, operation: operation, size: Int64(data.count), overwrite: overwrite, dataProvider: {
            let range = $0.clamped(to: 0..<Int64(data.count))
            return data[range]
        }, completionHandler: completionHandler)
    }
    
    func upload_multipart_file(_ targetPath: String, file: URL, operation: FileOperationType,
                                        overwrite: Bool, completionHandler: SimpleCompletionHandler) -> Progress? {
        // upload task can't handle uploading file
        
        return self.upload_multipart(targetPath, operation: operation, size: file.fileSize, overwrite: overwrite, dataProvider: { range in
            guard let handle = FileHandle(forReadingAtPath: file.path) else {
                throw CocoaError(.fileNoSuchFile, path: targetPath)
            }
            
            defer {
                handle.closeFile()
            }
            
            let offset = range.lowerBound
            handle.seek(toFileOffset: UInt64(offset))
            guard Int64(handle.offsetInFile) == offset else {
                throw CocoaError(.fileReadTooLarge, path: targetPath)
            }
            
            return handle.readData(ofLength: range.count)
        }, completionHandler: completionHandler)
    }
    
    private func upload_multipart(_ targetPath: String, operation: FileOperationType, size: Int64, overwrite: Bool,
                                  dataProvider: @escaping (Range<Int64>) throws -> Data, completionHandler: SimpleCompletionHandler) -> Progress? {
        guard size > 0 else { return nil }
        
        let progress = Progress(totalUnitCount: size)
        progress.setUserInfoObject(operation, forKey: .fileProvderOperationTypeKey)
        progress.kind = .file
        progress.setUserInfoObject(Progress.FileOperationKind.downloading, forKey: .fileOperationKindKey)
        
        let createURL = self.url(of: targetPath, modifier: "createUploadSession")
        var createRequest = URLRequest(url: createURL)
        createRequest.httpMethod = "POST"
        createRequest.setValue(authentication: self.credential, with: .oAuth2)
        createRequest.setValue(contentType: .json)
        if overwrite {
            createRequest.httpBody = Data(jsonDictionary: ["item": ["@microsoft.graph.conflictBehavior": "replace"] as NSDictionary])
        } else {
            createRequest.httpBody = Data(jsonDictionary: ["item": ["@microsoft.graph.conflictBehavior": "fail"] as NSDictionary])
        }
        let createSessionTask = session.dataTask(with: createRequest) { (data, response, error) in
            if let error = error {
                completionHandler?(error)
                return
            }
            
            if let data = data, let json = data.deserializeJSON(),
                let uploadURL = (json["uploadUrl"] as? String).flatMap(URL.init(string:)) {
                self.upload_multipart(url: uploadURL, operation: operation, size: size, progress: progress, dataProvider: dataProvider, completionHandler: completionHandler)
            }
        }
        createSessionTask.resume()
        
        return progress
    }
    
    private func upload_multipart(url: URL, operation: FileOperationType, size: Int64, range: Range<Int64>? = nil, uploadedSoFar: Int64 = 0,
                                  progress: Progress, dataProvider: @escaping (Range<Int64>) throws -> Data, completionHandler: SimpleCompletionHandler) {
        guard !progress.isCancelled else { return }
        
        let maximumSize: Int64 = 10_485_760 // Recommended by OneDrive documentations and divides evenly by 320 KiB, max 60MiB.
        var request = URLRequest(url: url)
        request.httpMethod = "PUT"
        request.setValue(authentication: self.credential, with: .oAuth2)
        
        let finalRange: Range<Int64>
        if let range = range {
            if range.count > maximumSize {
                finalRange = range.lowerBound..<(range.lowerBound + maximumSize)
            } else {
                finalRange = range
            }
        } else {
            finalRange = 0..<min(maximumSize, size)
        }
        request.setValue(contentRange: finalRange, totalBytes: size)
        
        let data: Data
        do {
            data = try dataProvider(finalRange)
        } catch {
            dispatch_queue.async {
                completionHandler?(error)
            }
            self.delegateNotify(operation, error: error)
            return
        }
        let task = session.uploadTask(with: request, from: data)
        
        var dictionary: [String: Any] = ["type": operation.description]
        dictionary["source"] = operation.source
        dictionary["dest"] = operation.destination
        dictionary["uploadedBytes"] = NSNumber(value: uploadedSoFar)
        dictionary["totalBytes"] = NSNumber(value: size)
        task.taskDescription = String(jsonDictionary: dictionary)
        sessionDelegate?.observerProgress(of: task, using: progress, kind: .upload)
        progress.cancellationHandler = { [weak task, weak self] in
            task?.cancel()
            var deleteRequest = URLRequest(url: url)
            deleteRequest.httpMethod = "DELETE"
            self?.session.dataTask(with: deleteRequest).resume()
        }
        progress.setUserInfoObject(Date(), forKey: .startingTimeKey)
        
        var allData = Data()
        dataCompletionHandlersForTasks[session.sessionDescription!]?[task.taskIdentifier] = { data in
            allData.append(data)
        }
        // We retain self here intentionally to allow resuming upload, This behavior may change anytime!
        completionHandlersForTasks[session.sessionDescription!]?[task.taskIdentifier] = { [weak task] error in
            if let error = error {
                progress.cancel()
                completionHandler?(error)
                self.delegateNotify(operation, error: error)
                return
            }
            
            guard let json = allData.deserializeJSON() else {
                let error = URLError(.badServerResponse, userInfo: [NSURLErrorKey: url, NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString])
                completionHandler?(error)
                self.delegateNotify(operation, error: error)
                return
            }
            
            if let _ = json["error"] {
                let code = ((task?.response as? HTTPURLResponse)?.statusCode).flatMap(FileProviderHTTPErrorCode.init(rawValue:)) ?? .badRequest
                let error = self.serverError(with: code, path: self.relativePathOf(url: url), data: allData)
                completionHandler?(error)
                self.delegateNotify(operation, error: error)
                return
            }
            
            if let ranges = json["nextExpectedRanges"] as? [String], let firstRange = ranges.first {
                let uploaded = uploadedSoFar + Int64(finalRange.count)
                let comp = firstRange.components(separatedBy: "-")
                let lower = comp.first.flatMap(Int64.init) ?? uploaded
                let upper = comp.dropFirst().first.flatMap(Int64.init) ?? Int64.max
                let range = Range<Int64>(uncheckedBounds: (lower: lower, upper: upper + 1))
                self.upload_multipart(url: url, operation: operation, size: size, range: range, uploadedSoFar: uploaded, progress: progress,
                                      dataProvider: dataProvider, completionHandler: completionHandler)
                return
            }
            
            if let _ = json["id"] as? String {
                completionHandler?(nil)
                self.delegateNotify(operation)
            }
        }
        
        task.resume()
    }
    
    static let dateFormatter = DateFormatter()
    static let decimalFormatter = NumberFormatter()
    
    func mapMediaInfo(_ json: [String: Any]) -> (dictionary: [String: Any], keys: [String]) {
        
        func spaceCamelCase(_ text: String) -> String {
            var newString: String = ""
            
            let upperCase = CharacterSet.uppercaseLetters
            for scalar in text.unicodeScalars {
                if upperCase.contains(scalar) {
                    newString.append(" ")
                }
                let character = Character(scalar)
                newString.append(character)
            }
            
            return newString.capitalized
        }
        
        var dic = [String: Any]()
        var keys = [String]()
        
        func add(key: String, value: Any?) {
            if let value = value, !((value as? String)?.isEmpty ?? false) {
                keys.append(key)
                dic[key] = value
            }
        }
        
        if let parent = json["image"] as? [String: Any] ?? json["video"] as? [String: Any], let height = parent["height"] as? UInt64, let width = parent["width"] as? UInt64 {
            add(key: "Dimensions", value: "\(width)x\(height)")
        }
        if let location = json["location"] as? [String: Any], let latitude = location["latitude"] as? Double, let longitude = location["longitude"] as? Double {
            OneDriveFileProvider.decimalFormatter.numberStyle = .decimal
            OneDriveFileProvider.decimalFormatter.maximumFractionDigits = 5
            let latStr = OneDriveFileProvider.decimalFormatter.string(from: NSNumber(value: latitude))!
            let longStr = OneDriveFileProvider.decimalFormatter.string(from: NSNumber(value: longitude))!
            add(key: "Location", value: "\(latStr), \(longStr)")
        }
        if let parent = json["image"] as? [String: Any] ?? json["video"] as? [String: Any], let duration = parent["duration"] as? UInt64 {
            add(key: "Duration", value: (TimeInterval(duration) / 1000).formatshort)
        }
        if let timeTakenStr = json["takenDateTime"] as? String, let timeTaken = Date(rfcString: timeTakenStr) {
            OneDriveFileProvider.dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
            add(key: "Date taken", value:  OneDriveFileProvider.dateFormatter.string(from: timeTaken))
        }
        
        if let photo = json["photo"] as? [String: Any] {
            add(key: "Device make", value: photo["cameraMake"] as? String)
            add(key: "Device model", value: photo["cameraModel"] as? String)
            add(key: "focalLength", value: photo["focalLength"] as? Double)
            add(key: "fNumber", value: photo["fNumber"] as? Double)
            if let expNom = photo["exposureNumerator"] as? Double, let expDen = photo["exposureDenominator"] as? Double {
                add(key: "Exposure time", value: "\(Int(expNom))/\(Int(expDen))")
            }
            add(key: "ISO speed", value: photo["iso"] as? Int64)
        }
        
        if let audio = json["audio"] as? [String: Any] {
            for (key, value) in audio {
                var value = value
                if key == "bitrate" || key == "isVariableBitrate" { continue }
                let casedKey = spaceCamelCase(key)
                switch casedKey {
                case "Duration":
                    value = (value as? Int64).map { (TimeInterval($0) / 1000).formatshort } as Any
                case "Bitrate":
                    value = (value as? Int64).map { "\($0)kbps" } as Any
                default:
                    break
                }
                add(key: casedKey, value: value)
            }
        }
        
        add(key: "Bitrate", value: (json["video"] as? NSDictionary)?["bitrate"] as? Int)
        
        return (dic, keys)
    }
}
