//
//  Initializer.swift
//  ZcashLightClientKit
//
//  Created by Francisco Gindre on 13/09/2019.
//  Copyright © 2019 Electric Coin Company. All rights reserved.
//

import Foundation

/**
Wrapper for the Rust backend. This class basically represents all the Rust-wallet
capabilities and the supporting data required to exercise those abilities.
*/
public enum InitializerError: Error {
    case cacheDbInitFailed(Error)
    case dataDbInitFailed(Error)
    case accountInitFailed(Error)
    case invalidViewingKey(key: String)
}

/**
Represents a lightwallet instance endpoint to connect to
*/
public struct LightWalletEndpoint {
    public var host: String
    public var port: Int
    public var secure: Bool
    public var singleCallTimeoutInMillis: Int64
    public var streamingCallTimeoutInMillis: Int64
    
    /**
    initializes a LightWalletEndpoint
    - Parameters:
        - address: a String containing the host address
        - port: string with the port of the host address
        - secure: true if connecting through TLS. Default value is true
        - singleCallTimeoutInMillis: timeout for single calls in Milliseconds. Default 30 seconds
        - streamingCallTimeoutInMillis: timeout for streaming calls in Milliseconds. Default 100 seconds
    */
    public init(
        address: String,
        port: Int,
        secure: Bool = true,
        singleCallTimeoutInMillis: Int64 = 30000,
        streamingCallTimeoutInMillis: Int64 = 100000
    ) {
        self.host = address
        self.port = port
        self.secure = secure
        self.singleCallTimeoutInMillis = singleCallTimeoutInMillis
        self.streamingCallTimeoutInMillis = streamingCallTimeoutInMillis
    }
}

/**
Wrapper for all the Rust backend functionality that does not involve processing blocks. This
class initializes the Rust backend and the supporting data required to exercise those abilities.
The [cash.z.wallet.sdk.block.CompactBlockProcessor] handles all the remaining Rust backend
functionality, related to processing blocks.
*/
public class Initializer {

    public enum InitializationResult {
        case success
        case seedRequired
    }

    private(set) var rustBackend: ZcashRustBackendWelding.Type
    private(set) var alias: String
    private(set) var endpoint: LightWalletEndpoint
    
    private var lowerBoundHeight: BlockHeight
    private(set) var cacheDbURL: URL
    private(set) var dataDbURL: URL
    private(set) var pendingDbURL: URL
    private(set) var spendParamsURL: URL
    private(set) var outputParamsURL: URL
    private(set) var lightWalletService: LightWalletService
    private(set) var transactionRepository: TransactionRepository
    private(set) var accountRepository: AccountRepository
    private(set) var storage: CompactBlockStorage
    private(set) var downloader: CompactBlockDownloader
    private(set) var network: ZcashNetwork
    private(set) public var viewingKeys: [UnifiedFullViewingKey]
    /// The effective birthday of the wallet based on the height provided when initializing
    /// and the checkpoints available on this SDK
    private(set) public var walletBirthday: BlockHeight

    /**
    Constructs the Initializer
    - Parameters:
        - cacheDbURL: location of the compact blocks cache db
        - dataDbURL: Location of the data db
        - pendingDbURL: location of the pending transactions database
        - endpoint: the endpoint representing the lightwalletd instance you want to point to
        - spendParamsURL: location of the spend parameters
        - outputParamsURL: location of the output parameters
    */
    convenience public init (
        cacheDbURL: URL,
        dataDbURL: URL,
        pendingDbURL: URL,
        endpoint: LightWalletEndpoint,
        network: ZcashNetwork,
        spendParamsURL: URL,
        outputParamsURL: URL,
        viewingKeys: [UnifiedFullViewingKey],
        walletBirthday: BlockHeight,
        alias: String = "",
        loggerProxy: Logger? = nil
    ) {
        let lwdService = LightWalletGRPCService(endpoint: endpoint)
        
        self.init(
            rustBackend: ZcashRustBackend.self,
            lowerBoundHeight: walletBirthday,
            network: network,
            cacheDbURL: cacheDbURL,
            dataDbURL: dataDbURL,
            pendingDbURL: pendingDbURL,
            endpoint: endpoint,
            service: lwdService,
            repository: TransactionRepositoryBuilder.build(dataDbURL: dataDbURL),
            accountRepository: AccountRepositoryBuilder.build(
                dataDbURL: dataDbURL,
                readOnly: true,
                caching: true
            ),
            storage: CompactBlockStorage(url: cacheDbURL, readonly: false),
            spendParamsURL: spendParamsURL,
            outputParamsURL: outputParamsURL,
            viewingKeys: viewingKeys,
            walletBirthday: walletBirthday,
            alias: alias,
            loggerProxy: loggerProxy
        )
    }
    
    /**
    Internal for dependency injection purposes
    */
    init(
        rustBackend: ZcashRustBackendWelding.Type,
        lowerBoundHeight: BlockHeight,
        network: ZcashNetwork,
        cacheDbURL: URL,
        dataDbURL: URL,
        pendingDbURL: URL,
        endpoint: LightWalletEndpoint,
        service: LightWalletService,
        repository: TransactionRepository,
        accountRepository: AccountRepository,
        storage: CompactBlockStorage,
        spendParamsURL: URL,
        outputParamsURL: URL,
        viewingKeys: [UnifiedFullViewingKey],
        walletBirthday: BlockHeight,
        alias: String = "",
        loggerProxy: Logger? = nil
    ) {
        logger = loggerProxy
        self.rustBackend = rustBackend
        self.lowerBoundHeight = lowerBoundHeight
        self.cacheDbURL = cacheDbURL
        self.dataDbURL = dataDbURL
        self.pendingDbURL = pendingDbURL
        self.endpoint = endpoint
        self.spendParamsURL = spendParamsURL
        self.outputParamsURL = outputParamsURL
        self.alias = alias
        self.lightWalletService = service
        self.transactionRepository = repository
        self.accountRepository = accountRepository
        self.storage = storage
        self.downloader = CompactBlockDownloader(service: service, storage: storage)
        self.viewingKeys = viewingKeys
        self.walletBirthday = walletBirthday
        self.network = network
    }

    /// Initialize the wallet. The ZIP-32 seed bytes can optionally be passed to perform
    /// database migrations. most of the times the seed won't be needed. If they do and are
    /// not provided this will fail with `InitializationResult.seedRequired`. It could
    /// be the case that this method is invoked by a wallet that does not contain the seed phrase
    /// and is view-only, or by a wallet that does have the seed but the process does not have the
    /// consent of the OS to fetch the keys from the secure storage, like on background tasks.
    ///
    /// 'cache.db' and 'data.db' files are created by this function (if they
    /// do not already exist). These files can be given a prefix for scenarios where multiple wallets
    ///
    /// - Parameter seed: ZIP-32 Seed bytes for the wallet that will be initialized
    /// - Throws: `InitializerError.dataDbInitFailed` if the creation of the dataDb fails
    /// `InitializerError.accountInitFailed` if the account table can't be initialized. 
    public func initialize(with seed: [UInt8]?) throws -> InitializationResult {
        do {
            try storage.createTable()
        } catch {
            throw InitializerError.cacheDbInitFailed(error)
        }
        
        do {
            if case .seedRequired = try rustBackend.initDataDb(dbData: dataDbURL, seed: seed, networkType: network.networkType) {
                return .seedRequired
            }
        } catch {
            throw InitializerError.dataDbInitFailed(error)
        }

        let checkpoint = Checkpoint.birthday(with: self.walletBirthday, network: network)
        do {
            try rustBackend.initBlocksTable(
                dbData: dataDbURL,
                height: Int32(checkpoint.height),
                hash: checkpoint.hash,
                time: checkpoint.time,
                saplingTree: checkpoint.saplingTree,
                networkType: network.networkType
            )
        } catch RustWeldingError.dataDbNotEmpty {
            // this is fine
        } catch {
            throw InitializerError.dataDbInitFailed(error)
        }
        self.walletBirthday = checkpoint.height
        
        let lastDownloaded = (try? downloader.storage.latestHeight()) ?? walletBirthday
        // resume from last downloaded block
        lowerBoundHeight = max(walletBirthday, lastDownloaded)
 
        do {
            try rustBackend.initAccountsTable(
                dbData: dataDbURL,
                ufvks: viewingKeys,
                networkType: network.networkType
            )
        } catch RustWeldingError.dataDbNotEmpty {
            // this is fine
        } catch RustWeldingError.malformedStringInput {
            throw RustWeldingError.malformedStringInput
        } catch {
            throw InitializerError.accountInitFailed(error)
        }

        let migrationManager = MigrationManager(
            cacheDbConnection: SimpleConnectionProvider(path: cacheDbURL.path),
            pendingDbConnection: SimpleConnectionProvider(path: pendingDbURL.path),
            networkType: self.network.networkType
        )

        try migrationManager.performMigration()

        return .success
    }

    /// get (unverified) balance from the given account index
    /// - Parameter account: the index of the account
    @available(*, deprecated, message: "This function will be removed soon. Use the function returning `Zatoshi` instead")
    public func getBalance(account index: Int = 0) -> Int64 {
        rustBackend.getBalance(dbData: dataDbURL, account: Int32(index), networkType: network.networkType)
    }


    /// get (unverified) balance from the given account index
    /// - Parameter account: the index of the account
    /// - Returns: balance in `Zatoshi`
    public func getBalance(account index: Int = 0) -> Zatoshi {
        Zatoshi(
            rustBackend.getBalance(
                dbData: dataDbURL,
                account: Int32(index),
                networkType: network.networkType
            )
        )
    }

    /// get verified balance from the given account index
    /// - Parameter account: the index of the account
    @available(*, deprecated, message: "This function will be removed soon. Use the one returning `Zatoshi` instead")
    public func getVerifiedBalance(account index: Int = 0) -> Int64 {
        rustBackend.getVerifiedBalance(dbData: dataDbURL, account: Int32(index), networkType: network.networkType)
    }

    /// get verified balance from the given account index
    /// - Parameter account: the index of the account
    /// - Returns: balance in `Zatoshi`
    public func getVerifiedBalance(account index: Int = 0) -> Zatoshi {
        Zatoshi(
            rustBackend.getVerifiedBalance(
                dbData: dataDbURL,
                account: Int32(index),
                networkType: network.networkType
            )
        )
    }
    
    /**
    checks if the provided address is a valid sapling address
    */
    public func isValidSaplingAddress(_ address: String) -> Bool {
        rustBackend.isValidSaplingAddress(address, networkType: network.networkType)
    }

    /**
    checks if the provided address is a transparent zAddress
    */
    public func isValidTransparentAddress(_ address: String) -> Bool {
        rustBackend.isValidTransparentAddress(address, networkType: network.networkType)
    }
    
    func isSpendParameterPresent() -> Bool {
        FileManager.default.isReadableFile(atPath: self.spendParamsURL.path)
    }
    
    func isOutputParameterPresent() -> Bool {
        FileManager.default.isReadableFile(atPath: self.outputParamsURL.path)
    }
    
    @discardableResult
    func downloadParametersIfNeeded() async throws -> Bool {
        let spendParameterPresent = isSpendParameterPresent()
        let outputParameterPresent = isOutputParameterPresent()
        
        if spendParameterPresent && outputParameterPresent {
            return true
        }
        
        let outputURL = self.outputParamsURL
        let spendURL = self.spendParamsURL
        
        do {
            if !outputParameterPresent && !spendParameterPresent {
                async let outputURLRequest = SaplingParameterDownloader.downloadOutputParameter(outputURL)
                async let spendURLRequest = SaplingParameterDownloader.downloadSpendParameter(spendURL)
                _ = try await [outputURLRequest, spendURLRequest]
                return false
            } else if !outputParameterPresent {
                try await SaplingParameterDownloader.downloadOutputParameter(outputURL)
                return false
            } else if !spendParameterPresent {
                try await SaplingParameterDownloader.downloadSpendParameter(spendURL)
                return false
            }
        } catch {
            throw error
        }
        return true
    }
}

enum CompactBlockProcessorBuilder {
    // swiftlint:disable:next function_parameter_count
    static func buildProcessor(
        configuration: CompactBlockProcessor.Configuration,
        service: LightWalletService,
        storage: CompactBlockStorage,
        transactionRepository: TransactionRepository,
        accountRepository: AccountRepository,
        backend: ZcashRustBackendWelding.Type
    ) -> CompactBlockProcessor {
        return CompactBlockProcessor(
            service: service,
            storage: storage,
            backend: backend,
            config: configuration,
            repository: transactionRepository,
            accountRepository: accountRepository
        )
    }
}


extension InitializerError: LocalizedError {
    public var errorDescription: String? {
        switch self {
        case .invalidViewingKey:
            return "The provided viewing key is invalid"
        case .cacheDbInitFailed(let error):
            return "cacheDb Init failed with error: \(error.localizedDescription)"
        case .dataDbInitFailed(let error):
            return "dataDb init failed with error: \(error.localizedDescription)"
        case .accountInitFailed(let error):
            return "account table init failed with error: \(error.localizedDescription)"
        }
    }
}
