// A wrapper for Document that allows the client to gather mutations on the client side and commit them
// when it wants to.

import {isEqual} from 'lodash'

import Document from './Document'
import Mutation from './Mutation'
import SquashingBuffer from './SquashingBuffer'
import debug from './debug'

class Commit {
  mutations: mutations
  tries: number
  constructor(mutations) {
    this.mutations = mutations
    this.tries = 0
  }
  apply(doc: Object): Object {
    return Mutation.applyAll(doc, this.mutations)
  }
  squash(doc: Object) {
    const result = Mutation.squash(doc, this.mutations)
    result.assignRandomTransactionId()
    return result
  }
}

export default class BufferedDocument {
  // The Document we are wrapping
  document: Document
  // The Document with local changes applied
  LOCAL: Object
  // Commits that are waiting to be delivered to the server
  commits: Array<Commit>
  // Local mutations that are not scheduled to be committed yet
  buffer: SquashingBuffer
  onMutation: Function
  onRebase: Function
  onDelete: Function
  commitHandler: Function
  constructor(doc) {
    this.buffer = new SquashingBuffer(doc)
    this.document = new Document(doc)
    this.document.onMutation = msg => this.handleDocMutation(msg)
    this.document.onRebase = msg => this.handleDocRebase(msg)
    this.LOCAL = doc
    this.mutations = []
    this.commits = []
  }

  // Used to reset the state of the local document model. If the model has been inconsistent
  // for too long, it has probably missed a notification, and should reload the document from the server
  reset(doc) {
    if (doc) {
      debug('Document state reset to revision %s', doc._rev)
    } else {
      debug('Document state reset to being deleted')
    }
    this.document.reset(doc)
    this.rebase()
  }

  set onConsistencyChanged(callback: Function) {
    this.document.onConsistencyChanged = callback
  }

  get onConsistencyChanged(): Function {
    return this.document.onConsistencyChanged
  }

  get inconsitentAt(): Date {
    return this.document.inconsitentAt
  }

  // Add a change to the buffer
  add(mutation: Mutation) {
    debug('Staged local mutation')
    this.buffer.add(mutation)
    const oldLocal = this.LOCAL
    this.LOCAL = mutation.apply(this.LOCAL)
    if (this.onMutation && oldLocal !== this.LOCAL) {
      debug('onMutation fired')
      this.onMutation({
        mutation,
        document: this.LOCAL,
        remote: false
      })
      if (this.LOCAL === null && this.onDelete) {
        this.onDelete(this.LOCAL)
      }
    }
  }

  // Call when a mutation arrives from Sanity
  arrive(mutation: Mutation) {
    debug('Remote mutation arrived %s -> %s', mutation.previousRev, mutation.resultRev)
    if (mutation.previousRev == mutation.resultRev) {
      throw new Error(
        `Mutation ${mutation.transactionId} has previousRev == resultRev (${mutation.previousRev})`
      )
    }
    return this.document.arrive(mutation)
  }

  // Submit all mutations in the buffer to be committed
  commit() {
    // Anything to commit?
    if (!this.buffer.hasChanges()) {
      return
    }
    debug('Committing local changes')
    // Collect current staged mutations into a commit and ...
    this.commits.push(new Commit([this.buffer.purge()]))
    // ... clear the table for the next commit.
    this.buffer = new SquashingBuffer(this.LOCAL)
    this.performCommits()
  }

  // Starts the committer that will try to committ all staged commits to the database
  // by calling the commitHandler. Will keep running until all commits are successfully
  // committed.
  performCommits() {
    if (!this.commitHandler) {
      throw new Error('No commitHandler configured for this BufferedDocument')
    }
    if (this.committerRunning) {
      // We can have only one committer at any given time
      return
    }
    this._cycleCommitter()
  }

  // TODO: Error handling, right now retries after every error,
  _cycleCommitter() {
    if (this.commits.length == 0) {
      this.committerRunning = false
      return
    }
    this.committerRunning = true
    const commit = this.commits.shift()
    const squashed = commit.squash(this.LOCAL)
    const docResponder = this.document.stage(squashed, true)

    const responder = {
      success: () => {
        debug('Commit succeeded')
        docResponder.success()
        // Keep running the committer until no more commits
        this._cycleCommitter()
      },
      failure: () => {
        debug('Commit failed')
        // Re stage commit
        commit.tries += 1
        if (this.LOCAL !== null) {
          // Only schedule this commit for a retry of the document still exist to avoid looping
          // indefinitely when the document was deleted from under our noses
          this.commits.unshift(commit)
        }
        docResponder.failure()
        // Retry
        // TODO: Be able to _not_ retry if failure is permanent
        setTimeout(() => this._cycleCommitter(), 1000)
      }
    }
    debug('Posting commit')
    this.commitHandler({
      mutation: squashed,
      success: responder.success,
      failure: responder.failure
    })
  }

  handleDocRebase(msg) {
    this.rebase()
  }

  handleDocumentDeleted() {
    debug('Document deleted')
    // If the document was just deleted, fire the onDelete event with the absolutely latest version of the document
    // before someone deleted it so that the client may revive the document in the last state the user saw it, should
    // she so desire.
    if (this.LOCAL !== null && this.onDelete) {
      this.onDelete(this.LOCAL)
    }
    this.commits = []
    this.mutations = []
  }

  handleDocMutation(msg) {
    // If we have no local changes, we can just pass this on to the client
    if (this.commits.length == 0 && !this.buffer.hasChanges()) {
      debug('Document mutated from remote with no local changes')
      this.LOCAL = this.document.EDGE
      this.buffer = new SquashingBuffer(this.LOCAL)
      if (this.onMutation) {
        this.onMutation(msg)
      }
      return
    }
    debug('Document mutated from remote with local changes')

    // If there are local edits, and the document was deleted, we need to purge those local edits now
    if (this.document.EDGE === null) {
      this.handleDocumentDeleted()
    }

    // We had local changes, so need to signal rebase
    this.rebase()
  }

  rebase() {
    debug('Rebasing document')
    if (this.document.EDGE === null) {
      this.handleDocumentDeleted()
    }

    const oldLocal = this.LOCAL
    this.LOCAL = this.commits.reduce((doc, commit) => commit.apply(doc), this.document.EDGE)
    this.LOCAL = this.buffer.rebase(this.LOCAL)
    // Copy over rev, since we don't care if it changed, we only care about the content
    if (oldLocal !== null && this.LOCAL !== null) {
      oldLocal._rev = this.LOCAL._rev
    }
    const changed = !isEqual(this.LOCAL, oldLocal)
    if (changed && this.onRebase) {
      this.onRebase(this.LOCAL)
    }
  }
}

@@ -296,16 +296,46 @@
 debug'%0A%0A
+const ONE_MINUTE = 1000 * 60%0A%0A
 class Co
@@ -4881,132 +4881,233 @@
  // 
-Retry%0A        // TODO: Be able to _not_ retry if failure is permanent%0A        setTimeout(() =%3E this._cycleCommitter(), 1000)
+Todo: Need better error handling (i.e. propagate to user and provide means of retrying)%0A        if (commit.tries %3C 200) %7B%0A          setTimeout(() =%3E this._cycleCommitter(), Math.min(commit.tries * 1000, ONE_MINUTE))%0A        %7D
 %0A   

Use exponential backoff with hard limit when retrying commits (#954)