const _ = require('lodash');
const Promise = require('bluebird');

module.exports = {
  options: { alias: 'lock' },
  async init(self) {
    await self.ensureCollection();
  },
  methods(self) {
    return {

      // Obtain a lock with the given name. The lock remains exclusive until
      // we unlock it (except for certain situations in unusual synchronous
      // code, see below).
      //
      // We MUST release the lock later by calling `unlock` with the same name.
      //
      // If the lock is in use by another party, this method will wait until it
      // is no longer in use, unless `options.wait` is present. If
      // `options.wait` is explicitly `false`, the method will not wait at all,
      // and the error reported will be the string `'locked'`. If `options.wait`
      // is a number, the method will wait that many milliseconds before
      // reporting the `locked` error.
      //
      // The `options` argument can be omitted completely.
      //
      // Calling this method when you already have the specified lock will
      // wait for the previous lock to be released, unless the `waitForSelf`
      // option is explicitly `false`, in which case an error is thrown.
      //
      // SYNCHRONOUS CODE: if you need to go more than 30 seconds without ever
      // returning to the event loop, set `options.idleTimeout` to a longer
      // period of time (in milliseconds). This applies only to synchronous
      // code. (And seriously, why  are you running without returning for 5
      // minutes in nodejs? Nobody can see your site while you do that.)

      async lock(name, options) {

        let lock = null;
        let when = null;

        // Implementation notes: since `_id` must be unique, we know
        // we have the lock if we succeed in inserting a mongodb doc with
        // an _id equal to the lock name. If we fail due to a duplicate key,
        // we just keep trying, with exponential backoff but no less than
        // every 100ms.
        //
        // A crashed process should not be allowed to camp on a lock forever,
        // so we also poll each time to see if the existing lock's `when` stamp
        // is older than the `idleTimeout`.

        let retryDelay = 10;
        const start = Date.now();

        options = options || {};
        const idleTimeout = options.idleTimeout || 30 * 1000;
        let wait = Number.MAX_VALUE;
        if (_.isNumber(options.wait)) {
          wait = options.wait;
        }
        if (options.wait === false) {
          wait = 0;
        }

        self.intervals = self.intervals || {};
        if (self.intervals[name]) {
          if (options.waitForSelf !== false) {
            return retry();
          } else {
            throw new Error('Attempted to lock ' + name + ' which we have already locked.');
          }
        }

        return attempt();

        async function attempt() {
          when = Date.now();
          try {
            await fetch();
            await timeout();
            await insert();
            self.intervals[name] = setInterval(refresh, Math.min(idleTimeout / 4, 1000));
          } catch (err) {
            // Only duplicate keys should be retried
            if (err.code !== 11000) {
              throw err;
            }
            return retry();
          }
        }

        // We don't trust this for concurrency because it's not atomic.
        // We just use it to remove old locks if needed
        async function fetch() {
          lock = await self.db.findOne({ _id: name });
        }

        async function timeout() {
          if (!lock) {
            return;
          }
          if (lock.when + lock.idleTimeout >= when) {
            return;
          }
          await self.db.deleteMany({
            _id: name,
            unique: lock.unique
          });
        }

        async function insert() {
          await self.db.insertOne({
            _id: name,
            when,
            idleTimeout,
            unique: self.apos.util.generateId()
          });
        }

        async function retry() {
          if (start + wait < Date.now()) {
            throw self.apos.error('locked');
          }
          await Promise.delay(retryDelay);
          // Exponential backoff, but only to a reasonable limit
          retryDelay *= 2;
          if (retryDelay > 100) {
            retryDelay = 100;
          }
          return attempt();
        }

        async function refresh() {
          // For unit testing purposes we can test what happens when
          // idleTimeout is short and there is no auto-refresh happening
          if (options.noRefresh) {
            return;
          }
          if (!self.intervals[name]) {
            return;
          }
          try {
            await self.db.updateOne({ _id: name }, { $set: { when: Date.now() } });
          } catch (err) {
            // Not much more we can do with this error as the
            // lock method has returned and the operation is underway
            self.apos.util.error(err);
          }
        }
      },

      // Release the given lock name. You must first obtain a lock successfully
      // via `lock`. Calling this method when you do not already have the lock
      // will yield an error.

      async unlock(name) {

        self.intervals = self.intervals || {};
        if (!self.intervals[name]) {
          throw new Error('Attempted to unlock ' + name + ' which is not locked');
        }
        clearInterval(self.intervals[name]);
        delete self.intervals[name];
        await self.db.deleteMany({ _id: name });
      },

      // Obtains the named lock, awaits the provided function,
      // and then releases the lock whether the function throws
      // an error or not.
      //
      // You can think of this as an "upgrade" of your function to
      // run within a lock in every way.
      //
      // The return value of `withLock` will be the value that
      // `fn` returns. If `fn` throws an error it will be
      // re-thrown after the lock is safely released.

      async withLock(name, fn) {

        let locked = false;

        try {
          await self.apos.lock.lock(name);
          locked = true;
          return await fn();
        } finally {
          if (locked) {
            await self.apos.lock.unlock(name);
          }
        }
      },

      async ensureCollection() {
        self.db = await self.apos.db.collection('aposLocks');
      }
    };
  }
};
