//  Copyright (c) 2020 650 Industries, Inc. All rights reserved.

import ExpoModulesTestCore

@testable import EXUpdates

import EXManifests

class MockErrorRecoveryDelegate: ErrorRecoveryDelegate {
  public enum Method {
    case relaunch
    case loadRemoteUpdate
    case markFailedLaunchForLaunchedUpdate
    case markSuccessfulLaunchForLaunchedUpdate
    case throwException
  }
  
  public var config: EXUpdates.UpdatesConfig
  public var launchedUpdateToReturn: EXUpdates.Update? = nil
  public var remoteLoadStatus: EXUpdates.RemoteLoadStatus = .Idle
  
  private let relaunchCompletionParams: (Error?, Bool)
  
  init(config: UpdatesConfig, relaunchCompletionParams: (Error?, Bool)) {
    self.config = config
    self.relaunchCompletionParams = relaunchCompletionParams
  }
  
  func launchedUpdate() -> EXUpdates.Update? {
    return launchedUpdateToReturn
  }
  
  private var callRecord: [Method: Int] = [:]
  public func verify(_ method: Method, times: Int = 1) {
    let nTimes = self.callRecord[method] ?? 0
    expect(nTimes).to(equal(times), description: "Method \(method) called \(nTimes) times, expected \(times)")
  }
  public func never(_ method: Method) {
    verify(method, times: 0)
  }
  private func recordCall(method: Method) {
    guard let currentCount = callRecord[method] else {
      callRecord[method] = 1
      return
    }
    callRecord[method] = currentCount + 1
  }
  
  func relaunch(completion: (Error?, Bool) -> Void) {
    recordCall(method: .relaunch)
    completion(relaunchCompletionParams.0, relaunchCompletionParams.1)
  }
  
  func loadRemoteUpdate() {
    recordCall(method: .loadRemoteUpdate)
  }
  
  func markFailedLaunchForLaunchedUpdate() {
    recordCall(method: .markFailedLaunchForLaunchedUpdate)
  }
  
  func markSuccessfulLaunchForLaunchedUpdate() {
    recordCall(method: .markSuccessfulLaunchForLaunchedUpdate)
  }
  
  func throwException(_ exception: NSException) {
    recordCall(method: .throwException)
  }
}

private extension DispatchQueue {
  func flush() {
    self.sync {
      // flush queue
    }
  }
}

class ErrorRecoverySpec : ExpoSpec {
  override func spec() {
    func setUp() -> (DispatchQueue, ErrorRecovery) {
      let testQueue = DispatchQueue(label: "expo.errorRecoveryTestQueue")
      return (testQueue, ErrorRecovery(errorRecoveryQueue: testQueue, diskWriteQueue: testQueue, remoteLoadTimeout: 500))
    }
    
    describe("handleError") {
      it("NewWorkingUpdateAlreadyLoaded") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .NewUpdateLoaded
        errorRecovery.delegate = mockDelegate
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        testQueue.flush()
        
        mockDelegate.verify(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.verify(.relaunch)
        mockDelegate.never(.loadRemoteUpdate)
        mockDelegate.never(.throwException)
      }
      
      it("NewWorkingUpdateAlreadyLoaded_RCTContentDidAppear") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .NewUpdateLoaded
        errorRecovery.delegate = mockDelegate
        
        errorRecovery.startMonitoring()
        NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil)
        
        mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate)
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        testQueue.flush()
        
        mockDelegate.never(.relaunch)
        mockDelegate.never(.loadRemoteUpdate)
        mockDelegate.verify(.throwException)
      }
      
      it("NewUpdateLoaded_RelaunchFails") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (NSError(domain: "huh", code: 123), false)
        )
        mockDelegate.remoteLoadStatus = .NewUpdateLoaded
        errorRecovery.delegate = mockDelegate
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        testQueue.flush()
        testQueue.flush()
        
        mockDelegate.verify(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.verify(.relaunch)
        mockDelegate.never(.loadRemoteUpdate)
        mockDelegate.verify(.throwException)
      }
      
      // TODO(eric): make these tests less flaky on CI and reenable them
      xit("NewWorkingUpdateLoading") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .Loading
        errorRecovery.delegate = mockDelegate
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        
        mockDelegate.remoteLoadStatus = .NewUpdateLoaded
        errorRecovery.notify(newRemoteLoadStatus: .NewUpdateLoaded)
        
        testQueue.flush()
        
        mockDelegate.verify(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.verify(.relaunch)
        mockDelegate.never(.loadRemoteUpdate)
        mockDelegate.never(.throwException)
      }
      
      xit("NewWorkingUpdateLoading_RCTContentDidAppear") {
        let (testQueue, errorRecovery) = setUp()
        // should wait a short window for new update to load, then crash
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .Loading
        errorRecovery.delegate = mockDelegate
        
        errorRecovery.startMonitoring()
        NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil)
        mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate)
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        
        // make sure we're waiting
        Thread.sleep(forTimeInterval: 0.2)
        testQueue.flush()
        // don't throw yet!
        mockDelegate.never(.throwException)
        
        mockDelegate.remoteLoadStatus = .NewUpdateLoaded
        errorRecovery.notify(newRemoteLoadStatus: .NewUpdateLoaded)
        testQueue.flush()
        
        mockDelegate.never(.relaunch)
        mockDelegate.verify(.throwException)
      }
      
      it("NewBrokenUpdateLoaded_WorkingUpdateCached") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .NewUpdateLoaded
        errorRecovery.delegate = mockDelegate
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        testQueue.flush()
        
        mockDelegate.verify(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.verify(.relaunch)
        
        let error2 = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error2)
        testQueue.flush()
        
        mockDelegate.verify(.markFailedLaunchForLaunchedUpdate, times: 2)
        mockDelegate.verify(.relaunch, times: 2)
        mockDelegate.never(.loadRemoteUpdate)
        mockDelegate.never(.throwException)
      }
      
      it("NewBrokenUpdateLoaded_UpdateAlreadyLaunchedSuccessfully") {
        let (testQueue, errorRecovery) = setUp()
        // if an update has already been launched successfully, we don't want to fall back to an older update
        
        let config = UpdatesConfig.config(fromDictionary: [:])
        let database = UpdatesDatabase()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: config,
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .NewUpdateLoaded
        
        let mockUpdate = Update(
          manifest: ManifestFactory.manifest(forManifestJSON: [:]),
          config: config,
          database: database,
          updateId: UUID(),
          scopeKey: "wat",
          commitTime: Date(),
          runtimeVersion: "1.0",
          keep: true,
          status: .Status0_Unused,
          isDevelopmentMode: false,
          assetsFromManifest: []
        )
        mockUpdate.successfulLaunchCount = 1
        
        mockDelegate.launchedUpdateToReturn = mockUpdate
        errorRecovery.delegate = mockDelegate
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        testQueue.flush()
        
        mockDelegate.never(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.verify(.relaunch)
        
        mockUpdate.successfulLaunchCount = 0
        let error2 = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error2)
        testQueue.flush()
        
        mockDelegate.verify(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.verify(.throwException)
        mockDelegate.never(.loadRemoteUpdate)
      }
      
      // TODO(eric): make these tests less flaky on CI and reenable them
      xit("RemoteLoadTimesOut") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .Loading
        errorRecovery.delegate = mockDelegate
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        
        // wait for more than 500ms
        Thread.sleep(forTimeInterval: 0.6)
        testQueue.flush()
        
        mockDelegate.verify(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.verify(.relaunch)
        mockDelegate.never(.loadRemoteUpdate)
        mockDelegate.never(.throwException)
      }
      
      xit("RemoteLoadTimesOut_UpdateAlreadyLaunchedSuccessfully") {
        let (testQueue, errorRecovery) = setUp()
        // if an update has already been launched successfully, we don't want to fall back to an older update
        let config = UpdatesConfig.config(fromDictionary: [:])
        let database = UpdatesDatabase()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: config,
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .Loading
        errorRecovery.delegate = mockDelegate
        
        let mockUpdate = Update(
          manifest: ManifestFactory.manifest(forManifestJSON: [:]),
          config: config,
          database: database,
          updateId: UUID(),
          scopeKey: "wat",
          commitTime: Date(),
          runtimeVersion: "1.0",
          keep: true,
          status: .Status0_Unused,
          isDevelopmentMode: false,
          assetsFromManifest: []
        )
        mockUpdate.successfulLaunchCount = 1
        
        mockDelegate.launchedUpdateToReturn = mockUpdate
        errorRecovery.delegate = mockDelegate
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        
        // wait for more than 500ms
        Thread.sleep(forTimeInterval: 0.6)
        testQueue.flush()
        
        mockDelegate.verify(.throwException)
        mockDelegate.never(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.never(.relaunch)
        mockDelegate.never(.loadRemoteUpdate)
      }
      
      xit("RemoteLoadTimesOut_RCTContentDidAppear") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .Loading
        errorRecovery.delegate = mockDelegate
        
        errorRecovery.startMonitoring()
        NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil)
        mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate)
        
        // if RCTContentDidAppear has already fired, we don't want to roll back to an older update
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        
        // wait for more than 500ms
        Thread.sleep(forTimeInterval: 0.6)
        testQueue.flush()
        
        mockDelegate.verify(.throwException)
        mockDelegate.never(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.never(.relaunch)
        mockDelegate.never(.loadRemoteUpdate)
      }
      
      it("NoRemoteUpdate") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .Idle
        errorRecovery.delegate = mockDelegate
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        testQueue.flush()
        
        mockDelegate.verify(.markFailedLaunchForLaunchedUpdate)
        // should try to load a remote update since we don't have one already
        mockDelegate.verify(.loadRemoteUpdate)
        
        // indicate there isn't a new update from the server
        errorRecovery.notify(newRemoteLoadStatus: .Idle)
        testQueue.flush()
        mockDelegate.verify(.relaunch)
      }
      
      it("NoRemoteUpdate_RCTContentDidAppear") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .Idle
        errorRecovery.delegate = mockDelegate
        
        errorRecovery.startMonitoring()
        NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil)
        mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate)
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        testQueue.flush()
        
        // should try to load a remote update since we don't have one already
        mockDelegate.verify(.loadRemoteUpdate)
        
        // indicate there isn't a new update from the server
        errorRecovery.notify(newRemoteLoadStatus: .Idle)
        testQueue.flush()
        mockDelegate.verify(.throwException)
      }
      
      it("CheckAutomaticallyNever") {
        let (testQueue, errorRecovery) = setUp()
        let config = UpdatesConfig.config(fromDictionary: [
          UpdatesConfig.EXUpdatesConfigCheckOnLaunchKey: UpdatesConfig.EXUpdatesConfigCheckOnLaunchValueNever
        ])
        let mockDelegate = MockErrorRecoveryDelegate(
          config: config,
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .Idle
        errorRecovery.delegate = mockDelegate
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        testQueue.flush()
        
        mockDelegate.verify(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.verify(.relaunch)
      }
      
      it("CheckAutomaticallyNever_RCTContentDidAppear") {
        let (testQueue, errorRecovery) = setUp()
        let config = UpdatesConfig.config(fromDictionary: [
          UpdatesConfig.EXUpdatesConfigCheckOnLaunchKey: UpdatesConfig.EXUpdatesConfigCheckOnLaunchValueNever
        ])
        let mockDelegate = MockErrorRecoveryDelegate(
          config: config,
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .Idle
        errorRecovery.delegate = mockDelegate
        
        errorRecovery.startMonitoring()
        NotificationCenter.default.post(name: NSNotification.Name.RCTContentDidAppear, object: nil)
        mockDelegate.verify(.markSuccessfulLaunchForLaunchedUpdate)
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        testQueue.flush()
        
        mockDelegate.verify(.throwException)
      }
    }
    
    describe("multiple errors") {
      it("handles two errors") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .Idle
        errorRecovery.delegate = mockDelegate
        
        let error = NSError(domain: "wat", code: 1)
        errorRecovery.handle(error: error)
        errorRecovery.handle(error: error)
        testQueue.flush()
        
        // the actual error recovery should only happen once despite there being two errors
        mockDelegate.verify(.loadRemoteUpdate, times: 1)
      }
    }
    
    describe("exceptions") {
      it("handles exceptions") {
        let (testQueue, errorRecovery) = setUp()
        let mockDelegate = MockErrorRecoveryDelegate(
          config: UpdatesConfig.config(fromDictionary: [:]),
          relaunchCompletionParams: (nil, true)
        )
        mockDelegate.remoteLoadStatus = .NewUpdateLoaded
        errorRecovery.delegate = mockDelegate
        
        let testException = NSException(name: NSExceptionName.genericException, reason: "wat")
        errorRecovery.handle(exception: testException)
        testQueue.flush()
        
        mockDelegate.verify(.markFailedLaunchForLaunchedUpdate)
        mockDelegate.verify(.relaunch)
        mockDelegate.never(.loadRemoteUpdate)
        mockDelegate.never(.throwException)
      }
    }
    
    describe("error log") {
      it("consume") {
        let (testQueue, errorRecovery) = setUp()
        // start with a clean slate
        _ = ErrorRecovery.consumeErrorLog()
        
        let error = NSError(domain: "TestDomain", code: 47, userInfo: [NSLocalizedDescriptionKey: "TestLocalizedDescription"])
        errorRecovery.writeErrorOrExceptionToLog(error)
        testQueue.flush()
        
        let errorLog = ErrorRecovery.consumeErrorLog()
        expect(errorLog?.contains("TestDomain")) == true
        expect(errorLog?.contains("47")) == true
        expect(errorLog?.contains("TestLocalizedDescription")) == true
      }
      
      it("consume multiple errors") {
        let (testQueue, errorRecovery) = setUp()
        // start with a clean slate
        _ = ErrorRecovery.consumeErrorLog()
        
        let error = NSError(domain: "TestDomain", code: 47, userInfo: [NSLocalizedDescriptionKey: "TestLocalizedDescription"])
        errorRecovery.writeErrorOrExceptionToLog(error)
        
        let exception = NSException(name: NSExceptionName(rawValue: "TestName"), reason: "TestReason")
        errorRecovery.writeErrorOrExceptionToLog(exception)
        testQueue.flush()
        
        let errorLog = ErrorRecovery.consumeErrorLog()
        expect(errorLog?.contains("TestDomain")) == true
        expect(errorLog?.contains("47")) == true
        expect(errorLog?.contains("TestLocalizedDescription")) == true
        expect(errorLog?.contains("TestName")) == true
        expect(errorLog?.contains("TestReason")) == true
      }
    }
  }
}
