// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.

import Foundation
import GRDB
import PromiseKit
import SignalCoreKit

public enum GRDBStorageError: Error {  // TODO: Rename to `StorageError`
    case generic
    case migrationFailed
    case invalidKeySpec
    case decodingFailed
    
    case failedToSave
    case objectNotFound
    case objectNotSaved
    
    case invalidSearchPattern
}

// TODO: Protocol for storage (just need to have 'read' and 'write' methods and mock 'Database'?

// TODO: Rename to `Storage`
public final class GRDBStorage {
    public static var shared: GRDBStorage!  // TODO: Figure out how/if we want to do this
    
    private static let dbFileName: String = "Session.sqlite"
    private static let keychainService: String = "TSKeyChainService"
    private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec"
    private static let kSQLCipherKeySpecLength: Int32 = 48
    
    private static var sharedDatabaseDirectoryPath: String { "\(OWSFileSystem.appSharedDataDirectoryPath())/database" }
    private static var databasePath: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)" }
    private static var databasePathShm: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-shm" }
    private static var databasePathWal: String { "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)-wal" }
    
    public static var isDatabasePasswordAccessible: Bool {
        guard (try? getDatabaseCipherKeySpec()) != nil else { return false }
        
        return true
    }
    
    private let dbPool: DatabasePool
    private let migrator: DatabaseMigrator
    
    // MARK: - Initialization
    
    public init?(
        migrations: [TargetMigrations]
    ) throws {
        print("RAWR START \("\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)")")
        GRDBStorage.deleteDatabaseFiles() // TODO: Remove this
        try! GRDBStorage.deleteDbKeys() // TODO: Remove this
        
        // Create the database directory if needed and ensure it's protection level is set before attempting to
        // create the database KeySpec or the database itself
        OWSFileSystem.ensureDirectoryExists(GRDBStorage.sharedDatabaseDirectoryPath)
        OWSFileSystem.protectFileOrFolder(atPath: GRDBStorage.sharedDatabaseDirectoryPath)
        
        // Generate the database KeySpec if needed (this MUST be done before we try to access the database
        // as a different thread might attempt to access the database before the key is successfully created)
        //
        // Note: We reset the bytes immediately after generation to ensure the database key doesn't hang
        // around in memory unintentionally
        var tmpKeySpec: Data = GRDBStorage.getOrGenerateDatabaseKeySpec()
        tmpKeySpec.resetBytes(in: 0..<tmpKeySpec.count)
        
        // Configure the database and create the DatabasePool for interacting with the database
        var config = Configuration()
        config.maximumReaderCount = 10  // Increase the max read connection limit - Default is 5
        config.prepareDatabase { db in
            var keySpec: Data = GRDBStorage.getOrGenerateDatabaseKeySpec()
            defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
            // Use a raw key spec, where the 96 hexadecimal digits are provided
            // (i.e. 64 hex for the 256 bit key, followed by 32 hex for the 128 bit salt)
            // using explicit BLOB syntax, e.g.:
            //
            // x'98483C6EB40B6C31A448C22A66DED3B5E5E8D5119CAC8327B655C8B5C483648101010101010101010101010101010101'
            keySpec = try (keySpec.toHexString().data(using: .utf8) ?? { throw GRDBStorageError.invalidKeySpec }())
            keySpec.insert(contentsOf: [120, 39], at: 0)    // "x'" prefix
            keySpec.append(39)                              // "'" suffix
            
            try db.usePassphrase(keySpec)
            
            // According to the SQLCipher docs iOS needs the 'cipher_plaintext_header_size' value set to at least
            // 32 as iOS extends special privileges to the database and needs this header to be in plaintext
            // to determine the file type
            //
            // For more info see: https://www.zetetic.net/sqlcipher/sqlcipher-api/#cipher_plaintext_header_size
            try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32")
        }
        
        // Create the DatabasePool to allow us to connect to the database
        dbPool = try DatabasePool(
            path: "\(GRDBStorage.sharedDatabaseDirectoryPath)/\(GRDBStorage.dbFileName)",
            configuration: config
        )
        
        // Setup and run any required migrations
        migrator = {
            var migrator: DatabaseMigrator = DatabaseMigrator()
            migrations
                .sorted()
                .reduce(into: [[(identifier: TargetMigrations.Identifier, migrations: TargetMigrations.MigrationSet)]]()) { result, next in
                    next.migrations.enumerated().forEach { index, migrationSet in
                        if result.count <= index {
                            result.append([])
                        }

                        result[index] = (result[index] + [(next.identifier, migrationSet)])
                    }
                }
                .compactMap { $0 }
                .forEach { sortedMigrationInfo in
                    sortedMigrationInfo.forEach { migrationInfo in
                        migrationInfo.migrations.forEach { migration in
                            migrator.registerMigration(migrationInfo.identifier, migration: migration)
                        }
                    }
                }
            
            return migrator
        }()
        try! migrator.migrate(dbPool)
        
        GRDBStorage.shared = self   // TODO: Fix this
    }
    
    // MARK: - Security
    
    private static func getDatabaseCipherKeySpec() throws -> Data {
        return try CurrentAppContext().keychainStorage().data(forService: keychainService, key: dbCipherKeySpecKey)
    }
    
    @discardableResult private static func getOrGenerateDatabaseKeySpec() -> Data {
        do {
            var keySpec: Data = try getDatabaseCipherKeySpec()
            defer { keySpec.resetBytes(in: 0..<keySpec.count) }
            
            guard keySpec.count == kSQLCipherKeySpecLength else { throw GRDBStorageError.invalidKeySpec }
            
            return keySpec
        }
        catch {
            print("RAWR \(error.localizedDescription), \((error as? KeychainStorageError)?.code), \(errSecItemNotFound)")
            
            switch (error, (error as? KeychainStorageError)?.code) {
                // TODO: Are there other errors we know about that indicate an invalid keychain?
//                errSecNotAvailable: OSStatus { get } /* No keychain is available. You may need to restart your computer. */
//                public var errSecNoSuchKeychain
                    
                    //errSecInteractionNotAllowed
                    
                case (GRDBStorageError.invalidKeySpec, _):
                    // For these cases it means either the keySpec or the keychain has become corrupt so in order to
                    // get back to a "known good state" and behave like a new install we need to reset the storage
                    // and regenerate the key
                    // TODO: Check what this 'isRunningTests' does (use the approach to check if XCTTestCase exists instead?)
                    if !CurrentAppContext().isRunningTests {
                        // Try to reset app by deleting database.
                        resetAllStorage()
                    }
                    fallthrough
                
                case (_, errSecItemNotFound):
                    // No keySpec was found so we need to generate a new one
                    do {
                        var keySpec: Data = Randomness.generateRandomBytes(kSQLCipherKeySpecLength)
                        defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
                        
                        try CurrentAppContext().keychainStorage().set(data: keySpec, service: keychainService, key: dbCipherKeySpecKey)
                        print("RAWR new keySpec generated and saved")
                        return keySpec
                    }
                    catch {
                        Thread.sleep(forTimeInterval: 15)    // Sleep to allow any background behaviours to complete
                        fatalError("Setting keychain value failed with error: \(error.localizedDescription)")
                    }
                    
                default:
                    // Because we use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, the keychain will be inaccessible
                    // after device restart until device is unlocked for the first time. If the app receives a push
                    // notification, we won't be able to access the keychain to process that notification, so we should
                    // just terminate by throwing an uncaught exception
                    if CurrentAppContext().isMainApp || CurrentAppContext().isInBackground() {
                        let appState: UIApplication.State = CurrentAppContext().reportedApplicationState
                        
                        // In this case we should have already detected the situation earlier and exited gracefully (in the
                        // app delegate) using isDatabasePasswordAccessible, but we want to stop the app running here anyway
                        Thread.sleep(forTimeInterval: 5)    // Sleep to allow any background behaviours to complete
                        fatalError("CipherKeySpec inaccessible. New install or no unlock since device restart?, ApplicationState: \(NSStringForUIApplicationState(appState))")
                    }
                    
                    Thread.sleep(forTimeInterval: 5)    // Sleep to allow any background behaviours to complete
                    fatalError("CipherKeySpec inaccessible; not main app.")
            }
        }
    }
    
    // MARK: - File Management
    
    private static func resetAllStorage() {
        NotificationCenter.default.post(name: .resetStorage, object: nil)
        
        // This might be redundant but in the spirit of thoroughness...
        self.deleteDatabaseFiles()

        try? self.deleteDbKeys()

        if CurrentAppContext().isMainApp {
//            TSAttachmentStream.deleteAttachments()
        }

        // TODO: Delete Profiles on Disk?
    }
    
    private static func deleteDatabaseFiles() {
        OWSFileSystem.deleteFile(databasePath)
        OWSFileSystem.deleteFile(databasePathShm)
        OWSFileSystem.deleteFile(databasePathWal)
    }
    
    private static func deleteDbKeys() throws {
        try CurrentAppContext().keychainStorage().remove(service: keychainService, key: dbCipherKeySpecKey)
    }
    
    // MARK: - Functions
    
    @discardableResult public func write<T>(updates: (Database) throws -> T?) -> T? {
        return try? dbPool.write(updates)
    }
    
    public func writeAsync<T>(updates: @escaping (Database) throws -> T) {
        writeAsync(updates: updates, completion: { _, _ in })
    }
    
    public func writeAsync<T>(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result<T, Error>) throws -> Void) {
        dbPool.asyncWrite(
            updates,
            completion: { db, result in
                try? completion(db, result)
            }
        )
    }
    
    @discardableResult public func read<T>(_ value: (Database) throws -> T?) -> T? {
        return try? dbPool.read(value)
    }
    
    /// Rever to the `ValueObservation.start` method for full documentation
    ///
    /// - parameter observation: The observation to start
    /// - parameter scheduler: A Scheduler. By default, fresh values are
    ///   dispatched asynchronously on the main queue.
    /// - parameter onError: A closure that is provided eventual errors that
    ///   happen during observation
    /// - parameter onChange: A closure that is provided fresh values
    /// - returns: a DatabaseCancellable
    public func start<Reducer: ValueReducer>(
        _ observation: ValueObservation<Reducer>,
        scheduling scheduler: ValueObservationScheduler = .async(onQueue: .main),
        onError: @escaping (Error) -> Void,
        onChange: @escaping (Reducer.Value) -> Void
    ) -> DatabaseCancellable {
        observation.start(
            in: dbPool,
            scheduling: scheduler,
            onError: onError,
            onChange: onChange
        )
    }
    
    public func addObserver(_ observer: TransactionObserver?) {
        guard let observer: TransactionObserver = observer else { return }
        
        dbPool.add(transactionObserver: observer)
    }
}

// MARK: - Promise Extensions

public extension GRDBStorage {
    // FIXME: Would be good to replace these with Swift Combine
    @discardableResult func read<T>(_ value: (Database) throws -> Promise<T>) -> Promise<T> {
        do {
            return try dbPool.read(value)
        }
        catch {
            return Promise(error: error)
        }
    }
    
    @discardableResult func write<T>(updates: (Database) throws -> Promise<T>) -> Promise<T> {
        do {
            return try dbPool.write(updates)
        }
        catch {
            return Promise(error: error)
        }
    }
}