//
//  WebDAV.swift
//  WebDAV-Swift
//
//  Created by Isaac Lyons on 10/29/20.
//

import UIKit
import SWXMLHash

public class WebDAV: NSObject, URLSessionDelegate {
    static let domain = "app.lyons.webdav-swift"
    
    //MARK: Properties
    
    /// The formatter used when rendering cache size in `getCacheSize`.
    public var byteCountFormatter = ByteCountFormatter()
    
    public var filesCache: [AccountPath: [WebDAVFile]] = [:]
    public var dataCache = Cache<AccountPath, Data>()
    public var imageCache = Cache<AccountPath, UIImage>()
    public var thumbnailCache = Cache<AccountPath, [ThumbnailProperties: UIImage]>()
    
    public override init() {
        super.init()
        loadFilesCacheFromDisk()
    }
    
    //MARK: Static
    
    public static func sortedFiles(_ files: [WebDAVFile], foldersFirst: Bool, includeSelf: Bool) -> [WebDAVFile] {
        var files = files
        if !includeSelf, !files.isEmpty {
            files.removeFirst()
        }
        if foldersFirst {
            files = files.filter { $0.isDirectory } + files.filter { !$0.isDirectory }
        }
        return files
    }
}

//MARK: Public

public extension WebDAV {
    
    //MARK: WebDAV Requests
    
    /// List the files and directories at the specified path.
    /// - Parameters:
    ///   - path: The path to list files from.
    ///   - account: The WebDAV account.
    ///   - password: The WebDAV account's password.
    ///   - foldersFirst: Whether folders should be sorted to the top of the list.
    ///   Defaults to `true`.
    ///   - includeSelf: Whether or not the folder itself at the path should be included as a file in the list.
    ///   If so, the folder's WebDAVFile will be the first in the list.
    ///   Defaults to `false`.
    ///   - options: Options for caching the results. Empty set uses default caching behavior.
    ///   - completion: If account properties are invalid, this will run immediately on the same thread.
    ///   Otherwise, it runs when the network call finishes on a background thread.
    ///   - files: The files at the directory specified. `nil` if there was an error.
    ///   - error: A WebDAVError if the call was unsuccessful.
    /// - Returns: The data task for the request.
    @discardableResult
    func listFiles<A: WebDAVAccount>(atPath path: String, account: A, password: String, foldersFirst: Bool = true, includeSelf: Bool = false, caching options: WebDAVCachingOptions = [], completion: @escaping (_ files: [WebDAVFile]?, _ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
        // Check the cache
        var cachedResponse: [WebDAVFile]?
        let accountPath = AccountPath(account: account, path: path)
        if !options.contains(.doNotReturnCachedResult) {
            if let files = filesCache[accountPath] {
                let sortedFiles = WebDAV.sortedFiles(files, foldersFirst: foldersFirst, includeSelf: includeSelf)
                completion(sortedFiles, nil)
                
                if !options.contains(.requestEvenIfCached) {
                    return nil
                } else {
                    // Remember the cached completion. If the fetched results
                    // are the same, don't bother completing again.
                    cachedResponse = sortedFiles
                }
            }
        }
        
        guard var request = authorizedRequest(path: path, account: account, password: password, method: .propfind) else {
            completion(nil, .invalidCredentials)
            return nil
        }
        
        let body =
"""
<?xml version="1.0"?>
<d:propfind  xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
  <d:prop>
        <d:getlastmodified />
        <d:getetag />
        <d:getcontenttype />
        <oc:fileid />
        <oc:permissions />
        <oc:size />
        <nc:has-preview />
        <oc:favorite />
  </d:prop>
</d:propfind>
"""
        request.httpBody = body.data(using: .utf8)
        
        let task = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil).dataTask(with: request) { [weak self] data, response, error in
            var error = WebDAVError.getError(response: response, error: error)
            
            // Check the response
            let response = response as? HTTPURLResponse
            
            guard 200...299 ~= response?.statusCode ?? 0,
                  let data = data,
                  let string = String(data: data, encoding: .utf8) else {
                return completion(nil, error)
            }
            
            // Create WebDAVFiles from the XML response
            
            let xml = SWXMLHash.config { config in
                config.shouldProcessNamespaces = true
            }.parse(string)
            
            let files = xml["multistatus"]["response"].all.compactMap { WebDAVFile(xml: $0, baseURL: account.baseURL) }
            
            // Caching
            
            if options.contains(.removeExistingCache) {
                // Remove cached result
                self?.filesCache.removeValue(forKey: accountPath)
                self?.saveFilesCacheToDisk()
            } else if !options.contains(.doNotCacheResult) {
                // Cache the result
                self?.filesCache[accountPath] = files
                self?.saveFilesCacheToDisk()
            }
            
            do {
                try self?.cleanupCache(at: path, account: account, files: Array(files.dropFirst()))
            } catch let cachingError {
                error = .nsError(cachingError)
            }
            
            let sortedFiles = WebDAV.sortedFiles(files, foldersFirst: foldersFirst, includeSelf: includeSelf)
            // Don't send a duplicate completion if the results are the same.
            if sortedFiles != cachedResponse {
                completion(sortedFiles, error)
            }
        }
        
        task.resume()
        return task
    }
    
    /// Upload data to the specified file path.
    /// - Parameters:
    ///   - data: The data of the file to upload.
    ///   - path: The path, including file name and extension, to upload the file to.
    ///   - account: The WebDAV account.
    ///   - password: The WebDAV account's password.
    ///   - completion: If account properties are invalid, this will run immediately on the same thread.
    ///   Otherwise, it runs when the network call finishes on a background thread.
    ///   - error: A WebDAVError if the call was unsuccessful. `nil` if it was.
    /// - Returns: The upload task for the request.
    @discardableResult
    func upload<A: WebDAVAccount>(data: Data, toPath path: String, account: A, password: String, completion: @escaping (_ error: WebDAVError?) -> Void) -> URLSessionUploadTask? {
        guard let request = authorizedRequest(path: path, account: account, password: password, method: .put) else {
            completion(.invalidCredentials)
            return nil
        }
        
        let task = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil).uploadTask(with: request, from: data) { _, response, error in
            completion(WebDAVError.getError(response: response, error: error))
        }
        
        task.resume()
        return task
    }
    /// Upload a file to the specified file path.
    /// - Parameters:
    ///   - file: The path to the file to upload.
    ///   - path: The path, including file name and extension, to upload the file to.
    ///   - account: The WebDAV account.
    ///   - password: The WebDAV account's password.
    ///   - completion: If account properties are invalid, this will run immediately on the same thread.
    ///   Otherwise, it runs when the network call finishes on a background thread.
    ///   - error: A WebDAVError if the call was unsuccessful. `nil` if it was.
    /// - Returns: The upload task for the request.
    @discardableResult
    func upload<A: WebDAVAccount>(file: URL, toPath path: String, account: A, password: String, completion: @escaping (_ error: WebDAVError?) -> Void) -> URLSessionUploadTask? {
        guard let request = authorizedRequest(path: path, account: account, password: password, method: .put) else {
            completion(.invalidCredentials)
            return nil
        }
        
        let task = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil).uploadTask(with: request, fromFile: file) { _, response, error in
            completion(WebDAVError.getError(response: response, error: error))
        }
        
        task.resume()
        return task
    }
    
    /// Download data from the specified file path.
    /// - Parameters:
    ///   - path: The path of the file to download.
    ///   - account: The WebDAV account.
    ///   - password: The WebDAV account's password.
    ///   - options: Options for caching the results. Empty set uses default caching behavior.
    ///   - completion: If account properties are invalid, this will run immediately on the same thread.
    ///   Otherwise, it runs when the network call finishes on a background thread.
    ///   - data: The data of the file downloaded, if successful.
    ///   - error: A WebDAVError if the call was unsuccessful. `nil` if it was.
    /// - Returns: The data task for the request.
    @discardableResult
    func download<A: WebDAVAccount>(fileAtPath path: String, account: A, password: String, caching options: WebDAVCachingOptions = [], completion: @escaping (_ data: Data?, _ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
        cachingDataTask(cache: dataCache, path: path, account: account, password: password, caching: options, valueFromData: { $0 }, completion: completion)
    }
    
    /// Create a folder at the specified path
    /// - Parameters:
    ///   - path: The path to create a folder at.
    ///   - account: The WebDAV account.
    ///   - password: The WebDAV account's password.
    ///   - completion: If account properties are invalid, this will run immediately on the same thread.
    ///   Otherwise, it runs when the network call finishes on a background thread.
    ///   - error: A WebDAVError if the call was unsuccessful. `nil` if it was.
    /// - Returns: The data task for the request.
    @discardableResult
    func createFolder<A: WebDAVAccount>(atPath path: String, account: A, password: String, completion: @escaping (_ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
        basicDataTask(path: path, account: account, password: password, method: .mkcol, completion: completion)
    }
    
    /// Delete the file or folder at the specified path.
    /// - Parameters:
    ///   - path: The path of the file or folder to delete.
    ///   - account: The WebDAV account.
    ///   - password: The WebDAV account's password.
    ///   - completion: If account properties are invalid, this will run immediately on the same thread.
    ///   Otherwise, it runs when the network call finishes on a background thread.
    ///   - error: A WebDAVError if the call was unsuccessful. `nil` if it was.
    /// - Returns: The data task for the request.
    @discardableResult
    func deleteFile<A: WebDAVAccount>(atPath path: String, account: A, password: String, completion: @escaping (_ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
        basicDataTask(path: path, account: account, password: password, method: .delete, completion: completion)
    }
    
    /// Move the file to the specified destination.
    /// - Parameters:
    ///   - path: The original path of the file.
    ///   - destination: The desired destination path of the file.
    ///   - account: The WebDAV account.
    ///   - password: The WebDAV account's password.
    ///   - completion: If account properties are invalid, this will run immediately on the same thread.
    ///   Otherwise, it runs when the network call finishes on a background thread.
    ///   - error: A WebDAVError if the call was unsuccessful. `nil` if it was.
    /// - Returns: The data task for the request.
    @discardableResult
    func moveFile<A: WebDAVAccount>(fromPath path: String, to destination: String, account: A, password: String, completion: @escaping (_ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
        basicDataTask(path: path, destination: destination, account: account, password: password, method: .move, completion: completion)
    }
    
    /// Copy the file to the specified destination.
    /// - Parameters:
    ///   - path: The original path of the file.
    ///   - destination: The desired destination path of the copy.
    ///   - account: The WebDAV account.
    ///   - password: The WebDAV account's password.
    ///   - completion: If account properties are invalid, this will run immediately on the same thread.
    ///   Otherwise, it runs when the network call finishes on a background thread.
    ///   - error: A WebDAVError if the call was unsuccessful. `nil` if it was.
    /// - Returns: The data task for the request.
    @discardableResult
    func copyFile<A: WebDAVAccount>(fromPath path: String, to destination: String, account: A, password: String, completion: @escaping (_ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
        basicDataTask(path: path, destination: destination, account: account, password: password, method: .copy, completion: completion)
    }
    
    //MARK: Cache
    
    /// Get the cached data for the item at the specified path from the memory cache if available.
    /// Otherwise load it from disk and save to memory cache.
    /// - Parameters:
    ///   - path: The path used to download the data.
    ///   - account: The WebDAV account used to download the data.
    /// - Returns: The cached data if it is available.
    func getCachedData<A: WebDAVAccount>(forItemAtPath path: String, account: A) -> Data? {
        getCachedValue(cache: dataCache, forItemAtPath: path, account: account, valueFromData: { $0 })
    }
    
    /// Get the cached value for the item at the specified path directly from the memory cache.
    /// - Parameters:
    ///   - cache: The memory cache the data is stored in.
    ///   - path: The path used to download the data.
    ///   - account: The WebDAV account used to download the data.
    /// - Returns: The cached data if it is available in the given memory cache.
    func getCachedValue<A: WebDAVAccount, Value: Equatable>(from cache: Cache<AccountPath, Value>, forItemAtPath path: String, account: A) -> Value? {
        cache[AccountPath(account: account, path: path)]
    }
    
    /// Generic function to get the cached value for the item at the specified path from the memory cache if available.
    /// Otherwise load it from disk and save to memory cache.
    /// - Parameters:
    ///   - cache: The memory cache for the value.
    ///   - path: The path used to download the data.
    ///   - account: The WebDAV account used to download the data.
    ///   - valueFromData: Convert `Data` to the desired value type.
    /// - Returns: The cached value if it is available.
    func getCachedValue<A: WebDAVAccount, Value: Equatable>(cache: Cache<AccountPath, Value>, forItemAtPath path: String, account: A, valueFromData: @escaping (_ data: Data) -> Value?) -> Value? {
        getCachedValue(from: cache, forItemAtPath: path, account: account) ??
            loadCachedValueFromDisk(cache: cache, forItemAtPath: path, account: account, valueFromData: valueFromData)
    }
    
    /// Deletes the cached data or image for the item at the specified path.
    ///
    /// Deletes cached data from both memory and disk caches. Deletes cached data from both `dataCache` and `imageCache`.
    /// - Parameters:
    ///   - path: The path used to download the data.
    ///   - account: The WebDAV account used to download the data.
    /// - Throws: An error if the file can't be deleted.
    func deleteCachedData<A: WebDAVAccount>(forItemAtPath path: String, account: A) throws {
        let accountPath = AccountPath(account: account, path: path)
        dataCache.removeValue(forKey: accountPath)
        imageCache.removeValue(forKey: accountPath)
        
        try deleteCachedDataFromDisk(forItemAtPath: path, account: account)
    }
    
    /// Deletes all downloaded data that has been cached.
    ///
    /// Does not clear the files cache.
    /// - Throws: An error if the resources couldn't be deleted.
    func deleteAllCachedData() throws {
        dataCache.removeAllValues()
        imageCache.removeAllValues()
        try deleteAllDiskCachedData()
    }
    
    /// Get the total disk space for the contents of the image cache.
    /// For a formatted string of the size, see `getCacheSize`.
    /// - Returns: The total allocated space of the cache in bytes.
    func getCacheByteCount() -> Int {
        guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first,
              let urls = FileManager.default.enumerator(at: caches, includingPropertiesForKeys: nil)?.allObjects as? [URL] else { return 0 }
        
        return urls.lazy.reduce(0) { total, url -> Int in
            ((try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize) ?? 0) + total
        }
    }
    
    /// Get the total disk space for the contents of the image cache and display it as a localized
    /// description that is formatted with the appropriate byte modifier (KB, MB, GB and so on).
    ///
    /// This formats the size using this object's `byteCountFormatter` which can be modified.
    /// - Returns: A localized string of the total allocated space of the cache.
    func getCacheSize() -> String {
        byteCountFormatter.string(fromByteCount: Int64(getCacheByteCount()))
    }
    
    /// The URL to the directory of the deprecated Networking image data cache.
    var networkingCacheURL: URL? {
        FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("com.3lvis.networking")
    }
    
    /// The caching system has changed from WebDAV Swift v2 to v3.
    /// Run this function if upgrading from v2 to v3 to clear the old cache.
    /// - Throws: An error if the cache couldn't be deleted.
    func clearV2Cache() throws {
        guard let caches = networkingCacheURL,
              FileManager.default.fileExists(atPath: caches.path) else { return }
        try FileManager.default.removeItem(at: caches)
    }
    
    func clearFilesMemoryCache() {
        filesCache.removeAll()
    }
    
}

//MARK: Internal

extension WebDAV {
    
    //MARK: Standard Requests
    
    func cachingDataTask<A: WebDAVAccount, Value: Equatable>(
        cache: Cache<AccountPath, Value>, path: String, account: A, password: String,
        caching options: WebDAVCachingOptions, valueFromData: @escaping (_ data: Data) -> Value?, placeholder: (() -> Value?)? = nil,
        completion: @escaping (_ value: Value?, _ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
        
        // Check cache
        
        var cachedValue: Value?
        let accountPath = AccountPath(account: account, path: path)
        if !options.contains(.doNotReturnCachedResult) {
            if let value = getCachedValue(cache: cache, forItemAtPath: path, account: account, valueFromData: valueFromData) {
                completion(value, nil)
                
                if !options.contains(.requestEvenIfCached) {
                    if options.contains(.removeExistingCache) {
                        try? deleteCachedData(forItemAtPath: path, account: account)
                    }
                    return nil
                } else {
                    // Remember the cached completion. If the fetched results
                    // are the same, don't bother completing again.
                    cachedValue = value
                }
            }
        }
        
        // Cached data was not returned. Continue with network fetch.
        
        if options.contains(.removeExistingCache) {
            try? deleteCachedData(forItemAtPath: path, account: account)
        }
        
        let placeholderTask = DispatchWorkItem {
            if let placeholderValue = placeholder?() {
                completion(placeholderValue, .placeholder)
            }
        }
        DispatchQueue.global(qos: .utility).async(execute: placeholderTask)
        
        // Create network request
        
        guard let request = authorizedRequest(path: path, account: account, password: password, method: .get) else {
            completion(nil, .invalidCredentials)
            return nil
        }
        
        // Perform network request
        
        let task = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil).dataTask(with: request) { [weak self] data, response, error in
            var error = WebDAVError.getError(response: response, error: error)
            
            if let error = error {
                return completion(nil, error)
            } else if let data = data,
                      let value = valueFromData(data) {
                placeholderTask.cancel()
                // Cache result
                if !options.contains(.removeExistingCache),
                   !options.contains(.doNotCacheResult) {
                    // Memory cache
                    cache.set(value, forKey: accountPath)
                    // Disk cache
                    do {
                        try self?.saveDataToDiskCache(data, forItemAtPath: path, account: account)
                    } catch let cachingError {
                        error = .nsError(cachingError)
                    }
                }
                
                // Don't send a duplicate completion if the results are the same.
                if value != cachedValue {
                    completion(value, error)
                }
            } else {
                completion(nil, nil)
            }
        }
        
        task.resume()
        return task
    }
    
    /// Creates a basic authentication credential.
    /// - Parameters:
    ///   - username: The username
    ///   - password: The password
    /// - Returns: A base-64 encoded credential if the provided credentials are valid (can be encoded as UTF-8).
    func auth(username: String, password: String) -> String? {
        let authString = username + ":" + password
        let authData = authString.data(using: .utf8)
        return authData?.base64EncodedString()
    }
    
    /// Creates an authorized URL request at the path and with the HTTP method specified.
    /// - Parameters:
    ///   - path: The path of the request
    ///   - account: The WebDAV account
    ///   - password: The WebDAV password
    ///   - method: The HTTP Method for the request.
    /// - Returns: The URL request if the credentials are valid (can be encoded as UTF-8).
    func authorizedRequest<A: WebDAVAccount>(path: String, account: A, password: String, method: HTTPMethod) -> URLRequest? {
        guard let unwrappedAccount = UnwrappedAccount(account: account),
              let auth = self.auth(username: unwrappedAccount.username, password: password) else { return nil }
        
        let url = unwrappedAccount.baseURL.appendingPathComponent(path)
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue
        request.addValue("Basic \(auth)", forHTTPHeaderField: "Authorization")
        
        return request
    }
    
    func basicDataTask<A: WebDAVAccount>(path: String, destination: String? = nil, account: A, password: String, method: HTTPMethod, completion: @escaping (_ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
        guard var request = authorizedRequest(path: path, account: account, password: password, method: method),
              let unwrappedAccount = UnwrappedAccount(account: account) else {
            completion(.invalidCredentials)
            return nil
        }
        
        if let destination = destination {
            let destionationURL = unwrappedAccount.baseURL.appendingPathComponent(destination)
            request.addValue(destionationURL.absoluteString, forHTTPHeaderField: "Destination")
        }
        
        let task = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil).dataTask(with: request) { data, response, error in
            completion(WebDAVError.getError(response: response, error: error))
        }
        
        task.resume()
        return task
    }
    
    //MARK: Pathing
    
    func nextcloudBaseURL(for baseURL: URL) -> URL? {
        guard baseURL.absoluteString.lowercased().contains("remote.php/dav/files/"),
              let index = baseURL.pathComponents.map({ $0.lowercased() }).firstIndex(of: "remote.php") else { return nil }
        
        // Remove Nextcloud files path components
        var previewURL = baseURL
        for _ in 0 ..< baseURL.pathComponents.count - index {
            previewURL.deleteLastPathComponent()
        }
        
        // Add Nextcloud thumbnail components
        return previewURL
    }
    
    //MARK: Cache
    
    func cleanupFilesCache<A: WebDAVAccount>(at path: String, account: A, files: [WebDAVFile]) {
        let directory = path.trimmingCharacters(in: AccountPath.slash)
        var changed = false
        // Remove from cache if the the parent directory no longer exists
        for (key, _) in filesCache
        where key.path != directory
            && key.path.starts(with: directory)
            && !files.contains(where: { key.path.starts(with: $0.path.trimmingCharacters(in: AccountPath.slash)) }) {
            filesCache.removeValue(forKey: key)
            changed = true
        }
        
        if changed {
            saveFilesCacheToDisk()
        }
    }
    
    func cleanupCache<A: WebDAVAccount>(at path: String, account: A, files: [WebDAVFile]) throws {
        cleanupFilesCache(at: path, account: account, files: files)
        try cleanupDiskCache(at: path, account: account, files: files)
    }
    
}
