/**
 * dispose.js provides tools to components that needs to dispose of resources, such as
 * destroy DOM, and unsubscribe from events. The motivation with examples is presented here:
 *
 *    https://phab.getgrist.com/w/disposal/
 */


var ko = require('knockout');
var util = require('util');
var _ = require("underscore");

// Use the browser globals in a way that allows replacing them with mocks in tests.
var G = require('./browserGlobals').get('DocumentFragment', 'Node');

/**
 * Disposable is a base class for components that need cleanup (e.g. maintain DOM, listen to
 * events, subscribe to anything). It provides a .dispose() method that should be called to
 * destroy the component, and .autoDispose() method that the component should use to take
 * responsibility for other pieces that require cleanup.
 *
 * To define a disposable prototype:
 *    function Foo() { ... }
 *    dispose.makeDisposable(Foo);
 *
 * To define a disposable ES6 class:
 *    class Foo extends dispose.Disposable { create() {...} }
 *
 *    NB: Foo should not have its construction logic in a constructor but in a `create` method
 *    instead. If Foo defines a constructor (for taking advantage of type checking) the constructor
 *    should only call super `super(arg1, arg2 ...)`. Any way calling `new Foo(...args)` safely
 *    construct the component.
 *
 * In Foo's constructor or methods, take ownership of other objects:
 *    this.bar = this.autoDispose(Bar.create(...));
 * The argument will get disposed when `this` is disposed. If it's a DOM node, it will get removed
 * using ko.removeNode(). If it has a `dispose` method, it will be called.
 *
 * For more customized disposal:
 *    this.baz = this.autoDisposeWith('destroy', new Baz());
 *    this.elem = this.autoDisposeWith(ko.cleanNode, document.createElement(...));
 * When `this` is disposed, will call this.baz.destroy(), and ko.cleanNode(this.elem).
 *
 * To call another method on disposal (e.g. to add custom disposal logic):
 *    this.autoDisposeCallback(this.myUnsubscribeAllMethod);
 * The method will be called with `this` as context, and no arguments.
 *
 * To create Foo:
 *    var foo = Foo.create(args...);
 * `Foo.create` ensures that if the constructor throws an exception, any calls to .autoDispose()
 * that happened before that are honored.
 *
 * To dispose of Foo:
 *    foo.dispose();
 * Owned objects will be disposed in reverse order from which `autoDispose` were called. Note that
 * `foo` is no longer usable afterwards, and all its properties are wiped.
 * If Foo has a `stopListening` method (e.g. inherits from Backbone.Events), `dispose` will call
 * it automatically, as if it were added with `this.autoDisposeCallback(this.stopListening)`.
 *
 * To release an owned object:
 *    this.disposeRelease(this.bar);
 *
 * To dispose of an owned object early:
 *    this.disposeDiscard(this.bar);
 *
 * To determine if a reference refers to object that has already been disposed:
 *    foo.isDisposed()
 */
class Disposable {
  /**
   * A safe constructor which calls dispose() in case the creation throws an exception.
   */
  constructor(...args) {
    safelyConstruct(this.create, this, args);
  }

  /**
   * Static method to allow rewriting old classes into ES6 without modifying their
   * instantiation to use `new Foo()` (i.e. you can continue to use `Foo.create()`).
   */
  static create(...args) {
    return new this(...args);
  }
}

Object.assign(Disposable.prototype, {
  /**
   * Take ownership of `obj`, and dispose it when `this.dispose` is called.
   * @param {Object} obj: Object to take ownership of. It can be a DOM node or an object with a
   *    `dispose` method.
   * @returns {Object} obj
   */
  autoDispose: function(obj) {
    return this.autoDisposeWith(defaultDisposer, obj);
  },

  /**
   * As for autoDispose, but we receive a promise of an object.  We wait for it to
   * resolve and then take ownership of it.  We return a promise that resolves to
   * the object, or to null if the owner is disposed in the meantime.
   */
  autoDisposePromise: function(objPromise) {
    return objPromise.then(obj => {
      if (this.isDisposed()) {
        defaultDisposer(obj);
        return null;
      }
      this.autoDispose(obj);
      return obj;
    });
  },

  /**
   * Take ownership of `obj`, and dispose it when `this.dispose` is called by calling the
   * specified function.
   * @param {Function|String} disposer: If a function, disposer(obj) will be called to dispose the
   *    object, with `this` as the context. If a string, then obj[disposer]() will be called. E.g.
   *        this.autoDisposeWith('destroy', a);     // will call a.destroy()
   *        this.autoDisposeWith(ko.cleanNode, b);  // will call ko.cleanNode(b)
   * @param {Object} obj: Object to take ownership of, on which `disposer` will be called.
   * @returns {Object} obj
   */
  autoDisposeWith: function(disposer, obj) {
    var list = this._disposalList || (this._disposalList = []);
    list.push({ obj: obj,
                disposer: typeof disposer === 'string' ? methodDisposer(disposer) : disposer });
    return obj;
  },

  /**
   * Adds the given callback to be called when `this.dispose` is called.
   * @param {Function} callback: Called on disposal with `this` as the context and no arguments.
   * @returns nothing
   */
  autoDisposeCallback: function(callback) {
    this.autoDisposeWith(callFuncHelper, callback);
  },

  /**
   * Remove `obj` from the list of owned objects; it will not be disposed on `this.dispose`.
   * @param {Object} obj: Object to release.
   * @returns {Object} obj
   */
  disposeRelease: function(obj) {
    removeObjectToDispose(this._disposalList, obj);
    return obj;
  },

  /**
   * Dispose of an owned object `obj` now, and remove it from the list of owned objects.
   * @param {Object} obj: Object to release.
   * @returns nothing
   */
  disposeDiscard: function(obj) {
    var entry = removeObjectToDispose(this._disposalList, obj);
    if (entry) {
      entry.disposer.call(this, obj);
    }
  },

  /**
   * Returns whether this object has already been disposed.
   */
  isDisposed: function() {
    return this._disposalList === WIPED_VALUE;
  },

  /**
   * Clean up `this` by disposing of all owned objects, and calling `stopListening()` if defined.
   */
  dispose: function() {
    if (this.isDisposed()) {
      return;
    }

    var disposalList = this._disposalList;
    this._disposalList = WIPED_VALUE; // This makes isDisposed() true.
    if (disposalList) {
      // Go backwards through the disposal list, and dispose of everything.
      for (var i = disposalList.length - 1; i >= 0; i--) {
        var entry = disposalList[i];
        disposeHelper(this, entry.disposer, entry.obj);
      }
    }

    // Call stopListening if it exists. This is a convenience when using Backbone.Events. It's
    // equivalent to calling this.autoDisposeCallback(this.stopListening) in constructor.
    if (typeof this.stopListening === 'function') {
      // Wrap in disposeHelper so that errors get caught.
      disposeHelper(this, callFuncHelper, this.stopListening);
    }

    // Finish by wiping out the object, since nothing should use it after dispose().
    // See https://phab.getgrist.com/w/disposal/ for more motivation.
    wipeOutObject(this);
  }
});
exports.Disposable = Disposable;


/**
 * The recommended way to make an object disposable. It simply adds the methods of `Disposable` to
 * its prototype, and also adds a `Class.create()` function, for a safer way to construct objects
 * (see `safeCreate` for explanation). For instance,
 *    function Foo(args...) {...}
 *    dispose.makeDisposable(Foo);
 * Now you can create Foo objects with:
 *    var foo = Foo.create(args...);
 * And dispose of them with:
 *    foo.dispose();
 */
function makeDisposable(Constructor) {
  Object.assign(Constructor.prototype, Disposable.prototype);
  Constructor.create = safeConstructor;
}
exports.makeDisposable = makeDisposable;


/**
 * Helper to create and construct an object safely: `safeCreate(Foo, ...)` is similar to `new
 * Foo(...)`. The difference is that in case of an exception in the constructor, the dispose()
 * method will be called on the partially constructed object.
 * If you call makeDisposable(Foo), then Foo.create(...) is equivalent and more convenient.
 * @returns {Object} the newly constructed object.
 */
function safeCreate(Constructor, varArgs) {
  return safeConstructor.apply(Constructor, Array.prototype.slice.call(arguments, 1));
}
exports.safeCreate = safeCreate;


/**
 * Helper used by makeDisposable() for the `create` property of a disposable class. E.g. when
 * assigned to Foo.create, the call `Foo.create(args)` becomes similar to `new Foo(args)`, but
 * calls dispose() in case the constructor throws an exception.
 */
var safeConstructor = function(varArgs) {
  var Constructor = this;
  var obj = Object.create(Constructor.prototype);
  return safelyConstruct(Constructor, obj, arguments);
};

var safelyConstruct = function(Constructor, obj, args) {
  try {
    Constructor.apply(obj, args);
    return obj;
  } catch (e) {
    // Be a bit more helpful and concise in reporting errors: print error as an object (that
    // includes its stacktrace in FF and Chrome), and avoid printing it multiple times as it
    // bubbles up through the stack of safeConstructor calls.
    if (!e.printed) {
      let name = obj.constructor.name || Constructor.name;
      console.error("Error constructing %s:", name, e);
      // assigning printed to a string throws: TypeError: Cannot create property 'printed' on [...]
      if (_.isObject(e)) {
        e.printed = true;
      }
    }
    obj.dispose();
    throw e;
  }
};

// It doesn't matter what the value is, but some values cause more helpful errors than others.
// E.g. if x = "disposed", then x.foo() throws "undefined is not a function", while when x = null,
// x.foo() throws "Cannot read property 'foo' of null", which seems more helpful.
var WIPED_VALUE = null;


/**
 * Wipe out the given object by setting each property to a dummy value. This is helpful for
 * objects that are disposed and should be ready to be garbage-collected. The goals are:
 * - If anything still refers to the object and uses it, we'll get an early error, rather than
 *   silently keep going, potentially doing useless work (or worse) and wasting resources.
 * - If anything still refers to the object but doesn't use it, the fields of the object can
 *   still be garbage-collected.
 * - If there are circular references between the object and its properties, they get broken,
 *   making the job easier for the garbage collector.
 */
function wipeOutObject(obj) {
  for (var k in obj) {
    if (obj.hasOwnProperty(k)) {
      obj[k] = WIPED_VALUE;
    }
  }
}

/**
 * Internal helper used by disposeDiscard() and disposeRelease(). It finds, removes, and returns
 * an entry from the given disposalList.
 */
function removeObjectToDispose(disposalList, obj) {
  if (disposalList) {
    for (var i = 0; i < disposalList.length; i++) {
      if (disposalList[i].obj === obj) {
        var entry = disposalList[i];
        disposalList.splice(i, 1);
        return entry;
      }
    }
  }
  return null;
}

/**
 * Internal helper to allow adding cleanup callbacks to the disposalList. It acts as the
 * "disposer" for callback, by simply calling them with the same context that it is called with.
 */
var callFuncHelper = function(callback) {
  callback.call(this);
};

/**
 * Internal helper to dispose objects that need a differently-named method to be called on them.
 * It's used by `autoDisposeWith` when the disposer is a string method name.
 */
function methodDisposer(methodName) {
  return function(obj) {
    obj[methodName]();
  };
}

/**
 * Internal helper to call a disposer on an object. It swallows errors (but reports them) to make
 * sure that when we dispose of an object, an error in disposing of one owned part doesn't stop
 * the disposal of the other parts.
 */
function disposeHelper(owner, disposer, obj) {
  try {
    disposer.call(owner, obj);
  } catch (e) {
    console.error("While disposing %s, error disposing %s: %s",
      describe(owner), describe(obj), e);
  }
}

/**
 * Helper for reporting errors during disposal. Try to report the type of the object.
 */
function describe(obj) {
  return (obj && obj.constructor && obj.constructor.name ? obj.constructor.name :
    util.inspect(obj, {depth: 1}));
}

/**
 * Internal helper that implements the default disposal for an object. It just supports removing
 * DOM nodes with ko.removeNode, and calling dispose() on any part that has a `dispose` method.
 */
function defaultDisposer(obj) {
  if (obj instanceof G.Node) {
    // This does both knockout- and jquery-related cleaning, and removes the node from the DOM.
    ko.removeNode(obj);
  } else if (typeof obj.dispose === 'function') {
    obj.dispose();
  } else {
    throw new Error("Object has no 'dispose' method");
  }
}

/**
 * Removes all children of the given node, and all knockout bindings. You can use it as
 *    this.autoDisposeWith(dispose.emptyNode, node);
 */
function emptyNode(node) {
  ko.virtualElements.emptyNode(node);
  ko.cleanNode(node);
}

exports.emptyNode = emptyNode;
