_ = require 'underscore'
Folder = require '../../src/flux/models/folder'
Thread = require '../../src/flux/models/thread'
Message = require '../../src/flux/models/message'
Actions = require '../../src/flux/actions'
NylasAPI = require '../../src/flux/nylas-api'
Query = require '../../src/flux/models/query'
DatabaseStore = require '../../src/flux/stores/database-store'
Task = require '../../src/flux/tasks/task'
ChangeMailTask = require '../../src/flux/tasks/change-mail-task'

{APIError} = require '../../src/flux/errors'
{Utils} = require '../../src/flux/models/utils'

describe "ChangeMailTask", ->
  beforeEach ->
    @threadA = new Thread(id: 'A', folders: [new Folder(id:'folderA')])
    @threadB = new Thread(id: 'B', folders: [new Folder(id:'folderB')])
    @threadC = new Thread(id: 'C', folders: [new Folder(id:'folderC')])
    @threadAChanged = new Thread(id: 'A', folders: [new Folder(id:'folderC')])

    @threadAMesage1 = new Message(id:'A1', threadId: 'A')
    @threadAMesage2 = new Message(id:'A2', threadId: 'A')
    @threadBMesage1 = new Message(id:'B1', threadId: 'B')

    threads = [@threadA, @threadB, @threadC]
    messages = [@threadAMesage1, @threadAMesage2, @threadBMesage1]

    # Instead of spying on find/findAll, we fake the evaluation of the query.
    # This allows queries to be built with findAll().where().blabla... without
    # a complex stub chain. Works since query "matchers" can be evaluated in JS
    spyOn(DatabaseStore, 'run').andCallFake (query) =>
      if query._klass is Message
        models = messages
      else if query._klass is Thread
        models = threads
      else
        throw new Error("Not stubbed!")

      models = models.filter (model) ->
        for matcher in query._matchers
          if matcher.evaluate(model) is false
            return false
        return true

      if query._singular
        models = models[0]
      Promise.resolve(models)

    spyOn(DatabaseStore, 'persistModels').andReturn(Promise.resolve())
    spyOn(DatabaseStore, 'persistModel').andReturn(Promise.resolve())

  it "leaves subclasses to implement changesToModel", ->
    task = new ChangeMailTask()
    expect( => task.changesToModel() ).toThrow()

  it "leaves subclasses to implement requestBodyForModel", ->
    task = new ChangeMailTask()
    expect( => task.requestBodyForModel() ).toThrow()

  describe "performLocal", ->
    it "rejects if it's an undo task and no restore values are present", ->
      task = new ChangeMailTask()
      task._isUndoTask = true
      spyOn(task, '_performLocalThreads').andReturn(Promise.resolve())

      waitsForPromise =>
        task.performLocal().catch (err) =>
          expect(err.message).toEqual("ChangeMailTask: No _restoreValues provided for undo task.")

    it "should always call _performLocalThreads and then _performLocalMessages", ->
      task = new ChangeMailTask()
      task.threads = [@threadA]

      @messagesResolve = null
      spyOn(task, '_performLocalThreads').andCallFake => Promise.resolve()
      spyOn(task, '_performLocalMessages').andCallFake =>
        new Promise (resolve, reject) => @messagesResolve = resolve

      runs ->
        task.performLocal()
      waitsFor ->
        task._performLocalThreads.callCount > 0
      runs ->
        advanceClock()
        @messagesResolve()
      waitsFor ->
        task._performLocalMessages.callCount > 0
      runs ->
        expect(task._performLocalThreads).toHaveBeenCalled()
        expect(task._performLocalMessages).toHaveBeenCalled()

  describe "_performLocalThreads", ->
    beforeEach ->
      @task = new ChangeMailTask()
      @task.threads = [@threadA, @threadB]
      # Note: Simulate applyChanges only changing threadA, not threadB
      spyOn(@task, '_applyChanges').andReturn([@threadAChanged])

    it "calls _applyChanges and writes changed threads to the database", ->
      waitsForPromise =>
        @task._performLocalThreads().then =>
          expect(@task._applyChanges).toHaveBeenCalledWith(@task.threads)
          expect(DatabaseStore.persistModels).toHaveBeenCalledWith([@threadAChanged])

    describe "when processNestedMessages is overridden to return true", ->
      it "fetches messages on changed threads and appends them to the messages to update", ->
        waitsForPromise =>
          @task.processNestedMessages = => true
          @task._performLocalThreads().then =>
            expect(@task._applyChanges).toHaveBeenCalledWith(@task.threads)
            expect(@task.messages).toEqual([@threadAMesage1, @threadAMesage2])

  describe "_performLocalMessages", ->
    beforeEach ->
      @task = new ChangeMailTask()
      @task.messages = [@threadAMesage1, @threadAMesage2, @threadBMesage1]
      # Note: Simulate applyChanges only changing threadBMesage1
      spyOn(@task, '_applyChanges').andReturn([@threadBMesage1])

    it "calls _applyChanges and writes changed messages to the database", ->
      waitsForPromise =>
        @task._performLocalMessages().then =>
          expect(@task._applyChanges).toHaveBeenCalledWith(@task.messages)
          expect(DatabaseStore.persistModels).toHaveBeenCalledWith([@threadBMesage1])

  describe "_applyChanges", ->
    beforeEach ->
      @task = new ChangeMailTask()

    describe "when applying forwards", ->
      beforeEach ->
        spyOn(@task, '_shouldChangeBackwards').andReturn(false)
        spyOn(@task, 'changesToModel').andCallFake (thread) =>
          if thread is @threadC
            return {folders: [new Folder(id: "different!")]}
          else
            return {folders: thread.folders}

      it "should call changesToModel on each model", ->
        @task._applyChanges([@threadA, @threadB])
        expect(@task.changesToModel.callCount).toBe(2)
        expect(@task.changesToModel.calls[0].args[0]).toBe(@threadA)
        expect(@task.changesToModel.calls[1].args[0]).toBe(@threadB)

      it "should return only the models with new values", ->
        out = @task._applyChanges([@threadA, @threadB, @threadC])
        expect(_.isArray(out)).toBe(true)
        expect(out.length).toBe(1)
        expect(out[0].id).toBe('C')
        expect(out[0].folders[0].id).toBe('different!')

      it "should save restore values only for changed items", ->
        out = @task._applyChanges([@threadA, @threadB, @threadC])
        expect(@task._restoreValues['A']).toBe(undefined)
        expect(@task._restoreValues['B']).toBe(undefined)
        expect(@task._restoreValues['C']).toEqual(folders: @threadC.folders)

      it "should treat models as if they're frozen, returning new models", ->
        out = @task._applyChanges([@threadA, @threadB, @threadC])
        expect(out[0]).not.toBe(@threadC)
        expect(out[0].id).toBe(@threadC.id)
        expect(@task._restoreValues['C']).toEqual(folders: @threadC.folders)

    describe "when applying backwards (reverting or undoing)", ->
      beforeEach ->
        spyOn(@task, '_shouldChangeBackwards').andReturn(true)
        @task._restoreValues =
          'C': {folders: [new Folder(id:'oldFolderC')]}

      it "should return only models with the restore values, with the restore values applied", ->
        out = @task._applyChanges([@threadA, @threadB, @threadC])
        expect(_.isArray(out)).toBe(true)
        expect(out.length).toBe(1)
        expect(out[0].id).toBe('C')
        expect(out[0].folders[0].id).toBe('oldFolderC')

  describe "performRemote", ->
    describe "if threads are set", ->
      it "should only call _performRequests with threads", ->
        @task = new ChangeMailTask()
        @task.threads = [@threadA, @threadB]
        @task.messages = [@threadAMesage1, @threadAMesage2]
        spyOn(@task, '_performRequests').andReturn(Promise.resolve())
        waitsForPromise =>
          @task.performRemote().then =>
            expect(@task._performRequests).toHaveBeenCalledWith(Thread, @task.threads)
            expect(@task._performRequests.callCount).toBe(1)

    describe "if only messages are set", ->
      it "should only call _performRequests with messages", ->
        @task = new ChangeMailTask()
        @task.threads = []
        @task.messages = [@threadAMesage1, @threadAMesage2]
        spyOn(@task, '_performRequests').andReturn(Promise.resolve())
        waitsForPromise =>
          @task.performRemote().then =>
            expect(@task._performRequests).toHaveBeenCalledWith(Message, @task.messages)
            expect(@task._performRequests.callCount).toBe(1)

    describe "if somehow there are no threads or messages", ->
      it "should resolve", ->
        @task = new ChangeMailTask()
        @task.threads = []
        @task.messages = []
        waitsForPromise =>
          @task.performRemote().then (code) =>
            expect(code).toEqual(Task.Status.Success)

    describe "if _performRequests resolves", ->
      it "should resolve with Task.Status.Success", ->
        @task = new ChangeMailTask()
        spyOn(@task, '_performRequests').andReturn(Promise.resolve())
        waitsForPromise =>
          @task.performRemote().then (result) =>
            expect(result).toBe(Task.Status.Success)

    describe "if _performRequests rejects with a permanent network error", ->
      beforeEach ->
        @task = new ChangeMailTask()
        spyOn(@task, '_performRequests').andReturn(Promise.reject(new APIError(statusCode: 400)))
        spyOn(@task, 'performLocal').andReturn(Promise.resolve())

      it "should set isReverting and call performLocal", ->
        waitsForPromise =>
          @task.performRemote().then (result) =>
            expect(@task.performLocal).toHaveBeenCalled()
            expect(@task._isReverting).toBe(true)

      it "should resolve with Task.Status.Failed after reverting", ->
        waitsForPromise =>
          @task.performRemote().then (result) =>
            expect(result).toBe(Task.Status.Failed)

    describe "if _performRequests rejects with a temporary network error", ->
      beforeEach ->
        @task = new ChangeMailTask()
        spyOn(@task, '_performRequests').andReturn(Promise.reject(new APIError(statusCode: NylasAPI.SampleTemporaryErrorCode)))
        spyOn(@task, 'performLocal').andReturn(Promise.resolve())

      it "should not revert", ->
        waitsForPromise =>
          @task.performRemote().then (result) =>
            expect(@task.performLocal).not.toHaveBeenCalled()
            expect(@task._isReverting).not.toBe(true)

      it "should resolve with Task.Status.Retry", ->
        waitsForPromise =>
          @task.performRemote().then (result) =>
            expect(result).toBe(Task.Status.Retry)


    describe "_performRequests", ->
      beforeEach ->
        @task = new ChangeMailTask()
        @task._restoreValues =
          'A': {}
          'B': {}
          'C': {}
          'A1': {}
        spyOn(@task, 'requestBodyForModel').andCallFake (model) =>
          if model is @threadA
            return {field: 'thread-a-body'}
          if model is @threadB
            return {field: 'thread-b-body'}
          if model is @threadAMesage1
            return {field: 'message-1'}

      it "should call NylasAPI.makeRequest for each model, passing the result of requestBodyForModel", ->
        spyOn(NylasAPI, 'makeRequest').andReturn(Promise.resolve())
        runs ->
          @task._performRequests(Thread, [@threadA, @threadB])
        waitsFor ->
          NylasAPI.makeRequest.callCount is 2
        runs ->
          expect(NylasAPI.makeRequest.calls[0].args[0].body).toEqual({field: 'thread-a-body'})
          expect(NylasAPI.makeRequest.calls[1].args[0].body).toEqual({field: 'thread-b-body'})

      it "should resolve when all of the requests complete", ->
        promises = []
        spyOn(NylasAPI, 'makeRequest').andCallFake ->
          new Promise (resolve, reject) -> promises.push({resolve, reject})

        resolved = false
        runs ->
          @task._performRequests(Thread, [@threadA, @threadB]).then =>
            resolved = true
        waitsFor ->
          NylasAPI.makeRequest.callCount is 2
        runs ->
          expect(resolved).toBe(false)
          promises[0].resolve()
          advanceClock()
          expect(resolved).toBe(false)
          promises[1].resolve()
          advanceClock()
          expect(resolved).toBe(true)

      it "should carry on and resolve if a request 404s, since the NylasAPI manager will clean the object from the cache", ->
        promises = []
        spyOn(NylasAPI, 'makeRequest').andCallFake ->
          new Promise (resolve, reject) -> promises.push({resolve, reject})

        resolved = false
        runs ->
          @task._performRequests(Thread, [@threadA, @threadB]).then =>
            resolved = true
        waitsFor ->
          NylasAPI.makeRequest.callCount is 2
        runs ->
          promises[0].resolve()
          promises[1].reject(new APIError(statusCode: 404))
          advanceClock()
          expect(resolved).toBe(true)

      it "should reject with the request error encountered by any request", ->
        promises = []
        spyOn(NylasAPI, 'makeRequest').andCallFake ->
          new Promise (resolve, reject) -> promises.push({resolve, reject})

        err = null
        runs ->
          @task._performRequests(Thread, [@threadA, @threadB]).catch (error) =>
            err = error
        waitsFor ->
          NylasAPI.makeRequest.callCount is 2
        runs ->
          expect(err).toBe(null)
          promises[0].resolve()
          advanceClock()
          expect(err).toBe(null)
          apiError = new APIError(statusCode: NylasAPI.SampleTemporaryErrorCode)
          promises[1].reject(apiError)
          advanceClock()
          expect(err).toBe(apiError)

      it "should use /threads when the klass provided is Thread", ->
        spyOn(NylasAPI, 'makeRequest').andCallFake ->
          new Promise (resolve, reject) -> #noop
        runs ->
          @task._performRequests(Thread, [@threadA, @threadB])
        waitsFor ->
          NylasAPI.makeRequest.callCount is 2
        runs ->
          path = "/threads/#{@threadA.id}"
          expect(NylasAPI.makeRequest.calls[0].args[0].path).toBe(path)
          expect(NylasAPI.makeRequest.calls[0].args[0].accountId).toBe(@threadA.accountId)

      it "should use /messages when the klass provided is Message", ->
        spyOn(NylasAPI, 'makeRequest').andCallFake ->
          new Promise (resolve, reject) -> #noop
        runs ->
          @task._performRequests(Message, [@threadAMesage1])
        waitsFor ->
          NylasAPI.makeRequest.callCount is 1
        runs ->
          path = "/messages/#{@threadAMesage1.id}"
          expect(NylasAPI.makeRequest.calls[0].args[0].path).toBe(path)
          expect(NylasAPI.makeRequest.calls[0].args[0].accountId).toBe(@threadAMesage1.accountId)

      it "should decrement change counts as requests complete", ->
        spyOn(NylasAPI, 'makeRequest').andCallFake ->
          new Promise (resolve, reject) -> #noop
        spyOn(@task, '_removeLock')
        runs ->
          @task._performRequests(Thread, [@threadAMesage1])
        waitsFor ->
          NylasAPI.makeRequest.callCount is 1
        runs ->
          NylasAPI.makeRequest.calls[0].args[0].beforeProcessing({})
          expect(@task._removeLock).toHaveBeenCalledWith(@threadAMesage1)

      it "should make no more than 10 requests at once", ->
        resolves = []
        spyOn(@task, '_removeLock')
        spyOn(NylasAPI, 'makeRequest').andCallFake ->
          new Promise (resolve, reject) -> resolves.push(resolve)

        threads = []
        threads.push new Thread(id: "#{idx}", subject: idx) for idx in [0..100]
        @task._restoreValues = _.map threads, (t) -> {some: 'data'}
        @task._performRequests(Thread, threads)
        advanceClock()
        expect(resolves.length).toEqual(5)
        advanceClock()
        expect(resolves.length).toEqual(5)
        resolves[0]()
        resolves[1]()
        advanceClock()
        expect(resolves.length).toEqual(7)
        resolves[idx]() for idx in [2...7]
        advanceClock()
        expect(resolves.length).toEqual(12)


      it "should stop making requests after non-404 network errors", ->
        resolves = []
        rejects = []
        spyOn(@task, '_removeLock')
        spyOn(NylasAPI, 'makeRequest').andCallFake ->
          new Promise (resolve, reject) ->
            resolves.push(resolve)
            rejects.push(reject)

        threads = []
        threads.push new Thread(id: "#{idx}", subject: idx) for idx in [0..100]
        @task._restoreValues = _.map threads, (t) -> {some: 'data'}
        @task._performRequests(Thread, threads)
        advanceClock()
        expect(resolves.length).toEqual(5)
        resolves[idx]() for idx in [0...4]
        advanceClock()
        expect(resolves.length).toEqual(9)

        # simulate request failure
        reject = rejects[rejects.length - 1]
        reject(new APIError(statusCode: 400))
        advanceClock()

        # simulate more requests succeeding
        resolves[idx]() for idx in [5...9]
        advanceClock()

        # check that no more requests have been queued
        expect(resolves.length).toEqual(9)

  describe "optimistic object locking", ->
    beforeEach ->
      @task = new ChangeMailTask()
      spyOn(@task, '_performLocalThreads').andReturn(Promise.resolve())
      spyOn(@task, '_lockAll')

    it "increments the locks in performLocal", ->
      waitsForPromise =>
        @task.performLocal().then =>
          expect(@task._lockAll).toHaveBeenCalled()

    describe "when the task is reverting after request failures", ->
      it "should not increment change locks", ->
        @task._isReverting = true
        waitsForPromise =>
          @task.performLocal().then =>
            expect(@task._lockAll).not.toHaveBeenCalled()

    describe "when the task is undoing", ->
      it "should increment change locks", ->
        @task._isUndoTask = true
        @task._restoreValues = {}
        waitsForPromise =>
          @task.performLocal().then =>
            expect(@task._lockAll).toHaveBeenCalled()

    describe "when performRemote is returning Task.Status.Success", ->
      it "should clean up locks", ->
        spyOn(@task, '_performRequests').andReturn(Promise.resolve())
        spyOn(@task, '_ensureLocksRemoved')
        waitsForPromise =>
          @task.performRemote().then =>
            expect(@task._ensureLocksRemoved).toHaveBeenCalled()

    describe "when performRemote is returning Task.Status.Failed after reverting", ->
      it "should clean up locks", ->
        spyOn(@task, '_performRequests').andReturn(Promise.reject(new APIError(statusCode: 400)))
        spyOn(@task, '_ensureLocksRemoved')
        waitsForPromise =>
          @task.performRemote().then =>
            expect(@task._ensureLocksRemoved).toHaveBeenCalled()

    describe "when performRemote is returning Task.Status.Retry", ->
      it "should not clean up locks", ->
        spyOn(@task, '_performRequests').andReturn(Promise.reject(new APIError(statusCode: NylasAPI.SampleTemporaryErrorCode)))
        spyOn(@task, '_ensureLocksRemoved')
        waitsForPromise =>
          @task.performRemote().then =>
            expect(@task._ensureLocksRemoved).not.toHaveBeenCalled()

  describe "_lockAll", ->
    beforeEach ->
      @task = new ChangeMailTask()
      @task.threads = [@threadA, @threadB]
      spyOn(NylasAPI, 'incrementOptimisticChangeCount')

    it "should keep a hash of the items that it locks", ->
      @task._lockAll()
      expect(NylasAPI.incrementOptimisticChangeCount.callCount).toBe(2)
      expect(@task._locked).toEqual('A': 1, 'B': 1)

    it "should not break anything if it's accidentally called twice", ->
      @task._lockAll()
      @task._lockAll()
      expect(NylasAPI.incrementOptimisticChangeCount.callCount).toBe(4)
      expect(@task._locked).toEqual('A': 2, 'B': 2)

  describe "_ensureLocksRemoved", ->
    it "should decrement locks given any aribtrarily messed up lock state and reset the locked array", ->
      @task = new ChangeMailTask()
      @task.threads = [@threadA, @threadB, @threadC]
      spyOn(NylasAPI, 'decrementOptimisticChangeCount')
      @task._locked = {'A': 2, 'B': 2, 'C': 1}
      @task._ensureLocksRemoved()
      expect(NylasAPI.decrementOptimisticChangeCount.callCount).toBe(5)
      expect(NylasAPI.decrementOptimisticChangeCount.calls[0].args[1]).toBe('A')
      expect(NylasAPI.decrementOptimisticChangeCount.calls[1].args[1]).toBe('A')
      expect(NylasAPI.decrementOptimisticChangeCount.calls[2].args[1]).toBe('B')
      expect(NylasAPI.decrementOptimisticChangeCount.calls[3].args[1]).toBe('B')
      expect(NylasAPI.decrementOptimisticChangeCount.calls[4].args[1]).toBe('C')
      expect(@task._locked).toEqual(null)

  describe "createIdenticalTask", ->
    it "should return a copy of the task, but with the objects converted into object ids", ->
      task = new ChangeMailTask()
      task.messages = [@threadAMesage1, @threadAMesage2]
      clone = task.createIdenticalTask()
      expect(clone.messages).toEqual([@threadAMesage1.id, @threadAMesage2.id])

      task = new ChangeMailTask()
      task.threads = [@threadA, @threadB]
      clone = task.createIdenticalTask()
      expect(clone.threads).toEqual([@threadA.id, @threadB.id])

      task = new ChangeMailTask()
      task.threads = [@threadA.id, @threadB.id]
      clone = task.createIdenticalTask()
      expect(clone.threads).toEqual([@threadA.id, @threadB.id])

  describe "createUndoTask", ->
    it "should return a task initialized with _isUndoTask and _restoreValues", ->
      task = new ChangeMailTask()
      task.messages = [@threadAMesage1, @threadAMesage2]
      task._restoreValues = {'A': 'bla'}
      undo = task.createUndoTask()
      expect(undo.messages).toEqual([@threadAMesage1.id, @threadAMesage2.id])
      expect(undo._restoreValues).toBe(task._restoreValues)
      expect(undo._isUndoTask).toBe(true)

    it "should throw if you try to make an undo task of an undo task", ->
      task = new ChangeMailTask()
      task._isUndoTask = true
      expect( -> task.createUndoTask()).toThrow()

    it "should throw if _restoreValues are not availble", ->
      task = new ChangeMailTask()
      task.messages = [@threadAMesage1, @threadAMesage2]
      task._restoreValues = null
      expect( -> task.createUndoTask()).toThrow()

  describe "isDependentTask", ->
    it "should return true if another, older ChangeMailTask involves the same threads", ->
      a = new ChangeMailTask()
      a.threads = ['t1', 't2', 't3']
      a.creationDate = new Date(1000)
      b = new ChangeMailTask()
      b.threads = ['t3', 't4', 't7']
      b.creationDate = new Date(2000)
      c = new ChangeMailTask()
      c.threads = ['t0', 't7']
      c.creationDate = new Date(3000)
      expect(a.isDependentTask(b)).toEqual(false)
      expect(a.isDependentTask(c)).toEqual(false)
      expect(b.isDependentTask(a)).toEqual(true)
      expect(c.isDependentTask(a)).toEqual(false)
      expect(c.isDependentTask(b)).toEqual(true)
