//
//  NetworkUpgradeTests.swift
//  ZcashLightClientKit-Unit-Tests
//
//  Created by Francisco Gindre on 10/30/20.
//

import XCTest
@testable import TestUtils
@testable import ZcashLightClientKit

// swiftlint:disable implicitly_unwrapped_optional type_body_length force_unwrapping
class NetworkUpgradeTests: XCTestCase {
    let activationHeight: BlockHeight = 1028500
    let spendingKey =
        // swiftlint:disable:next line_length
        "secret-extended-key-test1qv2vf437qqqqpqpfc0arpv55ncq33p2p895hlcx0ra6d0g739v93luqdjpxun3kt050j9qnrqjyp8d7fdxgedfyxpjmuyha2ulxa6hmqvm2gnvuc3tvs3enpxwuz768qfkd286vr3jgyrgr5ddx2ukrdl95ak3tzqylzjeqw3pnmgtmwsvemrj3sk6vqgwxm9khlv46wccn33ayw52prr233ea069c9u8m3839dvw30sdf6k32xddhpte6p6qsuxval6usyh6lr55pgypkgtz"

    // TODO: Parameterize this from environment
    let testRecipientAddress = "ztestsapling12k9m98wmpjts2m56wc60qzhgsfvlpxcwah268xk5yz4h942sd58jy3jamqyxjwums6hw7kfa4cc"
    let sendAmount = Zatoshi(1000)
    let branchID = "2bb40e60"
    let chainName = "main"

    var birthday: BlockHeight = 1013250
    var coordinator: TestCoordinator!
    var network = ZcashNetworkBuilder.network(for: .testnet)
    
    override func setUpWithError() throws {
        try super.setUpWithError()
        try coordinator.reset(saplingActivation: birthday, branchID: branchID, chainName: chainName)
    }
    
    override func tearDownWithError() throws {
        try super.tearDownWithError()
        NotificationCenter.default.removeObserver(self)
        try coordinator.stop()
        try? FileManager.default.removeItem(at: coordinator.databases.cacheDB)
        try? FileManager.default.removeItem(at: coordinator.databases.dataDB)
        try? FileManager.default.removeItem(at: coordinator.databases.pendingDB)
    }

    /**
    Given that a wallet had funds prior to activation it can spend them after activation
    */
    func testSpendPriorFundsAfterActivation() async throws {
        try FakeChainBuilder.buildChain(
            darksideWallet: coordinator.service,
            birthday: birthday,
            networkActivationHeight: activationHeight,
            branchID: branchID,
            chainName: chainName,
            length: 15300
        )
        
        let firstSyncExpectation = XCTestExpectation(description: "first sync")
        
        try coordinator.applyStaged(blockheight: activationHeight - ZcashSDK.defaultStaleTolerance)
        sleep(5)
        
        try await withCheckedThrowingContinuation { continuation in
            do {
                try coordinator.sync(completion: { synchronizer in
                    firstSyncExpectation.fulfill()
                    continuation.resume()
                }, error: self.handleError)
            } catch {
                continuation.resume(throwing: error)
            }
        }
        wait(for: [firstSyncExpectation], timeout: 120)
        let verifiedBalance: Zatoshi = coordinator.synchronizer.initializer.getVerifiedBalance()
        guard verifiedBalance > network.constants.defaultFee(for: activationHeight) else {
            XCTFail("not enough balance to continue test")
            return
        }
    
        try coordinator.applyStaged(blockheight: activationHeight + 1)
        sleep(2)
       
        let sendExpectation = XCTestExpectation(description: "send expectation")
        var pendingEntity: PendingTransactionEntity?
        let spendAmount = Zatoshi(10000)

        /*
        send transaction to recipient address
        */
        do {
            let pendingTx = try await coordinator.synchronizer.sendToAddress(
                spendingKey: self.coordinator.spendingKeys!.first!,
                zatoshi: spendAmount,
                toAddress: self.testRecipientAddress,
                memo: "this is a test",
                from: 0)
            pendingEntity = pendingTx
            sendExpectation.fulfill()
        } catch {
            self.handleError(error)
        }
        
        wait(for: [sendExpectation], timeout: 11)
        
        guard pendingEntity != nil else {
            XCTFail("no pending transaction after sending")
            try coordinator.stop()
            return
        }

        /*
        getIncomingTransaction
        */
        guard let incomingTx = try coordinator.getIncomingTransactions()?.first else {
            XCTFail("no incoming transaction")
            try coordinator.stop()
            return
        }
        
        let sentTxHeight: BlockHeight = activationHeight + 2
   
        /*
        stage transaction at sentTxHeight
        */
        try coordinator.stageTransaction(incomingTx, at: sentTxHeight)
        try coordinator.applyStaged(blockheight: activationHeight + 20)
        sleep(1)
        let afterSendExpectation = XCTestExpectation(description: "aftersend")
        
        try await withCheckedThrowingContinuation { continuation in
            do {
                try coordinator.sync(completion: { synchronizer in
                    afterSendExpectation.fulfill()
                    continuation.resume()
                }, error: self.handleError)
            } catch {
                continuation.resume(throwing: error)
            }
        }

        wait(for: [afterSendExpectation], timeout: 10)
        
        XCTAssertEqual(coordinator.synchronizer.initializer.getVerifiedBalance(), verifiedBalance - spendAmount)
    }
    
    /**
    Given that a wallet receives funds after activation it can spend them when confirmed
    */
    func testSpendPostActivationFundsAfterConfirmation() async throws {
        try FakeChainBuilder.buildChainPostActivationFunds(
            darksideWallet: coordinator.service,
            birthday: birthday,
            networkActivationHeight: activationHeight,
            length: 15300
        )
        
        let firstSyncExpectation = XCTestExpectation(description: "first sync")
        
        try coordinator.applyStaged(blockheight: activationHeight + 10)
        sleep(3)
        
        try await withCheckedThrowingContinuation { continuation in
            do {
                try coordinator.sync(completion: { synchronizer in
                    firstSyncExpectation.fulfill()
                    continuation.resume()
                }, error: self.handleError)
            } catch {
                continuation.resume(throwing: error)
            }
        }
        
        wait(for: [firstSyncExpectation], timeout: 120)
        guard try !coordinator.synchronizer.allReceivedTransactions().filter({ $0.minedHeight > activationHeight }).isEmpty else {
            XCTFail("this test requires funds received after activation height")
            return
        }
        
        try coordinator.applyStaged(blockheight: activationHeight + 20)
        sleep(2)
        
        let sendExpectation = XCTestExpectation(description: "send expectation")
        var pendingEntity: PendingTransactionEntity?
        let spendAmount = Zatoshi(10000)

        /*
        send transaction to recipient address
        */
        do {
            let pendingTx = try await coordinator.synchronizer.sendToAddress(
                spendingKey: self.coordinator.spendingKeys!.first!,
                zatoshi: spendAmount,
                toAddress: self.testRecipientAddress,
                memo: "this is a test",
                from: 0)
            pendingEntity = pendingTx
            sendExpectation.fulfill()
        } catch {
            self.handleError(error)
        }
        
        wait(for: [sendExpectation], timeout: 11)
        
        guard pendingEntity != nil else {
            XCTFail("no pending transaction after sending")
            try coordinator.stop()
            return
        }
        
        try coordinator.applyStaged(blockheight: activationHeight + 1 + 10)
        
        let afterSendExpectation = XCTestExpectation(description: "aftersend")
        
        try await withCheckedThrowingContinuation { continuation in
            do {
                try coordinator.sync(completion: { synchronizer in
                    afterSendExpectation.fulfill()
                    continuation.resume()
                }, error: self.handleError)
            } catch {
                continuation.resume(throwing: error)
            }
        }
        
        wait(for: [afterSendExpectation], timeout: 10)
    }

    /**
    Given that a wallet sends funds some between (activation - expiry_height) and activation, those funds are shown as sent if mined.
    */
    func testSpendMinedSpendThatExpiresOnActivation() async throws {
        try FakeChainBuilder.buildChain(
            darksideWallet: coordinator.service,
            birthday: birthday,
            networkActivationHeight: activationHeight,
            branchID: branchID,
            chainName: chainName,
            length: 15300
        )
        
        let firstSyncExpectation = XCTestExpectation(description: "first sync")
        
        try coordinator.applyStaged(blockheight: activationHeight - 10)
        sleep(3)
        
        try await withCheckedThrowingContinuation { continuation in
            do {
                try coordinator.sync(completion: { synchronizer in
                    firstSyncExpectation.fulfill()
                    continuation.resume()
                }, error: self.handleError)
            } catch {
                continuation.resume(throwing: error)
            }
        }
        
        wait(for: [firstSyncExpectation], timeout: 120)
        let verifiedBalance: Zatoshi = coordinator.synchronizer.initializer.getVerifiedBalance()
        XCTAssertTrue(verifiedBalance > network.constants.defaultFee(for: activationHeight))
        
        let sendExpectation = XCTestExpectation(description: "send expectation")
        var pendingEntity: PendingTransactionEntity?
        let spendAmount = Zatoshi(10000)

        /*
        send transaction to recipient address
        */
        do {
            let pendingTx = try await coordinator.synchronizer.sendToAddress(
                spendingKey: self.coordinator.spendingKeys!.first!,
                zatoshi: spendAmount,
                toAddress: self.testRecipientAddress,
                memo: "this is a test",
                from: 0)
            pendingEntity = pendingTx
            sendExpectation.fulfill()
        } catch {
            self.handleError(error)
        }
        
        wait(for: [sendExpectation], timeout: 11)
        
        guard let pendingTx = pendingEntity else {
            XCTFail("no pending transaction after sending")
            try coordinator.stop()
            return
        }
        
        /*
        getIncomingTransaction
        */
        guard let incomingTx = try coordinator.getIncomingTransactions()?.first else {
            XCTFail("no incoming transaction")
            try coordinator.stop()
            return
        }
        
        let sentTxHeight: BlockHeight = activationHeight - 5
        
        /*
        stage transaction at sentTxHeight
        */
        try coordinator.stageTransaction(incomingTx, at: sentTxHeight)
        
        try coordinator.applyStaged(blockheight: activationHeight + 5)
        sleep(2)
        
        let afterSendExpectation = XCTestExpectation(description: "aftersend")
        
        try await withCheckedThrowingContinuation { continuation in
            do {
                try coordinator.sync(completion: { synchronizer in
                    afterSendExpectation.fulfill()
                    continuation.resume()
                }, error: self.handleError)
            } catch {
                continuation.resume(throwing: error)
            }
        }
        
        wait(for: [afterSendExpectation], timeout: 10)
        
        guard
            let confirmedTx = try coordinator.synchronizer.allConfirmedTransactions(from: nil, limit: Int.max)?
                .first(where: { $0.rawTransactionId == pendingTx.rawTransactionId })
        else {
            XCTFail("the sent transaction is not listed as a confirmed transaction")
            return
        }
        
        XCTAssertEqual(confirmedTx.minedHeight, sentTxHeight)
    }
    
    /**
    Given that a wallet sends funds somewhere between (activation - expiry_height) and activation, those funds are available if  expired after expiration height.
    */
    func testExpiredSpendAfterActivation() async throws {
        try FakeChainBuilder.buildChain(
            darksideWallet: coordinator.service,
            birthday: birthday,
            networkActivationHeight: activationHeight,
            branchID: branchID,
            chainName: chainName,
            length: 15300
        )
        
        let firstSyncExpectation = XCTestExpectation(description: "first sync")
        let offset = 5
        try coordinator.applyStaged(blockheight: activationHeight - 10)
        sleep(3)
        
        let verifiedBalancePreActivation: Zatoshi = coordinator.synchronizer.initializer.getVerifiedBalance()
        
        try await withCheckedThrowingContinuation { continuation in
            do {
                try coordinator.sync(completion: { synchronizer in
                    firstSyncExpectation.fulfill()
                    continuation.resume()
                }, error: self.handleError)
            } catch {
                continuation.resume(throwing: error)
            }
        }
        
        wait(for: [firstSyncExpectation], timeout: 120)
        let verifiedBalance: Zatoshi = coordinator.synchronizer.initializer.getVerifiedBalance()
        guard verifiedBalance > network.constants.defaultFee(for: activationHeight) else {
            XCTFail("balance is not enough to continue with this test")
            return
        }
        
        let sendExpectation = XCTestExpectation(description: "send expectation")
        var pendingEntity: PendingTransactionEntity?
        let spendAmount = Zatoshi(10000)

        /*
        send transaction to recipient address
        */
        do {
            let pendingTx = try await coordinator.synchronizer.sendToAddress(
                spendingKey: self.coordinator.spendingKeys!.first!,
                zatoshi: spendAmount,
                toAddress: self.testRecipientAddress,
                memo: "this is a test",
                from: 0)
            pendingEntity = pendingTx
            sendExpectation.fulfill()
        } catch {
            self.handleError(error)
        }
        
        wait(for: [sendExpectation], timeout: 11)
        
        guard let pendingTx = pendingEntity else {
            XCTFail("no pending transaction after sending")
            try coordinator.stop()
            return
        }
        
        /*
        getIncomingTransaction
        */
        guard try coordinator.getIncomingTransactions()?.first != nil else {
            XCTFail("no incoming transaction")
            try coordinator.stop()
            return
        }
            
        /*
        don't stage transaction
        */
        try coordinator.applyStaged(blockheight: activationHeight + offset)
        sleep(2)
        
        let afterSendExpectation = XCTestExpectation(description: "aftersend")
        
        try await withCheckedThrowingContinuation { continuation in
            do {
                try coordinator.sync(completion: { synchronizer in
                    afterSendExpectation.fulfill()
                    continuation.resume()
                }, error: self.handleError)
            } catch {
                continuation.resume(throwing: error)
            }
        }
        
        wait(for: [afterSendExpectation], timeout: 10)
        
        guard
            try coordinator.synchronizer.allConfirmedTransactions(from: nil, limit: Int.max)?
                .first(where: { $0.rawTransactionId == pendingTx.rawTransactionId }) == nil
        else {
            XCTFail("the sent transaction should not be not listed as a confirmed transaction")
            return
        }
        
        XCTAssertEqual(verifiedBalancePreActivation, coordinator.synchronizer.initializer.getVerifiedBalance())
    }
    
    /**
    Given that a wallet has notes both received prior and after activation these can be combined to supply a larger amount spend.
    */
    func testCombinePreActivationNotesAndPostActivationNotesOnSpend() async throws {
        try FakeChainBuilder.buildChainMixedFunds(
            darksideWallet: coordinator.service,
            birthday: birthday,
            networkActivationHeight: activationHeight,
            branchID: branchID,
            chainName: chainName,
            length: 15300
        )
        
        let firstSyncExpectation = XCTestExpectation(description: "first sync")
        
        try coordinator.applyStaged(blockheight: activationHeight - 1)
        sleep(3)
        
        try await withCheckedThrowingContinuation { continuation in
            do {
                try coordinator.sync(completion: { synchronizer in
                    firstSyncExpectation.fulfill()
                    continuation.resume()
                }, error: self.handleError)
            } catch {
                continuation.resume(throwing: error)
            }
        }
        
        wait(for: [firstSyncExpectation], timeout: 120)
        
        let preActivationBalance: Zatoshi = coordinator.synchronizer.initializer.getVerifiedBalance()
        
        try coordinator.applyStaged(blockheight: activationHeight + 30)
        sleep(2)
        
        let secondSyncExpectation = XCTestExpectation(description: "second sync")
        try await withCheckedThrowingContinuation { continuation in
            do {
                try coordinator.sync(completion: { synchronizer in
                    secondSyncExpectation.fulfill()
                    continuation.resume()
                }, error: self.handleError)
            } catch {
                continuation.resume(throwing: error)
            }
        }

        wait(for: [secondSyncExpectation], timeout: 10)
        guard try !coordinator.synchronizer.allReceivedTransactions().filter({ $0.minedHeight > activationHeight }).isEmpty else {
            XCTFail("this test requires funds received after activation height")
            return
        }
        let postActivationBalance: Zatoshi = coordinator.synchronizer.initializer.getVerifiedBalance()
        
        XCTAssertTrue(preActivationBalance < postActivationBalance, "This test requires that funds post activation are greater that pre activation")
        let sendExpectation = XCTestExpectation(description: "send expectation")
        var pendingEntity: PendingTransactionEntity?
        
        // spend all the funds
        let spendAmount = Zatoshi(
            postActivationBalance.amount - Int64(network.constants.defaultFee(for: activationHeight).amount)
        )
        
        /*
        send transaction to recipient address
        */
        do {
            let pendingTx = try await coordinator.synchronizer.sendToAddress(
                spendingKey: self.coordinator.spendingKeys!.first!,
                zatoshi: spendAmount,
                toAddress: self.testRecipientAddress,
                memo: "this is a test",
                from: 0)
            pendingEntity = pendingTx
            sendExpectation.fulfill()
        } catch {
            self.handleError(error)
        }
        
        wait(for: [sendExpectation], timeout: 15)
        
        guard pendingEntity != nil else {
            XCTFail("no pending transaction after sending")
            try coordinator.stop()
            return
        }
        
        XCTAssertEqual(coordinator.synchronizer.initializer.getVerifiedBalance(), .zero)
    }
 
    func handleError(_ error: Error?) {
        _ = try? coordinator.stop()
        guard let testError = error else {
            XCTFail("failed with nil error")
            return
        }
        XCTFail("Failed with error: \(testError)")
    }
}
