import ExpoModulesTestCore

@testable import ExpoModulesCore

final class EventEmitterSpec: ExpoSpec {
  override class func spec() {
    let appContext = AppContext.create()
    let runtime = try! appContext.runtime

    describe("JS class") {
      it("exists") {
        let eventEmitterClass = try runtime.eval("expo.EventEmitter")
        expect(eventEmitterClass.kind) == .function
      }

      it("has functions in prototype") {
        let prototype = try runtime.eval("expo.EventEmitter.prototype").asObject()
        let addListener = prototype.getProperty("addListener")
        let removeListener = prototype.getProperty("removeListener")
        let removeAllListeners = prototype.getProperty("removeAllListeners")
        let emit = prototype.getProperty("emit")

        expect(addListener.kind) == .function
        expect(removeListener.kind) == .function
        expect(removeAllListeners.kind) == .function
        expect(emit.kind) == .function
      }

      it("creates an instance") {
        let eventEmitter = try runtime.eval("new expo.EventEmitter()")
        expect(eventEmitter.kind) == .object
      }

      it("calls a listener") {
        let result = try runtime.eval([
          "emitter = new expo.EventEmitter()",
          "result = null",
          "emitter.addListener('test', payload => { result = payload })",
          "emitter.emit('test', 'it\\'s a payload')",
          "result"
        ])
        expect(result.kind) == .string
        expect(try result.asString()) == "it's a payload"
      }

      it("removes a listener") {
        let result = try runtime.eval([
          "emitter = new expo.EventEmitter()",
          "result = null",
          "listener = () => { result = 1 }",
          "emitter.addListener('test', listener)",
          "emitter.removeListener('test', listener)",
          "emitter.emit('test')",
          "result"
        ])
        expect(result.kind) == .null
      }

      it("removes all listeners") {
        let result = try runtime.eval([
          "emitter = new expo.EventEmitter()",
          "result = null",
          "emitter.addListener('test', () => { result = 1 })",
          "emitter.addListener('test', () => { result = 2 })",
          "emitter.removeAllListeners('test')",
          "emitter.emit('test')",
          "result"
        ])
        expect(result.kind) == .null
      }

      it("emits with multiple arguments") {
        let args = try runtime
          .eval([
            "emitter = new expo.EventEmitter()",
            "result = null",
            "emitter.addListener('test', (a, b, c) => { result = [a, b, c] })",
            "emitter.emit('test', 14, 2, 24)",
            "result"
          ])
          .asArray()
          .compactMap({ try $0?.asInt() })

        expect(args.count) == 3
        expect(args[0]) == 14
        expect(args[1]) == 2
        expect(args[2]) == 24
      }

      it("returns listener count with listeners added") {
        let listenerCount = try runtime.eval([
          "emitter = new expo.EventEmitter()",
          "emitter.addListener('test', () => {})",
          "emitter.addListener('test', () => {})",
          "emitter.listenerCount('test')"
        ])
        expect(listenerCount.kind) == .number
        expect(try listenerCount.asInt()) == 2
      }

      it("returns listener count without any listeners") {
        let listenerCount = try runtime.eval([
          "emitter = new expo.EventEmitter()",
          "emitter.listenerCount('test')"
        ])
        expect(listenerCount.kind) == .number
        expect(try listenerCount.asInt()) == 0
      }

      it("returns listener count for the proper event") {
        let listenerCount = try runtime.eval([
          "emitter = new expo.EventEmitter()",
          "emitter.addListener('test1', () => {})",
          "emitter.addListener('test1', () => {})",
          "emitter.addListener('test2', () => {})",
          "emitter.listenerCount('test2')"
        ])
        expect(listenerCount.kind) == .number
        expect(try listenerCount.asInt()) == 1
      }

      it("calls startObserving on addListener") {
        var calls: Int = 0
        let eventName = "testEvent"
        let listenerA = runtime.createSyncFunction("listenerA") { _, _ in }
        let listenerB = runtime.createSyncFunction("listenerB") { _, _ in }
        let observer = try setupEventObserver(runtime: runtime, functionName: "startObserving") { arguments in
          expect(try arguments.first?.asString()) == eventName
          calls = calls + 1
        }

        observer.addListener.call(withArguments: [eventName, listenerA], thisObject: observer.emitter, asConstructor: false)
        observer.addListener.call(withArguments: [eventName, listenerB], thisObject: observer.emitter, asConstructor: false)

        expect(calls) == 1
      }

      it("calls stopObserving on removeListener") {
        var calls: Int = 0
        let eventName = "testEvent"
        let listener = runtime.createSyncFunction("listener") { _, _ in }
        let observer = try setupEventObserver(runtime: runtime, functionName: "stopObserving") { arguments in
          expect(try arguments.first?.asString()) == eventName
          calls = calls + 1
        }

        observer.addListener.call(withArguments: [eventName, listener], thisObject: observer.emitter, asConstructor: false)
        observer.removeListener.call(withArguments: [eventName, listener], thisObject: observer.emitter, asConstructor: false)
        observer.removeListener.call(withArguments: [eventName, listener], thisObject: observer.emitter, asConstructor: false)

        expect(calls) == 1
      }

      it("calls stopObserving on removeAllListeners") {
        var calls: Int = 0
        let eventName = "testEvent"
        let listener = runtime.createSyncFunction("listener") { _, _ in }
        let observer = try setupEventObserver(runtime: runtime, functionName: "stopObserving") { arguments in
          expect(try arguments.first?.asString()) == eventName
          calls = calls + 1
        }

        observer.addListener.call(withArguments: [eventName, listener], thisObject: observer.emitter, asConstructor: false)
        observer.removeAllListeners.call(withArguments: [eventName], thisObject: observer.emitter, asConstructor: false)
        observer.removeAllListeners.call(withArguments: [eventName], thisObject: observer.emitter, asConstructor: false)

        expect(calls) == 1
      }

      it("returns a subscription") {
        let subscription = try runtime.eval([
          "emitter = new expo.EventEmitter()",
          "subscription = emitter.addListener('test', () => {})"
        ])

        expect(try subscription.asObject().getPropertyNames()).to(contain("remove"))
        expect(try subscription.asObject().getProperty("remove").kind) == .function
      }

      it("removes a listener from subscription") {
        let wasCalled = try runtime.eval([
          "wasCalled = false",
          "emitter = new expo.EventEmitter()",
          "subscription = emitter.addListener('test', () => { wasCalled = true })",
          "subscription.remove()",
          "emitter.emit('test')",
          "wasCalled"
        ])

        expect(try wasCalled.asBool()) == false
      }

      it("removes only related listener") {
        let counter = try runtime.eval([
          "counter = 0",
          "emitter = new expo.EventEmitter()",
          "subscription1 = emitter.addListener('test', () => { counter |= 1 })",
          "subscription2 = emitter.addListener('test', () => { counter |= 2 })",
          "subscription1.remove()",
          "emitter.emit('test')",
          "counter"
        ])

        expect(try counter.asInt()) == 2
      }

      it("provides backwards compatibility for the legacy wrapper") {
        let emittersAreEqual = try runtime.eval([
          "emitterA = new expo.EventEmitter()",
          "emitterB = new expo.EventEmitter(emitterA)",
          "emitterA === emitterB"
        ])
        expect(try emittersAreEqual.asBool()) == true
      }

      it("calls all listeners even if removed by a listener") {
        // The third listener should be called even though it was already removed by the second listener.
        let result = try runtime.eval([
          "emitter = new expo.EventEmitter()",
          "result = 0",
          "listener = () => emitter.removeAllListeners('test')",
          "emitter.addListener('test', () => result |= 1)",
          "emitter.addListener('test', listener)",
          "emitter.addListener('test', () => result |= 2)",
          "emitter.emit('test')",
          "result"
        ])
        expect(try result.asInt()) == 3
      }

      it("calls only existing listeners even if a listener adds more") {
        // The listener added in the second listener shouldn't be called.
        let result = try runtime.eval([
          "emitter = new expo.EventEmitter()",
          "result = 0",
          "emitter.addListener('test', () => result |= 1)",
          "emitter.addListener('test', () => emitter.addListener('test', () => result |= 2))",
          "emitter.addListener('test', () => result |= 4)",
          "emitter.emit('test')",
          "result"
        ])
        expect(try result.asInt()) == 5
      }
    }
  }
}

private typealias EventObserver = (
  emitter: JavaScriptObject,
  addListener: RawJavaScriptFunction,
  removeListener: RawJavaScriptFunction,
  removeAllListeners: RawJavaScriptFunction
)

private func setupEventObserver(
  runtime: ExpoRuntime,
  functionName: String,
  callback: @escaping (_ arguments: [JavaScriptValue]) throws -> Void
) throws -> EventObserver {
  let emitter = try! runtime.eval("new expo.EventEmitter()").asObject()
  let observingFunction = runtime.createSyncFunction(functionName) { [callback] this, arguments in
    try callback(arguments)
    return Optional<Any>.none as Any
  }

  emitter.setProperty(functionName, value: observingFunction)

  return (
    emitter,
    addListener: try emitter.getProperty("addListener").asFunction(),
    removeListener: try emitter.getProperty("removeListener").asFunction(),
    removeAllListeners: try emitter.getProperty("removeAllListeners").asFunction()
  )
}
